Files
hlamingo/hlamingo/brain.py
T
alexandClaude Opus 5 bae5116d77 Подсказка названия по фотографии
Фото выбрано в форме — приложение показывает его модели и предлагает название
с прочитанными надписями. «Подставить» переносит в поля, «Не надо» прячет.
Само не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель назвала
«конструктором LEGO Technic, 8376, 401 шт.». Запрет на выдумывание в промпте
помог: та же вещь стала «красной щёткой для посуды».

Смотрит gemma3:4b. Выбрана замером на настоящих снимках из базы:
  gemma3:4b     3.5 с, отвечает всегда, надписи читает верно
                («Лампа светодиодная C35, Нейтральный белый, 9 Вт LED, E14»);
  qwen3-vl:8b   11-32 с и часто пустой ответ — это ризонер, ему нужен бюджет
                в 2000+ токенов, и уменьшение картинки не спасает.
Проверялась и гипотеза, что дело в размере снимка: у gemma3 разницы между
1280 и 512 px почти нет, у qwen3-vl время падает с 62 до 41 с, но ответ
всё равно пуст. Картинка всё же уменьшается до 768 px — незачем гонять лишнее.

Генерация тегов чинилась по ходу: на «Стабилизатор 7805 (TO-220)» модель
отвечала массивом объектов с пояснениями, не укладывалась в 256 токенов,
и JSON обрывался на полуслове — теги выходили пустыми. Бюджет поднят до 500,
промпт требует плоский массив строк, а из битого ответа строки теперь
вытаскиваются регуляркой: лучше несколько живых тегов, чем ничего.

Pillow добавлен в зависимости — для уменьшения картинки перед распознаванием.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:24:49 +03:00

412 lines
20 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.
"""ИИ-слой: разбор свободной фразы в структуру и генерация синонимов.
Пользователь пишет одну строку — «положил стабилизаторы 7805, 12 штук, в коробку
A3 на второй полке стеллажа А в гараже». Модель превращает её в JSON, который
понимает store.py.
Ollama выключается вместе с компьютером, поэтому обе функции обязаны переживать
его отсутствие: разбор падает на регулярки, синонимы просто не генерируются.
"""
import re
import json
import logging
from . import ollama
from . import config
logger = logging.getLogger(__name__)
PARSE_SYSTEM = """Ты разбираешь фразы о том, куда человек положил вещи, в JSON.
Верни ТОЛЬКО JSON-объект, без пояснений и без markdown-ограждения:
{"action": "...", "items": ["..."], "qty": "...", "container": "...",
"place_path": ["...", "..."], "note": "..."}
action:
"put" — человек кладёт или перекладывает вещь
"find" — спрашивает, где вещь лежит
"list_container" — спрашивает, что лежит в конкретной коробке
"list_place" — спрашивает, что лежит в комнате/на полке/в шкафу
"unknown" — непонятно
items — СПИСОК названий вещей в именительном падеже, единственном числе.
Если названо несколько вещей — перечисли ВСЕ, ничего не теряй.
qty — количество, если названо ("12 шт"), иначе null.
container — код коробки: буква и цифра, например "A3". Если коробка не названа — null.
place_path — путь от крупного к мелкому: ["Гараж","Стеллаж А","Полка 2"].
Только реально названные места. Если мест нет — [].
note — уточнение, если есть, иначе null.
ВАЖНО про перекладывание: во фразе вида «переложил X ИЗ A В B» нужно место
НАЗНАЧЕНИЯ — то есть B. Откуда вещь забрали — не записывай.
ВАЖНО про вопросы: фраза, начинающаяся с «где», «куда», «что», «не помню», —
это ВОПРОС, action="find", даже если внутри есть слово «положил».
«куда я положил зарядку» — человек СПРАШИВАЕТ, а не сообщает.
Признак записи: сказано, КУДА вещь легла. Нет места назначения — значит вопрос.
Примеры:
«положил стабилизаторы 7805, 12 штук, в коробку A3 на второй полке стеллажа А в гараже»
{"action":"put","items":["Стабилизатор 7805"],"qty":"12 шт","container":"A3","place_path":["Гараж","Стеллаж А","Полка 2"],"note":null}
«положил паспорт и права в верхний ящик стола в спальне»
{"action":"put","items":["паспорт","права"],"qty":null,"container":null,"place_path":["Спальня","Верхний ящик стола"],"note":null}
«переложил дрель из коробки A1 в коробку C2»
{"action":"put","items":["дрель"],"qty":null,"container":"C2","place_path":[],"note":null}
«где мультиметр»
{"action":"find","items":["мультиметр"],"qty":null,"container":null,"place_path":[],"note":null}
«куда я положил зарядку от шуруповёрта»
{"action":"find","items":["зарядка от шуруповёрта"],"qty":null,"container":null,"place_path":[],"note":null}
«что в коробке B7»
{"action":"list_container","items":[],"qty":null,"container":"B7","place_path":[],"note":null}
«лопата стоит в углу гаража»
{"action":"put","items":["лопата"],"qty":null,"container":null,"place_path":["Гараж"],"note":"в углу"}
"""
ALIAS_SYSTEM = """Ты придумываешь альтернативные названия вещи, чтобы её потом можно было найти.
Верни ТОЛЬКО плоский JSON-массив СТРОК, 3-7 штук: ["первое","второе","третье"].
НЕ объекты, НЕ поля type/function/description — только строки. Без пояснений
до и после массива: любой лишний текст ломает разбор.
Включай: обиходные названия, синонимы, маркировку и артикулы, расшифровку
сокращений, а для деталей — что это по функции.
«Стабилизатор 7805» → ["LM7805","стабилизатор 5 вольт","линейный регулятор напряжения","КР142ЕН5А","стабилизатор питания"]
«изолента» → ["изоляционная лента","синяя лента","ПВХ-лента","электроизоляционная лента"]
"""
_THINK_RE = re.compile(r"<think(?:ing)?>.*?</think(?:ing)?>", re.IGNORECASE | re.DOTALL)
_THINK_CLOSE_RE = re.compile(r"</think(?:ing)?>", re.IGNORECASE)
_THINK_OPEN_RE = re.compile(r"<think(?:ing)?>", re.IGNORECASE)
def _strip_thoughts(content):
"""Убирает <think>…</think>: модели-ризонеры пишут их прямо в content,
и без чистки JSON не разбирается. Обрабатываем и «висячие» теги."""
if not content:
return content
text = _THINK_RE.sub("", content)
if _THINK_CLOSE_RE.search(text):
text = _THINK_CLOSE_RE.split(text)[-1]
if _THINK_OPEN_RE.search(text):
text = _THINK_OPEN_RE.split(text)[0]
return text.strip()
def _extract_json(content, opener="{", closer="}"):
"""Достаёт JSON из ответа модели, терпя обрамление и болтовню вокруг."""
text = _strip_thoughts(content)
if not text:
return None
fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL)
if fenced:
text = fenced.group(1).strip()
try:
return json.loads(text)
except Exception:
pass
match = re.search(re.escape(opener) + r".*" + re.escape(closer), text, re.DOTALL)
if not match:
return None
try:
return json.loads(match.group(0))
except Exception:
return None
# ============================================================
# Разбор фразы
# ============================================================
def parse(text, model=None):
"""Фраза → структура. Всегда возвращает словарь, никогда не бросает."""
text = (text or "").strip()
if not text:
return _blank("unknown")
model = model or config.CHAT_MODEL
content = ollama.chat(model, [
{"role": "system", "content": PARSE_SYSTEM},
{"role": "user", "content": text},
])
data = _extract_json(content) if content else None
if isinstance(data, dict) and data.get("action") in (
"put", "find", "list_container", "list_place", "unknown"):
return _guard_question(_clean(data), text)
if content:
logger.info("Модель не дала разбираемый JSON, беру регулярки: %r", content[:200])
return fallback_parse(text)
_QUESTION_RE = re.compile(r"^\s*(?:где|куда|что|чего|не\s+помню|подскажи|напомни)\b",
re.IGNORECASE)
def _guard_question(parsed, text):
"""Не даёт записать вопрос как факт.
Модель цепляется за слово «положил» и превращает «куда я положил зарядку»
в запись. Признак записи один: сказано, КУДА вещь легла. Нет ни коробки,
ни места — значит человек спрашивает, и придумывать ему новое место нельзя.
"""
if parsed.get("action") != "put":
return parsed
if parsed.get("container") or parsed.get("place_path"):
return parsed
if _QUESTION_RE.match(text or "") or (text or "").rstrip().endswith("?"):
parsed["action"] = "find"
return parsed
def _blank(action):
return {"action": action, "items": [], "item": None, "qty": None,
"container": None, "place_path": [], "note": None, "by": "regex"}
def _as_list(value):
"""Поле может прийти строкой или списком — приводим к списку строк."""
if value is None:
return []
if isinstance(value, str):
value = [value]
if not isinstance(value, list):
return []
return [str(v).strip() for v in value if str(v).strip()]
def _clean(data):
def s(key):
value = data.get(key)
if value is None:
return None
value = str(value).strip()
return value or None
# Старое поле item тоже принимаем: модель иногда сбивается на него,
# и терять из-за этого запись нельзя.
items = _as_list(data.get("items")) or _as_list(data.get("item"))
return {
"action": data.get("action"),
"items": items,
"item": items[0] if items else None,
"qty": s("qty"),
"container": s("container"),
"place_path": _as_list(data.get("place_path")),
"note": s("note"),
"by": "model",
}
# ============================================================
# Фолбэк без ИИ
# ============================================================
_PUT_RE = re.compile(
r"^(?:я\s+)?(?:положил|положила|кладу|убрал|убрала|засунул|положу|переложил|"
r"поставил|поставила|лежит|хранится)\s+(?P<item>.+?)"
r"\s+(?:в|во|на|под|за)\s+(?P<place>.+)$", re.IGNORECASE)
_FIND_RE = re.compile(
r"^(?:где|найди|найти|куда\s+я\s+положил|куда\s+положил|поищи|ищу)\s+"
r"(?:мой|моя|моё|мои|the)?\s*(?P<item>.+?)[\?\.!]*$", re.IGNORECASE)
_LIST_BOX_RE = re.compile(
r"^(?:что|чего|покажи)\s+(?:лежит\s+)?(?:в|во)\s+(?:коробке|ящике|боксе|)\s*"
r"(?P<code>[A-ZА-Я]{1,2}\s?\d{1,3})[\?\.!]*$", re.IGNORECASE)
_CODE_RE = re.compile(r"\b(?:коробк\w*|ящик\w*|бокс\w*)\s*([A-ZА-Я]{1,2}\s?-?\s?\d{1,3})\b",
re.IGNORECASE)
_QTY_RE = re.compile(r"(\d+)\s*(шт\w*|штук\w*|метр\w*|м\b|кг\b|грамм\w*|пар\w*|упаковк\w*)",
re.IGNORECASE)
def fallback_parse(text):
"""Разбор регулярками — когда Ollama недоступен. Понимает простые фразы."""
text = (text or "").strip()
match = _LIST_BOX_RE.match(text)
if match:
result = _blank("list_container")
result["container"] = match.group("code")
return result
match = _FIND_RE.match(text)
if match:
return _with_items(_blank("find"), [match.group("item").strip(" ?.!")])
match = _PUT_RE.match(text)
if match:
result = _blank("put")
item = match.group("item").strip()
place = match.group("place").strip(" ?.!")
# «переложил дрель ИЗ коробки A1 в коробку C2»: всё после «из» — это
# место, откуда забрали. В название вещи оно попасть не должно.
item = re.split(r"\s+из\s+", item, 1)[0].strip()
qty = _QTY_RE.search(item)
if qty:
result["qty"] = qty.group(0).strip()
item = _QTY_RE.sub("", item).strip(" ,")
_with_items(result, re.split(r"\s*(?:,|\sи\s)\s*", item.strip(" ,")))
code = _CODE_RE.search(place)
if code:
result["container"] = code.group(1)
# Всё после кода коробки — описание места («A3 на полке 2 в гараже»).
rest = place[code.end():].strip(" ,")
result["place_path"] = _split_places(rest)
else:
result["place_path"] = _split_places(place)
return result
# Ничего не распознали: считаем, что это поиск — так безопаснее,
# чем записать непонятую фразу в базу как предмет.
return _with_items(_blank("find"), [text.strip(" ?.!")])
def _with_items(result, names):
names = [n.strip(" ,.") for n in names if n and n.strip(" ,.")]
result["items"] = names
result["item"] = names[0] if names else None
return result
def _split_places(text):
"""«на полке 2 в гараже» → ['гараже','полке 2'] (от крупного к мелкому).
Предлоги идут от мелкого к крупному, поэтому части переворачиваем.
"""
text = (text or "").strip(" ,.")
if not text:
return []
parts = re.split(r"\s+(?:в|во|на|под|за)\s+", text)
parts = [p.strip(" ,.") for p in parts if p.strip(" ,.")]
parts.reverse()
return parts
# ============================================================
# Подсказка по фотографии
# ============================================================
LOOK_PROMPT = (
"Посмотри на фото и определи, что это за предмет. Ответь ТОЛЬКО JSON, "
"без пояснений:\n"
'{"name": "...", "text": "...", "sure": true}\n'
"name — как назвал бы вещь человек, который кладёт её в коробку: коротко, "
"по-русски, в именительном падеже. Если на упаковке есть марка и параметры "
"(мощность, размер, цоколь) — включи их в название.\n"
"text — все надписи и маркировка, которые действительно видно на предмете, "
"через запятую. Если надписей нет — пустая строка.\n"
"sure — true, если предмет узнан уверенно; false, если это догадка.\n"
"НЕ ВЫДУМЫВАЙ марки, артикулы и модели, которых не видно на фото: "
"лучше общее название («красная щётка»), чем придуманный артикул."
)
def describe_photo(image_bytes):
"""Что на фотографии: {name, text, sure} или None.
Это подсказка, а не готовая запись: модели склонны домысливать бренды
и артикулы, когда надписей не видно. Проверено на реальных снимках —
клипсу для кабеля модель называла «конструктором LEGO Technic 8376».
Поэтому результат всегда показывается человеку на подтверждение.
"""
answer = ollama.look(image_bytes, LOOK_PROMPT)
if not answer:
return None
data = _extract_json(answer)
if not isinstance(data, dict):
# Модель ответила прозой — берём первую строку как название.
line = _strip_thoughts(answer).strip().splitlines()[0] if answer.strip() else ""
line = line.strip(" .;")
return {"name": line[:80], "text": "", "sure": False} if line else None
name = str(data.get("name") or "").strip(" .;")
if not name:
return None
return {
"name": name[:80],
"text": str(data.get("text") or "").strip()[:300],
"sure": bool(data.get("sure", False)),
}
# ============================================================
# Синонимы
# ============================================================
def aliases(name, note=None, model=None):
"""3-7 альтернативных названий. Пустой список, если модель недоступна."""
name = (name or "").strip()
if not name:
return []
model = model or config.CHAT_MODEL
prompt = name + (" (" + note + ")" if note else "")
# 500, а не 256: модель может ответить многословно (объектами вместо строк),
# и тогда на 256 токенах JSON обрывается на полуслове — разбирать нечего.
content = ollama.chat(model, [
{"role": "system", "content": ALIAS_SYSTEM},
{"role": "user", "content": prompt},
], num_predict=500)
if not content:
return []
tags = _clean_tags(_extract_json(content, "[", "]"), name)
return tags or _salvage_tags(content, name)
_QUOTED_RE = re.compile(r'"([^"\\]{2,60})"')
_SERVICE_WORDS = {"name", "type", "function", "description", "synonym",
"synonyms", "aliases", "tags", "value", "text"}
def _salvage_tags(content, name):
"""Достаёт теги из оборванного или неверно оформленного ответа.
Модель иногда не укладывается в лимит, и JSON приходит битым. Строки в нём
всё равно есть — вытаскиваем их, отбрасывая имена полей вроде "name".
Лучше несколько живых тегов, чем ничего.
"""
text = _strip_thoughts(content or "")
found = [m for m in _QUOTED_RE.findall(text) if m.strip().lower() not in _SERVICE_WORDS]
return _clean_tags(found, name)
def _clean_tags(data, name):
"""Достаёт из ответа модели список тегов, терпя её вольности с форматом.
Модель может прислать не массив строк, а объект вида
{"name": ..., "synonyms": [...]} — так делает qwen3. Без разбора такой
объект превращался в строку целиком и попадал в теги как мусор.
Дубликаты тоже реальны: та же qwen3 присылала одно значение семь раз.
"""
if isinstance(data, dict):
for key in ("synonyms", "aliases", "tags", "items", "list"):
if isinstance(data.get(key), list):
data = data[key]
break
else:
return []
if not isinstance(data, list):
return []
out = []
for value in data:
# Вложенный объект — берём из него что-то похожее на название.
if isinstance(value, dict):
value = value.get("name") or value.get("synonym") or value.get("text") or ""
# Числа оставляем: «7805» или «220» — вполне рабочие теги.
# bool в Python — подвид int, поэтому его отсекаем отдельно.
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
continue
value = str(value).strip().strip('"')
if not value or len(value) > 60:
continue
low = value.lower()
if low == (name or "").lower() or any(low == o.lower() for o in out):
continue
out.append(value)
return out[:7]