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:
+2
-2
@@ -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
|
||||
|
||||
# Модель для эмбеддингов (поиск по смыслу). Необязательна — без неё
|
||||
# работает поиск по словам и тегам:
|
||||
|
||||
@@ -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`; используется тестами |
|
||||
|
||||
@@ -479,6 +479,43 @@ def api_edit_item(item_id):
|
||||
"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"])
|
||||
def api_edit_container(code):
|
||||
db = store.load()
|
||||
|
||||
+33
-3
@@ -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]
|
||||
|
||||
+11
-5
@@ -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 — не ризонер: не тратит время на <think> и надёжнее держит формат 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. Замер на одном и том же складе:
|
||||
|
||||
+8
-1
@@ -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():
|
||||
|
||||
+39
-2
@@ -712,11 +712,16 @@ function itemEditor(it, cardEl) {
|
||||
+ '<label>Заметка</label><input class="eNote">'
|
||||
+ '<label>Теги для поиска</label><div class="tags eTags"></div>'
|
||||
+ '<input class="eNewTag mt" placeholder="добавить тег и нажать Enter">'
|
||||
+ '<div class="acts">'
|
||||
+ '<button class="eRetag">🔄 Пересоздать теги</button>'
|
||||
+ '</div>'
|
||||
+ '<p class="editHint">Тег убирается нажатием на ×. По тегам вещь и находится, '
|
||||
+ 'так что лишние лучше убрать, а свои — добавить.</p>'
|
||||
+ 'так что лишние лучше убрать, а свои — добавить. «Пересоздать» просит модель '
|
||||
+ 'придумать их заново — полезно после смены модели.</p>'
|
||||
+ '<div class="acts">'
|
||||
+ '<button class="eSave">Сохранить</button>'
|
||||
+ '<button class="eDel warn">🗑 Удалить запись</button>'
|
||||
+ '<button class="eCancel">Отмена</button>'
|
||||
+ '<button class="eDel warn">🗑 Удалить</button>'
|
||||
+ '</div>';
|
||||
|
||||
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'});
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user