Фото выбрано в форме — приложение показывает его модели и предлагает название
с прочитанными надписями. «Подставить» переносит в поля, «Не надо» прячет.
Само не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель назвала
«конструктором 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>
412 lines
20 KiB
Python
412 lines
20 KiB
Python
"""ИИ-слой: разбор свободной фразы в структуру и генерация синонимов.
|
||
|
||
Пользователь пишет одну строку — «положил стабилизаторы 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]
|