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:
2026-07-30 14:27:20 +03:00
co-authored by Claude Opus 5
commit 17b7d49d2f
26 changed files with 3760 additions and 0 deletions
+18
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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.
+288
View File
@@ -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, выбранную модель и доступность.
+1
View File
@@ -0,0 +1 @@
"""hlamingo — помощник, который помнит, куда что положено."""
+631
View File
@@ -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()
+309
View File
@@ -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*", 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]
+104
View File
@@ -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"}
+85
View File
@@ -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
+174
View File
@@ -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]
+557
View File
@@ -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
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
flask
+37
View File
@@ -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
+28
View File
@@ -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

+25
View File
@@ -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"
}
]
}
+864
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>
+59
View File
@@ -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
+22
View File
@@ -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%
+195
View File
@@ -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())
+107
View File
@@ -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()