Фото выбрано в форме — приложение показывает его модели и предлагает название
с прочитанными надписями. «Подставить» переносит в поля, «Не надо» прячет.
Само не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель назвала
«конструктором 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 <noreply@anthropic.com>
225 lines
12 KiB
Python
225 lines
12 KiB
Python
"""Конфигурация: адрес Ollama, модели, пути.
|
|
|
|
Настройки берутся из переменных окружения, а те, что не заданы, — из файла
|
|
`.env` в корне проекта (см. `.env.example`). Так личные адреса не попадают
|
|
в репозиторий.
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
_PROJECT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def _load_env_file():
|
|
"""Подхватывает `.env`: строки вида КЛЮЧ=значение, «#» — комментарий.
|
|
|
|
Свой парсер вместо python-dotenv: формат тривиальный, а лишняя зависимость
|
|
в проекте, который ставится одним батником, только усложняет установку.
|
|
Настоящие переменные окружения имеют приоритет — их не перетираем.
|
|
"""
|
|
path = _PROJECT / ".env"
|
|
if not path.exists():
|
|
return
|
|
try:
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
key, value = key.strip(), value.strip().strip('"').strip("'")
|
|
if key and key not in os.environ:
|
|
os.environ[key] = value
|
|
except Exception:
|
|
pass # битый .env не должен мешать запуску
|
|
|
|
|
|
_load_env_file()
|
|
|
|
# Своя папка для данных, если задана. Определяем здесь, потому что от неё
|
|
# зависит и путь к .env.
|
|
_ROOT_OVERRIDE = Path(os.environ["HLAMINGO_DATA_DIR"]) \
|
|
if os.environ.get("HLAMINGO_DATA_DIR") else None
|
|
|
|
# Файл настроек. В песочнице он тоже свой: иначе тест перезаписал бы рабочий
|
|
# .env — и однажды уже перезаписал.
|
|
ENV_FILE = (_ROOT_OVERRIDE / ".env") if _ROOT_OVERRIDE else (_PROJECT / ".env")
|
|
|
|
# Что разрешено менять из браузера. Белый список, а не любые ключи: в .env
|
|
# нельзя позволять писать произвольные строки — этот файл читает приложение
|
|
# при запуске.
|
|
EDITABLE = ("OLLAMA_HOST", "HLAMINGO_CHAT_MODEL", "HLAMINGO_EMBED_MODEL")
|
|
|
|
_HOST_RE = None # ленивая компиляция: re нужен только при сохранении
|
|
|
|
|
|
def _validate(key, raw):
|
|
"""Проверяет одно значение и возвращает нормализованное. Бросает ValueError."""
|
|
import re as _re
|
|
|
|
value = str(raw or "").strip()
|
|
if key == "OLLAMA_HOST":
|
|
if not _re.match(r"^https?://[\w.\-]+(:\d{1,5})?/?$", value):
|
|
raise ValueError("Адрес Ollama должен быть вида http://хост:11434")
|
|
return value.rstrip("/")
|
|
if value and not _re.match(r"^[\w./:\-]+$", value):
|
|
raise ValueError("Недопустимое имя модели: %s" % value)
|
|
return value
|
|
|
|
|
|
def save_settings(values):
|
|
"""Пишет настройки в .env и применяет их на ходу.
|
|
|
|
Перезапуск не нужен: остальные модули читают значения через `config.X`,
|
|
а не забирают копию при импорте. Возвращает список изменённых ключей.
|
|
|
|
Сначала проверяем ВСЁ, и только потом применяем: иначе неверная модель
|
|
оставляла бы уже подменённый адрес Ollama — половина настроек применена,
|
|
половина отвергнута.
|
|
"""
|
|
checked = {}
|
|
for key, raw in (values or {}).items():
|
|
if key in EDITABLE:
|
|
checked[key] = _validate(key, raw)
|
|
|
|
changed = [key for key, value in checked.items() if _apply(key, value)]
|
|
if changed:
|
|
_write_env({key: current(key) for key in EDITABLE})
|
|
return changed
|
|
|
|
|
|
def current(key):
|
|
"""Текущее значение настройки по имени переменной окружения."""
|
|
return {"OLLAMA_HOST": OLLAMA_HOST,
|
|
"HLAMINGO_CHAT_MODEL": CHAT_MODEL,
|
|
"HLAMINGO_EMBED_MODEL": EMBED_MODEL}.get(key, "")
|
|
|
|
|
|
def _apply(key, value):
|
|
"""Обновляет значение в этом модуле. True, если оно действительно менялось."""
|
|
global OLLAMA_HOST, CHAT_MODEL, EMBED_MODEL
|
|
if key == "OLLAMA_HOST" and value != OLLAMA_HOST:
|
|
OLLAMA_HOST = value
|
|
elif key == "HLAMINGO_CHAT_MODEL" and value and value != CHAT_MODEL:
|
|
CHAT_MODEL = value
|
|
elif key == "HLAMINGO_EMBED_MODEL" and value and value != EMBED_MODEL:
|
|
EMBED_MODEL = value
|
|
else:
|
|
return False
|
|
os.environ[key] = value
|
|
return True
|
|
|
|
|
|
def _write_env(values):
|
|
"""Перезаписывает .env, сохраняя комментарии и посторонние строки."""
|
|
if ENV_FILE.exists():
|
|
lines = ENV_FILE.read_text(encoding="utf-8").splitlines()
|
|
else:
|
|
lines = ["# Локальные настройки этой установки. В репозиторий не попадает."]
|
|
|
|
seen, out = set(), []
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
key = stripped.split("=", 1)[0].strip() if "=" in stripped else ""
|
|
if key in values and not stripped.startswith("#"):
|
|
out.append("%s=%s" % (key, values[key]))
|
|
seen.add(key)
|
|
else:
|
|
out.append(line)
|
|
for key, value in values.items():
|
|
if key not in seen and value:
|
|
out.append("%s=%s" % (key, value))
|
|
|
|
ENV_FILE.write_text("\n".join(out) + "\n", encoding="utf-8")
|
|
|
|
# ============================================================
|
|
# Ollama
|
|
# ============================================================
|
|
# Адрес Ollama. Свой задаётся переменной окружения OLLAMA_HOST — в коде держим
|
|
# нейтральный дефолт, чтобы не зашивать в репозиторий адреса чужой сети.
|
|
# set OLLAMA_HOST=http://192.168.1.50:11434
|
|
OLLAMA_HOST = (os.environ.get("OLLAMA_HOST") or "http://localhost:11434").rstrip("/")
|
|
|
|
# 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" # быстрее, но русский хуже
|
|
|
|
# Модель, которая смотрит на фотографии: подсказывает название вещи и читает
|
|
# надписи с упаковки. Пустая строка — распознавание выключено.
|
|
#
|
|
# 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, а верный
|
|
# ответ на «паяльник» — 0.691. Зазор −0.013, порогом не
|
|
# разделить: мусор всегда обгонял правильное.
|
|
# bge-m3: верные не ниже 0.537, весь мусор не выше 0.460.
|
|
# Зазор +0.077, порог 0.50 работает.
|
|
# При смене модели нужен полный пересчёт: search.reindex(db). Векторы разных
|
|
# моделей несовместимы, и смешанная база тихо испортит поиск.
|
|
EMBED_MODEL = os.environ.get("HLAMINGO_EMBED_MODEL") or "bge-m3"
|
|
# bge-m3 префиксов задачи не требует — в отличие от nomic-embed-text, которому
|
|
# были нужны разные приставки для документа и запроса.
|
|
EMBED_DOC_PREFIX = ""
|
|
EMBED_QUERY_PREFIX = ""
|
|
|
|
CHAT_TIMEOUT = 120 # разбор одной фразы; больше не нужно
|
|
EMBED_TIMEOUT = 30
|
|
MAX_OUTPUT_TOKENS = 512 # кап на ответ: разбор фразы — это короткий JSON
|
|
|
|
# ============================================================
|
|
# Поиск
|
|
# ============================================================
|
|
TOP_K = 5
|
|
MIN_SCORE = 0.5 # нижняя граница для смысловой догадки; см. search.py
|
|
|
|
# ============================================================
|
|
# Пути
|
|
# ============================================================
|
|
PROJECT_DIR = _PROJECT
|
|
TEMPLATES_DIR = PROJECT_DIR / "templates"
|
|
|
|
# Где живут данные. Переопределяется переменной окружения — и это не удобство,
|
|
# а защита: тесты ОБЯЗАНЫ подставить свою папку, иначе затрут настоящую базу.
|
|
# Один раз так уже потерялись реальные записи, потому что тест начинался
|
|
# с удаления рабочей папки data.
|
|
_ROOT = _ROOT_OVERRIDE or PROJECT_DIR
|
|
DATA_DIR = _ROOT / "data"
|
|
DB_FILE = DATA_DIR / "db.json"
|
|
PHOTOS_DIR = (_ROOT / "photos") if _ROOT_OVERRIDE else (PROJECT_DIR / "static" / "photos")
|
|
|
|
# True, когда работаем на настоящих данных пользователя. Тесты это проверяют
|
|
# и отказываются что-либо удалять, если флаг стоит.
|
|
IS_LIVE_DATA = _ROOT_OVERRIDE is None
|
|
|
|
# ============================================================
|
|
# Веб
|
|
# ============================================================
|
|
HOST = "0.0.0.0" # чтобы достучаться с телефона по локалке
|
|
try:
|
|
PORT = int(os.environ.get("HLAMINGO_PORT") or 5000)
|
|
except ValueError:
|
|
PORT = 5000
|
|
MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # фото жмётся на клиенте; 8 МБ — щедрый запас
|
|
ALLOWED_PHOTO_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|