From bae5116d77c42b1bfa7a49e687fbcaac6875af4c Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 31 Jul 2026 01:24:49 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=84=D0=BE=D1=82=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фото выбрано в форме — приложение показывает его модели и предлагает название с прочитанными надписями. «Подставить» переносит в поля, «Не надо» прячет. Само не подставляется, и это принципиально: модели домысливают марки и артикулы, когда надписей не видно. В замере клипсу для кабеля модель назвала «конструктором 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 --- .env.example | 5 +++ README.md | 27 +++++++++++++++ hlamingo/app.py | 56 +++++++++++++++++++++++++++++++ hlamingo/brain.py | 78 +++++++++++++++++++++++++++++++++++++++++--- hlamingo/config.py | 16 +++++++++ hlamingo/ollama.py | 78 +++++++++++++++++++++++++++++++++++++++++++- requirements.txt | 1 + templates/index.html | 44 +++++++++++++++++++++++++ tests/test_all.py | 32 ++++++++++++++++-- 9 files changed, 329 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index b12f1a5..ddd285d 100644 --- a/.env.example +++ b/.env.example @@ -14,5 +14,10 @@ OLLAMA_HOST=http://localhost:11434 # ollama pull bge-m3 # HLAMINGO_EMBED_MODEL=bge-m3 +# Модель, которая смотрит на фотографии и подсказывает название вещи. +# Необязательна — пустое значение выключает подсказку: +# ollama pull gemma3:4b +# HLAMINGO_VISION_MODEL=gemma3:4b + # Порт веб-интерфейса. # HLAMINGO_PORT=5000 diff --git a/README.md b/README.md index d4785cd..b2487f4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ ``` ollama pull qwen3:8b разбор фраз и теги ollama pull bge-m3 поиск по смыслу +ollama pull gemma3:4b подсказка названия по фото (необязательно) ``` ### Какую модель выбрать @@ -102,6 +103,7 @@ start.cmd запускает сервер и печатает адреса | `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama | | `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги | | `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу | +| `HLAMINGO_VISION_MODEL` | `gemma3:4b` | подсказка по фото; пусто — выключено | | `HLAMINGO_PORT` | `5000` | порт веб-интерфейса | | `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами | @@ -175,6 +177,31 @@ start.cmd запускает сервер и печатает адреса Модель разбирает фразу за ~1,5 с, места из неё создаются сами. +### Подсказка по фотографии + +Когда фото выбрано в форме заведения вещи, приложение показывает его модели +и предлагает название с прочитанными надписями: + +``` +фото коробки лампы → «Лампочка LED E14» + надписи: Лампа светодиодная C35, Нейтральный белый, 9 Вт LED, E14 +``` + +Кнопка **«✓ Подставить»** переносит это в поля, **«Не надо»** прячет. +Само оно не подставляется, и это принципиально: модели домысливают марки +и артикулы, когда надписей не видно. В замере клипсу для кабеля модель +назвала «конструктором LEGO Technic, 8376, 401 шт.». Поэтому — только +подсказка, решает человек. + +Смотрит **`gemma3:4b`** (`HLAMINGO_VISION_MODEL`, 3,3 ГБ). Выбрана замером +на настоящих снимках: 3,5 с и всегда отвечает, тогда как `qwen3-vl:8b` — 11–32 с +и часто пустой ответ, потому что это ризонер, которому нужен бюджет +в 2000+ токенов. Перед отправкой картинка уменьшается до 768 px: снимок +разворачивается в токены, а для чтения крупных надписей столько не нужно. + +Распознавание отключается пустым `HLAMINGO_VISION_MODEL` — тогда блок +подсказки просто не появляется. + ### Фото Две кнопки: **«📷 Снять»** открывает камеру, **«🖼»** — готовые снимки в галерее. diff --git a/hlamingo/app.py b/hlamingo/app.py index 2e5969b..c8203e7 100644 --- a/hlamingo/app.py +++ b/hlamingo/app.py @@ -495,7 +495,12 @@ def api_retag_item(item_id): return jsonify({"ok": False, "reply": "Модель недоступна — теги придумывать некому."}), 503 + # Вторая попытка не роскошь: если только что распознавали фото, в памяти + # Ollama лежит vision-модель, и первый запрос уходит на её выгрузку + # и загрузку текстовой — он может вернуться пустым. fresh = brain.aliases(item.get("name"), item.get("note")) + if not fresh: + fresh = brain.aliases(item.get("name"), item.get("note")) if not fresh: return jsonify({"ok": False, "reply": "Модель не вернула ни одного тега."}) @@ -611,6 +616,57 @@ def _drop_photo_file(filename): logger.warning("Не удалось удалить фото %s: %s", filename, e) +@app.route("/api/recognize", methods=["POST"]) +def api_recognize(): + """Что на снимке — подсказка для поля «Предмет». + + Ничего не сохраняет: возвращает предложение, а решает человек. Модели + домысливают бренды и артикулы, когда надписей не видно, поэтому запись + вслепую сделала бы базу хуже, а не лучше. + """ + upload = request.files.get("photo") + if not upload or not upload.filename: + return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400 + if not config.VISION_MODEL: + return jsonify({"ok": False, "reply": "Распознавание выключено."}), 503 + if not ollama.check(): + return jsonify({"ok": False, "reply": "Модель недоступна."}), 503 + + guess = brain.describe_photo(upload.read()) + if not guess: + return jsonify({"ok": False, "reply": "Не смог разобрать, что на фото."}) + + reply = "Похоже на «%s»" % guess["name"] + if guess.get("text"): + reply += ". Надписи: %s" % guess["text"] + if not guess.get("sure"): + reply += " (не уверен — проверь)" + return jsonify({"ok": True, "guess": guess, "reply": reply}) + + +@app.route("/api/item//recognize", methods=["POST"]) +def api_recognize_saved(item_id): + """То же, но для фотографии, которая уже прикреплена к вещи.""" + db = store.load() + item = db["items"].get(item_id) + if not item or not item.get("photo"): + return jsonify({"ok": False, "reply": "У этой записи нет фото."}), 404 + if not config.VISION_MODEL or not ollama.check(): + return jsonify({"ok": False, "reply": "Модель недоступна."}), 503 + + path = PHOTOS_DIR / item["photo"] + if not path.exists(): + return jsonify({"ok": False, "reply": "Файл фотографии не найден."}), 404 + + guess = brain.describe_photo(path.read_bytes()) + if not guess: + return jsonify({"ok": False, "reply": "Не смог разобрать, что на фото."}) + return jsonify({"ok": True, "guess": guess, + "reply": "На фото похоже на «%s»%s" % ( + guess["name"], + (". Надписи: " + guess["text"]) if guess.get("text") else "")}) + + @app.route("/api/photo", methods=["DELETE"]) def api_delete_photo(): data = request.get_json(silent=True) or {} diff --git a/hlamingo/brain.py b/hlamingo/brain.py index 8051f6d..ee07edf 100644 --- a/hlamingo/brain.py +++ b/hlamingo/brain.py @@ -71,7 +71,9 @@ note — уточнение, если есть, иначе null. ALIAS_SYSTEM = """Ты придумываешь альтернативные названия вещи, чтобы её потом можно было найти. -Верни ТОЛЬКО JSON-массив строк, 3-7 штук, без пояснений. +Верни ТОЛЬКО плоский JSON-массив СТРОК, 3-7 штук: ["первое","второе","третье"]. +НЕ объекты, НЕ поля type/function/description — только строки. Без пояснений +до и после массива: любой лишний текст ломает разбор. Включай: обиходные названия, синонимы, маркировку и артикулы, расшифровку сокращений, а для деталей — что это по функции. @@ -286,6 +288,53 @@ def _split_places(text): 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)), + } + + # ============================================================ # Синонимы # ============================================================ @@ -296,12 +345,33 @@ def aliases(name, note=None, model=None): 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=256) - data = _extract_json(content, "[", "]") if content else None - return _clean_tags(data, name) + ], 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): diff --git a/hlamingo/config.py b/hlamingo/config.py index 2224283..738ce0a 100644 --- a/hlamingo/config.py +++ b/hlamingo/config.py @@ -152,6 +152,22 @@ OLLAMA_HOST = (os.environ.get("OLLAMA_HOST") or "http://localhost:11434").rstrip CHAT_MODEL = os.environ.get("HLAMINGO_CHAT_MODEL") or "qwen3:8b" CHAT_MODEL_ALT = "qwen2.5:7b" # быстрее, но русский хуже +# Модель, которая смотрит на фотографии: подсказывает название вещи и читает +# надписи с упаковки. Пустая строка — распознавание выключено. +# +# gemma3:4b выбрана по замеру на настоящих снимках из базы: +# gemma3:4b 3.5 с, отвечает всегда, надписи читает верно («IEK, C35, +# 9 Вт LED, E14»), занимает 3.3 ГБ; +# qwen3-vl:8b 11-32 с и часто пустой ответ: это ризонер, ему нужен бюджет +# в 2000+ токенов, и даже уменьшение картинки не спасает. +# Обе выдумывают, когда надписей нет, поэтому результат — только подсказка, +# которую человек подтверждает. +VISION_MODEL = os.environ.get("HLAMINGO_VISION_MODEL", "gemma3:4b").strip() +VISION_BUDGET = 400 # хватает на название и список надписей +# Картинку перед отправкой уменьшаем: снимок с телефона разворачивается +# в тысячи токенов, а для чтения крупных надписей столько не нужно. +VISION_MAX_SIDE = 768 + # bge-m3 — многоязычная модель, на русском заметно лучше прежней # nomic-embed-text. Замер на одном и том же складе: # nomic-embed-text: «велосипедная камера» набирала мультиметру 0.704, а верный diff --git a/hlamingo/ollama.py b/hlamingo/ollama.py index 313e18c..5219fb6 100644 --- a/hlamingo/ollama.py +++ b/hlamingo/ollama.py @@ -4,8 +4,10 @@ активно развивается, и общий модуль сделал бы hlamingo заложником её рефакторингов. """ +import io import json import time +import base64 import socket import logging import urllib.parse @@ -29,6 +31,11 @@ logger = logging.getLogger(__name__) # Поэтому: сначала быстрая проверка TCP (полтора секунды вместо двух минут), # а после неудачи — пауза, в течение которой к серверу вообще не ходим. # ============================================================ +# Бюджет ответа для моделей, которые всегда размышляют: размышления идут +# в счёт того же лимита, и обычных 512 токенов им не хватает даже на короткий +# ответ. Замер на qwen3-vl: при 200 токенах content пуст, при 400 — уже ответ. +THINKING_BUDGET = 1200 + CONNECT_TIMEOUT = 1.5 # столько ждём само соединение DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи UP_CACHE = 5.0 # столько верим, что сервер жив, без повторной проверки @@ -142,12 +149,81 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS, with urllib.request.urlopen(req, timeout=timeout) as r: resp = json.loads(r.read().decode("utf-8")) _mark_up() - return (resp.get("message") or {}).get("content", "") except Exception as e: _mark_down() logger.warning("Запрос к модели %s не удался: %s", model, e) return None + message = resp.get("message") or {} + content = message.get("content", "") + + # Модели вроде qwen3-vl размышляют всегда, и think=False их не выключает: + # весь бюджет уходит в поле thinking, а content остаётся пустым при + # done_reason="length". Один раз повторяем с запасом — иначе такая модель + # выглядит нерабочей, хотя дело только в лимите. + if not content.strip() and message.get("thinking") \ + and resp.get("done_reason") == "length" and num_predict < THINKING_BUDGET: + logger.info("Модель %s ушла в размышления — повторяю с бюджетом %d", + model, THINKING_BUDGET) + return chat(model, messages, timeout=timeout, + num_predict=THINKING_BUDGET, think=think) + return content + + +def look(image_bytes, prompt, model=None, budget=None, timeout=180): + """Показать модели картинку и получить текстовый ответ. None при неудаче. + + Картинка уменьшается перед отправкой: изображение разворачивается в токены, + и снимок с телефона стоит их тысячами без пользы для чтения крупных надписей. + """ + model = model or config.VISION_MODEL + if not model or not image_bytes or not available(): + return None + + small = _shrink(image_bytes, config.VISION_MAX_SIDE) + payload = { + "model": model, + "stream": False, + "messages": [{"role": "user", "content": prompt, + "images": [base64.b64encode(small).decode("ascii")]}], + "options": {"num_predict": budget or config.VISION_BUDGET, "temperature": 0}, + } + req = urllib.request.Request( + config.OLLAMA_HOST + "/api/chat", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + resp = json.loads(r.read().decode("utf-8")) + _mark_up() + return ((resp.get("message") or {}).get("content") or "").strip() + except Exception as e: + _mark_down() + logger.warning("Не удалось распознать фото моделью %s: %s", model, e) + return None + + +def _shrink(image_bytes, side): + """Уменьшает картинку по длинной стороне. Без Pillow отдаёт как есть.""" + try: + from PIL import Image + except ImportError: + return image_bytes + try: + img = Image.open(io.BytesIO(image_bytes)) + img = img.convert("RGB") + if max(img.size) > side: + ratio = side / float(max(img.size)) + img = img.resize((max(1, int(img.width * ratio)), + max(1, int(img.height * ratio))), Image.LANCZOS) + out = io.BytesIO() + img.save(out, format="JPEG", quality=85) + return out.getvalue() + except Exception as e: + logger.warning("Не удалось уменьшить картинку: %s", e) + return image_bytes + def embed(text, is_query=False): """Вектор текста или None. is_query=True для поисковых запросов. diff --git a/requirements.txt b/requirements.txt index 7e10602..4111845 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flask +pillow diff --git a/templates/index.html b/templates/index.html index 7dd599b..b4947cc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -209,6 +209,7 @@ +
@@ -510,11 +511,53 @@ async function takeFormPhoto(input) { img.src = URL.createObjectURL(pendingPhoto); preview.appendChild(img); $('#formPhotoClear').hidden = false; + recognizePending(); // фото есть — спросим модель, что на нём } catch (e) { say('Не смог прочитать это изображение: ' + e); clearPendingPhoto(); } } + +// Подсказка по фото: показываем, что увидела модель, и даём подставить это +// в поле названия. Само не подставляется — модель домысливает бренды, +// когда надписей не видно. +async function recognizePending() { + if (!pendingPhoto) return; + const box = $('#guessBox'); + box.hidden = false; + box.innerHTML = '
Смотрю на фото…
'; + try { + const fd = new FormData(); + fd.append('photo', pendingPhoto, 'photo.jpg'); + const d = await api('/api/recognize', {method: 'POST', body: fd}); + if (!d.ok) { box.hidden = true; return; } + + const g = d.guess; + box.innerHTML = '
На фото похоже на:
' + + '

' + esc(g.name) + '

' + + (g.text ? '
Надписи: ' + esc(g.text) + '
' : '') + + (g.sure ? '' : '
Модель не уверена — проверьте.
'); + + const bar = document.createElement('div'); + bar.className = 'acts'; + const use = document.createElement('button'); + use.textContent = '✓ Подставить'; + use.onclick = () => { + $('#itemName').value = g.name; + if (g.text && !$('#itemNote').value) $('#itemNote').value = g.text; + box.hidden = true; + $('#itemName').focus(); + }; + const skip = document.createElement('button'); + skip.textContent = 'Не надо'; + skip.onclick = () => { box.hidden = true; }; + bar.appendChild(use); + bar.appendChild(skip); + box.appendChild(bar); + } catch (e) { + box.hidden = true; + } +} $('#formCamInput').onchange = () => takeFormPhoto($('#formCamInput')); $('#formGalInput').onchange = () => takeFormPhoto($('#formGalInput')); @@ -524,6 +567,7 @@ function clearPendingPhoto() { $('#formPhotoPreview').hidden = true; $('#formPhotoPreview').innerHTML = ''; $('#formPhotoClear').hidden = true; + $('#guessBox').hidden = true; } // Отправляет уже сжатый blob к готовой записи. Возвращает имя файла или null. diff --git a/tests/test_all.py b/tests/test_all.py index 4bccd5e..acc5c18 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -116,13 +116,39 @@ r.check("пустое имя — 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["item"]["aliases"], d) - r.check("мусорный тег заменён", "мусорный тег" not in d["item"]["aliases"], - d["item"]["aliases"]) + 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",