diff --git a/.env.example b/.env.example index edb6a57..b12f1a5 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,8 @@ OLLAMA_HOST=http://localhost:11434 # Модель для разбора свободных фраз. Должна быть установлена в Ollama: -# ollama pull qwen2.5:7b -# HLAMINGO_CHAT_MODEL=qwen2.5:7b +# ollama pull qwen3:8b +# HLAMINGO_CHAT_MODEL=qwen3:8b # Модель для эмбеддингов (поиск по смыслу). Необязательна — без неё # работает поиск по словам и тегам: diff --git a/README.md b/README.md index 4f88d29..d4785cd 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,49 @@ Модели (на машине с Ollama): ``` -ollama pull qwen2.5:7b разбор фраз и теги -ollama pull bge-m3 поиск по смыслу (необязательно) +ollama pull qwen3:8b разбор фраз и теги +ollama pull bge-m3 поиск по смыслу ``` +### Какую модель выбрать + +Обе задачи русские, и требование к модели одно: она должна **думать +по-русски**, а не переводить с английского на ходу. Замеры на наших же +задачах — разбор восьми фраз и генерация тегов для шести предметов: + +| Модель | Размер | Разбор фраз | Русский в тегах | Скорость | +|---|---|---|---|---| +| **`qwen3:8b`** | 5 ГБ | 8/8 | чистый | 2,5 с на предмет | +| `qwen2.5:7b` | 4,7 ГБ | 8/8 | «электротool Makita» | 1,8 с | + +Разница видна именно на тегах — по ним вещь потом и находится: + +``` +qwen2.5: шуруповёрт Makita → «электротool Makita», «удлинитель для шуруповерта» + мультиметр → «многофункциональный мера», «вoltage meter» +qwen3: изолента → «изоляционная лента», «лента для изоляции», «ПВХ-лента» + саморезы 4х50 → «шурупы по дереву», «крепёжный элемент» +``` + +У `qwen2.5` в слове «вoltage» первая буква кириллическая, а «электротool» — +гибрид двух языков. В поиске такие теги бесполезны. + +**Что учесть, выбирая свою модель:** + +- **Меньше 7B на русском обычно плохи** — путают падежи и сваливаются + в английский. `qwen2.5-coder:1.5b` для этой задачи не годится. +- **Модели-ризонеры** (`qwen3`, `deepseek-r1`) тратят бюджет ответа на + размышления и возвращают пустоту. В `ollama.py` это гасится `think=False`; + добавляя свою, проверьте, что она отвечает, а не молчит. +- **Формат важнее красноречия.** Приложение ждёт строгий JSON. `qwen3` иногда + присылает объект вместо списка тегов — разбор это переживает, но модель, + совсем не держащая формат, будет постоянно сваливаться на регулярки. +- **Эмбеддер обязан быть многоязычным** — см. раздел про поиск. + +Модель выбирается в настройках (вкладка **⚙**) из реально установленных. +После смены у записанных вещей теги остаются прежними; обновить их можно +кнопкой **«🔄 Пересоздать теги»** в карточке. + ## Установка Положите `install.cmd` в пустую папку и запустите — он скачает проект, создаст @@ -61,7 +100,7 @@ start.cmd запускает сервер и печатает адреса | Переменная | По умолчанию | Зачем | |---|---|---| | `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama | -| `HLAMINGO_CHAT_MODEL` | `qwen2.5:7b` | разбор фраз и теги | +| `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги | | `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу | | `HLAMINGO_PORT` | `5000` | порт веб-интерфейса | | `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами | diff --git a/hlamingo/app.py b/hlamingo/app.py index 1fce207..2e5969b 100644 --- a/hlamingo/app.py +++ b/hlamingo/app.py @@ -479,6 +479,43 @@ def api_edit_item(item_id): "reply": "Обновил: %s." % ", ".join(changed)}) +@app.route("/api/item//retag", methods=["POST"]) +def api_retag_item(item_id): + """Пересобрать теги заново — например, после смены модели. + + Теги придумывает модель, и с новой они выходят другими: старая давала + «электротool Makita», новая — «шуруповерт Макита». Ручной способ обновить + их, не перезаводя вещь. + """ + db = store.load() + item = db["items"].get(item_id) + if not item: + return jsonify({"ok": False, "reply": "Такой записи нет."}), 404 + if not ollama.check(): + return jsonify({"ok": False, + "reply": "Модель недоступна — теги придумывать некому."}), 503 + + fresh = brain.aliases(item.get("name"), item.get("note")) + if not fresh: + return jsonify({"ok": False, "reply": "Модель не вернула ни одного тега."}) + + keep = request.args.get("keep") == "1" + if keep: + merged = list(item.get("aliases") or []) + for tag in fresh: + if not any(tag.lower() == existing.lower() for existing in merged): + merged.append(tag) + item["aliases"] = merged[:12] + else: + item["aliases"] = fresh + + item["emb"] = None # словарь предмета изменился + search.ensure_embeddings(db) + store.save(db) + return jsonify({"ok": True, "item": _card(db, item), + "reply": "Теги пересозданы: %s" % ", ".join(item["aliases"])}) + + @app.route("/api/containers/", methods=["PATCH"]) def api_edit_container(code): db = store.load() diff --git a/hlamingo/brain.py b/hlamingo/brain.py index 10f2174..8051f6d 100644 --- a/hlamingo/brain.py +++ b/hlamingo/brain.py @@ -301,11 +301,41 @@ def aliases(name, note=None, model=None): {"role": "user", "content": prompt}, ], num_predict=256) data = _extract_json(content, "[", "]") if content else None + return _clean_tags(data, 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: - value = str(value).strip() - if value and value.lower() != name.lower() and value not in out: - out.append(value) + # Вложенный объект — берём из него что-то похожее на название. + 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] diff --git a/hlamingo/config.py b/hlamingo/config.py index a8c172b..2224283 100644 --- a/hlamingo/config.py +++ b/hlamingo/config.py @@ -140,11 +140,17 @@ def _write_env(values): # set OLLAMA_HOST=http://192.168.1.50:11434 OLLAMA_HOST = (os.environ.get("OLLAMA_HOST") or "http://localhost:11434").rstrip("/") -# qwen2.5 — не ризонер: не тратит время на и надёжнее держит формат JSON, -# а нам от модели нужен именно строгий JSON, а не рассуждения. -# omnicoder-9b — запасной вариант, если qwen начнёт путаться в русских фразах. -CHAT_MODEL = os.environ.get("HLAMINGO_CHAT_MODEL") or "qwen2.5:7b" -CHAT_MODEL_ALT = "carstenuhlig/omnicoder-9b" +# qwen3:8b — русский заметно чище, чем у qwen2.5:7b. Замер на наших же задачах +# (разбор фраз + генерация тегов для русских предметов): +# qwen2.5:7b разбор 8/8, но в тегах «электротool Makita», «вoltage meter», +# «многофункциональный мера» — модель думает по-английски +# и переводит на ходу; +# qwen3:8b разбор 8/8, теги на нормальном русском. +# Плата — примерно на треть медленнее на тегах (2.5 с против 1.8 с на предмет). +# Это ризонер: рассуждения выключаются параметром think=False в ollama.chat, +# иначе модель тратит весь бюджет ответа на внутренний монолог. +CHAT_MODEL = os.environ.get("HLAMINGO_CHAT_MODEL") or "qwen3:8b" +CHAT_MODEL_ALT = "qwen2.5:7b" # быстрее, но русский хуже # bge-m3 — многоязычная модель, на русском заметно лучше прежней # nomic-embed-text. Замер на одном и том же складе: diff --git a/hlamingo/ollama.py b/hlamingo/ollama.py index 54450e1..313e18c 100644 --- a/hlamingo/ollama.py +++ b/hlamingo/ollama.py @@ -112,16 +112,23 @@ def models(): return [] -def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS): +def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS, + think=False): """Один запрос к /api/chat. Возвращает content или None при любой беде. num_predict обязателен: без капа модель может уйти в длинную генерацию и подвесить ход на минуты — а нам нужен короткий JSON. + + think=False выключает режим размышлений у моделей-ризонеров (qwen3, + deepseek-r1 и подобных). Без этого они тратят весь бюджет ответа на + внутренний монолог и возвращают пустой content — проверено на qwen3:8b. + Модели без такого режима поле просто игнорируют. """ payload = { "model": model, "messages": messages, "stream": False, + "think": think, "options": {"num_predict": num_predict, "temperature": 0}, } if not available(): diff --git a/templates/index.html b/templates/index.html index 900fe8a..7dd599b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -712,11 +712,16 @@ function itemEditor(it, cardEl) { + '' + '
' + '' + + '
' + + '' + + '
' + '

Тег убирается нажатием на ×. По тегам вещь и находится, ' - + 'так что лишние лучше убрать, а свои — добавить.

' + + 'так что лишние лучше убрать, а свои — добавить. «Пересоздать» просит модель ' + + 'придумать их заново — полезно после смены модели.

' + '
' + '' - + '' + + '' + + '' + '
'; const q = s => box.querySelector(s); @@ -768,6 +773,38 @@ function itemEditor(it, cardEl) { } finally { btn.disabled = false; } }; + // Отмена: закрыть правку, вернув поля к тому, что было. Без неё случайное + // изменение можно было только сохранить или удалить вещь целиком. + q('.eCancel').onclick = () => { + q('.eName').value = it.name || ''; + q('.eQty').value = it.qty || ''; + q('.eNote').value = it.note || ''; + tags = (it.aliases || []).slice(); + drawTags(); + q('.eNewTag').value = ''; + box.hidden = true; + cardEl.classList.remove('wide'); + }; + + q('.eRetag').onclick = async () => { + const btn = q('.eRetag'); + btn.disabled = true; + const was = btn.textContent; + btn.textContent = 'Думаю…'; + try { + const d = await api('/api/item/' + encodeURIComponent(it.id) + '/retag', + {method:'POST'}); + say(d.reply); + if (d.ok && d.item) { + // Показываем сразу, но не сохраняем молча: список ещё можно поправить + // руками, а «Сохранить» закрепит его. + tags = (d.item.aliases || []).slice(); + it.aliases = tags.slice(); + drawTags(); + } + } finally { btn.disabled = false; btn.textContent = was; } + }; + q('.eDel').onclick = async () => { if (!confirm('Удалить «' + (it.name || '') + '» из памяти?')) return; const d = await api('/api/item/' + encodeURIComponent(it.id), {method:'DELETE'}); diff --git a/tests/test_all.py b/tests/test_all.py index 62a6b58..4bccd5e 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -116,6 +116,31 @@ r.check("пустое имя — 400", r.check("занятое имя — 400", c.open("/api/item/" + item, method="PATCH", json={"name": "лопата"}).status_code == 400) +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"]) + 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"]