Files
hlamingo/hlamingo/app.py
T
alexandClaude Opus 5 4f298ac203 Настройка из браузера: вкладка ⚙ вместо правки файла
Инсталлятор спрашивает адрес Ollama в консоли, но это можно пропустить
и задать всё с телефона: адрес, модель для разбора фраз, модель для поиска
по смыслу. «Проверить связь» показывает, отвечает ли Ollama и сколько там
моделей; сами модели выбираются из реально установленных, а не вводятся
руками. Если Ollama молчит, предупреждение наверху ведёт прямо в настройки.

Настройки применяются сразу: модули читают их через config.X, а не забирают
копию при импорте, поэтому перезапуск не нужен.

Из веба правятся только OLLAMA_HOST и две модели — по белому списку, потому
что .env читает приложение при запуске. Адрес проверяется на формат, имена
моделей — на недопустимые символы.

Попутно исправлены два дефекта, найденных тестами:
- .env в песочнице теперь тоже свой; до этого прогон тестов перезаписывал
  рабочий файл настроек;
- значения проверяются все до применения: раньше неверное имя модели
  оставляло уже подменённый адрес Ollama.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:40:15 +03:00

697 lines
29 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("/")
alive = ollama.check()
models = ollama.models() if alive else []
finally:
config.OLLAMA_HOST = was # проба не должна менять настройки
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": "Менять нечего."})
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/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/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()