hlamingo — помощник «где что лежит»
Веб-помощник для домашнего склада: помнит, в какой коробке и в каком месте лежит вещь, и находит её по названию, тегам и маркировке. Открывается с телефона, интеллект — локальная Ollama. Основное: - структурный ввод «место → ёмкость → предмет» и кнопки Положить / Забрать / Переместить / Удалить; - иерархия мест (Гараж → Стеллаж А → Полка 2), ёмкости с кодом «буква+цифра», состояние «на руках» для взятых вещей; - поиск по словам с учётом русских окончаний, теги от модели расширяют словарь предмета, смысловой поиск — вспомогательный (замеры в search.py); - фото к вещам и коробкам: съёмка и выбор из галереи; - правка и удаление всех сущностей, удаление коробки не теряет её содержимое; - работает и без Ollama: отключаются только разбор фраз, теги и поиск по смыслу. Данные (data/, static/photos/) и локальный конфиг (.env) в репозиторий не попадают. Тесты работают только в песочнице: путь к данным берётся из окружения, tests/guard.py отказывается запускаться на живых данных. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
+27
@@ -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
|
||||
@@ -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.
|
||||
@@ -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, в котором опции «Камера» нет вообще — только готовые файлы.
|
||||
Одним полем оба сценария не покрыть, поэтому в разметке два `<input>`:
|
||||
с `capture` и без.
|
||||
|
||||
Снимок ужимается в браузере до 1280 px, файлы лежат в `static\photos\`.
|
||||
Новое фото заменяет прежнее; «🗑 Фото» убирает снимок и удаляет файл с диска.
|
||||
|
||||
`getUserMedia` не используется — он требует HTTPS и по локальному адресу
|
||||
`http://192.168.0.x:5000` просто не запустился бы.
|
||||
|
||||
## Как работает поиск
|
||||
|
||||
Три сигнала, по убыванию надёжности:
|
||||
|
||||
1. **По словам** — основа. Запрос и карточка режутся на слова, слова грубо
|
||||
приводятся к основе, так что «стабилизаторы», «стабилизатора» и
|
||||
«стабилизатор» — одно и то же. Работает всегда, даже без Ollama.
|
||||
2. **Синонимы от модели.** При записи новой вещи модель придумывает
|
||||
альтернативные названия: «изолента» → «изоляционная лента», «ПВХ-лента»;
|
||||
«Стабилизатор 7805» → «LM7805», «стабилизатор 5 вольт», «КР142ЕН5А».
|
||||
Они лежат в карточке и расширяют словарь предмета.
|
||||
3. **По смыслу (эмбеддинги)** — только если по словам ничего не нашлось,
|
||||
с высоким порогом и с пометкой «похоже по смыслу».
|
||||
|
||||
Почему семантика на третьем месте, а не на первом: замер на `nomic-embed-text`
|
||||
показал, что на русском он почти не разделяет смыслы — «велосипедная камера»
|
||||
давала мультиметру 0.689, а верное попадание на «паяльник» — 0.691. Порогом
|
||||
такое не разделить, поэтому лучше честное «не нашёл», чем уверенно показанное
|
||||
не то. Хочется настоящего смыслового поиска — нужен многоязычный эмбеддер:
|
||||
|
||||
```
|
||||
ollama pull bge-m3
|
||||
```
|
||||
|
||||
затем в `hlamingo\config.py` сменить `EMBED_MODEL`, обнулить `EMBED_DOC_PREFIX`
|
||||
и `EMBED_QUERY_PREFIX` и **один раз пересчитать векторы** — `search.reindex(db)`.
|
||||
Пересчёт обязателен: векторы разных моделей несовместимы, и смешанная база
|
||||
тихо испортит поиск, ничем себя не выдав.
|
||||
|
||||
## Что если Ollama выключен
|
||||
|
||||
Если Ollama на отдельной машине, она бывает выключена. Приложение это переживает:
|
||||
наверху появляется предупреждение, а работает почти всё.
|
||||
|
||||
| | Ollama есть | Ollama нет |
|
||||
|---|---|---|
|
||||
| Кнопки, места, коробки, фото, обзор | да | да |
|
||||
| Поиск по названию и синонимам | да | да |
|
||||
| Синонимы для новых вещей | да | нет |
|
||||
| Разбор свободных фраз | модель | упрощённо, регулярками |
|
||||
| Поиск по смыслу | да | нет |
|
||||
|
||||
Модели: `qwen2.5:7b` для разбора фраз (в конфиге — `CHAT_MODEL`).
|
||||
Не `omnicoder-9b`: тот ризонер, пишет `<think>` прямо в ответ и медленнее.
|
||||
|
||||
## Устройство
|
||||
|
||||
```
|
||||
hlamingo\
|
||||
config.py адрес Ollama, модели, пути, порог поиска
|
||||
ollama.py клиент: chat / embeddings / проверка доступности
|
||||
store.py места (иерархия), ёмкости, предметы; атомарная запись базы
|
||||
search.py поиск по словам + смысловая догадка
|
||||
brain.py разбор фразы моделью, синонимы, фолбэк на регулярки
|
||||
app.py Flask: маршруты
|
||||
templates\index.html вся страница
|
||||
data\db.json база (в .gitignore)
|
||||
static\photos\ фотографии (в .gitignore)
|
||||
```
|
||||
|
||||
Три сущности вместо плоского «предмет → строка места»:
|
||||
|
||||
- **место** — узел иерархии, ссылается на родителя;
|
||||
- **ёмкость** — коробка с кодом, стоит в каком-то месте;
|
||||
- **предмет** — лежит либо в ёмкости, либо прямо в месте (лопата в углу),
|
||||
либо «на руках».
|
||||
|
||||
Полный адрес собирается подъёмом по родителям: `Гараж → Стеллаж А → Полка 2 →
|
||||
коробка A3`. У каждого предмета хранится история перемещений.
|
||||
|
||||
Мелочи, которые заметно экономят нервы:
|
||||
|
||||
- **Коды коробок терпимы к раскладке.** Коробка подписана от руки «А3»
|
||||
кириллицей, а в поиске набирается «A3» латиницей — для hlamingo это одна
|
||||
и та же коробка.
|
||||
- **Резервные копии.** Перед каждой перезаписью прошлое состояние базы уходит
|
||||
в `data\backups\db-ГГГГММДД-ЧЧММ.json`, хранятся последние 40. Откат — просто
|
||||
скопировать нужный файл обратно в `data\db.json`. Полноценный бэкап папки они
|
||||
не заменяют, но спасают от затёртой или испорченной базы: однажды она уже
|
||||
потерялась целиком, и откатываться было некуда.
|
||||
- **Запись базы атомарная** (временный файл + подмена): обрыв на середине
|
||||
не уничтожит всё, что записано про вещи.
|
||||
- **Битая база не роняет приложение** — запускается с пустой, файл не трогает.
|
||||
- **Имена файлов фото генерируются сервером**, имя от клиента не используется.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Аутентификации нет: помощник рассчитан на домашнюю локальную сеть.
|
||||
- Количество не пересчитывается при «забрать» — состояние вещи целиком
|
||||
«в коробке» или «на руках».
|
||||
- Фото не распознаётся, это иллюстрация. Автоопределение содержимого потребует
|
||||
vision-модели (`llava`, `qwen2.5-vl`) — на сервере такой сейчас нет.
|
||||
|
||||
## Ярлык на телефоне
|
||||
|
||||
Откройте страницу и в меню браузера выберите «Добавить на главный экран».
|
||||
Ярлык получит иконку коробки, а приложение откроется без адресной строки
|
||||
(`display: standalone` в манифесте).
|
||||
|
||||
Иконки лежат в `static\` и рисуются генератором — при желании перерисовать:
|
||||
|
||||
```
|
||||
venv\Scripts\python.exe tools\make_icons.py
|
||||
```
|
||||
|
||||
`tools\make_icons.py` пишет PNG своим минимальным энкодером, без Pillow:
|
||||
иконки нужны один раз, и зависимость ради них в проекте лишняя. Содержимое
|
||||
держится внутри центральных 80 % — Android обрезает иконку по маске
|
||||
(круг, скруглённый квадрат), и края могут срезаться.
|
||||
|
||||
Что зачем нужно:
|
||||
|
||||
| Файл | Кому |
|
||||
|---|---|
|
||||
| `manifest.webmanifest` | Android: имя, иконка ярлыка, запуск без адресной строки |
|
||||
| `apple-touch-icon.png` | iOS: иконка ярлыка (манифест там не используется) |
|
||||
| `icon-192.png`, `icon-512.png` | иконки из манифеста |
|
||||
| `favicon-32.png` | вкладка браузера и запрос `/favicon.ico` |
|
||||
|
||||
Манифест отдаётся отдельным маршрутом с типом `application/manifest+json`:
|
||||
из `static/` он ушёл бы как обычный файл, и браузер мог не признать его
|
||||
манифестом.
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
tests\run.cmd
|
||||
```
|
||||
|
||||
56 проверок: иерархия мест, ёмкости, поиск, правка, фото, разбор фраз.
|
||||
Часть требует Ollama — без него они пропускаются, а не падают.
|
||||
|
||||
**Тесты работают только в песочнице.** `tests\run.cmd` выставляет
|
||||
`HLAMINGO_DATA_DIR` на временную папку, а `tests\guard.py` отказывается
|
||||
запускаться, если приложение всё же смотрит на настоящие данные.
|
||||
|
||||
Это не перестраховка: однажды тест начинался с удаления рабочей папки `data`,
|
||||
и один прогон уничтожил реальную базу с вещами. Восстановить её не удалось —
|
||||
`os.replace` не оставляет следов, в корзину `rmtree` не кладёт. Уцелели только
|
||||
фотографии, потому что их файлы тест не трогал. Поэтому путь к данным теперь
|
||||
берётся из окружения, и никакой тест не может дотянуться до живой базы.
|
||||
|
||||
## Отладка
|
||||
|
||||
Живо ли Ollama и какие модели установлены (подставьте свой адрес):
|
||||
|
||||
```
|
||||
curl http://localhost:11434/api/tags
|
||||
```
|
||||
|
||||
Что об этом думает само приложение: `http://localhost:5000/api/health` —
|
||||
отдаёт адрес Ollama, выбранную модель и доступность.
|
||||
@@ -0,0 +1 @@
|
||||
"""hlamingo — помощник, который помнит, куда что положено."""
|
||||
+631
@@ -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/<code>")
|
||||
def api_container(code):
|
||||
return jsonify(_do_list_container(store.load(), code))
|
||||
|
||||
|
||||
@app.route("/api/item/<item_id>", 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/<item_id>", 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/<code>", 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/<code>", 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/<place_id>", 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/<place_id>", 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()
|
||||
@@ -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"<think(?:ing)?>.*?</think(?:ing)?>", re.IGNORECASE | re.DOTALL)
|
||||
_THINK_CLOSE_RE = re.compile(r"</think(?:ing)?>", re.IGNORECASE)
|
||||
_THINK_OPEN_RE = re.compile(r"<think(?:ing)?>", re.IGNORECASE)
|
||||
|
||||
|
||||
def _strip_thoughts(content):
|
||||
"""Убирает <think>…</think>: модели-ризонеры пишут их прямо в 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<item>.+?)"
|
||||
r"\s+(?:в|во|на|под|за)\s+(?P<place>.+)$", re.IGNORECASE)
|
||||
_FIND_RE = re.compile(
|
||||
r"^(?:где|найди|найти|куда\s+я\s+положил|куда\s+положил|поищи|ищу)\s+"
|
||||
r"(?:мой|моя|моё|мои|the)?\s*(?P<item>.+?)[\?\.!]*$", re.IGNORECASE)
|
||||
_LIST_BOX_RE = re.compile(
|
||||
r"^(?:что|чего|покажи)\s+(?:лежит\s+)?(?:в|во)\s+(?:коробке|ящике|боксе|)\s*"
|
||||
r"(?P<code>[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]
|
||||
@@ -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 — не ризонер: не тратит время на <think> и надёжнее держит формат 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"}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)}
|
||||
+189
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
flask
|
||||
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 608 B |
Binary file not shown.
|
After Width: | Height: | Size: 157 B |
Binary file not shown.
|
After Width: | Height: | Size: 640 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0f1216">
|
||||
<title>hlamingo — где что лежит</title>
|
||||
<!-- Иконки: Android берёт их из манифеста, iOS — из apple-touch-icon.
|
||||
Оба хотят PNG, поэтому SVG во вкладке тут не годится. -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="hlamingo">
|
||||
<style>
|
||||
:root {
|
||||
--bg:#0f1216; --card:#171c22; --line:#2a333d; --fg:#e8edf2; --dim:#93a1b0;
|
||||
--accent:#ff7a9c; --ok:#4ade80; --warn:#fbbf24; --radius:14px;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg:#f4f6f8; --card:#fff; --line:#dde3ea; --fg:#151b22; --dim:#5d6b7a; --accent:#d6336c; }
|
||||
}
|
||||
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
|
||||
/* Ни одна картинка не должна расширять страницу: снимок с телефона — 1280 px,
|
||||
и без этого правила появляется горизонтальная прокрутка. */
|
||||
img { max-width:100%; height:auto; }
|
||||
body { margin:0; background:var(--bg); color:var(--fg);
|
||||
font:17px/1.5 -apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
padding:0 0 env(safe-area-inset-bottom);
|
||||
/* Длинный артикул без пробелов («КР142ЕН5А-XYZ-000123») не должен
|
||||
распирать страницу — пусть переносится. */
|
||||
overflow-wrap:break-word; }
|
||||
header { position:sticky; top:0; z-index:10; background:var(--bg);
|
||||
padding:14px 16px 0; border-bottom:1px solid var(--line); }
|
||||
h1 { margin:0 0 10px; font-size:20px; } h1 span { color:var(--accent); }
|
||||
#banner { display:none; margin:0 0 10px; padding:9px 12px; border-radius:10px;
|
||||
background:#3a2e10; color:var(--warn); font-size:14px; }
|
||||
nav { display:flex; gap:6px; }
|
||||
nav button { flex:1; padding:11px 4px; font-size:15px; font-weight:600; background:none;
|
||||
border:none; border-bottom:3px solid transparent; color:var(--dim); cursor:pointer; }
|
||||
nav button.on { color:var(--fg); border-bottom-color:var(--accent); }
|
||||
main { padding:16px; max-width:720px; margin:0 auto; }
|
||||
.tab { display:none; } .tab.on { display:block; }
|
||||
|
||||
label { display:block; margin:14px 0 5px; font-size:14px; color:var(--dim); }
|
||||
input, select, textarea {
|
||||
width:100%; padding:13px; font:inherit; color:var(--fg); background:var(--card);
|
||||
border:1px solid var(--line); border-radius:var(--radius); appearance:none;
|
||||
}
|
||||
select { background-image:linear-gradient(45deg,transparent 50%,var(--dim) 50%),
|
||||
linear-gradient(135deg,var(--dim) 50%,transparent 50%);
|
||||
background-position:calc(100% - 20px) 22px, calc(100% - 14px) 22px;
|
||||
background-size:6px 6px,6px 6px; background-repeat:no-repeat; padding-right:40px; }
|
||||
textarea { min-height:76px; resize:vertical; }
|
||||
.row { display:flex; gap:10px; }
|
||||
.row > * { flex:1; }
|
||||
.mt { margin-top:12px; }
|
||||
|
||||
/* переключатель действия */
|
||||
.modes { display:grid; grid-template-columns:repeat(4,1fr); gap:6px; margin-bottom:4px; }
|
||||
.modes button {
|
||||
padding:12px 2px; font:600 14px/1.2 inherit; color:var(--dim); cursor:pointer;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:12px;
|
||||
}
|
||||
.modes button.on { color:#fff; background:var(--accent); border-color:var(--accent); }
|
||||
.modes button.on.danger { background:#c0392b; border-color:#c0392b; }
|
||||
|
||||
button.act { width:100%; padding:16px; font:600 17px/1 inherit; color:#fff; cursor:pointer;
|
||||
background:var(--accent); border:none; border-radius:var(--radius); }
|
||||
button.act.danger { background:#c0392b; }
|
||||
button.act.ghost { background:var(--card); color:var(--fg); border:1px solid var(--line); }
|
||||
button.act:disabled { opacity:.5; }
|
||||
.small { padding:11px; font-size:15px; }
|
||||
.hint { margin:10px 2px 0; color:var(--dim); font-size:14px; }
|
||||
details { margin-top:18px; border-top:1px solid var(--line); padding-top:12px; }
|
||||
summary { cursor:pointer; color:var(--dim); font-size:15px; }
|
||||
|
||||
#reply { margin-top:16px; padding:14px; border-radius:var(--radius); background:var(--card);
|
||||
border-left:4px solid var(--accent); display:none; white-space:pre-wrap; }
|
||||
.card { margin-top:12px; padding:13px; background:var(--card);
|
||||
border:1px solid var(--line); border-radius:var(--radius); }
|
||||
.card h3 { margin:0 0 4px; font-size:17px; }
|
||||
.addr { color:var(--accent); font-size:15px; }
|
||||
.addr.taken { color:var(--warn); }
|
||||
.meta { color:var(--dim); font-size:13px; margin-top:5px; }
|
||||
.tags { margin-top:7px; display:flex; flex-wrap:wrap; gap:5px; }
|
||||
.tag { font-size:12px; padding:3px 8px; border-radius:20px; background:var(--bg);
|
||||
border:1px solid var(--line); color:var(--dim); }
|
||||
.photo img { margin-top:10px; width:100%; border-radius:10px; display:block; }
|
||||
/* Превью в форме не должно занимать весь экран — до него ещё кнопка записи. */
|
||||
#formPhotoPreview img { max-height:40vh; width:auto; object-fit:contain; }
|
||||
.acts { display:flex; gap:8px; margin-top:10px; flex-wrap:wrap; }
|
||||
.acts button { flex:1 1 auto; min-width:76px; padding:10px 4px;
|
||||
font:600 14px/1.2 inherit; cursor:pointer;
|
||||
background:var(--bg); color:var(--fg); border:1px solid var(--line);
|
||||
border-radius:10px; }
|
||||
.acts button.narrow { flex:0 0 46px; min-width:46px; font-size:16px; }
|
||||
.acts button.warn { color:#e06c5a; }
|
||||
.edit { margin-top:12px; padding-top:12px; border-top:1px solid var(--line); }
|
||||
.edit label { margin:10px 0 4px; }
|
||||
.edit input { padding:11px; }
|
||||
.tag.del { cursor:pointer; padding-right:6px; }
|
||||
.tag.del b { color:#e06c5a; margin-left:6px; font-weight:700; }
|
||||
.editHint { color:var(--dim); font-size:13px; margin:6px 0 0; }
|
||||
.why { float:right; font-size:12px; color:var(--dim); }
|
||||
.pick { cursor:pointer; }
|
||||
.pick:active { border-color:var(--accent); }
|
||||
|
||||
/* Глубокая иерархия наращивает отступ на каждом уровне: прокручиваем сам
|
||||
блок, а не всю страницу. */
|
||||
.tree { overflow-x:auto; }
|
||||
.tree ul { list-style:none; margin:4px 0 0; padding-left:16px; border-left:1px solid var(--line); }
|
||||
.tree li { margin:6px 0; }
|
||||
.place { font-weight:600; }
|
||||
.editPlace { cursor:pointer; }
|
||||
.box { display:inline-block; padding:4px 10px; margin:3px 5px 3px 0; background:var(--card);
|
||||
border:1px solid var(--line); border-radius:8px; font-size:15px; cursor:pointer; }
|
||||
.box b { color:var(--accent); }
|
||||
.empty { color:var(--dim); padding:24px 4px; text-align:center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>hla<span>mingo</span> — где что лежит</h1>
|
||||
<div id="banner"></div>
|
||||
<nav>
|
||||
<button class="on" data-tab="do">Действие</button>
|
||||
<button data-tab="find">Найти</button>
|
||||
<button data-tab="tree">Обзор</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ================= Действие ================= -->
|
||||
<section class="tab on" id="tab-do">
|
||||
<div class="modes">
|
||||
<button class="on" data-mode="put">Положить</button>
|
||||
<button data-mode="take">Забрать</button>
|
||||
<button data-mode="move">Переместить</button>
|
||||
<button data-mode="delete" class="danger">Удалить</button>
|
||||
</div>
|
||||
|
||||
<!-- выбор вещи: для забрать / переместить / удалить -->
|
||||
<div id="pickWrap" hidden>
|
||||
<label>Какая вещь</label>
|
||||
<input type="text" id="pickQuery" placeholder="начни вводить название" autocomplete="off">
|
||||
<div id="pickList"></div>
|
||||
<div id="picked" class="card" hidden></div>
|
||||
</div>
|
||||
|
||||
<!-- куда: для положить / переместить -->
|
||||
<div id="whereWrap">
|
||||
<label>Место</label>
|
||||
<select id="placeSel"></select>
|
||||
<div class="row mt">
|
||||
<input type="text" id="newPlaceName" placeholder="новое место, напр. Полка 2">
|
||||
<button class="act ghost small" id="addPlaceBtn" style="flex:0 0 38%">Добавить</button>
|
||||
</div>
|
||||
<p class="hint">Новое место добавится внутрь выбранного выше — так строится «Гараж → Стеллаж А → Полка 2».</p>
|
||||
|
||||
<label>Ёмкость</label>
|
||||
<select id="boxSel"></select>
|
||||
<div class="row mt">
|
||||
<input type="text" id="newBoxCode" placeholder="код коробки, напр. A3">
|
||||
<button class="act ghost small" id="addBoxBtn" style="flex:0 0 38%">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- что: для положить -->
|
||||
<div id="whatWrap">
|
||||
<label>Предмет</label>
|
||||
<input type="text" id="itemName" placeholder="стабилизатор 7805" autocomplete="off">
|
||||
<div class="row mt">
|
||||
<input type="text" id="itemQty" placeholder="количество, напр. 12 шт">
|
||||
<input type="text" id="itemNote" placeholder="заметка">
|
||||
</div>
|
||||
|
||||
<label>Фото (можно сразу)</label>
|
||||
<div class="row">
|
||||
<button class="act ghost small" id="formCamBtn">📷 Снять</button>
|
||||
<button class="act ghost small" id="formGalBtn">🖼 Из галереи</button>
|
||||
<button class="act ghost small" id="formPhotoClear" style="flex:0 0 26%" hidden>Убрать</button>
|
||||
</div>
|
||||
<div id="formPhotoPreview" class="photo" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="mt"><button class="act" id="goBtn">Положить</button></div>
|
||||
<p class="hint">Фото можно добавить сразу здесь — оно прикрепится к новой вещи.
|
||||
«Снять» открывает камеру, «Из галереи» — уже готовые снимки.
|
||||
К уже лежащим вещам фото добавляется теми же кнопками на их карточках,
|
||||
на вкладках «Найти» и «Обзор».</p>
|
||||
|
||||
<details>
|
||||
<summary>Или просто напиши фразой</summary>
|
||||
<textarea id="sayText" class="mt" placeholder="положил стабилизаторы 7805, 12 штук, в коробку A3 на второй полке стеллажа А в гараже"></textarea>
|
||||
<div class="mt"><button class="act ghost" id="sayBtn">Разобрать и записать</button></div>
|
||||
<p class="hint">Спросить тоже можно: «где мультиметр», «что в коробке A3».</p>
|
||||
</details>
|
||||
|
||||
<div id="reply"></div>
|
||||
<div id="doCards"></div>
|
||||
</section>
|
||||
|
||||
<!-- ================= Найти ================= -->
|
||||
<section class="tab" id="tab-find">
|
||||
<input type="text" id="findText" placeholder="стабилизатор на 5 вольт" autocomplete="off">
|
||||
<div class="mt"><button class="act" id="findBtn">Искать</button></div>
|
||||
<p class="hint">Ищет по названию, синонимам и маркировке: «стабилизатор 5 вольт» находит 7805.</p>
|
||||
<div id="findCards"></div>
|
||||
</section>
|
||||
|
||||
<!-- ================= Обзор ================= -->
|
||||
<section class="tab" id="tab-tree">
|
||||
<button class="act ghost" id="treeBtn">Обновить</button>
|
||||
<div id="treeBox" class="tree"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Два поля, а не одно, и это вынужденно.
|
||||
С capture телефон открывает камеру, но галерея недоступна.
|
||||
Без capture Android 13+ показывает системный Photo Picker, в котором
|
||||
опции «Камера» нет вообще — только готовые файлы.
|
||||
Поэтому съёмка и выбор из галереи разведены по отдельным кнопкам. -->
|
||||
<input type="file" id="camInput" accept="image/*" capture="environment" hidden>
|
||||
<input type="file" id="galInput" accept="image/*" hidden>
|
||||
<input type="file" id="formCamInput" accept="image/*" capture="environment" hidden>
|
||||
<input type="file" id="formGalInput" accept="image/*" hidden>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
let mode = 'put';
|
||||
let photoTarget = null; // куда прикрепить снимок: {target, cardEl, btn}
|
||||
let pendingPhoto = null; // фото, выбранное ДО записи — прицепится к новой вещи
|
||||
let pickedItem = null; // выбранная вещь для забрать/переместить/удалить
|
||||
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g,
|
||||
c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
|
||||
async function api(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// Последнее место и коробку помним на телефоне: складывая десяток деталей
|
||||
// в одну коробку, выбирать её каждый раз заново незачем.
|
||||
const LAST = {
|
||||
get(key) { try { return localStorage.getItem('hlamingo.' + key) || ''; } catch (e) { return ''; } },
|
||||
set(key, value) { try { localStorage.setItem('hlamingo.' + key, value || ''); } catch (e) {} }
|
||||
};
|
||||
function say(text) { $('#reply').style.display = 'block'; $('#reply').textContent = text || ''; }
|
||||
|
||||
// ---------- вкладки ----------
|
||||
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
|
||||
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b));
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('on', t.id === 'tab-' + b.dataset.tab));
|
||||
if (b.dataset.tab === 'tree') loadTree();
|
||||
});
|
||||
|
||||
// ---------- баннер ----------
|
||||
api('/api/health').then(h => {
|
||||
if (!h.ollama) {
|
||||
$('#banner').style.display = 'block';
|
||||
$('#banner').textContent = 'ИИ недоступен (' + h.host + '). Кнопки работают полностью; '
|
||||
+ 'не будет только синонимов и разбора свободных фраз.';
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// ---------- режимы ----------
|
||||
const LABEL = {put:'Положить', take:'Забрать', move:'Переместить', delete:'Удалить'};
|
||||
document.querySelectorAll('.modes button').forEach(b => b.onclick = () => {
|
||||
mode = b.dataset.mode;
|
||||
document.querySelectorAll('.modes button').forEach(x => x.classList.toggle('on', x === b));
|
||||
$('#pickWrap').hidden = (mode === 'put');
|
||||
$('#whereWrap').hidden = (mode === 'take' || mode === 'delete');
|
||||
$('#whatWrap').hidden = (mode !== 'put');
|
||||
$('#goBtn').textContent = LABEL[mode];
|
||||
$('#goBtn').className = 'act' + (mode === 'delete' ? ' danger' : '');
|
||||
pickedItem = null; $('#picked').hidden = true; $('#pickList').innerHTML = '';
|
||||
$('#pickQuery').value = '';
|
||||
});
|
||||
|
||||
// ---------- справочники ----------
|
||||
async function loadPlaces(keep) {
|
||||
const d = await api('/api/places');
|
||||
const sel = $('#placeSel');
|
||||
// Что подставить: текущий выбор, а при первой загрузке — прошлый сеанс.
|
||||
const want = (keep ? sel.value : '') || sel.value || LAST.get('place');
|
||||
sel.innerHTML = '<option value="">— без места —</option>'
|
||||
+ d.places.map(p => '<option value="' + esc(p.id) + '">' + esc(p.path) + '</option>').join('');
|
||||
sel.value = want;
|
||||
// Место могли удалить — тогда select молча остался пустым, и помнить его нечего.
|
||||
if (sel.value !== want) LAST.set('place', '');
|
||||
return d.places;
|
||||
}
|
||||
async function loadBoxes() {
|
||||
const place = $('#placeSel').value;
|
||||
// Показываем коробки выбранного места, но не прячем остальные: коробку могли
|
||||
// ещё не привязать к месту, и она не должна исчезнуть из списка.
|
||||
const d = await api('/api/containers' + (place ? '?place=' + encodeURIComponent(place) : ''));
|
||||
const all = place ? (await api('/api/containers')).containers : d.containers;
|
||||
const here = d.containers.map(b => b.code);
|
||||
const sel = $('#boxSel');
|
||||
const want = sel.value || LAST.get('box');
|
||||
const opt = b => '<option value="' + esc(b.code) + '">' + esc(b.code)
|
||||
+ (b.count ? ' · ' + b.count + ' поз.' : '') + (here.includes(b.code) ? '' : ' — ' + esc(b.address || 'без места')) + '</option>';
|
||||
const mine = all.filter(b => here.includes(b.code));
|
||||
const rest = all.filter(b => !here.includes(b.code));
|
||||
sel.innerHTML = '<option value="">— без коробки —</option>' + mine.map(opt).join('')
|
||||
+ (rest.length ? '<optgroup label="другие места">' + rest.map(opt).join('') + '</optgroup>' : '');
|
||||
sel.value = want;
|
||||
if (sel.value !== want) LAST.set('box', ''); // коробку удалили или переименовали
|
||||
}
|
||||
|
||||
$('#placeSel').onchange = () => { LAST.set('place', $('#placeSel').value); loadBoxes(); };
|
||||
$('#boxSel').onchange = () => LAST.set('box', $('#boxSel').value);
|
||||
|
||||
$('#addPlaceBtn').onclick = async () => {
|
||||
const name = $('#newPlaceName').value.trim();
|
||||
if (!name) return;
|
||||
const d = await api('/api/places', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name, parent: $('#placeSel').value || null})});
|
||||
if (!d.ok) return say(d.reply);
|
||||
$('#newPlaceName').value = '';
|
||||
await loadPlaces();
|
||||
$('#placeSel').value = d.id;
|
||||
LAST.set('place', d.id); // программная установка не поднимает onchange
|
||||
await loadBoxes();
|
||||
say('Место добавлено: ' + d.path);
|
||||
};
|
||||
|
||||
$('#addBoxBtn').onclick = async () => {
|
||||
const code = $('#newBoxCode').value.trim();
|
||||
if (!code) return;
|
||||
const d = await api('/api/containers', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({code, place: $('#placeSel').value || null})});
|
||||
if (!d.ok) return say(d.reply);
|
||||
$('#newBoxCode').value = '';
|
||||
await loadBoxes();
|
||||
$('#boxSel').value = d.code;
|
||||
LAST.set('box', d.code);
|
||||
say('Коробка ' + d.code + ' добавлена: ' + d.address);
|
||||
};
|
||||
|
||||
// ---------- выбор вещи ----------
|
||||
let pickTimer = null;
|
||||
$('#pickQuery').addEventListener('input', () => {
|
||||
clearTimeout(pickTimer);
|
||||
pickTimer = setTimeout(async () => {
|
||||
const q = $('#pickQuery').value.trim();
|
||||
const d = await api('/api/items' + (q ? '?q=' + encodeURIComponent(q) : ''));
|
||||
$('#pickList').innerHTML = '';
|
||||
(d.items || []).slice(0, 8).forEach(it => {
|
||||
const el = card(it, true); el.classList.add('pick');
|
||||
el.onclick = () => {
|
||||
pickedItem = it;
|
||||
$('#picked').hidden = false;
|
||||
$('#picked').innerHTML = '<h3>' + esc(it.name) + '</h3><div class="addr">'
|
||||
+ esc(it.address) + '</div>';
|
||||
$('#pickList').innerHTML = '';
|
||||
$('#pickQuery').value = it.name;
|
||||
};
|
||||
$('#pickList').appendChild(el);
|
||||
});
|
||||
}, 250);
|
||||
});
|
||||
$('#pickQuery').addEventListener('focus', () => $('#pickQuery').dispatchEvent(new Event('input')));
|
||||
|
||||
// ---------- фото до записи ----------
|
||||
// Так вещь заводится одним заходом: название, место, снимок — и всё.
|
||||
$('#formCamBtn').onclick = () => $('#formCamInput').click();
|
||||
$('#formGalBtn').onclick = () => $('#formGalInput').click();
|
||||
|
||||
async function takeFormPhoto(input) {
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
pendingPhoto = await shrink(file);
|
||||
const preview = $('#formPhotoPreview');
|
||||
preview.hidden = false;
|
||||
preview.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(pendingPhoto);
|
||||
preview.appendChild(img);
|
||||
$('#formPhotoClear').hidden = false;
|
||||
} catch (e) {
|
||||
say('Не смог прочитать это изображение: ' + e);
|
||||
clearPendingPhoto();
|
||||
}
|
||||
}
|
||||
$('#formCamInput').onchange = () => takeFormPhoto($('#formCamInput'));
|
||||
$('#formGalInput').onchange = () => takeFormPhoto($('#formGalInput'));
|
||||
|
||||
$('#formPhotoClear').onclick = clearPendingPhoto;
|
||||
function clearPendingPhoto() {
|
||||
pendingPhoto = null;
|
||||
$('#formPhotoPreview').hidden = true;
|
||||
$('#formPhotoPreview').innerHTML = '';
|
||||
$('#formPhotoClear').hidden = true;
|
||||
}
|
||||
|
||||
// Отправляет уже сжатый blob к готовой записи. Возвращает имя файла или null.
|
||||
async function uploadBlob(blob, target) {
|
||||
const fd = new FormData();
|
||||
fd.append('photo', blob, 'photo.jpg');
|
||||
fd.append('target', target.type);
|
||||
if (target.type === 'item') fd.append('id', target.id); else fd.append('code', target.code);
|
||||
const d = await api('/api/photo', {method:'POST', body: fd});
|
||||
return d.ok ? d.photo : null;
|
||||
}
|
||||
|
||||
// ---------- главное действие ----------
|
||||
$('#goBtn').onclick = async () => {
|
||||
const btn = $('#goBtn');
|
||||
btn.disabled = true;
|
||||
const was = btn.textContent; btn.textContent = 'Секунду…';
|
||||
try {
|
||||
let d;
|
||||
if (mode === 'put') {
|
||||
const name = $('#itemName').value.trim();
|
||||
if (!name) return say('Напиши, что кладём.');
|
||||
d = await api('/api/put', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({
|
||||
name, qty: $('#itemQty').value.trim() || null, note: $('#itemNote').value.trim() || null,
|
||||
place: $('#placeSel').value || null, container: $('#boxSel').value || null})});
|
||||
// Фото, выбранное до записи, цепляем к только что созданной вещи.
|
||||
if (d.ok && pendingPhoto && d.item) {
|
||||
btn.textContent = 'Гружу фото…';
|
||||
const name = await uploadBlob(pendingPhoto, {type:'item', id: d.item.id});
|
||||
if (name) { d.item.photo = name; d.reply += ' Фото прикрепил.'; }
|
||||
else { d.reply += ' Но фото загрузить не удалось.'; }
|
||||
clearPendingPhoto();
|
||||
}
|
||||
if (d.ok) {
|
||||
// Название и заметку чистим, а место с коробкой оставляем: следующая
|
||||
// вещь, скорее всего, ложится туда же.
|
||||
$('#itemName').value = ''; $('#itemQty').value = ''; $('#itemNote').value = '';
|
||||
LAST.set('place', $('#placeSel').value);
|
||||
LAST.set('box', $('#boxSel').value);
|
||||
$('#itemName').focus();
|
||||
}
|
||||
} else if (!pickedItem) {
|
||||
return say('Сначала выбери вещь из списка.');
|
||||
} else if (mode === 'take') {
|
||||
d = await api('/api/take', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({id: pickedItem.id})});
|
||||
} else if (mode === 'move') {
|
||||
d = await api('/api/move', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({id: pickedItem.id, place: $('#placeSel').value || null,
|
||||
container: $('#boxSel').value || null})});
|
||||
} else {
|
||||
if (!confirm('Удалить «' + pickedItem.name + '» из памяти?')) return;
|
||||
d = await api('/api/item/' + encodeURIComponent(pickedItem.id), {method:'DELETE'});
|
||||
}
|
||||
say(d.reply);
|
||||
const items = d.items || (d.item ? [d.item] : []);
|
||||
render($('#doCards'), items);
|
||||
if (d.ok && mode !== 'put') {
|
||||
pickedItem = null; $('#picked').hidden = true; $('#pickQuery').value = '';
|
||||
}
|
||||
if (d.ok) { loadBoxes(); }
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = was;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- свободная фраза ----------
|
||||
$('#sayBtn').onclick = async () => {
|
||||
const text = $('#sayText').value.trim();
|
||||
if (!text) return;
|
||||
const btn = $('#sayBtn'); btn.disabled = true; btn.textContent = 'Думаю…';
|
||||
try {
|
||||
const d = await api('/api/say', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({text})});
|
||||
say(d.reply);
|
||||
render($('#doCards'), d.items || (d.item ? [d.item] : []));
|
||||
if (d.kind === 'put') { $('#sayText').value = ''; loadPlaces(true); loadBoxes(); }
|
||||
} catch (e) { say('Не достучался до сервера: ' + e); }
|
||||
finally { btn.disabled = false; btn.textContent = 'Разобрать и записать'; }
|
||||
};
|
||||
|
||||
// ---------- карточки ----------
|
||||
// noPhoto=true для карточек-вариантов при выборе вещи: там кнопка мешала бы
|
||||
// попасть по самой карточке.
|
||||
function card(it, noPhoto) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'card';
|
||||
let html = '';
|
||||
if (it.why) html += '<span class="why">' + esc(it.why) + '</span>';
|
||||
html += '<h3>' + esc(it.name || '') + '</h3>';
|
||||
html += '<div class="addr' + (it.state === 'taken' ? ' taken' : '') + '">'
|
||||
+ esc(it.address || '') + '</div>';
|
||||
const meta = [];
|
||||
if (it.qty) meta.push(esc(it.qty));
|
||||
if (it.note) meta.push(esc(it.note));
|
||||
if (meta.length) html += '<div class="meta">' + meta.join(' · ') + '</div>';
|
||||
if (it.aliases && it.aliases.length)
|
||||
html += '<div class="tags">' + it.aliases.map(a => '<span class="tag">' + esc(a) + '</span>').join('') + '</div>';
|
||||
html += '<div class="photo"' + (it.photo ? '' : ' hidden') + '>'
|
||||
+ (it.photo ? '<img src="/static/photos/' + encodeURIComponent(it.photo) + '" alt="">' : '')
|
||||
+ '</div>';
|
||||
el.innerHTML = html;
|
||||
if (!noPhoto) {
|
||||
el.appendChild(actionBar({type:'item', id: it.id}, el, !!it.photo));
|
||||
el.appendChild(itemEditor(it, el));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// Ряд кнопок под карточкой: снять, выбрать из галереи, правка, удалить фото.
|
||||
function actionBar(target, cardEl, hasPhoto) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'acts';
|
||||
|
||||
const drop = document.createElement('button');
|
||||
|
||||
// Камера и галерея — раздельно: одним полем оба сценария на Android не покрыть.
|
||||
const cam = document.createElement('button');
|
||||
cam.textContent = '📷 Снять';
|
||||
cam.onclick = e => {
|
||||
e.stopPropagation();
|
||||
photoTarget = {target, cardEl, btn: cam, dropBtn: drop};
|
||||
$('#camInput').click();
|
||||
};
|
||||
bar.appendChild(cam);
|
||||
|
||||
const gal = document.createElement('button');
|
||||
gal.className = 'narrow';
|
||||
gal.textContent = '🖼';
|
||||
gal.title = 'Выбрать из галереи';
|
||||
gal.onclick = e => {
|
||||
e.stopPropagation();
|
||||
photoTarget = {target, cardEl, btn: gal, dropBtn: drop};
|
||||
$('#galInput').click();
|
||||
};
|
||||
bar.appendChild(gal);
|
||||
|
||||
const edit = document.createElement('button');
|
||||
edit.textContent = '✏️ Изменить';
|
||||
edit.onclick = e => {
|
||||
e.stopPropagation();
|
||||
const box = cardEl.querySelector('.edit');
|
||||
if (box) box.hidden = !box.hidden;
|
||||
};
|
||||
bar.appendChild(edit);
|
||||
|
||||
drop.className = 'warn';
|
||||
drop.textContent = '🗑 Фото';
|
||||
drop.hidden = !hasPhoto;
|
||||
drop.onclick = async e => {
|
||||
e.stopPropagation();
|
||||
const body = target.type === 'item' ? {target:'item', id: target.id}
|
||||
: {target:'container', code: target.code};
|
||||
const d = await api('/api/photo', {method:'DELETE',
|
||||
headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
||||
say(d.reply);
|
||||
if (d.ok) {
|
||||
const ph = cardEl.querySelector('.photo');
|
||||
if (ph) { ph.hidden = true; ph.innerHTML = ''; }
|
||||
photo.textContent = '📷 Фото';
|
||||
drop.hidden = true;
|
||||
}
|
||||
};
|
||||
bar.appendChild(drop);
|
||||
bar._dropBtn = drop;
|
||||
return bar;
|
||||
}
|
||||
|
||||
// Форма правки предмета. Теги снимаются по одному: модель регулярно
|
||||
// придумывает мусор вроде «электротool Makita», и его надо убирать руками.
|
||||
function itemEditor(it, cardEl) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'edit';
|
||||
box.hidden = true;
|
||||
box.innerHTML =
|
||||
'<label>Название</label><input class="eName">'
|
||||
+ '<label>Количество</label><input class="eQty">'
|
||||
+ '<label>Заметка</label><input class="eNote">'
|
||||
+ '<label>Теги для поиска</label><div class="tags eTags"></div>'
|
||||
+ '<input class="eNewTag mt" placeholder="добавить тег и нажать Enter">'
|
||||
+ '<p class="editHint">Тег убирается нажатием на ×. По тегам вещь и находится, '
|
||||
+ 'так что лишние лучше убрать, а свои — добавить.</p>'
|
||||
+ '<div class="acts">'
|
||||
+ '<button class="eSave">Сохранить</button>'
|
||||
+ '<button class="eDel warn">🗑 Удалить запись</button>'
|
||||
+ '</div>';
|
||||
|
||||
const q = s => box.querySelector(s);
|
||||
q('.eName').value = it.name || '';
|
||||
q('.eQty').value = it.qty || '';
|
||||
q('.eNote').value = it.note || '';
|
||||
|
||||
let tags = (it.aliases || []).slice();
|
||||
function drawTags() {
|
||||
const holder = q('.eTags');
|
||||
holder.innerHTML = '';
|
||||
if (!tags.length) {
|
||||
holder.innerHTML = '<span class="tag">тегов нет</span>';
|
||||
return;
|
||||
}
|
||||
tags.forEach((t, i) => {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'tag del';
|
||||
el.innerHTML = esc(t) + '<b>×</b>';
|
||||
el.onclick = () => { tags.splice(i, 1); drawTags(); };
|
||||
holder.appendChild(el);
|
||||
});
|
||||
}
|
||||
drawTags();
|
||||
|
||||
q('.eNewTag').addEventListener('keydown', e => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
const v = e.target.value.trim();
|
||||
if (v && !tags.some(t => t.toLowerCase() === v.toLowerCase())) tags.push(v);
|
||||
e.target.value = '';
|
||||
drawTags();
|
||||
});
|
||||
|
||||
q('.eSave').onclick = async () => {
|
||||
const btn = q('.eSave');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const d = await api('/api/item/' + encodeURIComponent(it.id), {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: q('.eName').value, qty: q('.eQty').value,
|
||||
note: q('.eNote').value, aliases: tags})});
|
||||
say(d.reply);
|
||||
if (d.ok) {
|
||||
// Перерисовываем карточку целиком: изменились и заголовок, и теги.
|
||||
const fresh = card(d.item);
|
||||
cardEl.replaceWith(fresh);
|
||||
}
|
||||
} finally { btn.disabled = false; }
|
||||
};
|
||||
|
||||
q('.eDel').onclick = async () => {
|
||||
if (!confirm('Удалить «' + (it.name || '') + '» из памяти?')) return;
|
||||
const d = await api('/api/item/' + encodeURIComponent(it.id), {method:'DELETE'});
|
||||
say(d.reply);
|
||||
if (d.ok) cardEl.remove();
|
||||
};
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
function render(box, items) { box.innerHTML = ''; (items || []).forEach(it => box.appendChild(card(it))); }
|
||||
|
||||
// ---------- найти ----------
|
||||
async function doFind() {
|
||||
const q = $('#findText').value.trim();
|
||||
if (!q) return;
|
||||
const btn = $('#findBtn'); btn.disabled = true; btn.textContent = 'Ищу…';
|
||||
try {
|
||||
const d = await api('/api/find?q=' + encodeURIComponent(q));
|
||||
const box = $('#findCards');
|
||||
if (!d.items || !d.items.length) box.innerHTML = '<p class="empty">' + esc(d.reply || 'Ничего не нашлось.') + '</p>';
|
||||
else render(box, d.items);
|
||||
} finally { btn.disabled = false; btn.textContent = 'Искать'; }
|
||||
}
|
||||
$('#findBtn').onclick = doFind;
|
||||
$('#findText').addEventListener('keydown', e => { if (e.key === 'Enter') doFind(); });
|
||||
|
||||
// ---------- обзор ----------
|
||||
async function loadTree() {
|
||||
const box = $('#treeBox');
|
||||
box.innerHTML = '<p class="empty">Загружаю…</p>';
|
||||
const d = await api('/api/tree');
|
||||
if (!(d.places || []).length && !(d.loose_containers || []).length && !(d.loose_items || []).length) {
|
||||
box.innerHTML = '<p class="empty">Пока пусто. Положи первую вещь на вкладке «Действие».</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = '<ul>' + (d.places || []).map(place).join('')
|
||||
+ (d.loose_containers || []).map(b => '<li>' + boxTag(b) + '</li>').join('')
|
||||
+ (d.loose_items || []).map(i => '<li>' + esc(i.name) + '</li>').join('') + '</ul>';
|
||||
box.querySelectorAll('.box').forEach(el => el.onclick = () => openBox(el.dataset.code));
|
||||
box.querySelectorAll('.editPlace').forEach(el => el.onclick = () => openPlace(el.dataset.id));
|
||||
}
|
||||
function place(p) {
|
||||
const inner = (p.places || []).map(place).join('')
|
||||
+ (p.containers || []).map(b => '<li>' + boxTag(b) + '</li>').join('')
|
||||
+ (p.items || []).map(i => '<li>' + esc(i.name) + '</li>').join('');
|
||||
return '<li><span class="place editPlace" data-id="' + esc(p.id) + '">' + esc(p.name)
|
||||
+ ' ✏️</span>' + (inner ? '<ul>' + inner + '</ul>' : '') + '</li>';
|
||||
}
|
||||
function boxTag(b) {
|
||||
return '<span class="box" data-code="' + esc(b.code) + '"><b>' + esc(b.code) + '</b> · ' + b.count + '</span>';
|
||||
}
|
||||
|
||||
// ---- коробка: содержимое + правка ----
|
||||
async function openBox(code) {
|
||||
const d = await api('/api/container/' + encodeURIComponent(code));
|
||||
const box = $('#treeBox');
|
||||
const head = document.createElement('div');
|
||||
head.className = 'card';
|
||||
head.innerHTML = '<h3>Коробка ' + esc(code) + '</h3>'
|
||||
+ '<div class="addr">' + esc(d.reply || '') + '</div>'
|
||||
+ '<div class="photo"' + (d.photo ? '' : ' hidden') + '>'
|
||||
+ (d.photo ? '<img src="/static/photos/' + encodeURIComponent(d.photo) + '" alt="">' : '')
|
||||
+ '</div>';
|
||||
// Фото самой коробки: снял, как она выглядит и что в ней лежит.
|
||||
head.appendChild(actionBar({type:'container', code}, head, !!d.photo));
|
||||
head.appendChild(boxEditor(code, head));
|
||||
box.prepend(head);
|
||||
(d.items || []).forEach(it => box.insertBefore(card(it), head.nextSibling));
|
||||
window.scrollTo({top:0, behavior:'smooth'});
|
||||
}
|
||||
|
||||
function boxEditor(code, cardEl) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'edit';
|
||||
box.hidden = true;
|
||||
box.innerHTML =
|
||||
'<label>Код коробки</label><input class="bCode">'
|
||||
+ '<label>Что это</label><input class="bKind" placeholder="коробка, ящик, пакет">'
|
||||
+ '<label>Где стоит</label><select class="bPlace"></select>'
|
||||
+ '<label>Заметка</label><input class="bNote">'
|
||||
+ '<div class="acts">'
|
||||
+ '<button class="bSave">Сохранить</button>'
|
||||
+ '<button class="bDel warn">🗑 Удалить коробку</button>'
|
||||
+ '</div>'
|
||||
+ '<p class="editHint">При удалении коробки вещи из неё не пропадут — '
|
||||
+ 'они останутся в том месте, где она стояла.</p>';
|
||||
|
||||
const q = s => box.querySelector(s);
|
||||
q('.bCode').value = code;
|
||||
api('/api/containers').then(d => {
|
||||
const me = (d.containers || []).find(b => b.code === code) || {};
|
||||
q('.bKind').value = me.kind || 'коробка';
|
||||
q('.bNote').value = me.note || '';
|
||||
return api('/api/places').then(pd => {
|
||||
q('.bPlace').innerHTML = '<option value="">— без места —</option>'
|
||||
+ pd.places.map(p => '<option value="' + esc(p.id) + '">' + esc(p.path) + '</option>').join('');
|
||||
if (me.place) q('.bPlace').value = me.place;
|
||||
});
|
||||
});
|
||||
|
||||
q('.bSave').onclick = async () => {
|
||||
const d = await api('/api/containers/' + encodeURIComponent(code), {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({code: q('.bCode').value, kind: q('.bKind').value,
|
||||
place: q('.bPlace').value || null, note: q('.bNote').value})});
|
||||
say(d.reply);
|
||||
if (d.ok) { await loadTree(); if (d.code) openBox(d.code); }
|
||||
};
|
||||
|
||||
q('.bDel').onclick = async () => {
|
||||
if (!confirm('Удалить коробку ' + code + '? Вещи из неё останутся в её месте.')) return;
|
||||
const d = await api('/api/containers/' + encodeURIComponent(code), {method:'DELETE'});
|
||||
say(d.reply);
|
||||
if (d.ok) loadTree();
|
||||
};
|
||||
return box;
|
||||
}
|
||||
|
||||
// ---- место: правка ----
|
||||
async function openPlace(placeId) {
|
||||
const pd = await api('/api/places');
|
||||
const me = (pd.places || []).find(p => p.id === placeId);
|
||||
if (!me) return;
|
||||
const box = $('#treeBox');
|
||||
const head = document.createElement('div');
|
||||
head.className = 'card';
|
||||
head.innerHTML = '<h3>Место</h3><div class="addr">' + esc(me.path) + '</div>'
|
||||
+ '<label>Название</label><input class="pName">'
|
||||
+ '<label>Внутри чего</label><select class="pParent"></select>'
|
||||
+ '<div class="acts">'
|
||||
+ '<button class="pSave">Сохранить</button>'
|
||||
+ '<button class="pDel warn">🗑 Удалить место</button>'
|
||||
+ '</div>'
|
||||
+ '<p class="editHint">Удалить можно только пустое место — иначе покажу, '
|
||||
+ 'что внутри, чтобы записи о вещах не пропали молча.</p>';
|
||||
|
||||
const q = s => head.querySelector(s);
|
||||
q('.pName').value = me.path.split(' → ').pop();
|
||||
// Себя в списке родителей не показываем; вложенные отсечёт сервер.
|
||||
q('.pParent').innerHTML = '<option value="">— это верхний уровень —</option>'
|
||||
+ pd.places.filter(p => p.id !== placeId)
|
||||
.map(p => '<option value="' + esc(p.id) + '">' + esc(p.path) + '</option>').join('');
|
||||
const parentPath = me.path.split(' → ').slice(0, -1).join(' → ');
|
||||
const parent = pd.places.find(p => p.path === parentPath);
|
||||
if (parent) q('.pParent').value = parent.id;
|
||||
|
||||
q('.pSave').onclick = async () => {
|
||||
const d = await api('/api/places/' + encodeURIComponent(placeId), {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: q('.pName').value, parent: q('.pParent').value || null})});
|
||||
say(d.reply);
|
||||
if (d.ok) { await loadTree(); loadPlaces(true).then(loadBoxes); }
|
||||
};
|
||||
|
||||
q('.pDel').onclick = async () => {
|
||||
if (!confirm('Удалить место «' + q('.pName').value + '»?')) return;
|
||||
const d = await api('/api/places/' + encodeURIComponent(placeId), {method:'DELETE'});
|
||||
say(d.reply);
|
||||
if (d.ok) { await loadTree(); loadPlaces().then(loadBoxes); }
|
||||
};
|
||||
|
||||
box.prepend(head);
|
||||
window.scrollTo({top:0, behavior:'smooth'});
|
||||
}
|
||||
$('#treeBtn').onclick = loadTree;
|
||||
|
||||
// ---------- фото ----------
|
||||
async function attachPhoto(input) {
|
||||
const file = input.files[0];
|
||||
if (!file || !photoTarget) { input.value = ''; return; }
|
||||
const {target, cardEl, btn, dropBtn} = photoTarget;
|
||||
const was = btn.textContent;
|
||||
btn.disabled = true; btn.textContent = 'Гружу…';
|
||||
try {
|
||||
const blob = await shrink(file);
|
||||
const fd = new FormData();
|
||||
fd.append('photo', blob, 'photo.jpg');
|
||||
fd.append('target', target.type);
|
||||
if (target.type === 'item') fd.append('id', target.id); else fd.append('code', target.code);
|
||||
const d = await api('/api/photo', {method:'POST', body: fd});
|
||||
say(d.reply);
|
||||
if (d.ok) {
|
||||
const box = cardEl.querySelector('.photo');
|
||||
if (box) {
|
||||
box.hidden = false;
|
||||
box.innerHTML = '<img src="/static/photos/' + encodeURIComponent(d.photo) + '" alt="">';
|
||||
}
|
||||
if (dropBtn) dropBtn.hidden = false;
|
||||
}
|
||||
btn.textContent = was;
|
||||
} catch (e) {
|
||||
say('Фото не загрузилось: ' + e); btn.textContent = was;
|
||||
} finally {
|
||||
btn.disabled = false; input.value = ''; photoTarget = null;
|
||||
}
|
||||
}
|
||||
$('#camInput').onchange = () => attachPhoto($('#camInput'));
|
||||
$('#galInput').onchange = () => attachPhoto($('#galInput'));
|
||||
|
||||
// Снимок с телефона — 3-5 МБ. Ужимаем до 1280 px, иначе вайфай и папка с фото
|
||||
// быстро становятся проблемой.
|
||||
function shrink(file, max = 1280, quality = 0.82) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const scale = Math.min(1, max / Math.max(img.width, img.height));
|
||||
const c = document.createElement('canvas');
|
||||
c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
|
||||
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
|
||||
c.toBlob(b => b ? resolve(b) : reject(new Error('canvas')), 'image/jpeg', quality);
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('image')); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- старт ----------
|
||||
loadPlaces().then(loadBoxes);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
@@ -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%
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user