qwen3:8b вместо qwen2.5, отмена правки и пересоздание тегов

Замер на наших задачах (разбор 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:51:13 +03:00
co-authored by Claude Opus 5
parent 39c807d4de
commit 01d7f17f89
8 changed files with 197 additions and 16 deletions
+2 -2
View File
@@ -6,8 +6,8 @@
OLLAMA_HOST=http://localhost:11434 OLLAMA_HOST=http://localhost:11434
# Модель для разбора свободных фраз. Должна быть установлена в Ollama: # Модель для разбора свободных фраз. Должна быть установлена в Ollama:
# ollama pull qwen2.5:7b # ollama pull qwen3:8b
# HLAMINGO_CHAT_MODEL=qwen2.5:7b # HLAMINGO_CHAT_MODEL=qwen3:8b
# Модель для эмбеддингов (поиск по смыслу). Необязательна — без неё # Модель для эмбеддингов (поиск по смыслу). Необязательна — без неё
# работает поиск по словам и тегам: # работает поиск по словам и тегам:
+42 -3
View File
@@ -16,10 +16,49 @@
Модели (на машине с Ollama): Модели (на машине с Ollama):
``` ```
ollama pull qwen2.5:7b разбор фраз и теги ollama pull qwen3:8b разбор фраз и теги
ollama pull bge-m3 поиск по смыслу (необязательно) 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` в пустую папку и запустите — он скачает проект, создаст Положите `install.cmd` в пустую папку и запустите — он скачает проект, создаст
@@ -61,7 +100,7 @@ start.cmd запускает сервер и печатает адреса
| Переменная | По умолчанию | Зачем | | Переменная | По умолчанию | Зачем |
|---|---|---| |---|---|---|
| `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama | | `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama |
| `HLAMINGO_CHAT_MODEL` | `qwen2.5:7b` | разбор фраз и теги | | `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги |
| `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу | | `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу |
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса | | `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами | | `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
+37
View File
@@ -479,6 +479,43 @@ def api_edit_item(item_id):
"reply": "Обновил: %s." % ", ".join(changed)}) "reply": "Обновил: %s." % ", ".join(changed)})
@app.route("/api/item/<item_id>/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/<code>", methods=["PATCH"]) @app.route("/api/containers/<code>", methods=["PATCH"])
def api_edit_container(code): def api_edit_container(code):
db = store.load() db = store.load()
+33 -3
View File
@@ -301,11 +301,41 @@ def aliases(name, note=None, model=None):
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},
], num_predict=256) ], num_predict=256)
data = _extract_json(content, "[", "]") if content else None 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): if not isinstance(data, list):
return [] return []
out = [] out = []
for value in data: for value in data:
value = str(value).strip() # Вложенный объект — берём из него что-то похожее на название.
if value and value.lower() != name.lower() and value not in out: if isinstance(value, dict):
out.append(value) 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] return out[:7]
+11 -5
View File
@@ -140,11 +140,17 @@ def _write_env(values):
# set OLLAMA_HOST=http://192.168.1.50:11434 # set OLLAMA_HOST=http://192.168.1.50:11434
OLLAMA_HOST = (os.environ.get("OLLAMA_HOST") or "http://localhost:11434").rstrip("/") OLLAMA_HOST = (os.environ.get("OLLAMA_HOST") or "http://localhost:11434").rstrip("/")
# qwen2.5 — не ризонер: не тратит время на <think> и надёжнее держит формат JSON, # qwen3:8b — русский заметно чище, чем у qwen2.5:7b. Замер на наших же задачах
# а нам от модели нужен именно строгий JSON, а не рассуждения. # (разбор фраз + генерация тегов для русских предметов):
# omnicoder-9b — запасной вариант, если qwen начнёт путаться в русских фразах. # qwen2.5:7b разбор 8/8, но в тегах «электротool Makita», «вoltage meter»,
CHAT_MODEL = os.environ.get("HLAMINGO_CHAT_MODEL") or "qwen2.5:7b" # «многофункциональный мера» — модель думает по-английски
CHAT_MODEL_ALT = "carstenuhlig/omnicoder-9b" # и переводит на ходу;
# 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 — многоязычная модель, на русском заметно лучше прежней # bge-m3 — многоязычная модель, на русском заметно лучше прежней
# nomic-embed-text. Замер на одном и том же складе: # nomic-embed-text. Замер на одном и том же складе:
+8 -1
View File
@@ -112,16 +112,23 @@ def models():
return [] 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 при любой беде. """Один запрос к /api/chat. Возвращает content или None при любой беде.
num_predict обязателен: без капа модель может уйти в длинную генерацию num_predict обязателен: без капа модель может уйти в длинную генерацию
и подвесить ход на минуты а нам нужен короткий JSON. и подвесить ход на минуты а нам нужен короткий JSON.
think=False выключает режим размышлений у моделей-ризонеров (qwen3,
deepseek-r1 и подобных). Без этого они тратят весь бюджет ответа на
внутренний монолог и возвращают пустой content проверено на qwen3:8b.
Модели без такого режима поле просто игнорируют.
""" """
payload = { payload = {
"model": model, "model": model,
"messages": messages, "messages": messages,
"stream": False, "stream": False,
"think": think,
"options": {"num_predict": num_predict, "temperature": 0}, "options": {"num_predict": num_predict, "temperature": 0},
} }
if not available(): if not available():
+39 -2
View File
@@ -712,11 +712,16 @@ function itemEditor(it, cardEl) {
+ '<label>Заметка</label><input class="eNote">' + '<label>Заметка</label><input class="eNote">'
+ '<label>Теги для поиска</label><div class="tags eTags"></div>' + '<label>Теги для поиска</label><div class="tags eTags"></div>'
+ '<input class="eNewTag mt" placeholder="добавить тег и нажать Enter">' + '<input class="eNewTag mt" placeholder="добавить тег и нажать Enter">'
+ '<div class="acts">'
+ '<button class="eRetag">🔄 Пересоздать теги</button>'
+ '</div>'
+ '<p class="editHint">Тег убирается нажатием на ×. По тегам вещь и находится, ' + '<p class="editHint">Тег убирается нажатием на ×. По тегам вещь и находится, '
+ 'так что лишние лучше убрать, а свои — добавить.</p>' + 'так что лишние лучше убрать, а свои — добавить. «Пересоздать» просит модель '
+ 'придумать их заново — полезно после смены модели.</p>'
+ '<div class="acts">' + '<div class="acts">'
+ '<button class="eSave">Сохранить</button>' + '<button class="eSave">Сохранить</button>'
+ '<button class="eDel warn">🗑 Удалить запись</button>' + '<button class="eCancel">Отмена</button>'
+ '<button class="eDel warn">🗑 Удалить</button>'
+ '</div>'; + '</div>';
const q = s => box.querySelector(s); const q = s => box.querySelector(s);
@@ -768,6 +773,38 @@ function itemEditor(it, cardEl) {
} finally { btn.disabled = false; } } 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 () => { q('.eDel').onclick = async () => {
if (!confirm('Удалить «' + (it.name || '') + '» из памяти?')) return; if (!confirm('Удалить «' + (it.name || '') + '» из памяти?')) return;
const d = await api('/api/item/' + encodeURIComponent(it.id), {method:'DELETE'}); const d = await api('/api/item/' + encodeURIComponent(it.id), {method:'DELETE'});
+25
View File
@@ -116,6 +116,31 @@ r.check("пустое имя — 400",
r.check("занятое имя — 400", r.check("занятое имя — 400",
c.open("/api/item/" + item, method="PATCH", json={"name": "лопата"}).status_code == 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== Фото ==") print("\n== Фото ==")
d = upload("item", item) d = upload("item", item)
fname = d["photo"] fname = d["photo"]