Files
hlamingo/hlamingo/app.py
T
alexandClaude Opus 5 bae5116d77 Подсказка названия по фотографии
Фото выбрано в форме — приложение показывает его модели и предлагает название
с прочитанными надписями. «Подставить» переносит в поля, «Не надо» прячет.
Само не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель назвала
«конструктором LEGO Technic, 8376, 401 шт.». Запрет на выдумывание в промпте
помог: та же вещь стала «красной щёткой для посуды».

Смотрит gemma3:4b. Выбрана замером на настоящих снимках из базы:
  gemma3:4b     3.5 с, отвечает всегда, надписи читает верно
                («Лампа светодиодная C35, Нейтральный белый, 9 Вт LED, E14»);
  qwen3-vl:8b   11-32 с и часто пустой ответ — это ризонер, ему нужен бюджет
                в 2000+ токенов, и уменьшение картинки не спасает.
Проверялась и гипотеза, что дело в размере снимка: у gemma3 разницы между
1280 и 512 px почти нет, у qwen3-vl время падает с 62 до 41 с, но ответ
всё равно пуст. Картинка всё же уменьшается до 768 px — незачем гонять лишнее.

Генерация тегов чинилась по ходу: на «Стабилизатор 7805 (TO-220)» модель
отвечала массивом объектов с пояснениями, не укладывалась в 256 токенов,
и JSON обрывался на полуслове — теги выходили пустыми. Бюджет поднят до 500,
промпт требует плоский массив строк, а из битого ответа строки теперь
вытаскиваются регуляркой: лучше несколько живых тегов, чем ничего.

Pillow добавлен в зависимости — для уменьшения картинки перед распознаванием.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:24:49 +03:00

795 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Flask-приложение: маршруты и точка входа."""
import uuid
import logging
import webbrowser
import threading
from flask import Flask, request, jsonify, send_from_directory
from . import store, search, brain, ollama, config
from .config import (PROJECT_DIR, TEMPLATES_DIR, PHOTOS_DIR, HOST, PORT,
MAX_UPLOAD_BYTES, ALLOWED_PHOTO_EXT)
logger = logging.getLogger(__name__)
app = Flask(__name__, static_folder=str(PROJECT_DIR / "static"), static_url_path="/static")
app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_BYTES
# ============================================================
# Страница
# ============================================================
@app.route("/")
def index():
return send_from_directory(str(TEMPLATES_DIR), "index.html")
@app.route("/manifest.webmanifest")
def manifest():
"""Манифест отдаём отдельным маршрутом — с правильным MIME-типом.
Из /static/ Flask отдал бы его как обычный файл, и браузер мог бы
отказаться считать его манифестом, а без манифеста Android не берёт
иконку для ярлыка на домашнем экране.
"""
return send_from_directory(str(PROJECT_DIR / "static"), "manifest.webmanifest",
mimetype="application/manifest+json")
@app.route("/favicon.ico")
def favicon():
"""Браузеры просят /favicon.ico сами, независимо от тегов в разметке."""
return send_from_directory(str(PROJECT_DIR / "static"), "favicon-32.png",
mimetype="image/png")
@app.route("/api/health")
def health():
return jsonify({"ollama": ollama.check(), "host": config.OLLAMA_HOST,
"model": config.CHAT_MODEL,
# Пока ничего не записано и ИИ не отвечает — человек, скорее
# всего, только что поставил приложение: покажем настройки.
"fresh": not config.DB_FILE.exists()})
# ============================================================
# Настройки из браузера
#
# Чтобы после установки не возвращаться к консоли: адрес Ollama и модели
# задаются с того же телефона, а модели выбираются из реально имеющихся.
# ============================================================
@app.route("/api/config")
def api_config():
return jsonify({
"ollama_host": config.OLLAMA_HOST,
"chat_model": config.CHAT_MODEL,
"embed_model": config.EMBED_MODEL,
"available": ollama.check(),
"models": ollama.models(),
"env_file": str(config.ENV_FILE),
})
@app.route("/api/config/probe", methods=["POST"])
def api_config_probe():
"""Проверяет адрес, ничего не сохраняя: что там за Ollama и какие модели."""
host = ((request.get_json(silent=True) or {}).get("ollama_host") or "").strip()
if not host:
return jsonify({"ok": False, "reply": "Адрес не указан."}), 400
was = config.OLLAMA_HOST
try:
config.OLLAMA_HOST = host.rstrip("/")
ollama.reset_breaker() # проверяем именно этот адрес, без памяти о прошлом
alive = ollama.check()
models = ollama.models() if alive else []
finally:
config.OLLAMA_HOST = was # проба не должна менять настройки
ollama.reset_breaker()
if not alive:
return jsonify({"ok": False, "models": [],
"reply": "По адресу %s Ollama не отвечает." % host})
return jsonify({"ok": True, "models": models,
"reply": "Ollama отвечает, моделей: %d." % len(models)})
@app.route("/api/config", methods=["POST"])
def api_config_save():
data = request.get_json(silent=True) or {}
try:
changed = config.save_settings({
"OLLAMA_HOST": data.get("ollama_host"),
"HLAMINGO_CHAT_MODEL": data.get("chat_model"),
"HLAMINGO_EMBED_MODEL": data.get("embed_model"),
})
except ValueError as e:
return jsonify({"ok": False, "reply": str(e)}), 400
if not changed:
return jsonify({"ok": True, "reply": "Менять нечего."})
# Адрес поменяли — прошлая неудача больше ничего не значит, иначе новый
# сервер полминуты считался бы мёртвым.
ollama.reset_breaker()
alive = ollama.check()
reply = "Сохранил в .env. " + ("Ollama отвечает." if alive else
"Но Ollama по этому адресу молчит.")
return jsonify({"ok": True, "reply": reply, "available": alive,
"changed": changed})
# ============================================================
# Главный маршрут: свободная фраза
# ============================================================
@app.route("/api/say", methods=["POST"])
def say():
text = ((request.get_json(silent=True) or {}).get("text") or "").strip()
if not text:
return jsonify({"reply": "Напиши, что положил или что ищешь."})
parsed = brain.parse(text)
db = store.load()
action = parsed.get("action")
if action == "put":
return jsonify(_do_put(db, parsed))
if action == "list_container":
return jsonify(_do_list_container(db, parsed.get("container")))
if action == "list_place":
return jsonify(_do_list_place(db, parsed.get("place_path")))
return jsonify(_do_find(db, parsed.get("item") or text))
def _do_put(db, parsed):
"""Положить по разобранной фразе: места из фразы создаём на лету."""
if not (parsed.get("items") or parsed.get("item")):
return {"reply": "Не понял, что именно ты положил. Напиши: «положил X в коробку A3»."}
place_id = store.ensure_place_path(db, parsed.get("place_path"))
return _put_all(db, parsed, place_id)
def _put_all(db, parsed, place_id):
"""Общая часть записи — одна для кнопок и для свободной фразы."""
names = parsed.get("items") or ([parsed["item"]] if parsed.get("item") else [])
if not names:
return {"ok": False, "reply": "Не понял, что записать."}
saved, moved = [], []
for name in names:
known = store.find_item_by_name(db, name)
# Синонимы просим только для новых вещей: у известной они уже есть,
# а лишний запрос к модели — это лишние секунды ожидания при записи.
new_aliases = [] if known else brain.aliases(name, parsed.get("note"))
item, prev = store.put_item(
db, name,
# Количество относится к одной вещи; на список «паспорт и права»
# его вешать нельзя — получится «2 шт» у каждого.
qty=parsed.get("qty") if len(names) == 1 else None,
container=parsed.get("container"),
place_id=place_id,
note=parsed.get("note"),
aliases=new_aliases,
)
if item:
saved.append(item)
if prev:
moved.append((item, prev))
if not saved:
return {"ok": False, "reply": "Не понял, что записать."}
search.ensure_embeddings(db)
store.save(db)
address = store.item_address(db, saved[0])
listed = ", ".join(%s»" % it["name"] for it in saved)
if moved and len(saved) == 1:
reply = "Переложил: %s теперь %s (было: %s)." % (listed, address, moved[0][1])
else:
reply = "Запомнил: %s%s." % (listed, address)
if len(saved) == 1 and saved[0].get("qty"):
reply += " Количество: %s." % saved[0]["qty"]
return {"ok": True, "reply": reply, "kind": "put", "item": _card(db, saved[0]),
"items": [_card(db, it) for it in saved],
"aliases": saved[0].get("aliases") or []}
def _do_find(db, query):
hits = search.search(db, query)
if not hits:
return {"reply": "Про «%s» ничего не записано." % query, "kind": "find", "items": []}
cards = []
for item, score, why in hits:
card = _card(db, item)
card["score"] = round(score, 3)
card["why"] = why
cards.append(card)
top = cards[0]
reply = %s» — %s." % (top["name"], top["address"])
if top.get("qty"):
reply += " Количество: %s." % top["qty"]
if len(cards) > 1:
reply += " Есть ещё похожее — смотри ниже."
return {"reply": reply, "kind": "find", "items": cards}
def _do_list_container(db, code):
key = store.norm_code(code)
if not key or key not in db["containers"]:
return {"reply": "Коробку «%s» не знаю." % (code or "?"), "kind": "list", "items": []}
items = store.container_contents(db, key)
address = store.container_address(db, key)
common = {"kind": "list", "code": key, "photo": db["containers"][key].get("photo")}
if not items:
return dict(common, reply="%s — пока пусто." % address, items=[])
return dict(common, reply="%s%d поз.:" % (address, len(items)),
items=[_card(db, it) for it in items])
def _do_list_place(db, path):
name = (path or [None])[-1]
place_id = store.find_place_by_name(db, name) if name else None
if not place_id:
return {"reply": "Место «%s» не знаю." % (name or "?"), "kind": "list", "items": []}
# Всё, что лежит в этом месте и в любом вложенном.
inside = _descendants(db, place_id)
boxes = [b["code"] for b in db["containers"].values() if b.get("place") in inside]
items = [it for it in db["items"].values()
if it.get("container") in boxes or it.get("place") in inside]
chain = " → ".join(store.place_chain(db, place_id))
if not items:
return {"reply": "%s — пока ничего не записано." % chain, "kind": "list", "items": []}
return {"reply": "%s%d поз.:" % (chain, len(items)), "kind": "list",
"items": [_card(db, it) for it in items]}
def _descendants(db, place_id):
"""Место и все вложенные в него места."""
result = {place_id}
changed = True
while changed:
changed = False
for pid, place in db["places"].items():
if place.get("parent") in result and pid not in result:
result.add(pid)
changed = True
return result
def _card(db, item):
return {
"id": item["id"],
"name": item.get("name"),
"qty": item.get("qty"),
"note": item.get("note"),
"photo": item.get("photo"),
"aliases": item.get("aliases") or [],
"container": item.get("container"),
"state": item.get("state") or "stored",
"address": store.item_address(db, item),
"history": (item.get("history") or [])[-5:],
}
# ============================================================
# Действия кнопками: место → ёмкость → предмет
#
# Здесь ИИ не участвует в главном: место и коробку человек выбирает сам,
# и ошибиться разбором фразы уже негде. Модель нужна только для синонимов.
# ============================================================
@app.route("/api/places")
def api_places():
db = store.load()
return jsonify({"places": store.all_places(db)})
@app.route("/api/places", methods=["POST"])
def api_add_place():
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"ok": False, "reply": "Название места пустое."}), 400
db = store.load()
parent = data.get("parent") or None
if parent and parent not in db["places"]:
return jsonify({"ok": False, "reply": "Родительское место не найдено."}), 404
chain = store.place_chain(db, parent) if parent else []
place_id = store.ensure_place_path(db, chain + [name])
store.save(db)
return jsonify({"ok": True, "id": place_id,
"path": " → ".join(store.place_chain(db, place_id))})
@app.route("/api/containers")
def api_containers():
db = store.load()
return jsonify({"containers": store.all_containers(db, request.args.get("place"))})
@app.route("/api/containers", methods=["POST"])
def api_add_container():
data = request.get_json(silent=True) or {}
code = store.norm_code(data.get("code"))
if not code:
return jsonify({"ok": False, "reply": "Код коробки пустой."}), 400
db = store.load()
store.ensure_container(db, code, kind=data.get("kind"), place_id=data.get("place") or None)
store.save(db)
return jsonify({"ok": True, "code": code, "address": store.container_address(db, code)})
@app.route("/api/put", methods=["POST"])
def api_put():
"""Положить: место и коробку выбрали кнопками, здесь только название вещи."""
data = request.get_json(silent=True) or {}
names = [n.strip() for n in (data.get("names") or [data.get("name") or ""]) if n.strip()]
if not names:
return jsonify({"ok": False, "reply": "Не указано, что кладём."}), 400
db = store.load()
place_id = data.get("place") or None
if place_id and place_id not in db["places"]:
return jsonify({"ok": False, "reply": "Место не найдено."}), 404
parsed = {"items": names, "qty": data.get("qty"), "container": data.get("container"),
"place_path": None, "note": data.get("note")}
result = _put_all(db, parsed, place_id)
return jsonify(result)
@app.route("/api/take", methods=["POST"])
def api_take():
"""Забрать вещь из коробки — теперь она на руках."""
data = request.get_json(silent=True) or {}
db = store.load()
item = _resolve(db, data)
if item is None:
return jsonify({"ok": False, "reply": "Не понял, какую вещь забрать."}), 404
if isinstance(item, list):
return jsonify({"ok": False, "reply": "Уточни, какую именно:", "items": item})
was = store.take_item(db, item)
store.save(db)
return jsonify({"ok": True, "kind": "take", "item": _card(db, item),
"reply": "Забрал «%s» (было: %s). Отметил, что на руках."
% (item["name"], was)})
@app.route("/api/move", methods=["POST"])
def api_move():
"""Переместить вещь в другое место или коробку."""
data = request.get_json(silent=True) or {}
db = store.load()
item = _resolve(db, data)
if item is None:
return jsonify({"ok": False, "reply": "Не понял, какую вещь перемещаем."}), 404
if isinstance(item, list):
return jsonify({"ok": False, "reply": "Уточни, какую именно:", "items": item})
place_id = data.get("place") or None
container = data.get("container")
if not place_id and not container:
return jsonify({"ok": False, "reply": "Не указано, куда перемещать."}), 400
moved, prev = store.put_item(db, item["name"], container=container, place_id=place_id)
store.save(db)
address = store.item_address(db, moved)
reply = ("Переместил «%s»: теперь %s (было: %s)." % (moved["name"], address, prev)
if prev else %s» теперь %s." % (moved["name"], address))
return jsonify({"ok": True, "kind": "move", "item": _card(db, moved), "reply": reply})
@app.route("/api/items")
def api_items():
"""Список вещей для выбора: все или подходящие под запрос."""
db = store.load()
query = (request.args.get("q") or "").strip()
if query:
items = [it for it, _, _ in search.search(db, query, top_k=20)]
else:
items = sorted(db["items"].values(), key=lambda i: (i.get("name") or "").lower())
return jsonify({"items": [_card(db, it) for it in items[:50]]})
def _resolve(db, data):
"""Находит вещь по id или по названию.
None — не нашли; список — нашли несколько и нужно уточнение;
иначе сам предмет.
"""
item_id = (data.get("id") or "").strip()
if item_id:
return db["items"].get(item_id)
name = (data.get("name") or "").strip()
if not name:
return None
exact = store.find_item_by_name(db, name)
if exact:
return exact
hits = search.search(db, name, top_k=5)
if not hits:
return None
if len(hits) == 1:
return hits[0][0]
return [_card(db, it) for it, _, _ in hits]
# ============================================================
# Просмотр
# ============================================================
@app.route("/api/find")
def api_find():
query = (request.args.get("q") or "").strip()
if not query:
return jsonify({"items": []})
db = store.load()
return jsonify(_do_find(db, query))
@app.route("/api/tree")
def api_tree():
return jsonify(store.tree(store.load()))
@app.route("/api/container/<code>")
def api_container(code):
return jsonify(_do_list_container(store.load(), code))
@app.route("/api/item/<item_id>", methods=["DELETE"])
def api_delete_item(item_id):
db = store.load()
item = db["items"].get(item_id)
if not item:
return jsonify({"ok": False, "reply": "Такой записи нет."}), 404
_drop_photo_file(store.detach_photo(item))
store.remove_item(db, item_id)
store.save(db)
return jsonify({"ok": True, "reply": "Удалил «%s»." % item.get("name")})
# ============================================================
# Правка. Каждая сущность — предмет, ёмкость, место — правится и удаляется.
# ============================================================
@app.route("/api/item/<item_id>", methods=["PATCH"])
def api_edit_item(item_id):
db = store.load()
item = db["items"].get(item_id)
if not item:
return jsonify({"ok": False, "reply": "Такой записи нет."}), 404
fields = request.get_json(silent=True) or {}
try:
changed = store.update_item(db, item, fields)
except ValueError as e:
return jsonify({"ok": False, "reply": str(e)}), 400
if not changed:
return jsonify({"ok": True, "item": _card(db, item), "reply": "Менять нечего."})
search.ensure_embeddings(db)
store.save(db)
return jsonify({"ok": True, "item": _card(db, item),
"reply": "Обновил: %s." % ", ".join(changed)})
@app.route("/api/item/<item_id>/retag", methods=["POST"])
def api_retag_item(item_id):
"""Пересобрать теги заново — например, после смены модели.
Теги придумывает модель, и с новой они выходят другими: старая давала
«электротool Makita», новая — «шуруповерт Макита». Ручной способ обновить
их, не перезаводя вещь.
"""
db = store.load()
item = db["items"].get(item_id)
if not item:
return jsonify({"ok": False, "reply": "Такой записи нет."}), 404
if not ollama.check():
return jsonify({"ok": False,
"reply": "Модель недоступна — теги придумывать некому."}), 503
# Вторая попытка не роскошь: если только что распознавали фото, в памяти
# Ollama лежит vision-модель, и первый запрос уходит на её выгрузку
# и загрузку текстовой — он может вернуться пустым.
fresh = brain.aliases(item.get("name"), item.get("note"))
if not fresh:
fresh = brain.aliases(item.get("name"), item.get("note"))
if not fresh:
return jsonify({"ok": False, "reply": "Модель не вернула ни одного тега."})
keep = request.args.get("keep") == "1"
if keep:
merged = list(item.get("aliases") or [])
for tag in fresh:
if not any(tag.lower() == existing.lower() for existing in merged):
merged.append(tag)
item["aliases"] = merged[:12]
else:
item["aliases"] = fresh
item["emb"] = None # словарь предмета изменился
search.ensure_embeddings(db)
store.save(db)
return jsonify({"ok": True, "item": _card(db, item),
"reply": "Теги пересозданы: %s" % ", ".join(item["aliases"])})
@app.route("/api/containers/<code>", methods=["PATCH"])
def api_edit_container(code):
db = store.load()
if store.norm_code(code) not in db["containers"]:
return jsonify({"ok": False, "reply": "Такой коробки нет."}), 404
try:
key, changed = store.update_container(db, code, request.get_json(silent=True) or {})
except ValueError as e:
return jsonify({"ok": False, "reply": str(e)}), 400
if not changed:
return jsonify({"ok": True, "code": key, "reply": "Менять нечего."})
store.save(db)
return jsonify({"ok": True, "code": key, "address": store.container_address(db, key),
"reply": "Обновил коробку %s: %s." % (key, ", ".join(changed))})
@app.route("/api/containers/<code>", methods=["DELETE"])
def api_delete_container(code):
db = store.load()
key = store.norm_code(code)
box = db["containers"].get(key)
if not box:
return jsonify({"ok": False, "reply": "Такой коробки нет."}), 404
_drop_photo_file(store.detach_photo(box))
_, moved = store.delete_container(db, key)
store.save(db)
reply = "Коробка %s удалена." % key
if moved:
where = " → ".join(store.place_chain(db, box.get("place"))) or "без места"
reply += " Вещи не потерялись (%d): теперь они просто %s%s." % (
len(moved), where, ", ".join(moved))
return jsonify({"ok": True, "reply": reply, "moved": moved})
@app.route("/api/places/<place_id>", methods=["PATCH"])
def api_edit_place(place_id):
db = store.load()
if place_id not in db["places"]:
return jsonify({"ok": False, "reply": "Такого места нет."}), 404
try:
changed = store.update_place(db, place_id, request.get_json(silent=True) or {})
except ValueError as e:
return jsonify({"ok": False, "reply": str(e)}), 400
if not changed:
return jsonify({"ok": True, "reply": "Менять нечего."})
store.save(db)
return jsonify({"ok": True, "path": " → ".join(store.place_chain(db, place_id)),
"reply": "Обновил место: %s." % ", ".join(changed)})
@app.route("/api/places/<place_id>", methods=["DELETE"])
def api_delete_place(place_id):
db = store.load()
place = db["places"].get(place_id)
if not place:
return jsonify({"ok": False, "reply": "Такого места нет."}), 404
# Непустое место не удаляем: перенести содержимое некуда, если это корень,
# а молча выбросить записи о вещах нельзя — в них весь смысл.
blockers = store.place_blockers(db, place_id)
if any(blockers.values()):
parts = []
if blockers["places"]:
parts.append("места: " + ", ".join(blockers["places"]))
if blockers["containers"]:
parts.append("коробки: " + ", ".join(blockers["containers"]))
if blockers["items"]:
parts.append("вещи: " + ", ".join(blockers["items"]))
return jsonify({"ok": False, "blockers": blockers,
"reply": "Сначала разбери, что внутри «%s» — %s."
% (place.get("name"), "; ".join(parts))}), 409
store.delete_place(db, place_id)
store.save(db)
return jsonify({"ok": True, "reply": "Место «%s» удалено." % place.get("name")})
# ============================================================
# Удаление фото
# ============================================================
def _drop_photo_file(filename):
"""Убирает файл снимка с диска, чтобы папка не копила мусор."""
if not filename:
return
path = PHOTOS_DIR / filename
try:
if path.exists():
path.unlink()
except Exception as e:
logger.warning("Не удалось удалить фото %s: %s", filename, e)
@app.route("/api/recognize", methods=["POST"])
def api_recognize():
"""Что на снимке — подсказка для поля «Предмет».
Ничего не сохраняет: возвращает предложение, а решает человек. Модели
домысливают бренды и артикулы, когда надписей не видно, поэтому запись
вслепую сделала бы базу хуже, а не лучше.
"""
upload = request.files.get("photo")
if not upload or not upload.filename:
return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400
if not config.VISION_MODEL:
return jsonify({"ok": False, "reply": "Распознавание выключено."}), 503
if not ollama.check():
return jsonify({"ok": False, "reply": "Модель недоступна."}), 503
guess = brain.describe_photo(upload.read())
if not guess:
return jsonify({"ok": False, "reply": "Не смог разобрать, что на фото."})
reply = "Похоже на «%s»" % guess["name"]
if guess.get("text"):
reply += ". Надписи: %s" % guess["text"]
if not guess.get("sure"):
reply += " (не уверен — проверь)"
return jsonify({"ok": True, "guess": guess, "reply": reply})
@app.route("/api/item/<item_id>/recognize", methods=["POST"])
def api_recognize_saved(item_id):
"""То же, но для фотографии, которая уже прикреплена к вещи."""
db = store.load()
item = db["items"].get(item_id)
if not item or not item.get("photo"):
return jsonify({"ok": False, "reply": "У этой записи нет фото."}), 404
if not config.VISION_MODEL or not ollama.check():
return jsonify({"ok": False, "reply": "Модель недоступна."}), 503
path = PHOTOS_DIR / item["photo"]
if not path.exists():
return jsonify({"ok": False, "reply": "Файл фотографии не найден."}), 404
guess = brain.describe_photo(path.read_bytes())
if not guess:
return jsonify({"ok": False, "reply": "Не смог разобрать, что на фото."})
return jsonify({"ok": True, "guess": guess,
"reply": "На фото похоже на «%s»%s" % (
guess["name"],
(". Надписи: " + guess["text"]) if guess.get("text") else "")})
@app.route("/api/photo", methods=["DELETE"])
def api_delete_photo():
data = request.get_json(silent=True) or {}
db = store.load()
target = data.get("target")
if target == "container":
entity = db["containers"].get(store.norm_code(data.get("code")))
elif target == "item":
entity = db["items"].get(data.get("id") or "")
else:
return jsonify({"ok": False, "reply": "Не указано, у чего убрать фото."}), 400
if not entity:
return jsonify({"ok": False, "reply": "Не найдено."}), 404
if not entity.get("photo"):
return jsonify({"ok": True, "reply": "Фото и так нет."})
_drop_photo_file(store.detach_photo(entity))
store.save(db)
return jsonify({"ok": True, "reply": "Фото убрал."})
# ============================================================
# Фото
# ============================================================
@app.route("/api/photo", methods=["POST"])
def api_photo():
upload = request.files.get("photo")
if not upload or not upload.filename:
return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400
# Имя файла придумываем сами: имя от клиента может содержать «../»
# и увести запись за пределы папки с фотографиями.
ext = ("." + upload.filename.rsplit(".", 1)[-1].lower()) if "." in upload.filename else ""
if ext not in ALLOWED_PHOTO_EXT:
return jsonify({"ok": False, "reply": "Такой формат не принимаю: %s" % (ext or "?")}), 400
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
filename = uuid.uuid4().hex[:12] + ext
upload.save(str(PHOTOS_DIR / filename))
db = store.load()
target = request.form.get("target")
if target == "container":
key = store.norm_code(request.form.get("code"))
if key not in db["containers"]:
return jsonify({"ok": False, "reply": "Коробка не найдена."}), 404
db["containers"][key]["photo"] = filename
elif target == "item":
item = db["items"].get(request.form.get("id") or "")
if not item:
return jsonify({"ok": False, "reply": "Предмет не найден."}), 404
item["photo"] = filename
else:
return jsonify({"ok": False, "reply": "Не указано, к чему крепить фото."}), 400
store.save(db)
return jsonify({"ok": True, "photo": filename, "reply": "Фото прикрепил."})
# ============================================================
# Запуск
# ============================================================
def local_ips():
"""Адреса этого компьютера в локальной сети — их вводят на телефоне.
Первым идёт тот, по которому машина реально выходит в сеть. Это важно:
на этом компьютере шесть адресов (WSL, VMware, VPN), и угадать среди них
нужный невозможно.
Определяем здесь, а не в start.cmd: парсинг ipconfig ломается о
локализованные названия полей, а батник вообще не может содержать
кириллицу без порчи кодировки.
"""
import socket
found = []
# UDP-сокет никуда не отправляет данных: connect лишь заставляет систему
# выбрать исходящий интерфейс — то есть основной адрес машины.
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
# Адрес произвольный и внешний: пакетов не уходит, важен лишь выбор
# маршрута. Внешний, чтобы не привязываться к чужой подсети.
probe.connect(("8.8.8.8", 9))
found.append(probe.getsockname()[0])
except Exception:
pass
finally:
probe.close()
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
address = info[4][0]
if not address.startswith(("127.", "169.254.")) and address not in found:
found.append(address)
except Exception as e:
logger.warning("Не удалось определить адреса: %s", e)
return found
def main():
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s")
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
alive = ollama.check()
logger.info("Ollama %s: %s", config.OLLAMA_HOST, "доступен" if alive else "НЕ отвечает")
if not alive:
logger.warning("Без Ollama работает запись, обзор и поиск по названию. "
"Разбор фраз — упрощённый, поиск по смыслу отключён.")
addresses = local_ips()
if addresses:
logger.info("С ТЕЛЕФОНА: http://%s:%d", addresses[0], PORT)
if len(addresses) > 1:
logger.info("Если не открылось, другие адреса этого ПК: %s",
", ".join("http://%s:%d" % (a, PORT) for a in addresses[1:]))
logger.info("Здесь же: http://localhost:%d", PORT)
threading.Timer(1.0, lambda: webbrowser.open("http://localhost:%d" % PORT)).start()
app.run(host=HOST, port=PORT, debug=False, threaded=True)
if __name__ == "__main__":
main()