From 17b7d49d2f81ca3f7128013c6ee0b6a3c79c149a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 30 Jul 2026 14:27:20 +0300 Subject: [PATCH] =?UTF-8?q?hlamingo=20=E2=80=94=20=D0=BF=D0=BE=D0=BC=D0=BE?= =?UTF-8?q?=D1=89=D0=BD=D0=B8=D0=BA=20=C2=AB=D0=B3=D0=B4=D0=B5=20=D1=87?= =?UTF-8?q?=D1=82=D0=BE=20=D0=BB=D0=B5=D0=B6=D0=B8=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Веб-помощник для домашнего склада: помнит, в какой коробке и в каком месте лежит вещь, и находит её по названию, тегам и маркировке. Открывается с телефона, интеллект — локальная Ollama. Основное: - структурный ввод «место → ёмкость → предмет» и кнопки Положить / Забрать / Переместить / Удалить; - иерархия мест (Гараж → Стеллаж А → Полка 2), ёмкости с кодом «буква+цифра», состояние «на руках» для взятых вещей; - поиск по словам с учётом русских окончаний, теги от модели расширяют словарь предмета, смысловой поиск — вспомогательный (замеры в search.py); - фото к вещам и коробкам: съёмка и выбор из галереи; - правка и удаление всех сущностей, удаление коробки не теряет её содержимое; - работает и без Ollama: отключаются только разбор фраз, теги и поиск по смыслу. Данные (data/, static/photos/) и локальный конфиг (.env) в репозиторий не попадают. Тесты работают только в песочнице: путь к данным берётся из окружения, tests/guard.py отказывается запускаться на живых данных. Co-Authored-By: Claude Opus 5 --- .env.example | 18 + .gitattributes | 18 + .gitignore | 27 ++ LICENSE | 21 + README.md | 288 ++++++++++++ hlamingo/__init__.py | 1 + hlamingo/app.py | 631 ++++++++++++++++++++++++++ hlamingo/brain.py | 309 +++++++++++++ hlamingo/config.py | 104 +++++ hlamingo/ollama.py | 85 ++++ hlamingo/search.py | 174 ++++++++ hlamingo/store.py | 557 +++++++++++++++++++++++ install.cmd | 189 ++++++++ requirements.txt | 1 + setup.cmd | 37 ++ start.cmd | 28 ++ static/apple-touch-icon.png | Bin 0 -> 608 bytes static/favicon-32.png | Bin 0 -> 157 bytes static/icon-192.png | Bin 0 -> 640 bytes static/icon-512.png | Bin 0 -> 2620 bytes static/manifest.webmanifest | 25 ++ templates/index.html | 864 ++++++++++++++++++++++++++++++++++++ tests/guard.py | 59 +++ tests/run.cmd | 22 + tests/test_all.py | 195 ++++++++ tools/make_icons.py | 107 +++++ 26 files changed, 3760 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 hlamingo/__init__.py create mode 100644 hlamingo/app.py create mode 100644 hlamingo/brain.py create mode 100644 hlamingo/config.py create mode 100644 hlamingo/ollama.py create mode 100644 hlamingo/search.py create mode 100644 hlamingo/store.py create mode 100644 install.cmd create mode 100644 requirements.txt create mode 100644 setup.cmd create mode 100644 start.cmd create mode 100644 static/apple-touch-icon.png create mode 100644 static/favicon-32.png create mode 100644 static/icon-192.png create mode 100644 static/icon-512.png create mode 100644 static/manifest.webmanifest create mode 100644 templates/index.html create mode 100644 tests/guard.py create mode 100644 tests/run.cmd create mode 100644 tests/test_all.py create mode 100644 tools/make_icons.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..41229eb --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Пример настроек. Скопируйте в .env и поправьте под себя. +# Файл .env в репозиторий не попадает. + +# Адрес Ollama. Если Ollama на этом же компьютере — оставьте localhost. +# Если на другой машине в локальной сети — укажите её адрес. +OLLAMA_HOST=http://localhost:11434 + +# Модель для разбора свободных фраз. Должна быть установлена в Ollama: +# ollama pull qwen2.5:7b +# HLAMINGO_CHAT_MODEL=qwen2.5:7b + +# Модель для эмбеддингов (поиск по смыслу). Необязательна — без неё +# работает поиск по словам и тегам: +# ollama pull nomic-embed-text +# HLAMINGO_EMBED_MODEL=nomic-embed-text:latest + +# Порт веб-интерфейса. +# HLAMINGO_PORT=5000 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..705e66d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Батникам нужны CRLF: с LF cmd.exe способен повести себя странно — +# например, оборвать последнюю строку или неверно разобрать блок в скобках. +*.cmd text eol=crlf +*.bat text eol=crlf + +# Остальному текстовому — LF в репозитории, нормализация при выгрузке. +*.py text +*.md text +*.html text +*.json text +*.webmanifest text +*.txt text +.env.example text +.gitignore text + +# Иконки не трогать. +*.png binary +*.jpg binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22e1dca --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# ============================================================ +# Личные данные. НИЧЕГО из этого не должно попасть в репозиторий: +# data/ — что где лежит (включая резервные копии), +# static/photos/ — фотографии вещей. +# Иконки в static/ не исключаем: они часть приложения (tools/make_icons.py). +# ============================================================ +data/ +static/photos/ +*.json.tmp + +# Локальные настройки: адрес Ollama, порт +.env + +# Окружение и кеш Python +venv/ +.venv/ +__pycache__/ +*.py[cod] + +# Мусор системы и редакторов +Thumbs.db +desktop.ini +.DS_Store +.vscode/ +.idea/ +*.swp +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..50e8d7b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alex + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a71daec --- /dev/null +++ b/README.md @@ -0,0 +1,288 @@ +# 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 спросит про брандмауэр — разрешить для частной +сети.** Иначе телефон до сервера не достучится, а выглядеть это будет как +«страница не грузится». + +## Настройки + +Все через `.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` | — | другая папка для данных; используется тестами | + +## Как пользоваться + +### Вкладка «Действие» — четыре кнопки + +| Кнопка | Что делает | +|---|---| +| **Положить** | Выбираешь место, выбираешь коробку, вписываешь предмет | +| **Забрать** | Вещь уходит «на руках» — больше не числится в коробке | +| **Переместить** | Вещь переезжает в другую коробку или место | +| **Удалить** | Убирает запись совсем | + +Ввод структурный: **место → ёмкость → предмет**. Место и коробку выбираешь из +списков, поэтому ИИ в самом ответственном месте не участвует и ошибиться +разбором фразы негде. Новое место добавляется *внутрь* выбранного — так и +строится «Гараж → Стеллаж А → Полка 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 нет | +|---|---|---| +| Кнопки, места, коробки, фото, обзор | да | да | +| Поиск по названию и синонимам | да | да | +| Синонимы для новых вещей | да | нет | +| Разбор свободных фраз | модель | упрощённо, регулярками | +| Поиск по смыслу | да | нет | + +Модели: `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, выбранную модель и доступность. diff --git a/hlamingo/__init__.py b/hlamingo/__init__.py new file mode 100644 index 0000000..0347add --- /dev/null +++ b/hlamingo/__init__.py @@ -0,0 +1 @@ +"""hlamingo — помощник, который помнит, куда что положено.""" diff --git a/hlamingo/app.py b/hlamingo/app.py new file mode 100644 index 0000000..dbb6258 --- /dev/null +++ b/hlamingo/app.py @@ -0,0 +1,631 @@ +"""Flask-приложение: маршруты и точка входа.""" + +import uuid +import logging +import webbrowser +import threading + +from flask import Flask, request, jsonify, send_from_directory + +from . import store, search, brain, ollama +from .config import (PROJECT_DIR, TEMPLATES_DIR, PHOTOS_DIR, HOST, PORT, + MAX_UPLOAD_BYTES, ALLOWED_PHOTO_EXT, CHAT_MODEL, OLLAMA_HOST) + +logger = logging.getLogger(__name__) + +app = Flask(__name__, static_folder=str(PROJECT_DIR / "static"), static_url_path="/static") +app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_BYTES + + +# ============================================================ +# Страница +# ============================================================ +@app.route("/") +def index(): + return send_from_directory(str(TEMPLATES_DIR), "index.html") + + +@app.route("/manifest.webmanifest") +def manifest(): + """Манифест отдаём отдельным маршрутом — с правильным MIME-типом. + + Из /static/ Flask отдал бы его как обычный файл, и браузер мог бы + отказаться считать его манифестом, а без манифеста Android не берёт + иконку для ярлыка на домашнем экране. + """ + return send_from_directory(str(PROJECT_DIR / "static"), "manifest.webmanifest", + mimetype="application/manifest+json") + + +@app.route("/favicon.ico") +def favicon(): + """Браузеры просят /favicon.ico сами, независимо от тегов в разметке.""" + return send_from_directory(str(PROJECT_DIR / "static"), "favicon-32.png", + mimetype="image/png") + + +@app.route("/api/health") +def health(): + return jsonify({"ollama": ollama.check(), "host": OLLAMA_HOST, "model": CHAT_MODEL}) + + +# ============================================================ +# Главный маршрут: свободная фраза +# ============================================================ +@app.route("/api/say", methods=["POST"]) +def say(): + text = ((request.get_json(silent=True) or {}).get("text") or "").strip() + if not text: + return jsonify({"reply": "Напиши, что положил или что ищешь."}) + + parsed = brain.parse(text) + db = store.load() + action = parsed.get("action") + + if action == "put": + return jsonify(_do_put(db, parsed)) + if action == "list_container": + return jsonify(_do_list_container(db, parsed.get("container"))) + if action == "list_place": + return jsonify(_do_list_place(db, parsed.get("place_path"))) + return jsonify(_do_find(db, parsed.get("item") or text)) + + +def _do_put(db, parsed): + """Положить по разобранной фразе: места из фразы создаём на лету.""" + if not (parsed.get("items") or parsed.get("item")): + return {"reply": "Не понял, что именно ты положил. Напиши: «положил X в коробку A3»."} + place_id = store.ensure_place_path(db, parsed.get("place_path")) + return _put_all(db, parsed, place_id) + + +def _put_all(db, parsed, place_id): + """Общая часть записи — одна для кнопок и для свободной фразы.""" + names = parsed.get("items") or ([parsed["item"]] if parsed.get("item") else []) + if not names: + return {"ok": False, "reply": "Не понял, что записать."} + + saved, moved = [], [] + + for name in names: + known = store.find_item_by_name(db, name) + # Синонимы просим только для новых вещей: у известной они уже есть, + # а лишний запрос к модели — это лишние секунды ожидания при записи. + new_aliases = [] if known else brain.aliases(name, parsed.get("note")) + item, prev = store.put_item( + db, name, + # Количество относится к одной вещи; на список «паспорт и права» + # его вешать нельзя — получится «2 шт» у каждого. + qty=parsed.get("qty") if len(names) == 1 else None, + container=parsed.get("container"), + place_id=place_id, + note=parsed.get("note"), + aliases=new_aliases, + ) + if item: + saved.append(item) + if prev: + moved.append((item, prev)) + + if not saved: + return {"ok": False, "reply": "Не понял, что записать."} + + search.ensure_embeddings(db) + store.save(db) + + address = store.item_address(db, saved[0]) + listed = ", ".join("«%s»" % it["name"] for it in saved) + if moved and len(saved) == 1: + reply = "Переложил: %s теперь %s (было: %s)." % (listed, address, moved[0][1]) + else: + reply = "Запомнил: %s — %s." % (listed, address) + if len(saved) == 1 and saved[0].get("qty"): + reply += " Количество: %s." % saved[0]["qty"] + + return {"ok": True, "reply": reply, "kind": "put", "item": _card(db, saved[0]), + "items": [_card(db, it) for it in saved], + "aliases": saved[0].get("aliases") or []} + + +def _do_find(db, query): + hits = search.search(db, query) + if not hits: + return {"reply": "Про «%s» ничего не записано." % query, "kind": "find", "items": []} + + cards = [] + for item, score, why in hits: + card = _card(db, item) + card["score"] = round(score, 3) + card["why"] = why + cards.append(card) + + top = cards[0] + reply = "«%s» — %s." % (top["name"], top["address"]) + if top.get("qty"): + reply += " Количество: %s." % top["qty"] + if len(cards) > 1: + reply += " Есть ещё похожее — смотри ниже." + return {"reply": reply, "kind": "find", "items": cards} + + +def _do_list_container(db, code): + key = store.norm_code(code) + if not key or key not in db["containers"]: + return {"reply": "Коробку «%s» не знаю." % (code or "?"), "kind": "list", "items": []} + items = store.container_contents(db, key) + address = store.container_address(db, key) + common = {"kind": "list", "code": key, "photo": db["containers"][key].get("photo")} + if not items: + return dict(common, reply="%s — пока пусто." % address, items=[]) + return dict(common, reply="%s — %d поз.:" % (address, len(items)), + items=[_card(db, it) for it in items]) + + +def _do_list_place(db, path): + name = (path or [None])[-1] + place_id = store.find_place_by_name(db, name) if name else None + if not place_id: + return {"reply": "Место «%s» не знаю." % (name or "?"), "kind": "list", "items": []} + + # Всё, что лежит в этом месте и в любом вложенном. + inside = _descendants(db, place_id) + boxes = [b["code"] for b in db["containers"].values() if b.get("place") in inside] + items = [it for it in db["items"].values() + if it.get("container") in boxes or it.get("place") in inside] + chain = " → ".join(store.place_chain(db, place_id)) + if not items: + return {"reply": "%s — пока ничего не записано." % chain, "kind": "list", "items": []} + return {"reply": "%s — %d поз.:" % (chain, len(items)), "kind": "list", + "items": [_card(db, it) for it in items]} + + +def _descendants(db, place_id): + """Место и все вложенные в него места.""" + result = {place_id} + changed = True + while changed: + changed = False + for pid, place in db["places"].items(): + if place.get("parent") in result and pid not in result: + result.add(pid) + changed = True + return result + + +def _card(db, item): + return { + "id": item["id"], + "name": item.get("name"), + "qty": item.get("qty"), + "note": item.get("note"), + "photo": item.get("photo"), + "aliases": item.get("aliases") or [], + "container": item.get("container"), + "state": item.get("state") or "stored", + "address": store.item_address(db, item), + "history": (item.get("history") or [])[-5:], + } + + +# ============================================================ +# Действия кнопками: место → ёмкость → предмет +# +# Здесь ИИ не участвует в главном: место и коробку человек выбирает сам, +# и ошибиться разбором фразы уже негде. Модель нужна только для синонимов. +# ============================================================ +@app.route("/api/places") +def api_places(): + db = store.load() + return jsonify({"places": store.all_places(db)}) + + +@app.route("/api/places", methods=["POST"]) +def api_add_place(): + data = request.get_json(silent=True) or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify({"ok": False, "reply": "Название места пустое."}), 400 + db = store.load() + parent = data.get("parent") or None + if parent and parent not in db["places"]: + return jsonify({"ok": False, "reply": "Родительское место не найдено."}), 404 + chain = store.place_chain(db, parent) if parent else [] + place_id = store.ensure_place_path(db, chain + [name]) + store.save(db) + return jsonify({"ok": True, "id": place_id, + "path": " → ".join(store.place_chain(db, place_id))}) + + +@app.route("/api/containers") +def api_containers(): + db = store.load() + return jsonify({"containers": store.all_containers(db, request.args.get("place"))}) + + +@app.route("/api/containers", methods=["POST"]) +def api_add_container(): + data = request.get_json(silent=True) or {} + code = store.norm_code(data.get("code")) + if not code: + return jsonify({"ok": False, "reply": "Код коробки пустой."}), 400 + db = store.load() + store.ensure_container(db, code, kind=data.get("kind"), place_id=data.get("place") or None) + store.save(db) + return jsonify({"ok": True, "code": code, "address": store.container_address(db, code)}) + + +@app.route("/api/put", methods=["POST"]) +def api_put(): + """Положить: место и коробку выбрали кнопками, здесь только название вещи.""" + data = request.get_json(silent=True) or {} + names = [n.strip() for n in (data.get("names") or [data.get("name") or ""]) if n.strip()] + if not names: + return jsonify({"ok": False, "reply": "Не указано, что кладём."}), 400 + + db = store.load() + place_id = data.get("place") or None + if place_id and place_id not in db["places"]: + return jsonify({"ok": False, "reply": "Место не найдено."}), 404 + + parsed = {"items": names, "qty": data.get("qty"), "container": data.get("container"), + "place_path": None, "note": data.get("note")} + result = _put_all(db, parsed, place_id) + return jsonify(result) + + +@app.route("/api/take", methods=["POST"]) +def api_take(): + """Забрать вещь из коробки — теперь она на руках.""" + data = request.get_json(silent=True) or {} + db = store.load() + item = _resolve(db, data) + if item is None: + return jsonify({"ok": False, "reply": "Не понял, какую вещь забрать."}), 404 + if isinstance(item, list): + return jsonify({"ok": False, "reply": "Уточни, какую именно:", "items": item}) + + was = store.take_item(db, item) + store.save(db) + return jsonify({"ok": True, "kind": "take", "item": _card(db, item), + "reply": "Забрал «%s» (было: %s). Отметил, что на руках." + % (item["name"], was)}) + + +@app.route("/api/move", methods=["POST"]) +def api_move(): + """Переместить вещь в другое место или коробку.""" + data = request.get_json(silent=True) or {} + db = store.load() + item = _resolve(db, data) + if item is None: + return jsonify({"ok": False, "reply": "Не понял, какую вещь перемещаем."}), 404 + if isinstance(item, list): + return jsonify({"ok": False, "reply": "Уточни, какую именно:", "items": item}) + + place_id = data.get("place") or None + container = data.get("container") + if not place_id and not container: + return jsonify({"ok": False, "reply": "Не указано, куда перемещать."}), 400 + + moved, prev = store.put_item(db, item["name"], container=container, place_id=place_id) + store.save(db) + address = store.item_address(db, moved) + reply = ("Переместил «%s»: теперь %s (было: %s)." % (moved["name"], address, prev) + if prev else "«%s» теперь %s." % (moved["name"], address)) + return jsonify({"ok": True, "kind": "move", "item": _card(db, moved), "reply": reply}) + + +@app.route("/api/items") +def api_items(): + """Список вещей для выбора: все или подходящие под запрос.""" + db = store.load() + query = (request.args.get("q") or "").strip() + if query: + items = [it for it, _, _ in search.search(db, query, top_k=20)] + else: + items = sorted(db["items"].values(), key=lambda i: (i.get("name") or "").lower()) + return jsonify({"items": [_card(db, it) for it in items[:50]]}) + + +def _resolve(db, data): + """Находит вещь по id или по названию. + + None — не нашли; список — нашли несколько и нужно уточнение; + иначе сам предмет. + """ + item_id = (data.get("id") or "").strip() + if item_id: + return db["items"].get(item_id) + + name = (data.get("name") or "").strip() + if not name: + return None + exact = store.find_item_by_name(db, name) + if exact: + return exact + hits = search.search(db, name, top_k=5) + if not hits: + return None + if len(hits) == 1: + return hits[0][0] + return [_card(db, it) for it, _, _ in hits] + + +# ============================================================ +# Просмотр +# ============================================================ +@app.route("/api/find") +def api_find(): + query = (request.args.get("q") or "").strip() + if not query: + return jsonify({"items": []}) + db = store.load() + return jsonify(_do_find(db, query)) + + +@app.route("/api/tree") +def api_tree(): + return jsonify(store.tree(store.load())) + + +@app.route("/api/container/") +def api_container(code): + return jsonify(_do_list_container(store.load(), code)) + + +@app.route("/api/item/", methods=["DELETE"]) +def api_delete_item(item_id): + db = store.load() + item = db["items"].get(item_id) + if not item: + return jsonify({"ok": False, "reply": "Такой записи нет."}), 404 + _drop_photo_file(store.detach_photo(item)) + store.remove_item(db, item_id) + store.save(db) + return jsonify({"ok": True, "reply": "Удалил «%s»." % item.get("name")}) + + +# ============================================================ +# Правка. Каждая сущность — предмет, ёмкость, место — правится и удаляется. +# ============================================================ +@app.route("/api/item/", methods=["PATCH"]) +def api_edit_item(item_id): + db = store.load() + item = db["items"].get(item_id) + if not item: + return jsonify({"ok": False, "reply": "Такой записи нет."}), 404 + + fields = request.get_json(silent=True) or {} + try: + changed = store.update_item(db, item, fields) + except ValueError as e: + return jsonify({"ok": False, "reply": str(e)}), 400 + + if not changed: + return jsonify({"ok": True, "item": _card(db, item), "reply": "Менять нечего."}) + search.ensure_embeddings(db) + store.save(db) + return jsonify({"ok": True, "item": _card(db, item), + "reply": "Обновил: %s." % ", ".join(changed)}) + + +@app.route("/api/containers/", methods=["PATCH"]) +def api_edit_container(code): + db = store.load() + if store.norm_code(code) not in db["containers"]: + return jsonify({"ok": False, "reply": "Такой коробки нет."}), 404 + try: + key, changed = store.update_container(db, code, request.get_json(silent=True) or {}) + except ValueError as e: + return jsonify({"ok": False, "reply": str(e)}), 400 + + if not changed: + return jsonify({"ok": True, "code": key, "reply": "Менять нечего."}) + store.save(db) + return jsonify({"ok": True, "code": key, "address": store.container_address(db, key), + "reply": "Обновил коробку %s: %s." % (key, ", ".join(changed))}) + + +@app.route("/api/containers/", methods=["DELETE"]) +def api_delete_container(code): + db = store.load() + key = store.norm_code(code) + box = db["containers"].get(key) + if not box: + return jsonify({"ok": False, "reply": "Такой коробки нет."}), 404 + + _drop_photo_file(store.detach_photo(box)) + _, moved = store.delete_container(db, key) + store.save(db) + reply = "Коробка %s удалена." % key + if moved: + where = " → ".join(store.place_chain(db, box.get("place"))) or "без места" + reply += " Вещи не потерялись (%d): теперь они просто %s — %s." % ( + len(moved), where, ", ".join(moved)) + return jsonify({"ok": True, "reply": reply, "moved": moved}) + + +@app.route("/api/places/", methods=["PATCH"]) +def api_edit_place(place_id): + db = store.load() + if place_id not in db["places"]: + return jsonify({"ok": False, "reply": "Такого места нет."}), 404 + try: + changed = store.update_place(db, place_id, request.get_json(silent=True) or {}) + except ValueError as e: + return jsonify({"ok": False, "reply": str(e)}), 400 + + if not changed: + return jsonify({"ok": True, "reply": "Менять нечего."}) + store.save(db) + return jsonify({"ok": True, "path": " → ".join(store.place_chain(db, place_id)), + "reply": "Обновил место: %s." % ", ".join(changed)}) + + +@app.route("/api/places/", methods=["DELETE"]) +def api_delete_place(place_id): + db = store.load() + place = db["places"].get(place_id) + if not place: + return jsonify({"ok": False, "reply": "Такого места нет."}), 404 + + # Непустое место не удаляем: перенести содержимое некуда, если это корень, + # а молча выбросить записи о вещах нельзя — в них весь смысл. + blockers = store.place_blockers(db, place_id) + if any(blockers.values()): + parts = [] + if blockers["places"]: + parts.append("места: " + ", ".join(blockers["places"])) + if blockers["containers"]: + parts.append("коробки: " + ", ".join(blockers["containers"])) + if blockers["items"]: + parts.append("вещи: " + ", ".join(blockers["items"])) + return jsonify({"ok": False, "blockers": blockers, + "reply": "Сначала разбери, что внутри «%s» — %s." + % (place.get("name"), "; ".join(parts))}), 409 + + store.delete_place(db, place_id) + store.save(db) + return jsonify({"ok": True, "reply": "Место «%s» удалено." % place.get("name")}) + + +# ============================================================ +# Удаление фото +# ============================================================ +def _drop_photo_file(filename): + """Убирает файл снимка с диска, чтобы папка не копила мусор.""" + if not filename: + return + path = PHOTOS_DIR / filename + try: + if path.exists(): + path.unlink() + except Exception as e: + logger.warning("Не удалось удалить фото %s: %s", filename, e) + + +@app.route("/api/photo", methods=["DELETE"]) +def api_delete_photo(): + data = request.get_json(silent=True) or {} + db = store.load() + target = data.get("target") + + if target == "container": + entity = db["containers"].get(store.norm_code(data.get("code"))) + elif target == "item": + entity = db["items"].get(data.get("id") or "") + else: + return jsonify({"ok": False, "reply": "Не указано, у чего убрать фото."}), 400 + + if not entity: + return jsonify({"ok": False, "reply": "Не найдено."}), 404 + if not entity.get("photo"): + return jsonify({"ok": True, "reply": "Фото и так нет."}) + + _drop_photo_file(store.detach_photo(entity)) + store.save(db) + return jsonify({"ok": True, "reply": "Фото убрал."}) + + +# ============================================================ +# Фото +# ============================================================ +@app.route("/api/photo", methods=["POST"]) +def api_photo(): + upload = request.files.get("photo") + if not upload or not upload.filename: + return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400 + + # Имя файла придумываем сами: имя от клиента может содержать «../» + # и увести запись за пределы папки с фотографиями. + ext = ("." + upload.filename.rsplit(".", 1)[-1].lower()) if "." in upload.filename else "" + if ext not in ALLOWED_PHOTO_EXT: + return jsonify({"ok": False, "reply": "Такой формат не принимаю: %s" % (ext or "?")}), 400 + + PHOTOS_DIR.mkdir(parents=True, exist_ok=True) + filename = uuid.uuid4().hex[:12] + ext + upload.save(str(PHOTOS_DIR / filename)) + + db = store.load() + target = request.form.get("target") + if target == "container": + key = store.norm_code(request.form.get("code")) + if key not in db["containers"]: + return jsonify({"ok": False, "reply": "Коробка не найдена."}), 404 + db["containers"][key]["photo"] = filename + elif target == "item": + item = db["items"].get(request.form.get("id") or "") + if not item: + return jsonify({"ok": False, "reply": "Предмет не найден."}), 404 + item["photo"] = filename + else: + return jsonify({"ok": False, "reply": "Не указано, к чему крепить фото."}), 400 + + store.save(db) + return jsonify({"ok": True, "photo": filename, "reply": "Фото прикрепил."}) + + +# ============================================================ +# Запуск +# ============================================================ +def local_ips(): + """Адреса этого компьютера в локальной сети — их вводят на телефоне. + + Первым идёт тот, по которому машина реально выходит в сеть. Это важно: + на этом компьютере шесть адресов (WSL, VMware, VPN), и угадать среди них + нужный невозможно. + + Определяем здесь, а не в start.cmd: парсинг ipconfig ломается о + локализованные названия полей, а батник вообще не может содержать + кириллицу без порчи кодировки. + """ + import socket + found = [] + # UDP-сокет никуда не отправляет данных: connect лишь заставляет систему + # выбрать исходящий интерфейс — то есть основной адрес машины. + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + # Адрес произвольный и внешний: пакетов не уходит, важен лишь выбор + # маршрута. Внешний, чтобы не привязываться к чужой подсети. + probe.connect(("8.8.8.8", 9)) + found.append(probe.getsockname()[0]) + except Exception: + pass + finally: + probe.close() + + try: + for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): + address = info[4][0] + if not address.startswith(("127.", "169.254.")) and address not in found: + found.append(address) + except Exception as e: + logger.warning("Не удалось определить адреса: %s", e) + return found + + +def main(): + logging.basicConfig(level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s") + PHOTOS_DIR.mkdir(parents=True, exist_ok=True) + + alive = ollama.check() + logger.info("Ollama %s: %s", OLLAMA_HOST, "доступен" if alive else "НЕ отвечает") + if not alive: + logger.warning("Без Ollama работает запись, обзор и поиск по названию. " + "Разбор фраз — упрощённый, поиск по смыслу отключён.") + + addresses = local_ips() + if addresses: + logger.info("С ТЕЛЕФОНА: http://%s:%d", addresses[0], PORT) + if len(addresses) > 1: + logger.info("Если не открылось, другие адреса этого ПК: %s", + ", ".join("http://%s:%d" % (a, PORT) for a in addresses[1:])) + logger.info("Здесь же: http://localhost:%d", PORT) + + threading.Timer(1.0, lambda: webbrowser.open("http://localhost:%d" % PORT)).start() + app.run(host=HOST, port=PORT, debug=False, threaded=True) + + +if __name__ == "__main__": + main() diff --git a/hlamingo/brain.py b/hlamingo/brain.py new file mode 100644 index 0000000..34289c0 --- /dev/null +++ b/hlamingo/brain.py @@ -0,0 +1,309 @@ +"""ИИ-слой: разбор свободной фразы в структуру и генерация синонимов. + +Пользователь пишет одну строку — «положил стабилизаторы 7805, 12 штук, в коробку +A3 на второй полке стеллажа А в гараже». Модель превращает её в JSON, который +понимает store.py. + +Ollama выключается вместе с компьютером, поэтому обе функции обязаны переживать +его отсутствие: разбор падает на регулярки, синонимы просто не генерируются. +""" + +import re +import json +import logging + +from . import ollama +from .config import CHAT_MODEL + +logger = logging.getLogger(__name__) + +PARSE_SYSTEM = """Ты разбираешь фразы о том, куда человек положил вещи, в JSON. + +Верни ТОЛЬКО JSON-объект, без пояснений и без markdown-ограждения: +{"action": "...", "items": ["..."], "qty": "...", "container": "...", + "place_path": ["...", "..."], "note": "..."} + +action: + "put" — человек кладёт или перекладывает вещь + "find" — спрашивает, где вещь лежит + "list_container" — спрашивает, что лежит в конкретной коробке + "list_place" — спрашивает, что лежит в комнате/на полке/в шкафу + "unknown" — непонятно + +items — СПИСОК названий вещей в именительном падеже, единственном числе. + Если названо несколько вещей — перечисли ВСЕ, ничего не теряй. +qty — количество, если названо ("12 шт"), иначе null. +container — код коробки: буква и цифра, например "A3". Если коробка не названа — null. +place_path — путь от крупного к мелкому: ["Гараж","Стеллаж А","Полка 2"]. + Только реально названные места. Если мест нет — []. +note — уточнение, если есть, иначе null. + +ВАЖНО про перекладывание: во фразе вида «переложил X ИЗ A В B» нужно место +НАЗНАЧЕНИЯ — то есть B. Откуда вещь забрали — не записывай. + +ВАЖНО про вопросы: фраза, начинающаяся с «где», «куда», «что», «не помню», — +это ВОПРОС, action="find", даже если внутри есть слово «положил». +«куда я положил зарядку» — человек СПРАШИВАЕТ, а не сообщает. +Признак записи: сказано, КУДА вещь легла. Нет места назначения — значит вопрос. + +Примеры: +«положил стабилизаторы 7805, 12 штук, в коробку A3 на второй полке стеллажа А в гараже» +{"action":"put","items":["Стабилизатор 7805"],"qty":"12 шт","container":"A3","place_path":["Гараж","Стеллаж А","Полка 2"],"note":null} + +«положил паспорт и права в верхний ящик стола в спальне» +{"action":"put","items":["паспорт","права"],"qty":null,"container":null,"place_path":["Спальня","Верхний ящик стола"],"note":null} + +«переложил дрель из коробки A1 в коробку C2» +{"action":"put","items":["дрель"],"qty":null,"container":"C2","place_path":[],"note":null} + +«где мультиметр» +{"action":"find","items":["мультиметр"],"qty":null,"container":null,"place_path":[],"note":null} + +«куда я положил зарядку от шуруповёрта» +{"action":"find","items":["зарядка от шуруповёрта"],"qty":null,"container":null,"place_path":[],"note":null} + +«что в коробке B7» +{"action":"list_container","items":[],"qty":null,"container":"B7","place_path":[],"note":null} + +«лопата стоит в углу гаража» +{"action":"put","items":["лопата"],"qty":null,"container":null,"place_path":["Гараж"],"note":"в углу"} +""" + +ALIAS_SYSTEM = """Ты придумываешь альтернативные названия вещи, чтобы её потом можно было найти. + +Верни ТОЛЬКО JSON-массив строк, 3-7 штук, без пояснений. +Включай: обиходные названия, синонимы, маркировку и артикулы, расшифровку +сокращений, а для деталей — что это по функции. + +«Стабилизатор 7805» → ["LM7805","стабилизатор 5 вольт","линейный регулятор напряжения","КР142ЕН5А","стабилизатор питания"] +«изолента» → ["изоляционная лента","синяя лента","ПВХ-лента","электроизоляционная лента"] +""" + +_THINK_RE = re.compile(r".*?", re.IGNORECASE | re.DOTALL) +_THINK_CLOSE_RE = re.compile(r"", re.IGNORECASE) +_THINK_OPEN_RE = re.compile(r"", re.IGNORECASE) + + +def _strip_thoughts(content): + """Убирает : модели-ризонеры пишут их прямо в content, + и без чистки JSON не разбирается. Обрабатываем и «висячие» теги.""" + if not content: + return content + text = _THINK_RE.sub("", content) + if _THINK_CLOSE_RE.search(text): + text = _THINK_CLOSE_RE.split(text)[-1] + if _THINK_OPEN_RE.search(text): + text = _THINK_OPEN_RE.split(text)[0] + return text.strip() + + +def _extract_json(content, opener="{", closer="}"): + """Достаёт JSON из ответа модели, терпя обрамление и болтовню вокруг.""" + text = _strip_thoughts(content) + if not text: + return None + fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL) + if fenced: + text = fenced.group(1).strip() + try: + return json.loads(text) + except Exception: + pass + match = re.search(re.escape(opener) + r".*" + re.escape(closer), text, re.DOTALL) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +# ============================================================ +# Разбор фразы +# ============================================================ +def parse(text, model=CHAT_MODEL): + """Фраза → структура. Всегда возвращает словарь, никогда не бросает.""" + text = (text or "").strip() + if not text: + return _blank("unknown") + + content = ollama.chat(model, [ + {"role": "system", "content": PARSE_SYSTEM}, + {"role": "user", "content": text}, + ]) + data = _extract_json(content) if content else None + if isinstance(data, dict) and data.get("action") in ( + "put", "find", "list_container", "list_place", "unknown"): + return _guard_question(_clean(data), text) + + if content: + logger.info("Модель не дала разбираемый JSON, беру регулярки: %r", content[:200]) + return fallback_parse(text) + + +_QUESTION_RE = re.compile(r"^\s*(?:где|куда|что|чего|не\s+помню|подскажи|напомни)\b", + re.IGNORECASE) + + +def _guard_question(parsed, text): + """Не даёт записать вопрос как факт. + + Модель цепляется за слово «положил» и превращает «куда я положил зарядку» + в запись. Признак записи один: сказано, КУДА вещь легла. Нет ни коробки, + ни места — значит человек спрашивает, и придумывать ему новое место нельзя. + """ + if parsed.get("action") != "put": + return parsed + if parsed.get("container") or parsed.get("place_path"): + return parsed + if _QUESTION_RE.match(text or "") or (text or "").rstrip().endswith("?"): + parsed["action"] = "find" + return parsed + + +def _blank(action): + return {"action": action, "items": [], "item": None, "qty": None, + "container": None, "place_path": [], "note": None, "by": "regex"} + + +def _as_list(value): + """Поле может прийти строкой или списком — приводим к списку строк.""" + if value is None: + return [] + if isinstance(value, str): + value = [value] + if not isinstance(value, list): + return [] + return [str(v).strip() for v in value if str(v).strip()] + + +def _clean(data): + def s(key): + value = data.get(key) + if value is None: + return None + value = str(value).strip() + return value or None + + # Старое поле item тоже принимаем: модель иногда сбивается на него, + # и терять из-за этого запись нельзя. + items = _as_list(data.get("items")) or _as_list(data.get("item")) + return { + "action": data.get("action"), + "items": items, + "item": items[0] if items else None, + "qty": s("qty"), + "container": s("container"), + "place_path": _as_list(data.get("place_path")), + "note": s("note"), + "by": "model", + } + + +# ============================================================ +# Фолбэк без ИИ +# ============================================================ +_PUT_RE = re.compile( + r"^(?:я\s+)?(?:положил|положила|кладу|убрал|убрала|засунул|положу|переложил|" + r"поставил|поставила|лежит|хранится)\s+(?P.+?)" + r"\s+(?:в|во|на|под|за)\s+(?P.+)$", re.IGNORECASE) +_FIND_RE = re.compile( + r"^(?:где|найди|найти|куда\s+я\s+положил|куда\s+положил|поищи|ищу)\s+" + r"(?:мой|моя|моё|мои|the)?\s*(?P.+?)[\?\.!]*$", re.IGNORECASE) +_LIST_BOX_RE = re.compile( + r"^(?:что|чего|покажи)\s+(?:лежит\s+)?(?:в|во)\s+(?:коробке|ящике|боксе|)\s*" + r"(?P[A-ZА-Я]{1,2}\s?\d{1,3})[\?\.!]*$", re.IGNORECASE) +_CODE_RE = re.compile(r"\b(?:коробк\w*|ящик\w*|бокс\w*)\s*([A-ZА-Я]{1,2}\s?-?\s?\d{1,3})\b", + re.IGNORECASE) +_QTY_RE = re.compile(r"(\d+)\s*(шт\w*|штук\w*|метр\w*|м\b|кг\b|грамм\w*|пар\w*|упаковк\w*)", + re.IGNORECASE) + + +def fallback_parse(text): + """Разбор регулярками — когда Ollama недоступен. Понимает простые фразы.""" + text = (text or "").strip() + + match = _LIST_BOX_RE.match(text) + if match: + result = _blank("list_container") + result["container"] = match.group("code") + return result + + match = _FIND_RE.match(text) + if match: + return _with_items(_blank("find"), [match.group("item").strip(" ?.!")]) + + match = _PUT_RE.match(text) + if match: + result = _blank("put") + item = match.group("item").strip() + place = match.group("place").strip(" ?.!") + + # «переложил дрель ИЗ коробки A1 в коробку C2»: всё после «из» — это + # место, откуда забрали. В название вещи оно попасть не должно. + item = re.split(r"\s+из\s+", item, 1)[0].strip() + + qty = _QTY_RE.search(item) + if qty: + result["qty"] = qty.group(0).strip() + item = _QTY_RE.sub("", item).strip(" ,") + _with_items(result, re.split(r"\s*(?:,|\sи\s)\s*", item.strip(" ,"))) + + code = _CODE_RE.search(place) + if code: + result["container"] = code.group(1) + # Всё после кода коробки — описание места («A3 на полке 2 в гараже»). + rest = place[code.end():].strip(" ,") + result["place_path"] = _split_places(rest) + else: + result["place_path"] = _split_places(place) + return result + + # Ничего не распознали: считаем, что это поиск — так безопаснее, + # чем записать непонятую фразу в базу как предмет. + return _with_items(_blank("find"), [text.strip(" ?.!")]) + + +def _with_items(result, names): + names = [n.strip(" ,.") for n in names if n and n.strip(" ,.")] + result["items"] = names + result["item"] = names[0] if names else None + return result + + +def _split_places(text): + """«на полке 2 в гараже» → ['гараже','полке 2'] (от крупного к мелкому). + + Предлоги идут от мелкого к крупному, поэтому части переворачиваем. + """ + text = (text or "").strip(" ,.") + if not text: + return [] + parts = re.split(r"\s+(?:в|во|на|под|за)\s+", text) + parts = [p.strip(" ,.") for p in parts if p.strip(" ,.")] + parts.reverse() + return parts + + +# ============================================================ +# Синонимы +# ============================================================ +def aliases(name, note=None, model=CHAT_MODEL): + """3-7 альтернативных названий. Пустой список, если модель недоступна.""" + name = (name or "").strip() + if not name: + return [] + prompt = name + (" (" + note + ")" if note else "") + content = ollama.chat(model, [ + {"role": "system", "content": ALIAS_SYSTEM}, + {"role": "user", "content": prompt}, + ], num_predict=256) + data = _extract_json(content, "[", "]") if content else None + 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) + return out[:7] diff --git a/hlamingo/config.py b/hlamingo/config.py new file mode 100644 index 0000000..c50535c --- /dev/null +++ b/hlamingo/config.py @@ -0,0 +1,104 @@ +"""Конфигурация: адрес 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() + +# ============================================================ +# 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("/") + +# qwen2.5 — не ризонер: не тратит время на и надёжнее держит формат JSON, +# а нам от модели нужен именно строгий JSON, а не рассуждения. +# omnicoder-9b — запасной вариант, если qwen начнёт путаться в русских фразах. +CHAT_MODEL = os.environ.get("HLAMINGO_CHAT_MODEL") or "qwen2.5:7b" +CHAT_MODEL_ALT = "carstenuhlig/omnicoder-9b" + +# Единственный эмбеддер на сервере. С русским справляется слабо (замеры — в +# комментарии к search.py), поэтому смысловой поиск у нас вспомогательный. +# Станет заметно лучше с многоязычной моделью: +# ollama pull bge-m3 +# после чего сменить строку ниже, обнулить префиксы и один раз пересчитать +# векторы: search.reindex(db) — старые векторы с новой моделью несовместимы. +EMBED_MODEL = os.environ.get("HLAMINGO_EMBED_MODEL") or "nomic-embed-text:latest" +# nomic-embed-text обучен на префиксах задачи: без них качество ещё ниже. +# Документы и запросы кодируются РАЗНЫМИ префиксами. Для bge-m3 префиксы не нужны. +EMBED_DOC_PREFIX = "search_document: " +EMBED_QUERY_PREFIX = "search_query: " + +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 = Path(os.environ.get("HLAMINGO_DATA_DIR") or PROJECT_DIR) +DATA_DIR = _ROOT / "data" +DB_FILE = DATA_DIR / "db.json" +PHOTOS_DIR = (_ROOT / "photos") if os.environ.get("HLAMINGO_DATA_DIR") \ + else (PROJECT_DIR / "static" / "photos") + +# True, когда работаем на настоящих данных пользователя. Тесты это проверяют +# и отказываются что-либо удалять, если флаг стоит. +IS_LIVE_DATA = not os.environ.get("HLAMINGO_DATA_DIR") + +# ============================================================ +# Веб +# ============================================================ +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"} diff --git a/hlamingo/ollama.py b/hlamingo/ollama.py new file mode 100644 index 0000000..162cb99 --- /dev/null +++ b/hlamingo/ollama.py @@ -0,0 +1,85 @@ +"""Клиент Ollama: чат и эмбеддинги. + +Копия нужной части emilia/ollama.py, а не импорт из соседнего проекта: Эмилия +активно развивается, и общий модуль сделал бы hlamingo заложником её рефакторингов. +""" + +import json +import logging +import urllib.request + +from .config import (OLLAMA_HOST, CHAT_TIMEOUT, EMBED_TIMEOUT, MAX_OUTPUT_TOKENS, + EMBED_MODEL, EMBED_DOC_PREFIX, EMBED_QUERY_PREFIX) + +logger = logging.getLogger(__name__) + + +def check(): + """Доступен ли сервер. Таймаут короткий: это дёргает баннер в UI.""" + try: + req = urllib.request.Request(OLLAMA_HOST + "/api/tags") + with urllib.request.urlopen(req, timeout=3) as r: + return r.status == 200 + except Exception: + return False + + +def models(): + """Список установленных моделей (для диагностики).""" + try: + req = urllib.request.Request(OLLAMA_HOST + "/api/tags") + with urllib.request.urlopen(req, timeout=5) as r: + data = json.loads(r.read().decode("utf-8")) + return [m.get("model", "") for m in data.get("models", [])] + except Exception as e: + logger.warning("Список моделей не получен: %s", e) + return [] + + +def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS): + """Один запрос к /api/chat. Возвращает content или None при любой беде. + + num_predict обязателен: без капа модель может уйти в длинную генерацию + и подвесить ход на минуты — а нам нужен короткий JSON. + """ + payload = { + "model": model, + "messages": messages, + "stream": False, + "options": {"num_predict": num_predict, "temperature": 0}, + } + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + OLLAMA_HOST + "/api/chat", data=data, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + resp = json.loads(r.read().decode("utf-8")) + return (resp.get("message") or {}).get("content", "") + except Exception as e: + logger.warning("Запрос к модели %s не удался: %s", model, e) + return None + + +def embed(text, is_query=False): + """Вектор текста или None. is_query=True для поисковых запросов. + + Префиксы обязательны: nomic-embed-text обучен с ними, и без разделения + «документ / запрос» качество поиска заметно падает. + """ + text = (text or "").strip() + if not text: + return None + prefix = EMBED_QUERY_PREFIX if is_query else EMBED_DOC_PREFIX + data = json.dumps({"model": EMBED_MODEL, "prompt": prefix + text}).encode("utf-8") + req = urllib.request.Request( + OLLAMA_HOST + "/api/embeddings", data=data, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=EMBED_TIMEOUT) as r: + return json.loads(r.read().decode("utf-8")).get("embedding") + except Exception as e: + logger.warning("Эмбеддинг не получен: %s", e) + return None diff --git a/hlamingo/search.py b/hlamingo/search.py new file mode 100644 index 0000000..a75e11e --- /dev/null +++ b/hlamingo/search.py @@ -0,0 +1,174 @@ +"""Поиск предметов: по словам (с учётом русских окончаний) плюс семантика. + +Порядок сигналов выбран по замерам на реальном эмбеддере, а не наугад. + +1. СЛОВА — основной сигнал. Запрос и карточка предмета режутся на слова, слова + грубо приводятся к основе («стабилизаторы» → «стабилизатор»), считается доля + совпавших. Работает всегда, в том числе с выключенным Ollama. +2. СИНОНИМЫ от модели («изолента» → «изоляционная лента», «ПВХ-лента») лежат + в самой карточке и расширяют словарь предмета — то есть усиливают пункт 1. +3. СЕМАНТИКА — дополнение, а не основа. Замер на nomic-embed-text показал, что + на русском он почти не разделяет смыслы: «велосипедная камера» давала + мультиметру 0.689, а верный ответ на «паяльник» — 0.691. Порогом такое не + разделить, поэтому семантика подключается только когда по словам ничего не + нашлось, с высоким порогом, и помечается в выдаче как догадка. + +Хочется настоящего смыслового поиска — нужен многоязычный эмбеддер (bge-m3): +см. EMBED_MODEL в config.py и раздел про поиск в README. +""" + +import re +import math +import logging + +from . import ollama +from .config import TOP_K, MIN_SCORE + +logger = logging.getLogger(__name__) + +# Слова, которые есть в любом запросе и ничего не сообщают о предмете. +STOP = { + "где", "что", "чем", "как", "куда", "мой", "моя", "моё", "мои", "мне", + "в", "во", "на", "за", "под", "из", "от", "до", "по", "с", "со", "у", "к", + "и", "а", "но", "или", "для", "же", "ли", "это", "тот", "та", "те", + "лежит", "лежат", "находится", "хранится", "положил", "искать", "найти", +} + +# Самые частые русские окончания, от длинных к коротким: обрезаем первое +# подходящее. Это не морфология, а дешёвая замена ей — но «стабилизаторы», +# «стабилизатора» и «стабилизатором» после неё сходятся в одну основу. +_ENDINGS = ( + "ами", "ями", "ого", "ему", "ому", "ыми", "ими", "ах", "ях", "ов", "ев", + "ам", "ям", "ой", "ей", "ые", "ий", "ый", "ая", "яя", "ое", "ее", "ии", + "ом", "ем", "им", "ых", "их", "ую", "юю", "ть", + "а", "я", "ы", "и", "о", "е", "у", "ю", "й", "ь", +) +_WORD_RE = re.compile(r"[0-9a-zA-Zа-яёА-ЯЁ]+") + +MIN_WORD_SCORE = 0.5 # совпало меньше половины значимых слов — не показываем +SEMANTIC_MIN = 0.70 # порог догадки по смыслу (см. калибровку выше) + + +def stem(word): + """Грубая основа слова. Короткие слова и коды («A3», «7805») не трогаем.""" + word = word.lower().replace("ё", "е") + if len(word) <= 4 or word.isdigit(): + return word + for ending in _ENDINGS: + if word.endswith(ending) and len(word) - len(ending) >= 4: + return word[:-len(ending)] + return word + + +def tokens(text, keep_stop=False): + """Значимые основы слов текста.""" + out = [] + for word in _WORD_RE.findall(text or ""): + low = word.lower() + if not keep_stop and low in STOP: + continue + out.append(stem(word)) + return out + + +def item_text(item): + """Всё, по чему предмет можно узнать: название, синонимы, заметка.""" + parts = [item.get("name") or ""] + parts += item.get("aliases") or [] + if item.get("note"): + parts.append(item["note"]) + return ", ".join(p for p in parts if p) + + +def word_score(query, item): + """Доля слов запроса, найденных в карточке предмета: 0…1.""" + q = tokens(query) + if not q: + return 0.0 + haystack = set(tokens(item_text(item))) + if not haystack: + return 0.0 + + found = 0 + for word in q: + if word in haystack: + found += 1 + continue + # Частичное совпадение: «стабилиз» ↔ «стабилизатор». Помогает там, где + # обрезка окончаний сработала по-разному в запросе и в карточке. + if len(word) >= 5 and any(h.startswith(word) or word.startswith(h) + for h in haystack if len(h) >= 5): + found += 0.8 + return min(1.0, found / len(q)) + + +# ============================================================ +# Семантика +# ============================================================ +def cosine(a, b): + if not a or not b or len(a) != len(b): + return 0.0 + dot = sum(x * y for x, y in zip(a, b)) + na = math.sqrt(sum(x * x for x in a)) + nb = math.sqrt(sum(y * y for y in b)) + if na == 0 or nb == 0: + return 0.0 + return dot / (na * nb) + + +def ensure_embeddings(db): + """Досчитывает векторы предметам без них. True, если базу надо сохранить.""" + changed = False + for item in db["items"].values(): + if item.get("emb"): + continue + vector = ollama.embed(item_text(item)) + if not vector: + return changed # эмбеддер молчит — не долбим его на каждой записи + item["emb"] = vector + changed = True + return changed + + +def reindex(db): + """Полный пересчёт векторов — после смены модели эмбеддера. + + Векторы разных моделей несовместимы: смешанная база тихо испортит поиск, + поэтому при смене EMBED_MODEL пересчитывать нужно всё сразу. + """ + for item in db["items"].values(): + item["emb"] = None + return ensure_embeddings(db) + + +# ============================================================ +# Поиск +# ============================================================ +def search(db, query, top_k=TOP_K, min_score=MIN_SCORE): + """Список (item, score, why). why — «по названию» или «похоже по смыслу».""" + if not (query or "").strip() or not db["items"]: + return [] + + scored = [] + for item in db["items"].values(): + score = word_score(query, item) + if score >= MIN_WORD_SCORE: + scored.append((item, score, "по названию")) + scored.sort(key=lambda t: t[1], reverse=True) + if scored: + return scored[:top_k] + + # По словам не нашлось — пробуем догадаться по смыслу. Отдельно и с высоким + # порогом: лучше честное «не нашёл», чем уверенно показанное не то. + vector = ollama.embed(query, is_query=True) + if not vector: + return [] + guesses = [] + for item in db["items"].values(): + if not item.get("emb"): + continue + score = cosine(vector, item["emb"]) + if score >= max(SEMANTIC_MIN, min_score): + guesses.append((item, score, "похоже по смыслу")) + guesses.sort(key=lambda t: t[1], reverse=True) + return guesses[:top_k] diff --git a/hlamingo/store.py b/hlamingo/store.py new file mode 100644 index 0000000..8206923 --- /dev/null +++ b/hlamingo/store.py @@ -0,0 +1,557 @@ +"""База: места, ёмкости, предметы. Один JSON-файл, атомарная запись. + +Три сущности вместо плоского «предмет → строка»: + place — узел иерархии (Гараж → Стеллаж А → Полка 2), ссылается на parent; + container — коробка с кодом «буква+цифра», стоит в каком-то месте; + item — предмет; лежит ЛИБО в ёмкости, ЛИБО прямо в месте (лопата в углу). +""" + +import os +import re +import json +import uuid +import shutil +import logging +from datetime import datetime + +from .config import DB_FILE, DATA_DIR + +logger = logging.getLogger(__name__) + +EMPTY = {"version": 1, "places": {}, "containers": {}, "items": {}} + +# Кириллические буквы, неотличимые на вид от латинских. Коробка подписана от руки +# «А3», а в поиске набирается «A3» с латинской A — и без этой нормализации это +# две разные коробки. Приводим код к латинице, оригинал храним для показа. +_HOMOGLYPHS = str.maketrans({ + "А": "A", "В": "B", "Е": "E", "К": "K", "М": "M", "Н": "H", "О": "O", + "Р": "P", "С": "C", "Т": "T", "У": "Y", "Х": "X", +}) + + +def now(): + return datetime.now().strftime("%d.%m.%Y %H:%M") + + +def norm_code(code): + """Ключ ёмкости: верхний регистр, без пробелов, кириллица → латиница.""" + text = (code or "").strip().upper().replace(" ", "").replace("-", "") + return text.translate(_HOMOGLYPHS) + + +def _new_id(prefix): + return prefix + "-" + uuid.uuid4().hex[:6] + + +# ============================================================ +# Чтение и запись +# ============================================================ +def load(): + """Читает базу. Битый или отсутствующий файл — пустая база, но не падение.""" + if not DB_FILE.exists(): + return json.loads(json.dumps(EMPTY)) + try: + data = json.loads(DB_FILE.read_text(encoding="utf-8")) + except Exception as e: + logger.error("База повреждена (%s) — работаем с пустой, файл не трогаем.", e) + return json.loads(json.dumps(EMPTY)) + for key in ("places", "containers", "items"): + data.setdefault(key, {}) + data.setdefault("version", 1) + return data + + +BACKUPS_KEEP = 40 # сколько прошлых состояний базы держать + + +def _backup(): + """Складывает копию текущей базы в data/backups перед перезаписью. + + Имя с точностью до минуты: при частых записях копии не размножаются, но + история за последние десятки правок остаётся. Дёшево — файл маленький. + Это страховка от того, что базу затрут или испортят: однажды она уже + потерялась целиком, и откатиться было некуда. + """ + if not DB_FILE.exists(): + return + folder = DATA_DIR / "backups" + try: + folder.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d-%H%M") + shutil.copy2(str(DB_FILE), str(folder / ("db-%s.json" % stamp))) + old = sorted(folder.glob("db-*.json"))[:-BACKUPS_KEEP] + for path in old: + path.unlink() + except Exception as e: + # Не смогли сделать копию — это не повод терять саму запись. + logger.warning("Резервная копия не создана: %s", e) + + +def save(db): + """Атомарная запись: сначала во временный файл, потом подмена. + + Прямая запись в db.json означала бы, что обрыв на середине (питание, Ctrl+C) + уничтожит вообще всё, что было записано про вещи. + """ + DATA_DIR.mkdir(parents=True, exist_ok=True) + _backup() + tmp = DB_FILE.with_suffix(".json.tmp") + tmp.write_text(json.dumps(db, ensure_ascii=False, indent=1), encoding="utf-8") + os.replace(tmp, DB_FILE) + + +# ============================================================ +# Места +# ============================================================ +def ensure_place_path(db, path): + """Создаёт (или находит) цепочку мест ['Гараж','Стеллаж А','Полка 2']. + + Возвращает id последнего места или None для пустого пути. Совпадение ищем + по имени среди детей текущего родителя: «Полка 2» в гараже и «Полка 2» + в кладовке — разные места. + """ + parent = None + for raw in path or []: + name = (raw or "").strip() + if not name: + continue + found = None + for pid, place in db["places"].items(): + if place.get("parent") == parent and place.get("name", "").lower() == name.lower(): + found = pid + break + if not found: + found = _new_id("p") + db["places"][found] = {"id": found, "name": name, "parent": parent} + parent = found + return parent + + +def place_chain(db, place_id): + """Список имён от корня к месту: ['Гараж','Стеллаж А','Полка 2'].""" + chain = [] + seen = set() + current = place_id + while current and current in db["places"] and current not in seen: + seen.add(current) # страховка от кольца в parent + place = db["places"][current] + chain.append(place.get("name", "?")) + current = place.get("parent") + chain.reverse() + return chain + + +def find_place_by_name(db, name): + """Первое место с таким именем (для запросов «что в гараже»).""" + query = (name or "").strip().lower() + if not query: + return None + for pid, place in db["places"].items(): + if place.get("name", "").lower() == query: + return pid + for pid, place in db["places"].items(): + if query in place.get("name", "").lower(): + return pid + return None + + +# ============================================================ +# Ёмкости +# ============================================================ +def ensure_container(db, code, kind=None, place_id=None): + """Создаёт или обновляет ёмкость. Ключ — нормализованный код.""" + key = norm_code(code) + if not key: + return None + box = db["containers"].get(key) + if not box: + box = {"code": key, "label": (code or "").strip(), "kind": kind or "коробка", + "place": place_id, "photo": None, "note": "", "updated": now()} + db["containers"][key] = box + else: + if place_id: + box["place"] = place_id + if kind: + box["kind"] = kind + box["updated"] = now() + return key + + +def container_address(db, code): + """Полный адрес ёмкости: 'Гараж → Стеллаж А → Полка 2 → коробка A3'.""" + box = db["containers"].get(norm_code(code)) + if not box: + return "" + parts = place_chain(db, box.get("place")) + parts.append("%s %s" % (box.get("kind") or "коробка", box.get("code"))) + return " → ".join(parts) + + +def container_contents(db, code): + key = norm_code(code) + return [it for it in db["items"].values() if it.get("container") == key] + + +# ============================================================ +# Предметы +# ============================================================ +def item_address(db, item): + """Где лежит предмет — читаемой строкой.""" + if item.get("state") == "taken": + taken = item.get("taken_at") + return "на руках (забрал %s)" % taken if taken else "на руках" + if item.get("container"): + return container_address(db, item["container"]) + chain = place_chain(db, item.get("place")) + return " → ".join(chain) if chain else "место не указано" + + +def find_item_by_name(db, name): + """Точное совпадение по названию или синониму — чтобы не плодить дубли.""" + query = (name or "").strip().lower() + if not query: + return None + for item in db["items"].values(): + if item.get("name", "").lower() == query: + return item + if any(query == (a or "").lower() for a in item.get("aliases", [])): + return item + return None + + +def put_item(db, name, qty=None, container=None, place_id=None, note=None, aliases=None): + """Кладёт предмет в ёмкость или место. Уже известный предмет — перемещает. + + Возвращает (item, prev_address): prev_address не пуст, если вещь переехала. + """ + name = (name or "").strip() + if not name: + return None, None + + item = find_item_by_name(db, name) + prev = item_address(db, item) if item else None + + if not item: + item = {"id": _new_id("i"), "name": name, "aliases": [], "qty": None, + "container": None, "place": None, "photo": None, "note": "", + "state": "stored", "taken_at": None, "emb": None, "history": []} + db["items"][item["id"]] = item + + key = norm_code(container) if container else None + if key and key not in db["containers"]: + ensure_container(db, key, place_id=place_id) + elif key and place_id: + db["containers"][key]["place"] = place_id + + item["container"] = key + # Место у предмета держим только когда он лежит без ёмкости: иначе два + # источника правды разъедутся, стоит коробке переехать на другую полку. + item["place"] = None if key else place_id + item["state"] = "stored" + item["taken_at"] = None + if qty: + item["qty"] = qty + if note: + item["note"] = note + if aliases: + merged = list(item.get("aliases") or []) + for alias in aliases: + alias = (alias or "").strip() + if alias and alias.lower() != name.lower() and \ + not any(alias.lower() == a.lower() for a in merged): + merged.append(alias) + item["aliases"] = merged + # Название или синонимы изменились — старый вектор больше не описывает предмет. + item["emb"] = None + item["history"].append({"at": now(), "container": key, "place": item["place"]}) + + new_address = item_address(db, item) + return item, (prev if prev and prev != new_address else None) + + +def take_item(db, item): + """Забрать вещь: она больше не в коробке, а на руках. + + Без этого состояния взятая и израсходованная деталь продолжала бы + числиться в коробке — и поиск отправлял бы копать пустое место. + """ + was = item_address(db, item) + item["state"] = "taken" + item["taken_at"] = now() + item["container"] = None + item["place"] = None + item["history"].append({"at": now(), "action": "забрал", "from": was}) + return was + + +def remove_item(db, item_id): + return db["items"].pop(item_id, None) is not None + + +# ============================================================ +# Правка +# ============================================================ +def detach_photo(entity): + """Отвязывает фото, возвращая имя файла — его удаляет вызывающий.""" + old = entity.get("photo") + entity["photo"] = None + return old + + +def update_item(db, item, fields): + """Правка предмета: название, количество, заметка, синонимы. + + Возвращает список изменённых полей. Название и синонимы сбрасывают вектор: + старый описывал уже другой текст. + """ + changed = [] + + if "name" in fields: + name = (fields["name"] or "").strip() + if not name: + raise ValueError("Название не может быть пустым.") + clash = find_item_by_name(db, name) + if clash and clash["id"] != item["id"]: + raise ValueError("«%s» уже есть в списке — иначе получились бы две " + "записи об одной вещи." % name) + if name != item.get("name"): + item["name"] = name + changed.append("название") + + if "aliases" in fields: + raw = fields["aliases"] + if isinstance(raw, str): + raw = [a for a in re.split(r"\s*,\s*", raw) if a.strip()] + aliases = [] + for alias in raw or []: + alias = str(alias).strip() + if alias and alias.lower() != item["name"].lower() and \ + not any(alias.lower() == a.lower() for a in aliases): + aliases.append(alias) + if aliases != (item.get("aliases") or []): + item["aliases"] = aliases + changed.append("синонимы") + + for key, label in (("qty", "количество"), ("note", "заметка")): + if key in fields: + value = (fields[key] or "").strip() or None + if value != (item.get(key) or None): + item[key] = value + changed.append(label) + + if "название" in changed or "синонимы" in changed or "заметка" in changed: + item["emb"] = None + return changed + + +def rename_container(db, old_code, new_code): + """Переименовать ёмкость. + + Код — это ключ, по которому предметы ссылаются на коробку, поэтому ссылки + надо перенести вместе с ней. Иначе вещи остались бы висеть на несуществующем + коде и пропали из коробки. + """ + old = norm_code(old_code) + new = norm_code(new_code) + if not new: + raise ValueError("Код коробки не может быть пустым.") + if old == new: + return False + if new in db["containers"]: + raise ValueError("Коробка %s уже есть." % new) + + box = db["containers"].pop(old) + box["code"] = new + box["label"] = (new_code or "").strip() + db["containers"][new] = box + for item in db["items"].values(): + if item.get("container") == old: + item["container"] = new + return True + + +def update_container(db, code, fields): + """Правка ёмкости: код, тип, место, заметка.""" + key = norm_code(code) + box = db["containers"].get(key) + if not box: + return None, [] + changed = [] + + if "code" in fields and norm_code(fields["code"]) != key: + if rename_container(db, key, fields["code"]): + key = norm_code(fields["code"]) + box = db["containers"][key] + changed.append("код") + + if "place" in fields: + place_id = fields["place"] or None + if place_id and place_id not in db["places"]: + raise ValueError("Такого места нет.") + if place_id != box.get("place"): + box["place"] = place_id + changed.append("место") + + for field, label in (("kind", "тип"), ("note", "заметка")): + if field in fields: + value = (fields[field] or "").strip() or None + if field == "kind" and not value: + value = "коробка" + if value != (box.get(field) or None): + box[field] = value + changed.append(label) + + if changed: + box["updated"] = now() + return key, changed + + +def container_blockers(db, code): + """Что мешает удалить ёмкость — список названий вещей внутри.""" + return [it.get("name") for it in container_contents(db, norm_code(code))] + + +def delete_container(db, code): + """Удалить ёмкость. Вещи из неё не теряются — остаются в её месте. + + Молча выбросить содержимое нельзя: именно эти записи и есть весь смысл + приложения. Коробки нет — значит вещи лежат там, где она стояла. + """ + key = norm_code(code) + box = db["containers"].pop(key, None) + if not box: + return None, [] + moved = [] + for item in db["items"].values(): + if item.get("container") == key: + item["container"] = None + item["place"] = box.get("place") + item["history"].append({"at": now(), "action": "коробка %s удалена" % key, + "place": box.get("place")}) + moved.append(item.get("name")) + return box, moved + + +def update_place(db, place_id, fields): + """Правка места: название и родитель.""" + place = db["places"].get(place_id) + if not place: + return [] + changed = [] + + if "name" in fields: + name = (fields["name"] or "").strip() + if not name: + raise ValueError("Название места не может быть пустым.") + if name != place.get("name"): + place["name"] = name + changed.append("название") + + if "parent" in fields: + parent = fields["parent"] or None + if parent == place_id: + raise ValueError("Место не может лежать внутри самого себя.") + if parent and parent not in db["places"]: + raise ValueError("Такого места нет.") + # Нельзя переехать внутрь своего же потомка: получилось бы кольцо, + # и подъём по родителям зацикливался бы. + if parent and place_id in _ancestors(db, parent): + raise ValueError("Нельзя переместить место внутрь вложенного в него.") + if parent != place.get("parent"): + place["parent"] = parent + changed.append("расположение") + return changed + + +def _ancestors(db, place_id): + """Все родители места, вверх до корня.""" + out, seen = [], set() + current = place_id + while current and current in db["places"] and current not in seen: + seen.add(current) + out.append(current) + current = db["places"][current].get("parent") + return out + + +def place_blockers(db, place_id): + """Что внутри места и мешает его удалить.""" + inside = [p.get("name") for p in db["places"].values() if p.get("parent") == place_id] + boxes = [b["code"] for b in db["containers"].values() if b.get("place") == place_id] + items = [i.get("name") for i in db["items"].values() + if not i.get("container") and i.get("place") == place_id] + return {"places": inside, "containers": boxes, "items": items} + + +def delete_place(db, place_id): + """Удалить пустое место. Непустое не трогаем — см. place_blockers.""" + return db["places"].pop(place_id, None) is not None + + +def all_places(db): + """Плоский список мест с полными путями — для выпадающего списка.""" + out = [] + for pid in db["places"]: + out.append({"id": pid, "path": " → ".join(place_chain(db, pid))}) + out.sort(key=lambda p: p["path"]) + return out + + +def all_containers(db, place_id=None): + """Ёмкости, при желании — только стоящие в указанном месте или под ним.""" + boxes = db["containers"].values() + if place_id: + boxes = [b for b in boxes if b.get("place") == place_id] + return sorted( + [{"code": b["code"], "kind": b.get("kind") or "коробка", + "place": b.get("place"), "note": b.get("note") or "", + "photo": b.get("photo"), "address": container_address(db, b["code"]), + "count": len(container_contents(db, b["code"]))} for b in boxes], + key=lambda b: b["code"]) + + +# ============================================================ +# Дерево для обзора +# ============================================================ +def tree(db): + """Дерево мест с вложенными ёмкостями и предметами — для режима «Обзор».""" + children = {} + for pid, place in db["places"].items(): + children.setdefault(place.get("parent"), []).append(pid) + + def node(pid): + return { + "id": pid, + "name": db["places"][pid].get("name", "?"), + "places": [node(c) for c in sorted( + children.get(pid, []), key=lambda x: db["places"][x].get("name", ""))], + "containers": [ + {"code": box["code"], "label": box.get("label") or box["code"], + "kind": box.get("kind"), "photo": box.get("photo"), + "count": len(container_contents(db, box["code"]))} + for box in sorted(db["containers"].values(), key=lambda b: b["code"]) + if box.get("place") == pid + ], + "items": [_brief(db, it) for it in db["items"].values() + if not it.get("container") and it.get("place") == pid], + } + + roots = [node(pid) for pid in sorted( + children.get(None, []), key=lambda x: db["places"][x].get("name", ""))] + + # Ёмкости и предметы, которым не указали место, иначе они пропадут из обзора. + orphan_boxes = [ + {"code": b["code"], "label": b.get("label") or b["code"], "kind": b.get("kind"), + "photo": b.get("photo"), "count": len(container_contents(db, b["code"]))} + for b in sorted(db["containers"].values(), key=lambda b: b["code"]) + if not b.get("place") + ] + orphan_items = [_brief(db, it) for it in db["items"].values() + if not it.get("container") and not it.get("place")] + return {"places": roots, "loose_containers": orphan_boxes, "loose_items": orphan_items} + + +def _brief(db, item): + return {"id": item["id"], "name": item.get("name"), "qty": item.get("qty"), + "photo": item.get("photo"), "aliases": item.get("aliases") or [], + "address": item_address(db, item)} diff --git a/install.cmd b/install.cmd new file mode 100644 index 0000000..32b4ebb --- /dev/null +++ b/install.cmd @@ -0,0 +1,189 @@ +@echo off +rem ============================================================ +rem hlamingo installer +rem +rem Downloads the app and sets up everything needed to run it. +rem Put this file in an empty folder and double-click it. +rem +rem ASCII only: cmd.exe reads .cmd in the system codepage, so Cyrillic here +rem would turn into garbage and break the script. Russian text is printed +rem from Python instead. +rem ============================================================ +setlocal +chcp 65001 >nul +cls + +set REPO_WEB=https://git.08h.ru/alex/hlamingo +set REPO_GIT=https://git.08h.ru/alex/hlamingo.git +set BRANCH=main +set TARGET=hlamingo + +echo ======================================== +echo hlamingo - installer +echo ======================================== +echo. +echo Source: %REPO_WEB% +echo Target: %CD%\%TARGET% +echo. + +cd /d "%~dp0" + +rem ---------- 1. Python ---------- +where python >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Python not found in PATH. + echo. + echo Install Python 3.9 or newer from https://www.python.org/downloads/ + echo IMPORTANT: check "Add python.exe to PATH" in the installer. + echo Then run this file again. + pause + exit /b 1 +) +for /f "delims=" %%v in ('python -c "import sys;print(sys.version.split()[0])"') do set PYVER=%%v +echo [1/4] Python %PYVER% found. + +rem ---------- 2. Download ---------- +if exist "%TARGET%\hlamingo\app.py" goto :update + +where git >nul 2>&1 +if errorlevel 1 goto :download_zip + +echo [2/4] Cloning with git... +git clone --branch %BRANCH% "%REPO_GIT%" "%TARGET%" +if errorlevel 1 ( + echo git clone failed, falling back to zip download. + goto :download_zip +) +goto :setup + +:download_zip +echo [2/4] Downloading archive (git not available or clone failed)... +rem PowerShell does the work: no curl/tar dependency on older Windows. +rem Проверяем, что скачался именно архив: сервер, требующий входа, отдаёт +rem HTML-страницу логина с кодом 200, и распаковка падала бы невнятно. +powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "$ErrorActionPreference='Stop';" ^ + "$url='%REPO_WEB%/archive/%BRANCH%.zip';" ^ + "$zip=Join-Path $env:TEMP 'hlamingo-src.zip';" ^ + "$tmp=Join-Path $env:TEMP 'hlamingo-unpack';" ^ + "Write-Host (' ' + $url);" ^ + "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing -MaximumRedirection 0 -ErrorAction SilentlyContinue;" ^ + "if (-not (Test-Path $zip)) { throw 'server returned nothing' }" ^ + "$head=[IO.File]::ReadAllBytes($zip) | Select-Object -First 2;" ^ + "if ($head[0] -ne 0x50 -or $head[1] -ne 0x4B) { Remove-Item $zip -Force; throw 'the server sent a web page instead of an archive - the repository is private or requires signing in' }" ^ + "if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp }" ^ + "Expand-Archive -Path $zip -DestinationPath $tmp -Force;" ^ + "$root=Get-ChildItem $tmp -Directory | Select-Object -First 1;" ^ + "if (-not $root) { throw 'archive looks empty' }" ^ + "Move-Item $root.FullName (Join-Path (Get-Location) '%TARGET%');" ^ + "Remove-Item -Recurse -Force $tmp, $zip" +if errorlevel 1 ( + echo. + echo [ERROR] Download failed. + echo. + echo Possible reasons: + echo - %REPO_WEB% is not reachable from this computer; + echo - the repository is private, or the server requires signing in + echo to view anything. In that case download the project manually + echo and run setup.cmd inside it. + pause + exit /b 1 +) +goto :setup + +:update +echo [2/4] Already downloaded - updating. +where git >nul 2>&1 +if not errorlevel 1 ( + if exist "%TARGET%\.git" ( + pushd "%TARGET%" + git pull --ff-only + popd + ) +) + +rem ---------- 3. venv and dependencies ---------- +:setup +if not exist "%TARGET%\requirements.txt" ( + echo [ERROR] Download looks incomplete: requirements.txt is missing. + pause + exit /b 1 +) +pushd "%TARGET%" + +echo [3/4] Creating virtual environment and installing dependencies... +if not exist "venv" python -m venv venv +if errorlevel 1 ( + echo [ERROR] Could not create venv. + popd + pause + exit /b 1 +) +venv\Scripts\python.exe -m pip install --upgrade pip --quiet +venv\Scripts\python.exe -m pip install -r requirements.txt --quiet +if errorlevel 1 ( + echo [ERROR] Dependency installation failed. + popd + pause + exit /b 1 +) + +rem ---------- 4. Ollama address ---------- +echo. +echo [4/4] Where is Ollama running? +echo Enter to accept the default (this computer). +echo Otherwise type the address of the machine on your network, +echo for example: http://192.168.1.50:11434 +echo. +set OLLAMA_ADDR= +set /p OLLAMA_ADDR="Ollama URL [http://localhost:11434]: " +if "%OLLAMA_ADDR%"=="" set OLLAMA_ADDR=http://localhost:11434 + +rem Локальный конфиг делаем из образца: рядом с адресом Ollama человек увидит +rem и остальные настройки с пояснениями, а не одну голую строку. +if exist ".env" ( + echo .env already exists - left as is. Edit it by hand if needed. +) else ( + rem -Encoding UTF8 обязателен: без него PowerShell 5.1 читает файл в + rem системной кодировке, и русские пояснения превращаются в кракозябры. + powershell -NoProfile -Command ^ + "$src = if (Test-Path '.env.example') { Get-Content '.env.example' -Encoding UTF8 } else { @('OLLAMA_HOST=') };" ^ + "$out = $src -replace '^\s*#?\s*OLLAMA_HOST=.*', 'OLLAMA_HOST=%OLLAMA_ADDR%';" ^ + "if (-not ($out -match '^OLLAMA_HOST=')) { $out += 'OLLAMA_HOST=%OLLAMA_ADDR%' }" ^ + "Set-Content -Path '.env' -Value $out -Encoding UTF8" + if errorlevel 1 ( + > .env echo OLLAMA_HOST=%OLLAMA_ADDR% + ) + echo Saved to .env ^(OLLAMA_HOST=%OLLAMA_ADDR%^) +) + +rem Check whether Ollama actually answers, and report in plain language. +venv\Scripts\python.exe -c "from hlamingo import ollama, config; ok=ollama.check(); print((' Ollama ' + config.OLLAMA_HOST + (' отвечает.' if ok else ' НЕ отвечает — приложение будет работать, но без разбора фраз и поиска по смыслу.')))" + +echo. +echo ======================================== +echo Done. +echo ======================================== +echo. +echo Start the app: %CD%\start.cmd +echo Run tests: %CD%\tests\run.cmd +echo. +echo A shortcut named "hlamingo" is on your Desktop. +powershell -NoProfile -Command ^ + "$s=(New-Object -ComObject WScript.Shell).CreateShortcut([IO.Path]::Combine([Environment]::GetFolderPath('Desktop'),'hlamingo.lnk'));" ^ + "$s.TargetPath=(Join-Path (Get-Location) 'start.cmd');" ^ + "$s.WorkingDirectory=(Get-Location).Path;" ^ + "$s.Description='hlamingo - where my stuff is';" ^ + "$s.Save()" 2>nul + +popd +echo. +set RUNNOW= +set /p RUNNOW="Start it now? [Y/n]: " +if /i "%RUNNOW%"=="n" goto :end +pushd "%TARGET%" +call start.cmd +popd + +:end +endlocal diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7e10602 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask diff --git a/setup.cmd b/setup.cmd new file mode 100644 index 0000000..3f8d493 --- /dev/null +++ b/setup.cmd @@ -0,0 +1,37 @@ +@echo off +rem ASCII only! See the note in start.cmd. +cls +echo ======================================== +echo hlamingo - setup +echo ======================================== +echo. + +cd /d "%~dp0" + +where python >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Python not found in PATH. + pause + exit /b 1 +) + +if not exist "venv" ( + echo Creating virtual environment... + python -m venv venv + if errorlevel 1 ( + echo [ERROR] Failed to create venv. + pause + exit /b 1 + ) +) else ( + echo venv already exists. +) + +echo. +echo Installing dependencies INTO VENV ^(global Python untouched^)... +venv\Scripts\python.exe -m pip install --upgrade pip +venv\Scripts\python.exe -m pip install -r requirements.txt + +echo. +echo Done. Run start.cmd +pause diff --git a/start.cmd b/start.cmd new file mode 100644 index 0000000..53643cd --- /dev/null +++ b/start.cmd @@ -0,0 +1,28 @@ +@echo off +rem ASCII only! cmd.exe reads .cmd in the system codepage, so any Cyrillic +rem here turns into garbage and breaks the script. Russian text belongs in +rem Python output, not in this file. +rem +rem chcp 65001 + PYTHONIOENCODING: without both, Russian log lines from Python +rem come out as mojibake in the console. +chcp 65001 >nul +set PYTHONIOENCODING=utf-8 +cls +echo ======================================== +echo hlamingo - where my stuff is +echo ======================================== +echo. + +cd /d "%~dp0" + +if not exist "venv\Scripts\python.exe" ( + echo [ERROR] venv not found. Run setup.cmd first. + pause + exit /b 1 +) + +venv\Scripts\python.exe -m hlamingo.app + +echo. +echo Server stopped. +pause diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dc1151ad29ad278c14209bc3723f89bee4f1848d GIT binary patch literal 608 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbofl1xd#WAGf*4x_~eVH8uTrR%t zE_%@vbw?mqK;R;C9)pTf-m`CO>sa?KpD0_g@4non{@#9v0uH`|iWV&G4+LbIoPiup zRPI4V6j>lg1)?~r|MBa`pWi)bVCFMg_nDDhCf8!x36AeTMyN&DNsn)VJ&*rBHMO@m zcjlO{g;8zKO#?Yu3%%LB-#$ugPPE}Fh_RHu`q(h-aK)0xJEGlI@>@JOacrZb+hQPt zVT%JK-R^ zOhL=T4=q%ufJpFvv|;;{Mx$xcYE{UgAX4R6jWFMMZm&vqM*RSLPmxUq!ca& zVu8fi+nZ6uKuUmOAhS>vgSjN?IQ;cdKqY$I;fySB%z`75rM>Pwr`C+h>kD_w&H<(z N22WQ%mvv4FO#tdg@_PUP literal 0 HcmV?d00001 diff --git a/static/favicon-32.png b/static/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..40f91aab7d5efe192897c1d86b5fe160397a411b GIT binary patch literal 157 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WQaoK8Ln>}1|A;qjU)GU$rDyH&_P|i~Cf_7og=0Tms+t;wo(n(bWb>Ni-=i*}QDW!l zZ!lrHNvHoN3A+UXJy8Z(2UoBz77IAqbTRGp3RV?mhUItkcd+mM*9WwZ!PC{xWt~$( F6974;Hf;a^ literal 0 HcmV?d00001 diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..0ceb6ec87f0da2ebd8fe49562f6b2725f21020d5 GIT binary patch literal 640 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfyv#|#WAGf*4sN9dk--PxLkbO zUG$_+m|2)>xC%Wi*276 zLSI#$7I1jBJiX=EABlw(4$suf!K@r0s|w6Y0J3VqtPr5O1Tf13$VvvYY=EpZFv|eQ z$^^4ifUF!aOTyvH7fGPg1RTDENX?S}QxkS!zG%B#8z>|?=CuMH!T}7PhJWX4pE49N zFtPys1S0qp*ok5>Fg}56!_NX6x?V`30c2x?4A2H*SPV=jfHr`1AhCW`I=tcqxf)lL hf@2w1v@c2LU-F$v(kaMxFEC{>c)I$ztaD0e0syKayvG0l literal 0 HcmV?d00001 diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..bcbdd5ee7bae096af994e4190996a46148ac3a4d GIT binary patch literal 2620 zcmds3O=uHA6n>MP)@>v&_ z*{wt8g{0JR&_WW2G~!LjxB=Q<{WfOmWS%*zr0O)76!{2q%?5iM%!`U15P@3LB=bKy zXhLdCol}b&`#5tY$cbkva}k3|ZPnQxw6|)wBdkcZN-rnP2ZHgY&8!5t0;aZq}CRi1bEmn~^v8eU^vCTzt zxw*;+Lj1PRD=2wLv5R4sKcrxZgie#kC?Yut8i`N#RO{6W2SpjS+lIZxv=+9lHFV!k zCDlHb)y`QC0$GO6Y_qdufG*^OMBJ%s%a7WAYhd(!JNgj~%;_iw?hh0u8$^*b^g5@- zF`@J7_;x%lubQA=i?2_vU~= U;8toqc$@Hxp6d%|&&KZk1Y@`4=l}o! literal 0 HcmV?d00001 diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..50fba74 --- /dev/null +++ b/static/manifest.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "hlamingo — где что лежит", + "short_name": "hlamingo", + "description": "Помнит, куда положены вещи, и находит их по названию и тегам.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0f1216", + "theme_color": "#0f1216", + "icons": [ + { + "src": "/static/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..9e50e36 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,864 @@ + + + + + + +hlamingo — где что лежит + + + + + + + + + + + + + +
+

hlamingo — где что лежит

+ + +
+ +
+ +
+
+ + + + +
+ + + + + +
+ + +
+ + +
+

Новое место добавится внутрь выбранного выше — так строится «Гараж → Стеллаж А → Полка 2».

+ + + +
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + + +
+ +
+ +
+

Фото можно добавить сразу здесь — оно прикрепится к новой вещи. + «Снять» открывает камеру, «Из галереи» — уже готовые снимки. + К уже лежащим вещам фото добавляется теми же кнопками на их карточках, + на вкладках «Найти» и «Обзор».

+ +
+ Или просто напиши фразой + +
+

Спросить тоже можно: «где мультиметр», «что в коробке A3».

+
+ +
+
+
+ + +
+ +
+

Ищет по названию, синонимам и маркировке: «стабилизатор 5 вольт» находит 7805.

+
+
+ + +
+ +
+
+
+ + + + + + + + + + diff --git a/tests/guard.py b/tests/guard.py new file mode 100644 index 0000000..84838ab --- /dev/null +++ b/tests/guard.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +"""Защита живых данных. Импортировать ПЕРВЫМ в любом тесте. + +История: тест начинался со `shutil.rmtree(DATA_DIR)`, DATA_DIR указывал на +рабочую папку, и один прогон уничтожил реальную базу с вещами. Восстановить +её не удалось. Поэтому теперь тест обязан работать в песочнице, а проверка +этого — не пожелание, а условие запуска. +""" + +import os +import sys +import atexit +import shutil +import tempfile +from pathlib import Path + + +def sandbox(): + """Переводит приложение на временную папку и возвращает её. + + Вызывать ДО импорта hlamingo.* — переменная окружения читается в config + на этапе импорта. Если живые данные всё же оказались подключены, тест + падает, а не удаляет их. + """ + root = Path(tempfile.mkdtemp(prefix="hlamingo-test-")) + os.environ["HLAMINGO_DATA_DIR"] = str(root) + + for name in list(sys.modules): + if name == "hlamingo" or name.startswith("hlamingo."): + del sys.modules[name] # чтобы config перечитал окружение + + from hlamingo import config + if config.IS_LIVE_DATA: + raise SystemExit("ОТКАЗ: приложение смотрит на живые данные. Тест не запускается.") + if root not in config.DATA_DIR.parents and config.DATA_DIR != root / "data": + raise SystemExit("ОТКАЗ: база вне песочницы (%s)." % config.DATA_DIR) + + atexit.register(lambda: shutil.rmtree(str(root), ignore_errors=True)) + return root + + +class Report(object): + """Счётчик проверок с человеческим выводом.""" + + def __init__(self): + self.ok = 0 + self.fail = 0 + + def check(self, label, cond, extra=""): + if cond: + self.ok += 1 + print(" OK %s" % label) + else: + self.fail += 1 + print(" FAIL %s %s" % (label, extra)) + + def finish(self): + print("\n%s успешно: %d, провалено: %d\n" % ("=" * 40, self.ok, self.fail)) + return 1 if self.fail else 0 diff --git a/tests/run.cmd b/tests/run.cmd new file mode 100644 index 0000000..0c13381 --- /dev/null +++ b/tests/run.cmd @@ -0,0 +1,22 @@ +@echo off +rem ASCII only - see note in start.cmd. +rem HLAMINGO_DATA_DIR points the app at a throwaway folder. This is the outer +rem safety belt: tests must never touch the real data\db.json. +chcp 65001 >nul +set PYTHONIOENCODING=utf-8 +cd /d "%~dp0.." + +if not exist "venv\Scripts\python.exe" ( + echo [ERROR] venv not found. Run setup.cmd first. + pause + exit /b 1 +) + +set HLAMINGO_DATA_DIR=%TEMP%\hlamingo-tests +venv\Scripts\python.exe tests\test_all.py +set CODE=%ERRORLEVEL% +set HLAMINGO_DATA_DIR= + +if %CODE%==0 (echo All checks passed.) else (echo FAILURES: %CODE%) +pause +exit /b %CODE% diff --git a/tests/test_all.py b/tests/test_all.py new file mode 100644 index 0000000..7d5b4a7 --- /dev/null +++ b/tests/test_all.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +"""Проверки hlamingo. Работают ТОЛЬКО в песочнице — см. tests/guard.py. + +Запуск: tests\run.cmd (или venv\Scripts\python.exe tests\test_all.py) +Часть проверок требует Ollama; без него они пропускаются, а не падают. +""" + +import io +import os +import sys +import struct +import zlib + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from guard import sandbox, Report # noqa: E402 +SANDBOX = sandbox() # обязательно ДО импорта hlamingo + +from hlamingo import store, search, brain, ollama, config # noqa: E402 +from hlamingo.app import app # noqa: E402 + +if sys.version_info >= (3, 7): + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + +r = Report() +c = app.test_client() +HAS_AI = ollama.check() + + +def j(method, url, **body): + return c.open(url, method=method, json=body or None).get_json() + + +def png(): + def ch(t, d): + raw = t + d + return struct.pack(">I", len(d)) + raw + struct.pack(">I", zlib.crc32(raw) & 0xffffffff) + return (b"\x89PNG\r\n\x1a\n" + ch(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)) + + ch(b"IDAT", zlib.compress(b"\x00\x00\x00\x00")) + ch(b"IEND", b"")) + + +def upload(target, ident): + field = "id" if target == "item" else "code" + return c.post("/api/photo", content_type="multipart/form-data", + data={"target": target, field: ident, + "photo": (io.BytesIO(png()), "x.png")}).get_json() + + +print("Песочница: %s" % SANDBOX) +print("Ollama: %s\n" % ("доступен" if HAS_AI else "нет — часть проверок пропущу")) + +# ============================================================ +print("== Места: иерархия ==") +garage = j("POST", "/api/places", name="Гараж")["id"] +rack = j("POST", "/api/places", name="Стеллаж А", parent=garage)["id"] +shelf = j("POST", "/api/places", name="Полка 2", parent=rack)["id"] +pantry = j("POST", "/api/places", name="Кладовка")["id"] +d = c.get("/api/places").get_json() +r.check("путь собирается", any(p["path"] == "Гараж → Стеллаж А → Полка 2" for p in d["places"]), d) +r.check("повтор не плодит дубли", + j("POST", "/api/places", name="Стеллаж А", parent=garage)["id"] == rack) +r.check("чужой родитель — 404", + c.post("/api/places", json={"name": "X", "parent": "нет"}).status_code == 404) + +print("\n== Ёмкости ==") +d = j("POST", "/api/containers", code="a3", place=shelf) +r.check("код к верхнему регистру", d["code"] == "A3", d) +r.check("адрес коробки", d["address"] == "Гараж → Стеллаж А → Полка 2 → коробка A3", d) +r.check("кириллическая А3 = латинская A3", store.norm_code("А3") == "A3") + +print("\n== Положить ==") +d = j("POST", "/api/put", name="Стабилизатор 7805", qty="12 шт", place=shelf, container="A3") +item = d["item"]["id"] +r.check("записалось", d.get("ok"), d) +r.check("адрес", d["item"]["address"].endswith("коробка A3"), d["item"]["address"]) +if HAS_AI: + r.check("синонимы от модели", len(d["item"]["aliases"]) > 0, d["item"]["aliases"]) +d = j("POST", "/api/put", names=["отвёртка", "плоскогубцы"], place=shelf, container="A3") +r.check("две вещи разом", len(d["items"]) == 2, d) +d = j("POST", "/api/put", name="лопата", place=garage) +r.check("вещь без коробки", d["item"]["address"] == "Гараж", d["item"]["address"]) + +print("\n== Поиск ==") +r.check("по названию", c.get("/api/find?q=7805").get_json()["items"][0]["id"] == item) +r.check("по числу словоформ", c.get("/api/find?q=отвёртки").get_json()["items"], "отвёртки") +r.check("чужого не находит", c.get("/api/find?q=картошка").get_json()["items"] == []) +if HAS_AI: + hits = c.get("/api/find?q=стабилизатор на 5 вольт").get_json()["items"] + r.check("по смыслу через синонимы", hits and hits[0]["id"] == item, hits) + +print("\n== Забрать и переместить ==") +d = j("POST", "/api/take", id=item) +r.check("на руках", d["item"]["state"] == "taken", d["item"]) +r.check("из коробки исчез", + all(i["id"] != item for i in c.get("/api/container/A3").get_json()["items"])) +r.check("но находится", "на руках" in c.get("/api/find?q=7805").get_json()["items"][0]["address"]) +d = j("POST", "/api/move", id=item, container="A3", place=shelf) +r.check("вернулся в коробку", d["item"]["state"] == "stored", d["item"]) +r.check("без назначения — 400", + c.post("/api/move", json={"id": item}).status_code == 400) + +print("\n== Правка предмета ==") +d = j("PATCH", "/api/item/" + item, name="Стабилизатор 7805 (TO-220)", qty="10 шт", note="в ленте") +r.check("поля обновились", + d["item"]["name"].endswith("(TO-220)") and d["item"]["qty"] == "10 шт", d["item"]) +d = j("PATCH", "/api/item/" + item, aliases=["7805", "LM7805"]) +r.check("теги заменяются", d["item"]["aliases"] == ["7805", "LM7805"], d["item"]["aliases"]) +d = j("PATCH", "/api/item/" + item, aliases="строкой, через, запятые") +r.check("теги строкой", len(d["item"]["aliases"]) == 3, d["item"]["aliases"]) +r.check("пустое имя — 400", + c.open("/api/item/" + item, method="PATCH", json={"name": " "}).status_code == 400) +r.check("занятое имя — 400", + c.open("/api/item/" + item, method="PATCH", json={"name": "лопата"}).status_code == 400) + +print("\n== Фото ==") +d = upload("item", item) +fname = d["photo"] +r.check("файл на диске", (config.PHOTOS_DIR / fname).exists(), config.PHOTOS_DIR) +r.check("имя своё, не клиентское", "x" != fname[0], fname) +r.check("видно в карточке", c.get("/api/find?q=7805").get_json()["items"][0]["photo"] == fname) +d = j("DELETE", "/api/photo", target="item", id=item) +r.check("фото убрано", d.get("ok"), d) +r.check("файл удалён", not (config.PHOTOS_DIR / fname).exists()) +r.check("повторно — не ошибка", j("DELETE", "/api/photo", target="item", id=item).get("ok")) +d = upload("container", "A3") +r.check("фото коробки", c.get("/api/container/A3").get_json()["photo"] == d["photo"], d) +r.check("битый формат — 400", + c.post("/api/photo", content_type="multipart/form-data", + data={"target": "item", "id": item, + "photo": (io.BytesIO(b"MZ"), "../evil.exe")}).status_code == 400) + +print("\n== Правка ёмкости ==") +d = j("PATCH", "/api/containers/A3", kind="ящик", place=pantry, note="синий") +r.check("тип и место", d["address"] == "Кладовка → ящик A3", d) +d = j("PATCH", "/api/containers/A3", code="C5") +r.check("код изменён", d["code"] == "C5", d) +r.check("вещи переехали с кодом", len(c.get("/api/container/C5").get_json()["items"]) == 3, + c.get("/api/container/C5").get_json()) +r.check("старого кода нет", c.get("/api/container/A3").get_json()["items"] == []) +j("POST", "/api/containers", code="D1", place=garage) +r.check("занятый код — 400", + c.open("/api/containers/C5", method="PATCH", json={"code": "D1"}).status_code == 400) + +print("\n== Удаление ёмкости не теряет вещи ==") +d = j("DELETE", "/api/containers/C5") +r.check("вещи переехали в её место", len(d["moved"]) == 3, d) +r.check("вещь теперь в месте коробки", + c.get("/api/find?q=7805").get_json()["items"][0]["address"] == "Кладовка") + +print("\n== Правка места ==") +r.check("переименование", j("PATCH", "/api/places/" + shelf, name="Полка 5")["path"] + == "Гараж → Стеллаж А → Полка 5") +r.check("сам в себя — 400", + c.open("/api/places/" + garage, method="PATCH", + json={"parent": garage}).status_code == 400) +r.check("внутрь потомка — 400", + c.open("/api/places/" + garage, method="PATCH", + json={"parent": shelf}).status_code == 400) +r.check("непустое не удалить", + c.open("/api/places/" + garage, method="DELETE").status_code == 409) +empty = j("POST", "/api/places", name="Балкон")["id"] +r.check("пустое удаляется", j("DELETE", "/api/places/" + empty).get("ok")) + +print("\n== Удаление предмета ==") +d = upload("item", item) +fname = d["photo"] +r.check("удалён", c.delete("/api/item/" + item).get_json().get("ok")) +r.check("фото тоже убрано", not (config.PHOTOS_DIR / fname).exists()) +r.check("404 на несуществующем", c.delete("/api/item/нет").status_code == 404) + +print("\n== Устойчивость ==") +config.DB_FILE.write_text("{ битый json", encoding="utf-8") +r.check("битая база не роняет", c.get("/api/tree").status_code == 200) +r.check("пустой ввод не роняет", "reply" in j("POST", "/api/say", text=" ")) + +print("\n== Разбор фразы ==") +p = brain.fallback_parse("положил изоленту в коробку B2 на полке 1 в гараже") +r.check("регулярки: put", p["action"] == "put" and store.norm_code(p["container"]) == "B2", p) +p = brain.fallback_parse("где мультиметр") +r.check("регулярки: find", p["action"] == "find" and p["item"] == "мультиметр", p) +p = brain.fallback_parse("переложил дрель из коробки A1 в коробку C2") +r.check("«из» не попадает в название", "из" not in (p["item"] or ""), p["item"]) +if HAS_AI: + p = brain.parse("положил стабилизаторы 7805, 12 штук, в коробку A3 на второй полке в гараже") + r.check("модель: put + коробка", p["action"] == "put" and store.norm_code(p["container"]) == "A3", p) + p = brain.parse("куда я положил зарядку от шуруповёрта") + r.check("вопрос не пишется как факт", p["action"] == "find", p) + p = brain.parse("переложил дрель из коробки A1 в коробку C2") + r.check("берётся место назначения", store.norm_code(p["container"] or "") == "C2", p) + +sys.exit(r.finish()) diff --git a/tools/make_icons.py b/tools/make_icons.py new file mode 100644 index 0000000..d9ee6e7 --- /dev/null +++ b/tools/make_icons.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +"""Рисует иконки приложения и пишет их в static/. + +Свой минимальный PNG-энкодер вместо Pillow: иконки генерируются один раз, +и тащить зависимость в проект ради этого не хочется. + +Запуск: venv\\Scripts\\python.exe tools\\make_icons.py +""" + +import os +import zlib +import struct +from pathlib import Path + +OUT = Path(__file__).resolve().parent.parent / "static" + +BG = (0xFF, 0x7A, 0x9C) # розовый акцент интерфейса +BOX = (0xFF, 0xFF, 0xFF) # передняя стенка +RIM = (0xEC, 0xE2, 0xE6) # борта, видимые сверху — чуть темнее для объёма +INSIDE = (0xC9, 0x3B, 0x63) # внутренность: коробка открыта, туда и кладут + +# Иконку на Android обрезают по маске (круг, скруглённый квадрат), поэтому всё +# значимое держим внутри центральных ~80%: 10% с каждой стороны могут срезать. +SAFE = 0.80 + + +def png(path, size, pixels): + """Пишет RGB-PNG размером size×size из плоского списка (r,g,b).""" + raw = bytearray() + for y in range(size): + raw.append(0) # фильтр строки: none + for x in range(size): + raw += bytes(pixels[y * size + x]) + + def chunk(tag, data): + body = tag + data + return (struct.pack(">I", len(data)) + body + + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)) + + ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8 бит, RGB + with open(str(path), "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n") + f.write(chunk(b"IHDR", ihdr)) + f.write(chunk(b"IDAT", zlib.compress(bytes(raw), 9))) + f.write(chunk(b"IEND", b"")) + + +def draw(size): + """Коробка с откинутыми клапанами — узнаётся даже в 32 пикселя.""" + px = [BG] * (size * size) + + def rect(x0, y0, x1, y1, color): + # Координаты в долях от размера: одна отрисовка работает для всех иконок. + ax, ay = int(x0 * size), int(y0 * size) + bx, by = int(x1 * size), int(y1 * size) + for y in range(max(0, ay), min(size, by)): + row = y * size + for x in range(max(0, ax), min(size, bx)): + px[row + x] = color + + def trapezoid(y0, y1, top_l, top_r, bot_l, bot_r, color): + """Заливка трапеции: боковые края интерполируются по высоте. + + Именно она даёт перспективу — без неё коробка выглядит плоским окном. + """ + ay, by = int(y0 * size), int(y1 * size) + if by <= ay: + return + for y in range(max(0, ay), min(size, by)): + t = (y - ay) / float(by - ay) + xl = int((top_l + (bot_l - top_l) * t) * size) + xr = int((top_r + (bot_r - top_r) * t) * size) + row = y * size + for x in range(max(0, xl), min(size, xr)): + px[row + x] = color + + m = (1.0 - SAFE) / 2.0 # отступ безопасной зоны + s = SAFE + + def f(v): + return m + v * s + + rim_top, rim_bot = f(0.13), f(0.44) # верхние борта, видимые сверху + body_bot = f(0.90) + + # Борта: сверху уже, снизу шире — взгляд сверху под углом. + trapezoid(rim_top, rim_bot, f(0.24), f(0.76), f(0.09), f(0.91), RIM) + # Внутренность — вырез в бортах. Виден проём: коробка открыта. + trapezoid(rim_top + 0.045 * s, rim_bot - 0.035 * s, + f(0.31), f(0.69), f(0.19), f(0.81), INSIDE) + # Передняя стенка. + rect(f(0.09), rim_bot, f(0.91), body_bot, BOX) + # Тонкая тень под кромкой — отделяет стенку от бортов. + rect(f(0.09), rim_bot, f(0.91), rim_bot + 0.02 * s, RIM) + return px + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + for size, name in ((512, "icon-512.png"), (192, "icon-192.png"), + (180, "apple-touch-icon.png"), (32, "favicon-32.png")): + png(OUT / name, size, draw(size)) + print(" %s %d×%d %d байт" % (name, size, size, (OUT / name).stat().st_size)) + + +if __name__ == "__main__": + main()