# hlamingo — помощник «где что лежит»
Раскладывать хлам по коробкам бесполезно, если потом не можешь найти нужное:
проще заказать деталь заново, чем искать. hlamingo помнит, что и куда положено,
и находит по названию, синонимам и маркировке — с телефона, стоя у полки.
## Что нужно
- **Windows** и **Python 3.9+** ([python.org](https://www.python.org/downloads/),
при установке отметить «Add python.exe to PATH»).
- **[Ollama](https://ollama.com)** — на этом же компьютере или на любой машине
в локальной сети. Без него приложение тоже работает: остаются кнопки, места,
коробки, фото и поиск по названию и тегам; отключаются только разбор
свободных фраз, автоматические теги и поиск по смыслу.
Модели (на машине с Ollama):
```
ollama pull qwen2.5:7b разбор фраз и теги
ollama pull nomic-embed-text поиск по смыслу (необязательно)
```
## Установка
Положите `install.cmd` в пустую папку и запустите — он скачает проект, создаст
виртуальное окружение, поставит зависимости, спросит адрес Ollama и сделает
ярлык на рабочем столе.
Если проект уже скачан:
```
setup.cmd один раз: создаёт venv и ставит Flask ВНУТРЬ него
start.cmd запускает сервер и печатает адреса для телефона
```
Глобальный Python не затрагивается — всё живёт в `venv\` внутри проекта.
## Запуск
`start.cmd` печатает адрес, который надо открыть на телефоне, — вида
`http://192.168.1.50:5000`. На самом компьютере — `http://localhost:5000`.
**При первом запуске Windows спросит про брандмауэр — разрешить для частной
сети.** Иначе телефон до сервера не достучится, а выглядеть это будет как
«страница не грузится».
## Настройки
**Проще всего — в браузере:** вкладка **⚙**. Там указывается адрес Ollama,
кнопка «Проверить связь» говорит, отвечает ли он и сколько там моделей, а модели
выбираются из списка реально установленных. Сохранённое применяется сразу,
перезапуск не нужен. Если Ollama не отвечает, наверху появляется предупреждение
со ссылкой прямо в настройки.
Инсталлятор спрашивает адрес в консоли при установке, но это можно пропустить
(Enter) и задать потом в браузере.
Под капотом всё это — файл `.env` в корне проекта (образец — `.env.example`);
в репозиторий он не попадает. Переменные окружения имеют приоритет над файлом.
| Переменная | По умолчанию | Зачем |
|---|---|---|
| `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama |
| `HLAMINGO_CHAT_MODEL` | `qwen2.5:7b` | разбор фраз и теги |
| `HLAMINGO_EMBED_MODEL` | `nomic-embed-text:latest` | поиск по смыслу |
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
Из браузера правятся только `OLLAMA_HOST` и две модели — по белому списку.
Произвольные ключи в `.env` через веб записать нельзя: этот файл читает
приложение при запуске. Адрес проверяется на формат, имена моделей — на
недопустимые символы, и если хоть одно значение неверно, не применяется
ни одно (иначе половина настроек принята, половина отвергнута).
## Как пользоваться
### Вкладка «Действие» — четыре кнопки
| Кнопка | Что делает |
|---|---|
| **Положить** | Выбираешь место, выбираешь коробку, вписываешь предмет |
| **Забрать** | Вещь уходит «на руках» — больше не числится в коробке |
| **Переместить** | Вещь переезжает в другую коробку или место |
| **Удалить** | Убирает запись совсем |
Найденное и содержимое коробок показывается плиткой, как витрина в магазине:
сколько колонок влезает в экран, столько и будет. Кнопки на карточках —
иконками: 📷 снять, 🖼 из галереи, ✏️ изменить, 🗑 убрать фото. При правке
карточка растягивается на всю ширину, чтобы поля были нормального размера.
Ввод структурный: **место → ёмкость → предмет**. Место и коробку выбираешь из
списков, поэтому ИИ в самом ответственном месте не участвует и ошибиться
разбором фразы негде. Новое место добавляется *внутрь* выбранного — так и
строится «Гараж → Стеллаж А → Полка 2». Коробки маркируются кодом «буква+цифра».
Кнопка **«Забрать»** важнее, чем кажется: без неё взятая и израсходованная
деталь продолжала бы числиться в коробке, и поиск отправлял бы копать пустое место.
### Правка
Всё можно поправить и удалить — предмет, ёмкость, место.
- **Предмет.** Кнопка **«✏️ Изменить»** на карточке: название, количество,
заметка и теги. Теги снимаются нажатием на «×», новые добавляются полем
внизу — модель регулярно придумывает мусор вроде «электротool Makita»,
и его надо убирать руками. По тегам вещь и находится, так что состав важен.
- **Ёмкость.** В «Обзоре» нажмите коробку → «✏️ Изменить»: код, тип, место,
заметка. **Смена кода безопасна** — ссылки вещей переезжают вместе с ней.
- **Место.** В «Обзоре» нажмите на название места (у него значок ✏️):
переименовать или перенести внутрь другого.
Кнопка **«🗑 Фото»** убирает снимок и удаляет файл с диска, чтобы папка
не копила мусор.
Удаление устроено так, чтобы записи о вещах не пропадали молча:
| Что удаляем | Что происходит |
|---|---|
| Предмет | Удаляется вместе со своим фото |
| Коробка | Удаляется, а вещи из неё остаются в её месте — не теряются |
| Место | Только пустое. Иначе покажет, что внутри, и не тронет |
Иерархия защищена от петель: место нельзя перенести внутрь вложенного в него
или внутрь самого себя — иначе сборка адреса зацикливалась бы.
### Свободная фраза
Под формой есть «Или просто напиши фразой» — для быстрого ввода:
```
положил стабилизаторы 7805, 12 штук, в коробку A3 на второй полке стеллажа А в гараже
переложил дрель из коробки A1 в коробку C2
где мультиметр
что в коробке A3
```
Модель разбирает фразу за ~1,5 с, места из неё создаются сами.
### Фото
Две кнопки: **«📷 Снять»** открывает камеру, **«🖼»** — готовые снимки в галерее.
Обе есть и в форме заведения вещи (с превью, фото прицепится к новой записи),
и на карточке любой вещи в «Найти» и «Обзоре». У коробки тоже.
**Почему две кнопки, а не одна.** С атрибутом `capture` телефон открывает
камеру, но галерея недоступна. Без `capture` Android 13+ показывает системный
Photo Picker, в котором опции «Камера» нет вообще — только готовые файлы.
Одним полем оба сценария не покрыть, поэтому в разметке два ``:
с `capture` и без.
Снимок ужимается в браузере до 1280 px, файлы лежат в `static\photos\`.
Новое фото заменяет прежнее; «🗑 Фото» убирает снимок и удаляет файл с диска.
`getUserMedia` не используется — он требует HTTPS и по локальному адресу
`http://192.168.0.x:5000` просто не запустился бы.
## Как работает поиск
Три сигнала, по убыванию надёжности:
1. **По словам** — основа. Запрос и карточка режутся на слова, слова грубо
приводятся к основе, так что «стабилизаторы», «стабилизатора» и
«стабилизатор» — одно и то же. Работает всегда, даже без Ollama.
2. **Синонимы от модели.** При записи новой вещи модель придумывает
альтернативные названия: «изолента» → «изоляционная лента», «ПВХ-лента»;
«Стабилизатор 7805» → «LM7805», «стабилизатор 5 вольт», «КР142ЕН5А».
Они лежат в карточке и расширяют словарь предмета.
3. **По смыслу (эмбеддинги)** — только если по словам ничего не нашлось,
с высоким порогом и с пометкой «похоже по смыслу».
Почему семантика на третьем месте, а не на первом: замер на `nomic-embed-text`
показал, что на русском он почти не разделяет смыслы — «велосипедная камера»
давала мультиметру 0.689, а верное попадание на «паяльник» — 0.691. Порогом
такое не разделить, поэтому лучше честное «не нашёл», чем уверенно показанное
не то. Хочется настоящего смыслового поиска — нужен многоязычный эмбеддер:
```
ollama pull bge-m3
```
затем в `hlamingo\config.py` сменить `EMBED_MODEL`, обнулить `EMBED_DOC_PREFIX`
и `EMBED_QUERY_PREFIX` и **один раз пересчитать векторы** — `search.reindex(db)`.
Пересчёт обязателен: векторы разных моделей несовместимы, и смешанная база
тихо испортит поиск, ничем себя не выдав.
## Что если Ollama выключен
**Приложение работает и без Ollama вообще** — можно поставить и пользоваться,
не устанавливая её. Наверху появится предупреждение со ссылкой в настройки,
а всё основное останется на месте.
| | Ollama есть | Ollama нет |
|---|---|---|
| Кнопки, места, коробки, фото, обзор | да | да |
| Поиск по названию и синонимам | да | да |
| Синонимы для новых вещей | да | нет |
| Разбор свободных фраз | модель | упрощённо, регулярками |
| Поиск по смыслу | да | нет |
**Ожидания это не добавляет.** Если машина с Ollama выключена, пакеты уходят
в никуда, и без защиты каждый вызов честно ждал бы свой таймаут: замер показал
92 секунды на запись одной вещи и 154 на разбор фразы — выглядит как зависание.
Поэтому в `ollama.py` стоит предохранитель: перед дорогим запросом идёт быстрая
проверка соединения (полторы секунды вместо двух минут), а после неудачи
приложение полминуты вообще не тревожит сервер. Замер после: те же операции
отвечают мгновенно. Смена адреса в настройках сбрасывает паузу сразу.
Модели: `qwen2.5:7b` для разбора фраз (в конфиге — `CHAT_MODEL`).
Не `omnicoder-9b`: тот ризонер, пишет `` прямо в ответ и медленнее.
## Устройство
```
hlamingo\
config.py адрес Ollama, модели, пути, порог поиска
ollama.py клиент: chat / embeddings / проверка доступности
store.py места (иерархия), ёмкости, предметы; атомарная запись базы
search.py поиск по словам + смысловая догадка
brain.py разбор фразы моделью, синонимы, фолбэк на регулярки
app.py Flask: маршруты
templates\index.html вся страница
data\db.json база (в .gitignore)
static\photos\ фотографии (в .gitignore)
```
Три сущности вместо плоского «предмет → строка места»:
- **место** — узел иерархии, ссылается на родителя;
- **ёмкость** — коробка с кодом, стоит в каком-то месте;
- **предмет** — лежит либо в ёмкости, либо прямо в месте (лопата в углу),
либо «на руках».
Полный адрес собирается подъёмом по родителям: `Гараж → Стеллаж А → Полка 2 →
коробка A3`. У каждого предмета хранится история перемещений.
Мелочи, которые заметно экономят нервы:
- **Коды коробок терпимы к раскладке.** Коробка подписана от руки «А3»
кириллицей, а в поиске набирается «A3» латиницей — для hlamingo это одна
и та же коробка.
- **Резервные копии.** Перед каждой перезаписью прошлое состояние базы уходит
в `data\backups\db-ГГГГММДД-ЧЧММ.json`, хранятся последние 40. Откат — просто
скопировать нужный файл обратно в `data\db.json`. Полноценный бэкап папки они
не заменяют, но спасают от затёртой или испорченной базы: однажды она уже
потерялась целиком, и откатываться было некуда.
- **Запись базы атомарная** (временный файл + подмена): обрыв на середине
не уничтожит всё, что записано про вещи.
- **Битая база не роняет приложение** — запускается с пустой, файл не трогает.
- **Имена файлов фото генерируются сервером**, имя от клиента не используется.
## Ограничения
- Аутентификации нет: помощник рассчитан на домашнюю локальную сеть.
- Количество не пересчитывается при «забрать» — состояние вещи целиком
«в коробке» или «на руках».
- Фото не распознаётся, это иллюстрация. Автоопределение содержимого потребует
vision-модели (`llava`, `qwen2.5-vl`) — на сервере такой сейчас нет.
## Ярлык на телефоне
Откройте страницу и в меню браузера выберите «Добавить на главный экран».
Ярлык получит иконку коробки, а приложение откроется без адресной строки
(`display: standalone` в манифесте).
Иконки лежат в `static\` и рисуются генератором — при желании перерисовать:
```
venv\Scripts\python.exe tools\make_icons.py
```
`tools\make_icons.py` пишет PNG своим минимальным энкодером, без Pillow:
иконки нужны один раз, и зависимость ради них в проекте лишняя. Содержимое
держится внутри центральных 80 % — Android обрезает иконку по маске
(круг, скруглённый квадрат), и края могут срезаться.
Что зачем нужно:
| Файл | Кому |
|---|---|
| `manifest.webmanifest` | Android: имя, иконка ярлыка, запуск без адресной строки |
| `apple-touch-icon.png` | iOS: иконка ярлыка (манифест там не используется) |
| `icon-192.png`, `icon-512.png` | иконки из манифеста |
| `favicon-32.png` | вкладка браузера и запрос `/favicon.ico` |
Манифест отдаётся отдельным маршрутом с типом `application/manifest+json`:
из `static/` он ушёл бы как обычный файл, и браузер мог не признать его
манифестом.
## Тесты
```
tests\run.cmd
```
56 проверок: иерархия мест, ёмкости, поиск, правка, фото, разбор фраз.
Часть требует Ollama — без него они пропускаются, а не падают.
**Тесты работают только в песочнице.** `tests\run.cmd` выставляет
`HLAMINGO_DATA_DIR` на временную папку, а `tests\guard.py` отказывается
запускаться, если приложение всё же смотрит на настоящие данные.
Это не перестраховка: однажды тест начинался с удаления рабочей папки `data`,
и один прогон уничтожил реальную базу с вещами. Восстановить её не удалось —
`os.replace` не оставляет следов, в корзину `rmtree` не кладёт. Уцелели только
фотографии, потому что их файлы тест не трогал. Поэтому путь к данным теперь
берётся из окружения, и никакой тест не может дотянуться до живой базы.
## Отладка
Живо ли Ollama и какие модели установлены (подставьте свой адрес):
```
curl http://localhost:11434/api/tags
```
Что об этом думает само приложение: `http://localhost:5000/api/health` —
отдаёт адрес Ollama, выбранную модель и доступность.