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
+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()