Files
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

477 lines
27 KiB
Python
Raw Permalink 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.
# -*- 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== Подсказка по фотографии ==")
HAS_VISION = bool(config.VISION_MODEL) and HAS_AI and config.VISION_MODEL in ollama.models()
if HAS_VISION:
real = None
for candidate in ("31ee37dada70.jpg", "afbaf962ad16.jpg"):
path = config.PROJECT_DIR / "static" / "photos" / candidate
if path.exists():
real = path
break
if real:
d = c.post("/api/recognize", content_type="multipart/form-data",
data={"photo": (io.BytesIO(real.read_bytes()), "p.jpg")}).get_json()
r.check("модель что-то увидела", d.get("ok") and d["guess"]["name"], d)
r.check("название не пустое и короткое",
0 < len(d["guess"]["name"]) <= 80, d["guess"]["name"])
print(" предложила: %s" % d["guess"]["name"])
else:
print(" (реальных фото нет — пропускаю)")
else:
print(" (vision-модель недоступна — пропускаю)")
r.check("без файла — 400", c.post("/api/recognize", content_type="multipart/form-data",
data={}).status_code == 400)
r.check("для записи без фото — 404",
c.post("/api/item/нет-такой/recognize").status_code == 404)
print("\n== Пересоздание тегов ==")
if HAS_AI:
j("PATCH", "/api/item/" + item, aliases=["мусорный тег"])
d = c.post("/api/item/" + item + "/retag").get_json()
r.check("теги пересозданы", d.get("ok") and d.get("item", {}).get("aliases"), d)
r.check("мусорный тег заменён",
"мусорный тег" not in d.get("item", {}).get("aliases", ["мусорный тег"]),
d.get("item"))
d = c.post("/api/item/" + item + "/retag?keep=1").get_json()
r.check("режим дополнения работает", d.get("ok"), d)
r.check("retag для несуществующей — 404",
c.post("/api/item/нет-такой/retag").status_code == 404)
# Разбор кривых ответов модели: qwen3 присылала объект вместо списка строк,
# и он попадал в теги целиком, а ещё дублировала одно значение семь раз.
from hlamingo.brain import _clean_tags # noqa: E402
r.check("объект вместо списка разбирается",
_clean_tags({"name": "x", "synonyms": ["тестер", "дрель"]}, "x")
== ["тестер", "дрель"])
r.check("дубликаты схлопываются",
_clean_tags(["дрель", "ДРЕЛЬ", "бур"], "x") == ["дрель", "бур"])
r.check("вложенные объекты разбираются",
_clean_tags([{"name": "тестер"}, "мультиметр"], "x") == ["тестер", "мультиметр"])
r.check("мусор отсеивается", _clean_tags([None, True, {}, "годный"], "x") == ["годный"])
r.check("не список — пусто", _clean_tags("строка", "x") == [])
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== Работа без Ollama: не ждём таймаутов ==")
# Машина с Ollama бывает выключена, и тогда пакеты уходят в никуда. Без
# предохранителя запись одной вещи ждала 92 секунды, разбор фразы — 154:
# пользователь решит, что приложение зависло. 192.0.2.1 — из блока TEST-NET-1
# (RFC 5737), он гарантированно не отвечает.
import time as _time # noqa: E402
_was_host = config.OLLAMA_HOST
try:
config.OLLAMA_HOST = "http://192.0.2.1:11434"
ollama.reset_breaker()
t0 = _time.time()
alive = ollama.check()
first = _time.time() - t0
r.check("недоступность видна быстро", not alive and first < 5, "%.1f с" % first)
t0 = _time.time()
d = j("POST", "/api/put", name="вещь без ии")
put_time = _time.time() - t0
r.check("запись вещи не ждёт таймаут", d.get("ok") and put_time < 3,
"%.1f с, ответ: %s" % (put_time, d))
r.check("тегов нет, но вещь записана",
d.get("item", {}).get("aliases") == [], d.get("item"))
t0 = _time.time()
d = j("POST", "/api/say", text="положил кусачки в коробку D1")
say_time = _time.time() - t0
r.check("свободная фраза не ждёт таймаут", say_time < 3, "%.1f с" % say_time)
r.check("фраза разобрана регулярками", d.get("kind") == "put", d)
t0 = _time.time()
d = c.get("/api/find?q=вещь без ии").get_json()
r.check("поиск работает без ИИ", d["items"] and _time.time() - t0 < 3, d)
r.check("смена адреса сбрасывает паузу",
(ollama.reset_breaker() or True) and ollama._down_until == {})
finally:
config.OLLAMA_HOST = _was_host
ollama.reset_breaker()
r.check("после возврата адреса связь есть", ollama.check() if HAS_AI else True)
print("\n== Экспорт и импорт ==")
import json as _json # noqa: E402
import zipfile # noqa: E402
# Кладём снимок, чтобы в архиве было чему проверяться: предыдущие блоки
# свои фотографии за собой убирают.
_some = c.get("/api/items").get_json()["items"][0]["id"]
upload("item", _some)
before = c.get("/api/items").get_json()["items"]
resp = c.get("/api/export")
r.check("архив отдаётся", resp.status_code == 200, resp.status_code)
r.check("это zip", resp.data[:2] == b"PK", resp.data[:4])
archive = zipfile.ZipFile(io.BytesIO(resp.data))
names = archive.namelist()
r.check("внутри есть база", "db.json" in names, names[:5])
r.check("внутри есть фотографии", any(n.startswith("photos/") for n in names), names[:5])
r.check("локальные копии в архив не попали",
not any("backups" in n for n in names), [n for n in names if "backups" in n])
d = c.post("/api/import/check", content_type="multipart/form-data",
data={"archive": (io.BytesIO(resp.data), "a.zip")}).get_json()
r.check("проверка архива видит содержимое",
d.get("ok") and d["archive"]["items"] == len(before), d)
# Восстановление в ту же песочницу: данные должны остаться прежними,
# а прежнее состояние — отложиться для отката.
d = c.post("/api/import", content_type="multipart/form-data",
data={"archive": (io.BytesIO(resp.data), "a.zip")}).get_json()
r.check("импорт прошёл", d.get("ok"), d)
after = c.get("/api/items").get_json()["items"]
r.check("вещи на месте после импорта", len(after) == len(before),
(len(before), len(after)))
rollback = config.DATA_DIR / "backups" / d["result"]["rollback"]
r.check("страховка перед импортом создана", rollback.exists(), rollback)
# Архив с путём наружу: имя из архива не должно превращаться в путь.
evil = io.BytesIO()
with zipfile.ZipFile(evil, "w") as zf:
zf.writestr("db.json", _json.dumps({"version": 1, "items": {}, "places": {},
"containers": {}}))
zf.writestr("photos/../../evil.png", b"\x89PNG\r\n\x1a\n")
evil.seek(0)
outside = config.PROJECT_DIR.parent / "evil.png"
d = c.post("/api/import", content_type="multipart/form-data",
data={"archive": (evil, "evil.zip")}).get_json()
r.check("злой архив не пишет за пределы папки", not outside.exists(), outside)
if outside.exists():
outside.unlink()
r.check("чужой файл отвергается",
c.post("/api/import", content_type="multipart/form-data",
data={"archive": (io.BytesIO(b"not a zip"), "x.zip")}).status_code == 400)
r.check("импорт без файла — 400",
c.post("/api/import", content_type="multipart/form-data", data={}).status_code == 400)
# Возвращаем данные песочницы: злой архив заменил базу на пустую.
c.post("/api/import", content_type="multipart/form-data",
data={"archive": (io.BytesIO(resp.data), "a.zip")})
print("\n== Внешние копии ==")
r.check("состояние отдаётся", "backup" in c.get("/api/config").get_json())
r.check("без пути — копировать нечего",
c.post("/api/backup/now").status_code == 400 if not config.BACKUP_DIR else True)
dest = SANDBOX / "external"
config.save_settings({"HLAMINGO_BACKUP_DIR": str(dest)})
r.check("путь принят", config.BACKUP_DIR == str(dest), config.BACKUP_DIR)
d = c.post("/api/backup/now").get_json()
r.check("копия сделана", d.get("ok"), d)
r.check("база скопирована", (dest / "db.json").exists())
r.check("фотографии скопированы", (dest / "photos").exists())
# Недоступный путь не должен мешать записи вещи — сетевой диск отваливается.
config.save_settings({"HLAMINGO_BACKUP_DIR": "Z:\\нет-такого-диска\\backup"})
import time as _t2 # noqa: E402
t0 = _t2.time()
d = j("POST", "/api/put", name="вещь при отвалившемся диске")
r.check("запись прошла, несмотря на недоступный путь",
d.get("ok") and _t2.time() - t0 < 10, "%.1f с" % (_t2.time() - t0))
config.save_settings({"HLAMINGO_BACKUP_DIR": ""})
r.check("путь можно очистить", config.BACKUP_DIR == "", config.BACKUP_DIR)
print("\n== Батники: только ASCII ==")
# cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже
# в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки
# как команды. На этом уже дважды ломался инсталлятор.
import pathlib # noqa: E402
root = pathlib.Path(__file__).resolve().parent.parent
for cmd_file in sorted(root.rglob("*.cmd")):
if "venv" in cmd_file.parts:
continue
lines = cmd_file.read_text(encoding="utf-8").splitlines()
bad = [(n, l) for n, l in enumerate(lines, 1) if any(ord(ch) > 127 for ch in l)]
r.check("%s без не-ASCII" % cmd_file.relative_to(root), not bad,
bad[0] if bad else "")
print("\n== Настройки из браузера ==")
d = c.get("/api/config").get_json()
r.check("отдаёт текущие", d.get("ollama_host") and d.get("chat_model"), d)
r.check("говорит, где файл", str(d.get("env_file", "")).endswith(".env"), d.get("env_file"))
was_host = config.OLLAMA_HOST
# Самое важное: файл настроек тоже в песочнице. Иначе тест перезаписывает
# рабочий .env пользователя — так однажды и случилось.
r.check("файл .env внутри песочницы", str(SANDBOX) in str(config.ENV_FILE),
config.ENV_FILE)
d = j("POST", "/api/config", ollama_host="http://10.11.12.13:11434")
r.check("сохранилось", d.get("ok"), d)
r.check("применилось без перезапуска", config.OLLAMA_HOST == "http://10.11.12.13:11434",
config.OLLAMA_HOST)
r.check("ollama.py видит новый адрес",
c.get("/api/health").get_json()["host"] == "http://10.11.12.13:11434")
r.check("записалось в файл", config.ENV_FILE.exists()
and "10.11.12.13" in config.ENV_FILE.read_text(encoding="utf-8"))
r.check("мусорный адрес — 400",
c.post("/api/config", json={"ollama_host": "не адрес"}).status_code == 400)
r.check("инъекция в имя модели — 400",
c.post("/api/config",
json={"ollama_host": "http://127.0.0.1:11434",
"chat_model": "модель\nOLLAMA_HOST=http://зло"}).status_code == 400)
# ---- выбор мозгов: Ollama или веб-DeepSeek ----
# Проверяем не сам DeepSeek (для него нужен браузер с логином), а развилку:
# куда уходит разбор фраз и куда — эмбеддинги. Перепутать их значит тихо
# сломать поиск по смыслу, и это не видно, пока не начнёшь искать.
was_engine = config.ENGINE
DS = "http://127.0.0.1:11500"
try:
d = j("POST", "/api/config", engine="deepseek", deepseek_host=DS)
r.check("движок переключился", d.get("ok") and config.ENGINE == "deepseek", d)
r.check("думает DeepSeek", config.chat_host() == DS, config.chat_host())
r.check("здоровье докладывает движок",
c.get("/api/health").get_json()["engine"] == "deepseek")
r.check("ждём DeepSeek дольше", config.chat_timeout() > config.CHAT_TIMEOUT,
config.chat_timeout())
# Куда реально уходят запросы. Предохранитель и сеть отключаем: важно не
# то, ответил ли кто-то, а по какому адресу постучались.
tried = []
def _spy(req, **kw):
tried.append(req.full_url)
raise OSError("в тесте наружу не ходим")
real_urlopen = ollama.urllib.request.urlopen
real_available = ollama.available
ollama.urllib.request.urlopen = _spy
ollama.available = lambda host=None: True
try:
ollama.embed("гвозди")
ollama.chat(config.CHAT_MODEL, [{"role": "user", "content": "привет"}])
finally:
ollama.urllib.request.urlopen = real_urlopen
ollama.available = real_available
ollama.reset_breaker()
r.check("эмбеддинги идут в Ollama",
any(u.startswith(config.OLLAMA_HOST) and "embed" in u for u in tried), tried)
r.check("разбор фраз идёт в DeepSeek",
any(u.startswith(DS) and u.endswith("/api/chat") for u in tried), tried)
r.check("чужой движок — 400",
c.post("/api/config", json={"engine": "chatgpt"}).status_code == 400)
r.check("мусорный адрес DeepSeek — 400",
c.post("/api/config", json={"deepseek_host": "не адрес"}).status_code == 400)
r.check("проба берёт адрес из запроса, а не из настроек",
c.post("/api/config/probe",
json={"host": "http://192.0.2.1:11434"}).get_json()["ok"] is False
and config.OLLAMA_HOST != "http://192.0.2.1:11434")
finally:
j("POST", "/api/config", engine=was_engine)
ollama.reset_breaker()
r.check("движок вернулся", config.ENGINE == was_engine, config.ENGINE)
# Отказ должен быть полным: адрес из того же запроса применяться не должен,
# иначе половина настроек принята, а половина отвергнута.
r.check("при отказе НИЧЕГО не применилось",
config.OLLAMA_HOST == "http://10.11.12.13:11434", config.OLLAMA_HOST)
r.check("посторонние ключи игнорируются", config.save_settings({"PATH": "/зло"}) == [])
d = j("POST", "/api/config/probe", ollama_host="http://127.0.0.1:1")
r.check("проба мёртвого адреса не роняет", d.get("ok") is False, d)
r.check("проба не меняет настройку", config.OLLAMA_HOST == "http://10.11.12.13:11434",
config.OLLAMA_HOST)
j("POST", "/api/config", ollama_host=was_host) # возвращаем как было
r.check("адрес восстановлен", config.OLLAMA_HOST == was_host, config.OLLAMA_HOST)
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())