Files
hlamingo/hlamingo/app.py
T
Alex CubeandClaude Opus 5 4d8175931b Выбор мозгов: Ollama или веб-версия DeepSeek
Разбор свободных фраз, теги и подсказку по фотографии теперь может делать
не своя модель, а веб-версия 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>
2026-08-26 12:48:30 +03:00

914 lines
40 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 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()