Files
hlamingo/hlamingo/config.py
T
alexandClaude Opus 5 bae5116d77 Подсказка названия по фотографии
Фото выбрано в форме — приложение показывает его модели и предлагает название
с прочитанными надписями. «Подставить» переносит в поля, «Не надо» прячет.
Само не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель назвала
«конструктором 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>
2026-07-31 01:24:49 +03:00

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"}