diff --git a/.env.example b/.env.example index b9ede98..fc3d380 100644 --- a/.env.example +++ b/.env.example @@ -26,8 +26,14 @@ VISION_MAX_IMAGES=8 # JSON-экстракция памяти отдельной моделью (если основная капризничает): # MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat -# App -DATABASE_URL=sqlite:///./data/assistant.db +# PostgreSQL (docker compose default) +POSTGRES_USER=assistant +POSTGRES_PASSWORD=change-me-postgres-password +POSTGRES_DB=assistant + +# App — docker: postgresql+psycopg2://assistant:PASSWORD@postgres:5432/assistant +# local dev without docker: sqlite:///./data/assistant.db +DATABASE_URL=postgresql+psycopg2://assistant:change-me-postgres-password@postgres:5432/assistant CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080 SYSTEM_PROMPT_PATH=./prompts/assistant.md MEMORY_AUTO_EXTRACT=true @@ -46,16 +52,16 @@ OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org FITNESS_REMINDERS_ENABLED=true REMINDERS_ENABLED=true -# Taiga (on host :9000, nginx → taiga.grigowashere.ru) +# Taiga (on host :9000 — replace with your public URL) TAIGA_BASE_URL=http://host.docker.internal:9000 TAIGA_USERNAME=your_taiga_user TAIGA_PASSWORD=your_taiga_password -TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru +TAIGA_PUBLIC_URL=https://taiga.example.com -# Gitea (on host :3000, nginx → git.grigowashere.ru) +# Gitea (on host :3000 — replace with your public URL) GITEA_BASE_URL=http://host.docker.internal:3000 GITEA_TOKEN=your_gitea_api_token -GITEA_PUBLIC_URL=https://git.grigowashere.ru +GITEA_PUBLIC_URL=https://git.example.com GITEA_WEBHOOK_SECRET=generate_a_random_secret # Gitea webhook URL (repo Settings → Webhooks): @@ -64,8 +70,8 @@ GITEA_WEBHOOK_SECRET=generate_a_random_secret REPOS_DIR=/data/repos -# Homelab — GPU PC 192.168.1.109 -OPENMETEO_BASE_URL=http://192.168.1.109:8085 +# Homelab — replace host with your weather/ComfyUI server +OPENMETEO_BASE_URL=http://host.docker.internal:8085 WEATHER_LAT=59.9343 WEATHER_LON=30.3351 WEATHER_LOCATION_NAME=Санкт-Петербург @@ -85,8 +91,8 @@ MORNING_DIGEST_ENABLED=true MORNING_DIGEST_HOUR=8 MORNING_DIGEST_MINUTE=0 -# ComfyUI on GPU PC (Anima split-model — как в aiChatBot) -COMFYUI_BASE_URL=http://192.168.1.109:8188 +# ComfyUI (Anima split-model) +COMFYUI_BASE_URL=http://host.docker.internal:8188 COMFYUI_ENABLED=true # Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET. COMFYUI_CHECKPOINT= diff --git a/.env.example.refactor_bak b/.env.example.refactor_bak deleted file mode 100644 index 373f1ce..0000000 --- a/.env.example.refactor_bak +++ /dev/null @@ -1,106 +0,0 @@ -# Server (internal bind inside containers) -HOST=0.0.0.0 -BACKEND_INTERNAL_PORT=8080 -FRONTEND_INTERNAL_PORT=80 - -# External ports on the host (docker compose publish) -BACKEND_PORT=8080 -FRONTEND_PORT=3080 -VITE_DEV_PORT=5173 - -# OpenRouter -OPENROUTER_API_KEY=sk-or-v1-your-key-here -OPENROUTER_MODEL=deepseek/deepseek-chat -# deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются: -# OPENROUTER_MODEL=deepseek/deepseek-v4-pro -OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 -OPENROUTER_TOOLS_ENABLED=true -# none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning. -OPENROUTER_REASONING_EFFORT=none -# JSON-экстракция памяти отдельной моделью (если основная капризничает): -# MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat - -# App -DATABASE_URL=sqlite:///./data/assistant.db -CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080 -SYSTEM_PROMPT_PATH=./prompts/assistant.md -MEMORY_AUTO_EXTRACT=true - -# Fitness (wger + Open Food Facts — public HTTPS, no proxy) -WGER_BASE_URL=https://wger.de/api/v2 -OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org -FITNESS_REMINDERS_ENABLED=true -REMINDERS_ENABLED=true - -# Taiga (on host :9000, nginx → taiga.grigowashere.ru) -TAIGA_BASE_URL=http://host.docker.internal:9000 -TAIGA_USERNAME=your_taiga_user -TAIGA_PASSWORD=your_taiga_password -TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru - -# Gitea (on host :3000, nginx → git.grigowashere.ru) -GITEA_BASE_URL=http://host.docker.internal:3000 -GITEA_TOKEN=your_gitea_api_token -GITEA_PUBLIC_URL=https://git.grigowashere.ru -GITEA_WEBHOOK_SECRET=generate_a_random_secret - -# Gitea webhook URL (repo Settings → Webhooks): -# https://assistant.your-domain/api/v1/webhooks/gitea ← nginx → 127.0.0.1:BACKEND_PORT -# http://172.17.0.1:8202/api/v1/webhooks/gitea ← если Gitea в Docker (не 127.0.0.1!) - -REPOS_DIR=/data/repos - -# Homelab — GPU PC 192.168.1.109 -OPENMETEO_BASE_URL=http://192.168.1.109:8085 -WEATHER_LAT=59.9343 -WEATHER_LON=30.3351 -WEATHER_LOCATION_NAME=Санкт-Петербург -WEATHER_CACHE_SEC=300 - -# News RSS (comma-separated) -NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss -NEWS_CACHE_SEC=1800 -NEWS_MAX_ITEMS=7 - -# Morning digest (Europe/Moscow or user profile timezone) -MORNING_DIGEST_ENABLED=true -MORNING_DIGEST_HOUR=8 -MORNING_DIGEST_MINUTE=0 - -# ComfyUI on GPU PC (Anima split-model — как в aiChatBot) -COMFYUI_BASE_URL=http://192.168.1.109:8188 -COMFYUI_ENABLED=true -# Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET. -COMFYUI_CHECKPOINT= -COMFYUI_UNET=anima-preview3-base.safetensors -COMFYUI_CLIP=qwen_3_06b_base.safetensors -COMFYUI_VAE=qwen_image_vae.safetensors -COMFYUI_STYLE_LORA=anima-preview-3-masterpieces-v5.safetensors -COMFYUI_STYLE_LORA_WEIGHT=0.7 -COMFYUI_STEPS=30 -COMFYUI_CFG=4 -COMFYUI_SAMPLER=er_sde -COMFYUI_SCHEDULER=simple -COMFYUI_WIDTH=1024 -COMFYUI_HEIGHT=720 -COMFYUI_NEGATIVE_PROMPT=worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia -COMFYUI_ROFL_ENABLED=true -COMFYUI_ROFL_MAX_PER_DAY=1 -COMFYUI_ROFL_PROBABILITY=0.15 -COMFYUI_ROFL_MIN_INTERVAL_HOURS=12 -GENERATED_MEDIA_DIR=./data/generated - -# RP Chat (aiChatBot) — генерация картинок + sd-prompt; persona_id в карточке персонажа -RP_CHAT_BASE_URL=http://host.docker.internal:8201 -RP_CHAT_ENABLED=true -RP_CHAT_TIMEOUT_SEC=300 - -# Netdata on server -NETDATA_BASE_URL=http://host.docker.internal:19999 -NETDATA_PUBLIC_URL= -NETDATA_ALERTS_ENABLED=true -NETDATA_POLL_INTERVAL_SEC=120 - -# Vector DB (phase 3) -QDRANT_PORT=6333 -QDRANT_GRPC_PORT=6334 diff --git a/Jenkinsfile b/Jenkinsfile index 600f485..2334f30 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -87,6 +87,10 @@ pipeline { git reset --hard "origin/${GIT_BRANCH}" git clean -fd -e .env -e data -e 'data/**' + docker compose build backend + docker compose run --rm --no-deps backend \ + sh -c 'pip install -q -r requirements-dev.txt && pytest tests/ -q --tb=short' + if [ "${DOCKER_PULL}" = "true" ]; then docker compose build --pull else diff --git a/README.md b/README.md index 2d9b21e..4856bf1 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,18 @@ Домашний ИИ-ассистент с REST API, веб-интерфейсом и помидоро-таймером. LLM — OpenRouter (по умолчанию DeepSeek). -## Возможности (MVP) +## Возможности -- Чат с потоковыми ответами (SSE) -- Управление помидоро из чата через tool calling -- REST API для внешних клиентов (Telegram-бот, мобильное приложение) -- Веб-морда: вкладки «Чат» и «Помидоро» +- Чат с потоковыми ответами (SSE), скриншоты и vision-разбор изображений +- Помидоро-таймер с циклом работа/перерывы, управление из чата (tool calling) +- Долгосрочная память, профиль пользователя, опциональный RAG (Qdrant) +- Фитнес: дневник, TDEE/Navy, графики веса и состава тела +- Списки покупок, календарь напоминаний +- Персонаж и генерация картинок (ComfyUI / RP Chat) +- Интеграции: Taiga, Gitea, погода, утренний дайджест, Netdata +- Мультипользовательская авторизация по API-токену (`/login`) +- Веб-интерфейс: Чат, Помидоро, Персонаж, Память, Фитнес, Покупки, Календарь, Настройки +- REST API для внешних клиентов — см. [Telegram-бот](telegram-bot/README.md) ## Быстрый старт @@ -49,7 +55,8 @@ docker compose up --build | `TAIGA_PORT` | 9000 | Taiga (фаза 2) | | `GITEA_PORT` | 3000 | Gitea HTTP (фаза 2) | | `GITEA_SSH_PORT` | 222 | Gitea SSH (фаза 2) | -| `QDRANT_PORT` | 6333 | Qdrant HTTP (фаза 3) | +| `QDRANT_PORT` | 6333 | Qdrant HTTP | +| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | assistant | PostgreSQL в docker compose | ### 3. Локальная разработка @@ -59,10 +66,16 @@ docker compose up --build cd backend python -m venv .venv .venv\Scripts\activate # Windows -pip install -r requirements.txt +pip install -r requirements-dev.txt uvicorn app.main:app --reload --port 8080 ``` +Для локального backend без Docker задайте в `.env`: + +```env +DATABASE_URL=sqlite:///./data/assistant.db +``` + **Frontend:** ```bash @@ -75,34 +88,30 @@ Vite dev-server: http://localhost:5173 (проксирует `/api` на backend ## REST API +Полная схема — Swagger UI: `http://localhost:${BACKEND_PORT:-8080}/docs` + +Основные эндпоинты (префикс `/api/v1`, авторизация `Authorization: Bearer ` если `AUTH_REQUIRED=true`): + | Method | Path | Описание | |--------|------|----------| -| GET | `/api/v1/health` | Healthcheck | -| POST | `/api/v1/chat/sessions` | Создать чат-сессию | -| GET | `/api/v1/chat/sessions` | Список сессий | -| GET | `/api/v1/chat/sessions/{id}` | История сообщений | -| POST | `/api/v1/chat/sessions/{id}/messages` | Отправить сообщение (SSE) | -| DELETE | `/api/v1/chat/sessions/{id}` | Удалить сессию | -| GET | `/api/v1/pomodoro/status` | Статус таймера | -| POST | `/api/v1/pomodoro/start` | Старт `{duration_min, task_note}` | -| POST | `/api/v1/pomodoro/pause` | Пауза | -| POST | `/api/v1/pomodoro/resume` | Продолжить | -| POST | `/api/v1/pomodoro/stop` | Стоп `{result, completed}` | -| GET | `/api/v1/pomodoro/history` | История сессий | -| GET | `/api/v1/projects` | Проекты Taiga + привязка Gitea | -| POST | `/api/v1/projects/sync-taiga` | Синхронизировать проекты из Taiga | -| PUT | `/api/v1/projects/{slug}/gitea` | Привязать Gitea repo | -| POST | `/api/v1/work-items` | Создать фичу/баг → Taiga + Gitea | -| GET | `/api/v1/work-items` | Список work items | -| POST | `/api/v1/webhooks/gitea` | Webhook для автозакрытия по push | +| POST | `/login` | Получить сессию по API-токену | +| GET | `/health` | Healthcheck | +| POST/GET | `/chat/sessions` | Чат-сессии и история | +| POST | `/chat/sessions/{id}/messages` | Сообщение (SSE) | +| GET/POST | `/pomodoro/*` | Таймер | +| GET/PUT | `/memory`, `/profile` | Память и профиль | +| GET/POST | `/fitness/*` | Фитнес, графики `/fitness/charts` | +| GET/POST | `/shopping/*` | Списки покупок | +| GET/POST | `/reminders/*` | Напоминания и календарь | +| GET/POST | `/documents/*` | Загрузка документов (RAG) | +| GET | `/homelab/status`, `/homelab/weather` | Homelab | +| GET/PUT | `/settings` | RAG toggle, пользователи | +| GET/POST | `/projects`, `/work-items` | Taiga + Gitea | +| POST | `/webhooks/gitea` | Webhook автозакрытия | ## Taiga + Gitea (фаза 2) -Taiga и Gitea работают **на хосте** (не в Docker): -- Taiga: `127.0.0.1:9000` → `taiga.grigowashere.ru` -- Gitea: `127.0.0.1:3000` → `git.grigowashere.ru` - -Контейнер backend достучится через `host.docker.internal` (настроено в `docker-compose.yml`). +Taiga и Gitea обычно работают **на хосте** (не в Docker compose). Контейнер backend достучится через `host.docker.internal` (настроено в `docker-compose.yml`). Публичные URL — в `.env` (`TAIGA_PUBLIC_URL`, `GITEA_PUBLIC_URL`). ### Настройка `.env` @@ -110,11 +119,11 @@ Taiga и Gitea работают **на хосте** (не в Docker): TAIGA_BASE_URL=http://host.docker.internal:9000 TAIGA_USERNAME=... TAIGA_PASSWORD=... -TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru +TAIGA_PUBLIC_URL=https://taiga.example.com GITEA_BASE_URL=http://host.docker.internal:3000 GITEA_TOKEN=... # Settings → Applications → Generate Token -GITEA_PUBLIC_URL=https://git.grigowashere.ru +GITEA_PUBLIC_URL=https://git.example.com GITEA_WEBHOOK_SECRET=... # произвольная строка ``` @@ -162,14 +171,44 @@ Closes gitea #12, taiga #45 ## Структура проекта ``` -backend/ FastAPI, OpenRouter, SQLite, помидоро -frontend/ React + Vite, чат и таймер -data/ SQLite БД (создаётся автоматически) +backend/ FastAPI, OpenRouter, PostgreSQL (docker) / SQLite (local dev) +frontend/ React + Vite +telegram-bot/ Telegram-клиент (отдельный VPS) +data/ uploads, generated media, SQLite-бэкап при миграции +deploy/ примеры nginx ``` -## Память и контекст (фаза 3a) +## PostgreSQL -Долгосрочная память в SQLite, без векторов: +В `docker compose` по умолчанию поднимается **PostgreSQL 16**. Переменные — `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` в `.env`. + +### Миграция с SQLite + +Если уже есть `./data/assistant.db`: + +```bash +# 1. Бэкап +cp -a data data.bak.$(date +%Y%m%d) + +# 2. Поднять postgres +docker compose up -d postgres + +# 3. Dry-run (подсчёт строк) +docker compose run --rm backend python scripts/migrate_sqlite_to_postgres.py --dry-run + +# 4. Импорт (DATABASE_URL уже указывает на postgres в compose) +docker compose run --rm backend python scripts/migrate_sqlite_to_postgres.py + +# 5. Перезапуск +docker compose up -d +``` + +Флаги: `--force` — очистить Postgres перед импортом; `--sqlite-path` — путь к файлу. +SQLite-файл **не удаляется** — остаётся бэкапом. + +## Память и контекст + +Долгосрочная память в БД (PostgreSQL или SQLite). При включённом RAG — семантический поиск фактов через Qdrant. | Слой | Что хранит | |------|------------| @@ -203,10 +242,29 @@ data/ SQLite БД (создаётся автоматически) | DELETE | `/api/v1/memory/facts/{id}` | забыть | | PUT | `/api/v1/memory/sessions/{id}/summary` | сводка чата | +## RAG (Qdrant) + +Векторный поиск по фактам памяти и загруженным документам. **Два условия одновременно:** + +1. `RAG_ENABLED=true` в `.env` (Qdrant должен быть доступен) +2. Toggle «RAG включён» в **Настройки** (веб-UI) + +Загрузка документов: Settings → документы, или `POST /api/v1/documents/upload` (текст: `.txt`, `.md`, `.json`, `.csv`). +Tool в чате: `search_documents`. + +Backfill существующих фактов: + +```bash +docker compose exec backend python -m app.rag.migrate_memory_to_qdrant +``` + +Ограничения: нет API удаления документов; session summaries индексируются, но в чате читаются из SQLite; при ошибке embedding — fallback на топ фактов из БД. + ## Фитнес-трекер -Профиль, дневник (еда/вода/вес/тренировки), калькуляторы TDEE, LLM-оценка ккал/БЖУ, -lookup wger + Open Food Facts, напоминания в чат (`💪`), вкладка `/fitness`. +Профиль, дневник (еда/вода/вес/шаги/тренировки), калькуляторы TDEE и Navy (WHR/LBM/FFMI), +графики веса и состава тела (`/fitness`, API `/fitness/charts`), LLM-оценка ккал/БЖУ, +lookup wger + Open Food Facts, vision-импорт скриншотов Mi Fitness, напоминания в чат. Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3». @@ -222,8 +280,8 @@ lookup wger + Open Food Facts, напоминания в чат (`💪`), вкл | Сервис | URL по умолчанию | Назначение | |--------|------------------|------------| -| Open-Meteo | `http://192.168.1.109:8085` | Погода СПб в контексте и tool `get_weather` | -| ComfyUI | `http://192.168.1.109:8188` | fallback / рофл-watcher | +| Open-Meteo | `$OPENMETEO_BASE_URL` | Погода в контексте и tool `get_weather` | +| ComfyUI | `$COMFYUI_BASE_URL` | fallback / рофл-watcher | | RP Chat (aiChatBot) | `http://host.docker.internal:8201` | `generate_image`: sd-prompt + Anima; appearance в `/character` | | Netdata | `http://host.docker.internal:19999` | Алерты warning/critical → notice в чат | @@ -244,10 +302,10 @@ curl -s http://localhost:${BACKEND_PORT:-8202}/api/v1/homelab/status | python3 - ```bash docker compose exec backend python -c " -import httpx +import os, httpx for url in [ - 'http://192.168.1.109:8085/v1/forecast?latitude=59.93&longitude=30.33¤t=temperature_2m', - 'http://192.168.1.109:8188/system_stats', + os.environ.get('OPENMETEO_BASE_URL', 'http://host.docker.internal:8085').rstrip('/') + '/v1/forecast?latitude=59.93&longitude=30.33¤t=temperature_2m', + os.environ.get('COMFYUI_BASE_URL', 'http://host.docker.internal:8188').rstrip('/') + '/system_stats', 'http://host.docker.internal:19999/api/v1/info', ]: try: @@ -261,12 +319,26 @@ for url in [ По умолчанию **Anima** (как в aiChatBot): `COMFYUI_UNET` + `COMFYUI_CLIP` + `COMFYUI_VAE` + style LoRA. `COMFYUI_CHECKPOINT` оставь пустым. Для SD1.5/Pony — укажи checkpoint и очисти `COMFYUI_UNET`. +## Telegram-бот + +Отдельный сервис в [`telegram-bot/`](telegram-bot/README.md): диалог с ассистентом с VPS, привязка API-токена, дублирование notice из чата. +Создание пользователя: Settings → Пользователи или `docker compose exec backend python -m scripts.create_user`. + +## Разработка и тесты + +```bash +cd backend +pip install -r requirements-dev.txt +pytest tests/ -q +``` + +Перед деплоем Jenkins запускает pytest (см. `Jenkinsfile`). CI на GitHub Actions нет — только Jenkins на linux-ноде. + ## Следующие фазы -- RAG по файлам (Qdrant) -- Telegram-бот - Taiga/fitness в утреннем дайджесте -- Графики веса, LLM-мотивация в напоминаниях +- LLM-мотивация в фитнес-напоминаниях (сейчас шаблонные строки) +- API удаления документов и re-index при включении RAG задним числом ## Модель diff --git a/backend/app/api/routes/__init__.py.refactor_bak b/backend/app/api/routes/__init__.py.refactor_bak deleted file mode 100644 index a69fb85..0000000 --- a/backend/app/api/routes/__init__.py.refactor_bak +++ /dev/null @@ -1,17 +0,0 @@ -from fastapi import APIRouter - -from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, reminders, shopping, webhooks - -api_router = APIRouter(prefix="/api/v1") -api_router.include_router(health.router, tags=["health"]) -api_router.include_router(homelab.router, tags=["homelab"]) -api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) -api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"]) -api_router.include_router(character.router, tags=["character"]) -api_router.include_router(projects.router, tags=["projects"]) -api_router.include_router(memory.router, tags=["memory"]) -api_router.include_router(fitness.router, tags=["fitness"]) -api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"]) -api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"]) -api_router.include_router(webhooks.router, tags=["webhooks"]) -api_router.include_router(media.router, tags=["media"]) diff --git a/backend/app/api/routes/chat.py.refactor_bak b/backend/app/api/routes/chat.py.refactor_bak deleted file mode 100644 index 6b85cce..0000000 --- a/backend/app/api/routes/chat.py.refactor_bak +++ /dev/null @@ -1,70 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session - -from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut -from app.chat.service import ChatService -from app.db.base import get_db - -router = APIRouter() - - -@router.post("/sessions", response_model=SessionOut) -def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut: - service = ChatService(db) - return service.create_session(title=payload.title) - - -@router.get("/sessions", response_model=list[SessionOut]) -def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]: - service = ChatService(db) - return service.list_sessions() - - -@router.get("/sessions/{session_id}", response_model=SessionDetailOut) -def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut: - service = ChatService(db) - session = service.get_session(session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - return session - - -@router.delete("/sessions/{session_id}") -def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]: - service = ChatService(db) - if not service.delete_session(session_id): - raise HTTPException(status_code=404, detail="Session not found") - return {"ok": True} - - -@router.post("/sessions/{session_id}/messages") -async def send_message( - session_id: int, - payload: MessageCreate, - db: Session = Depends(get_db), -) -> StreamingResponse: - service = ChatService(db) - if not service.get_session(session_id): - raise HTTPException(status_code=404, detail="Session not found") - - # Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД. - service.save_user_message(session_id, payload.content) - - async def event_stream(): - async for chunk in service.stream_response( - session_id, - payload.content, - user_message_saved=True, - ): - yield chunk - - return StreamingResponse( - event_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py index 46c441a..d486b35 100644 --- a/backend/app/chat/notices.py +++ b/backend/app/chat/notices.py @@ -3,6 +3,13 @@ from typing import Any from app.db.models import PomodoroSession from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK +from app.tools.fitness import TOOL_NAMES as FITNESS_TOOL_NAMES +from app.tools.homelab import TOOL_NAMES as HOMELAB_TOOL_NAMES +from app.tools.memory import TOOL_NAMES as MEMORY_TOOL_NAMES +from app.tools.pomodoro import TOOL_NAMES as POMODORO_TOOL_NAMES +from app.tools.projects import TOOL_NAMES as PROJECT_TOOL_NAMES +from app.tools.reminders import TOOL_NAMES as REMINDER_TOOL_NAMES +from app.tools.shopping import TOOL_NAMES as SHOPPING_TOOL_NAMES PHASE_LABELS = { PHASE_WORK: "Работа", @@ -48,57 +55,6 @@ def format_phase_completed_notice( return "\n".join(lines) -POMODORO_TOOL_NAMES = frozenset({ - "get_pomodoro_status", - "start_pomodoro", - "start_short_break", - "start_long_break", - "stop_pomodoro", - "skip_pomodoro_phase", - "reset_pomodoro_cycle", - "get_pomodoro_history", -}) - -MEMORY_TOOL_NAMES = frozenset({ - "remember_fact", - "recall_memories", - "forget_memory", - "update_profile", - "update_session_summary", -}) - -FITNESS_TOOL_NAMES = frozenset({ - "get_fitness_summary", - "get_fitness_history", - "set_fitness_profile", - "calc_fitness_targets", - "calc_body_composition", - "log_meal", - "log_water", - "log_weight", - "log_workout", - "lookup_food", - "lookup_exercise", - "set_fitness_reminder", -}) - -# Не засорять чат служебными ответами -REMINDER_TOOL_NAMES = frozenset({ - "list_reminders", - "create_reminder", - "update_reminder", - "delete_reminder", - "complete_reminder", -}) - -SHOPPING_TOOL_NAMES = frozenset({ - "list_shopping_lists", - "create_shopping_list", - "add_shopping_items", - "check_shopping_item", - "remove_shopping_item", - "delete_shopping_list", -}) TOOLS_SKIP_CHAT_NOTICE = frozenset({ "get_pomodoro_status", @@ -156,6 +112,10 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None: prefix = "🛒" elif tool_name in REMINDER_TOOL_NAMES: prefix = "📅" + elif tool_name in PROJECT_TOOL_NAMES: + prefix = "📋" + elif tool_name in HOMELAB_TOOL_NAMES: + prefix = "🏠" else: prefix = "📋" return f"{prefix} {data['error']}" diff --git a/backend/app/chat/service.py.refactor_bak b/backend/app/chat/service.py.refactor_bak deleted file mode 100644 index 4042418..0000000 --- a/backend/app/chat/service.py.refactor_bak +++ /dev/null @@ -1,468 +0,0 @@ -import asyncio -import json -import logging -import time -from collections.abc import AsyncIterator -from typing import Any - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.config import get_settings -from app.db.base import SessionLocal -from app.character.service import CharacterService -from app.chat.history import sanitize_openai_messages, strip_historical_reasoning -from app.chat.notice_inbox import DISPLAY_ONLY_ROLES -from app.chat.notices import ( - POMODORO_TOOL_NAMES, - format_pomodoro_context, - format_tool_notice, -) -from app.fitness.context import format_fitness_context, get_fitness_snapshot -from app.homelab.context import format_datetime_context -from app.homelab.openmeteo import format_weather_snapshot -from app.memory.context import ( - format_identity_hint, - format_memory_context, - get_memory_snapshot, -) -from app.memory.extract import extract_after_turn -from app.projects.context import format_projects_context, get_projects_snapshot -from app.reminders.context import format_reminders_context, get_reminders_snapshot -from app.shopping.context import format_shopping_context, get_shopping_snapshot -from app.db.models import ChatSession, Message -from app.llm.client import LLMClient -from app.pomodoro.service import PomodoroService -from app.tools.registry import TOOL_DEFINITIONS, execute_tool - -MAX_TOOL_ROUNDS = 5 -MAX_HISTORY_MESSAGES = 40 - -logger = logging.getLogger(__name__) - - -def _build_messages_for_session(session_id: int) -> list[dict[str, Any]]: - db = SessionLocal() - try: - service = ChatService(db) - session = service.get_session(session_id) - if not session: - return [] - return service._build_messages(session) - finally: - db.close() - - -async def _extract_memory_background( - session_id: int, - user_text: str, - assistant_text: str, -) -> None: - db = SessionLocal() - try: - await extract_after_turn(db, session_id, user_text, assistant_text) - except Exception as exc: - logger.warning("Background memory extraction failed: %s", exc) - finally: - db.close() - - -class ChatService: - def __init__(self, db: Session): - self.db = db - self.llm = LLMClient() - self.character = CharacterService() - - def list_sessions(self) -> list[ChatSession]: - stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()) - return list(self.db.scalars(stmt).all()) - - def get_session(self, session_id: int) -> ChatSession | None: - return self.db.get(ChatSession, session_id) - - def create_session(self, title: str = "Новый чат") -> ChatSession: - session = ChatSession(title=title) - self.db.add(session) - self.db.commit() - self.db.refresh(session) - return session - - def delete_session(self, session_id: int) -> bool: - session = self.get_session(session_id) - if not session: - return False - self.db.delete(session) - self.db.commit() - return True - - def _build_system_prompt(self, session_id: int | None = None) -> str: - status = PomodoroService(self.db).get_status() - memory_snapshot = get_memory_snapshot(self.db, session_id) - fitness_snapshot = get_fitness_snapshot(self.db) - shopping_snapshot = get_shopping_snapshot(self.db) - reminders_snapshot = get_reminders_snapshot(self.db) - projects_snapshot = get_projects_snapshot(self.db) - return ( - f"{self.character.get_system_prompt()}\n\n" - f"{format_datetime_context(self.db)}\n\n" - f"{format_memory_context(memory_snapshot)}\n\n" - f"{format_fitness_context(fitness_snapshot)}\n\n" - f"{format_shopping_context(shopping_snapshot)}\n\n" - f"{format_reminders_context(reminders_snapshot)}\n\n" - f"{format_weather_snapshot()}\n\n" - f"{format_pomodoro_context(status)}\n\n" - f"{format_projects_context(projects_snapshot)}" - ) - - def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]: - system_prompt = self._build_system_prompt(session.id) - all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES] - last_user = next((m.content for m in reversed(all_chat) if m.role == "user"), "") - if last_user: - memory_snapshot = get_memory_snapshot(self.db, session.id) - identity_hint = format_identity_hint(memory_snapshot, last_user) - if identity_hint: - system_prompt += f"\n\n{identity_hint}" - if len(all_chat) > MAX_HISTORY_MESSAGES: - system_prompt += ( - f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} " - f"из {len(all_chat)} сообщений. Раннее — в сводке сессии, если сохранена.]" - ) - messages: list[dict[str, Any]] = [ - {"role": "system", "content": system_prompt} - ] - chat_messages = all_chat[-MAX_HISTORY_MESSAGES:] if len(all_chat) > MAX_HISTORY_MESSAGES else all_chat - - for msg in chat_messages: - content = msg.content or None - entry: dict[str, Any] = {"role": msg.role, "content": content} - if msg.tool_calls_json: - entry["tool_calls"] = json.loads(msg.tool_calls_json) - if not content: - entry["content"] = None - reasoning_data = LLMClient.deserialize_reasoning(msg.reasoning_json) - if reasoning_data: - LLMClient.attach_reasoning_to_message( - entry, - reasoning=reasoning_data.get("reasoning", ""), - reasoning_details=reasoning_data.get("reasoning_details"), - ) - if msg.role == "tool" and msg.tool_call_id: - entry["tool_call_id"] = msg.tool_call_id - messages.append(entry) - messages = sanitize_openai_messages(messages) - messages = strip_historical_reasoning(messages) - return messages - - def _save_message( - self, - session_id: int, - role: str, - content: str = "", - tool_calls: list[dict[str, Any]] | None = None, - tool_call_id: str | None = None, - reasoning_json: str | None = None, - ) -> Message: - message = Message( - session_id=session_id, - role=role, - content=content, - tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None, - reasoning_json=reasoning_json, - tool_call_id=tool_call_id, - ) - self.db.add(message) - session = self.get_session(session_id) - if session and role == "user" and session.title == "Новый чат" and content: - session.title = content[:60] + ("..." if len(content) > 60 else "") - self.db.commit() - self.db.refresh(message) - return message - - def save_user_message(self, session_id: int, user_text: str) -> None: - self._save_message(session_id, "user", user_text) - - async def _fallback_complete( - self, - messages: list[dict[str, Any]], - session_id: int, - ) -> tuple[str, list[str], list[dict[str, Any]]]: - """Нестриминговый запасной путь, если stream вернул пустоту.""" - logger.info("chat session=%s fallback complete", session_id) - result: dict[str, Any] = {"content": "", "tool_calls": []} - for with_tools in (True, False): - result = await self.llm.complete( - messages, - tools=TOOL_DEFINITIONS if with_tools else None, - temperature=0.5, - visible_reply=True, - ) - if (result.get("content") or "").strip() or result.get("tool_calls"): - break - - tool_calls = result.get("tool_calls") or [] - content = (result.get("content") or "").strip() - notices: list[str] = [] - pomodoro_events: list[dict[str, Any]] = [] - - if tool_calls: - assistant_msg: dict[str, Any] = { - "role": "assistant", - "content": content or None, - "tool_calls": tool_calls, - } - messages.append(assistant_msg) - self._save_message( - session_id, - "assistant", - content, - tool_calls=tool_calls, - ) - for tool_call in tool_calls: - fn = tool_call["function"] - args = LLMClient.parse_tool_arguments(fn.get("arguments", "")) - tool_result = await execute_tool( - self.db, fn["name"], args, session_id=session_id - ) - messages.append( - { - "role": "tool", - "tool_call_id": tool_call["id"], - "content": tool_result, - } - ) - self._save_message( - session_id, - "tool", - tool_result, - tool_call_id=tool_call["id"], - ) - notice = format_tool_notice(fn["name"], tool_result) - if notice: - self._save_message(session_id, "notice", notice) - notices.append(notice) - if fn["name"] in POMODORO_TOOL_NAMES: - pomodoro_events.append( - {"name": fn["name"], "result": json.loads(tool_result)} - ) - - followup = await self.llm.complete( - messages, - tools=None, - temperature=0.4, - visible_reply=True, - ) - return (followup.get("content") or "").strip(), notices, pomodoro_events - - return content, notices, pomodoro_events - - async def stream_response( - self, - session_id: int, - user_text: str, - *, - user_message_saved: bool = False, - ) -> AsyncIterator[str]: - session = self.get_session(session_id) - if not session: - yield self._sse("error", {"message": "Session not found"}) - return - - if not user_message_saved: - self._save_message(session_id, "user", user_text) - yield self._sse("status", {"phase": "preparing"}) - t0 = time.monotonic() - messages = await asyncio.to_thread(_build_messages_for_session, session_id) - prepare_sec = time.monotonic() - t0 - if not messages: - yield self._sse("error", {"message": "Session not found"}) - return - yield self._sse("status", {"phase": "generating"}) - streamed_reply_parts: list[str] = [] - all_tool_notices: list[str] = [] - tools_executed = 0 - tool_round = 0 - - for _ in range(MAX_TOOL_ROUNDS): - tool_round += 1 - t_round = time.monotonic() - content_parts: list[str] = [] - tool_calls: list[dict[str, Any]] = [] - reasoning = "" - reasoning_details: list[Any] | None = None - finish_reason = "" - - # После tool-раунда стримим вживую; до tools — буфер (иначе текст «переписывает» notice). - stream_live = tools_executed > 0 - - async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS): - if event["type"] == "content": - content_parts.append(event["content"]) - if stream_live: - yield self._sse("token", {"content": event["content"]}) - elif event["type"] == "reasoning": - reasoning = event.get("reasoning", "") or reasoning - if event.get("reasoning_details"): - reasoning_details = event["reasoning_details"] - elif event["type"] == "error": - logger.warning( - "chat session=%s llm_error round=%d prepare=%.2fs: %s", - session_id, - tool_round, - prepare_sec, - event.get("content"), - ) - yield self._sse("error", {"message": event.get("content", "LLM error")}) - return - elif event["type"] == "tool_calls": - tool_calls = event["tool_calls"] - elif event["type"] == "done": - finish_reason = event.get("finish_reason", "") - - logger.info( - "chat session=%s round=%d prepare=%.2fs llm=%.2fs " - "content_len=%d tool_calls=%d finish_reason=%s reasoning_len=%d", - session_id, - tool_round, - prepare_sec, - time.monotonic() - t_round, - len("".join(content_parts)), - len(tool_calls), - finish_reason, - len(reasoning), - ) - - if tool_calls: - round_text = "".join(content_parts) - if round_text.strip(): - streamed_reply_parts.append(round_text) - - assistant_msg: dict[str, Any] = { - "role": "assistant", - "content": round_text or None, - "tool_calls": tool_calls, - } - LLMClient.attach_reasoning_to_message( - assistant_msg, - reasoning=reasoning, - reasoning_details=reasoning_details, - ) - reasoning_json = LLMClient.serialize_reasoning( - reasoning=reasoning, - reasoning_details=reasoning_details, - ) - messages.append(assistant_msg) - self._save_message( - session_id, - "assistant", - round_text, - tool_calls=tool_calls, - reasoning_json=reasoning_json, - ) - - round_notices: list[str] = [] - for tool_call in tool_calls: - fn = tool_call["function"] - args = LLMClient.parse_tool_arguments(fn.get("arguments", "")) - result = await execute_tool( - self.db, fn["name"], args, session_id=session_id - ) - tools_executed += 1 - tool_message = { - "role": "tool", - "tool_call_id": tool_call["id"], - "content": result, - } - messages.append(tool_message) - self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"]) - - notice = format_tool_notice(fn["name"], result) - if notice: - self._save_message(session_id, "notice", notice) - round_notices.append(notice) - all_tool_notices.append(notice) - - if fn["name"] in POMODORO_TOOL_NAMES: - yield self._sse( - "pomodoro", - {"name": fn["name"], "result": json.loads(result)}, - ) - - yield self._sse("status", {"phase": "tools"}) - for notice in round_notices: - yield self._sse("notice", {"content": notice}) - - continue - - if content_parts and not stream_live: - for part in content_parts: - yield self._sse("token", {"content": part}) - - final_content = "".join(content_parts).strip() - if not final_content and streamed_reply_parts and tools_executed == 0: - final_content = "".join(streamed_reply_parts).strip() - if not final_content and reasoning: - final_content = reasoning.strip() - if not final_content and tools_executed: - retry = await self.llm.complete( - messages, - tools=None, - temperature=0.4, - visible_reply=True, - ) - final_content = (retry.get("content") or "").strip() - if final_content: - yield self._sse("token", {"content": final_content}) - # Notices уже в чате как role=notice — не дублируем в assistant. - if not final_content: - final_content, fb_notices, fb_pomodoro = await self._fallback_complete( - messages, session_id - ) - if final_content: - yield self._sse("token", {"content": final_content}) - for notice in fb_notices: - yield self._sse("notice", {"content": notice}) - for event in fb_pomodoro: - yield self._sse("pomodoro", event) - - if not final_content: - logger.warning( - "chat session=%s empty_reply tools=%d rounds=%d finish_reason=%s", - session_id, - tools_executed, - tool_round, - finish_reason, - ) - yield self._sse( - "error", - { - "message": ( - "Модель не вернула ответ (finish_reason=" - f"{finish_reason or 'unknown'}). " - "Попробуй новый чат или проверь OPENROUTER_MODEL." - ), - }, - ) - return - - self._save_message(session_id, "assistant", final_content) - - logger.info( - "chat session=%s done tools=%d reply_len=%d total=%.2fs", - session_id, - tools_executed, - len(final_content), - time.monotonic() - t0, - ) - yield self._sse("done", {}) - if get_settings().memory_auto_extract: - asyncio.create_task( - _extract_memory_background(session_id, user_text, final_content) - ) - return - - yield self._sse("error", {"message": "Too many tool call rounds"}) - - @staticmethod - def _sse(event: str, data: dict[str, Any]) -> str: - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" diff --git a/backend/app/config.py b/backend/app/config.py index 31e5244..093d7e5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -66,11 +66,11 @@ class Settings(BaseSettings): taiga_base_url: str = "http://host.docker.internal:9000" taiga_username: str = "" taiga_password: str = "" - taiga_public_url: str = "https://taiga.grigowashere.ru" + taiga_public_url: str = "https://taiga.example.com" gitea_base_url: str = "http://host.docker.internal:3000" gitea_token: str = "" - gitea_public_url: str = "https://git.grigowashere.ru" + gitea_public_url: str = "https://git.example.com" gitea_webhook_secret: str = "" repos_dir: str = "/data/repos" @@ -80,7 +80,7 @@ class Settings(BaseSettings): fitness_reminders_enabled: bool = True reminders_enabled: bool = True - openmeteo_base_url: str = "http://192.168.1.109:8085" + openmeteo_base_url: str = "http://host.docker.internal:8085" weather_lat: float = 59.9343 weather_lon: float = 30.3351 weather_location_name: str = "Санкт-Петербург" @@ -100,7 +100,7 @@ class Settings(BaseSettings): morning_digest_hour: int = 8 morning_digest_minute: int = 0 - comfyui_base_url: str = "http://192.168.1.109:8188" + comfyui_base_url: str = "http://host.docker.internal:8188" comfyui_enabled: bool = True # Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty comfyui_checkpoint: str = "" diff --git a/backend/app/config.py.refactor_bak b/backend/app/config.py.refactor_bak deleted file mode 100644 index 3276f62..0000000 --- a/backend/app/config.py.refactor_bak +++ /dev/null @@ -1,127 +0,0 @@ -from functools import lru_cache -from pathlib import Path - -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - model_config = SettingsConfigDict( - env_file=(".env", "../.env"), - env_file_encoding="utf-8", - extra="ignore", - ) - - host: str = "0.0.0.0" - port: int = 8080 - - openrouter_api_key: str = "" - openrouter_model: str = "deepseek/deepseek-chat" - openrouter_base_url: str = "https://openrouter.ai/api/v1" - # Отдельная модель для JSON-задач (память, фитнес). Пусто = та же, что OPENROUTER_MODEL. - memory_extract_model: str = "" - # Некоторые модели (reasoning / без function calling) — выключить tools. - openrouter_tools_enabled: bool = True - # DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking. - openrouter_reasoning_effort: str = "none" - - database_url: str = "sqlite:///./data/assistant.db" - cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000" - system_prompt_path: str = "./prompts/assistant.md" - memory_auto_extract: bool = True - - # Taiga/Gitea on host (not in Docker) — use host.docker.internal from container - taiga_base_url: str = "http://host.docker.internal:9000" - taiga_username: str = "" - taiga_password: str = "" - taiga_public_url: str = "https://taiga.grigowashere.ru" - - gitea_base_url: str = "http://host.docker.internal:3000" - gitea_token: str = "" - gitea_public_url: str = "https://git.grigowashere.ru" - gitea_webhook_secret: str = "" - - repos_dir: str = "/data/repos" - - wger_base_url: str = "https://wger.de/api/v2" - openfoodfacts_base_url: str = "https://world.openfoodfacts.org" - fitness_reminders_enabled: bool = True - reminders_enabled: bool = True - - openmeteo_base_url: str = "http://192.168.1.109:8085" - weather_lat: float = 59.9343 - weather_lon: float = 30.3351 - weather_location_name: str = "Санкт-Петербург" - weather_cache_sec: int = 300 - - news_rss_urls: str = ( - "https://habr.com/ru/rss/all/all/," - "https://www.reddit.com/r/programming/.rss" - ) - news_cache_sec: int = 1800 - news_max_items: int = 7 - - morning_digest_enabled: bool = True - morning_digest_hour: int = 8 - morning_digest_minute: int = 0 - - comfyui_base_url: str = "http://192.168.1.109:8188" - comfyui_enabled: bool = True - # Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty - comfyui_checkpoint: str = "" - comfyui_unet: str = "anima-preview3-base.safetensors" - comfyui_clip: str = "qwen_3_06b_base.safetensors" - comfyui_vae: str = "qwen_image_vae.safetensors" - comfyui_style_lora: str = "anima-preview-3-masterpieces-v5.safetensors" - comfyui_style_lora_weight: float = 0.7 - comfyui_steps: int = 30 - comfyui_cfg: float = 4.0 - comfyui_sampler: str = "er_sde" - comfyui_scheduler: str = "simple" - comfyui_width: int = 1024 - comfyui_height: int = 720 - comfyui_negative_prompt: str = ( - "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia" - ) - comfyui_poll_interval_sec: float = 2.0 - comfyui_timeout_sec: float = 180.0 - comfyui_rofl_enabled: bool = True - comfyui_rofl_max_per_day: int = 1 - comfyui_rofl_probability: float = 0.15 - comfyui_rofl_min_interval_hours: int = 12 - generated_media_dir: str = "./data/generated" - - netdata_base_url: str = "http://host.docker.internal:19999" - netdata_public_url: str = "" - netdata_alerts_enabled: bool = True - netdata_poll_interval_sec: int = 120 - - rp_chat_base_url: str = "http://host.docker.internal:8201" - rp_chat_enabled: bool = True - rp_chat_timeout_sec: float = 300.0 - - @property - def cors_origins_list(self) -> list[str]: - return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] - - @property - def taiga_configured(self) -> bool: - return bool(self.taiga_username and self.taiga_password) - - @property - def gitea_configured(self) -> bool: - return bool(self.gitea_token) - - @property - def news_rss_urls_list(self) -> list[str]: - return [u.strip() for u in self.news_rss_urls.split(",") if u.strip()] - - def load_system_prompt(self) -> str: - path = Path(self.system_prompt_path) - if path.is_file(): - return path.read_text(encoding="utf-8") - return "Ты домашний ИИ-ассистент. Общайся на русском." - - -@lru_cache -def get_settings() -> Settings: - return Settings() diff --git a/backend/app/db/dialect.py b/backend/app/db/dialect.py new file mode 100644 index 0000000..0384478 --- /dev/null +++ b/backend/app/db/dialect.py @@ -0,0 +1,19 @@ +from sqlalchemy.engine import Engine + + +def dialect_name(engine: Engine) -> str: + return engine.dialect.name + + +def is_sqlite(engine: Engine) -> bool: + return dialect_name(engine) == "sqlite" + + +def is_postgresql(engine: Engine) -> bool: + return dialect_name(engine) == "postgresql" + + +def bool_literal(engine: Engine, value: bool = False) -> str: + if is_sqlite(engine): + return "1" if value else "0" + return "true" if value else "false" diff --git a/backend/app/db/migrate.py b/backend/app/db/migrate.py index a5b328f..92a4d42 100644 --- a/backend/app/db/migrate.py +++ b/backend/app/db/migrate.py @@ -1,6 +1,7 @@ from sqlalchemy import inspect, text from app.db.base import engine +from app.db.dialect import bool_literal def run_migrations() -> None: @@ -17,7 +18,7 @@ def run_migrations() -> None: conn.execute( text( "ALTER TABLE pomodoro_sessions " - "ADD COLUMN completion_notified BOOLEAN DEFAULT 0" + f"ADD COLUMN completion_notified BOOLEAN DEFAULT {bool_literal(engine, False)}" ) ) diff --git a/backend/app/db/migrate_fitness.py b/backend/app/db/migrate_fitness.py index 1e9d892..d711351 100644 --- a/backend/app/db/migrate_fitness.py +++ b/backend/app/db/migrate_fitness.py @@ -4,7 +4,7 @@ from sqlalchemy import inspect, select, text from sqlalchemy.orm import Session from app.db.base import engine -from app.db.models import FitnessProfile +from app.db.models import FitnessProfile, StepLog from app.fitness.calculators import DEFAULT_NEAT_KCAL, compute_targets, macro_targets logger = logging.getLogger(__name__) @@ -173,19 +173,7 @@ def run_fitness_migrations() -> None: ) if "step_logs" not in inspector.get_table_names(): - with engine.begin() as conn: - conn.execute( - text( - "CREATE TABLE step_logs (" - "id INTEGER PRIMARY KEY AUTOINCREMENT, " - "logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, " - "steps INTEGER DEFAULT 0, " - "active_calories FLOAT, " - "source VARCHAR(32) DEFAULT 'manual', " - "notes TEXT DEFAULT ''" - ")" - ) - ) + StepLog.__table__.create(engine, checkfirst=True) if "body_metrics" in inspector.get_table_names(): _add_column_if_missing( diff --git a/backend/app/db/migrate_multi_user.py b/backend/app/db/migrate_multi_user.py index 1292eb8..f53631a 100644 --- a/backend/app/db/migrate_multi_user.py +++ b/backend/app/db/migrate_multi_user.py @@ -11,6 +11,7 @@ from app.auth.tokens import hash_token from app.character.card import DEFAULT_CARD, normalize_card from app.config import get_settings from app.db.base import engine +from app.db.models import CharacterCard, User logger = logging.getLogger(__name__) @@ -55,39 +56,11 @@ def _add_column_if_missing(table: str, column: str, ddl: str) -> None: def _ensure_users_table() -> None: - if _table_exists("users"): - return - with engine.begin() as conn: - conn.execute( - text( - "CREATE TABLE users (" - "id INTEGER PRIMARY KEY AUTOINCREMENT, " - "username VARCHAR(64) NOT NULL UNIQUE, " - "display_name VARCHAR(255) DEFAULT '', " - "api_token_hash VARCHAR(64) NOT NULL, " - "is_active BOOLEAN DEFAULT 1, " - "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" - ")" - ) - ) - conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_api_token_hash ON users (api_token_hash)")) + User.__table__.create(engine, checkfirst=True) def _ensure_character_cards_table() -> None: - if _table_exists("character_cards"): - return - with engine.begin() as conn: - conn.execute( - text( - "CREATE TABLE character_cards (" - "id INTEGER PRIMARY KEY AUTOINCREMENT, " - "user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, " - "card_json TEXT DEFAULT '{}', " - "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP" - ")" - ) - ) - conn.execute(text("CREATE INDEX IF NOT EXISTS ix_character_cards_user_id ON character_cards (user_id)")) + CharacterCard.__table__.create(engine, checkfirst=True) def _add_user_id_columns() -> None: diff --git a/backend/app/db/models.py.refactor_bak b/backend/app/db/models.py.refactor_bak deleted file mode 100644 index 394de86..0000000 --- a/backend/app/db/models.py.refactor_bak +++ /dev/null @@ -1,299 +0,0 @@ -from datetime import datetime - -from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.db.base import Base - - -class ChatSession(Base): - __tablename__ = "chat_sessions" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - title: Mapped[str] = mapped_column(String(255), default="Новый чат") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - messages: Mapped[list["Message"]] = relationship( - back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at" - ) - - -class Message(Base): - __tablename__ = "messages" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True) - role: Mapped[str] = mapped_column(String(32)) - content: Mapped[str] = mapped_column(Text, default="") - tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True) - reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True) - tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - - session: Mapped["ChatSession"] = relationship(back_populates="messages") - - -class PomodoroCycle(Base): - __tablename__ = "pomodoro_cycles" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - work_duration_min: Mapped[int] = mapped_column(Integer, default=25) - short_break_min: Mapped[int] = mapped_column(Integer, default=5) - long_break_min: Mapped[int] = mapped_column(Integer, default=15) - sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4) - completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0) - task_note: Mapped[str] = mapped_column(Text, default="") - auto_advance: Mapped[bool] = mapped_column(Boolean, default=True) - chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - -class PomodoroSession(Base): - __tablename__ = "pomodoro_sessions" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - status: Mapped[str] = mapped_column(String(32), default="idle") - phase: Mapped[str] = mapped_column(String(32), default="work") - duration_min: Mapped[int] = mapped_column(Integer, default=25) - task_note: Mapped[str] = mapped_column(Text, default="") - result: Mapped[str | None] = mapped_column(Text, nullable=True) - completed: Mapped[bool] = mapped_column(Boolean, default=False) - completion_notified: Mapped[bool] = mapped_column(Boolean, default=False) - started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0) - finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - - -class TaigaProject(Base): - __tablename__ = "taiga_projects" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True) - name: Mapped[str] = mapped_column(String(255)) - slug: Mapped[str] = mapped_column(String(255), unique=True, index=True) - synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - - -class ProjectBinding(Base): - __tablename__ = "project_bindings" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True) - gitea_owner: Mapped[str] = mapped_column(String(255), default="") - gitea_repo: Mapped[str] = mapped_column(String(255), default="") - default_branch: Mapped[str] = mapped_column(String(64), default="main") - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - -class UserProfile(Base): - __tablename__ = "user_profile" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - data_json: Mapped[str] = mapped_column(Text, default="{}") - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - -class MemoryFact(Base): - __tablename__ = "memory_facts" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - category: Mapped[str] = mapped_column(String(64), default="fact", index=True) - content: Mapped[str] = mapped_column(Text) - source: Mapped[str] = mapped_column(String(32), default="user") - session_id: Mapped[int | None] = mapped_column( - ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True - ) - importance: Mapped[int] = mapped_column(Integer, default=3) - active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - -class SessionSummary(Base): - __tablename__ = "session_summaries" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - session_id: Mapped[int] = mapped_column( - ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True - ) - summary: Mapped[str] = mapped_column(Text, default="") - message_count: Mapped[int] = mapped_column(Integer, default=0) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - -class FitnessProfile(Base): - __tablename__ = "fitness_profiles" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - sex: Mapped[str] = mapped_column(String(16), default="male") - age: Mapped[int] = mapped_column(Integer, default=30) - height_cm: Mapped[float] = mapped_column(Float, default=170.0) - weight_kg: Mapped[float] = mapped_column(Float, default=70.0) - activity_level: Mapped[str] = mapped_column(String(32), default="moderate") - goal: Mapped[str] = mapped_column(String(32), default="maintain") - target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True) - weekly_workouts: Mapped[int] = mapped_column(Integer, default=3) - calorie_target: Mapped[float] = mapped_column(Float, default=2000.0) - protein_g: Mapped[float] = mapped_column(Float, default=140.0) - fat_g: Mapped[float] = mapped_column(Float, default=65.0) - carbs_g: Mapped[float] = mapped_column(Float, default=200.0) - water_l: Mapped[float] = mapped_column(Float, default=2.5) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - -class BodyMetric(Base): - __tablename__ = "body_metrics" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - weight_kg: Mapped[float] = mapped_column(Float) - body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True) - chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True) - waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True) - notes: Mapped[str] = mapped_column(Text, default="") - - -class FoodLog(Base): - __tablename__ = "food_logs" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - meal_type: Mapped[str] = mapped_column(String(32), default="snack") - description: Mapped[str] = mapped_column(Text, default="") - calories: Mapped[float] = mapped_column(Float, default=0) - protein_g: Mapped[float] = mapped_column(Float, default=0) - fat_g: Mapped[float] = mapped_column(Float, default=0) - carbs_g: Mapped[float] = mapped_column(Float, default=0) - source: Mapped[str] = mapped_column(String(32), default="llm") - estimated: Mapped[bool] = mapped_column(Boolean, default=True) - - -class WaterLog(Base): - __tablename__ = "water_logs" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - amount_ml: Mapped[int] = mapped_column(Integer) - - -class WorkoutLog(Base): - __tablename__ = "workout_logs" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - title: Mapped[str] = mapped_column(String(255), default="Тренировка") - notes: Mapped[str] = mapped_column(Text, default="") - duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True) - exercises_json: Mapped[str] = mapped_column(Text, default="[]") - - -class FitnessReminder(Base): - __tablename__ = "fitness_reminders" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - kind: Mapped[str] = mapped_column(String(32)) - hour: Mapped[int] = mapped_column(Integer, default=12) - minute: Mapped[int] = mapped_column(Integer, default=0) - interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) - enabled: Mapped[bool] = mapped_column(Boolean, default=True) - last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - - -class ShoppingList(Base): - __tablename__ = "shopping_lists" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(255), unique=True, index=True) - sort_order: Mapped[int] = mapped_column(Integer, default=0) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - items: Mapped[list["ShoppingListItem"]] = relationship( - back_populates="shopping_list", - cascade="all, delete-orphan", - order_by="ShoppingListItem.sort_order, ShoppingListItem.id", - ) - - -class ShoppingListItem(Base): - __tablename__ = "shopping_list_items" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - list_id: Mapped[int] = mapped_column( - ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True - ) - text: Mapped[str] = mapped_column(String(500)) - quantity: Mapped[float | None] = mapped_column(Float, nullable=True) - unit: Mapped[str] = mapped_column(String(64), default="") - checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True) - sort_order: Mapped[int] = mapped_column(Integer, default=0) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - - shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items") - - -class Reminder(Base): - __tablename__ = "reminders" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - title: Mapped[str] = mapped_column(String(255)) - notes: Mapped[str] = mapped_column(Text, default="") - due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) - all_day: Mapped[bool] = mapped_column(Boolean, default=False) - recurrence: Mapped[str] = mapped_column(String(16), default="none") - enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True) - last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - -class AssistantState(Base): - __tablename__ = "assistant_state" - - key: Mapped[str] = mapped_column(String(128), primary_key=True) - value: Mapped[str] = mapped_column(Text, default="") - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - -class WorkItem(Base): - __tablename__ = "work_items" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - taiga_slug: Mapped[str] = mapped_column(String(255), index=True) - taiga_project_id: Mapped[int] = mapped_column(Integer) - taiga_story_id: Mapped[int] = mapped_column(Integer) - taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True) - gitea_owner: Mapped[str] = mapped_column(String(255), default="") - gitea_repo: Mapped[str] = mapped_column(String(255), default="") - gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) - suggested_branch: Mapped[str] = mapped_column(String(255), default="") - raw_text: Mapped[str] = mapped_column(Text, default="") - title: Mapped[str] = mapped_column(String(500), default="") - status: Mapped[str] = mapped_column(String(32), default="open") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/homelab/rss.py b/backend/app/homelab/rss.py index e9e34d2..64030d5 100644 --- a/backend/app/homelab/rss.py +++ b/backend/app/homelab/rss.py @@ -18,7 +18,7 @@ class RssClient: self.max_items = settings.news_max_items def _fetch_feed(self, url: str) -> list[dict[str, str]]: - headers = {"User-Agent": "HomeAIAssistant/1.0 (+https://assistant.grigowashere.ru)"} + headers = {"User-Agent": "HomeAIAssistant/1.0"} with httpx.Client(timeout=20.0, headers=headers, follow_redirects=True) as client: response = client.get(url) response.raise_for_status() diff --git a/backend/app/llm/client.py.refactor_bak b/backend/app/llm/client.py.refactor_bak deleted file mode 100644 index 7c67fc8..0000000 --- a/backend/app/llm/client.py.refactor_bak +++ /dev/null @@ -1,269 +0,0 @@ -import json -import logging -from collections.abc import AsyncIterator -from typing import Any - -from openai import AsyncOpenAI - -from app.config import get_settings - -logger = logging.getLogger(__name__) - - -class LLMClient: - def __init__(self) -> None: - settings = get_settings() - self.model = settings.openrouter_model - self.tools_enabled = settings.openrouter_tools_enabled - self.reasoning_effort = settings.openrouter_reasoning_effort.strip().lower() - self.client = AsyncOpenAI( - api_key=settings.openrouter_api_key, - base_url=settings.openrouter_base_url, - ) - - def _reasoning_extra_body(self) -> dict[str, Any] | None: - if not self.reasoning_effort: - return None - if self.reasoning_effort == "none": - return {"reasoning": {"effort": "none", "exclude": True}} - return {"reasoning": {"effort": self.reasoning_effort}} - - @staticmethod - def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]: - parts: list[str] = [] - for attr in ("reasoning", "reasoning_content"): - value = getattr(delta, attr, None) - if value: - parts.append(str(value)) - - details: list[Any] = [] - raw_details = getattr(delta, "reasoning_details", None) - if raw_details: - if isinstance(raw_details, list): - details.extend(raw_details) - else: - details.append(raw_details) - - return "".join(parts), details - - @staticmethod - def _normalize_reasoning_details(details: Any) -> list[Any] | None: - if not details: - return None - items = details if isinstance(details, list) else [details] - normalized: list[Any] = [] - for item in items: - if hasattr(item, "model_dump"): - normalized.append(item.model_dump()) - elif isinstance(item, dict): - normalized.append(item) - else: - normalized.append(item) - return normalized or None - - @staticmethod - def attach_reasoning_to_message( - message: dict[str, Any], - *, - reasoning: str = "", - reasoning_details: list[Any] | None = None, - ) -> dict[str, Any]: - if reasoning: - message["reasoning"] = reasoning - message["reasoning_content"] = reasoning - normalized = LLMClient._normalize_reasoning_details(reasoning_details) - if normalized: - message["reasoning_details"] = normalized - return message - - async def stream_chat( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]] | None = None, - *, - model: str | None = None, - ) -> AsyncIterator[dict[str, Any]]: - use_tools = bool(tools) and self.tools_enabled - kwargs: dict[str, Any] = { - "model": model or self.model, - "messages": messages, - "stream": True, - "temperature": 0.7, - } - if use_tools: - kwargs["tools"] = tools - extra_body = self._reasoning_extra_body() - if extra_body: - kwargs["extra_body"] = extra_body - - try: - stream = await self.client.chat.completions.create(**kwargs) - except Exception as exc: - logger.exception("LLM stream failed: %s", exc) - yield {"type": "error", "content": str(exc)} - yield {"type": "done", "finish_reason": "error"} - return - - tool_calls: dict[int, dict[str, Any]] = {} - reasoning_parts: list[str] = [] - reasoning_details: list[Any] = [] - - try: - async for chunk in stream: - if not chunk.choices: - continue - - choice = chunk.choices[0] - delta = choice.delta - - if delta.content: - yield {"type": "content", "content": delta.content} - - reasoning_text, details = self._delta_reasoning(delta) - if reasoning_text: - reasoning_parts.append(reasoning_text) - if details: - reasoning_details.extend(details) - - if delta.tool_calls: - for tool_call in delta.tool_calls: - idx = tool_call.index - if idx not in tool_calls: - tool_calls[idx] = { - "id": tool_call.id or "", - "type": "function", - "function": {"name": "", "arguments": ""}, - } - if tool_call.id: - tool_calls[idx]["id"] = tool_call.id - if tool_call.function: - if tool_call.function.name: - tool_calls[idx]["function"]["name"] = tool_call.function.name - if tool_call.function.arguments: - tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments - - if choice.finish_reason: - reasoning = "".join(reasoning_parts) - normalized_details = self._normalize_reasoning_details(reasoning_details) - if reasoning or normalized_details: - yield { - "type": "reasoning", - "reasoning": reasoning, - "reasoning_details": normalized_details, - } - if tool_calls: - yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())} - logger.info( - "LLM stream done: model=%s finish_reason=%s tool_calls=%d " - "content_in_stream=%d reasoning_len=%d", - model or self.model, - choice.finish_reason, - len(tool_calls), - len(reasoning_parts), - len(reasoning), - ) - yield {"type": "done", "finish_reason": choice.finish_reason} - except Exception as exc: - logger.exception("LLM stream read failed: %s", exc) - yield {"type": "error", "content": str(exc)} - yield {"type": "done", "finish_reason": "error"} - - async def complete( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]] | None = None, - *, - temperature: float = 0.7, - model: str | None = None, - for_extraction: bool = False, - visible_reply: bool = False, - ) -> dict[str, Any]: - use_tools = bool(tools) and self.tools_enabled and not for_extraction - kwargs: dict[str, Any] = { - "model": model or self.model, - "messages": messages, - "temperature": temperature, - } - if use_tools: - kwargs["tools"] = tools - if for_extraction: - kwargs["extra_body"] = {"reasoning": {"effort": "none"}} - else: - extra_body = self._reasoning_extra_body() - if extra_body: - kwargs["extra_body"] = extra_body - - response = await self.client.chat.completions.create(**kwargs) - message = response.choices[0].message - - content = message.content or "" - reasoning = "" - for attr in ("reasoning", "reasoning_content"): - value = getattr(message, attr, None) - if value: - reasoning = str(value) - break - - if not content and reasoning and not visible_reply: - content = reasoning - - result: dict[str, Any] = { - "content": content, - "tool_calls": [], - "reasoning": reasoning, - "reasoning_details": getattr(message, "reasoning_details", None), - } - - if message.tool_calls: - result["tool_calls"] = [ - { - "id": tc.id, - "type": "function", - "function": { - "name": tc.function.name, - "arguments": tc.function.arguments, - }, - } - for tc in message.tool_calls - ] - - return result - - @staticmethod - def parse_tool_arguments(arguments: str) -> dict[str, Any]: - if not arguments: - return {} - try: - return json.loads(arguments) - except json.JSONDecodeError: - return {} - - @staticmethod - def serialize_reasoning( - *, - reasoning: str = "", - reasoning_details: list[Any] | None = None, - ) -> str | None: - payload: dict[str, Any] = {} - if reasoning: - payload["reasoning"] = reasoning - payload["reasoning_content"] = reasoning - if reasoning_details: - payload["reasoning_details"] = reasoning_details - if not payload: - return None - return json.dumps(payload, ensure_ascii=False) - - @staticmethod - def deserialize_reasoning(raw: str | None) -> dict[str, Any]: - if not raw: - return {} - try: - data = json.loads(raw) - except json.JSONDecodeError: - return {"reasoning": raw} - if isinstance(data, str): - return {"reasoning": data, "reasoning_content": data} - if isinstance(data, dict): - return data - return {} diff --git a/backend/app/main.py.refactor_bak b/backend/app/main.py.refactor_bak deleted file mode 100644 index 387bac0..0000000 --- a/backend/app/main.py.refactor_bak +++ /dev/null @@ -1,54 +0,0 @@ -import asyncio -from contextlib import asynccontextmanager, suppress - -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -from app.api.routes import api_router -from app.config import get_settings -from app.db.base import init_db -from app.fitness.watcher import fitness_watcher_loop -from app.homelab.watcher import homelab_watcher_loop -from app.pomodoro.watcher import pomodoro_watcher_loop -from app.reminders.watcher import reminders_watcher_loop - - -@asynccontextmanager -async def lifespan(_: FastAPI): - init_db() - pomodoro_task = asyncio.create_task(pomodoro_watcher_loop()) - fitness_task = asyncio.create_task(fitness_watcher_loop()) - homelab_task = asyncio.create_task(homelab_watcher_loop()) - reminders_task = asyncio.create_task(reminders_watcher_loop()) - yield - pomodoro_task.cancel() - fitness_task.cancel() - homelab_task.cancel() - reminders_task.cancel() - with suppress(asyncio.CancelledError): - await pomodoro_task - with suppress(asyncio.CancelledError): - await fitness_task - with suppress(asyncio.CancelledError): - await homelab_task - with suppress(asyncio.CancelledError): - await reminders_task - - -def create_app() -> FastAPI: - settings = get_settings() - app = FastAPI(title="Home AI Assistant", lifespan=lifespan) - - app.add_middleware( - CORSMiddleware, - allow_origins=settings.cors_origins_list, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - app.include_router(api_router) - return app - - -app = create_app() diff --git a/backend/app/memory/context.py.refactor_bak b/backend/app/memory/context.py.refactor_bak deleted file mode 100644 index c950e00..0000000 --- a/backend/app/memory/context.py.refactor_bak +++ /dev/null @@ -1,83 +0,0 @@ -from typing import Any - -from sqlalchemy.orm import Session - -from app.memory.service import MemoryService - -from app.memory.parse import is_identity_question - -MAX_FACTS_IN_CONTEXT = 25 -PROFILE_KEYS = ("name", "age", "timezone", "language", "notes") - - -def get_memory_snapshot(db: Session, session_id: int | None = None) -> dict[str, Any]: - return MemoryService(db).snapshot(session_id) - - -def format_memory_context(snapshot: dict[str, Any]) -> str: - lines = ["[Память и профиль — долгосрочный контекст]"] - - profile = snapshot.get("profile") or {} - profile_lines = [] - for key in PROFILE_KEYS: - value = (profile.get(key) or "").strip() - if value: - profile_lines.append(f"- {key}: {value}") - if profile_lines: - lines.append("Профиль пользователя:") - lines.extend(profile_lines) - else: - lines.append("Профиль: не заполнен (можно уточнить имя, часовой пояс).") - - summary = (snapshot.get("session_summary") or "").strip() - if summary: - lines.append("") - lines.append("Сводка текущего чата (ранние сообщения):") - lines.append(summary) - - facts = snapshot.get("facts") or [] - if facts: - lines.append("") - lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):") - for fact in facts[:MAX_FACTS_IN_CONTEXT]: - lines.append( - f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}" - ) - else: - lines.append("") - lines.append("Запомненные факты: пока нет.") - - lines.append("") - lines.append( - "Правила памяти: " - "«запомни» → remember_fact (имя/возраст также пишутся в профиль). " - "«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. " - "Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. " - "Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. " - "«забудь #N» → forget_memory. " - "Длинный чат — update_session_summary." - ) - return "\n".join(lines) - - -def format_identity_hint(snapshot: dict[str, Any], user_text: str) -> str: - if not is_identity_question(user_text): - return "" - - profile = snapshot.get("profile") or {} - facts = snapshot.get("facts") or [] - lines = [ - "[Вопрос об идентичности пользователя]", - "Ответь ТОЛЬКО из данных ниже. Не придумывай роли из сценария персонажа.", - ] - name = (profile.get("name") or "").strip() - age = (profile.get("age") or "").strip() - if name: - lines.append(f"Имя: {name}") - if age: - lines.append(f"Возраст: {age} лет") - for fact in facts: - lines.append(f"Факт: {fact.get('content')}") - if not name and not age and not facts: - lines.append("Данных нет — скажи, что не помнишь.") - return "\n".join(lines) diff --git a/backend/app/memory/service.py.refactor_bak b/backend/app/memory/service.py.refactor_bak deleted file mode 100644 index f222542..0000000 --- a/backend/app/memory/service.py.refactor_bak +++ /dev/null @@ -1,228 +0,0 @@ -import json -from datetime import datetime, timezone -from typing import Any - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.db.models import MemoryFact, SessionSummary, UserProfile -from app.memory.parse import normalize_text, parse_identity, texts_are_similar - -DEFAULT_PROFILE: dict[str, Any] = { - "name": "", - "age": "", - "timezone": "", - "language": "ru", - "notes": "", -} - - -class MemoryService: - def __init__(self, db: Session): - self.db = db - - def get_profile(self) -> dict[str, Any]: - row = self.db.scalar(select(UserProfile).limit(1)) - if not row: - return dict(DEFAULT_PROFILE) - try: - data = json.loads(row.data_json or "{}") - except json.JSONDecodeError: - data = {} - merged = dict(DEFAULT_PROFILE) - merged.update(data) - return merged - - def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]: - row = self.db.scalar(select(UserProfile).limit(1)) - if not row: - row = UserProfile(data_json="{}") - self.db.add(row) - self.db.flush() - - current = self.get_profile() - for key, value in updates.items(): - if value is None: - current.pop(key, None) - else: - current[key] = value - - row.data_json = json.dumps(current, ensure_ascii=False) - row.updated_at = datetime.now(timezone.utc) - self.db.commit() - return {"ok": True, "profile": current} - - def _find_similar_fact(self, text: str) -> MemoryFact | None: - for fact in self.db.scalars( - select(MemoryFact).where(MemoryFact.active.is_(True)) - ): - if texts_are_similar(fact.content, text): - return fact - return None - - def _sync_identity_to_profile(self, text: str) -> dict[str, Any] | None: - parsed = parse_identity(text) - if not parsed: - return None - return self.update_profile(parsed) - - def remember_fact( - self, - content: str, - *, - category: str = "fact", - source: str = "user", - session_id: int | None = None, - importance: int = 3, - ) -> dict[str, Any]: - text = content.strip() - if not text: - raise ValueError("Пустой факт") - - profile_sync = self._sync_identity_to_profile(text) - - existing = self._find_similar_fact(text) - if existing: - if len(text) > len(existing.content): - existing.content = text[:2000] - existing.category = category or existing.category - existing.importance = max(existing.importance, min(5, max(1, importance))) - existing.updated_at = datetime.now(timezone.utc) - if session_id: - existing.session_id = session_id - self.db.commit() - result = { - "ok": True, - "action": "updated", - "memory_id": existing.id, - "content": existing.content, - "category": existing.category, - } - if profile_sync: - result["profile"] = profile_sync.get("profile") - return result - - fact = MemoryFact( - category=(category or "fact")[:64], - content=text[:2000], - source=source[:32], - session_id=session_id, - importance=min(5, max(1, importance)), - ) - self.db.add(fact) - self.db.commit() - self.db.refresh(fact) - result = { - "ok": True, - "action": "created", - "memory_id": fact.id, - "content": fact.content, - "category": fact.category, - } - if profile_sync: - result["profile"] = profile_sync.get("profile") - return result - - def recall_memories( - self, - *, - query: str | None = None, - category: str | None = None, - limit: int = 20, - active_only: bool = True, - ) -> list[dict[str, Any]]: - stmt = select(MemoryFact).order_by( - MemoryFact.importance.desc(), - MemoryFact.updated_at.desc(), - ) - if active_only: - stmt = stmt.where(MemoryFact.active.is_(True)) - if category: - stmt = stmt.where(MemoryFact.category == category) - facts = self.db.scalars(stmt.limit(100)).all() - if query: - qnorm = normalize_text(query) - facts = [ - f - for f in facts - if qnorm in normalize_text(f.content) - or qnorm in normalize_text(f.category) - ] - facts = facts[: min(limit, 50)] - return [ - { - "id": f.id, - "category": f.category, - "content": f.content, - "importance": f.importance, - "source": f.source, - "updated_at": f.updated_at.isoformat() if f.updated_at else None, - } - for f in facts - ] - - def forget_memory(self, memory_id: int) -> dict[str, Any]: - fact = self.db.get(MemoryFact, memory_id) - if not fact: - raise ValueError(f"Память #{memory_id} не найдена") - fact.active = False - fact.updated_at = datetime.now(timezone.utc) - self.db.commit() - return {"ok": True, "memory_id": memory_id, "forgotten": fact.content} - - def get_active_facts(self, limit: int = 25) -> list[MemoryFact]: - return list( - self.db.scalars( - select(MemoryFact) - .where(MemoryFact.active.is_(True)) - .order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc()) - .limit(limit) - ).all() - ) - - def get_session_summary(self, session_id: int) -> SessionSummary | None: - return self.db.scalar( - select(SessionSummary).where(SessionSummary.session_id == session_id) - ) - - def update_session_summary( - self, - session_id: int, - summary: str, - *, - message_count: int = 0, - ) -> dict[str, Any]: - text = summary.strip() - if not text: - raise ValueError("Пустая сводка") - - row = self.get_session_summary(session_id) - if not row: - row = SessionSummary(session_id=session_id) - self.db.add(row) - - row.summary = text[:4000] - row.message_count = message_count - row.updated_at = datetime.now(timezone.utc) - self.db.commit() - return {"ok": True, "session_id": session_id, "summary": row.summary} - - def snapshot(self, session_id: int | None = None) -> dict[str, Any]: - facts = self.get_active_facts() - summary_row = self.get_session_summary(session_id) if session_id else None - return { - "profile": self.get_profile(), - "facts": [ - { - "id": f.id, - "category": f.category, - "content": f.content, - "importance": f.importance, - "source": f.source, - "updated_at": f.updated_at.isoformat() if f.updated_at else None, - } - for f in facts - ], - "session_summary": summary_row.summary if summary_row else "", - "total_facts": len(facts), - } diff --git a/backend/app/tools/_dispatch.py b/backend/app/tools/_dispatch.py new file mode 100644 index 0000000..b70ee20 --- /dev/null +++ b/backend/app/tools/_dispatch.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from typing import Any + +from sqlalchemy.orm import Session + +NOT_HANDLED: Any = object() + + +@dataclass +class ToolContext: + db: Session + user_id: int + session_id: int | None diff --git a/backend/app/tools/documents.py b/backend/app/tools/documents.py new file mode 100644 index 0000000..464b7d9 --- /dev/null +++ b/backend/app/tools/documents.py @@ -0,0 +1,37 @@ +from typing import Any + +from app.rag.retriever import retrieve_document_chunks +from app.tools._dispatch import NOT_HANDLED, ToolContext + +TOOL_NAMES = frozenset({"search_documents"}) + +TOOL_DEFINITIONS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "search_documents", + "description": "Семантический поиск по загруженным документам (RAG).", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Поисковый запрос"}, + "limit": {"type": "integer", "description": "Макс. фрагментов"}, + }, + "required": ["query"], + }, + }, + }, +] + + +async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any: + if name not in TOOL_NAMES: + return NOT_HANDLED + + if name == "search_documents": + return await retrieve_document_chunks( + arguments.get("query", ""), + user_id=ctx.user_id, + top_k=int(arguments.get("limit") or 6), + ) + return NOT_HANDLED diff --git a/backend/app/tools/fitness.py b/backend/app/tools/fitness.py new file mode 100644 index 0000000..ca32c07 --- /dev/null +++ b/backend/app/tools/fitness.py @@ -0,0 +1,403 @@ +from datetime import date, datetime, timedelta, timezone +from typing import Any + +from app.fitness.service import FitnessService +from app.fitness.structuring import structure_meal, structure_workout +from app.integrations.openfoodfacts import OpenFoodFactsClient +from app.integrations.wger import WgerClient +from app.tools._dispatch import NOT_HANDLED, ToolContext + +TOOL_NAMES = frozenset({ + "get_fitness_summary", + "get_fitness_history", + "set_fitness_profile", + "calc_fitness_targets", + "calc_body_composition", + "log_meal", + "log_water", + "log_weight", + "log_steps", + "log_workout", + "lookup_food", + "lookup_exercise", + "set_fitness_reminder", +}) + +TOOL_DEFINITIONS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "get_fitness_summary", + "description": ( + "Сводка фитнеса за день: ккал, БЖУ, вода, еда, тренировки. " + "Без даты — сегодня; date=YYYY-MM-DD или days_ago=1 (вчера)." + ), + "parameters": { + "type": "object", + "properties": { + "date": {"type": "string", "description": "Дата YYYY-MM-DD"}, + "days_ago": { + "type": "integer", + "description": "0 сегодня, 1 вчера, 2 позавчера…", + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_fitness_history", + "description": ( + "Краткая история за несколько дней (ккал, вода, тренировки по дням). " + "«На прошлой неделе», «за 7 дней»." + ), + "parameters": { + "type": "object", + "properties": { + "days": {"type": "integer", "description": "Сколько дней, по умолчанию 7"}, + "end_date": {"type": "string", "description": "Конец периода YYYY-MM-DD, по умолчанию сегодня"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "set_fitness_profile", + "description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды (TDEE = BMR + NEAT).", + "parameters": { + "type": "object", + "properties": { + "sex": {"type": "string", "description": "male/female"}, + "age": {"type": "integer"}, + "height_cm": {"type": "number"}, + "weight_kg": {"type": "number"}, + "goal": {"type": "string", "description": "lose/maintain/gain"}, + "target_weight_kg": {"type": "number"}, + "neat_base_kcal": { + "type": "number", + "description": "NEAT-база 200–300 ккал, по умолчанию 200", + }, + "activity_level": { + "type": "string", + "description": "sedentary/moderate/active/very_active — fallback для TDEE план", + }, + "weekly_workouts": { + "type": "integer", + "description": "Тренировок в неделю для fallback TDEE план", + }, + "baseline_steps": { + "type": "integer", + "description": "Ожидаемые шаги/день (fallback TDEE план)", + }, + "baseline_workout_kcal": { + "type": "number", + "description": "Ожидаемые ккал тренировок в неделю (fallback TDEE план)", + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "calc_fitness_targets", + "description": "Калькулятор BMR/TDEE/макросов без сохранения (rest-day: BMR + NEAT).", + "parameters": { + "type": "object", + "properties": { + "sex": {"type": "string"}, + "age": {"type": "integer"}, + "height_cm": {"type": "number"}, + "weight_kg": {"type": "number"}, + "goal": {"type": "string"}, + "neat_base_kcal": {"type": "number"}, + "steps": {"type": "integer", "description": "Шаги за день для расчёта TDEE"}, + }, + "required": ["weight_kg", "height_cm", "age"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "calc_body_composition", + "description": ( + "Navy-калькулятор % жира, WHR, LBM, FFMI без сохранения. " + "Пол/рост/вес из профиля, если не указаны." + ), + "parameters": { + "type": "object", + "properties": { + "sex": {"type": "string"}, + "height_cm": {"type": "number"}, + "weight_kg": {"type": "number"}, + "neck_cm": {"type": "number"}, + "waist_cm": {"type": "number"}, + "hip_cm": {"type": "number"}, + "body_fat_pct": {"type": "number"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "log_meal", + "description": "Записать приём пищи. LLM оценит ккал и БЖУ из текста.", + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "Что съел"}, + "meal_type": {"type": "string"}, + }, + "required": ["text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "log_water", + "description": "Записать воду в мл.", + "parameters": { + "type": "object", + "properties": { + "amount_ml": {"type": "integer"}, + }, + "required": ["amount_ml"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "log_weight", + "description": ( + "Записать антропометрию: вес и обхваты (см). " + "При neck+waist(+hip для женщин) автоматически считается Navy % жира." + ), + "parameters": { + "type": "object", + "properties": { + "weight_kg": {"type": "number"}, + "body_fat_pct": {"type": "number"}, + "neck_cm": {"type": "number"}, + "waist_cm": {"type": "number"}, + "hip_cm": {"type": "number"}, + "chest_cm": {"type": "number"}, + "notes": {"type": "string"}, + "date": {"type": "string"}, + "days_ago": {"type": "integer"}, + }, + "required": ["weight_kg"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "log_steps", + "description": "Записать шаги (можно задним числом: date или days_ago).", + "parameters": { + "type": "object", + "properties": { + "steps": {"type": "integer"}, + "active_calories": {"type": "number"}, + "notes": {"type": "string"}, + "date": {"type": "string"}, + "days_ago": {"type": "integer"}, + }, + "required": ["steps"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "log_workout", + "description": "Записать тренировку из текста (date/days_ago для прошлых дней).", + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "date": {"type": "string"}, + "days_ago": {"type": "integer"}, + }, + "required": ["text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "lookup_food", + "description": "Поиск продукта в Open Food Facts (ккал на 100г).", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "lookup_exercise", + "description": "Поиск упражнения в базе wger.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "set_fitness_reminder", + "description": "Вкл/выкл или настроить напоминание: water, meal, workout, weigh_in.", + "parameters": { + "type": "object", + "properties": { + "kind": {"type": "string"}, + "enabled": {"type": "boolean"}, + "hour": {"type": "integer"}, + "minute": {"type": "integer"}, + "interval_hours": {"type": "integer"}, + }, + "required": ["kind"], + }, + }, + }, +] + + +async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any: + if name not in TOOL_NAMES: + return NOT_HANDLED + + fitness = FitnessService(ctx.db, ctx.user_id) + + if name == "get_fitness_summary": + day: date | None = None + if arguments.get("date"): + day = date.fromisoformat(str(arguments["date"])) + elif arguments.get("days_ago") is not None: + day = datetime.now(timezone.utc).date() - timedelta(days=int(arguments["days_ago"])) + return fitness.get_daily_summary(day) + if name == "get_fitness_history": + end_day = None + if arguments.get("end_date"): + end_day = date.fromisoformat(str(arguments["end_date"])) + return fitness.get_history( + days=int(arguments.get("days") or 7), + end_day=end_day, + ) + if name == "set_fitness_profile": + updates = { + k: arguments[k] + for k in ( + "sex", "age", "height_cm", "weight_kg", + "goal", "target_weight_kg", "neat_base_kcal", + "activity_level", "weekly_workouts", + "baseline_steps", "baseline_workout_kcal", + ) + if k in arguments and arguments[k] is not None + } + return fitness.set_profile(updates) + if name == "calc_fitness_targets": + from app.fitness.calculators import compute_daily_targets + + steps = int(arguments.get("steps") or 0) + return compute_daily_targets(arguments, steps_total=steps, workouts=[]) + if name == "calc_body_composition": + return fitness.calc_body_composition(arguments) + if name == "log_meal": + structured = await structure_meal(arguments.get("text", "")) + return fitness.log_meal( + description=structured.get("description") or arguments.get("text", ""), + meal_type=arguments.get("meal_type") or structured.get("meal_type") or "snack", + calories=float(structured.get("calories") or 0), + protein_g=float(structured.get("protein_g") or 0), + fat_g=float(structured.get("fat_g") or 0), + carbs_g=float(structured.get("carbs_g") or 0), + source="llm", + estimated=True, + ) + if name == "log_water": + return fitness.log_water(int(arguments.get("amount_ml", 250))) + if name == "log_weight": + day = None + if arguments.get("date"): + day = date.fromisoformat(str(arguments["date"])) + return fitness.log_weight( + float(arguments["weight_kg"]), + body_fat_pct=arguments.get("body_fat_pct"), + chest_cm=arguments.get("chest_cm"), + waist_cm=arguments.get("waist_cm"), + neck_cm=arguments.get("neck_cm"), + hip_cm=arguments.get("hip_cm"), + notes=arguments.get("notes", ""), + day=day, + days_ago=arguments.get("days_ago"), + ) + if name == "log_steps": + day = None + if arguments.get("date"): + day = date.fromisoformat(str(arguments["date"])) + return fitness.log_steps( + int(arguments.get("steps") or 0), + active_calories=arguments.get("active_calories"), + notes=arguments.get("notes", ""), + day=day, + days_ago=arguments.get("days_ago"), + ) + if name == "log_workout": + structured = await structure_workout(arguments.get("text", "")) + day = None + if arguments.get("date"): + day = date.fromisoformat(str(arguments["date"])) + return fitness.log_workout( + title=structured.get("title") or "Тренировка", + notes=structured.get("notes") or arguments.get("text", ""), + duration_min=structured.get("duration_min"), + exercises=structured.get("exercises"), + active_calories=structured.get("active_calories"), + total_calories=structured.get("total_calories"), + steps=structured.get("steps"), + activity_type=structured.get("activity_type"), + met=structured.get("met"), + day=day, + days_ago=arguments.get("days_ago"), + ) + if name == "lookup_food": + return OpenFoodFactsClient().search( + arguments.get("query", ""), + limit=arguments.get("limit", 5), + ) + if name == "lookup_exercise": + return WgerClient().search_exercises( + arguments.get("query", ""), + limit=arguments.get("limit", 8), + ) + if name == "set_fitness_reminder": + return fitness.set_reminder( + arguments.get("kind", "water"), + enabled=arguments.get("enabled"), + hour=arguments.get("hour"), + minute=arguments.get("minute"), + interval_hours=arguments.get("interval_hours"), + ) + return NOT_HANDLED diff --git a/backend/app/tools/homelab.py b/backend/app/tools/homelab.py new file mode 100644 index 0000000..984ed48 --- /dev/null +++ b/backend/app/tools/homelab.py @@ -0,0 +1,116 @@ +from typing import Any + +from app.homelab.digest import build_weather_briefing +from app.homelab.image_gen import generate_image as run_generate_image +from app.homelab.openmeteo import OpenMeteoClient +from app.tools._dispatch import NOT_HANDLED, ToolContext + +TOOL_NAMES = frozenset({ + "get_weather", + "get_morning_briefing", + "generate_image", +}) + +TOOL_DEFINITIONS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": ( + "ОБЯЗАТЕЛЬНО для вопросов о погоде, «что на улице», «будет ли дождь», «завтра», «на неделю». " + "Текущая погода, почасовой и дневной прогноз." + ), + "parameters": { + "type": "object", + "properties": { + "hours_ahead": { + "type": "integer", + "description": "Сколько часов почасового прогноза (по умолчанию 12, до 168)", + }, + "days_ahead": { + "type": "integer", + "description": "Сколько дней дневного прогноза (по умолчанию 7, до 16)", + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_morning_briefing", + "description": "Утренний брифинг: погода и заголовки новостей.", + "parameters": { + "type": "object", + "properties": { + "include_news": { + "type": "boolean", + "description": "Включить новости (по умолчанию true)", + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "generate_image", + "description": ( + "Аниме-картинка (Anima). draw_self=true — персонаж из карточки; " + "scene_description — поза/кадр/одежда (booru-теги на англ. или короткий запрос: " + "full body, sitting, apron). Можно оба параметра: draw_self + scene_description. " + "Внешность только из appearance_tags карточки." + ), + "parameters": { + "type": "object", + "properties": { + "draw_self": { + "type": "boolean", + "description": "Нарисовать персонажа из карточки", + }, + "scene_description": { + "type": "string", + "description": ( + "Поза, кадр, одежда, обстановка — booru-теги или запрос " + "(full_body, standing, apron, blush). С draw_self=true — уточняет сцену." + ), + }, + }, + "required": [], + }, + }, + }, +] + + +async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any: + if name not in TOOL_NAMES: + return NOT_HANDLED + + if name == "get_weather": + hours = max(1, min(int(arguments.get("hours_ahead") or 12), 168)) + days = max(1, min(int(arguments.get("days_ahead") or 7), 16)) + client = OpenMeteoClient() + weather = client.fetch_forecast(hours_ahead=hours, days_ahead=days) + return { + "weather": weather, + "rain_summary": client.rain_summary(hours_ahead=hours, daily=weather.get("daily")) if weather.get("ok") else "", + "daily_summary": client.daily_summary(days_ahead=days) if weather.get("ok") else "", + } + if name == "get_morning_briefing": + include_news = arguments.get("include_news", True) + return build_weather_briefing( + hours_ahead=12, + include_news=bool(include_news), + ) + if name == "generate_image": + return await run_generate_image( + ctx.db, + user_id=ctx.user_id, + session_id=ctx.session_id, + draw_self=bool(arguments.get("draw_self")), + scene_description=arguments.get("scene_description", ""), + ) + return NOT_HANDLED diff --git a/backend/app/tools/memory.py b/backend/app/tools/memory.py new file mode 100644 index 0000000..dd28636 --- /dev/null +++ b/backend/app/tools/memory.py @@ -0,0 +1,146 @@ +from typing import Any + +from app.memory.service import MemoryService +from app.tools._dispatch import NOT_HANDLED, ToolContext + +TOOL_NAMES = frozenset({ + "remember_fact", + "recall_memories", + "forget_memory", + "update_profile", + "update_session_summary", +}) + +TOOL_DEFINITIONS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "remember_fact", + "description": ( + "Сохранить факт в долгосрочную память. " + "Когда пользователь просит «запомни», или сообщает устойчивое предпочтение/факт." + ), + "parameters": { + "type": "object", + "properties": { + "content": {"type": "string", "description": "Что запомнить"}, + "category": { + "type": "string", + "description": "preference, person, habit, project, fact", + }, + "importance": {"type": "integer", "description": "1-5, по умолчанию 3"}, + }, + "required": ["content"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "recall_memories", + "description": ( + "Поиск в долгосрочной памяти. " + "Когда спрашивают «что ты помнишь», «что я говорил про X»." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Подстрока для поиска"}, + "category": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "forget_memory", + "description": "Удалить (деактивировать) факт по id из recall_memories или снимка памяти.", + "parameters": { + "type": "object", + "properties": { + "memory_id": {"type": "integer"}, + }, + "required": ["memory_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "update_profile", + "description": ( + "Обновить профиль пользователя: name, timezone, language, notes. " + "Передавай только изменившиеся поля." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "string", "description": "Возраст пользователя"}, + "timezone": {"type": "string"}, + "language": {"type": "string"}, + "notes": {"type": "string"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "update_session_summary", + "description": ( + "Сохранить краткую сводку темы текущего чата " + "(когда диалог длинный или пользователь просит «сожми контекст»)." + ), + "parameters": { + "type": "object", + "properties": { + "summary": {"type": "string", "description": "2-5 предложений о теме чата"}, + "session_id": {"type": "integer"}, + }, + "required": ["summary", "session_id"], + }, + }, + }, +] + + +async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any: + if name not in TOOL_NAMES: + return NOT_HANDLED + + memory = MemoryService(ctx.db, ctx.user_id) + + if name == "remember_fact": + return memory.remember_fact( + arguments.get("content", ""), + category=arguments.get("category", "fact"), + importance=arguments.get("importance", 3), + session_id=ctx.session_id, + source="tool", + ) + if name == "recall_memories": + return memory.recall_memories( + query=arguments.get("query"), + category=arguments.get("category"), + limit=arguments.get("limit", 20), + ) + if name == "forget_memory": + return memory.forget_memory(int(arguments["memory_id"])) + if name == "update_profile": + updates = { + k: arguments[k] + for k in ("name", "age", "timezone", "language", "notes") + if k in arguments and arguments[k] is not None + } + return memory.update_profile(updates) + if name == "update_session_summary": + return memory.update_session_summary( + int(arguments["session_id"]), + arguments.get("summary", ""), + ) + return NOT_HANDLED diff --git a/backend/app/tools/pomodoro.py b/backend/app/tools/pomodoro.py new file mode 100644 index 0000000..a032835 --- /dev/null +++ b/backend/app/tools/pomodoro.py @@ -0,0 +1,157 @@ +from typing import Any + +from app.pomodoro.service import PomodoroService +from app.tools._dispatch import NOT_HANDLED, ToolContext + +TOOL_NAMES = frozenset({ + "get_pomodoro_status", + "start_pomodoro", + "start_short_break", + "start_long_break", + "stop_pomodoro", + "skip_pomodoro_phase", + "reset_pomodoro_cycle", + "get_pomodoro_history", +}) + +TOOL_DEFINITIONS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "get_pomodoro_status", + "description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Статус, фаза и прогресс цикла.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "start_pomodoro", + "description": "Запустить фазу работы в цикле помидоро (25 мин по умолчанию).", + "parameters": { + "type": "object", + "properties": { + "duration_min": {"type": "integer", "description": "Минуты работы"}, + "task_note": {"type": "string", "description": "Над чем работаем"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "start_short_break", + "description": "Запустить короткий перерыв между работами.", + "parameters": { + "type": "object", + "properties": { + "duration_min": {"type": "integer", "description": "Минуты перерыва"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "start_long_break", + "description": "Запустить длинный перерыв после завершения цикла работ.", + "parameters": { + "type": "object", + "properties": { + "duration_min": {"type": "integer", "description": "Минуты перерыва"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "stop_pomodoro", + "description": "Остановить текущую фазу таймера.", + "parameters": { + "type": "object", + "properties": { + "result": {"type": "string", "description": "Отчёт о сделанном"}, + "completed": { + "type": "boolean", + "description": "True если фаза полностью завершена", + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "skip_pomodoro_phase", + "description": "Досрочно завершить текущую фазу и перейти к следующей в цикле.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "reset_pomodoro_cycle", + "description": "Сбросить цикл помидоро: обнулить счётчик работ и остановить таймер.", + "parameters": { + "type": "object", + "properties": { + "clear_task": { + "type": "boolean", + "description": "Также очистить текущую задачу", + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_pomodoro_history", + "description": "История помидоро-сессий (таймер), не Taiga-задачи.", + "parameters": { + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "Сколько сессий вернуть"}, + }, + "required": [], + }, + }, + }, +] + + +async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any: + if name not in TOOL_NAMES: + return NOT_HANDLED + + pomodoro = PomodoroService(ctx.db, ctx.user_id) + + if name == "get_pomodoro_status": + return pomodoro.get_status() + if name == "start_pomodoro": + return pomodoro.start_work( + duration_min=arguments.get("duration_min"), + task_note=arguments.get("task_note", ""), + ) + if name == "start_short_break": + return pomodoro.start_short_break(duration_min=arguments.get("duration_min")) + if name == "start_long_break": + return pomodoro.start_long_break(duration_min=arguments.get("duration_min")) + if name == "stop_pomodoro": + return pomodoro.stop( + result=arguments.get("result", ""), + completed=arguments.get("completed", False), + ) + if name == "skip_pomodoro_phase": + return pomodoro.skip_phase() + if name == "reset_pomodoro_cycle": + return pomodoro.reset_cycle(clear_task=arguments.get("clear_task", False)) + if name == "get_pomodoro_history": + return pomodoro.history(limit=arguments.get("limit", 10)) + return NOT_HANDLED diff --git a/backend/app/tools/projects.py b/backend/app/tools/projects.py new file mode 100644 index 0000000..1018e89 --- /dev/null +++ b/backend/app/tools/projects.py @@ -0,0 +1,123 @@ +from typing import Any + +from app.projects.service import ProjectService +from app.tools._dispatch import NOT_HANDLED, ToolContext + +TOOL_NAMES = frozenset({ + "sync_taiga_projects", + "list_taiga_projects", + "list_taiga_tasks", + "create_work_item", + "list_work_items", +}) + +TOOL_DEFINITIONS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "sync_taiga_projects", + "description": "Синхронизировать список проектов из Taiga API. Вызывай если проекты неизвестны.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "list_taiga_projects", + "description": "Список проектов Taiga с привязкой Gitea.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "list_taiga_tasks", + "description": ( + "ОБЯЗАТЕЛЬНО при вопросах «какие задачи», «покажи задачи проекта», «что открыто в Taiga». " + "Живые user stories и tasks из Taiga API. НЕ путать с list_work_items." + ), + "parameters": { + "type": "object", + "properties": { + "project_slug": { + "type": "string", + "description": "Slug проекта, например home_assistant. Пусто = все проекты.", + }, + "limit": {"type": "integer", "description": "Макс. на проект, по умолчанию 20"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "create_work_item", + "description": ( + "Создать фичу/баг из вольного текста: структурировать через LLM, " + "создать Taiga story + Gitea issue. Вызывай при «заведи баг», «оформи фичу», «добавь в таигу»." + ), + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "Полное описание от пользователя"}, + "project_slug": { + "type": "string", + "description": "Slug проекта Taiga, если известен", + }, + }, + "required": ["text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_work_items", + "description": ( + "Только задачи, созданные ЭТИМ ассистентом через create_work_item (локальная БД). " + "НЕ использовать для общего вопроса «какие задачи в Taiga» — для того list_taiga_tasks." + ), + "parameters": { + "type": "object", + "properties": { + "status": {"type": "string", "description": "open или closed"}, + "limit": {"type": "integer"}, + }, + "required": [], + }, + }, + }, +] + + +async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any: + if name not in TOOL_NAMES: + return NOT_HANDLED + + projects = ProjectService(ctx.db, ctx.user_id) + + if name == "sync_taiga_projects": + from app.projects.context import invalidate_projects_snapshot_cache + + result = projects.sync_taiga_projects() + invalidate_projects_snapshot_cache(ctx.user_id) + return result + if name == "list_taiga_projects": + return projects.list_projects() + if name == "list_taiga_tasks": + return projects.list_taiga_open_tasks( + project_slug=arguments.get("project_slug"), + limit=arguments.get("limit", 20), + ) + if name == "create_work_item": + return await projects.create_work_item( + arguments.get("text", ""), + project_slug=arguments.get("project_slug"), + ) + if name == "list_work_items": + return projects.list_work_items( + limit=arguments.get("limit", 20), + status=arguments.get("status"), + ) + return NOT_HANDLED diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index dc44439..0aacf2c 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -1,818 +1,25 @@ import json -from datetime import date, datetime, timedelta, timezone from typing import Any from sqlalchemy.orm import Session -from app.fitness.service import FitnessService -from app.fitness.structuring import structure_meal, structure_workout -from app.homelab.digest import build_weather_briefing -from app.homelab.image_gen import generate_image as run_generate_image -from app.homelab.openmeteo import OpenMeteoClient -from app.integrations.openfoodfacts import OpenFoodFactsClient -from app.integrations.wger import WgerClient -from app.memory.service import MemoryService -from app.pomodoro.service import PomodoroService -from app.projects.service import ProjectService -from app.reminders_scoped.service import RemindersService -from app.shopping.service import ShoppingService +from app.tools import documents, fitness, homelab, memory, pomodoro, projects, reminders, shopping +from app.tools._dispatch import NOT_HANDLED, ToolContext -TOOL_DEFINITIONS: list[dict[str, Any]] = [ - { - "type": "function", - "function": { - "name": "get_pomodoro_status", - "description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Статус, фаза и прогресс цикла.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - }, - { - "type": "function", - "function": { - "name": "start_pomodoro", - "description": "Запустить фазу работы в цикле помидоро (25 мин по умолчанию).", - "parameters": { - "type": "object", - "properties": { - "duration_min": {"type": "integer", "description": "Минуты работы"}, - "task_note": {"type": "string", "description": "Над чем работаем"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "start_short_break", - "description": "Запустить короткий перерыв между работами.", - "parameters": { - "type": "object", - "properties": { - "duration_min": {"type": "integer", "description": "Минуты перерыва"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "start_long_break", - "description": "Запустить длинный перерыв после завершения цикла работ.", - "parameters": { - "type": "object", - "properties": { - "duration_min": {"type": "integer", "description": "Минуты перерыва"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "stop_pomodoro", - "description": "Остановить текущую фазу таймера.", - "parameters": { - "type": "object", - "properties": { - "result": {"type": "string", "description": "Отчёт о сделанном"}, - "completed": { - "type": "boolean", - "description": "True если фаза полностью завершена", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "skip_pomodoro_phase", - "description": "Досрочно завершить текущую фазу и перейти к следующей в цикле.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - }, - { - "type": "function", - "function": { - "name": "reset_pomodoro_cycle", - "description": "Сбросить цикл помидоро: обнулить счётчик работ и остановить таймер.", - "parameters": { - "type": "object", - "properties": { - "clear_task": { - "type": "boolean", - "description": "Также очистить текущую задачу", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_pomodoro_history", - "description": "История помидоро-сессий (таймер), не Taiga-задачи.", - "parameters": { - "type": "object", - "properties": { - "limit": {"type": "integer", "description": "Сколько сессий вернуть"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "sync_taiga_projects", - "description": "Синхронизировать список проектов из Taiga API. Вызывай если проекты неизвестны.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - }, - { - "type": "function", - "function": { - "name": "list_taiga_projects", - "description": "Список проектов Taiga с привязкой Gitea.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - }, - { - "type": "function", - "function": { - "name": "list_taiga_tasks", - "description": ( - "ОБЯЗАТЕЛЬНО при вопросах «какие задачи», «покажи задачи проекта», «что открыто в Taiga». " - "Живые user stories и tasks из Taiga API. НЕ путать с list_work_items." - ), - "parameters": { - "type": "object", - "properties": { - "project_slug": { - "type": "string", - "description": "Slug проекта, например home_assistant. Пусто = все проекты.", - }, - "limit": {"type": "integer", "description": "Макс. на проект, по умолчанию 20"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "create_work_item", - "description": ( - "Создать фичу/баг из вольного текста: структурировать через LLM, " - "создать Taiga story + Gitea issue. Вызывай при «заведи баг», «оформи фичу», «добавь в таигу»." - ), - "parameters": { - "type": "object", - "properties": { - "text": {"type": "string", "description": "Полное описание от пользователя"}, - "project_slug": { - "type": "string", - "description": "Slug проекта Taiga, если известен", - }, - }, - "required": ["text"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "remember_fact", - "description": ( - "Сохранить факт в долгосрочную память. " - "Когда пользователь просит «запомни», или сообщает устойчивое предпочтение/факт." - ), - "parameters": { - "type": "object", - "properties": { - "content": {"type": "string", "description": "Что запомнить"}, - "category": { - "type": "string", - "description": "preference, person, habit, project, fact", - }, - "importance": {"type": "integer", "description": "1-5, по умолчанию 3"}, - }, - "required": ["content"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "recall_memories", - "description": ( - "Поиск в долгосрочной памяти. " - "Когда спрашивают «что ты помнишь», «что я говорил про X»." - ), - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Подстрока для поиска"}, - "category": {"type": "string"}, - "limit": {"type": "integer"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "forget_memory", - "description": "Удалить (деактивировать) факт по id из recall_memories или снимка памяти.", - "parameters": { - "type": "object", - "properties": { - "memory_id": {"type": "integer"}, - }, - "required": ["memory_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "update_profile", - "description": ( - "Обновить профиль пользователя: name, timezone, language, notes. " - "Передавай только изменившиеся поля." - ), - "parameters": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "string", "description": "Возраст пользователя"}, - "timezone": {"type": "string"}, - "language": {"type": "string"}, - "notes": {"type": "string"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "update_session_summary", - "description": ( - "Сохранить краткую сводку темы текущего чата " - "(когда диалог длинный или пользователь просит «сожми контекст»)." - ), - "parameters": { - "type": "object", - "properties": { - "summary": {"type": "string", "description": "2-5 предложений о теме чата"}, - "session_id": {"type": "integer"}, - }, - "required": ["summary", "session_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "search_documents", - "description": "Семантический поиск по загруженным документам (RAG).", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Поисковый запрос"}, - "limit": {"type": "integer", "description": "Макс. фрагментов"}, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_fitness_summary", - "description": ( - "Сводка фитнеса за день: ккал, БЖУ, вода, еда, тренировки. " - "Без даты — сегодня; date=YYYY-MM-DD или days_ago=1 (вчера)." - ), - "parameters": { - "type": "object", - "properties": { - "date": {"type": "string", "description": "Дата YYYY-MM-DD"}, - "days_ago": { - "type": "integer", - "description": "0 сегодня, 1 вчера, 2 позавчера…", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_fitness_history", - "description": ( - "Краткая история за несколько дней (ккал, вода, тренировки по дням). " - "«На прошлой неделе», «за 7 дней»." - ), - "parameters": { - "type": "object", - "properties": { - "days": {"type": "integer", "description": "Сколько дней, по умолчанию 7"}, - "end_date": {"type": "string", "description": "Конец периода YYYY-MM-DD, по умолчанию сегодня"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "set_fitness_profile", - "description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды (TDEE = BMR + NEAT).", - "parameters": { - "type": "object", - "properties": { - "sex": {"type": "string", "description": "male/female"}, - "age": {"type": "integer"}, - "height_cm": {"type": "number"}, - "weight_kg": {"type": "number"}, - "goal": {"type": "string", "description": "lose/maintain/gain"}, - "target_weight_kg": {"type": "number"}, - "neat_base_kcal": { - "type": "number", - "description": "NEAT-база 200–300 ккал, по умолчанию 200", - }, - "activity_level": { - "type": "string", - "description": "sedentary/moderate/active/very_active — fallback для TDEE план", - }, - "weekly_workouts": { - "type": "integer", - "description": "Тренировок в неделю для fallback TDEE план", - }, - "baseline_steps": { - "type": "integer", - "description": "Ожидаемые шаги/день (fallback TDEE план)", - }, - "baseline_workout_kcal": { - "type": "number", - "description": "Ожидаемые ккал тренировок в неделю (fallback TDEE план)", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "calc_fitness_targets", - "description": "Калькулятор BMR/TDEE/макросов без сохранения (rest-day: BMR + NEAT).", - "parameters": { - "type": "object", - "properties": { - "sex": {"type": "string"}, - "age": {"type": "integer"}, - "height_cm": {"type": "number"}, - "weight_kg": {"type": "number"}, - "goal": {"type": "string"}, - "neat_base_kcal": {"type": "number"}, - "steps": {"type": "integer", "description": "Шаги за день для расчёта TDEE"}, - }, - "required": ["weight_kg", "height_cm", "age"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "calc_body_composition", - "description": ( - "Navy-калькулятор % жира, WHR, LBM, FFMI без сохранения. " - "Пол/рост/вес из профиля, если не указаны." - ), - "parameters": { - "type": "object", - "properties": { - "sex": {"type": "string"}, - "height_cm": {"type": "number"}, - "weight_kg": {"type": "number"}, - "neck_cm": {"type": "number"}, - "waist_cm": {"type": "number"}, - "hip_cm": {"type": "number"}, - "body_fat_pct": {"type": "number"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "log_meal", - "description": "Записать приём пищи. LLM оценит ккал и БЖУ из текста.", - "parameters": { - "type": "object", - "properties": { - "text": {"type": "string", "description": "Что съел"}, - "meal_type": {"type": "string"}, - }, - "required": ["text"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "log_water", - "description": "Записать воду в мл.", - "parameters": { - "type": "object", - "properties": { - "amount_ml": {"type": "integer"}, - }, - "required": ["amount_ml"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "log_weight", - "description": ( - "Записать антропометрию: вес и обхваты (см). " - "При neck+waist(+hip для женщин) автоматически считается Navy % жира." - ), - "parameters": { - "type": "object", - "properties": { - "weight_kg": {"type": "number"}, - "body_fat_pct": {"type": "number"}, - "neck_cm": {"type": "number"}, - "waist_cm": {"type": "number"}, - "hip_cm": {"type": "number"}, - "chest_cm": {"type": "number"}, - "notes": {"type": "string"}, - "date": {"type": "string"}, - "days_ago": {"type": "integer"}, - }, - "required": ["weight_kg"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "log_steps", - "description": "Записать шаги (можно задним числом: date или days_ago).", - "parameters": { - "type": "object", - "properties": { - "steps": {"type": "integer"}, - "active_calories": {"type": "number"}, - "notes": {"type": "string"}, - "date": {"type": "string"}, - "days_ago": {"type": "integer"}, - }, - "required": ["steps"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "log_workout", - "description": "Записать тренировку из текста (date/days_ago для прошлых дней).", - "parameters": { - "type": "object", - "properties": { - "text": {"type": "string"}, - "date": {"type": "string"}, - "days_ago": {"type": "integer"}, - }, - "required": ["text"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "lookup_food", - "description": "Поиск продукта в Open Food Facts (ккал на 100г).", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "limit": {"type": "integer"}, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "lookup_exercise", - "description": "Поиск упражнения в базе wger.", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "limit": {"type": "integer"}, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "set_fitness_reminder", - "description": "Вкл/выкл или настроить напоминание: water, meal, workout, weigh_in.", - "parameters": { - "type": "object", - "properties": { - "kind": {"type": "string"}, - "enabled": {"type": "boolean"}, - "hour": {"type": "integer"}, - "minute": {"type": "integer"}, - "interval_hours": {"type": "integer"}, - }, - "required": ["kind"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_weather", - "description": ( - "ОБЯЗАТЕЛЬНО для вопросов о погоде, «что на улице», «будет ли дождь», «завтра», «на неделю». " - "Текущая погода, почасовой и дневной прогноз." - ), - "parameters": { - "type": "object", - "properties": { - "hours_ahead": { - "type": "integer", - "description": "Сколько часов почасового прогноза (по умолчанию 12, до 168)", - }, - "days_ahead": { - "type": "integer", - "description": "Сколько дней дневного прогноза (по умолчанию 7, до 16)", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_morning_briefing", - "description": "Утренний брифинг: погода и заголовки новостей.", - "parameters": { - "type": "object", - "properties": { - "include_news": { - "type": "boolean", - "description": "Включить новости (по умолчанию true)", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "generate_image", - "description": ( - "Аниме-картинка (Anima). draw_self=true — персонаж из карточки; " - "scene_description — поза/кадр/одежда (booru-теги на англ. или короткий запрос: " - "full body, sitting, apron). Можно оба параметра: draw_self + scene_description. " - "Внешность только из appearance_tags карточки." - ), - "parameters": { - "type": "object", - "properties": { - "draw_self": { - "type": "boolean", - "description": "Нарисовать персонажа из карточки", - }, - "scene_description": { - "type": "string", - "description": ( - "Поза, кадр, одежда, обстановка — booru-теги или запрос " - "(full_body, standing, apron, blush). С draw_self=true — уточняет сцену." - ), - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_shopping_lists", - "description": "Все списки покупок с позициями. «Что купить», «покажи списки».", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - }, - { - "type": "function", - "function": { - "name": "create_shopping_list", - "description": "Создать новый список покупок.", - "parameters": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "Название списка, например «Продукты»"}, - }, - "required": ["name"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "add_shopping_items", - "description": "Добавить товары в список. Список создаётся, если не существует.", - "parameters": { - "type": "object", - "properties": { - "list_name": {"type": "string", "description": "Название списка"}, - "list_id": {"type": "integer"}, - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "text": {"type": "string"}, - "quantity": {"type": "number"}, - "unit": {"type": "string"}, - }, - "required": ["text"], - }, - }, - }, - "required": ["items"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "check_shopping_item", - "description": "Отметить позицию как купленную (checked=true) или снять отметку (false).", - "parameters": { - "type": "object", - "properties": { - "item_id": {"type": "integer"}, - "checked": {"type": "boolean"}, - }, - "required": ["item_id", "checked"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "remove_shopping_item", - "description": "Удалить позицию из списка по item_id.", - "parameters": { - "type": "object", - "properties": {"item_id": {"type": "integer"}}, - "required": ["item_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "delete_shopping_list", - "description": "Удалить весь список покупок.", - "parameters": { - "type": "object", - "properties": {"list_id": {"type": "integer"}}, - "required": ["list_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_reminders", - "description": "Список активных напоминаний. «Что напомнил», «мои напоминания».", - "parameters": { - "type": "object", - "properties": { - "limit": {"type": "integer", "description": "Макс. записей, по умолчанию 20"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "create_reminder", - "description": ( - "Создать напоминание. due_at — ISO datetime в часовом поясе пользователя " - "(см. [Текущее время]). Примеры: через 15 мин, завтра 09:00, 2027-05-12T12:16:00." - ), - "parameters": { - "type": "object", - "properties": { - "title": {"type": "string", "description": "О чём напомнить"}, - "due_at": {"type": "string", "description": "ISO datetime"}, - "notes": {"type": "string"}, - "all_day": {"type": "boolean"}, - "recurrence": { - "type": "string", - "enum": ["none", "daily", "weekly", "monthly", "yearly"], - "description": "Повтор (yearly — день рождения, Новый год)", - }, - }, - "required": ["title", "due_at"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "update_reminder", - "description": "Изменить напоминание по id.", - "parameters": { - "type": "object", - "properties": { - "reminder_id": {"type": "integer"}, - "title": {"type": "string"}, - "due_at": {"type": "string"}, - "notes": {"type": "string"}, - "all_day": {"type": "boolean"}, - "recurrence": { - "type": "string", - "enum": ["none", "daily", "weekly", "monthly", "yearly"], - }, - "enabled": {"type": "boolean"}, - }, - "required": ["reminder_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "delete_reminder", - "description": "Удалить напоминание по id.", - "parameters": { - "type": "object", - "properties": {"reminder_id": {"type": "integer"}}, - "required": ["reminder_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "complete_reminder", - "description": "Отметить напоминание выполненным (снять с календаря).", - "parameters": { - "type": "object", - "properties": {"reminder_id": {"type": "integer"}}, - "required": ["reminder_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_work_items", - "description": ( - "Только задачи, созданные ЭТИМ ассистентом через create_work_item (локальная БД). " - "НЕ использовать для общего вопроса «какие задачи в Taiga» — для того list_taiga_tasks." - ), - "parameters": { - "type": "object", - "properties": { - "status": {"type": "string", "description": "open или closed"}, - "limit": {"type": "integer"}, - }, - "required": [], - }, - }, - }, -] +_HANDLERS = ( + pomodoro, + projects, + memory, + documents, + fitness, + homelab, + shopping, + reminders, +) + +TOOL_DEFINITIONS: list[dict[str, Any]] = [] +for _handler in _HANDLERS: + TOOL_DEFINITIONS.extend(_handler.TOOL_DEFINITIONS) async def execute_tool( @@ -823,282 +30,13 @@ async def execute_tool( session_id: int | None = None, user_id: int, ) -> str: - pomodoro = PomodoroService(db, user_id) - projects = ProjectService(db, user_id) - memory = MemoryService(db, user_id) - fitness = FitnessService(db, user_id) - shopping = ShoppingService(db, user_id) - reminders = RemindersService(db, user_id) - + ctx = ToolContext(db=db, user_id=user_id, session_id=session_id) try: - if name == "get_pomodoro_status": - result = pomodoro.get_status() - elif name == "start_pomodoro": - result = pomodoro.start_work( - duration_min=arguments.get("duration_min"), - task_note=arguments.get("task_note", ""), - ) - elif name == "start_short_break": - result = pomodoro.start_short_break(duration_min=arguments.get("duration_min")) - elif name == "start_long_break": - result = pomodoro.start_long_break(duration_min=arguments.get("duration_min")) - elif name == "stop_pomodoro": - result = pomodoro.stop( - result=arguments.get("result", ""), - completed=arguments.get("completed", False), - ) - elif name == "skip_pomodoro_phase": - result = pomodoro.skip_phase() - elif name == "reset_pomodoro_cycle": - result = pomodoro.reset_cycle(clear_task=arguments.get("clear_task", False)) - elif name == "get_pomodoro_history": - result = pomodoro.history(limit=arguments.get("limit", 10)) - elif name == "sync_taiga_projects": - from app.projects.context import invalidate_projects_snapshot_cache - - result = projects.sync_taiga_projects() - invalidate_projects_snapshot_cache(user_id) - elif name == "list_taiga_projects": - result = projects.list_projects() - elif name == "list_taiga_tasks": - result = projects.list_taiga_open_tasks( - project_slug=arguments.get("project_slug"), - limit=arguments.get("limit", 20), - ) - elif name == "create_work_item": - result = await projects.create_work_item( - arguments.get("text", ""), - project_slug=arguments.get("project_slug"), - ) - elif name == "list_work_items": - result = projects.list_work_items( - limit=arguments.get("limit", 20), - status=arguments.get("status"), - ) - elif name == "remember_fact": - result = memory.remember_fact( - arguments.get("content", ""), - category=arguments.get("category", "fact"), - importance=arguments.get("importance", 3), - session_id=session_id, - source="tool", - ) - elif name == "recall_memories": - result = memory.recall_memories( - query=arguments.get("query"), - category=arguments.get("category"), - limit=arguments.get("limit", 20), - ) - elif name == "forget_memory": - result = memory.forget_memory(int(arguments["memory_id"])) - elif name == "update_profile": - updates = { - k: arguments[k] - for k in ("name", "age", "timezone", "language", "notes") - if k in arguments and arguments[k] is not None - } - result = memory.update_profile(updates) - elif name == "update_session_summary": - result = memory.update_session_summary( - int(arguments["session_id"]), - arguments.get("summary", ""), - ) - elif name == "search_documents": - import asyncio - - from app.rag.retriever import retrieve_document_chunks - - async def _run(): - return await retrieve_document_chunks( - arguments.get("query", ""), - user_id=user_id, - top_k=int(arguments.get("limit") or 6), - ) - - result = asyncio.run(_run()) - elif name == "get_fitness_summary": - day: date | None = None - if arguments.get("date"): - day = date.fromisoformat(str(arguments["date"])) - elif arguments.get("days_ago") is not None: - day = datetime.now(timezone.utc).date() - timedelta( - days=int(arguments["days_ago"]) - ) - result = fitness.get_daily_summary(day) - elif name == "get_fitness_history": - end_day = None - if arguments.get("end_date"): - end_day = date.fromisoformat(str(arguments["end_date"])) - result = fitness.get_history( - days=int(arguments.get("days") or 7), - end_day=end_day, - ) - elif name == "set_fitness_profile": - updates = { - k: arguments[k] - for k in ( - "sex", "age", "height_cm", "weight_kg", - "goal", "target_weight_kg", "neat_base_kcal", - "activity_level", "weekly_workouts", - "baseline_steps", "baseline_workout_kcal", - ) - if k in arguments and arguments[k] is not None - } - result = fitness.set_profile(updates) - elif name == "calc_fitness_targets": - from app.fitness.calculators import compute_daily_targets - - steps = int(arguments.get("steps") or 0) - result = compute_daily_targets(arguments, steps_total=steps, workouts=[]) - elif name == "calc_body_composition": - result = fitness.calc_body_composition(arguments) - elif name == "log_meal": - structured = await structure_meal(arguments.get("text", "")) - result = fitness.log_meal( - description=structured.get("description") or arguments.get("text", ""), - meal_type=arguments.get("meal_type") or structured.get("meal_type") or "snack", - calories=float(structured.get("calories") or 0), - protein_g=float(structured.get("protein_g") or 0), - fat_g=float(structured.get("fat_g") or 0), - carbs_g=float(structured.get("carbs_g") or 0), - source="llm", - estimated=True, - ) - elif name == "log_water": - result = fitness.log_water(int(arguments.get("amount_ml", 250))) - elif name == "log_weight": - day = None - if arguments.get("date"): - day = date.fromisoformat(str(arguments["date"])) - result = fitness.log_weight( - float(arguments["weight_kg"]), - body_fat_pct=arguments.get("body_fat_pct"), - chest_cm=arguments.get("chest_cm"), - waist_cm=arguments.get("waist_cm"), - neck_cm=arguments.get("neck_cm"), - hip_cm=arguments.get("hip_cm"), - notes=arguments.get("notes", ""), - day=day, - days_ago=arguments.get("days_ago"), - ) - elif name == "log_steps": - day = None - if arguments.get("date"): - day = date.fromisoformat(str(arguments["date"])) - result = fitness.log_steps( - int(arguments.get("steps") or 0), - active_calories=arguments.get("active_calories"), - notes=arguments.get("notes", ""), - day=day, - days_ago=arguments.get("days_ago"), - ) - elif name == "log_workout": - structured = await structure_workout(arguments.get("text", "")) - day = None - if arguments.get("date"): - day = date.fromisoformat(str(arguments["date"])) - result = fitness.log_workout( - title=structured.get("title") or "Тренировка", - notes=structured.get("notes") or arguments.get("text", ""), - duration_min=structured.get("duration_min"), - exercises=structured.get("exercises"), - active_calories=structured.get("active_calories"), - total_calories=structured.get("total_calories"), - steps=structured.get("steps"), - activity_type=structured.get("activity_type"), - met=structured.get("met"), - day=day, - days_ago=arguments.get("days_ago"), - ) - elif name == "lookup_food": - result = OpenFoodFactsClient().search( - arguments.get("query", ""), - limit=arguments.get("limit", 5), - ) - elif name == "lookup_exercise": - result = WgerClient().search_exercises( - arguments.get("query", ""), - limit=arguments.get("limit", 8), - ) - elif name == "set_fitness_reminder": - result = fitness.set_reminder( - arguments.get("kind", "water"), - enabled=arguments.get("enabled"), - hour=arguments.get("hour"), - minute=arguments.get("minute"), - interval_hours=arguments.get("interval_hours"), - ) - elif name == "get_weather": - hours = max(1, min(int(arguments.get("hours_ahead") or 12), 168)) - days = max(1, min(int(arguments.get("days_ahead") or 7), 16)) - client = OpenMeteoClient() - weather = client.fetch_forecast(hours_ahead=hours, days_ahead=days) - result = { - "weather": weather, - "rain_summary": client.rain_summary(hours_ahead=hours, daily=weather.get("daily")) if weather.get("ok") else "", - "daily_summary": client.daily_summary(days_ahead=days) if weather.get("ok") else "", - } - elif name == "get_morning_briefing": - include_news = arguments.get("include_news", True) - result = build_weather_briefing( - hours_ahead=12, - include_news=bool(include_news), - ) - elif name == "generate_image": - result = await run_generate_image( - db, - user_id=user_id, - session_id=session_id, - draw_self=bool(arguments.get("draw_self")), - scene_description=arguments.get("scene_description", ""), - ) - elif name == "list_shopping_lists": - result = shopping.list_lists(include_items=True) - elif name == "create_shopping_list": - result = shopping.create_list(arguments.get("name", "")) - elif name == "add_shopping_items": - result = shopping.add_items( - arguments.get("items") or [], - list_id=arguments.get("list_id"), - list_name=arguments.get("list_name"), - ) - elif name == "check_shopping_item": - result = shopping.set_item_checked( - int(arguments["item_id"]), - bool(arguments.get("checked", True)), - ) - elif name == "remove_shopping_item": - result = shopping.remove_item(int(arguments["item_id"])) - elif name == "delete_shopping_list": - result = shopping.delete_list(int(arguments["list_id"])) - elif name == "list_reminders": - result = reminders.list_upcoming(limit=int(arguments.get("limit") or 20)) - elif name == "create_reminder": - result = reminders.create( - title=arguments.get("title", ""), - due_at=arguments.get("due_at", ""), - notes=arguments.get("notes", ""), - all_day=bool(arguments.get("all_day", False)), - recurrence=arguments.get("recurrence", "none"), - ) - elif name == "update_reminder": - result = reminders.update( - int(arguments["reminder_id"]), - title=arguments.get("title"), - due_at=arguments.get("due_at"), - notes=arguments.get("notes"), - all_day=arguments.get("all_day"), - recurrence=arguments.get("recurrence"), - enabled=arguments.get("enabled"), - ) - elif name == "delete_reminder": - result = reminders.delete(int(arguments["reminder_id"])) - elif name == "complete_reminder": - result = reminders.complete(int(arguments["reminder_id"])) - else: - return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) - - return json.dumps(result, ensure_ascii=False) + for handler in _HANDLERS: + result = await handler.execute(name, arguments, ctx) + if result is not NOT_HANDLED: + return json.dumps(result, ensure_ascii=False) + return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) except ValueError as exc: return json.dumps({"error": str(exc)}, ensure_ascii=False) except Exception as exc: diff --git a/backend/app/tools/registry.py.refactor_bak b/backend/app/tools/registry.py.refactor_bak deleted file mode 100644 index 9b83953..0000000 --- a/backend/app/tools/registry.py.refactor_bak +++ /dev/null @@ -1,961 +0,0 @@ -import json -from datetime import date, datetime, timedelta, timezone -from typing import Any - -from sqlalchemy.orm import Session - -from app.fitness.service import FitnessService -from app.fitness.structuring import structure_meal, structure_workout -from app.homelab.digest import build_weather_briefing -from app.homelab.image_gen import generate_image as run_generate_image -from app.homelab.openmeteo import OpenMeteoClient -from app.integrations.openfoodfacts import OpenFoodFactsClient -from app.integrations.wger import WgerClient -from app.memory.service import MemoryService -from app.pomodoro.service import PomodoroService -from app.projects.service import ProjectService -from app.reminders.service import RemindersService -from app.shopping.service import ShoppingService - -TOOL_DEFINITIONS: list[dict[str, Any]] = [ - { - "type": "function", - "function": { - "name": "get_pomodoro_status", - "description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Статус, фаза и прогресс цикла.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - }, - { - "type": "function", - "function": { - "name": "start_pomodoro", - "description": "Запустить фазу работы в цикле помидоро (25 мин по умолчанию).", - "parameters": { - "type": "object", - "properties": { - "duration_min": {"type": "integer", "description": "Минуты работы"}, - "task_note": {"type": "string", "description": "Над чем работаем"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "start_short_break", - "description": "Запустить короткий перерыв между работами.", - "parameters": { - "type": "object", - "properties": { - "duration_min": {"type": "integer", "description": "Минуты перерыва"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "start_long_break", - "description": "Запустить длинный перерыв после завершения цикла работ.", - "parameters": { - "type": "object", - "properties": { - "duration_min": {"type": "integer", "description": "Минуты перерыва"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "stop_pomodoro", - "description": "Остановить текущую фазу таймера.", - "parameters": { - "type": "object", - "properties": { - "result": {"type": "string", "description": "Отчёт о сделанном"}, - "completed": { - "type": "boolean", - "description": "True если фаза полностью завершена", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "skip_pomodoro_phase", - "description": "Досрочно завершить текущую фазу и перейти к следующей в цикле.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - }, - { - "type": "function", - "function": { - "name": "reset_pomodoro_cycle", - "description": "Сбросить цикл помидоро: обнулить счётчик работ и остановить таймер.", - "parameters": { - "type": "object", - "properties": { - "clear_task": { - "type": "boolean", - "description": "Также очистить текущую задачу", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_pomodoro_history", - "description": "История помидоро-сессий (таймер), не Taiga-задачи.", - "parameters": { - "type": "object", - "properties": { - "limit": {"type": "integer", "description": "Сколько сессий вернуть"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "sync_taiga_projects", - "description": "Синхронизировать список проектов из Taiga API. Вызывай если проекты неизвестны.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - }, - { - "type": "function", - "function": { - "name": "list_taiga_projects", - "description": "Список проектов Taiga с привязкой Gitea.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - }, - { - "type": "function", - "function": { - "name": "list_taiga_tasks", - "description": ( - "ОБЯЗАТЕЛЬНО при вопросах «какие задачи», «покажи задачи проекта», «что открыто в Taiga». " - "Живые user stories и tasks из Taiga API. НЕ путать с list_work_items." - ), - "parameters": { - "type": "object", - "properties": { - "project_slug": { - "type": "string", - "description": "Slug проекта, например home_assistant. Пусто = все проекты.", - }, - "limit": {"type": "integer", "description": "Макс. на проект, по умолчанию 20"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "create_work_item", - "description": ( - "Создать фичу/баг из вольного текста: структурировать через LLM, " - "создать Taiga story + Gitea issue. Вызывай при «заведи баг», «оформи фичу», «добавь в таигу»." - ), - "parameters": { - "type": "object", - "properties": { - "text": {"type": "string", "description": "Полное описание от пользователя"}, - "project_slug": { - "type": "string", - "description": "Slug проекта Taiga, если известен", - }, - }, - "required": ["text"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "remember_fact", - "description": ( - "Сохранить факт в долгосрочную память. " - "Когда пользователь просит «запомни», или сообщает устойчивое предпочтение/факт." - ), - "parameters": { - "type": "object", - "properties": { - "content": {"type": "string", "description": "Что запомнить"}, - "category": { - "type": "string", - "description": "preference, person, habit, project, fact", - }, - "importance": {"type": "integer", "description": "1-5, по умолчанию 3"}, - }, - "required": ["content"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "recall_memories", - "description": ( - "Поиск в долгосрочной памяти. " - "Когда спрашивают «что ты помнишь», «что я говорил про X»." - ), - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Подстрока для поиска"}, - "category": {"type": "string"}, - "limit": {"type": "integer"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "forget_memory", - "description": "Удалить (деактивировать) факт по id из recall_memories или снимка памяти.", - "parameters": { - "type": "object", - "properties": { - "memory_id": {"type": "integer"}, - }, - "required": ["memory_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "update_profile", - "description": ( - "Обновить профиль пользователя: name, timezone, language, notes. " - "Передавай только изменившиеся поля." - ), - "parameters": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "string", "description": "Возраст пользователя"}, - "timezone": {"type": "string"}, - "language": {"type": "string"}, - "notes": {"type": "string"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "update_session_summary", - "description": ( - "Сохранить краткую сводку темы текущего чата " - "(когда диалог длинный или пользователь просит «сожми контекст»)." - ), - "parameters": { - "type": "object", - "properties": { - "summary": {"type": "string", "description": "2-5 предложений о теме чата"}, - "session_id": {"type": "integer"}, - }, - "required": ["summary", "session_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_fitness_summary", - "description": ( - "Сводка фитнеса за день: ккал, БЖУ, вода, еда, тренировки. " - "Без даты — сегодня; date=YYYY-MM-DD или days_ago=1 (вчера)." - ), - "parameters": { - "type": "object", - "properties": { - "date": {"type": "string", "description": "Дата YYYY-MM-DD"}, - "days_ago": { - "type": "integer", - "description": "0 сегодня, 1 вчера, 2 позавчера…", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_fitness_history", - "description": ( - "Краткая история за несколько дней (ккал, вода, тренировки по дням). " - "«На прошлой неделе», «за 7 дней»." - ), - "parameters": { - "type": "object", - "properties": { - "days": {"type": "integer", "description": "Сколько дней, по умолчанию 7"}, - "end_date": {"type": "string", "description": "Конец периода YYYY-MM-DD, по умолчанию сегодня"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "set_fitness_profile", - "description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды.", - "parameters": { - "type": "object", - "properties": { - "sex": {"type": "string", "description": "male/female"}, - "age": {"type": "integer"}, - "height_cm": {"type": "number"}, - "weight_kg": {"type": "number"}, - "activity_level": { - "type": "string", - "description": "sedentary/light/moderate/active/very_active", - }, - "goal": {"type": "string", "description": "lose/maintain/gain"}, - "target_weight_kg": {"type": "number"}, - "weekly_workouts": {"type": "integer"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "calc_fitness_targets", - "description": "Калькулятор BMR/TDEE/макросов без сохранения.", - "parameters": { - "type": "object", - "properties": { - "sex": {"type": "string"}, - "age": {"type": "integer"}, - "height_cm": {"type": "number"}, - "weight_kg": {"type": "number"}, - "activity_level": {"type": "string"}, - "goal": {"type": "string"}, - }, - "required": ["weight_kg", "height_cm", "age"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "log_meal", - "description": "Записать приём пищи. LLM оценит ккал и БЖУ из текста.", - "parameters": { - "type": "object", - "properties": { - "text": {"type": "string", "description": "Что съел"}, - "meal_type": {"type": "string"}, - }, - "required": ["text"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "log_water", - "description": "Записать воду в мл.", - "parameters": { - "type": "object", - "properties": { - "amount_ml": {"type": "integer"}, - }, - "required": ["amount_ml"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "log_weight", - "description": "Записать вес в кг.", - "parameters": { - "type": "object", - "properties": { - "weight_kg": {"type": "number"}, - "body_fat_pct": {"type": "number"}, - "notes": {"type": "string"}, - }, - "required": ["weight_kg"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "log_workout", - "description": "Записать тренировку из текста.", - "parameters": { - "type": "object", - "properties": { - "text": {"type": "string"}, - }, - "required": ["text"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "lookup_food", - "description": "Поиск продукта в Open Food Facts (ккал на 100г).", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "limit": {"type": "integer"}, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "lookup_exercise", - "description": "Поиск упражнения в базе wger.", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "limit": {"type": "integer"}, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "set_fitness_reminder", - "description": "Вкл/выкл или настроить напоминание: water, meal, workout, weigh_in.", - "parameters": { - "type": "object", - "properties": { - "kind": {"type": "string"}, - "enabled": {"type": "boolean"}, - "hour": {"type": "integer"}, - "minute": {"type": "integer"}, - "interval_hours": {"type": "integer"}, - }, - "required": ["kind"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_weather", - "description": ( - "ОБЯЗАТЕЛЬНО для вопросов о погоде, «что на улице», «будет ли дождь». " - "Текущая погода и прогноз по часам." - ), - "parameters": { - "type": "object", - "properties": { - "hours_ahead": { - "type": "integer", - "description": "Сколько часов прогноза (по умолчанию 12)", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_morning_briefing", - "description": "Утренний брифинг: погода и заголовки новостей.", - "parameters": { - "type": "object", - "properties": { - "include_news": { - "type": "boolean", - "description": "Включить новости (по умолчанию true)", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "generate_image", - "description": ( - "Аниме-картинка (Anima через RP-чат). " - "«Нарисуй себя» / портрет персонажа → draw_self=true. " - "Другая сцена → scene_description на английском (booru-теги). " - "Внешность берётся из карточки персонажа. Только по запросу или когда уместно." - ), - "parameters": { - "type": "object", - "properties": { - "draw_self": { - "type": "boolean", - "description": "Нарисовать персонажа из карточки в контексте текущего чата", - }, - "scene_description": { - "type": "string", - "description": "Описание сцены на английском (booru-теги), если не draw_self", - }, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_shopping_lists", - "description": "Все списки покупок с позициями. «Что купить», «покажи списки».", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - }, - { - "type": "function", - "function": { - "name": "create_shopping_list", - "description": "Создать новый список покупок.", - "parameters": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "Название списка, например «Продукты»"}, - }, - "required": ["name"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "add_shopping_items", - "description": "Добавить товары в список. Список создаётся, если не существует.", - "parameters": { - "type": "object", - "properties": { - "list_name": {"type": "string", "description": "Название списка"}, - "list_id": {"type": "integer"}, - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "text": {"type": "string"}, - "quantity": {"type": "number"}, - "unit": {"type": "string"}, - }, - "required": ["text"], - }, - }, - }, - "required": ["items"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "check_shopping_item", - "description": "Отметить позицию как купленную (checked=true) или снять отметку (false).", - "parameters": { - "type": "object", - "properties": { - "item_id": {"type": "integer"}, - "checked": {"type": "boolean"}, - }, - "required": ["item_id", "checked"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "remove_shopping_item", - "description": "Удалить позицию из списка по item_id.", - "parameters": { - "type": "object", - "properties": {"item_id": {"type": "integer"}}, - "required": ["item_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "delete_shopping_list", - "description": "Удалить весь список покупок.", - "parameters": { - "type": "object", - "properties": {"list_id": {"type": "integer"}}, - "required": ["list_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_reminders", - "description": "Список активных напоминаний. «Что напомнил», «мои напоминания».", - "parameters": { - "type": "object", - "properties": { - "limit": {"type": "integer", "description": "Макс. записей, по умолчанию 20"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "create_reminder", - "description": ( - "Создать напоминание. due_at — ISO datetime в часовом поясе пользователя " - "(см. [Текущее время]). Примеры: через 15 мин, завтра 09:00, 2027-05-12T12:16:00." - ), - "parameters": { - "type": "object", - "properties": { - "title": {"type": "string", "description": "О чём напомнить"}, - "due_at": {"type": "string", "description": "ISO datetime"}, - "notes": {"type": "string"}, - "all_day": {"type": "boolean"}, - "recurrence": { - "type": "string", - "enum": ["none", "daily", "weekly", "monthly", "yearly"], - "description": "Повтор (yearly — день рождения, Новый год)", - }, - }, - "required": ["title", "due_at"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "update_reminder", - "description": "Изменить напоминание по id.", - "parameters": { - "type": "object", - "properties": { - "reminder_id": {"type": "integer"}, - "title": {"type": "string"}, - "due_at": {"type": "string"}, - "notes": {"type": "string"}, - "all_day": {"type": "boolean"}, - "recurrence": { - "type": "string", - "enum": ["none", "daily", "weekly", "monthly", "yearly"], - }, - "enabled": {"type": "boolean"}, - }, - "required": ["reminder_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "delete_reminder", - "description": "Удалить напоминание по id.", - "parameters": { - "type": "object", - "properties": {"reminder_id": {"type": "integer"}}, - "required": ["reminder_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "complete_reminder", - "description": "Отметить напоминание выполненным (снять с календаря).", - "parameters": { - "type": "object", - "properties": {"reminder_id": {"type": "integer"}}, - "required": ["reminder_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_work_items", - "description": ( - "Только задачи, созданные ЭТИМ ассистентом через create_work_item (локальная БД). " - "НЕ использовать для общего вопроса «какие задачи в Taiga» — для того list_taiga_tasks." - ), - "parameters": { - "type": "object", - "properties": { - "status": {"type": "string", "description": "open или closed"}, - "limit": {"type": "integer"}, - }, - "required": [], - }, - }, - }, -] - - -async def execute_tool( - db: Session, - name: str, - arguments: dict[str, Any], - *, - session_id: int | None = None, -) -> str: - pomodoro = PomodoroService(db) - projects = ProjectService(db) - memory = MemoryService(db) - fitness = FitnessService(db) - shopping = ShoppingService(db) - reminders = RemindersService(db) - - try: - if name == "get_pomodoro_status": - result = pomodoro.get_status() - elif name == "start_pomodoro": - result = pomodoro.start_work( - duration_min=arguments.get("duration_min"), - task_note=arguments.get("task_note", ""), - ) - elif name == "start_short_break": - result = pomodoro.start_short_break(duration_min=arguments.get("duration_min")) - elif name == "start_long_break": - result = pomodoro.start_long_break(duration_min=arguments.get("duration_min")) - elif name == "stop_pomodoro": - result = pomodoro.stop( - result=arguments.get("result", ""), - completed=arguments.get("completed", False), - ) - elif name == "skip_pomodoro_phase": - result = pomodoro.skip_phase() - elif name == "reset_pomodoro_cycle": - result = pomodoro.reset_cycle(clear_task=arguments.get("clear_task", False)) - elif name == "get_pomodoro_history": - result = pomodoro.history(limit=arguments.get("limit", 10)) - elif name == "sync_taiga_projects": - from app.projects.context import invalidate_projects_snapshot_cache - - result = projects.sync_taiga_projects() - invalidate_projects_snapshot_cache() - elif name == "list_taiga_projects": - result = projects.list_projects() - elif name == "list_taiga_tasks": - result = projects.list_taiga_open_tasks( - project_slug=arguments.get("project_slug"), - limit=arguments.get("limit", 20), - ) - elif name == "create_work_item": - result = await projects.create_work_item( - arguments.get("text", ""), - project_slug=arguments.get("project_slug"), - ) - elif name == "list_work_items": - result = projects.list_work_items( - limit=arguments.get("limit", 20), - status=arguments.get("status"), - ) - elif name == "remember_fact": - result = memory.remember_fact( - arguments.get("content", ""), - category=arguments.get("category", "fact"), - importance=arguments.get("importance", 3), - session_id=session_id, - source="tool", - ) - elif name == "recall_memories": - result = memory.recall_memories( - query=arguments.get("query"), - category=arguments.get("category"), - limit=arguments.get("limit", 20), - ) - elif name == "forget_memory": - result = memory.forget_memory(int(arguments["memory_id"])) - elif name == "update_profile": - updates = { - k: arguments[k] - for k in ("name", "age", "timezone", "language", "notes") - if k in arguments and arguments[k] is not None - } - result = memory.update_profile(updates) - elif name == "update_session_summary": - result = memory.update_session_summary( - int(arguments["session_id"]), - arguments.get("summary", ""), - ) - elif name == "get_fitness_summary": - day: date | None = None - if arguments.get("date"): - day = date.fromisoformat(str(arguments["date"])) - elif arguments.get("days_ago") is not None: - day = datetime.now(timezone.utc).date() - timedelta( - days=int(arguments["days_ago"]) - ) - result = fitness.get_daily_summary(day) - elif name == "get_fitness_history": - end_day = None - if arguments.get("end_date"): - end_day = date.fromisoformat(str(arguments["end_date"])) - result = fitness.get_history( - days=int(arguments.get("days") or 7), - end_day=end_day, - ) - elif name == "set_fitness_profile": - updates = { - k: arguments[k] - for k in ( - "sex", "age", "height_cm", "weight_kg", "activity_level", - "goal", "target_weight_kg", "weekly_workouts", - ) - if k in arguments and arguments[k] is not None - } - result = fitness.set_profile(updates) - elif name == "calc_fitness_targets": - result = fitness.calc_targets(arguments) - elif name == "log_meal": - structured = await structure_meal(arguments.get("text", "")) - result = fitness.log_meal( - description=structured.get("description") or arguments.get("text", ""), - meal_type=arguments.get("meal_type") or structured.get("meal_type") or "snack", - calories=float(structured.get("calories") or 0), - protein_g=float(structured.get("protein_g") or 0), - fat_g=float(structured.get("fat_g") or 0), - carbs_g=float(structured.get("carbs_g") or 0), - source="llm", - estimated=True, - ) - elif name == "log_water": - result = fitness.log_water(int(arguments.get("amount_ml", 250))) - elif name == "log_weight": - result = fitness.log_weight( - float(arguments["weight_kg"]), - body_fat_pct=arguments.get("body_fat_pct"), - notes=arguments.get("notes", ""), - ) - elif name == "log_workout": - structured = await structure_workout(arguments.get("text", "")) - result = fitness.log_workout( - title=structured.get("title") or "Тренировка", - notes=structured.get("notes") or arguments.get("text", ""), - duration_min=structured.get("duration_min"), - exercises=structured.get("exercises"), - ) - elif name == "lookup_food": - result = OpenFoodFactsClient().search( - arguments.get("query", ""), - limit=arguments.get("limit", 5), - ) - elif name == "lookup_exercise": - result = WgerClient().search_exercises( - arguments.get("query", ""), - limit=arguments.get("limit", 8), - ) - elif name == "set_fitness_reminder": - result = fitness.set_reminder( - arguments.get("kind", "water"), - enabled=arguments.get("enabled"), - hour=arguments.get("hour"), - minute=arguments.get("minute"), - interval_hours=arguments.get("interval_hours"), - ) - elif name == "get_weather": - hours = int(arguments.get("hours_ahead") or 12) - client = OpenMeteoClient() - weather = client.fetch_current_and_hourly(hours_ahead=hours) - result = { - "weather": weather, - "rain_summary": client.rain_summary(hours_ahead=hours) if weather.get("ok") else "", - } - elif name == "get_morning_briefing": - include_news = arguments.get("include_news", True) - result = build_weather_briefing( - hours_ahead=12, - include_news=bool(include_news), - ) - elif name == "generate_image": - result = await run_generate_image( - db, - session_id=session_id, - draw_self=bool(arguments.get("draw_self")), - scene_description=arguments.get("scene_description", ""), - ) - elif name == "list_shopping_lists": - result = shopping.list_lists(include_items=True) - elif name == "create_shopping_list": - result = shopping.create_list(arguments.get("name", "")) - elif name == "add_shopping_items": - result = shopping.add_items( - arguments.get("items") or [], - list_id=arguments.get("list_id"), - list_name=arguments.get("list_name"), - ) - elif name == "check_shopping_item": - result = shopping.set_item_checked( - int(arguments["item_id"]), - bool(arguments.get("checked", True)), - ) - elif name == "remove_shopping_item": - result = shopping.remove_item(int(arguments["item_id"])) - elif name == "delete_shopping_list": - result = shopping.delete_list(int(arguments["list_id"])) - elif name == "list_reminders": - result = reminders.list_upcoming(limit=int(arguments.get("limit") or 20)) - elif name == "create_reminder": - result = reminders.create( - title=arguments.get("title", ""), - due_at=arguments.get("due_at", ""), - notes=arguments.get("notes", ""), - all_day=bool(arguments.get("all_day", False)), - recurrence=arguments.get("recurrence", "none"), - ) - elif name == "update_reminder": - result = reminders.update( - int(arguments["reminder_id"]), - title=arguments.get("title"), - due_at=arguments.get("due_at"), - notes=arguments.get("notes"), - all_day=arguments.get("all_day"), - recurrence=arguments.get("recurrence"), - enabled=arguments.get("enabled"), - ) - elif name == "delete_reminder": - result = reminders.delete(int(arguments["reminder_id"])) - elif name == "complete_reminder": - result = reminders.complete(int(arguments["reminder_id"])) - else: - return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) - - return json.dumps(result, ensure_ascii=False) - except ValueError as exc: - return json.dumps({"error": str(exc)}, ensure_ascii=False) - except Exception as exc: - return json.dumps({"error": str(exc)}, ensure_ascii=False) diff --git a/backend/app/tools/reminders.py b/backend/app/tools/reminders.py new file mode 100644 index 0000000..0cd3c60 --- /dev/null +++ b/backend/app/tools/reminders.py @@ -0,0 +1,134 @@ +from typing import Any + +from app.reminders_scoped.service import RemindersService +from app.tools._dispatch import NOT_HANDLED, ToolContext + +TOOL_NAMES = frozenset({ + "list_reminders", + "create_reminder", + "update_reminder", + "delete_reminder", + "complete_reminder", +}) + +TOOL_DEFINITIONS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "list_reminders", + "description": "Список активных напоминаний. «Что напомнил», «мои напоминания».", + "parameters": { + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "Макс. записей, по умолчанию 20"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "create_reminder", + "description": ( + "Создать напоминание. due_at — ISO datetime в часовом поясе пользователя " + "(см. [Текущее время]). Примеры: через 15 мин, завтра 09:00, 2027-05-12T12:16:00." + ), + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "О чём напомнить"}, + "due_at": {"type": "string", "description": "ISO datetime"}, + "notes": {"type": "string"}, + "all_day": {"type": "boolean"}, + "recurrence": { + "type": "string", + "enum": ["none", "daily", "weekly", "monthly", "yearly"], + "description": "Повтор (yearly — день рождения, Новый год)", + }, + }, + "required": ["title", "due_at"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "update_reminder", + "description": "Изменить напоминание по id.", + "parameters": { + "type": "object", + "properties": { + "reminder_id": {"type": "integer"}, + "title": {"type": "string"}, + "due_at": {"type": "string"}, + "notes": {"type": "string"}, + "all_day": {"type": "boolean"}, + "recurrence": { + "type": "string", + "enum": ["none", "daily", "weekly", "monthly", "yearly"], + }, + "enabled": {"type": "boolean"}, + }, + "required": ["reminder_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "delete_reminder", + "description": "Удалить напоминание по id.", + "parameters": { + "type": "object", + "properties": {"reminder_id": {"type": "integer"}}, + "required": ["reminder_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "complete_reminder", + "description": "Отметить напоминание выполненным (снять с календаря).", + "parameters": { + "type": "object", + "properties": {"reminder_id": {"type": "integer"}}, + "required": ["reminder_id"], + }, + }, + }, +] + + +async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any: + if name not in TOOL_NAMES: + return NOT_HANDLED + + reminders = RemindersService(ctx.db, ctx.user_id) + + if name == "list_reminders": + return reminders.list_upcoming(limit=int(arguments.get("limit") or 20)) + if name == "create_reminder": + return reminders.create( + title=arguments.get("title", ""), + due_at=arguments.get("due_at", ""), + notes=arguments.get("notes", ""), + all_day=bool(arguments.get("all_day", False)), + recurrence=arguments.get("recurrence", "none"), + ) + if name == "update_reminder": + return reminders.update( + int(arguments["reminder_id"]), + title=arguments.get("title"), + due_at=arguments.get("due_at"), + notes=arguments.get("notes"), + all_day=arguments.get("all_day"), + recurrence=arguments.get("recurrence"), + enabled=arguments.get("enabled"), + ) + if name == "delete_reminder": + return reminders.delete(int(arguments["reminder_id"])) + if name == "complete_reminder": + return reminders.complete(int(arguments["reminder_id"])) + return NOT_HANDLED diff --git a/backend/app/tools/shopping.py b/backend/app/tools/shopping.py new file mode 100644 index 0000000..8acde55 --- /dev/null +++ b/backend/app/tools/shopping.py @@ -0,0 +1,132 @@ +from typing import Any + +from app.shopping.service import ShoppingService +from app.tools._dispatch import NOT_HANDLED, ToolContext + +TOOL_NAMES = frozenset({ + "list_shopping_lists", + "create_shopping_list", + "add_shopping_items", + "check_shopping_item", + "remove_shopping_item", + "delete_shopping_list", +}) + +TOOL_DEFINITIONS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "list_shopping_lists", + "description": "Все списки покупок с позициями. «Что купить», «покажи списки».", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "create_shopping_list", + "description": "Создать новый список покупок.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Название списка, например «Продукты»"}, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "add_shopping_items", + "description": "Добавить товары в список. Список создаётся, если не существует.", + "parameters": { + "type": "object", + "properties": { + "list_name": {"type": "string", "description": "Название списка"}, + "list_id": {"type": "integer"}, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "quantity": {"type": "number"}, + "unit": {"type": "string"}, + }, + "required": ["text"], + }, + }, + }, + "required": ["items"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "check_shopping_item", + "description": "Отметить позицию как купленную (checked=true) или снять отметку (false).", + "parameters": { + "type": "object", + "properties": { + "item_id": {"type": "integer"}, + "checked": {"type": "boolean"}, + }, + "required": ["item_id", "checked"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "remove_shopping_item", + "description": "Удалить позицию из списка по item_id.", + "parameters": { + "type": "object", + "properties": {"item_id": {"type": "integer"}}, + "required": ["item_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "delete_shopping_list", + "description": "Удалить весь список покупок.", + "parameters": { + "type": "object", + "properties": {"list_id": {"type": "integer"}}, + "required": ["list_id"], + }, + }, + }, +] + + +async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any: + if name not in TOOL_NAMES: + return NOT_HANDLED + + shopping = ShoppingService(ctx.db, ctx.user_id) + + if name == "list_shopping_lists": + return shopping.list_lists(include_items=True) + if name == "create_shopping_list": + return shopping.create_list(arguments.get("name", "")) + if name == "add_shopping_items": + return shopping.add_items( + arguments.get("items") or [], + list_id=arguments.get("list_id"), + list_name=arguments.get("list_name"), + ) + if name == "check_shopping_item": + return shopping.set_item_checked( + int(arguments["item_id"]), + bool(arguments.get("checked", True)), + ) + if name == "remove_shopping_item": + return shopping.remove_item(int(arguments["item_id"])) + if name == "delete_shopping_list": + return shopping.delete_list(int(arguments["list_id"])) + return NOT_HANDLED diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..7df8cd2 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0 +pytest-asyncio>=0.24 diff --git a/backend/requirements.txt b/backend/requirements.txt index fb69c9e..bda2320 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,8 +5,8 @@ sqlalchemy>=2.0.36 pydantic-settings>=2.6.0 openai>=1.55.0 python-dotenv>=1.0.1 -aiosqlite>=0.20.0 httpx>=0.28.0 feedparser>=6.0.11 Pillow>=11.0.0 qdrant-client>=1.12.0,<1.13.0 +psycopg2-binary>=2.9.9 diff --git a/backend/requirements.txt.refactor_bak b/backend/requirements.txt.refactor_bak deleted file mode 100644 index 8bfd868..0000000 --- a/backend/requirements.txt.refactor_bak +++ /dev/null @@ -1,9 +0,0 @@ -fastapi>=0.115.0 -uvicorn[standard]>=0.32.0 -sqlalchemy>=2.0.36 -pydantic-settings>=2.6.0 -openai>=1.55.0 -python-dotenv>=1.0.1 -aiosqlite>=0.20.0 -httpx>=0.28.0 -feedparser>=6.0.11 diff --git a/backend/scripts/migrate_sqlite_to_postgres.py b/backend/scripts/migrate_sqlite_to_postgres.py new file mode 100644 index 0000000..b1ac9f4 --- /dev/null +++ b/backend/scripts/migrate_sqlite_to_postgres.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Copy assistant data from SQLite to PostgreSQL.""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +from sqlalchemy import create_engine, inspect, text + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from app.db import models # noqa: F401 +from app.db.base import Base + + +def _row_count(engine, table: str) -> int: + inspector = inspect(engine) + if not inspector.has_table(table): + return 0 + with engine.connect() as conn: + return int(conn.execute(text(f'SELECT COUNT(*) FROM "{table}"')).scalar() or 0) + + +def _copy_table(src_engine, dst_engine, table_name: str, columns: list[str]) -> int: + col_sql = ", ".join(f'"{c}"' for c in columns) + select_sql = f'SELECT {col_sql} FROM "{table_name}"' + insert_sql = f'INSERT INTO "{table_name}" ({col_sql}) VALUES ({", ".join(f":{c}" for c in columns)})' + + with src_engine.connect() as src_conn: + rows = src_conn.execute(text(select_sql)).mappings().all() + if not rows: + return 0 + + with dst_engine.begin() as dst_conn: + for row in rows: + dst_conn.execute(text(insert_sql), dict(row)) + return len(rows) + + +def _reset_serial(dst_engine, table_name: str) -> None: + with dst_engine.begin() as conn: + conn.execute( + text( + f"SELECT setval(pg_get_serial_sequence('{table_name}', 'id'), " + f"COALESCE((SELECT MAX(id) FROM \"{table_name}\"), 1), true)" + ) + ) + + +def _truncate_all(dst_engine) -> None: + table_names = [t.name for t in Base.metadata.sorted_tables] + if not table_names: + return + joined = ", ".join(f'"{name}"' for name in table_names) + with dst_engine.begin() as conn: + conn.execute(text(f"TRUNCATE TABLE {joined} RESTART IDENTITY CASCADE")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Migrate SQLite assistant.db to PostgreSQL") + parser.add_argument( + "--sqlite-path", + default=os.environ.get("SQLITE_PATH", "./data/assistant.db"), + help="Path to SQLite database file", + ) + parser.add_argument( + "--database-url", + default=os.environ.get("DATABASE_URL", ""), + help="PostgreSQL DATABASE_URL (postgresql+psycopg2://...)", + ) + parser.add_argument("--dry-run", action="store_true", help="Print row counts only") + parser.add_argument("--force", action="store_true", help="Truncate PostgreSQL tables before import") + args = parser.parse_args() + + if not args.database_url.startswith("postgresql"): + print("ERROR: DATABASE_URL must be a PostgreSQL URL (postgresql+psycopg2://...)") + return 1 + + sqlite_path = Path(args.sqlite_path) + if not sqlite_path.is_file(): + print(f"ERROR: SQLite file not found: {sqlite_path}") + return 1 + + src_engine = create_engine(f"sqlite:///{sqlite_path.as_posix()}") + dst_engine = create_engine(args.database_url) + + src_tables = set(inspect(src_engine).get_table_names()) + extra_tables = [t for t in ("_schema_migrations",) if t in src_tables] + + if args.dry_run: + print(f"Dry run: {sqlite_path} -> PostgreSQL") + total = 0 + for table in Base.metadata.sorted_tables: + count = _row_count(src_engine, table.name) if table.name in src_tables else 0 + if count: + print(f" {table.name}: {count}") + total += count + for name in extra_tables: + count = _row_count(src_engine, name) + if count: + print(f" {name}: {count}") + total += count + print(f"Total rows: {total}") + return 0 + + existing_users = _row_count(dst_engine, "users") + if existing_users > 0 and not args.force: + print( + f"ERROR: PostgreSQL already has {existing_users} user(s). " + "Use --force to truncate and re-import, or migrate to an empty database." + ) + return 1 + + Base.metadata.create_all(bind=dst_engine) + + if args.force and existing_users > 0: + print("Truncating PostgreSQL tables...") + _truncate_all(dst_engine) + + copied = 0 + for table in Base.metadata.sorted_tables: + if table.name not in src_tables: + continue + columns = [col.name for col in table.columns] + count = _copy_table(src_engine, dst_engine, table.name, columns) + if count: + print(f" {table.name}: {count} rows") + copied += count + if "id" in columns and count > 0: + _reset_serial(dst_engine, table.name) + + for name in extra_tables: + inspector = inspect(src_engine) + cols = [col["name"] for col in inspector.get_columns(name)] + count = _copy_table(src_engine, dst_engine, name, cols) + if count: + print(f" {name}: {count} rows") + copied += count + + print(f"Migration complete: {copied} rows copied from {sqlite_path}") + print("SQLite file kept as backup. Update .env DATABASE_URL if not already pointing to PostgreSQL.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/nginx-host-assistant.conf.example b/deploy/nginx-host-assistant.conf.example index 36a0d67..af61335 100644 --- a/deploy/nginx-host-assistant.conf.example +++ b/deploy/nginx-host-assistant.conf.example @@ -8,7 +8,7 @@ server { listen 443 ssl http2; - server_name assistant.grigowashere.ru; + server_name assistant.example.com; # До 8 скриншотов за раз (VISION_MAX_IMAGES), с запасом на JPEG до preprocess client_max_body_size 64m; diff --git a/docker-compose.yml b/docker-compose.yml index d77fea6..94d09c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,19 @@ services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-assistant} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-assistant} + POSTGRES_DB: ${POSTGRES_DB:-assistant} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-assistant} -d ${POSTGRES_DB:-assistant}"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + qdrant: image: qdrant/qdrant:v1.12.5 ports: @@ -15,8 +30,12 @@ services: env_file: .env environment: QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333} + DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-assistant}:${POSTGRES_PASSWORD:-assistant}@postgres:5432/${POSTGRES_DB:-assistant} depends_on: - - qdrant + postgres: + condition: service_healthy + qdrant: + condition: service_started volumes: - ./data:/app/data extra_hosts: @@ -36,4 +55,5 @@ services: restart: unless-stopped volumes: + postgres_data: qdrant_data: diff --git a/docker-compose.yml.refactor_bak b/docker-compose.yml.refactor_bak deleted file mode 100644 index db9e27d..0000000 --- a/docker-compose.yml.refactor_bak +++ /dev/null @@ -1,22 +0,0 @@ -services: - backend: - build: ./backend - ports: - - "${BACKEND_PORT:-8080}:${BACKEND_INTERNAL_PORT:-8080}" - env_file: .env - volumes: - - ./data:/app/data - extra_hosts: - - "host.docker.internal:host-gateway" - restart: unless-stopped - - frontend: - build: - context: ./frontend - args: - VITE_API_URL: "" - ports: - - "${FRONTEND_PORT:-3080}:${FRONTEND_INTERNAL_PORT:-80}" - depends_on: - - backend - restart: unless-stopped diff --git a/frontend/package.json.refactor_bak b/frontend/package.json.refactor_bak deleted file mode 100644 index 38d8a2d..0000000 --- a/frontend/package.json.refactor_bak +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "home-assistant-frontend", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-markdown": "^9.0.1", - "react-router-dom": "^6.28.0" - }, - "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.3", - "typescript": "^5.6.3", - "vite": "^5.4.11" - } -} diff --git a/frontend/src/App.css.refactor_bak b/frontend/src/App.css.refactor_bak deleted file mode 100644 index b1560be..0000000 --- a/frontend/src/App.css.refactor_bak +++ /dev/null @@ -1,81 +0,0 @@ -.app { - height: 100%; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.app-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem 1.5rem; - border-bottom: 1px solid #2a2f3a; - background: #151922; -} - -.app-header h1 { - margin: 0; - font-size: 1.1rem; - font-weight: 600; -} - -.app-header nav { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.app-header nav a { - padding: 0.45rem 0.9rem; - border-radius: 8px; - color: #a8b0bd; -} - -.app-header nav a.active { - background: #2b3445; - color: #fff; -} - -.app-main { - flex: 1; - min-height: 0; - overflow-x: hidden; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - overscroll-behavior: contain; -} - -@media (max-width: 768px) { - .app-header { - padding: 0.55rem 0.75rem; - gap: 0.5rem; - flex-shrink: 0; - } - - .app-header h1 { - display: none; - } - - .app-header nav { - flex: 1; - overflow-x: auto; - flex-wrap: nowrap; - gap: 0.35rem; - padding-bottom: 0.1rem; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - } - - .app-header nav::-webkit-scrollbar { - display: none; - } - - .app-header nav a { - padding: 0.4rem 0.65rem; - font-size: 0.85rem; - white-space: nowrap; - flex-shrink: 0; - } -} diff --git a/frontend/src/App.tsx.refactor_bak b/frontend/src/App.tsx.refactor_bak deleted file mode 100644 index ef2b1c4..0000000 --- a/frontend/src/App.tsx.refactor_bak +++ /dev/null @@ -1,49 +0,0 @@ -import { NavLink, Route, Routes } from "react-router-dom"; -import PomodoroWidget from "./components/PomodoroWidget"; -import { PomodoroProvider } from "./context/PomodoroContext"; -import { useVisualViewportHeight } from "./hooks/useVisualViewport"; -import Character from "./pages/Character"; -import Chat from "./pages/Chat"; -import Fitness from "./pages/Fitness"; -import Reminders from "./pages/Reminders"; -import Shopping from "./pages/Shopping"; -import Memory from "./pages/Memory"; -import Pomodoro from "./pages/Pomodoro"; -import "./App.css"; - -export default function App() { - useVisualViewportHeight(); - - return ( - -
-
-

Home AI Assistant

- -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
-
- ); -} diff --git a/frontend/src/api/client.ts.refactor_bak b/frontend/src/api/client.ts.refactor_bak deleted file mode 100644 index c9ce3f8..0000000 --- a/frontend/src/api/client.ts.refactor_bak +++ /dev/null @@ -1,555 +0,0 @@ -const API_BASE = import.meta.env.VITE_API_URL ?? ""; - -export interface ChatSession { - id: number; - title: string; - created_at: string; - updated_at: string; -} - -export interface ChatMessage { - id: number; - role: string; - content: string; - tool_calls_json?: string | null; - created_at: string; -} - -export interface SessionDetail extends ChatSession { - messages: ChatMessage[]; -} - -export interface PomodoroCycle { - completed_work_sessions: number; - sessions_until_long_break: number; - task_note: string; - work_duration_min: number; - short_break_min: number; - long_break_min: number; - auto_advance: boolean; - chat_notify_seq: number; -} - -export interface PomodoroStatus { - status: string; - phase: string; - duration_min: number; - task_note: string; - elapsed_seconds: number; - remaining_seconds: number; - session_id: number | null; - started_at?: string | null; - finished_at?: string | null; - cycle: PomodoroCycle; -} - -export interface CharacterCardData { - name: string; - description: string; - personality: string; - scenario: string; - first_mes: string; - mes_example: string; - system_prompt: string; - post_history_instructions: string; - tags: string[]; - creator: string; - creator_notes: string; - alternate_greetings: string[]; - character_version: string; -} - -export interface CharacterCardV2 { - spec: string; - spec_version: string; - data: CharacterCardData; -} - -export interface UserProfile { - name?: string; - age?: string; - timezone?: string; - language?: string; - notes?: string; -} - -export interface MemoryFact { - id: number; - category: string; - content: string; - importance: number; - source?: string; - updated_at?: string | null; -} - -export interface FitnessComputed { - bmr: number; - tdee: number; - bmi: number; -} - -export interface FitnessProfile { - sex?: string; - age?: number; - height_cm?: number; - weight_kg?: number; - activity_level?: string; - goal?: string; - target_weight_kg?: number | null; - weekly_workouts?: number; - calorie_target?: number; - protein_g?: number; - fat_g?: number; - carbs_g?: number; - water_l?: number; - computed?: FitnessComputed; -} - -export interface FoodLogItem { - id: number; - meal_type: string; - description: string; - calories: number; - protein_g: number; - fat_g: number; - carbs_g: number; - estimated: boolean; - logged_at?: string; -} - -export interface WaterLogItem { - id: number; - amount_ml: number; - logged_at?: string; -} - -export interface WorkoutLogItem { - id: number; - title: string; - notes?: string; - duration_min?: number | null; - exercises?: unknown[]; - logged_at?: string; -} - -export interface FitnessDailySummary { - date: string; - totals: { - calories: number; - protein_g: number; - fat_g: number; - carbs_g: number; - water_ml: number; - }; - targets: { - calories: number; - protein_g: number; - fat_g: number; - carbs_g: number; - water_ml: number; - }; - meals: FoodLogItem[]; - water: WaterLogItem[]; - workouts: WorkoutLogItem[]; -} - -export interface BodyMetric { - id: number; - weight_kg: number; - recorded_at?: string; -} - -export interface FitnessReminder { - id: number; - kind: string; - hour: number; - minute: number; - interval_hours?: number | null; - enabled: boolean; -} - -export interface FitnessDayOverview { - date: string; - has_data: boolean; - totals: FitnessDailySummary["totals"]; - targets: FitnessDailySummary["targets"]; - meal_count: number; - workout_count: number; -} - -export interface FitnessHistory { - start_date: string; - end_date: string; - days: number; - summaries: FitnessDayOverview[]; -} - -export interface FitnessSnapshot { - profile: FitnessProfile | null; - today: FitnessDailySummary; - history?: FitnessHistory; - body_metrics: BodyMetric[]; - reminders: FitnessReminder[]; -} - -export interface MemorySnapshot { - profile: UserProfile; - facts: MemoryFact[]; - session_summary?: string; - total_facts: number; -} - -export interface PomodoroHistoryItem { - id: number; - status: string; - phase: string; - duration_min: number; - task_note: string; - result: string | null; - completed: boolean; - elapsed_seconds: number; - finished_at: string | null; -} - -async function request(path: string, options?: RequestInit): Promise { - const response = await fetch(`${API_BASE}${path}`, options); - if (!response.ok) { - const text = await response.text(); - throw new Error(text || response.statusText); - } - return response.json() as Promise; -} - -export const api = { - health: () => request<{ status: string }>("/api/v1/health"), - - listSessions: () => request("/api/v1/chat/sessions"), - - createSession: (title = "Новый чат") => - request("/api/v1/chat/sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title }), - }), - - getSession: (id: number) => request(`/api/v1/chat/sessions/${id}`), - - deleteSession: (id: number) => - request<{ ok: boolean }>(`/api/v1/chat/sessions/${id}`, { method: "DELETE" }), - - sendMessage: async function* (sessionId: number, content: string) { - const response = await fetch(`${API_BASE}/api/v1/chat/sessions/${sessionId}/messages`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); - - if (!response.ok || !response.body) { - const detail = await response.text().catch(() => ""); - throw new Error(detail || `Ошибка отправки (${response.status})`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - const flushParts = function* (parts: string[]) { - for (const part of parts) { - if (!part.trim()) continue; - const lines = part.split("\n"); - let event = "message"; - let data = ""; - - for (const line of lines) { - if (line.startsWith("event: ")) event = line.slice(7); - if (line.startsWith("data: ")) data = line.slice(6); - } - - if (data) { - yield { event, data: JSON.parse(data) }; - } - } - }; - - try { - while (true) { - let done = false; - let value: Uint8Array | undefined; - try { - ({ done, value } = await reader.read()); - } catch { - throw new Error( - "Соединение прервалось (таймаут прокси). Обновите чат — ответ мог уже сохраниться.", - ); - } - - if (value) { - buffer += decoder.decode(value, { stream: !done }); - } - - const parts = buffer.split("\n\n"); - buffer = parts.pop() ?? ""; - yield* flushParts(parts); - - if (done) { - if (buffer.trim()) { - yield* flushParts([buffer]); - } - break; - } - } - } finally { - reader.releaseLock(); - } - }, - - pomodoroStatus: () => request("/api/v1/pomodoro/status"), - - pomodoroStart: (duration_min: number, task_note: string) => - request("/api/v1/pomodoro/start", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ duration_min, task_note }), - }), - - pomodoroPause: () => - request("/api/v1/pomodoro/pause", { method: "POST" }), - - pomodoroResume: () => - request("/api/v1/pomodoro/resume", { method: "POST" }), - - pomodoroStop: (result: string, completed: boolean) => - request("/api/v1/pomodoro/stop", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ result, completed }), - }), - - pomodoroHistory: () => request("/api/v1/pomodoro/history"), - - pomodoroResetCycle: (clear_task = false) => - request(`/api/v1/pomodoro/cycle/reset?clear_task=${clear_task}`, { - method: "POST", - }), - - pomodoroSkip: () => - request("/api/v1/pomodoro/skip", { method: "POST" }), - - pomodoroStartShortBreak: (duration_min?: number) => - request( - `/api/v1/pomodoro/break/short/start${duration_min ? `?duration_min=${duration_min}` : ""}`, - { method: "POST" } - ), - - pomodoroStartLongBreak: (duration_min?: number) => - request( - `/api/v1/pomodoro/break/long/start${duration_min ? `?duration_min=${duration_min}` : ""}`, - { method: "POST" } - ), - - getCharacter: () => request("/api/v1/character"), - - saveCharacter: (card: CharacterCardV2) => - request("/api/v1/character", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(card), - }), - - getMemorySnapshot: (sessionId?: number) => - request( - `/api/v1/memory${sessionId ? `?session_id=${sessionId}` : ""}` - ), - - updateProfile: (updates: UserProfile) => - request<{ ok: boolean; profile: UserProfile }>("/api/v1/profile", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ updates }), - }), - - createMemoryFact: (payload: { - content: string; - category?: string; - importance?: number; - session_id?: number; - }) => - request<{ ok: boolean; memory_id: number }>("/api/v1/memory/facts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }), - - forgetMemoryFact: (id: number) => - request<{ ok: boolean }>(`/api/v1/memory/facts/${id}`, { method: "DELETE" }), - - getFitnessSnapshot: () => request("/api/v1/fitness"), - - getFitnessSummary: (day?: string) => - request( - `/api/v1/fitness/summary${day ? `?day=${encodeURIComponent(day)}` : ""}` - ), - - getFitnessHistory: (days = 7, end?: string) => { - const params = new URLSearchParams({ days: String(days) }); - if (end) params.set("end", end); - return request(`/api/v1/fitness/history?${params}`); - }, - - updateFitnessProfile: (updates: Partial) => - request<{ ok: boolean; profile: FitnessProfile }>("/api/v1/fitness/profile", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updates), - }), - - deleteFitnessMeal: (id: number) => - request<{ ok: boolean }>(`/api/v1/fitness/meals/${id}`, { method: "DELETE" }), - - deleteFitnessWater: (id: number) => - request<{ ok: boolean }>(`/api/v1/fitness/water/${id}`, { method: "DELETE" }), - - updateFitnessReminder: ( - kind: string, - updates: { enabled?: boolean; hour?: number; minute?: number; interval_hours?: number } - ) => - request<{ ok: boolean }>(`/api/v1/fitness/reminders/${kind}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updates), - }), - - getShoppingSnapshot: () => request("/api/v1/shopping"), - - createShoppingList: (name: string) => - request<{ ok: boolean; list: ShoppingList }>("/api/v1/shopping/lists", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), - }), - - renameShoppingList: (listId: number, name: string) => - request<{ ok: boolean; list: ShoppingList }>(`/api/v1/shopping/lists/${listId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), - }), - - deleteShoppingList: (listId: number) => - request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}`, { method: "DELETE" }), - - addShoppingItems: (payload: { - list_id?: number; - list_name?: string; - items: { text: string; quantity?: number; unit?: string }[]; - }) => - request<{ ok: boolean }>("/api/v1/shopping/items", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }), - - setShoppingItemChecked: (itemId: number, checked: boolean) => - request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ checked }), - }), - - removeShoppingItem: (itemId: number) => - request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, { method: "DELETE" }), - - clearShoppingChecked: (listId: number) => - request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}/clear-checked`, { - method: "POST", - }), - - getRemindersSnapshot: () => request("/api/v1/reminders"), - - getRemindersCalendar: (year: number, month: number) => - request(`/api/v1/reminders/calendar?year=${year}&month=${month}`), - - createReminder: (payload: ReminderCreatePayload) => - request<{ ok: boolean; reminder: Reminder }>("/api/v1/reminders", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }), - - updateReminder: (id: number, payload: Partial & { enabled?: boolean }) => - request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }), - - deleteReminder: (id: number) => - request<{ ok: boolean }>(`/api/v1/reminders/${id}`, { method: "DELETE" }), - - completeReminder: (id: number) => - request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}/complete`, { - method: "POST", - }), -}; - -export interface ShoppingListItem { - id: number; - list_id: number; - text: string; - quantity: number | null; - unit: string; - checked: boolean; - sort_order: number; -} - -export interface ShoppingList { - id: number; - name: string; - sort_order: number; - item_count: number; - unchecked_count: number; - items?: ShoppingListItem[]; -} - -export interface ShoppingSnapshot { - lists: ShoppingList[]; - list_count: number; - total_items: number; - unchecked_items: number; -} - -export interface Reminder { - id: number; - title: string; - notes: string; - due_at: string; - due_at_local: string; - all_day: boolean; - recurrence: string; - enabled: boolean; - completed_at: string | null; - timezone: string; - created_at: string | null; -} - -export interface RemindersSnapshot { - notify_seq: number; - upcoming: Reminder[]; - upcoming_count: number; - timezone: string; -} - -export interface RemindersCalendar { - year: number; - month: number; - timezone: string; - reminders: Reminder[]; -} - -export interface ReminderCreatePayload { - title: string; - due_at: string; - notes?: string; - all_day?: boolean; - recurrence?: string; -} diff --git a/telegram-bot/README.md b/telegram-bot/README.md index c44ee39..53c8a44 100644 --- a/telegram-bot/README.md +++ b/telegram-bot/README.md @@ -1,6 +1,8 @@ # Home Assistant Telegram Bot -Telegram-бот для удалённого доступа к домашнему ассистенту Home Assistant. Работает на отдельном VPS и общается с backend через REST API. +Telegram-бот для удалённого доступа к домашнему ассистенту. Работает на отдельном VPS и общается с backend через REST API. + +Документация backend: [корневой README](../README.md) (auth, PostgreSQL, RAG, nginx `client_max_body_size` для фото). ## Возможности