hlamingo — помощник «где что лежит»
Веб-помощник для домашнего склада: помнит, в какой коробке и в каком месте лежит вещь, и находит её по названию, тегам и маркировке. Открывается с телефона, интеллект — локальная Ollama. Основное: - структурный ввод «место → ёмкость → предмет» и кнопки Положить / Забрать / Переместить / Удалить; - иерархия мест (Гараж → Стеллаж А → Полка 2), ёмкости с кодом «буква+цифра», состояние «на руках» для взятых вещей; - поиск по словам с учётом русских окончаний, теги от модели расширяют словарь предмета, смысловой поиск — вспомогательный (замеры в search.py); - фото к вещам и коробкам: съёмка и выбор из галереи; - правка и удаление всех сущностей, удаление коробки не теряет её содержимое; - работает и без Ollama: отключаются только разбор фраз, теги и поиск по смыслу. Данные (data/, static/photos/) и локальный конфиг (.env) в репозиторий не попадают. Тесты работают только в песочнице: путь к данным берётся из окружения, tests/guard.py отказывается запускаться на живых данных. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Защита живых данных. Импортировать ПЕРВЫМ в любом тесте.
|
||||
|
||||
История: тест начинался со `shutil.rmtree(DATA_DIR)`, DATA_DIR указывал на
|
||||
рабочую папку, и один прогон уничтожил реальную базу с вещами. Восстановить
|
||||
её не удалось. Поэтому теперь тест обязан работать в песочнице, а проверка
|
||||
этого — не пожелание, а условие запуска.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import atexit
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sandbox():
|
||||
"""Переводит приложение на временную папку и возвращает её.
|
||||
|
||||
Вызывать ДО импорта hlamingo.* — переменная окружения читается в config
|
||||
на этапе импорта. Если живые данные всё же оказались подключены, тест
|
||||
падает, а не удаляет их.
|
||||
"""
|
||||
root = Path(tempfile.mkdtemp(prefix="hlamingo-test-"))
|
||||
os.environ["HLAMINGO_DATA_DIR"] = str(root)
|
||||
|
||||
for name in list(sys.modules):
|
||||
if name == "hlamingo" or name.startswith("hlamingo."):
|
||||
del sys.modules[name] # чтобы config перечитал окружение
|
||||
|
||||
from hlamingo import config
|
||||
if config.IS_LIVE_DATA:
|
||||
raise SystemExit("ОТКАЗ: приложение смотрит на живые данные. Тест не запускается.")
|
||||
if root not in config.DATA_DIR.parents and config.DATA_DIR != root / "data":
|
||||
raise SystemExit("ОТКАЗ: база вне песочницы (%s)." % config.DATA_DIR)
|
||||
|
||||
atexit.register(lambda: shutil.rmtree(str(root), ignore_errors=True))
|
||||
return root
|
||||
|
||||
|
||||
class Report(object):
|
||||
"""Счётчик проверок с человеческим выводом."""
|
||||
|
||||
def __init__(self):
|
||||
self.ok = 0
|
||||
self.fail = 0
|
||||
|
||||
def check(self, label, cond, extra=""):
|
||||
if cond:
|
||||
self.ok += 1
|
||||
print(" OK %s" % label)
|
||||
else:
|
||||
self.fail += 1
|
||||
print(" FAIL %s %s" % (label, extra))
|
||||
|
||||
def finish(self):
|
||||
print("\n%s успешно: %d, провалено: %d\n" % ("=" * 40, self.ok, self.fail))
|
||||
return 1 if self.fail else 0
|
||||
@@ -0,0 +1,22 @@
|
||||
@echo off
|
||||
rem ASCII only - see note in start.cmd.
|
||||
rem HLAMINGO_DATA_DIR points the app at a throwaway folder. This is the outer
|
||||
rem safety belt: tests must never touch the real data\db.json.
|
||||
chcp 65001 >nul
|
||||
set PYTHONIOENCODING=utf-8
|
||||
cd /d "%~dp0.."
|
||||
|
||||
if not exist "venv\Scripts\python.exe" (
|
||||
echo [ERROR] venv not found. Run setup.cmd first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set HLAMINGO_DATA_DIR=%TEMP%\hlamingo-tests
|
||||
venv\Scripts\python.exe tests\test_all.py
|
||||
set CODE=%ERRORLEVEL%
|
||||
set HLAMINGO_DATA_DIR=
|
||||
|
||||
if %CODE%==0 (echo All checks passed.) else (echo FAILURES: %CODE%)
|
||||
pause
|
||||
exit /b %CODE%
|
||||
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Проверки hlamingo. Работают ТОЛЬКО в песочнице — см. tests/guard.py.
|
||||
|
||||
Запуск: tests\run.cmd (или venv\Scripts\python.exe tests\test_all.py)
|
||||
Часть проверок требует Ollama; без него они пропускаются, а не падают.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from guard import sandbox, Report # noqa: E402
|
||||
SANDBOX = sandbox() # обязательно ДО импорта hlamingo
|
||||
|
||||
from hlamingo import store, search, brain, ollama, config # noqa: E402
|
||||
from hlamingo.app import app # noqa: E402
|
||||
|
||||
if sys.version_info >= (3, 7):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
r = Report()
|
||||
c = app.test_client()
|
||||
HAS_AI = ollama.check()
|
||||
|
||||
|
||||
def j(method, url, **body):
|
||||
return c.open(url, method=method, json=body or None).get_json()
|
||||
|
||||
|
||||
def png():
|
||||
def ch(t, d):
|
||||
raw = t + d
|
||||
return struct.pack(">I", len(d)) + raw + struct.pack(">I", zlib.crc32(raw) & 0xffffffff)
|
||||
return (b"\x89PNG\r\n\x1a\n" + ch(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0))
|
||||
+ ch(b"IDAT", zlib.compress(b"\x00\x00\x00\x00")) + ch(b"IEND", b""))
|
||||
|
||||
|
||||
def upload(target, ident):
|
||||
field = "id" if target == "item" else "code"
|
||||
return c.post("/api/photo", content_type="multipart/form-data",
|
||||
data={"target": target, field: ident,
|
||||
"photo": (io.BytesIO(png()), "x.png")}).get_json()
|
||||
|
||||
|
||||
print("Песочница: %s" % SANDBOX)
|
||||
print("Ollama: %s\n" % ("доступен" if HAS_AI else "нет — часть проверок пропущу"))
|
||||
|
||||
# ============================================================
|
||||
print("== Места: иерархия ==")
|
||||
garage = j("POST", "/api/places", name="Гараж")["id"]
|
||||
rack = j("POST", "/api/places", name="Стеллаж А", parent=garage)["id"]
|
||||
shelf = j("POST", "/api/places", name="Полка 2", parent=rack)["id"]
|
||||
pantry = j("POST", "/api/places", name="Кладовка")["id"]
|
||||
d = c.get("/api/places").get_json()
|
||||
r.check("путь собирается", any(p["path"] == "Гараж → Стеллаж А → Полка 2" for p in d["places"]), d)
|
||||
r.check("повтор не плодит дубли",
|
||||
j("POST", "/api/places", name="Стеллаж А", parent=garage)["id"] == rack)
|
||||
r.check("чужой родитель — 404",
|
||||
c.post("/api/places", json={"name": "X", "parent": "нет"}).status_code == 404)
|
||||
|
||||
print("\n== Ёмкости ==")
|
||||
d = j("POST", "/api/containers", code="a3", place=shelf)
|
||||
r.check("код к верхнему регистру", d["code"] == "A3", d)
|
||||
r.check("адрес коробки", d["address"] == "Гараж → Стеллаж А → Полка 2 → коробка A3", d)
|
||||
r.check("кириллическая А3 = латинская A3", store.norm_code("А3") == "A3")
|
||||
|
||||
print("\n== Положить ==")
|
||||
d = j("POST", "/api/put", name="Стабилизатор 7805", qty="12 шт", place=shelf, container="A3")
|
||||
item = d["item"]["id"]
|
||||
r.check("записалось", d.get("ok"), d)
|
||||
r.check("адрес", d["item"]["address"].endswith("коробка A3"), d["item"]["address"])
|
||||
if HAS_AI:
|
||||
r.check("синонимы от модели", len(d["item"]["aliases"]) > 0, d["item"]["aliases"])
|
||||
d = j("POST", "/api/put", names=["отвёртка", "плоскогубцы"], place=shelf, container="A3")
|
||||
r.check("две вещи разом", len(d["items"]) == 2, d)
|
||||
d = j("POST", "/api/put", name="лопата", place=garage)
|
||||
r.check("вещь без коробки", d["item"]["address"] == "Гараж", d["item"]["address"])
|
||||
|
||||
print("\n== Поиск ==")
|
||||
r.check("по названию", c.get("/api/find?q=7805").get_json()["items"][0]["id"] == item)
|
||||
r.check("по числу словоформ", c.get("/api/find?q=отвёртки").get_json()["items"], "отвёртки")
|
||||
r.check("чужого не находит", c.get("/api/find?q=картошка").get_json()["items"] == [])
|
||||
if HAS_AI:
|
||||
hits = c.get("/api/find?q=стабилизатор на 5 вольт").get_json()["items"]
|
||||
r.check("по смыслу через синонимы", hits and hits[0]["id"] == item, hits)
|
||||
|
||||
print("\n== Забрать и переместить ==")
|
||||
d = j("POST", "/api/take", id=item)
|
||||
r.check("на руках", d["item"]["state"] == "taken", d["item"])
|
||||
r.check("из коробки исчез",
|
||||
all(i["id"] != item for i in c.get("/api/container/A3").get_json()["items"]))
|
||||
r.check("но находится", "на руках" in c.get("/api/find?q=7805").get_json()["items"][0]["address"])
|
||||
d = j("POST", "/api/move", id=item, container="A3", place=shelf)
|
||||
r.check("вернулся в коробку", d["item"]["state"] == "stored", d["item"])
|
||||
r.check("без назначения — 400",
|
||||
c.post("/api/move", json={"id": item}).status_code == 400)
|
||||
|
||||
print("\n== Правка предмета ==")
|
||||
d = j("PATCH", "/api/item/" + item, name="Стабилизатор 7805 (TO-220)", qty="10 шт", note="в ленте")
|
||||
r.check("поля обновились",
|
||||
d["item"]["name"].endswith("(TO-220)") and d["item"]["qty"] == "10 шт", d["item"])
|
||||
d = j("PATCH", "/api/item/" + item, aliases=["7805", "LM7805"])
|
||||
r.check("теги заменяются", d["item"]["aliases"] == ["7805", "LM7805"], d["item"]["aliases"])
|
||||
d = j("PATCH", "/api/item/" + item, aliases="строкой, через, запятые")
|
||||
r.check("теги строкой", len(d["item"]["aliases"]) == 3, d["item"]["aliases"])
|
||||
r.check("пустое имя — 400",
|
||||
c.open("/api/item/" + item, method="PATCH", json={"name": " "}).status_code == 400)
|
||||
r.check("занятое имя — 400",
|
||||
c.open("/api/item/" + item, method="PATCH", json={"name": "лопата"}).status_code == 400)
|
||||
|
||||
print("\n== Фото ==")
|
||||
d = upload("item", item)
|
||||
fname = d["photo"]
|
||||
r.check("файл на диске", (config.PHOTOS_DIR / fname).exists(), config.PHOTOS_DIR)
|
||||
r.check("имя своё, не клиентское", "x" != fname[0], fname)
|
||||
r.check("видно в карточке", c.get("/api/find?q=7805").get_json()["items"][0]["photo"] == fname)
|
||||
d = j("DELETE", "/api/photo", target="item", id=item)
|
||||
r.check("фото убрано", d.get("ok"), d)
|
||||
r.check("файл удалён", not (config.PHOTOS_DIR / fname).exists())
|
||||
r.check("повторно — не ошибка", j("DELETE", "/api/photo", target="item", id=item).get("ok"))
|
||||
d = upload("container", "A3")
|
||||
r.check("фото коробки", c.get("/api/container/A3").get_json()["photo"] == d["photo"], d)
|
||||
r.check("битый формат — 400",
|
||||
c.post("/api/photo", content_type="multipart/form-data",
|
||||
data={"target": "item", "id": item,
|
||||
"photo": (io.BytesIO(b"MZ"), "../evil.exe")}).status_code == 400)
|
||||
|
||||
print("\n== Правка ёмкости ==")
|
||||
d = j("PATCH", "/api/containers/A3", kind="ящик", place=pantry, note="синий")
|
||||
r.check("тип и место", d["address"] == "Кладовка → ящик A3", d)
|
||||
d = j("PATCH", "/api/containers/A3", code="C5")
|
||||
r.check("код изменён", d["code"] == "C5", d)
|
||||
r.check("вещи переехали с кодом", len(c.get("/api/container/C5").get_json()["items"]) == 3,
|
||||
c.get("/api/container/C5").get_json())
|
||||
r.check("старого кода нет", c.get("/api/container/A3").get_json()["items"] == [])
|
||||
j("POST", "/api/containers", code="D1", place=garage)
|
||||
r.check("занятый код — 400",
|
||||
c.open("/api/containers/C5", method="PATCH", json={"code": "D1"}).status_code == 400)
|
||||
|
||||
print("\n== Удаление ёмкости не теряет вещи ==")
|
||||
d = j("DELETE", "/api/containers/C5")
|
||||
r.check("вещи переехали в её место", len(d["moved"]) == 3, d)
|
||||
r.check("вещь теперь в месте коробки",
|
||||
c.get("/api/find?q=7805").get_json()["items"][0]["address"] == "Кладовка")
|
||||
|
||||
print("\n== Правка места ==")
|
||||
r.check("переименование", j("PATCH", "/api/places/" + shelf, name="Полка 5")["path"]
|
||||
== "Гараж → Стеллаж А → Полка 5")
|
||||
r.check("сам в себя — 400",
|
||||
c.open("/api/places/" + garage, method="PATCH",
|
||||
json={"parent": garage}).status_code == 400)
|
||||
r.check("внутрь потомка — 400",
|
||||
c.open("/api/places/" + garage, method="PATCH",
|
||||
json={"parent": shelf}).status_code == 400)
|
||||
r.check("непустое не удалить",
|
||||
c.open("/api/places/" + garage, method="DELETE").status_code == 409)
|
||||
empty = j("POST", "/api/places", name="Балкон")["id"]
|
||||
r.check("пустое удаляется", j("DELETE", "/api/places/" + empty).get("ok"))
|
||||
|
||||
print("\n== Удаление предмета ==")
|
||||
d = upload("item", item)
|
||||
fname = d["photo"]
|
||||
r.check("удалён", c.delete("/api/item/" + item).get_json().get("ok"))
|
||||
r.check("фото тоже убрано", not (config.PHOTOS_DIR / fname).exists())
|
||||
r.check("404 на несуществующем", c.delete("/api/item/нет").status_code == 404)
|
||||
|
||||
print("\n== Устойчивость ==")
|
||||
config.DB_FILE.write_text("{ битый json", encoding="utf-8")
|
||||
r.check("битая база не роняет", c.get("/api/tree").status_code == 200)
|
||||
r.check("пустой ввод не роняет", "reply" in j("POST", "/api/say", text=" "))
|
||||
|
||||
print("\n== Разбор фразы ==")
|
||||
p = brain.fallback_parse("положил изоленту в коробку B2 на полке 1 в гараже")
|
||||
r.check("регулярки: put", p["action"] == "put" and store.norm_code(p["container"]) == "B2", p)
|
||||
p = brain.fallback_parse("где мультиметр")
|
||||
r.check("регулярки: find", p["action"] == "find" and p["item"] == "мультиметр", p)
|
||||
p = brain.fallback_parse("переложил дрель из коробки A1 в коробку C2")
|
||||
r.check("«из» не попадает в название", "из" not in (p["item"] or ""), p["item"])
|
||||
if HAS_AI:
|
||||
p = brain.parse("положил стабилизаторы 7805, 12 штук, в коробку A3 на второй полке в гараже")
|
||||
r.check("модель: put + коробка", p["action"] == "put" and store.norm_code(p["container"]) == "A3", p)
|
||||
p = brain.parse("куда я положил зарядку от шуруповёрта")
|
||||
r.check("вопрос не пишется как факт", p["action"] == "find", p)
|
||||
p = brain.parse("переложил дрель из коробки A1 в коробку C2")
|
||||
r.check("берётся место назначения", store.norm_code(p["container"] or "") == "C2", p)
|
||||
|
||||
sys.exit(r.finish())
|
||||
Reference in New Issue
Block a user