From 01d7f17f8937b6066ce95fd96da9ca90aac7d9a6 Mon Sep 17 00:00:00 2001
From: Alex
Date: Thu, 30 Jul 2026 18:51:13 +0300
Subject: [PATCH] =?UTF-8?q?qwen3:8b=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?=
=?UTF-8?q?=20qwen2.5,=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF?=
=?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=B8=20=D0=BF=D0=B5=D1=80?=
=?UTF-8?q?=D0=B5=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=82?=
=?UTF-8?q?=D0=B5=D0=B3=D0=BE=D0=B2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Замер на наших задачах (разбор 8 фраз + теги для 6 предметов):
qwen2.5:7b разбор 8/8, но теги «электротool Makita», «вoltage meter»
(первая буква кириллическая), «многофункциональный мера» —
модель думает по-английски и переводит на ходу;
qwen3:8b разбор 8/8, теги на нормальном русском.
Плата — примерно на треть медленнее на тегах. Теги важнее: по ним вещь
и находится.
Поддержка моделей-ризонеров: ollama.chat получил think=False. Без него
qwen3 тратит весь бюджет ответа на внутренний монолог и возвращает пустой
content — на первом прогоне модель выглядела нерабочей.
Разбор ответа с тегами укреплён: qwen3 иногда присылает объект
{"name":..., "synonyms":[...]} вместо списка строк, и он попадал в теги
целиком как строка. Теперь достаётся вложенный список, отсеиваются дубликаты
в разном регистре, вложенные объекты, bool и слишком длинные строки.
По замечаниям Алекса в правке предмета:
- кнопка «Отмена» — раньше открытую форму можно было только сохранить или
удалить вещь целиком;
- кнопка «🔄 Пересоздать теги» — после смены модели теги остаются старыми,
и это способ обновить их, не перезаводя вещь. Новый список показывается,
но не сохраняется молча: его ещё можно поправить руками.
В README добавлен раздел «Какую модель выбрать» с замерами и требованиями
к своей модели для тех, кто ставит приложение себе.
Co-Authored-By: Claude Opus 5
---
.env.example | 4 ++--
README.md | 45 +++++++++++++++++++++++++++++++++++++++++---
hlamingo/app.py | 37 ++++++++++++++++++++++++++++++++++++
hlamingo/brain.py | 36 ++++++++++++++++++++++++++++++++---
hlamingo/config.py | 16 +++++++++++-----
hlamingo/ollama.py | 9 ++++++++-
templates/index.html | 41 ++++++++++++++++++++++++++++++++++++++--
tests/test_all.py | 25 ++++++++++++++++++++++++
8 files changed, 197 insertions(+), 16 deletions(-)
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"]