Раньше карточки шли одна под другой, и коробка с двумя десятками вещей превращалась в длинную колонку. Теперь сетка на CSS grid с auto-fill: сколько колонок влезает в экран, столько и будет — на телефоне обычно две, на планшете три-четыре. Чтобы плитка была компактной: - превью квадратные (object-fit: cover) — ряд выглядит ровным; - кнопки только иконками, подписи в них не влезали; что означает какая, написано подсказкой под сеткой и в title; - теги в плитке скрыты, они её распирали — видны при правке; - карточка с открытой правкой растягивается на всю ширину, иначе поля ввода оказались бы в колонке шириной с ладонь. Один результат сеткой не оформляется: выглядело бы как обрезанная витрина. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
20 KiB
Markdown
309 lines
20 KiB
Markdown
# 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, в котором опции «Камера» нет вообще — только готовые файлы.
|
||
Одним полем оба сценария не покрыть, поэтому в разметке два `<input>`:
|
||
с `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 нет |
|
||
|---|---|---|
|
||
| Кнопки, места, коробки, фото, обзор | да | да |
|
||
| Поиск по названию и синонимам | да | да |
|
||
| Синонимы для новых вещей | да | нет |
|
||
| Разбор свободных фраз | модель | упрощённо, регулярками |
|
||
| Поиск по смыслу | да | нет |
|
||
|
||
Модели: `qwen2.5:7b` для разбора фраз (в конфиге — `CHAT_MODEL`).
|
||
Не `omnicoder-9b`: тот ризонер, пишет `<think>` прямо в ответ и медленнее.
|
||
|
||
## Устройство
|
||
|
||
```
|
||
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, выбранную модель и доступность.
|