Разбор свободных фраз, теги и подсказку по фотографии теперь может делать не своя модель, а веб-версия DeepSeek. Своего клиента DeepSeek здесь нет и не нужно: рядом живёт отдельный сервис deepseek-api, который держит вкладку браузера и отдаёт наружу протокол Ollama. Для hlamingo вся разница сводится к тому, по какому адресу спрашивать. HLAMINGO_ENGINE=ollama|deepseek HLAMINGO_DEEPSEEK_HOST=http://localhost:11500 Эмбеддинги в выбор не входят и всегда идут на OLLAMA_HOST: веб-чат векторов не отдаёт, а подделать их нечем. Поэтому адрес Ollama нужен и в режиме DeepSeek, иначе молча умирает поиск по смыслу. Предохранитель в ollama.py теперь считается по каждому адресу отдельно. Иначе лежачая Ollama гасила бы живой DeepSeek: неудача по одному адресу на полминуты объявляла мёртвым и второй. Проба адреса больше не подменяет config.OLLAMA_HOST на время проверки — адрес передаётся явным параметром. Запросы идут в несколько потоков, и подмена задевала чужие. В настройках (вкладка "шестерёнка") появился выбор "Кто думает", поле адреса DeepSeek и вторая кнопка проверки связи. Списка моделей теперь два: для разбора фраз — у выбранного движка, для поиска по смыслу — у Ollama. Замер на разборе фразы и генерации тегов: Ollama qwen3:8b 2-3 с, DeepSeek 8-12 с плюс 15 с на подъём браузера при первом запросе. Тесты: 115 успешно, 0 провалено. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
914 lines
40 KiB
Python
914 lines
40 KiB
Python
"""Flask-приложение: маршруты и точка входа."""
|
||
|
||
import uuid
|
||
import logging
|
||
import webbrowser
|
||
import threading
|
||
|
||
from datetime import datetime
|
||
|
||
from flask import Flask, request, jsonify, send_from_directory, send_file
|
||
|
||
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():
|
||
# "ollama" здесь значит «мозги отвечают», кем бы они ни были: адрес берётся
|
||
# у того движка, который выбран.
|
||
return jsonify({"ollama": ollama.check(), "host": config.chat_host(),
|
||
"engine": config.ENGINE,
|
||
"model": config.CHAT_MODEL,
|
||
# Пока ничего не записано и ИИ не отвечает — человек, скорее
|
||
# всего, только что поставил приложение: покажем настройки.
|
||
"fresh": not config.DB_FILE.exists()})
|
||
|
||
|
||
# ============================================================
|
||
# Настройки из браузера
|
||
#
|
||
# Чтобы после установки не возвращаться к консоли: движок, адреса и модели
|
||
# задаются с того же телефона, а модели выбираются из реально имеющихся.
|
||
# ============================================================
|
||
@app.route("/api/config")
|
||
def api_config():
|
||
# Списков моделей два: думать может DeepSeek, а векторы для поиска
|
||
# по смыслу в любом случае считает настоящая Ollama.
|
||
chat_models = ollama.models()
|
||
embed_models = (chat_models if config.ENGINE == "ollama"
|
||
else ollama.models(config.OLLAMA_HOST))
|
||
return jsonify({
|
||
"engine": config.ENGINE,
|
||
"ollama_host": config.OLLAMA_HOST,
|
||
"deepseek_host": config.DEEPSEEK_HOST,
|
||
"chat_model": config.CHAT_MODEL,
|
||
"embed_model": config.EMBED_MODEL,
|
||
"available": ollama.check(),
|
||
"embed_available": bool(embed_models),
|
||
"models": chat_models,
|
||
"embed_models": embed_models,
|
||
"env_file": str(config.ENV_FILE),
|
||
"backup": store.external_state(),
|
||
})
|
||
|
||
|
||
@app.route("/api/export")
|
||
def api_export():
|
||
"""Отдаёт архив со всем: базой и фотографиями."""
|
||
archive = store.export_archive()
|
||
name = "hlamingo-%s.zip" % datetime.now().strftime("%Y%m%d-%H%M")
|
||
return send_file(archive, mimetype="application/zip",
|
||
as_attachment=True, download_name=name)
|
||
|
||
|
||
@app.route("/api/import/check", methods=["POST"])
|
||
def api_import_check():
|
||
"""Что в архиве — до того, как что-либо менять.
|
||
|
||
Импорт заменяет данные целиком, поэтому человек должен видеть, что
|
||
он получит взамен: сколько вещей в архиве против того, что есть сейчас.
|
||
"""
|
||
upload = request.files.get("archive")
|
||
if not upload:
|
||
return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400
|
||
|
||
parsed, error = store.inspect_archive(upload.read())
|
||
if error:
|
||
return jsonify({"ok": False, "reply": error}), 400
|
||
|
||
db = store.load()
|
||
return jsonify({
|
||
"ok": True,
|
||
"archive": {"items": len(parsed["db"].get("items", {})),
|
||
"places": len(parsed["db"].get("places", {})),
|
||
"containers": len(parsed["db"].get("containers", {})),
|
||
"photos": len(parsed["photos"])},
|
||
"current": {"items": len(db["items"]), "places": len(db["places"]),
|
||
"containers": len(db["containers"])},
|
||
})
|
||
|
||
|
||
@app.route("/api/import", methods=["POST"])
|
||
def api_import():
|
||
"""Восстанавливает базу и фотографии из архива."""
|
||
upload = request.files.get("archive")
|
||
if not upload:
|
||
return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400
|
||
|
||
result, error = store.import_archive(upload.read())
|
||
if error:
|
||
return jsonify({"ok": False, "reply": error}), 400
|
||
|
||
# Векторы могли не приехать (архив со старой моделью эмбеддера) —
|
||
# досчитываем, иначе поиск по смыслу молча не работал бы.
|
||
db = store.load()
|
||
if search.ensure_embeddings(db):
|
||
store.save(db)
|
||
|
||
return jsonify({"ok": True, "result": result,
|
||
"reply": "Восстановлено: %d вещей, %d фото. "
|
||
"Прежнее состояние сохранено как %s."
|
||
% (result["items"], result["photos"], result["rollback"])})
|
||
|
||
|
||
@app.route("/api/backup/now", methods=["POST"])
|
||
def api_backup_now():
|
||
"""Скопировать во внешнюю папку прямо сейчас, не дожидаясь расписания."""
|
||
if not config.BACKUP_DIR:
|
||
return jsonify({"ok": False, "reply": "Путь для копий не задан."}), 400
|
||
ok = store.external_backup(force=True)
|
||
state = store.external_state()
|
||
if ok:
|
||
return jsonify({"ok": True, "state": state,
|
||
"reply": "Скопировано в %s" % config.BACKUP_DIR})
|
||
return jsonify({"ok": False, "state": state,
|
||
"reply": "Не удалось скопировать: %s"
|
||
% (state.get("error") or "путь недоступен")})
|
||
|
||
|
||
@app.route("/api/config/probe", methods=["POST"])
|
||
def api_config_probe():
|
||
"""Проверяет адрес, ничего не сохраняя: кто там и какие модели.
|
||
|
||
Адрес передаётся явным параметром, а не подменой настройки на время
|
||
проверки: запросы идут в несколько потоков, и подмена задевала бы чужие.
|
||
"""
|
||
data = request.get_json(silent=True) or {}
|
||
host = (data.get("host") or data.get("ollama_host") or "").strip().rstrip("/")
|
||
if not host:
|
||
return jsonify({"ok": False, "reply": "Адрес не указан."}), 400
|
||
|
||
ollama.reset_breaker(host) # спрашиваем заново, без памяти о прошлом
|
||
alive = ollama.check(host)
|
||
models = ollama.models(host) if alive else []
|
||
ollama.reset_breaker(host)
|
||
|
||
if not alive:
|
||
return jsonify({"ok": False, "models": [],
|
||
"reply": "По адресу %s никто не отвечает." % host})
|
||
return jsonify({"ok": True, "models": models,
|
||
"reply": "Отвечает, моделей: %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({
|
||
"HLAMINGO_ENGINE": data.get("engine"),
|
||
"OLLAMA_HOST": data.get("ollama_host"),
|
||
"HLAMINGO_DEEPSEEK_HOST": data.get("deepseek_host"),
|
||
"HLAMINGO_CHAT_MODEL": data.get("chat_model"),
|
||
"HLAMINGO_EMBED_MODEL": data.get("embed_model"),
|
||
"HLAMINGO_BACKUP_DIR": data.get("backup_dir"),
|
||
})
|
||
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()
|
||
who = "DeepSeek" if config.ENGINE == "deepseek" else "Ollama"
|
||
reply = "Сохранил в .env. " + ("%s отвечает." % who if alive else
|
||
"Но %s по адресу %s молчит." % (who, config.chat_host()))
|
||
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
|
||
|
||
# Если у вещи есть фотография — сначала читаем надписи с неё. На упаковке
|
||
# настоящая маркировка, и модели не приходится гадать по названию.
|
||
seen = None
|
||
if item.get("photo") and config.VISION_MODEL:
|
||
path = PHOTOS_DIR / item["photo"]
|
||
if path.exists():
|
||
guess = brain.describe_photo(path.read_bytes())
|
||
if guess and guess.get("text"):
|
||
seen = guess["text"]
|
||
|
||
# Вторая попытка не роскошь: после обращения к vision-модели в памяти
|
||
# Ollama лежит она, и первый запрос уходит на её выгрузку и загрузку
|
||
# текстовой — он может вернуться пустым.
|
||
fresh = brain.aliases(item.get("name"), item.get("note"), seen=seen)
|
||
if not fresh:
|
||
fresh = brain.aliases(item.get("name"), item.get("note"), seen=seen)
|
||
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)
|
||
reply = "Теги пересозданы: %s" % ", ".join(item["aliases"])
|
||
if seen:
|
||
reply += "\n(прочитано с фото: %s)" % seen
|
||
return jsonify({"ok": True, "item": _card(db, item), "seen": seen, "reply": reply})
|
||
|
||
|
||
@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
|
||
|
||
# Общий лимит запроса поднят ради архива восстановления, поэтому размер
|
||
# снимка проверяем здесь отдельно.
|
||
blob = upload.read()
|
||
if len(blob) > config.MAX_PHOTO_BYTES:
|
||
return jsonify({"ok": False, "reply": "Слишком большой снимок (%.1f МБ)."
|
||
% (len(blob) / 1e6)}), 400
|
||
|
||
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||
filename = uuid.uuid4().hex[:12] + ext
|
||
(PHOTOS_DIR / filename).write_bytes(blob)
|
||
|
||
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("Мозги: %s, %s — %s", config.ENGINE, config.chat_host(),
|
||
"отвечают" if alive else "НЕ отвечают")
|
||
if not alive:
|
||
logger.warning("Без них работает запись, обзор и поиск по названию. "
|
||
"Разбор фраз — упрощённый, теги не создаются.")
|
||
# Векторы для поиска по смыслу считает только настоящая Ollama: веб-чат
|
||
# DeepSeek их не отдаёт. При выборе DeepSeek это отдельный адрес и отдельная
|
||
# причина молчания поиска — о ней стоит сказать вслух.
|
||
if config.ENGINE == "deepseek" and not ollama.check(config.OLLAMA_HOST):
|
||
logger.warning("Ollama %s не отвечает — поиск по смыслу отключён.",
|
||
config.OLLAMA_HOST)
|
||
|
||
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()
|