From c8a9429bed5256b0ceffc0eefa1383a52e955f72 Mon Sep 17 00:00:00 2001 From: Grigo Date: Sat, 13 Jun 2026 20:20:56 +0000 Subject: [PATCH] added RAG, Multiuser, TG bot --- .env.example | 227 +- .env.example.refactor_bak | 106 + backend/_p1.py | 34 + backend/app/api/chat_schemas.py | 12 + backend/app/api/routes/__init__.py | 37 +- .../app/api/routes/__init__.py.refactor_bak | 17 + backend/app/api/routes/auth.py | 73 + backend/app/api/routes/character.py | 142 +- backend/app/api/routes/chat.py | 228 +- backend/app/api/routes/chat.py.refactor_bak | 70 + backend/app/api/routes/documents.py | 51 + backend/app/api/routes/fitness.py | 531 +++-- backend/app/api/routes/memory.py | 257 ++- backend/app/api/routes/pomodoro.py | 202 +- backend/app/api/routes/projects.py | 154 +- backend/app/api/routes/reminders.py | 252 +- backend/app/api/routes/settings.py | 34 + backend/app/api/routes/shopping.py | 234 +- backend/app/api/routes/webhooks.py | 224 +- backend/app/auth/__init__.py | 5 + backend/app/auth/deps.py | 34 + backend/app/auth/service.py | 61 + backend/app/auth/tokens.py | 9 + backend/app/character/card.py | 200 +- backend/app/character/service.py | 70 +- backend/app/chat/generation.py | 95 + backend/app/chat/notice_inbox.py | 91 +- backend/app/chat/notices.py | 829 +++---- backend/app/chat/service.py | 1032 +++++---- backend/app/chat/service.py.refactor_bak | 468 ++++ backend/app/config.py | 265 ++- backend/app/config.py.refactor_bak | 127 + backend/app/db/migrate_fitness.py | 94 + backend/app/db/migrate_multi_user.py | 249 ++ backend/app/db/models.py | 695 +++--- backend/app/db/models.py.refactor_bak | 299 +++ backend/app/fitness/activity_budget.py | 143 ++ backend/app/fitness/body_composition.py | 128 ++ backend/app/fitness/context.py | 149 +- backend/app/fitness/reminders.py | 225 +- backend/app/fitness/service.py | 1131 +++++---- backend/app/fitness/service.py.agent_read | 441 ++++ backend/app/fitness/structuring.py | 162 +- backend/app/homelab/context.py | 84 +- backend/app/homelab/image_gen.py | 261 +-- backend/app/homelab_scoped/__init__.py | 0 backend/app/homelab_scoped/notices.py | 5 + backend/app/homelab_scoped/watcher.py | 164 ++ backend/app/llm/client.py | 592 ++--- backend/app/llm/client.py.refactor_bak | 269 +++ backend/app/main.py | 119 +- backend/app/main.py.refactor_bak | 54 + backend/app/memory/context.py | 172 +- backend/app/memory/context.py.refactor_bak | 83 + backend/app/memory/extract.py | 305 +-- backend/app/memory/service.py | 528 +++-- backend/app/memory/service.py.refactor_bak | 228 ++ backend/app/pomodoro/completion.py | 183 +- backend/app/pomodoro/cycle.py | 179 +- backend/app/pomodoro/service.py | 583 ++--- backend/app/pomodoro/watcher.py | 79 +- backend/app/projects/context.py | 308 +-- backend/app/projects/service.py | 942 ++++---- backend/app/rag/__init__.py | 5 + backend/app/rag/chunker.py | 20 + backend/app/rag/embeddings.py | 10 + backend/app/rag/ingest.py | 152 ++ backend/app/rag/migrate_memory_to_qdrant.py | 37 + backend/app/rag/retriever.py | 67 + backend/app/rag/store.py | 64 + backend/app/reminders/completion.py | 147 +- backend/app/reminders/context.py | 66 +- backend/app/reminders/fire.py | 95 +- backend/app/reminders/service.py | 482 ++-- backend/app/reminders_scoped/__init__.py | 3 + backend/app/reminders_scoped/completion.py | 74 + backend/app/reminders_scoped/context.py | 33 + backend/app/reminders_scoped/fire.py | 50 + backend/app/reminders_scoped/service.py | 245 ++ backend/app/reminders_scoped/watcher.py | 31 + backend/app/settings/__init__.py | 1 + backend/app/settings/service.py | 98 + backend/app/shopping/context.py | 94 +- backend/app/shopping/service.py | 447 ++-- backend/app/tools/registry.py | 2034 +++++++++-------- backend/app/tools/registry.py.refactor_bak | 961 ++++++++ backend/backfill_fitness_activity.py | 72 + backend/requirements.txt | 20 +- backend/requirements.txt.refactor_bak | 9 + backend/scripts/create_user.py | 42 + backend/tests/test_activity_budget.py | 84 + backend/tests/test_body_composition.py | 102 + backend/tests/test_multi_user.py | 154 ++ docker-compose.yml | 61 +- docker-compose.yml.refactor_bak | 22 + frontend/Dockerfile | 3 + frontend/package.json | 49 +- frontend/package.json.refactor_bak | 24 + frontend/src/App.css | 215 +- frontend/src/App.css.refactor_bak | 81 + frontend/src/App.tsx | 241 +- frontend/src/App.tsx.refactor_bak | 49 + frontend/src/api/client.ts | 1376 ++++++----- frontend/src/api/client.ts.refactor_bak | 555 +++++ frontend/src/components/MessageBubble.tsx | 101 + frontend/src/components/MessageList.tsx | 123 + frontend/src/components/RequireAuth.tsx | 23 + frontend/src/context/AuthContext.tsx | 75 + frontend/src/hooks/usePomodoroNotify.ts | 47 + frontend/src/hooks/useThrottledStreaming.ts | 51 + frontend/src/pages/Chat.old.tsx | 417 ++++ frontend/src/pages/Chat.performance.css | 60 + frontend/src/pages/Chat.tsx | 1029 +++++---- frontend/src/pages/Fitness.css | 635 ++--- frontend/src/pages/Fitness.tsx | 991 ++++---- frontend/src/pages/Login.css | 70 + frontend/src/pages/Login.tsx | 66 + frontend/src/pages/Settings.css | 64 + frontend/src/pages/Settings.tsx | 215 ++ frontend/src/pages/test_overwrite.txt | 1 + frontend/src/utils/mergeMessages.ts | 81 + telegram-bot/.env.example | 14 + telegram-bot/.gitignore | 5 + telegram-bot/Dockerfile | 13 + telegram-bot/README.md | 145 ++ telegram-bot/bot/__init__.py | 1 + telegram-bot/bot/access.py | 18 + telegram-bot/bot/config.py | 60 + telegram-bot/bot/filters.py | 20 + telegram-bot/bot/ha_client.py | 143 ++ telegram-bot/bot/handlers/__init__.py | 12 + telegram-bot/bot/handlers/auth.py | 126 + telegram-bot/bot/handlers/chat.py | 129 ++ telegram-bot/bot/handlers/commands.py | 33 + telegram-bot/bot/handlers/start.py | 44 + telegram-bot/bot/main.py | 54 + telegram-bot/bot/middleware.py | 25 + telegram-bot/bot/notify_worker.py | 143 ++ telegram-bot/bot/sse.py | 51 + telegram-bot/bot/storage.py | 196 ++ telegram-bot/docker-compose.yml | 7 + telegram-bot/requirements.txt | 3 + 142 files changed, 19901 insertions(+), 8790 deletions(-) create mode 100644 .env.example.refactor_bak create mode 100644 backend/_p1.py create mode 100644 backend/app/api/chat_schemas.py create mode 100644 backend/app/api/routes/__init__.py.refactor_bak create mode 100644 backend/app/api/routes/auth.py create mode 100644 backend/app/api/routes/chat.py.refactor_bak create mode 100644 backend/app/api/routes/documents.py create mode 100644 backend/app/api/routes/settings.py create mode 100644 backend/app/auth/__init__.py create mode 100644 backend/app/auth/deps.py create mode 100644 backend/app/auth/service.py create mode 100644 backend/app/auth/tokens.py create mode 100644 backend/app/chat/generation.py create mode 100644 backend/app/chat/service.py.refactor_bak create mode 100644 backend/app/config.py.refactor_bak create mode 100644 backend/app/db/migrate_fitness.py create mode 100644 backend/app/db/migrate_multi_user.py create mode 100644 backend/app/db/models.py.refactor_bak create mode 100644 backend/app/fitness/activity_budget.py create mode 100644 backend/app/fitness/body_composition.py create mode 100644 backend/app/fitness/service.py.agent_read create mode 100644 backend/app/homelab_scoped/__init__.py create mode 100644 backend/app/homelab_scoped/notices.py create mode 100644 backend/app/homelab_scoped/watcher.py create mode 100644 backend/app/llm/client.py.refactor_bak create mode 100644 backend/app/main.py.refactor_bak create mode 100644 backend/app/memory/context.py.refactor_bak create mode 100644 backend/app/memory/service.py.refactor_bak create mode 100644 backend/app/rag/__init__.py create mode 100644 backend/app/rag/chunker.py create mode 100644 backend/app/rag/embeddings.py create mode 100644 backend/app/rag/ingest.py create mode 100644 backend/app/rag/migrate_memory_to_qdrant.py create mode 100644 backend/app/rag/retriever.py create mode 100644 backend/app/rag/store.py create mode 100644 backend/app/reminders_scoped/__init__.py create mode 100644 backend/app/reminders_scoped/completion.py create mode 100644 backend/app/reminders_scoped/context.py create mode 100644 backend/app/reminders_scoped/fire.py create mode 100644 backend/app/reminders_scoped/service.py create mode 100644 backend/app/reminders_scoped/watcher.py create mode 100644 backend/app/settings/__init__.py create mode 100644 backend/app/settings/service.py create mode 100644 backend/app/tools/registry.py.refactor_bak create mode 100644 backend/backfill_fitness_activity.py create mode 100644 backend/requirements.txt.refactor_bak create mode 100644 backend/scripts/create_user.py create mode 100644 backend/tests/test_activity_budget.py create mode 100644 backend/tests/test_body_composition.py create mode 100644 backend/tests/test_multi_user.py create mode 100644 docker-compose.yml.refactor_bak create mode 100644 frontend/package.json.refactor_bak create mode 100644 frontend/src/App.css.refactor_bak create mode 100644 frontend/src/App.tsx.refactor_bak create mode 100644 frontend/src/api/client.ts.refactor_bak create mode 100644 frontend/src/components/MessageBubble.tsx create mode 100644 frontend/src/components/MessageList.tsx create mode 100644 frontend/src/components/RequireAuth.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/hooks/usePomodoroNotify.ts create mode 100644 frontend/src/hooks/useThrottledStreaming.ts create mode 100644 frontend/src/pages/Chat.old.tsx create mode 100644 frontend/src/pages/Chat.performance.css create mode 100644 frontend/src/pages/Login.css create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Settings.css create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/test_overwrite.txt create mode 100644 frontend/src/utils/mergeMessages.ts create mode 100644 telegram-bot/.env.example create mode 100644 telegram-bot/.gitignore create mode 100644 telegram-bot/Dockerfile create mode 100644 telegram-bot/README.md create mode 100644 telegram-bot/bot/__init__.py create mode 100644 telegram-bot/bot/access.py create mode 100644 telegram-bot/bot/config.py create mode 100644 telegram-bot/bot/filters.py create mode 100644 telegram-bot/bot/ha_client.py create mode 100644 telegram-bot/bot/handlers/__init__.py create mode 100644 telegram-bot/bot/handlers/auth.py create mode 100644 telegram-bot/bot/handlers/chat.py create mode 100644 telegram-bot/bot/handlers/commands.py create mode 100644 telegram-bot/bot/handlers/start.py create mode 100644 telegram-bot/bot/main.py create mode 100644 telegram-bot/bot/middleware.py create mode 100644 telegram-bot/bot/notify_worker.py create mode 100644 telegram-bot/bot/sse.py create mode 100644 telegram-bot/bot/storage.py create mode 100644 telegram-bot/docker-compose.yml create mode 100644 telegram-bot/requirements.txt diff --git a/.env.example b/.env.example index 373f1ce..603902c 100644 --- a/.env.example +++ b/.env.example @@ -1,106 +1,121 @@ -# 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 +# 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 + +# Multi-user (API token auth) +DEFAULT_USER_USERNAME=owner +DEFAULT_USER_DISPLAY_NAME= +DEFAULT_API_TOKEN=change-me-to-long-random-string +AUTH_REQUIRED=true +# Опционально для dev (автовход без /login). В prod оставьте пустым. +VITE_API_TOKEN= + +# 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 + +# RAG / embeddings +QDRANT_URL=http://qdrant:6333 +EMBEDDING_MODEL=openai/text-embedding-3-small +RAG_ENABLED=true +RAG_TOP_K=8 +MEMORY_FACTS_IN_CONTEXT=8 diff --git a/.env.example.refactor_bak b/.env.example.refactor_bak new file mode 100644 index 0000000..373f1ce --- /dev/null +++ b/.env.example.refactor_bak @@ -0,0 +1,106 @@ +# 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/backend/_p1.py b/backend/_p1.py new file mode 100644 index 0000000..e0cb626 --- /dev/null +++ b/backend/_p1.py @@ -0,0 +1,34 @@ +import pathlib +ROOT = pathlib.Path(".").resolve() +svc = ROOT / "app/fitness/service.py" +text = svc.read_text(encoding="utf-8") +old = """from app.db.models import ( + BodyMetric, + FitnessProfile, + FitnessReminder, + FoodLog, + WaterLog, + WorkoutLog, +) +from app.fitness.calculators import compute_targets, one_rep_max""" +new = """from app.db.models import ( + BodyMetric, + FitnessProfile, + FitnessReminder, + FoodLog, + StepLog, + WaterLog, + WorkoutLog, +) +from app.fitness.activity_budget import ( + build_base_targets, + compute_activity_bonus, + estimate_workout_active_kcal, + scale_targets, +) +from app.fitness.calculators import compute_targets, one_rep_max""" +if old not in text: + raise SystemExit("import block missing") +text = text.replace(old, new, 1) +svc.write_text(text, encoding="utf-8") +print("ok imports") diff --git a/backend/app/api/chat_schemas.py b/backend/app/api/chat_schemas.py new file mode 100644 index 0000000..cf04d03 --- /dev/null +++ b/backend/app/api/chat_schemas.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + +from app.api.schemas import MessageOut + + +class MessagesPageOut(BaseModel): + messages: list[MessageOut] + has_more: bool + + +class GenerationStatusOut(BaseModel): + active: bool diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index a69fb85..67300e1 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1,17 +1,20 @@ -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"]) +from fastapi import APIRouter + +from app.api.routes import auth, character, chat, documents, fitness, health, homelab, media, memory, pomodoro, projects, reminders, settings, shopping, webhooks + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(health.router, tags=["health"]) +api_router.include_router(auth.router) +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"]) +api_router.include_router(settings.router, tags=["settings"]) +api_router.include_router(documents.router, tags=["documents"]) diff --git a/backend/app/api/routes/__init__.py.refactor_bak b/backend/app/api/routes/__init__.py.refactor_bak new file mode 100644 index 0000000..a69fb85 --- /dev/null +++ b/backend/app/api/routes/__init__.py.refactor_bak @@ -0,0 +1,17 @@ +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/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 0000000..9d82df3 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -0,0 +1,73 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth.deps import get_current_user +from app.auth.service import create_user, find_user_by_token, user_to_dict +from app.db.base import get_db +from app.db.models import User + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +class LoginRequest(BaseModel): + token: str = Field(min_length=8, max_length=256) + + +class CreateUserRequest(BaseModel): + username: str = Field(min_length=2, max_length=64) + display_name: str = "" + token: str | None = Field(default=None, min_length=8, max_length=256) + + +@router.post("/login") +def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict[str, Any]: + user = find_user_by_token(db, payload.token) + if not user: + raise HTTPException(status_code=401, detail="Неверный токен") + return {"ok": True, "user": user_to_dict(user), "token": payload.token.strip()} + + +@router.get("/me") +def me(user: User = Depends(get_current_user)) -> dict[str, Any]: + return {"ok": True, "user": user_to_dict(user)} + + +@router.get("/users") +def list_users( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict[str, Any]: + rows = db.scalars(select(User).where(User.is_active.is_(True)).order_by(User.id)).all() + return { + "ok": True, + "users": [user_to_dict(row) for row in rows], + "current_user_id": user.id, + } + + +@router.post("/users") +def register_user( + payload: CreateUserRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict[str, Any]: + try: + new_user, plain_token = create_user( + db, + username=payload.username, + display_name=payload.display_name, + api_token=payload.token, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return { + "ok": True, + "user": user_to_dict(new_user), + "token": plain_token, + "created_by": user.username, + } diff --git a/backend/app/api/routes/character.py b/backend/app/api/routes/character.py index 448bdaa..01ccd1d 100644 --- a/backend/app/api/routes/character.py +++ b/backend/app/api/routes/character.py @@ -1,62 +1,80 @@ -from typing import Any - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field - -from app.character.service import CharacterService - -router = APIRouter() - - -class CharacterCardData(BaseModel): - name: str = "Ассистент" - description: str = "" - personality: str = "" - scenario: str = "" - first_mes: str = "" - mes_example: str = "" - system_prompt: str = "" - post_history_instructions: str = "" - tags: list[str] = Field(default_factory=list) - creator: str = "" - creator_notes: str = "" - alternate_greetings: list[str] = Field(default_factory=list) - character_version: str = "1.0" - appearance_tags: str = "" - appearance_prose: str = "" - lora_name: str = "" - lora_weight: float = 0.8 - rp_persona_id: str = "" - sd_enabled: bool = True - - -class CharacterCardV2(BaseModel): - spec: str = "chara_card_v2" - spec_version: str = "2.0" - data: CharacterCardData - - -@router.get("/character") -def get_character() -> dict[str, Any]: - return CharacterService().get_card() - - -@router.put("/character") -def update_character(payload: CharacterCardV2) -> dict[str, Any]: - return CharacterService().save_card(payload.model_dump()) - - -@router.get("/character/prompt") -def get_character_prompt() -> dict[str, str]: - service = CharacterService() - return { - "system_prompt": service.get_system_prompt(), - "first_mes": service.get_card().get("data", {}).get("first_mes", ""), - } - - -@router.post("/character/import") -def import_character(payload: dict[str, Any]) -> dict[str, Any]: - if not payload: - raise HTTPException(status_code=400, detail="Empty card") - return CharacterService().save_card(payload) +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.deps import get_current_user +from app.character.service import CharacterService +from app.db.base import get_db +from app.db.models import User + +router = APIRouter() + + +class CharacterCardData(BaseModel): + name: str = "Ассистент" + description: str = "" + personality: str = "" + scenario: str = "" + first_mes: str = "" + mes_example: str = "" + system_prompt: str = "" + post_history_instructions: str = "" + tags: list[str] = Field(default_factory=list) + creator: str = "" + creator_notes: str = "" + alternate_greetings: list[str] = Field(default_factory=list) + character_version: str = "1.0" + appearance_tags: str = "" + appearance_prose: str = "" + lora_name: str = "" + lora_weight: float = 0.8 + rp_persona_id: str = "" + sd_enabled: bool = True + + +class CharacterCardV2(BaseModel): + spec: str = "chara_card_v2" + spec_version: str = "2.0" + data: CharacterCardData + + +@router.get("/character") +def get_character( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict[str, Any]: + return CharacterService(db, user.id).get_card() + + +@router.put("/character") +def update_character( + payload: CharacterCardV2, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict[str, Any]: + return CharacterService(db, user.id).save_card(payload.model_dump()) + + +@router.get("/character/prompt") +def get_character_prompt( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict[str, str]: + service = CharacterService(db, user.id) + return { + "system_prompt": service.get_system_prompt(), + "first_mes": service.get_card().get("data", {}).get("first_mes", ""), + } + + +@router.post("/character/import") +def import_character( + payload: dict[str, Any], + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict[str, Any]: + if not payload: + raise HTTPException(status_code=400, detail="Empty card") + return CharacterService(db, user.id).save_card(payload) diff --git a/backend/app/api/routes/chat.py b/backend/app/api/routes/chat.py index 6b85cce..42c0a20 100644 --- a/backend/app/api/routes/chat.py +++ b/backend/app/api/routes/chat.py @@ -1,70 +1,158 @@ -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", - }, - ) +import asyncio + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from app.api.chat_schemas import GenerationStatusOut, MessagesPageOut +from app.api.schemas import ( + MessageCreate, + SessionCreate, + SessionDetailOut, + SessionOut, +) +from app.chat.generation import ( + GenerationBusyError, + get_active_handle, + is_generation_active, + start_generation, + subscribe_generation, +) +from app.chat.service import ChatService +from app.auth.deps import get_current_user +from app.db.base import get_db +from app.db.models import User + +router = APIRouter() + + +@router.post("/sessions", response_model=SessionOut) +def create_session(payload: SessionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionOut: + service = ChatService(db, user.id) + return service.create_session(title=payload.title) + + +@router.get("/sessions", response_model=list[SessionOut]) +def list_sessions(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[SessionOut]: + service = ChatService(db, user.id) + return service.list_sessions() + + +@router.get("/sessions/{session_id}", response_model=SessionDetailOut) +def get_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionDetailOut: + service = ChatService(db, user.id) + session = service.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return session + + +@router.get("/sessions/{session_id}/messages", response_model=MessagesPageOut) +def list_messages( + session_id: int, + limit: int = 30, + before_id: int | None = None, + after_id: int | None = None, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> MessagesPageOut: + service = ChatService(db, user.id) + if not service.get_session(session_id): + raise HTTPException(status_code=404, detail="Session not found") + messages, has_more = service.list_messages( + session_id, + limit=min(max(limit, 1), 100), + before_id=before_id, + after_id=after_id, + ) + return MessagesPageOut(messages=messages, has_more=has_more) + + +@router.get("/sessions/{session_id}/generation", response_model=GenerationStatusOut) +def generation_status(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> GenerationStatusOut: + service = ChatService(db, user.id) + if not service.get_session(session_id): + raise HTTPException(status_code=404, detail="Session not found") + return GenerationStatusOut(active=is_generation_active(session_id)) + + +@router.get("/sessions/{session_id}/generation/stream") +async def generation_stream(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> StreamingResponse: + service = ChatService(db, user.id) + if not service.get_session(session_id): + raise HTTPException(status_code=404, detail="Session not found") + + handle = get_active_handle(session_id) + if not handle: + raise HTTPException(status_code=404, detail="No active generation") + + async def event_stream(): + async for chunk in subscribe_generation(handle): + yield chunk + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.delete("/sessions/{session_id}") +def delete_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]: + service = ChatService(db, user.id) + 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), user: User = Depends(get_current_user), +) -> StreamingResponse: + service = ChatService(db, user.id) + if not service.get_session(session_id): + raise HTTPException(status_code=404, detail="Session not found") + + if is_generation_active(session_id): + raise HTTPException(status_code=409, detail="Generation already in progress") + + # Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД. + service.save_user_message(session_id, payload.content) + + try: + handle = await start_generation(session_id, user.id, payload.content) + except GenerationBusyError: + raise HTTPException(status_code=409, detail="Generation already in progress") from None + + async def event_stream(): + try: + async for chunk in subscribe_generation(handle): + yield chunk + except asyncio.CancelledError: + raise + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.get("/sessions/{session_id}/context-preview") +def context_preview( + session_id: int, + query: str | None = None, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict: + service = ChatService(db, user.id) + return service.context_preview(session_id, query=query) + diff --git a/backend/app/api/routes/chat.py.refactor_bak b/backend/app/api/routes/chat.py.refactor_bak new file mode 100644 index 0000000..6b85cce --- /dev/null +++ b/backend/app/api/routes/chat.py.refactor_bak @@ -0,0 +1,70 @@ +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/api/routes/documents.py b/backend/app/api/routes/documents.py new file mode 100644 index 0000000..324e357 --- /dev/null +++ b/backend/app/api/routes/documents.py @@ -0,0 +1,51 @@ +from typing import Any + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth.deps import get_current_user +from app.db.base import get_db +from app.db.models import User +from app.db.models import Document +from app.rag.ingest import ingest_document_file + +router = APIRouter() + + +@router.get("/documents") +def list_documents(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]: + docs = db.scalars(select(Document).where(Document.user_id == user.id).order_by(Document.created_at.desc())).all() + return [ + { + "id": d.id, + "title": d.title, + "filename": d.filename, + "size_bytes": d.size_bytes, + "created_at": d.created_at.isoformat() if d.created_at else None, + } + for d in docs + ] + + +@router.post("/documents/upload") +async def upload_document( + file: UploadFile = File(...), + title: str = Form(""), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict[str, Any]: + raw = await file.read() + if not raw: + raise HTTPException(status_code=400, detail="Empty file") + try: + doc = await ingest_document_file( + db, + user_id=user.id, + title=title.strip() or (file.filename or "document"), + filename=file.filename or "upload.txt", + raw_bytes=raw, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, "document": doc} diff --git a/backend/app/api/routes/fitness.py b/backend/app/api/routes/fitness.py index 085dc58..d059b71 100644 --- a/backend/app/api/routes/fitness.py +++ b/backend/app/api/routes/fitness.py @@ -1,223 +1,308 @@ -from datetime import date -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from app.db.base import get_db -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 - -router = APIRouter() - - -class ProfileUpdate(BaseModel): - sex: str | None = None - age: int | None = None - height_cm: float | None = None - weight_kg: float | None = None - activity_level: str | None = None - goal: str | None = None - target_weight_kg: float | None = None - weekly_workouts: int | None = None - - -class MealCreate(BaseModel): - text: str = Field(min_length=1) - meal_type: str | None = None - - -class WaterCreate(BaseModel): - amount_ml: int = Field(gt=0) - - -class WeightCreate(BaseModel): - weight_kg: float = Field(gt=0) - body_fat_pct: float | None = None - chest_cm: float | None = None - waist_cm: float | None = None - notes: str = "" - - -class WorkoutCreate(BaseModel): - text: str = Field(min_length=1) - - -class ReminderUpdate(BaseModel): - enabled: bool | None = None - hour: int | None = Field(default=None, ge=0, le=23) - minute: int | None = Field(default=None, ge=0, le=59) - interval_hours: int | None = Field(default=None, ge=1, le=12) - - -@router.get("/fitness") -def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]: - return FitnessService(db).snapshot() - - -@router.get("/fitness/summary") -def get_summary( - day: str | None = None, - db: Session = Depends(get_db), -) -> dict[str, Any]: - d = date.fromisoformat(day) if day else None - return FitnessService(db).get_daily_summary(d) - - -@router.get("/fitness/history") -def get_history( - days: int = 7, - end: str | None = None, - db: Session = Depends(get_db), -) -> dict[str, Any]: - end_day = date.fromisoformat(end) if end else None - return FitnessService(db).get_history(days=days, end_day=end_day) - - -@router.get("/fitness/profile") -def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]: - profile = FitnessService(db).get_profile() - return profile or {"configured": False} - - -@router.put("/fitness/profile") -def update_profile( - payload: ProfileUpdate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - return FitnessService(db).set_profile(payload.model_dump(exclude_none=True)) - - -@router.post("/fitness/profile/calc") -def calc_targets( - payload: ProfileUpdate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - params = payload.model_dump(exclude_none=True) - if not params: - raise HTTPException(status_code=400, detail="No parameters") - return FitnessService(db).calc_targets(params) - - -@router.post("/fitness/meals") -async def create_meal( - payload: MealCreate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - service = FitnessService(db) - try: - structured = await structure_meal(payload.text) - except Exception as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - return service.log_meal( - description=structured.get("description") or payload.text, - meal_type=payload.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=bool(structured.get("estimated", True)), - ) - - -@router.post("/fitness/water") -def create_water( - payload: WaterCreate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - return FitnessService(db).log_water(payload.amount_ml) - - -@router.post("/fitness/weight") -def create_weight( - payload: WeightCreate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - return FitnessService(db).log_weight( - payload.weight_kg, - body_fat_pct=payload.body_fat_pct, - chest_cm=payload.chest_cm, - waist_cm=payload.waist_cm, - notes=payload.notes, - ) - - -@router.post("/fitness/workouts") -async def create_workout( - payload: WorkoutCreate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - service = FitnessService(db) - try: - structured = await structure_workout(payload.text) - except Exception as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - return service.log_workout( - title=structured.get("title") or "Тренировка", - notes=structured.get("notes") or payload.text, - duration_min=structured.get("duration_min"), - exercises=structured.get("exercises"), - ) - - -@router.get("/fitness/body-metrics") -def list_metrics( - limit: int = 30, - db: Session = Depends(get_db), -) -> list[dict[str, Any]]: - return FitnessService(db).list_body_metrics(limit=limit) - - -@router.delete("/fitness/meals/{log_id}") -def delete_meal(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]: - if not FitnessService(db).delete_food_log(log_id): - raise HTTPException(status_code=404, detail="Not found") - return {"ok": True} - - -@router.delete("/fitness/water/{log_id}") -def delete_water(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]: - if not FitnessService(db).delete_water_log(log_id): - raise HTTPException(status_code=404, detail="Not found") - return {"ok": True} - - -@router.delete("/fitness/workouts/{log_id}") -def delete_workout(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]: - if not FitnessService(db).delete_workout_log(log_id): - raise HTTPException(status_code=404, detail="Not found") - return {"ok": True} - - -@router.get("/fitness/reminders") -def list_reminders(db: Session = Depends(get_db)) -> list[dict[str, Any]]: - return FitnessService(db).list_reminders() - - -@router.put("/fitness/reminders/{kind}") -def update_reminder( - kind: str, - payload: ReminderUpdate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - return FitnessService(db).set_reminder( - kind, - enabled=payload.enabled, - hour=payload.hour, - minute=payload.minute, - interval_hours=payload.interval_hours, - ) - - -@router.get("/fitness/lookup/food") -def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]: - return OpenFoodFactsClient().search(q, limit=limit) - - -@router.get("/fitness/lookup/exercise") -def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]: - return WgerClient().search_exercises(q, limit=limit) +from datetime import date +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.deps import get_current_user +from app.db.base import get_db +from app.db.models import User +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 + +router = APIRouter() + + +class ProfileUpdate(BaseModel): + sex: str | None = None + age: int | None = None + height_cm: float | None = None + weight_kg: float | None = None + activity_level: str | None = None + goal: str | None = None + target_weight_kg: float | None = None + weekly_workouts: int | None = None + baseline_steps: int | None = None + baseline_workout_kcal: float | None = None + + +class MealCreate(BaseModel): + text: str = Field(min_length=1) + meal_type: str | None = None + + +class WaterCreate(BaseModel): + amount_ml: int = Field(gt=0) + + +class WeightCreate(BaseModel): + weight_kg: float = Field(gt=0) + body_fat_pct: float | None = None + chest_cm: float | None = None + waist_cm: float | None = None + neck_cm: float | None = None + hip_cm: float | None = None + notes: str = "" + day: str | None = None + days_ago: int | None = Field(default=None, ge=0, le=90) + recorded_at: str | None = None + + +class BodyCompositionCalc(BaseModel): + weight_kg: float | None = None + height_cm: float | None = None + sex: str | None = None + neck_cm: float | None = None + waist_cm: float | None = None + hip_cm: float | None = None + body_fat_pct: float | None = None + + +class StepsCreate(BaseModel): + steps: int = Field(ge=0) + active_calories: float | None = None + notes: str = "" + day: str | None = None + days_ago: int | None = Field(default=None, ge=0, le=90) + logged_at: str | None = None + + +class WorkoutCreate(BaseModel): + text: str = Field(min_length=1) + day: str | None = None + days_ago: int | None = Field(default=None, ge=0, le=90) + logged_at: str | None = None + + +class ReminderUpdate(BaseModel): + enabled: bool | None = None + hour: int | None = Field(default=None, ge=0, le=23) + minute: int | None = Field(default=None, ge=0, le=59) + interval_hours: int | None = Field(default=None, ge=1, le=12) + + +@router.get("/fitness") +def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + return FitnessService(db, user.id).snapshot() + + +@router.get("/fitness/summary") +def get_summary( + day: str | None = None, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + d = date.fromisoformat(day) if day else None + return FitnessService(db, user.id).get_daily_summary(d) + + +@router.get("/fitness/workout-stats") +def get_workout_stats( + days: int = 7, + end: str | None = None, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + end_day = date.fromisoformat(end) if end else None + return FitnessService(db, user.id).get_workout_stats(days=days, end_day=end_day) + + +@router.get("/fitness/history") +def get_history( + days: int = 7, + end: str | None = None, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + end_day = date.fromisoformat(end) if end else None + return FitnessService(db, user.id).get_history(days=days, end_day=end_day) + + +@router.get("/fitness/profile") +def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + profile = FitnessService(db, user.id).get_profile() + return profile or {"configured": False} + + +@router.put("/fitness/profile") +def update_profile( + payload: ProfileUpdate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + return FitnessService(db, user.id).set_profile(payload.model_dump(exclude_none=True)) + + +@router.post("/fitness/profile/calc") +def calc_targets( + payload: ProfileUpdate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + params = payload.model_dump(exclude_none=True) + if not params: + raise HTTPException(status_code=400, detail="No parameters") + return FitnessService(db, user.id).calc_targets(params) + + +@router.post("/fitness/meals") +async def create_meal( + payload: MealCreate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + service = FitnessService(db, user.id) + try: + structured = await structure_meal(payload.text) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return service.log_meal( + description=structured.get("description") or payload.text, + meal_type=payload.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=bool(structured.get("estimated", True)), + ) + + +@router.post("/fitness/water") +def create_water( + payload: WaterCreate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + return FitnessService(db, user.id).log_water(payload.amount_ml) + + +@router.post("/fitness/weight") +def create_weight( + payload: WeightCreate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + day = date.fromisoformat(payload.day) if payload.day else None + return FitnessService(db, user.id).log_weight( + payload.weight_kg, + body_fat_pct=payload.body_fat_pct, + chest_cm=payload.chest_cm, + waist_cm=payload.waist_cm, + neck_cm=payload.neck_cm, + hip_cm=payload.hip_cm, + notes=payload.notes, + recorded_at=payload.recorded_at, + day=day, + days_ago=payload.days_ago, + ) + + +@router.post("/fitness/body-composition/calc") +def calc_body_composition( + payload: BodyCompositionCalc, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + return FitnessService(db, user.id).calc_body_composition(payload.model_dump(exclude_none=True)) + + +@router.post("/fitness/steps") +def create_steps( + payload: StepsCreate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + day = date.fromisoformat(payload.day) if payload.day else None + return FitnessService(db, user.id).log_steps( + payload.steps, + active_calories=payload.active_calories, + notes=payload.notes, + day=day, + days_ago=payload.days_ago, + logged_at=payload.logged_at, + ) + + +@router.delete("/fitness/steps/{log_id}") +def delete_steps(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]: + if not FitnessService(db, user.id).delete_step_log(log_id): + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + + +@router.post("/fitness/workouts") +async def create_workout( + payload: WorkoutCreate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + service = FitnessService(db, user.id) + try: + structured = await structure_workout(payload.text) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + day = date.fromisoformat(payload.day) if payload.day else None + return service.log_workout( + title=structured.get("title") or "Тренировка", + notes=structured.get("notes") or payload.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"), + day=day, + days_ago=payload.days_ago, + logged_at=payload.logged_at, + ) + + +@router.get("/fitness/body-metrics") +def list_metrics( + limit: int = 30, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> list[dict[str, Any]]: + return FitnessService(db, user.id).list_body_metrics(limit=limit) + + +@router.delete("/fitness/meals/{log_id}") +def delete_meal(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]: + if not FitnessService(db, user.id).delete_food_log(log_id): + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + + +@router.delete("/fitness/water/{log_id}") +def delete_water(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]: + if not FitnessService(db, user.id).delete_water_log(log_id): + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + + +@router.delete("/fitness/workouts/{log_id}") +def delete_workout(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]: + if not FitnessService(db, user.id).delete_workout_log(log_id): + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + + +@router.get("/fitness/reminders") +def list_reminders(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]: + return FitnessService(db, user.id).list_reminders() + + +@router.put("/fitness/reminders/{kind}") +def update_reminder( + kind: str, + payload: ReminderUpdate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + return FitnessService(db, user.id).set_reminder( + kind, + enabled=payload.enabled, + hour=payload.hour, + minute=payload.minute, + interval_hours=payload.interval_hours, + ) + + +@router.get("/fitness/lookup/food") +def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]: + return OpenFoodFactsClient().search(q, limit=limit) + + +@router.get("/fitness/lookup/exercise") +def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]: + return WgerClient().search_exercises(q, limit=limit) diff --git a/backend/app/api/routes/memory.py b/backend/app/api/routes/memory.py index e87adf2..c226fd6 100644 --- a/backend/app/api/routes/memory.py +++ b/backend/app/api/routes/memory.py @@ -1,127 +1,130 @@ -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from app.db.base import get_db -from app.db.models import ChatSession -from app.memory.extract import extract_after_turn -from app.memory.service import MemoryService - -router = APIRouter() - - -class ProfileUpdate(BaseModel): - updates: dict[str, Any] = Field(default_factory=dict) - - -class FactCreate(BaseModel): - content: str = Field(min_length=1) - category: str = "fact" - importance: int = Field(default=3, ge=1, le=5) - session_id: int | None = None - - -class SessionSummaryUpdate(BaseModel): - summary: str = Field(min_length=1) - message_count: int = 0 - - -class ExtractRequest(BaseModel): - session_id: int - user_text: str = Field(min_length=1) - assistant_text: str = "" - force: bool = False - - -@router.get("/memory") -def get_memory_snapshot( - session_id: int | None = None, - db: Session = Depends(get_db), -) -> dict[str, Any]: - return MemoryService(db).snapshot(session_id) - - -@router.get("/profile") -def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]: - return MemoryService(db).get_profile() - - -@router.put("/profile") -def update_profile( - payload: ProfileUpdate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - try: - return MemoryService(db).update_profile(payload.updates) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.get("/memory/facts") -def list_facts( - query: str | None = None, - category: str | None = None, - limit: int = 30, - db: Session = Depends(get_db), -) -> list[dict[str, Any]]: - return MemoryService(db).recall_memories(query=query, category=category, limit=limit) - - -@router.post("/memory/facts") -def create_fact( - payload: FactCreate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - try: - return MemoryService(db).remember_fact( - payload.content, - category=payload.category, - session_id=payload.session_id, - importance=payload.importance, - source="api", - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.delete("/memory/facts/{memory_id}") -def forget_fact(memory_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: - try: - return MemoryService(db).forget_memory(memory_id) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - - -@router.post("/memory/extract") -async def extract_memories( - payload: ExtractRequest, - db: Session = Depends(get_db), -) -> dict: - session = db.get(ChatSession, payload.session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - return await extract_after_turn( - db, - payload.session_id, - payload.user_text, - payload.assistant_text, - force=payload.force, - ) - - -@router.put("/memory/sessions/{session_id}/summary") -def update_session_summary( - session_id: int, - payload: SessionSummaryUpdate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - try: - return MemoryService(db).update_session_summary( - session_id, - payload.summary, - message_count=payload.message_count, - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.deps import get_current_user +from app.db.base import get_db +from app.db.models import User +from app.db.models import ChatSession +from app.memory.extract import extract_after_turn +from app.memory.service import MemoryService + +router = APIRouter() + + +class ProfileUpdate(BaseModel): + updates: dict[str, Any] = Field(default_factory=dict) + + +class FactCreate(BaseModel): + content: str = Field(min_length=1) + category: str = "fact" + importance: int = Field(default=3, ge=1, le=5) + session_id: int | None = None + + +class SessionSummaryUpdate(BaseModel): + summary: str = Field(min_length=1) + message_count: int = 0 + + +class ExtractRequest(BaseModel): + session_id: int + user_text: str = Field(min_length=1) + assistant_text: str = "" + force: bool = False + + +@router.get("/memory") +def get_memory_snapshot( + session_id: int | None = None, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + return MemoryService(db, user.id).snapshot(session_id) + + +@router.get("/profile") +def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + return MemoryService(db, user.id).get_profile() + + +@router.put("/profile") +def update_profile( + payload: ProfileUpdate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + try: + return MemoryService(db, user.id).update_profile(payload.updates) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/memory/facts") +def list_facts( + query: str | None = None, + category: str | None = None, + limit: int = 30, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> list[dict[str, Any]]: + return MemoryService(db, user.id).recall_memories(query=query, category=category, limit=limit) + + +@router.post("/memory/facts") +def create_fact( + payload: FactCreate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + try: + return MemoryService(db, user.id).remember_fact( + payload.content, + category=payload.category, + session_id=payload.session_id, + importance=payload.importance, + source="api", + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.delete("/memory/facts/{memory_id}") +def forget_fact(memory_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + try: + return MemoryService(db, user.id).forget_memory(memory_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/memory/extract") +async def extract_memories( + payload: ExtractRequest, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict: + session = db.get(ChatSession, payload.session_id) + if not session or session.user_id != user.id: + raise HTTPException(status_code=404, detail="Session not found") + return await extract_after_turn( + db, + payload.session_id, + payload.user_text, + payload.assistant_text, + user_id=user.id, + force=payload.force, + ) + + +@router.put("/memory/sessions/{session_id}/summary") +def update_session_summary( + session_id: int, + payload: SessionSummaryUpdate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + try: + return MemoryService(db, user.id).update_session_summary( + session_id, + payload.summary, + message_count=payload.message_count, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/app/api/routes/pomodoro.py b/backend/app/api/routes/pomodoro.py index d3e2e22..139ffc3 100644 --- a/backend/app/api/routes/pomodoro.py +++ b/backend/app/api/routes/pomodoro.py @@ -1,100 +1,102 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session - -from app.api.schemas import PomodoroStart, PomodoroStop -from app.db.base import get_db -from app.pomodoro.service import PomodoroService - -router = APIRouter() - - -def _handle_value_error(exc: ValueError) -> HTTPException: - return HTTPException(status_code=400, detail=str(exc)) - - -@router.get("/status") -def get_status(db: Session = Depends(get_db)) -> dict: - return PomodoroService(db).get_status() - - -@router.post("/start") -def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict: - try: - return PomodoroService(db).start( - duration_min=payload.duration_min, - task_note=payload.task_note, - ) - except ValueError as exc: - raise _handle_value_error(exc) from exc - - -@router.post("/pause") -def pause_pomodoro(db: Session = Depends(get_db)) -> dict: - try: - return PomodoroService(db).pause() - except ValueError as exc: - raise _handle_value_error(exc) from exc - - -@router.post("/resume") -def resume_pomodoro(db: Session = Depends(get_db)) -> dict: - try: - return PomodoroService(db).resume() - except ValueError as exc: - raise _handle_value_error(exc) from exc - - -@router.post("/stop") -def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict: - try: - return PomodoroService(db).stop( - result=payload.result, - completed=payload.completed, - ) - except ValueError as exc: - raise _handle_value_error(exc) from exc - - -@router.get("/history") -def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]: - return PomodoroService(db).history(limit=limit) - - -@router.post("/work/start") -def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict: - try: - return PomodoroService(db).start_work( - duration_min=payload.duration_min, - task_note=payload.task_note, - ) - except ValueError as exc: - raise _handle_value_error(exc) from exc - - -@router.post("/break/short/start") -def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict: - try: - return PomodoroService(db).start_short_break(duration_min=duration_min) - except ValueError as exc: - raise _handle_value_error(exc) from exc - - -@router.post("/break/long/start") -def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict: - try: - return PomodoroService(db).start_long_break(duration_min=duration_min) - except ValueError as exc: - raise _handle_value_error(exc) from exc - - -@router.post("/cycle/reset") -def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db)) -> dict: - return PomodoroService(db).reset_cycle(clear_task=clear_task) - - -@router.post("/skip") -def skip_phase(db: Session = Depends(get_db)) -> dict: - try: - return PomodoroService(db).skip_phase() - except ValueError as exc: - raise _handle_value_error(exc) from exc +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.schemas import PomodoroStart, PomodoroStop +from app.auth.deps import get_current_user +from app.db.base import get_db +from app.db.models import User +from app.pomodoro.service import PomodoroService + +router = APIRouter() + + +def _handle_value_error(exc: ValueError) -> HTTPException: + return HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/status") +def get_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + return PomodoroService(db, user.id).get_status() + + +@router.post("/start") +def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + try: + return PomodoroService(db, user.id).start( + duration_min=payload.duration_min, + task_note=payload.task_note, + ) + except ValueError as exc: + raise _handle_value_error(exc) from exc + + +@router.post("/pause") +def pause_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + try: + return PomodoroService(db, user.id).pause() + except ValueError as exc: + raise _handle_value_error(exc) from exc + + +@router.post("/resume") +def resume_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + try: + return PomodoroService(db, user.id).resume() + except ValueError as exc: + raise _handle_value_error(exc) from exc + + +@router.post("/stop") +def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + try: + return PomodoroService(db, user.id).stop( + result=payload.result, + completed=payload.completed, + ) + except ValueError as exc: + raise _handle_value_error(exc) from exc + + +@router.get("/history") +def get_history(limit: int = 20, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]: + return PomodoroService(db, user.id).history(limit=limit) + + +@router.post("/work/start") +def start_work(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + try: + return PomodoroService(db, user.id).start_work( + duration_min=payload.duration_min, + task_note=payload.task_note, + ) + except ValueError as exc: + raise _handle_value_error(exc) from exc + + +@router.post("/break/short/start") +def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + try: + return PomodoroService(db, user.id).start_short_break(duration_min=duration_min) + except ValueError as exc: + raise _handle_value_error(exc) from exc + + +@router.post("/break/long/start") +def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + try: + return PomodoroService(db, user.id).start_long_break(duration_min=duration_min) + except ValueError as exc: + raise _handle_value_error(exc) from exc + + +@router.post("/cycle/reset") +def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + return PomodoroService(db, user.id).reset_cycle(clear_task=clear_task) + + +@router.post("/skip") +def skip_phase(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + try: + return PomodoroService(db, user.id).skip_phase() + except ValueError as exc: + raise _handle_value_error(exc) from exc diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index e4eb9a6..512ff5c 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -1,76 +1,78 @@ -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from app.db.base import get_db -from app.projects.service import ProjectService - -router = APIRouter() - - -class GiteaBinding(BaseModel): - gitea_owner: str = Field(min_length=1) - gitea_repo: str = Field(min_length=1) - default_branch: str = "main" - - -class WorkItemCreate(BaseModel): - text: str = Field(min_length=1) - project_slug: str | None = None - - -@router.get("/projects") -def list_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]: - return ProjectService(db).list_projects() - - -@router.post("/projects/sync-taiga") -def sync_taiga_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]: - try: - return ProjectService(db).sync_taiga_projects() - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.put("/projects/{taiga_slug}/gitea") -def bind_gitea( - taiga_slug: str, - payload: GiteaBinding, - db: Session = Depends(get_db), -) -> dict[str, Any]: - try: - return ProjectService(db).bind_gitea( - taiga_slug, - payload.gitea_owner, - payload.gitea_repo, - payload.default_branch, - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.post("/work-items") -async def create_work_item( - payload: WorkItemCreate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - try: - return await ProjectService(db).create_work_item( - payload.text, - project_slug=payload.project_slug, - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - -@router.get("/work-items") -def list_work_items( - limit: int = 30, - status: str | None = None, - db: Session = Depends(get_db), -) -> list[dict[str, Any]]: - return ProjectService(db).list_work_items(limit=limit, status=status) +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.deps import get_current_user +from app.db.base import get_db +from app.db.models import User +from app.projects.service import ProjectService + +router = APIRouter() + + +class GiteaBinding(BaseModel): + gitea_owner: str = Field(min_length=1) + gitea_repo: str = Field(min_length=1) + default_branch: str = "main" + + +class WorkItemCreate(BaseModel): + text: str = Field(min_length=1) + project_slug: str | None = None + + +@router.get("/projects") +def list_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]: + return ProjectService(db, user.id).list_projects() + + +@router.post("/projects/sync-taiga") +def sync_taiga_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]: + try: + return ProjectService(db, user.id).sync_taiga_projects() + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.put("/projects/{taiga_slug}/gitea") +def bind_gitea( + taiga_slug: str, + payload: GiteaBinding, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + try: + return ProjectService(db, user.id).bind_gitea( + taiga_slug, + payload.gitea_owner, + payload.gitea_repo, + payload.default_branch, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/work-items") +async def create_work_item( + payload: WorkItemCreate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + try: + return await ProjectService(db, user.id).create_work_item( + payload.text, + project_slug=payload.project_slug, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.get("/work-items") +def list_work_items( + limit: int = 30, + status: str | None = None, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> list[dict[str, Any]]: + return ProjectService(db, user.id).list_work_items(limit=limit, status=status) diff --git a/backend/app/api/routes/reminders.py b/backend/app/api/routes/reminders.py index f6ec114..ae09de2 100644 --- a/backend/app/api/routes/reminders.py +++ b/backend/app/api/routes/reminders.py @@ -1,124 +1,128 @@ -from datetime import datetime, timezone -from typing import Any -from zoneinfo import ZoneInfo - -from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from app.db.base import get_db -from app.homelab.context import resolve_timezone -from app.reminders.service import RemindersService - -router = APIRouter() - - -class ReminderCreate(BaseModel): - title: str = Field(min_length=1, max_length=255) - due_at: str = Field(description="ISO datetime, например 2027-05-12T12:16:00") - notes: str = "" - all_day: bool = False - recurrence: str = "none" - - -class ReminderUpdate(BaseModel): - title: str | None = Field(default=None, min_length=1, max_length=255) - due_at: str | None = None - notes: str | None = None - all_day: bool | None = None - recurrence: str | None = None - enabled: bool | None = None - - -@router.get("") -def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]: - return RemindersService(db).snapshot() - - -@router.get("/upcoming") -def list_upcoming( - limit: int = Query(30, ge=1, le=100), - db: Session = Depends(get_db), -) -> list[dict[str, Any]]: - return RemindersService(db).list_upcoming(limit=limit) - - -@router.get("/calendar") -def calendar( - year: int = Query(..., ge=2000, le=2100), - month: int = Query(..., ge=1, le=12), - db: Session = Depends(get_db), -) -> dict[str, Any]: - tz_name = resolve_timezone(db) - try: - tz = ZoneInfo(tz_name) - except Exception: - tz = ZoneInfo("Europe/Moscow") - - start = datetime(year, month, 1, tzinfo=tz) - if month == 12: - end = datetime(year + 1, 1, 1, tzinfo=tz) - else: - end = datetime(year, month + 1, 1, tzinfo=tz) - - service = RemindersService(db) - items = service.list_in_range( - date_from=start.astimezone(timezone.utc), - date_to=end.astimezone(timezone.utc), - ) - return { - "year": year, - "month": month, - "timezone": tz_name, - "reminders": items, - } - - -@router.post("") -def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db)) -> dict[str, Any]: - try: - return RemindersService(db).create( - title=payload.title, - due_at=payload.due_at, - notes=payload.notes, - all_day=payload.all_day, - recurrence=payload.recurrence, - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.patch("/{reminder_id}") -def update_reminder( - reminder_id: int, - payload: ReminderUpdate, - db: Session = Depends(get_db), -) -> dict[str, Any]: - try: - return RemindersService(db).update( - reminder_id, - title=payload.title, - due_at=payload.due_at, - notes=payload.notes, - all_day=payload.all_day, - recurrence=payload.recurrence, - enabled=payload.enabled, - ) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - - -@router.delete("/{reminder_id}") -def delete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: - try: - return RemindersService(db).delete(reminder_id) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - - -@router.post("/{reminder_id}/complete") -def complete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: - try: - return RemindersService(db).complete(reminder_id) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc +from datetime import datetime, timezone +from typing import Any +from zoneinfo import ZoneInfo + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.deps import get_current_user +from app.db.base import get_db +from app.db.models import User +from app.homelab.context import resolve_timezone +from app.reminders_scoped.service import RemindersService + +router = APIRouter() + + +class ReminderCreate(BaseModel): + title: str = Field(min_length=1, max_length=255) + due_at: str = Field(description="ISO datetime, например 2027-05-12T12:16:00") + notes: str = "" + all_day: bool = False + recurrence: str = "none" + + +class ReminderUpdate(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=255) + due_at: str | None = None + notes: str | None = None + all_day: bool | None = None + recurrence: str | None = None + enabled: bool | None = None + + +@router.get("") +def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + return RemindersService(db, user.id).snapshot() + + +@router.get("/upcoming") +def list_upcoming( + limit: int = Query(30, ge=1, le=100), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> list[dict[str, Any]]: + return RemindersService(db, user.id).list_upcoming(limit=limit) + + +@router.get("/calendar") +def calendar( + year: int = Query(..., ge=2000, le=2100), + month: int = Query(..., ge=1, le=12), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict[str, Any]: + tz_name = resolve_timezone(db, user.id) + try: + tz = ZoneInfo(tz_name) + except Exception: + tz = ZoneInfo("Europe/Moscow") + + start = datetime(year, month, 1, tzinfo=tz) + if month == 12: + end = datetime(year + 1, 1, 1, tzinfo=tz) + else: + end = datetime(year, month + 1, 1, tzinfo=tz) + + service = RemindersService(db, user.id) + items = service.list_in_range( + date_from=start.astimezone(timezone.utc), + date_to=end.astimezone(timezone.utc), + ) + return { + "year": year, + "month": month, + "timezone": tz_name, + "reminders": items, + } + + +@router.post("") +def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + try: + return RemindersService(db, user.id).create( + title=payload.title, + due_at=payload.due_at, + notes=payload.notes, + all_day=payload.all_day, + recurrence=payload.recurrence, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.patch("/{reminder_id}") +def update_reminder( + reminder_id: int, + payload: ReminderUpdate, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + try: + return RemindersService(db, user.id).update( + reminder_id, + title=payload.title, + due_at=payload.due_at, + notes=payload.notes, + all_day=payload.all_day, + recurrence=payload.recurrence, + enabled=payload.enabled, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.delete("/{reminder_id}") +def delete_reminder(reminder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + try: + return RemindersService(db, user.id).delete(reminder_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/{reminder_id}/complete") +def complete_reminder(reminder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + try: + return RemindersService(db, user.id).complete(reminder_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py new file mode 100644 index 0000000..1034fd6 --- /dev/null +++ b/backend/app/api/routes/settings.py @@ -0,0 +1,34 @@ +from typing import Any + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.deps import get_current_user +from app.db.base import get_db +from app.db.models import User +from app.settings.service import SETTING_KEYS, SettingsService + +router = APIRouter() + + +class SettingsPatch(BaseModel): + openrouter_model: str | None = None + memory_extract_model: str | None = None + openrouter_reasoning_effort: str | None = None + rag_enabled: bool | None = None + rag_top_k: int | None = Field(default=None, ge=1, le=50) + + +@router.get("/settings") +def get_settings_route(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + return SettingsService(db).snapshot() + + +@router.patch("/settings") +def patch_settings_route( + payload: SettingsPatch, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + updates = payload.model_dump(exclude_unset=True) + return SettingsService(db).patch(updates) diff --git a/backend/app/api/routes/shopping.py b/backend/app/api/routes/shopping.py index cd96f67..d220cc9 100644 --- a/backend/app/api/routes/shopping.py +++ b/backend/app/api/routes/shopping.py @@ -1,116 +1,118 @@ -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from app.db.base import get_db -from app.shopping.service import ShoppingService - -router = APIRouter() - - -class ListCreate(BaseModel): - name: str = Field(min_length=1, max_length=255) - - -class ListRename(BaseModel): - name: str = Field(min_length=1, max_length=255) - - -class ItemInput(BaseModel): - text: str = Field(min_length=1, max_length=500) - quantity: float | None = None - unit: str = "" - - -class ItemsAdd(BaseModel): - list_id: int | None = None - list_name: str | None = None - items: list[ItemInput] = Field(min_length=1) - - -class ItemChecked(BaseModel): - checked: bool - - -@router.get("") -def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]: - return ShoppingService(db).snapshot() - - -@router.get("/lists") -def list_lists(db: Session = Depends(get_db)) -> list[dict[str, Any]]: - return ShoppingService(db).list_lists(include_items=True) - - -@router.post("/lists") -def create_list(payload: ListCreate, db: Session = Depends(get_db)) -> dict[str, Any]: - try: - return ShoppingService(db).create_list(payload.name) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.get("/lists/{list_id}") -def get_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: - data = ShoppingService(db).get_list(list_id=list_id) - if not data: - raise HTTPException(status_code=404, detail="List not found") - return data - - -@router.patch("/lists/{list_id}") -def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db)) -> dict[str, Any]: - try: - return ShoppingService(db).rename_list(list_id, payload.name) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.delete("/lists/{list_id}") -def delete_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: - try: - return ShoppingService(db).delete_list(list_id) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - - -@router.post("/items") -def add_items(payload: ItemsAdd, db: Session = Depends(get_db)) -> dict[str, Any]: - try: - return ShoppingService(db).add_items( - [i.model_dump() for i in payload.items], - list_id=payload.list_id, - list_name=payload.list_name, - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.patch("/items/{item_id}") -def set_item_checked( - item_id: int, - payload: ItemChecked, - db: Session = Depends(get_db), -) -> dict[str, Any]: - try: - return ShoppingService(db).set_item_checked(item_id, payload.checked) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - - -@router.delete("/items/{item_id}") -def remove_item(item_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: - try: - return ShoppingService(db).remove_item(item_id) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - - -@router.post("/lists/{list_id}/clear-checked") -def clear_checked(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: - try: - return ShoppingService(db).clear_checked(list_id) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.deps import get_current_user +from app.db.base import get_db +from app.db.models import User +from app.shopping.service import ShoppingService + +router = APIRouter() + + +class ListCreate(BaseModel): + name: str = Field(min_length=1, max_length=255) + + +class ListRename(BaseModel): + name: str = Field(min_length=1, max_length=255) + + +class ItemInput(BaseModel): + text: str = Field(min_length=1, max_length=500) + quantity: float | None = None + unit: str = "" + + +class ItemsAdd(BaseModel): + list_id: int | None = None + list_name: str | None = None + items: list[ItemInput] = Field(min_length=1) + + +class ItemChecked(BaseModel): + checked: bool + + +@router.get("") +def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + return ShoppingService(db, user.id).snapshot() + + +@router.get("/lists") +def list_lists(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]: + return ShoppingService(db, user.id).list_lists(include_items=True) + + +@router.post("/lists") +def create_list(payload: ListCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + try: + return ShoppingService(db, user.id).create_list(payload.name) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/lists/{list_id}") +def get_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + data = ShoppingService(db, user.id).get_list(list_id=list_id) + if not data: + raise HTTPException(status_code=404, detail="List not found") + return data + + +@router.patch("/lists/{list_id}") +def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + try: + return ShoppingService(db, user.id).rename_list(list_id, payload.name) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.delete("/lists/{list_id}") +def delete_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + try: + return ShoppingService(db, user.id).delete_list(list_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/items") +def add_items(payload: ItemsAdd, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + try: + return ShoppingService(db, user.id).add_items( + [i.model_dump() for i in payload.items], + list_id=payload.list_id, + list_name=payload.list_name, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.patch("/items/{item_id}") +def set_item_checked( + item_id: int, + payload: ItemChecked, + db: Session = Depends(get_db), user: User = Depends(get_current_user), +) -> dict[str, Any]: + try: + return ShoppingService(db, user.id).set_item_checked(item_id, payload.checked) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.delete("/items/{item_id}") +def remove_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + try: + return ShoppingService(db, user.id).remove_item(item_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/lists/{list_id}/clear-checked") +def clear_checked(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: + try: + return ShoppingService(db, user.id).clear_checked(list_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/backend/app/api/routes/webhooks.py b/backend/app/api/routes/webhooks.py index 30392df..d1d8dc2 100644 --- a/backend/app/api/routes/webhooks.py +++ b/backend/app/api/routes/webhooks.py @@ -1,118 +1,106 @@ -import hashlib -import hmac -import json -import logging -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException, Request -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.config import get_settings -from app.db.base import SessionLocal, get_db -from app.db.models import ChatSession, Message, ProjectBinding -from app.projects.service import ProjectService - -router = APIRouter() -logger = logging.getLogger(__name__) - - -def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool: - if not secret: - return True - if not signature: - return False - if signature.startswith("sha256="): - signature = signature[7:] - expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() - return hmac.compare_digest(expected, signature) - - -def _post_close_notice(results: list[dict[str, Any]], owner: str, repo: str) -> None: - if not results: - return - db = SessionLocal() - try: - session = db.scalar( - select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1) - ) - if not session: - session = ChatSession(title="Git") - db.add(session) - db.commit() - db.refresh(session) - - lines = [f"🔀 **Push** `{owner}/{repo}`"] - for item in results: - if "closed" in item: - lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}") - elif "error" in item: - lines.append(f"- ошибка: {item['error']}") - - db.add(Message(session_id=session.id, role="notice", content="\n".join(lines))) - db.commit() - finally: - db.close() - - -@router.post("/webhooks/gitea") -async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]: - body = await request.body() - settings = get_settings() - signature = ( - request.headers.get("X-Gitea-Signature") - or request.headers.get("X-Gogs-Signature") - or request.headers.get("X-Hub-Signature-256") - ) - - if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret): - raise HTTPException(status_code=401, detail="Invalid webhook signature") - - payload = json.loads(body) - if payload.get("secret") and settings.gitea_webhook_secret: - if payload.get("secret") != settings.gitea_webhook_secret: - raise HTTPException(status_code=401, detail="Invalid webhook secret") - - event = request.headers.get("X-Gitea-Event", "") - if event != "push": - return {"ok": True, "skipped": event} - - repo = payload.get("repository", {}) - owner = repo.get("owner", {}).get("login", "") - repo_name = repo.get("name", "") - if not owner or not repo_name: - raise HTTPException(status_code=400, detail="Missing repository info") - - binding = db.scalar( - select(ProjectBinding).where( - ProjectBinding.gitea_owner == owner, - ProjectBinding.gitea_repo == repo_name, - ) - ) - if not binding: - return {"ok": True, "skipped": "unknown repo"} - - commits = list(payload.get("commits") or []) - if not commits: - head = payload.get("head_commit") - if head: - commits = [head] - - logger.info( - "Gitea push %s/%s ref=%s commits=%d", - owner, - repo_name, - payload.get("ref", ""), - len(commits), - ) - - service = ProjectService(db) - results = service.process_push(owner, repo_name, commits) - if results: - logger.info("Gitea push results: %s", results) - else: - logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name) - - _post_close_notice(results, owner, repo_name) - - return {"ok": True, "results": results, "commits_processed": len(commits)} +import hashlib +import hmac +import json +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.chat.notice_inbox import post_notice_to_latest_chat +from app.config import get_settings +from app.db.base import get_db +from app.db.models import ProjectBinding +from app.projects.service import ProjectService + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool: + if not secret: + return True + if not signature: + return False + if signature.startswith("sha256="): + signature = signature[7:] + expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature) + + +def _post_close_notice( + results: list[dict[str, Any]], owner: str, repo: str, user_id: int +) -> None: + if not results: + return + lines = [f"🔀 **Push** `{owner}/{repo}`"] + for item in results: + if "closed" in item: + lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}") + elif "error" in item: + lines.append(f"- ошибка: {item['error']}") + post_notice_to_latest_chat("\n".join(lines), user_id) + + +@router.post("/webhooks/gitea") +async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]: + body = await request.body() + settings = get_settings() + signature = ( + request.headers.get("X-Gitea-Signature") + or request.headers.get("X-Gogs-Signature") + or request.headers.get("X-Hub-Signature-256") + ) + + if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret): + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + payload = json.loads(body) + if payload.get("secret") and settings.gitea_webhook_secret: + if payload.get("secret") != settings.gitea_webhook_secret: + raise HTTPException(status_code=401, detail="Invalid webhook secret") + + event = request.headers.get("X-Gitea-Event", "") + if event != "push": + return {"ok": True, "skipped": event} + + repo = payload.get("repository", {}) + owner = repo.get("owner", {}).get("login", "") + repo_name = repo.get("name", "") + if not owner or not repo_name: + raise HTTPException(status_code=400, detail="Missing repository info") + + binding = db.scalar( + select(ProjectBinding).where( + ProjectBinding.gitea_owner == owner, + ProjectBinding.gitea_repo == repo_name, + ) + ) + if not binding: + return {"ok": True, "skipped": "unknown repo"} + + commits = list(payload.get("commits") or []) + if not commits: + head = payload.get("head_commit") + if head: + commits = [head] + + logger.info( + "Gitea push %s/%s ref=%s commits=%d", + owner, + repo_name, + payload.get("ref", ""), + len(commits), + ) + + service = ProjectService(db, binding.user_id) + results = service.process_push(owner, repo_name, commits) + if results: + logger.info("Gitea push results: %s", results) + else: + logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name) + + _post_close_notice(results, owner, repo_name, binding.user_id) + + return {"ok": True, "results": results, "commits_processed": len(commits)} diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..2ea25f6 --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1,5 @@ +from app.auth.deps import get_current_user +from app.auth.service import create_user, find_user_by_token +from app.auth.tokens import hash_token, verify_token + +__all__ = ["get_current_user", "hash_token", "verify_token"] diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py new file mode 100644 index 0000000..5771b47 --- /dev/null +++ b/backend/app/auth/deps.py @@ -0,0 +1,34 @@ +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth.tokens import hash_token +from app.db.base import get_db +from app.db.models import User + + +def _extract_token(request: Request) -> str | None: + auth = request.headers.get("Authorization", "") + if auth.lower().startswith("bearer "): + token = auth[7:].strip() + if token: + return token + header = request.headers.get("X-API-Token", "").strip() + return header or None + + +def get_current_user( + request: Request, + db: Session = Depends(get_db), +) -> User: + token = _extract_token(request) + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API token") + + token_hash = hash_token(token) + user = db.scalar( + select(User).where(User.api_token_hash == token_hash, User.is_active.is_(True)) + ) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API token") + return user diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py new file mode 100644 index 0000000..54c13d5 --- /dev/null +++ b/backend/app/auth/service.py @@ -0,0 +1,61 @@ +import secrets +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth.tokens import hash_token +from app.db.models import CharacterCard, User +from app.character.card import DEFAULT_CARD, normalize_card +import json + + +def find_user_by_token(db: Session, token: str) -> User | None: + token_hash = hash_token(token.strip()) + return db.scalar( + select(User).where(User.api_token_hash == token_hash, User.is_active.is_(True)) + ) + + +def user_to_dict(user: User) -> dict[str, Any]: + return { + "id": user.id, + "username": user.username, + "display_name": user.display_name or user.username, + } + + +def create_user( + db: Session, + *, + username: str, + display_name: str = "", + api_token: str | None = None, +) -> tuple[User, str]: + clean = username.strip().lower() + if not clean: + raise ValueError("username не может быть пустым") + existing = db.scalar(select(User).where(User.username == clean)) + if existing: + raise ValueError(f"Пользователь «{clean}» уже существует") + + plain_token = (api_token or "").strip() or secrets.token_urlsafe(32) + user = User( + username=clean, + display_name=(display_name or clean).strip(), + api_token_hash=hash_token(plain_token), + is_active=True, + ) + db.add(user) + db.flush() + + card = normalize_card(DEFAULT_CARD) + db.add( + CharacterCard( + user_id=user.id, + card_json=json.dumps(card, ensure_ascii=False), + ) + ) + db.commit() + db.refresh(user) + return user, plain_token diff --git a/backend/app/auth/tokens.py b/backend/app/auth/tokens.py new file mode 100644 index 0000000..41ea4b3 --- /dev/null +++ b/backend/app/auth/tokens.py @@ -0,0 +1,9 @@ +import hashlib + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def verify_token(plain: str, token_hash: str) -> bool: + return hash_token(plain) == token_hash diff --git a/backend/app/character/card.py b/backend/app/character/card.py index 6b62345..ff831f4 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -1,100 +1,100 @@ -from typing import Any - -TOOLS_INSTRUCTIONS = """ -Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс). -Обязательные правила: -- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент. -- Никогда не выдумывай статус таймера или список задач. -- После вызова инструмента кратко объясни результат пользователю по-человечески. -- Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break, - stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history. -- Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items. -- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga). -- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД). -- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. -- Фитнес: get_fitness_summary (date/days_ago), get_fitness_history, set_fitness_profile, log_meal, log_water, log_weight, log_workout, -- «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history. - calc_fitness_targets, lookup_food, lookup_exercise, set_fitness_reminder. -- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary. -- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай. -- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе. -- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools. -- Никогда не пиши «ожидаю ответа от системы». -- В текстовых ответах пользователю не используй эмодзи. -- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай. -- Утренний брифинг (погода + новости) → get_morning_briefing. -- Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй. -- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list. -- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки. -- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder. -- «Напомни через 15 минут», «завтра утром», «12 мая в 9:00» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]). -- День рождения, Новый год и другие праздники → recurrence yearly. -- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе. -""".strip() - -DEFAULT_CARD: dict[str, Any] = { - "spec": "chara_card_v2", - "spec_version": "2.0", - "data": { - "name": "Домашний ассистент", - "description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.", - "personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.", - "scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.", - "first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?", - "mes_example": "", - "system_prompt": "", - "post_history_instructions": "", - "alternate_greetings": [], - "tags": ["assistant", "home", "pomodoro"], - "creator": "", - "creator_notes": "", - "character_version": "1.0", - "appearance_tags": "", - "appearance_prose": "", - "lora_name": "", - "lora_weight": 0.8, - "rp_persona_id": "", - "sd_enabled": True, - }, -} - - -def normalize_card(raw: dict[str, Any]) -> dict[str, Any]: - if "data" in raw and isinstance(raw["data"], dict): - card = { - "spec": raw.get("spec", "chara_card_v2"), - "spec_version": raw.get("spec_version", "2.0"), - "data": {**DEFAULT_CARD["data"], **raw["data"]}, - } - return card - - if "name" in raw or "description" in raw: - return { - "spec": "chara_card_v2", - "spec_version": "2.0", - "data": {**DEFAULT_CARD["data"], **raw}, - } - - return DEFAULT_CARD.copy() - - -def build_system_prompt(card: dict[str, Any]) -> str: - data = card.get("data", {}) - parts: list[str] = [] - - name = data.get("name", "Ассистент") - parts.append(f"Ты — {name}.") - - if data.get("system_prompt"): - parts.append(data["system_prompt"]) - if data.get("description"): - parts.append(data["description"]) - if data.get("personality"): - parts.append(f"Характер: {data['personality']}") - if data.get("scenario"): - parts.append(f"Сценарий: {data['scenario']}") - if data.get("post_history_instructions"): - parts.append(data["post_history_instructions"]) - - parts.append(TOOLS_INSTRUCTIONS) - return "\n\n".join(part for part in parts if part.strip()) +from typing import Any + +TOOLS_INSTRUCTIONS = """ +Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс). +Обязательные правила: +- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент. +- Никогда не выдумывай статус таймера или список задач. +- После вызова инструмента кратко объясни результат пользователю по-человечески. +- Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break, + stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history. +- Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items. +- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga). +- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД). +- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. +- Фитнес: get_fitness_summary (date/days_ago), get_fitness_history, set_fitness_profile, log_meal, log_water, log_weight (neck_cm/waist_cm/hip_cm → Navy), log_workout, +- «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history. + calc_fitness_targets, calc_body_composition (расчёт Navy/WHR/LBM/FFMI без записи), lookup_food, lookup_exercise, set_fitness_reminder. +- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary. +- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай. +- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе. +- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools. +- Никогда не пиши «ожидаю ответа от системы». +- В текстовых ответах пользователю не используй эмодзи. +- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай. +- Утренний брифинг (погода + новости) → get_morning_briefing. +- Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй. +- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list. +- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки. +- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder. +- «Напомни через 15 минут», «завтра утром», «12 мая в 9:00» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]). +- День рождения, Новый год и другие праздники → recurrence yearly. +- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе. +""".strip() + +DEFAULT_CARD: dict[str, Any] = { + "spec": "chara_card_v2", + "spec_version": "2.0", + "data": { + "name": "Домашний ассистент", + "description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.", + "personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.", + "scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.", + "first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?", + "mes_example": "", + "system_prompt": "", + "post_history_instructions": "", + "alternate_greetings": [], + "tags": ["assistant", "home", "pomodoro"], + "creator": "", + "creator_notes": "", + "character_version": "1.0", + "appearance_tags": "", + "appearance_prose": "", + "lora_name": "", + "lora_weight": 0.8, + "rp_persona_id": "", + "sd_enabled": True, + }, +} + + +def normalize_card(raw: dict[str, Any]) -> dict[str, Any]: + if "data" in raw and isinstance(raw["data"], dict): + card = { + "spec": raw.get("spec", "chara_card_v2"), + "spec_version": raw.get("spec_version", "2.0"), + "data": {**DEFAULT_CARD["data"], **raw["data"]}, + } + return card + + if "name" in raw or "description" in raw: + return { + "spec": "chara_card_v2", + "spec_version": "2.0", + "data": {**DEFAULT_CARD["data"], **raw}, + } + + return DEFAULT_CARD.copy() + + +def build_system_prompt(card: dict[str, Any]) -> str: + data = card.get("data", {}) + parts: list[str] = [] + + name = data.get("name", "Ассистент") + parts.append(f"Ты — {name}.") + + if data.get("system_prompt"): + parts.append(data["system_prompt"]) + if data.get("description"): + parts.append(data["description"]) + if data.get("personality"): + parts.append(f"Характер: {data['personality']}") + if data.get("scenario"): + parts.append(f"Сценарий: {data['scenario']}") + if data.get("post_history_instructions"): + parts.append(data["post_history_instructions"]) + + parts.append(TOOLS_INSTRUCTIONS) + return "\n\n".join(part for part in parts if part.strip()) diff --git a/backend/app/character/service.py b/backend/app/character/service.py index 37fe26e..e6bd2a5 100644 --- a/backend/app/character/service.py +++ b/backend/app/character/service.py @@ -1,27 +1,43 @@ -import json -from pathlib import Path -from typing import Any - -from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card - -CARD_PATH = Path("./data/character.json") - - -class CharacterService: - def get_card(self) -> dict[str, Any]: - if CARD_PATH.is_file(): - try: - raw = json.loads(CARD_PATH.read_text(encoding="utf-8")) - return normalize_card(raw) - except (json.JSONDecodeError, OSError): - pass - return normalize_card(DEFAULT_CARD) - - def save_card(self, raw: dict[str, Any]) -> dict[str, Any]: - card = normalize_card(raw) - CARD_PATH.parent.mkdir(parents=True, exist_ok=True) - CARD_PATH.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8") - return card - - def get_system_prompt(self) -> str: - return build_system_prompt(self.get_card()) +import json +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card +from app.db.models import CharacterCard + + +class CharacterService: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + + def get_card(self) -> dict[str, Any]: + row = self.db.scalar( + select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1) + ) + if not row: + return normalize_card(DEFAULT_CARD) + try: + return normalize_card(json.loads(row.card_json or "{}")) + except json.JSONDecodeError: + return normalize_card(DEFAULT_CARD) + + def save_card(self, raw: dict[str, Any]) -> dict[str, Any]: + card = normalize_card(raw) + row = self.db.scalar( + select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1) + ) + if not row: + row = CharacterCard(user_id=self.user_id, card_json="{}") + self.db.add(row) + self.db.flush() + row.card_json = json.dumps(card, ensure_ascii=False) + row.updated_at = datetime.now(timezone.utc) + self.db.commit() + return card + + def get_system_prompt(self) -> str: + return build_system_prompt(self.get_card()) diff --git a/backend/app/chat/generation.py b/backend/app/chat/generation.py new file mode 100644 index 0000000..273c465 --- /dev/null +++ b/backend/app/chat/generation.py @@ -0,0 +1,95 @@ +import asyncio +import logging +from dataclasses import dataclass, field + +from app.chat.service import ChatService +from app.db.base import SessionLocal + +logger = logging.getLogger(__name__) + + +class GenerationBusyError(Exception): + """Сессия уже генерирует ответ.""" + + +@dataclass +class GenerationHandle: + session_id: int + user_id: int + user_text: str + task: asyncio.Task | None = None + subscribers: list[asyncio.Queue[str | None]] = field(default_factory=list) + _lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + async def broadcast(self, chunk: str | None) -> None: + async with self._lock: + targets = list(self.subscribers) + for queue in targets: + try: + queue.put_nowait(chunk) + except asyncio.QueueFull: + logger.debug("generation queue full for session=%s, dropping subscriber", self.session_id) + + def add_subscriber(self) -> asyncio.Queue[str | None]: + queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=512) + self.subscribers.append(queue) + return queue + + def remove_subscriber(self, queue: asyncio.Queue[str | None]) -> None: + if queue in self.subscribers: + self.subscribers.remove(queue) + + +_registry: dict[int, GenerationHandle] = {} +_registry_lock = asyncio.Lock() + + +def is_generation_active(session_id: int) -> bool: + return session_id in _registry + + +def get_active_handle(session_id: int) -> GenerationHandle | None: + return _registry.get(session_id) + + +async def _run_generation(handle: GenerationHandle) -> None: + db = SessionLocal() + try: + service = ChatService(db, handle.user_id) + async for chunk in service.stream_response( + handle.session_id, + handle.user_text, + user_message_saved=True, + ): + await handle.broadcast(chunk) + except Exception as exc: + logger.exception("Background generation failed session=%s", handle.session_id) + await handle.broadcast(ChatService._sse("error", {"message": str(exc)})) + finally: + await handle.broadcast(None) + db.close() + async with _registry_lock: + if _registry.get(handle.session_id) is handle: + _registry.pop(handle.session_id, None) + + +async def start_generation(session_id: int, user_id: int, user_text: str) -> GenerationHandle: + async with _registry_lock: + if session_id in _registry: + raise GenerationBusyError() + handle = GenerationHandle(session_id=session_id, user_id=user_id, user_text=user_text) + _registry[session_id] = handle + handle.task = asyncio.create_task(_run_generation(handle)) + return handle + + +async def subscribe_generation(handle: GenerationHandle): + queue = handle.add_subscriber() + try: + while True: + chunk = await queue.get() + if chunk is None: + break + yield chunk + finally: + handle.remove_subscriber(queue) diff --git a/backend/app/chat/notice_inbox.py b/backend/app/chat/notice_inbox.py index 6a3494b..32ba391 100644 --- a/backend/app/chat/notice_inbox.py +++ b/backend/app/chat/notice_inbox.py @@ -1,44 +1,47 @@ -"""Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю).""" - -from sqlalchemy import select - -from app.db.base import SessionLocal -from app.db.models import ChatSession, Message - -DISPLAY_ONLY_ROLES = frozenset({"notice", "character"}) - - -def _latest_chat_session(db) -> ChatSession: - session = db.scalar( - select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1) - ) - if not session: - session = ChatSession(title="Уведомления") - db.add(session) - db.commit() - db.refresh(session) - return session - - -def post_notice_to_latest_chat(content: str) -> int | None: - """Сохраняет notice в последний активный чат. Возвращает session_id.""" - db = SessionLocal() - try: - session = _latest_chat_session(db) - db.add(Message(session_id=session.id, role="notice", content=content)) - db.commit() - return session.id - finally: - db.close() - - -def post_character_comment_to_latest_chat(content: str) -> int | None: - """Реплика персонажа в UI; не попадает в контекст LLM (в отличие от assistant).""" - db = SessionLocal() - try: - session = _latest_chat_session(db) - db.add(Message(session_id=session.id, role="character", content=content)) - db.commit() - return session.id - finally: - db.close() +"""Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю).""" + +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import ChatSession, Message + +DISPLAY_ONLY_ROLES = frozenset({"notice", "character"}) + + +def _latest_chat_session(db, user_id: int) -> ChatSession: + session = db.scalar( + select(ChatSession) + .where(ChatSession.user_id == user_id) + .order_by(ChatSession.updated_at.desc()) + .limit(1) + ) + if not session: + session = ChatSession(user_id=user_id, title="Уведомления") + db.add(session) + db.commit() + db.refresh(session) + return session + + +def post_notice_to_latest_chat(content: str, user_id: int) -> int | None: + """Сохраняет notice в последний активный чат пользователя. Возвращает session_id.""" + db = SessionLocal() + try: + session = _latest_chat_session(db, user_id) + db.add(Message(session_id=session.id, role="notice", content=content)) + db.commit() + return session.id + finally: + db.close() + + +def post_character_comment_to_latest_chat(content: str, user_id: int) -> int | None: + """Реплика персонажа в UI; не попадает в контекст LLM (в отличие от assistant).""" + db = SessionLocal() + try: + session = _latest_chat_session(db, user_id) + db.add(Message(session_id=session.id, role="character", content=content)) + db.commit() + return session.id + finally: + db.close() diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py index 5606931..15c3732 100644 --- a/backend/app/chat/notices.py +++ b/backend/app/chat/notices.py @@ -1,397 +1,432 @@ -import json -from typing import Any - -from app.db.models import PomodoroSession -from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK - -PHASE_LABELS = { - PHASE_WORK: "Работа", - PHASE_SHORT_BREAK: "Короткий перерыв", - PHASE_LONG_BREAK: "Длинный перерыв", -} - - -def _format_time(seconds: int) -> str: - minutes, secs = divmod(max(0, seconds), 60) - return f"{minutes:02d}:{secs:02d}" - - -def format_phase_completed_notice( - session: PomodoroSession, - next_phase: str | None, -) -> str: - phase_label = PHASE_LABELS.get(session.phase, session.phase) - task = session.task_note or "без описания" - lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"] - - if next_phase == PHASE_SHORT_BREAK: - lines.append("Дальше: короткий перерыв ☕") - elif next_phase == PHASE_LONG_BREAK: - lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён") - elif next_phase == PHASE_WORK: - lines.append("Дальше: снова работа 💪") - else: - lines.append("Цикл сброшен. Можно отдохнуть и начать заново.") - - 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", - "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", - "recall_memories", - "get_fitness_summary", - "get_fitness_history", - "lookup_food", - "lookup_exercise", - "calc_fitness_targets", - "get_weather", - "get_morning_briefing", - "list_shopping_lists", - "list_reminders", -}) - - -def format_tool_notice(tool_name: str, raw_result: str) -> str | None: - if tool_name in TOOLS_SKIP_CHAT_NOTICE: - return None - - try: - data = json.loads(raw_result) - except json.JSONDecodeError: - return None - - if isinstance(data, dict) and "error" in data: - if tool_name in POMODORO_TOOL_NAMES: - prefix = "⏱" - elif tool_name in MEMORY_TOOL_NAMES: - prefix = "🧠" - elif tool_name in FITNESS_TOOL_NAMES: - prefix = "💪" - elif tool_name in SHOPPING_TOOL_NAMES: - prefix = "🛒" - elif tool_name in REMINDER_TOOL_NAMES: - prefix = "📅" - else: - prefix = "📋" - return f"{prefix} {data['error']}" - - if tool_name == "reset_pomodoro_cycle": - cycle = data.get("cycle", data) - return ( - "⏱ **Цикл помидоро сброшен** · " - f"прогресс: {cycle.get('completed_work_sessions', 0)}/" - f"{cycle.get('sessions_until_long_break', 4)}" - ) - - if tool_name in ( - "get_pomodoro_status", - "start_pomodoro", - "start_work", - "start_short_break", - "start_long_break", - "stop_pomodoro", - "skip_pomodoro_phase", - ): - return _format_status_notice(data) - - if tool_name == "get_pomodoro_history": - return _format_history_notice(data) - - if tool_name == "create_work_item": - return _format_work_item_notice(data) - - if tool_name == "list_work_items": - return _format_work_items_list_notice(data) - - if tool_name == "list_taiga_tasks": - return _format_taiga_tasks_notice(data) - - if tool_name == "sync_taiga_projects": - return f"📋 Синхронизировано проектов Taiga: **{len(data)}**" - - if tool_name == "list_taiga_projects": - if not isinstance(data, list) or not data: - return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects." - lines = ["📋 **Проекты:**"] - for p in data: - gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else "—" - lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}") - return "\n".join(lines) - - if tool_name == "remember_fact" and data.get("ok"): - action = "обновлено" if data.get("action") == "updated" else "сохранено" - return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}" - - if tool_name == "forget_memory" and data.get("ok"): - return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}" - - if tool_name == "update_profile" and data.get("ok"): - profile = data.get("profile") or {} - parts = [f"{k}={v}" for k, v in profile.items() if v] - return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}" - - if tool_name == "update_session_summary" and data.get("ok"): - return "🧠 **Сводка чата сохранена**" - - if tool_name == "log_meal" and data.get("ok"): - meal = data.get("meal", {}) - est = "≈" if meal.get("estimated") else "" - return ( - f"💪 **Приём пищи** · {meal.get('description')} · " - f"{est}{meal.get('calories', 0):.0f} ккал " - f"(Б{meal.get('protein_g', 0):.0f}/Ж{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})" - ) - - if tool_name == "log_water" and data.get("ok"): - w = data.get("water", {}) - return f"💪 **Вода** +{w.get('amount_ml')} мл" - - if tool_name == "log_weight" and data.get("ok"): - m = data.get("metric", {}) - return f"💪 **Вес** {m.get('weight_kg')} кг" - - if tool_name == "log_workout" and data.get("ok"): - wo = data.get("workout", {}) - return f"💪 **Тренировка** · {wo.get('title')}" - - if tool_name == "set_fitness_profile" and data.get("ok"): - p = data.get("profile", {}) - return ( - f"💪 **Профиль** · {p.get('calorie_target')} ккал, " - f"вода {p.get('water_l')} л" - ) - - if tool_name == "set_fitness_reminder" and data.get("ok"): - r = data.get("reminder", {}) - state = "вкл" if r.get("enabled") else "выкл" - return f"💪 **Напоминание {r.get('kind')}** · {state}" - - if tool_name == "generate_image" and data.get("ok"): - url = data.get("url", "") - return f"🎨 **Картинка готова**\n\n![image]({url})" - - if tool_name == "create_shopping_list" and data.get("ok"): - lst = data.get("list") or {} - action = "создан" if data.get("created") else "уже был" - return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})" - - if tool_name == "add_shopping_items" and data.get("ok"): - added = data.get("added") or [] - names = ", ".join(i.get("text", "") for i in added[:5]) - extra = f" +{len(added) - 5}" if len(added) > 5 else "" - return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}" - - if tool_name == "check_shopping_item" and data.get("ok"): - item = data.get("item") or {} - state = "куплено" if item.get("checked") else "снята отметка" - return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}" - - if tool_name == "remove_shopping_item" and data.get("ok"): - removed = data.get("removed") or {} - return f"🛒 **Удалено** · {removed.get('text')}" - - if tool_name == "delete_shopping_list" and data.get("ok"): - return f"🛒 **Список удалён** · «{data.get('name')}»" - - if tool_name == "create_reminder" and data.get("ok"): - r = data.get("reminder") or {} - rec = r.get("recurrence", "none") - rec_label = f" · повтор {rec}" if rec and rec != "none" else "" - return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}" - - if tool_name == "update_reminder" and data.get("ok"): - r = data.get("reminder") or {} - return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}" - - if tool_name == "delete_reminder" and data.get("ok"): - return f"📅 **Напоминание удалено** · «{data.get('title')}»" - - if tool_name == "complete_reminder" and data.get("ok"): - r = data.get("reminder") or {} - return f"📅 **Готово** · {r.get('title')}" - - return None - - -def _format_work_item_notice(data: dict[str, Any]) -> str | None: - if data.get("error"): - return f"📋 {data['error']}" - if not data.get("ok"): - return None - taiga = data.get("taiga", {}) - gitea = data.get("gitea", {}) - lines = [ - "📋 **Создано:**", - f"- Taiga: #{taiga.get('ref')} — {taiga.get('subject')}", - f"- URL: {taiga.get('url')}", - ] - if gitea.get("url"): - lines.append(f"- Gitea: {gitea.get('url')}") - if data.get("branch"): - lines.append(f"- Ветка: `{data['branch']}`") - subtasks = data.get("subtasks") or [] - if subtasks: - lines.append("**Подзадачи:**") - for t in subtasks: - lines.append(f"- #{t.get('ref')} {t.get('subject')}") - return "\n".join(lines) - - -def _format_work_items_list_notice(data: Any) -> str | None: - if not isinstance(data, list) or not data: - return "📋 Локальных work items (созданных ассистентом) нет." - lines = ["📋 **Work items ассистента:**"] - for item in data[:15]: - lines.append( - f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} " - f"({item.get('taiga_slug')})" - ) - return "\n".join(lines) - - -def _format_taiga_tasks_notice(data: Any) -> str | None: - if not isinstance(data, dict): - return None - if data.get("error"): - return f"📋 {data['error']}" - - blocks = data.get("projects") or [] - total_stories = data.get("total_stories", 0) - total_tasks = data.get("total_tasks", 0) - - if not blocks or (total_stories == 0 and total_tasks == 0): - slug = blocks[0].get("slug") if len(blocks) == 1 else None - if slug: - return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga." - return "📋 Открытых задач в Taiga не найдено." - - lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"] - for block in blocks: - stories = block.get("stories") or [] - tasks = block.get("tasks") or [] - if not stories and not tasks: - continue - lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):") - for s in stories: - lines.append(f"- story #{s.get('ref')} {s.get('subject')}") - for t in tasks: - lines.append(f"- task #{t.get('ref')} {t.get('subject')}") - return "\n".join(lines) - - -def _format_status_notice(data: dict[str, Any]) -> str: - status = data.get("status", "idle") - phase = data.get("phase", PHASE_WORK) - phase_label = PHASE_LABELS.get(phase, phase) - task = data.get("task_note") or "без описания" - remaining = data.get("remaining_seconds", 0) - duration = data.get("duration_min", 25) - cycle = data.get("cycle", {}) - cycle_info = "" - if cycle: - cycle_info = ( - f" · цикл {cycle.get('completed_work_sessions', 0)}/" - f"{cycle.get('sessions_until_long_break', 4)}" - ) - - if status == "idle": - return f"⏱ **Помидоро:** таймер не запущен{cycle_info}." - - if status == "running": - return ( - f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** " - f"из {duration} мин · _{task}_{cycle_info}" - ) - - if status == "paused": - elapsed = data.get("elapsed_seconds", 0) - return ( - f"⏱ **{phase_label} на паузе** · прошло {_format_time(elapsed)} " - f"из {duration} мин · _{task}_{cycle_info}" - ) - - if status == "completed": - return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_" - - if status == "cancelled": - return f"⏱ **{phase_label} отменена** · _{task}_" - - return f"⏱ Помидоро: {status}" - - -def _format_history_notice(data: Any) -> str: - if not isinstance(data, list) or not data: - return "⏱ **История помидоро** пуста." - - lines = ["⏱ **История помидоро:**"] - for item in data[:10]: - task = item.get("task_note") or "без описания" - phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?")) - duration = item.get("duration_min", "?") - lines.append(f"- {phase}: {task} ({duration} мин)") - - return "\n".join(lines) - - -def format_pomodoro_context(status: dict[str, Any]) -> str: - notice = _format_status_notice(status) - cycle = status.get("cycle", {}) - extra = "" - if cycle: - extra = ( - f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, " - f"перерыв {cycle.get('short_break_min')} мин, " - f"длинный {cycle.get('long_break_min')} мин." - ) - return f"[Актуальный статус помидоро]\n{notice}{extra}" +import json +from typing import Any + +from app.db.models import PomodoroSession +from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK + +PHASE_LABELS = { + PHASE_WORK: "Работа", + PHASE_SHORT_BREAK: "Короткий перерыв", + PHASE_LONG_BREAK: "Длинный перерыв", +} + + +def _format_time(seconds: int) -> str: + minutes, secs = divmod(max(0, seconds), 60) + return f"{minutes:02d}:{secs:02d}" + + +def format_phase_completed_notice( + session: PomodoroSession, + next_phase: str | None, +) -> str: + phase_label = PHASE_LABELS.get(session.phase, session.phase) + task = session.task_note or "без описания" + lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"] + + if next_phase == PHASE_SHORT_BREAK: + lines.append("Дальше: короткий перерыв ☕") + elif next_phase == PHASE_LONG_BREAK: + lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён") + elif next_phase == PHASE_WORK: + lines.append("Дальше: снова работа 💪") + else: + lines.append("Цикл сброшен. Можно отдохнуть и начать заново.") + + 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", + "recall_memories", + "get_fitness_summary", + "get_fitness_history", + "lookup_food", + "lookup_exercise", + "calc_fitness_targets", + "calc_body_composition", + "get_weather", + "get_morning_briefing", + "list_shopping_lists", + "list_reminders", +}) + + + +def _format_body_composition_notice(computed: dict[str, Any], *, headline: str) -> str: + parts: list[str] = [] + bf = computed.get("body_fat_pct") + if bf is not None: + method = computed.get("body_fat_method") + if method == "navy": + parts.append(f"жир ≈{bf}% (Navy)") + elif method == "manual": + parts.append(f"жир {bf}%") + else: + parts.append(f"жир ≈{bf}%") + if computed.get("whr") is not None: + parts.append(f"WHR {computed.get('whr')}") + if computed.get("ffmi") is not None: + parts.append(f"FFMI {computed.get('ffmi')}") + if parts: + return f"{headline} — {', '.join(parts)}" + return headline + +def format_tool_notice(tool_name: str, raw_result: str) -> str | None: + if tool_name in TOOLS_SKIP_CHAT_NOTICE: + return None + + try: + data = json.loads(raw_result) + except json.JSONDecodeError: + return None + + if isinstance(data, dict) and "error" in data: + if tool_name in POMODORO_TOOL_NAMES: + prefix = "⏱" + elif tool_name in MEMORY_TOOL_NAMES: + prefix = "🧠" + elif tool_name in FITNESS_TOOL_NAMES: + prefix = "💪" + elif tool_name in SHOPPING_TOOL_NAMES: + prefix = "🛒" + elif tool_name in REMINDER_TOOL_NAMES: + prefix = "📅" + else: + prefix = "📋" + return f"{prefix} {data['error']}" + + if tool_name == "reset_pomodoro_cycle": + cycle = data.get("cycle", data) + return ( + "⏱ **Цикл помидоро сброшен** · " + f"прогресс: {cycle.get('completed_work_sessions', 0)}/" + f"{cycle.get('sessions_until_long_break', 4)}" + ) + + if tool_name in ( + "get_pomodoro_status", + "start_pomodoro", + "start_work", + "start_short_break", + "start_long_break", + "stop_pomodoro", + "skip_pomodoro_phase", + ): + return _format_status_notice(data) + + if tool_name == "get_pomodoro_history": + return _format_history_notice(data) + + if tool_name == "create_work_item": + return _format_work_item_notice(data) + + if tool_name == "list_work_items": + return _format_work_items_list_notice(data) + + if tool_name == "list_taiga_tasks": + return _format_taiga_tasks_notice(data) + + if tool_name == "sync_taiga_projects": + return f"📋 Синхронизировано проектов Taiga: **{len(data)}**" + + if tool_name == "list_taiga_projects": + if not isinstance(data, list) or not data: + return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects." + lines = ["📋 **Проекты:**"] + for p in data: + gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else "—" + lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}") + return "\n".join(lines) + + if tool_name == "remember_fact" and data.get("ok"): + action = "обновлено" if data.get("action") == "updated" else "сохранено" + return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}" + + if tool_name == "forget_memory" and data.get("ok"): + return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}" + + if tool_name == "update_profile" and data.get("ok"): + profile = data.get("profile") or {} + parts = [f"{k}={v}" for k, v in profile.items() if v] + return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}" + + if tool_name == "update_session_summary" and data.get("ok"): + return "🧠 **Сводка чата сохранена**" + + if tool_name == "log_meal" and data.get("ok"): + meal = data.get("meal", {}) + est = "≈" if meal.get("estimated") else "" + return ( + f"💪 **Приём пищи** · {meal.get('description')} · " + f"{est}{meal.get('calories', 0):.0f} ккал " + f"(Б{meal.get('protein_g', 0):.0f}/Ж{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})" + ) + + if tool_name == "log_water" and data.get("ok"): + w = data.get("water", {}) + return f"💪 **Вода** +{w.get('amount_ml')} мл" + + if tool_name == "log_weight" and data.get("ok"): + m = data.get("metric", {}) + computed = data.get("computed") or {} + headline = f"💪 **Вес** {m.get('weight_kg')} кг" + return _format_body_composition_notice(computed, headline=headline) + + if tool_name == "calc_body_composition" and isinstance(data, dict) and "error" not in data: + w = data.get("weight_kg") + headline = "💪 **Состав тела** (расчёт)" + if w is not None: + headline += f" · {w} кг" + msg = _format_body_composition_notice(data, headline=headline) + warnings = data.get("warnings") or [] + if warnings: + msg += f" · {'; '.join(warnings[:2])}" + return msg + + if tool_name == "log_workout" and data.get("ok"): + wo = data.get("workout", {}) + return f"💪 **Тренировка** · {wo.get('title')}" + + if tool_name == "set_fitness_profile" and data.get("ok"): + p = data.get("profile", {}) + return ( + f"💪 **Профиль** · {p.get('calorie_target')} ккал, " + f"вода {p.get('water_l')} л" + ) + + if tool_name == "set_fitness_reminder" and data.get("ok"): + r = data.get("reminder", {}) + state = "вкл" if r.get("enabled") else "выкл" + return f"💪 **Напоминание {r.get('kind')}** · {state}" + + if tool_name == "generate_image" and data.get("ok"): + url = data.get("url", "") + return f"🎨 **Картинка готова**\n\n![image]({url})" + + if tool_name == "create_shopping_list" and data.get("ok"): + lst = data.get("list") or {} + action = "создан" if data.get("created") else "уже был" + return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})" + + if tool_name == "add_shopping_items" and data.get("ok"): + added = data.get("added") or [] + names = ", ".join(i.get("text", "") for i in added[:5]) + extra = f" +{len(added) - 5}" if len(added) > 5 else "" + return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}" + + if tool_name == "check_shopping_item" and data.get("ok"): + item = data.get("item") or {} + state = "куплено" if item.get("checked") else "снята отметка" + return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}" + + if tool_name == "remove_shopping_item" and data.get("ok"): + removed = data.get("removed") or {} + return f"🛒 **Удалено** · {removed.get('text')}" + + if tool_name == "delete_shopping_list" and data.get("ok"): + return f"🛒 **Список удалён** · «{data.get('name')}»" + + if tool_name == "create_reminder" and data.get("ok"): + r = data.get("reminder") or {} + rec = r.get("recurrence", "none") + rec_label = f" · повтор {rec}" if rec and rec != "none" else "" + return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}" + + if tool_name == "update_reminder" and data.get("ok"): + r = data.get("reminder") or {} + return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}" + + if tool_name == "delete_reminder" and data.get("ok"): + return f"📅 **Напоминание удалено** · «{data.get('title')}»" + + if tool_name == "complete_reminder" and data.get("ok"): + r = data.get("reminder") or {} + return f"📅 **Готово** · {r.get('title')}" + + return None + + +def _format_work_item_notice(data: dict[str, Any]) -> str | None: + if data.get("error"): + return f"📋 {data['error']}" + if not data.get("ok"): + return None + taiga = data.get("taiga", {}) + gitea = data.get("gitea", {}) + lines = [ + "📋 **Создано:**", + f"- Taiga: #{taiga.get('ref')} — {taiga.get('subject')}", + f"- URL: {taiga.get('url')}", + ] + if gitea.get("url"): + lines.append(f"- Gitea: {gitea.get('url')}") + if data.get("branch"): + lines.append(f"- Ветка: `{data['branch']}`") + subtasks = data.get("subtasks") or [] + if subtasks: + lines.append("**Подзадачи:**") + for t in subtasks: + lines.append(f"- #{t.get('ref')} {t.get('subject')}") + return "\n".join(lines) + + +def _format_work_items_list_notice(data: Any) -> str | None: + if not isinstance(data, list) or not data: + return "📋 Локальных work items (созданных ассистентом) нет." + lines = ["📋 **Work items ассистента:**"] + for item in data[:15]: + lines.append( + f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} " + f"({item.get('taiga_slug')})" + ) + return "\n".join(lines) + + +def _format_taiga_tasks_notice(data: Any) -> str | None: + if not isinstance(data, dict): + return None + if data.get("error"): + return f"📋 {data['error']}" + + blocks = data.get("projects") or [] + total_stories = data.get("total_stories", 0) + total_tasks = data.get("total_tasks", 0) + + if not blocks or (total_stories == 0 and total_tasks == 0): + slug = blocks[0].get("slug") if len(blocks) == 1 else None + if slug: + return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga." + return "📋 Открытых задач в Taiga не найдено." + + lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"] + for block in blocks: + stories = block.get("stories") or [] + tasks = block.get("tasks") or [] + if not stories and not tasks: + continue + lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):") + for s in stories: + lines.append(f"- story #{s.get('ref')} {s.get('subject')}") + for t in tasks: + lines.append(f"- task #{t.get('ref')} {t.get('subject')}") + return "\n".join(lines) + + +def _format_status_notice(data: dict[str, Any]) -> str: + status = data.get("status", "idle") + phase = data.get("phase", PHASE_WORK) + phase_label = PHASE_LABELS.get(phase, phase) + task = data.get("task_note") or "без описания" + remaining = data.get("remaining_seconds", 0) + duration = data.get("duration_min", 25) + cycle = data.get("cycle", {}) + cycle_info = "" + if cycle: + cycle_info = ( + f" · цикл {cycle.get('completed_work_sessions', 0)}/" + f"{cycle.get('sessions_until_long_break', 4)}" + ) + + if status == "idle": + return f"⏱ **Помидоро:** таймер не запущен{cycle_info}." + + if status == "running": + return ( + f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** " + f"из {duration} мин · _{task}_{cycle_info}" + ) + + if status == "paused": + elapsed = data.get("elapsed_seconds", 0) + return ( + f"⏱ **{phase_label} на паузе** · прошло {_format_time(elapsed)} " + f"из {duration} мин · _{task}_{cycle_info}" + ) + + if status == "completed": + return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_" + + if status == "cancelled": + return f"⏱ **{phase_label} отменена** · _{task}_" + + return f"⏱ Помидоро: {status}" + + +def _format_history_notice(data: Any) -> str: + if not isinstance(data, list) or not data: + return "⏱ **История помидоро** пуста." + + lines = ["⏱ **История помидоро:**"] + for item in data[:10]: + task = item.get("task_note") or "без описания" + phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?")) + duration = item.get("duration_min", "?") + lines.append(f"- {phase}: {task} ({duration} мин)") + + return "\n".join(lines) + + +def format_pomodoro_context(status: dict[str, Any]) -> str: + notice = _format_status_notice(status) + cycle = status.get("cycle", {}) + extra = "" + if cycle: + extra = ( + f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, " + f"перерыв {cycle.get('short_break_min')} мин, " + f"длинный {cycle.get('long_break_min')} мин." + ) + return f"[Актуальный статус помидоро]\n{notice}{extra}" diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index 4042418..e0c356e 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -1,468 +1,564 @@ -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" +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_scoped.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 +_DOMAIN_CACHE: dict[str, tuple[float, str]] = {} +_DOMAIN_TTL_SEC = 60.0 + +_DOMAIN_KEYWORDS: dict[str, tuple[str, ...]] = { + "fitness": ("фитнес", "тренир", "калори", "еда", "вода", "вес", "workout", "meal", "белок", "жир"), + "shopping": ("покуп", "магазин", "список", "shopping", "корзин"), + "reminders": ("напомин", "календар", "событи", "дедлайн", "встреч", "план"), + "projects": ("taiga", "gitea", "задач", "проект", "git", "issue", "коммит", "ветк"), +} + +logger = logging.getLogger(__name__) + + +def _build_messages_for_session(session_id: int, user_id: int) -> list[dict[str, Any]]: + db = SessionLocal() + try: + service = ChatService(db, user_id) + 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_id: int, + user_text: str, + assistant_text: str, +) -> None: + db = SessionLocal() + try: + await extract_after_turn(db, session_id, user_text, assistant_text, user_id=user_id) + except Exception as exc: + logger.warning("Background memory extraction failed: %s", exc) + finally: + db.close() + + +class ChatService: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + self.llm = LLMClient() + self.character = CharacterService(db, user_id) + + def list_sessions(self) -> list[ChatSession]: + stmt = select(ChatSession).where(ChatSession.user_id == self.user_id).order_by(ChatSession.updated_at.desc()) + return list(self.db.scalars(stmt).all()) + + def get_session(self, session_id: int) -> ChatSession | None: + session = self.db.get(ChatSession, session_id) + if session and session.user_id != self.user_id: + return None + return session + + def list_messages( + self, + session_id: int, + limit: int = 30, + before_id: int | None = None, + after_id: int | None = None, + ) -> tuple[list[Message], bool]: + if not self.get_session(session_id): + return [], False + + if after_id is not None: + stmt = ( + select(Message) + .where(Message.session_id == session_id, Message.id > after_id) + .order_by(Message.created_at.asc()) + .limit(limit + 1) + ) + rows = list(self.db.scalars(stmt).all()) + has_more = len(rows) > limit + return rows[:limit], has_more + + stmt = select(Message).where(Message.session_id == session_id) + + if before_id is not None: + anchor = self.db.get(Message, before_id) + if anchor is None or anchor.session_id != session_id: + return [], False + stmt = stmt.where(Message.created_at < anchor.created_at) + + stmt = stmt.order_by(Message.created_at.desc()).limit(limit + 1) + rows = list(self.db.scalars(stmt).all()) + has_more = len(rows) > limit + page = rows[:limit] + page.reverse() + return page, has_more + + def create_session(self, title: str = "Новый чат") -> ChatSession: + session = ChatSession(user_id=self.user_id, 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 _cached_domain(self, key: str, loader, formatter) -> str: + now = time.monotonic() + hit = _DOMAIN_CACHE.get(f"{self.user_id}:{key}") + if hit and now < hit[0]: + return hit[1] + rendered = formatter(loader()) + _DOMAIN_CACHE[f"{self.user_id}:{key}"] = (now + _DOMAIN_TTL_SEC, rendered) + return rendered + + def _domain_relevant(self, key: str, user_query: str) -> bool: + query = user_query.strip().lower() + if not query: + return False + keywords = _DOMAIN_KEYWORDS.get(key, ()) + return any(kw in query for kw in keywords) + + def _optional_domain( + self, + key: str, + user_query: str, + loader, + formatter, + ) -> str: + if not self._domain_relevant(key, user_query): + return "" + return self._cached_domain(key, loader, formatter) + + def _build_system_prompt(self, session_id: int | None = None, user_query: str = "") -> str: + status = PomodoroService(self.db, self.user_id).get_status() + memory_snapshot = get_memory_snapshot(self.db, self.user_id, session_id, query=user_query) + fitness_snapshot = get_fitness_snapshot(self.db, self.user_id) + shopping_snapshot = get_shopping_snapshot(self.db, self.user_id) + reminders_snapshot = get_reminders_snapshot(self.db, self.user_id) + projects_snapshot = get_projects_snapshot(self.db, self.user_id) + parts = [ + self.character.get_system_prompt(), + format_datetime_context(self.db, self.user_id), + format_memory_context(memory_snapshot), + self._optional_domain("fitness", user_query, lambda: fitness_snapshot, format_fitness_context), + self._optional_domain("shopping", user_query, lambda: shopping_snapshot, format_shopping_context), + self._optional_domain("reminders", user_query, lambda: reminders_snapshot, format_reminders_context), + format_weather_snapshot(), + format_pomodoro_context(status), + self._optional_domain("projects", user_query, lambda: projects_snapshot, format_projects_context), + ] + return "\n\n".join(part for part in parts if part.strip()) + + def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]: + 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"), "") + system_prompt = self._build_system_prompt(session.id, user_query=last_user) + if last_user: + memory_snapshot = get_memory_snapshot(self.db, self.user_id, session.id, query=last_user) + 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, user_id=self.user_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 + + def context_preview(self, session_id: int, query: str | None = None) -> dict[str, Any]: + session = self.get_session(session_id) + if not session: + return {"ok": False, "error": "Session not found"} + all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES] + last_user = query or next((m.content for m in reversed(all_chat) if m.role == "user"), "") + system_prompt = self._build_system_prompt(session_id, user_query=last_user) + memory_snapshot = get_memory_snapshot(self.db, self.user_id, session_id, query=last_user) + return { + "ok": True, + "session_id": session_id, + "query": last_user, + "system_prompt_chars": len(system_prompt), + "memory_facts": len(memory_snapshot.get("facts") or []), + "memory_total_facts": memory_snapshot.get("total_facts", 0), + "system_prompt_preview": system_prompt[:4000], + } + + 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, self.user_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, user_id=self.user_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, self.user_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/chat/service.py.refactor_bak b/backend/app/chat/service.py.refactor_bak new file mode 100644 index 0000000..4042418 --- /dev/null +++ b/backend/app/chat/service.py.refactor_bak @@ -0,0 +1,468 @@ +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 3276f62..d559c37 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,127 +1,138 @@ -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() +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 + + default_user_username: str = "owner" + default_user_display_name: str = "" + default_api_token: str = "" + auth_required: bool = True + + qdrant_url: str = "http://qdrant:6333" + embedding_model: str = "openai/text-embedding-3-small" + rag_enabled: bool = False + rag_top_k: int = 8 + memory_facts_in_context: int = 8 + + # 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/config.py.refactor_bak b/backend/app/config.py.refactor_bak new file mode 100644 index 0000000..3276f62 --- /dev/null +++ b/backend/app/config.py.refactor_bak @@ -0,0 +1,127 @@ +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/migrate_fitness.py b/backend/app/db/migrate_fitness.py new file mode 100644 index 0000000..7e09201 --- /dev/null +++ b/backend/app/db/migrate_fitness.py @@ -0,0 +1,94 @@ +from sqlalchemy import inspect, text + +from app.db.base import engine + + +def _add_column_if_missing(table: str, column: str, ddl: str) -> None: + inspector = inspect(engine) + if table not in inspector.get_table_names(): + return + columns = {col["name"] for col in inspector.get_columns(table)} + if column in columns: + return + with engine.begin() as conn: + conn.execute(text(ddl)) + + +def run_fitness_migrations() -> None: + inspector = inspect(engine) + + if "fitness_profiles" in inspector.get_table_names(): + _add_column_if_missing( + "fitness_profiles", + "baseline_steps", + "ALTER TABLE fitness_profiles ADD COLUMN baseline_steps INTEGER", + ) + _add_column_if_missing( + "fitness_profiles", + "baseline_workout_kcal", + "ALTER TABLE fitness_profiles ADD COLUMN baseline_workout_kcal FLOAT", + ) + + if "workout_logs" in inspector.get_table_names(): + _add_column_if_missing( + "workout_logs", + "active_calories", + "ALTER TABLE workout_logs ADD COLUMN active_calories FLOAT", + ) + _add_column_if_missing( + "workout_logs", + "total_calories", + "ALTER TABLE workout_logs ADD COLUMN total_calories FLOAT", + ) + _add_column_if_missing( + "workout_logs", + "steps", + "ALTER TABLE workout_logs ADD COLUMN steps INTEGER", + ) + + 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 ''" + ")" + ) + ) + + if "body_metrics" in inspector.get_table_names(): + _add_column_if_missing( + "body_metrics", + "neck_cm", + "ALTER TABLE body_metrics ADD COLUMN neck_cm FLOAT", + ) + _add_column_if_missing( + "body_metrics", + "hip_cm", + "ALTER TABLE body_metrics ADD COLUMN hip_cm FLOAT", + ) + _add_column_if_missing( + "body_metrics", + "body_fat_method", + "ALTER TABLE body_metrics ADD COLUMN body_fat_method VARCHAR(16)", + ) + _add_column_if_missing( + "body_metrics", + "whr", + "ALTER TABLE body_metrics ADD COLUMN whr FLOAT", + ) + _add_column_if_missing( + "body_metrics", + "lbm_kg", + "ALTER TABLE body_metrics ADD COLUMN lbm_kg FLOAT", + ) + _add_column_if_missing( + "body_metrics", + "ffmi", + "ALTER TABLE body_metrics ADD COLUMN ffmi FLOAT", + ) diff --git a/backend/app/db/migrate_multi_user.py b/backend/app/db/migrate_multi_user.py new file mode 100644 index 0000000..1292eb8 --- /dev/null +++ b/backend/app/db/migrate_multi_user.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import json +import logging +import secrets +from pathlib import Path + +from sqlalchemy import inspect, text + +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 + +logger = logging.getLogger(__name__) + +TENANT_TABLES = ( + "chat_sessions", + "user_profile", + "memory_facts", + "fitness_profiles", + "body_metrics", + "food_logs", + "water_logs", + "workout_logs", + "step_logs", + "fitness_reminders", + "shopping_lists", + "reminders", + "documents", + "pomodoro_cycles", + "pomodoro_sessions", + "project_bindings", + "work_items", +) + +LEGACY_CARD_PATH = Path("./data/character.json") + + +def _table_exists(name: str) -> bool: + return name in inspect(engine).get_table_names() + + +def _columns(table: str) -> set[str]: + if not _table_exists(table): + return set() + return {col["name"] for col in inspect(engine).get_columns(table)} + + +def _add_column_if_missing(table: str, column: str, ddl: str) -> None: + if column in _columns(table): + return + with engine.begin() as conn: + conn.execute(text(ddl)) + + +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)")) + + +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)")) + + +def _add_user_id_columns() -> None: + for table in TENANT_TABLES: + if not _table_exists(table): + continue + _add_column_if_missing( + table, + "user_id", + f"ALTER TABLE {table} ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE", + ) + with engine.begin() as conn: + conn.execute(text(f"CREATE INDEX IF NOT EXISTS ix_{table}_user_id ON {table} (user_id)")) + + +def _ensure_default_user() -> tuple[int, str | None]: + settings = get_settings() + with engine.begin() as conn: + row = conn.execute(text("SELECT id FROM users ORDER BY id LIMIT 1")).fetchone() + if row: + return int(row[0]), None + + username = settings.default_user_username or "owner" + display_name = settings.default_user_display_name or username + plain_token = (settings.default_api_token or "").strip() + generated = False + if not plain_token: + plain_token = secrets.token_urlsafe(32) + generated = True + + token_hash = hash_token(plain_token) + conn.execute( + text( + "INSERT INTO users (id, username, display_name, api_token_hash, is_active) " + "VALUES (1, :username, :display_name, :token_hash, 1)" + ), + {"username": username, "display_name": display_name, "token_hash": token_hash}, + ) + if generated: + logger.warning( + "DEFAULT_API_TOKEN not set — generated token for user '%s': %s", + username, + plain_token, + ) + return 1, plain_token + return 1, None + + +def _backfill_user_id(default_user_id: int = 1) -> None: + with engine.begin() as conn: + for table in TENANT_TABLES: + if not _table_exists(table): + continue + conn.execute( + text(f"UPDATE {table} SET user_id = :uid WHERE user_id IS NULL"), + {"uid": default_user_id}, + ) + + +def _rebuild_shopping_unique() -> None: + if not _table_exists("shopping_lists"): + return + with engine.begin() as conn: + conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_shopping_lists_user_name ON shopping_lists (user_id, name)")) + + +def _rebuild_project_bindings_unique() -> None: + if not _table_exists("project_bindings"): + return + with engine.begin() as conn: + conn.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_project_bindings_user_slug " + "ON project_bindings (user_id, taiga_slug)" + ) + ) + + +def _import_character_card(user_id: int) -> None: + with engine.begin() as conn: + existing = conn.execute( + text("SELECT id FROM character_cards WHERE user_id = :uid"), + {"uid": user_id}, + ).fetchone() + if existing: + return + + card = normalize_card(DEFAULT_CARD) + if LEGACY_CARD_PATH.is_file(): + try: + raw = json.loads(LEGACY_CARD_PATH.read_text(encoding="utf-8")) + card = normalize_card(raw) + except (json.JSONDecodeError, OSError): + pass + + conn.execute( + text("INSERT INTO character_cards (user_id, card_json) VALUES (:uid, :json)"), + {"uid": user_id, "json": json.dumps(card, ensure_ascii=False)}, + ) + + +def _backfill_qdrant_user_id(default_user_id: int = 1) -> None: + settings = get_settings() + if not settings.rag_enabled: + return + try: + from app.rag.store import COLLECTION_DOC_CHUNKS, COLLECTION_FACTS, COLLECTION_SUMMARIES, _client + except Exception: + logger.exception("Qdrant backfill skipped") + return + + try: + client = _client() + except Exception: + logger.warning('Qdrant unavailable, skipping user_id backfill') + return + + for collection in (COLLECTION_FACTS, COLLECTION_SUMMARIES, COLLECTION_DOC_CHUNKS): + try: + if not client.collection_exists(collection): + continue + except Exception: + logger.warning('Qdrant unavailable for collection %s', collection) + continue + offset = None + while True: + points, offset = client.scroll( + collection_name=collection, + limit=100, + offset=offset, + with_payload=True, + with_vectors=False, + ) + if not points: + break + missing = [point.id for point in points if (point.payload or {}).get("user_id") is None] + if missing: + client.set_payload( + collection_name=collection, + payload={"user_id": default_user_id}, + points=missing, + ) + if offset is None: + break + logger.info("Qdrant user_id backfill completed for user_id=%s", default_user_id) + + +def run_multi_user_migrations() -> str | None: + """Returns newly generated API token if any.""" + _ensure_users_table() + _ensure_character_cards_table() + _add_user_id_columns() + user_id, new_token = _ensure_default_user() + _backfill_user_id(user_id) + _rebuild_shopping_unique() + _rebuild_project_bindings_unique() + _import_character_card(user_id) + _backfill_qdrant_user_id(user_id) + return new_token diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 394de86..ecf7a3a 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,299 +1,396 @@ -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) +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + username: Mapped[str] = mapped_column(String(64), unique=True, index=True) + display_name: Mapped[str] = mapped_column(String(255), default="") + api_token_hash: Mapped[str] = mapped_column(String(64), index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class CharacterCard(Base): + __tablename__ = "character_cards" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True + ) + card_json: Mapped[str] = mapped_column(Text, default="{}") + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class ChatSession(Base): + __tablename__ = "chat_sessions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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" + __table_args__ = (UniqueConstraint("user_id", "taiga_slug", name="uq_project_bindings_user_slug"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + taiga_slug: Mapped[str] = mapped_column(String(255), 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) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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) + baseline_steps: Mapped[int | None] = mapped_column(Integer, nullable=True) + baseline_workout_kcal: Mapped[float | None] = mapped_column(Float, nullable=True) + 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) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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) + body_fat_method: Mapped[str | None] = mapped_column(String(16), nullable=True) + chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True) + waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True) + neck_cm: Mapped[float | None] = mapped_column(Float, nullable=True) + hip_cm: Mapped[float | None] = mapped_column(Float, nullable=True) + whr: Mapped[float | None] = mapped_column(Float, nullable=True) + lbm_kg: Mapped[float | None] = mapped_column(Float, nullable=True) + ffmi: 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) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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 StepLog(Base): + __tablename__ = "step_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + steps: Mapped[int] = mapped_column(Integer, default=0) + active_calories: Mapped[float | None] = mapped_column(Float, nullable=True) + source: Mapped[str] = mapped_column(String(32), default="manual") + notes: Mapped[str] = mapped_column(Text, default="") + + +class WaterLog(Base): + __tablename__ = "water_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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) + active_calories: Mapped[float | None] = mapped_column(Float, nullable=True) + total_calories: Mapped[float | None] = mapped_column(Float, nullable=True) + steps: 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) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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" + __table_args__ = (UniqueConstraint("user_id", "name", name="uq_shopping_lists_user_name"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(255), 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) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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) + + +class Document(Base): + __tablename__ = "documents" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + title: Mapped[str] = mapped_column(String(255), default="") + filename: Mapped[str] = mapped_column(String(255), default="") + content_hash: Mapped[str] = mapped_column(String(64), default="", index=True) + size_bytes: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + chunks: Mapped[list["DocumentChunk"]] = relationship( + back_populates="document", + cascade="all, delete-orphan", + order_by="DocumentChunk.chunk_index", + ) + + +class DocumentChunk(Base): + __tablename__ = "document_chunks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + document_id: Mapped[int] = mapped_column( + ForeignKey("documents.id", ondelete="CASCADE"), index=True + ) + chunk_index: Mapped[int] = mapped_column(Integer, default=0) + content: Mapped[str] = mapped_column(Text, default="") + + document: Mapped["Document"] = relationship(back_populates="chunks") diff --git a/backend/app/db/models.py.refactor_bak b/backend/app/db/models.py.refactor_bak new file mode 100644 index 0000000..394de86 --- /dev/null +++ b/backend/app/db/models.py.refactor_bak @@ -0,0 +1,299 @@ +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/fitness/activity_budget.py b/backend/app/fitness/activity_budget.py new file mode 100644 index 0000000..2e601e9 --- /dev/null +++ b/backend/app/fitness/activity_budget.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any + +BASELINE_STEPS_BY_LEVEL: dict[str, int] = { + "sedentary": 5000, + "light": 7000, + "moderate": 9000, + "active": 11000, + "very_active": 13000, +} + +WORKOUT_KCAL_PER_SESSION = 200 +KCAL_PER_STEP_PER_KG = 0.0005 +FALLBACK_KCAL_PER_MIN = 6 + + +@dataclass +class ActivityBonus: + steps: int + steps_baseline: int + steps_bonus_kcal: float + workout_active_kcal: float + workout_baseline_kcal: float + workout_bonus_kcal: float + total_bonus_kcal: float + scale_factor: float + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def baseline_steps(profile: dict[str, Any]) -> int: + override = profile.get("baseline_steps") + if override is not None: + return int(override) + level = str(profile.get("activity_level") or "moderate") + return BASELINE_STEPS_BY_LEVEL.get(level, 9000) + + +def baseline_workout_kcal_day(profile: dict[str, Any]) -> float: + override = profile.get("baseline_workout_kcal") + if override is not None: + return float(override) + weekly = int(profile.get("weekly_workouts") or 3) + return round(weekly * WORKOUT_KCAL_PER_SESSION / 7, 1) + + +def estimate_workout_active_kcal(workout: dict[str, Any]) -> float: + active = workout.get("active_calories") + if active is not None: + return float(active) + duration = workout.get("duration_min") + if duration: + return float(duration) * FALLBACK_KCAL_PER_MIN + return 0.0 + + +def steps_bonus_kcal(*, steps: int, baseline_steps: int, weight_kg: float) -> float: + extra_steps = max(0, steps - baseline_steps) + return round(extra_steps * weight_kg * KCAL_PER_STEP_PER_KG, 1) + + +def compute_activity_bonus( + profile: dict[str, Any], + *, + steps_total: int, + workouts: list[dict[str, Any]], +) -> ActivityBonus: + weight_kg = float(profile.get("weight_kg") or 70) + steps_base = baseline_steps(profile) + workout_base = baseline_workout_kcal_day(profile) + + s_bonus = steps_bonus_kcal(steps=steps_total, baseline_steps=steps_base, weight_kg=weight_kg) + workout_active = round(sum(estimate_workout_active_kcal(w) for w in workouts), 1) + w_bonus = max(0.0, round(workout_active - workout_base, 1)) + total_bonus = round(s_bonus + w_bonus, 1) + + base_cal = float(profile.get("calorie_target") or 2000) + scale_factor = 1.0 if base_cal <= 0 else round((base_cal + total_bonus) / base_cal, 4) + + return ActivityBonus( + steps=steps_total, + steps_baseline=steps_base, + steps_bonus_kcal=s_bonus, + workout_active_kcal=workout_active, + workout_baseline_kcal=workout_base, + workout_bonus_kcal=w_bonus, + total_bonus_kcal=total_bonus, + scale_factor=scale_factor, + ) + + +def _targets_dict( + *, + calories: float, + protein_g: float, + fat_g: float, + carbs_g: float, + water_ml: float, +) -> dict[str, float]: + return { + "calories": round(calories), + "protein_g": round(protein_g), + "fat_g": round(fat_g), + "carbs_g": round(carbs_g), + "water_ml": round(water_ml), + } + + +def build_base_targets(profile: dict[str, Any]) -> dict[str, float]: + water_l = float(profile.get("water_l") or 2.5) + return _targets_dict( + calories=float(profile.get("calorie_target") or 2000), + protein_g=float(profile.get("protein_g") or 140), + fat_g=float(profile.get("fat_g") or 65), + carbs_g=float(profile.get("carbs_g") or 200), + water_ml=water_l * 1000, + ) + + +def scale_targets( + base_targets: dict[str, float], + bonus_kcal: float, +) -> tuple[dict[str, float], dict[str, float]]: + """Return (effective_targets, targets_base). Water is not scaled.""" + targets_base = dict(base_targets) + base_cal = float(base_targets["calories"]) + + if bonus_kcal <= 0 or base_cal <= 0: + return dict(base_targets), targets_base + + scale = (base_cal + bonus_kcal) / base_cal + effective = _targets_dict( + calories=base_cal + bonus_kcal, + protein_g=float(base_targets["protein_g"]) * scale, + fat_g=float(base_targets["fat_g"]) * scale, + carbs_g=float(base_targets["carbs_g"]) * scale, + water_ml=float(base_targets["water_ml"]), + ) + return effective, targets_base + diff --git a/backend/app/fitness/body_composition.py b/backend/app/fitness/body_composition.py new file mode 100644 index 0000000..b14480f --- /dev/null +++ b/backend/app/fitness/body_composition.py @@ -0,0 +1,128 @@ +import math +from typing import Any + + +def _is_female(sex: str) -> bool: + return sex.lower() in ("f", "female", "ж", "женский", "woman") + + +def _cm_to_inches(cm: float) -> float: + return cm / 2.54 + + +def _clamp_bf(value: float) -> float: + return round(max(3.0, min(50.0, value)), 1) + + +def navy_body_fat_pct( + *, + sex: str, + height_cm: float, + neck_cm: float, + waist_cm: float, + hip_cm: float | None = None, +) -> float | None: + if height_cm <= 0 or neck_cm <= 0 or waist_cm <= 0: + return None + + height_in = _cm_to_inches(height_cm) + neck_in = _cm_to_inches(neck_cm) + waist_in = _cm_to_inches(waist_cm) + + if _is_female(sex): + if hip_cm is None or hip_cm <= 0: + return None + hip_in = _cm_to_inches(hip_cm) + sum_in = waist_in + hip_in - neck_in + if sum_in <= 0: + return None + denom = ( + 1.29579 + - 0.35004 * math.log10(sum_in) + + 0.22100 * math.log10(height_in) + ) + else: + diff_in = waist_in - neck_in + if diff_in <= 0: + return None + denom = ( + 1.0324 + - 0.19077 * math.log10(diff_in) + + 0.15456 * math.log10(height_in) + ) + + if denom <= 0: + return None + + return _clamp_bf(495.0 / denom - 450.0) + + +def whr(waist_cm: float, hip_cm: float) -> float | None: + if waist_cm <= 0 or hip_cm <= 0: + return None + return round(waist_cm / hip_cm, 2) + + +def lean_body_mass(weight_kg: float, body_fat_pct: float) -> float: + return round(weight_kg * (1.0 - body_fat_pct / 100.0), 1) + + +def ffmi(weight_kg: float, height_cm: float, body_fat_pct: float) -> float | None: + if height_cm <= 0: + return None + height_m = height_cm / 100.0 + lbm = weight_kg * (1.0 - body_fat_pct / 100.0) + raw = lbm / (height_m * height_m) + normalized = raw + 6.1 * (1.8 - height_m) + return round(normalized, 1) + + +def compute_body_composition( + *, + sex: str, + height_cm: float, + weight_kg: float, + neck_cm: float | None = None, + waist_cm: float | None = None, + hip_cm: float | None = None, + body_fat_pct: float | None = None, +) -> dict[str, Any]: + warnings: list[str] = [] + result: dict[str, Any] = { + "body_fat_pct": None, + "body_fat_method": None, + "whr": None, + "lbm_kg": None, + "ffmi": None, + "warnings": warnings, + } + + bf = body_fat_pct + method: str | None = "manual" if bf is not None else None + + if bf is None and neck_cm and waist_cm: + navy_bf = navy_body_fat_pct( + sex=sex, + height_cm=height_cm, + neck_cm=neck_cm, + waist_cm=waist_cm, + hip_cm=hip_cm, + ) + if navy_bf is not None: + bf = navy_bf + method = "navy" + elif _is_female(sex) and not hip_cm: + warnings.append("Для Navy у женщин нужен обхват бёдер (hip_cm).") + elif neck_cm and waist_cm and waist_cm <= neck_cm: + warnings.append("Обхват талии должен быть больше шеи для Navy.") + + if bf is not None: + result["body_fat_pct"] = round(float(bf), 1) + result["body_fat_method"] = method + result["lbm_kg"] = lean_body_mass(weight_kg, float(bf)) + result["ffmi"] = ffmi(weight_kg, height_cm, float(bf)) + + if waist_cm and hip_cm: + result["whr"] = whr(waist_cm, hip_cm) + + return result diff --git a/backend/app/fitness/context.py b/backend/app/fitness/context.py index 0dedc23..7e369f3 100644 --- a/backend/app/fitness/context.py +++ b/backend/app/fitness/context.py @@ -1,55 +1,94 @@ -from typing import Any - -from sqlalchemy.orm import Session - -from app.fitness.service import FitnessService - - -def get_fitness_snapshot(db: Session) -> dict[str, Any]: - return FitnessService(db).snapshot() - - -def format_fitness_context(snapshot: dict[str, Any]) -> str: - lines = ["[Фитнес — сводка на сегодня]"] - - profile = snapshot.get("profile") - if not profile: - lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.") - else: - lines.append( - f"Цели: {profile.get('calorie_target')} ккал, " - f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, " - f"вода {profile.get('water_l')} л" - ) - if profile.get("goal"): - lines.append( - f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, " - f"рост {profile.get('height_cm')} см" - ) - - today = snapshot.get("today") or {} - totals = today.get("totals") or {} - targets = today.get("targets") or {} - water_l = totals.get("water_ml", 0) / 1000 - water_target = targets.get("water_ml", 2500) / 1000 - - lines.append("") - lines.append( - f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · " - f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · " - f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · " - f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г" - ) - lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л") - - workouts = today.get("workouts") or [] - if workouts: - lines.append(f"Тренировок сегодня: {len(workouts)}") - - lines.append("") - lines.append( - "Правила: log_meal, log_water, log_weight, log_workout, get_fitness_summary (date/days_ago), get_fitness_history, " - "set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. " - "Еда — оценка LLM (≈), пользователь может уточнить." - ) - return "\n".join(lines) +from typing import Any + +from sqlalchemy.orm import Session + +from app.fitness.service import FitnessService + + +def get_fitness_snapshot(db: Session, user_id: int) -> dict[str, Any]: + return FitnessService(db, user_id).snapshot() + + +def format_fitness_context(snapshot: dict[str, Any]) -> str: + lines = ["[Фитнес — сводка на сегодня]"] + + profile = snapshot.get("profile") + if not profile: + lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.") + else: + lines.append( + f"Цели (база): {profile.get('calorie_target')} ккал, " + f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, " + f"вода {profile.get('water_l')} л" + ) + if profile.get("goal"): + lines.append( + f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, " + f"рост {profile.get('height_cm')} см" + ) + + today = snapshot.get("today") or {} + totals = today.get("totals") or {} + targets = today.get("targets") or {} + targets_base = today.get("targets_base") or {} + activity = today.get("activity") or {} + steps_total = today.get("steps_total") or 0 + water_l = totals.get("water_ml", 0) / 1000 + water_target = targets.get("water_ml", 2500) / 1000 + + if profile and (activity.get("total_bonus_kcal") or steps_total): + lines.append( + f"Активность: шаги {steps_total} (база {activity.get('steps_baseline', 0)}), " + f"бонус +{activity.get('total_bonus_kcal', 0)} ккал" + ) + base_cal = targets_base.get("calories", profile.get("calorie_target")) + lines.append(f"Эффективная цель ккал: {base_cal} → {targets.get('calories', base_cal)}") + + lines.append("") + lines.append( + f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · " + f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · " + f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · " + f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г" + ) + lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л") + + workouts = today.get("workouts") or [] + if workouts: + lines.append(f"Тренировок сегодня: {len(workouts)}") + + stats = snapshot.get("workout_stats") or {} + if stats.get("count"): + lines.append( + f"Тренировки за {stats.get('days', 7)} дн.: {stats.get('count')} " + f"(цель/нед {stats.get('weekly_target')}, серия {stats.get('streak')} дн.)" + ) + + latest = (snapshot.get("body_metrics") or [None])[0] + if latest: + lines.append("") + lines.append("Антропометрия (последняя):") + parts = [f"{latest.get('weight_kg')} кг"] + if latest.get("body_fat_pct") is not None: + method = latest.get("body_fat_method") or "?" + parts.append(f"жир {latest.get('body_fat_pct')}% ({method})") + if latest.get("neck_cm"): + parts.append(f"шея {latest.get('neck_cm')}") + if latest.get("waist_cm"): + parts.append(f"талия {latest.get('waist_cm')}") + if latest.get("hip_cm"): + parts.append(f"бёдра {latest.get('hip_cm')}") + if latest.get("whr"): + parts.append(f"WHR {latest.get('whr')}") + if latest.get("ffmi"): + parts.append(f"FFMI {latest.get('ffmi')}") + lines.append(" · ".join(parts)) + + lines.append("") + lines.append( + "Правила: log_meal, log_water, log_weight (обхваты → Navy), log_steps, log_workout (date/days_ago), " + "calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, " + "set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. " + "Еда — оценка LLM (≈), пользователь может уточнить." + ) + return chr(10).join(lines) diff --git a/backend/app/fitness/reminders.py b/backend/app/fitness/reminders.py index 3820cad..7d97386 100644 --- a/backend/app/fitness/reminders.py +++ b/backend/app/fitness/reminders.py @@ -1,114 +1,111 @@ -from datetime import datetime, timedelta, timezone - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.config import get_settings -from app.db.base import SessionLocal -from app.db.models import ChatSession, FitnessReminder, Message -from app.fitness.service import FitnessService - -KIND_LABELS = { - "water": "Вода", - "meal": "Еда", - "workout": "Тренировка", - "weigh_in": "Взвешивание", -} - - -def _post_fitness_notice(content: str) -> None: - db = SessionLocal() - try: - session = db.scalar( - select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1) - ) - if not session: - session = ChatSession(title="Фитнес") - db.add(session) - db.commit() - db.refresh(session) - db.add(Message(session_id=session.id, role="notice", content=content)) - db.commit() - finally: - db.close() - - -def _build_notice(kind: str, summary: dict) -> str: - label = KIND_LABELS.get(kind, kind) - totals = summary.get("totals") or {} - targets = summary.get("targets") or {} - water_l = totals.get("water_ml", 0) / 1000 - water_target = targets.get("water_ml", 2500) / 1000 - cals = totals.get("calories", 0) - cal_target = targets.get("calories", 2000) - - if kind == "water": - return ( - f"💪 **{label}** · выпито {water_l:.1f}/{water_target:.1f} л сегодня. " - "Пора выпить стакан воды." - ) - if kind == "meal": - return ( - f"💪 **{label}** · {cals:.0f}/{cal_target:.0f} ккал за день. " - "Не забудь залогировать приём пищи." - ) - if kind == "workout": - workouts = summary.get("workouts") or [] - if workouts: - return f"💪 **{label}** · сегодня уже была тренировка. Отдыхай или лёгкая активность." - return "💪 **Тренировка** · запланирована на сегодня. Время двигаться!" - if kind == "weigh_in": - return "💪 **Взвешивание** · пора записать вес (log_weight)." - return f"💪 **{label}** · напоминание" - - -def check_reminders(db: Session) -> list[str]: - if not get_settings().fitness_reminders_enabled: - return [] - - now = datetime.now(timezone.utc) - service = FitnessService(db) - summary = service.get_daily_summary() - fired: list[str] = [] - - reminders = db.scalars( - select(FitnessReminder).where(FitnessReminder.enabled.is_(True)) - ).all() - - for rem in reminders: - should_fire = False - - if rem.interval_hours: - if rem.last_fired_at is None: - should_fire = now.hour >= rem.hour - else: - delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc) - should_fire = delta >= timedelta(hours=rem.interval_hours) - else: - if rem.kind == "weigh_in": - if rem.last_fired_at: - delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc) - should_fire = delta >= timedelta(days=7) - else: - should_fire = now.hour == rem.hour and now.minute >= rem.minute - else: - if rem.last_fired_at: - last = rem.last_fired_at.replace(tzinfo=timezone.utc) - already_today = last.date() == now.date() - if already_today: - continue - should_fire = now.hour == rem.hour and now.minute >= rem.minute - - if not should_fire: - continue - - notice = _build_notice(rem.kind, summary) - rem.last_fired_at = now - fired.append(notice) - - if fired: - db.commit() - for notice in fired: - _post_fitness_notice(notice) - - return fired +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.chat.notice_inbox import post_notice_to_latest_chat +from app.config import get_settings +from app.db.models import FitnessReminder, User +from app.fitness.service import FitnessService + +KIND_LABELS = { + "water": "Вода", + "meal": "Еда", + "workout": "Тренировка", + "weigh_in": "Взвешивание", +} + + +def _build_notice(kind: str, summary: dict) -> str: + label = KIND_LABELS.get(kind, kind) + totals = summary.get("totals") or {} + targets = summary.get("targets") or {} + water_l = totals.get("water_ml", 0) / 1000 + water_target = targets.get("water_ml", 2500) / 1000 + cals = totals.get("calories", 0) + cal_target = targets.get("calories", 2000) + + if kind == "water": + return ( + f"💪 **{label}** · выпито {water_l:.1f}/{water_target:.1f} л сегодня. " + "Пора выпить стакан воды." + ) + if kind == "meal": + return ( + f"💪 **{label}** · {cals:.0f}/{cal_target:.0f} ккал за день. " + "Не забудь залогировать приём пищи." + ) + if kind == "workout": + workouts = summary.get("workouts") or [] + if workouts: + return f"💪 **{label}** · сегодня уже была тренировка. Отдыхай или лёгкая активность." + return "💪 **Тренировка** · запланирована на сегодня. Время двигаться!" + if kind == "weigh_in": + return "💪 **Взвешивание** · пора записать вес (log_weight)." + return f"💪 **{label}** · напоминание" + + +def _check_user_reminders(db: Session, user_id: int) -> list[str]: + now = datetime.now(timezone.utc) + service = FitnessService(db, user_id) + summary = service.get_daily_summary() + fired: list[str] = [] + + reminders = db.scalars( + select(FitnessReminder).where( + FitnessReminder.user_id == user_id, + FitnessReminder.enabled.is_(True), + ) + ).all() + + for rem in reminders: + should_fire = False + + if rem.interval_hours: + if rem.last_fired_at is None: + should_fire = now.hour >= rem.hour + else: + delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc) + should_fire = delta >= timedelta(hours=rem.interval_hours) + else: + if rem.kind == "weigh_in": + if rem.last_fired_at: + delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc) + should_fire = delta >= timedelta(days=7) + else: + should_fire = now.hour == rem.hour and now.minute >= rem.minute + else: + if rem.last_fired_at: + last = rem.last_fired_at.replace(tzinfo=timezone.utc) + already_today = last.date() == now.date() + if already_today: + continue + should_fire = now.hour == rem.hour and now.minute >= rem.minute + + if not should_fire: + continue + + notice = _build_notice(rem.kind, summary) + rem.last_fired_at = now + fired.append(notice) + + if fired: + for notice in fired: + post_notice_to_latest_chat(notice, user_id) + + return fired + + +def check_reminders(db: Session) -> list[str]: + if not get_settings().fitness_reminders_enabled: + return [] + + users = db.scalars(select(User).where(User.is_active.is_(True))).all() + all_fired: list[str] = [] + for user in users: + all_fired.extend(_check_user_reminders(db, user.id)) + + if all_fired: + db.commit() + + return all_fired diff --git a/backend/app/fitness/service.py b/backend/app/fitness/service.py index adea1fa..7af313a 100644 --- a/backend/app/fitness/service.py +++ b/backend/app/fitness/service.py @@ -1,441 +1,690 @@ -import json -from datetime import date, datetime, time, timedelta, timezone -from typing import Any - -from sqlalchemy import func, select -from sqlalchemy.orm import Session - -from app.db.models import ( - BodyMetric, - FitnessProfile, - FitnessReminder, - FoodLog, - WaterLog, - WorkoutLog, -) -from app.fitness.calculators import compute_targets, one_rep_max - -DEFAULT_REMINDERS = [ - {"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2}, - {"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None}, - {"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None}, - {"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None}, -] - - -class FitnessService: - def __init__(self, db: Session): - self.db = db - - def _get_profile_row(self) -> FitnessProfile | None: - return self.db.scalar(select(FitnessProfile).limit(1)) - - def get_profile(self) -> dict[str, Any] | None: - row = self._get_profile_row() - if not row: - return None - return self._profile_to_dict(row) - - def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]: - targets = compute_targets( - { - "sex": row.sex, - "age": row.age, - "height_cm": row.height_cm, - "weight_kg": row.weight_kg, - "activity_level": row.activity_level, - "goal": row.goal, - } - ) - return { - "sex": row.sex, - "age": row.age, - "height_cm": row.height_cm, - "weight_kg": row.weight_kg, - "activity_level": row.activity_level, - "goal": row.goal, - "target_weight_kg": row.target_weight_kg, - "weekly_workouts": row.weekly_workouts, - "calorie_target": row.calorie_target, - "protein_g": row.protein_g, - "fat_g": row.fat_g, - "carbs_g": row.carbs_g, - "water_l": row.water_l, - "computed": targets, - "updated_at": row.updated_at.isoformat() if row.updated_at else None, - } - - def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]: - row = self._get_profile_row() - is_new = row is None - if is_new: - row = FitnessProfile() - self.db.add(row) - self.db.flush() - - for key in ( - "sex", "age", "height_cm", "weight_kg", "activity_level", - "goal", "target_weight_kg", "weekly_workouts", - ): - if key in updates and updates[key] is not None: - setattr(row, key, updates[key]) - - targets = compute_targets( - { - "sex": row.sex, - "age": row.age, - "height_cm": row.height_cm, - "weight_kg": row.weight_kg, - "activity_level": row.activity_level, - "goal": row.goal, - } - ) - row.calorie_target = targets["calorie_target"] - row.protein_g = targets["protein_g"] - row.fat_g = targets["fat_g"] - row.carbs_g = targets["carbs_g"] - row.water_l = targets["water_l"] - row.updated_at = datetime.now(timezone.utc) - - if is_new: - self._ensure_default_reminders() - - self.db.commit() - self.db.refresh(row) - return {"ok": True, "profile": self._profile_to_dict(row)} - - def _ensure_default_reminders(self) -> None: - existing = self.db.scalars(select(FitnessReminder)).all() - if existing: - return - for item in DEFAULT_REMINDERS: - self.db.add(FitnessReminder(**item)) - - def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]: - return compute_targets(params) - - def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]: - d = day or datetime.now(timezone.utc).date() - start = datetime.combine(d, time.min, tzinfo=timezone.utc) - end = datetime.combine(d, time.max, tzinfo=timezone.utc) - return start, end - - def get_daily_summary(self, day: date | None = None) -> dict[str, Any]: - start, end = self._day_bounds(day) - profile = self.get_profile() - - foods = self.db.scalars( - select(FoodLog) - .where(FoodLog.logged_at >= start, FoodLog.logged_at <= end) - .order_by(FoodLog.logged_at) - ).all() - waters = self.db.scalars( - select(WaterLog) - .where(WaterLog.logged_at >= start, WaterLog.logged_at <= end) - .order_by(WaterLog.logged_at) - ).all() - workouts = self.db.scalars( - select(WorkoutLog) - .where(WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end) - .order_by(WorkoutLog.logged_at) - ).all() - - totals = { - "calories": sum(f.calories for f in foods), - "protein_g": sum(f.protein_g for f in foods), - "fat_g": sum(f.fat_g for f in foods), - "carbs_g": sum(f.carbs_g for f in foods), - "water_ml": sum(w.amount_ml for w in waters), - } - - targets = profile or { - "calorie_target": 2000, - "protein_g": 140, - "fat_g": 65, - "carbs_g": 200, - "water_l": 2.5, - } - - return { - "date": (day or datetime.now(timezone.utc).date()).isoformat(), - "profile_configured": profile is not None, - "totals": totals, - "targets": { - "calories": targets.get("calorie_target", 2000), - "protein_g": targets.get("protein_g", 140), - "fat_g": targets.get("fat_g", 65), - "carbs_g": targets.get("carbs_g", 200), - "water_ml": targets.get("water_l", 2.5) * 1000, - }, - "meals": [self._food_to_dict(f) for f in foods], - "water": [self._water_to_dict(w) for w in waters], - "workouts": [self._workout_to_dict(w) for w in workouts], - } - - def log_meal( - self, - *, - description: str, - meal_type: str = "snack", - calories: float = 0, - protein_g: float = 0, - fat_g: float = 0, - carbs_g: float = 0, - source: str = "llm", - estimated: bool = True, - ) -> dict[str, Any]: - row = FoodLog( - meal_type=meal_type[:32], - description=description[:2000], - calories=calories, - protein_g=protein_g, - fat_g=fat_g, - carbs_g=carbs_g, - source=source[:32], - estimated=estimated, - ) - self.db.add(row) - self.db.commit() - self.db.refresh(row) - return {"ok": True, "meal": self._food_to_dict(row)} - - def log_water(self, amount_ml: int) -> dict[str, Any]: - row = WaterLog(amount_ml=max(0, amount_ml)) - self.db.add(row) - self.db.commit() - self.db.refresh(row) - return {"ok": True, "water": self._water_to_dict(row)} - - def log_weight( - self, - weight_kg: float, - *, - body_fat_pct: float | None = None, - chest_cm: float | None = None, - waist_cm: float | None = None, - notes: str = "", - ) -> dict[str, Any]: - row = BodyMetric( - weight_kg=weight_kg, - body_fat_pct=body_fat_pct, - chest_cm=chest_cm, - waist_cm=waist_cm, - notes=notes[:1000], - ) - self.db.add(row) - profile = self._get_profile_row() - if profile: - profile.weight_kg = weight_kg - targets = compute_targets( - { - "sex": profile.sex, - "age": profile.age, - "height_cm": profile.height_cm, - "weight_kg": weight_kg, - "activity_level": profile.activity_level, - "goal": profile.goal, - } - ) - profile.calorie_target = targets["calorie_target"] - profile.protein_g = targets["protein_g"] - profile.fat_g = targets["fat_g"] - profile.carbs_g = targets["carbs_g"] - profile.water_l = targets["water_l"] - self.db.commit() - self.db.refresh(row) - return { - "ok": True, - "metric": { - "id": row.id, - "weight_kg": row.weight_kg, - "recorded_at": row.recorded_at.isoformat() if row.recorded_at else None, - }, - } - - def log_workout( - self, - *, - title: str, - notes: str = "", - duration_min: int | None = None, - exercises: list[dict[str, Any]] | None = None, - ) -> dict[str, Any]: - row = WorkoutLog( - title=title[:255], - notes=notes[:2000], - duration_min=duration_min, - exercises_json=json.dumps(exercises or [], ensure_ascii=False), - ) - self.db.add(row) - self.db.commit() - self.db.refresh(row) - return {"ok": True, "workout": self._workout_to_dict(row)} - - def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]: - rows = self.db.scalars( - select(BodyMetric).order_by(BodyMetric.recorded_at.desc()).limit(limit) - ).all() - return [ - { - "id": r.id, - "weight_kg": r.weight_kg, - "body_fat_pct": r.body_fat_pct, - "chest_cm": r.chest_cm, - "waist_cm": r.waist_cm, - "notes": r.notes, - "recorded_at": r.recorded_at.isoformat() if r.recorded_at else None, - } - for r in rows - ] - - def delete_food_log(self, log_id: int) -> bool: - row = self.db.get(FoodLog, log_id) - if not row: - return False - self.db.delete(row) - self.db.commit() - return True - - def delete_water_log(self, log_id: int) -> bool: - row = self.db.get(WaterLog, log_id) - if not row: - return False - self.db.delete(row) - self.db.commit() - return True - - def delete_workout_log(self, log_id: int) -> bool: - row = self.db.get(WorkoutLog, log_id) - if not row: - return False - self.db.delete(row) - self.db.commit() - return True - - def list_reminders(self) -> list[dict[str, Any]]: - rows = self.db.scalars(select(FitnessReminder).order_by(FitnessReminder.kind)).all() - return [self._reminder_to_dict(r) for r in rows] - - def set_reminder( - self, - kind: str, - *, - enabled: bool | None = None, - hour: int | None = None, - minute: int | None = None, - interval_hours: int | None = None, - ) -> dict[str, Any]: - row = self.db.scalar( - select(FitnessReminder).where(FitnessReminder.kind == kind) - ) - if not row: - row = FitnessReminder(kind=kind) - self.db.add(row) - if enabled is not None: - row.enabled = enabled - if hour is not None: - row.hour = hour - if minute is not None: - row.minute = minute - if interval_hours is not None: - row.interval_hours = interval_hours - self.db.commit() - self.db.refresh(row) - return {"ok": True, "reminder": self._reminder_to_dict(row)} - - def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]: - return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)} - - def get_history( - self, - *, - days: int = 7, - end_day: date | None = None, - ) -> dict[str, Any]: - days = max(1, min(days, 90)) - end = end_day or datetime.now(timezone.utc).date() - start = end - timedelta(days=days - 1) - summaries: list[dict[str, Any]] = [] - - for offset in range(days): - d = start + timedelta(days=offset) - full = self.get_daily_summary(d) - totals = full["totals"] - has_data = bool(full["meals"] or full["water"] or full["workouts"]) - summaries.append( - { - "date": full["date"], - "has_data": has_data, - "totals": totals, - "targets": full["targets"], - "meal_count": len(full["meals"]), - "workout_count": len(full["workouts"]), - } - ) - - return { - "start_date": start.isoformat(), - "end_date": end.isoformat(), - "days": days, - "summaries": summaries, - } - - def snapshot(self) -> dict[str, Any]: - today = datetime.now(timezone.utc).date() - return { - "profile": self.get_profile(), - "today": self.get_daily_summary(today), - "history": self.get_history(days=7, end_day=today), - "body_metrics": self.list_body_metrics(limit=10), - "reminders": self.list_reminders(), - } - - @staticmethod - def _food_to_dict(row: FoodLog) -> dict[str, Any]: - return { - "id": row.id, - "meal_type": row.meal_type, - "description": row.description, - "calories": row.calories, - "protein_g": row.protein_g, - "fat_g": row.fat_g, - "carbs_g": row.carbs_g, - "source": row.source, - "estimated": row.estimated, - "logged_at": row.logged_at.isoformat() if row.logged_at else None, - } - - @staticmethod - def _water_to_dict(row: WaterLog) -> dict[str, Any]: - return { - "id": row.id, - "amount_ml": row.amount_ml, - "logged_at": row.logged_at.isoformat() if row.logged_at else None, - } - - @staticmethod - def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]: - try: - exercises = json.loads(row.exercises_json or "[]") - except json.JSONDecodeError: - exercises = [] - return { - "id": row.id, - "title": row.title, - "notes": row.notes, - "duration_min": row.duration_min, - "exercises": exercises, - "logged_at": row.logged_at.isoformat() if row.logged_at else None, - } - - @staticmethod - def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]: - return { - "id": row.id, - "kind": row.kind, - "hour": row.hour, - "minute": row.minute, - "interval_hours": row.interval_hours, - "enabled": row.enabled, - "last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None, - } +import json +from datetime import date, datetime, time, timedelta, timezone +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import ( + BodyMetric, + FitnessProfile, + FitnessReminder, + FoodLog, + StepLog, + WaterLog, + WorkoutLog, +) +from app.fitness.activity_budget import ( + build_base_targets, + compute_activity_bonus, + estimate_workout_active_kcal, + scale_targets, +) +from app.fitness.calculators import compute_targets, one_rep_max +from app.fitness.body_composition import compute_body_composition + +DEFAULT_REMINDERS = [ + {"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2}, + {"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None}, + {"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None}, + {"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None}, +] + + +class FitnessService: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + + def _get_profile_row(self) -> FitnessProfile | None: + return self.db.scalar(select(FitnessProfile).where(FitnessProfile.user_id == self.user_id).limit(1)) + + def get_profile(self) -> dict[str, Any] | None: + row = self._get_profile_row() + if not row: + return None + return self._profile_to_dict(row) + + def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]: + targets = compute_targets( + { + "sex": row.sex, + "age": row.age, + "height_cm": row.height_cm, + "weight_kg": row.weight_kg, + "activity_level": row.activity_level, + "goal": row.goal, + } + ) + return { + "sex": row.sex, + "age": row.age, + "height_cm": row.height_cm, + "weight_kg": row.weight_kg, + "activity_level": row.activity_level, + "goal": row.goal, + "target_weight_kg": row.target_weight_kg, + "weekly_workouts": row.weekly_workouts, + "baseline_steps": row.baseline_steps, + "baseline_workout_kcal": row.baseline_workout_kcal, + "calorie_target": row.calorie_target, + "protein_g": row.protein_g, + "fat_g": row.fat_g, + "carbs_g": row.carbs_g, + "water_l": row.water_l, + "computed": targets, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } + + def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]: + row = self._get_profile_row() + is_new = row is None + if is_new: + row = FitnessProfile(user_id=self.user_id) + self.db.add(row) + self.db.flush() + + for key in ( + "sex", "age", "height_cm", "weight_kg", "activity_level", + "goal", "target_weight_kg", "weekly_workouts", + "baseline_steps", "baseline_workout_kcal", + ): + if key in updates and updates[key] is not None: + setattr(row, key, updates[key]) + + targets = compute_targets( + { + "sex": row.sex, + "age": row.age, + "height_cm": row.height_cm, + "weight_kg": row.weight_kg, + "activity_level": row.activity_level, + "goal": row.goal, + } + ) + row.calorie_target = targets["calorie_target"] + row.protein_g = targets["protein_g"] + row.fat_g = targets["fat_g"] + row.carbs_g = targets["carbs_g"] + row.water_l = targets["water_l"] + row.updated_at = datetime.now(timezone.utc) + + if is_new: + self._ensure_default_reminders() + + self.db.commit() + self.db.refresh(row) + return {"ok": True, "profile": self._profile_to_dict(row)} + + def _ensure_default_reminders(self) -> None: + existing = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id)).all() + if existing: + return + for item in DEFAULT_REMINDERS: + self.db.add(FitnessReminder(user_id=self.user_id, **item)) + + def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]: + return compute_targets(params) + + def calc_body_composition(self, params: dict[str, Any]) -> dict[str, Any]: + profile = self.get_profile() or {} + sex = params.get("sex") or profile.get("sex") or "male" + height_cm = float(params.get("height_cm") or profile.get("height_cm") or 170) + weight_kg = float(params.get("weight_kg") or profile.get("weight_kg") or 70) + return compute_body_composition( + sex=str(sex), + height_cm=height_cm, + weight_kg=weight_kg, + neck_cm=params.get("neck_cm"), + waist_cm=params.get("waist_cm"), + hip_cm=params.get("hip_cm"), + body_fat_pct=params.get("body_fat_pct"), + ) + + def get_latest_body_composition(self) -> dict[str, Any] | None: + rows = self.list_body_metrics(limit=1) + return rows[0] if rows else None + + @staticmethod + def _body_metric_to_dict(row: BodyMetric) -> dict[str, Any]: + return { + "id": row.id, + "weight_kg": row.weight_kg, + "body_fat_pct": row.body_fat_pct, + "body_fat_method": row.body_fat_method, + "chest_cm": row.chest_cm, + "waist_cm": row.waist_cm, + "neck_cm": row.neck_cm, + "hip_cm": row.hip_cm, + "whr": row.whr, + "lbm_kg": row.lbm_kg, + "ffmi": row.ffmi, + "notes": row.notes, + "recorded_at": row.recorded_at.isoformat() if row.recorded_at else None, + } + + @staticmethod + def _resolve_logged_at( + *, + logged_at: datetime | str | None = None, + day: date | None = None, + days_ago: int | None = None, + ) -> datetime: + if logged_at is not None: + if isinstance(logged_at, str): + dt = datetime.fromisoformat(logged_at.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + if logged_at.tzinfo is None: + return logged_at.replace(tzinfo=timezone.utc) + return logged_at + + target_day = day + if target_day is None and days_ago is not None: + target_day = datetime.now(timezone.utc).date() - timedelta(days=int(days_ago)) + + if target_day is None: + return datetime.now(timezone.utc) + + return datetime.combine(target_day, time(12, 0), tzinfo=timezone.utc) + + def _profile_for_budget(self, profile: dict[str, Any] | None) -> dict[str, Any]: + if profile: + return profile + return { + "calorie_target": 2000, + "protein_g": 140, + "fat_g": 65, + "carbs_g": 200, + "water_l": 2.5, + "weight_kg": 70, + "activity_level": "moderate", + "weekly_workouts": 3, + } + + + def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]: + d = day or datetime.now(timezone.utc).date() + start = datetime.combine(d, time.min, tzinfo=timezone.utc) + end = datetime.combine(d, time.max, tzinfo=timezone.utc) + return start, end + + def get_daily_summary(self, day: date | None = None) -> dict[str, Any]: + start, end = self._day_bounds(day) + profile_row = self.get_profile() + profile = self._profile_for_budget(profile_row) + + foods = self.db.scalars( + select(FoodLog) + .where(FoodLog.user_id == self.user_id, FoodLog.logged_at >= start, FoodLog.logged_at <= end) + .order_by(FoodLog.logged_at) + ).all() + waters = self.db.scalars( + select(WaterLog) + .where(WaterLog.user_id == self.user_id, WaterLog.logged_at >= start, WaterLog.logged_at <= end) + .order_by(WaterLog.logged_at) + ).all() + workouts_rows = self.db.scalars( + select(WorkoutLog) + .where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end) + .order_by(WorkoutLog.logged_at) + ).all() + steps_rows = self.db.scalars( + select(StepLog) + .where(StepLog.user_id == self.user_id, StepLog.logged_at >= start, StepLog.logged_at <= end) + .order_by(StepLog.logged_at) + ).all() + + workouts = [self._workout_to_dict(w) for w in workouts_rows] + steps_total = sum(s.steps for s in steps_rows) + + totals = { + "calories": sum(f.calories for f in foods), + "protein_g": sum(f.protein_g for f in foods), + "fat_g": sum(f.fat_g for f in foods), + "carbs_g": sum(f.carbs_g for f in foods), + "water_ml": sum(w.amount_ml for w in waters), + "steps": steps_total, + } + + base_targets = build_base_targets(profile) + activity = compute_activity_bonus( + profile, + steps_total=steps_total, + workouts=workouts, + ) + effective_targets, targets_base = scale_targets( + base_targets, + activity.total_bonus_kcal, + ) + + return { + "date": (day or datetime.now(timezone.utc).date()).isoformat(), + "profile_configured": profile_row is not None, + "totals": totals, + "targets": effective_targets, + "targets_base": targets_base, + "activity": activity.to_dict(), + "meals": [self._food_to_dict(f) for f in foods], + "water": [self._water_to_dict(w) for w in waters], + "workouts": workouts, + "steps": [self._step_to_dict(s) for s in steps_rows], + "steps_total": steps_total, + } + + def log_meal( + self, + *, + description: str, + meal_type: str = "snack", + calories: float = 0, + protein_g: float = 0, + fat_g: float = 0, + carbs_g: float = 0, + source: str = "llm", + estimated: bool = True, + ) -> dict[str, Any]: + row = FoodLog( + user_id=self.user_id, + meal_type=meal_type[:32], + description=description[:2000], + calories=calories, + protein_g=protein_g, + fat_g=fat_g, + carbs_g=carbs_g, + source=source[:32], + estimated=estimated, + ) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + return {"ok": True, "meal": self._food_to_dict(row)} + + def log_water(self, amount_ml: int) -> dict[str, Any]: + row = WaterLog(user_id=self.user_id, amount_ml=max(0, amount_ml)) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + return {"ok": True, "water": self._water_to_dict(row)} + + def log_steps( + self, + steps: int, + *, + active_calories: float | None = None, + logged_at: datetime | str | None = None, + day: date | None = None, + days_ago: int | None = None, + notes: str = "", + source: str = "manual", + ) -> dict[str, Any]: + row = StepLog( + user_id=self.user_id, + steps=max(0, int(steps)), + active_calories=active_calories, + notes=notes[:2000], + source=source[:32], + logged_at=self._resolve_logged_at( + logged_at=logged_at, + day=day, + days_ago=days_ago, + ), + ) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + return {"ok": True, "step_log": self._step_to_dict(row)} + + def log_weight( + self, + weight_kg: float, + *, + body_fat_pct: float | None = None, + chest_cm: float | None = None, + waist_cm: float | None = None, + neck_cm: float | None = None, + hip_cm: float | None = None, + notes: str = "", + recorded_at: datetime | str | None = None, + day: date | None = None, + days_ago: int | None = None, + ) -> dict[str, Any]: + profile = self.get_profile() or {} + sex = profile.get("sex") or "male" + height_cm = float(profile.get("height_cm") or 170) + + computed = compute_body_composition( + sex=str(sex), + height_cm=height_cm, + weight_kg=weight_kg, + neck_cm=neck_cm, + waist_cm=waist_cm, + hip_cm=hip_cm, + body_fat_pct=body_fat_pct, + ) + + row = BodyMetric( + user_id=self.user_id, + weight_kg=weight_kg, + body_fat_pct=computed.get("body_fat_pct"), + body_fat_method=computed.get("body_fat_method"), + chest_cm=chest_cm, + waist_cm=waist_cm, + neck_cm=neck_cm, + hip_cm=hip_cm, + whr=computed.get("whr"), + lbm_kg=computed.get("lbm_kg"), + ffmi=computed.get("ffmi"), + notes=notes[:1000], + recorded_at=self._resolve_logged_at( + logged_at=recorded_at, + day=day, + days_ago=days_ago, + ), + ) + self.db.add(row) + profile_row = self._get_profile_row() + if profile_row: + profile_row.weight_kg = weight_kg + targets = compute_targets( + { + "sex": profile_row.sex, + "age": profile_row.age, + "height_cm": profile_row.height_cm, + "weight_kg": weight_kg, + "activity_level": profile_row.activity_level, + "goal": profile_row.goal, + } + ) + profile_row.calorie_target = targets["calorie_target"] + profile_row.protein_g = targets["protein_g"] + profile_row.fat_g = targets["fat_g"] + profile_row.carbs_g = targets["carbs_g"] + profile_row.water_l = targets["water_l"] + self.db.commit() + self.db.refresh(row) + metric = self._body_metric_to_dict(row) + return { + "ok": True, + "metric": metric, + "computed": { + "body_fat_pct": computed.get("body_fat_pct"), + "body_fat_method": computed.get("body_fat_method"), + "whr": computed.get("whr"), + "lbm_kg": computed.get("lbm_kg"), + "ffmi": computed.get("ffmi"), + "warnings": computed.get("warnings") or [], + }, + } + + def log_workout( + self, + *, + title: str, + notes: str = "", + duration_min: int | None = None, + exercises: list[dict[str, Any]] | None = None, + active_calories: float | None = None, + total_calories: float | None = None, + steps: int | None = None, + logged_at: datetime | str | None = None, + day: date | None = None, + days_ago: int | None = None, + ) -> dict[str, Any]: + row = WorkoutLog( + user_id=self.user_id, + title=title[:255], + notes=notes[:2000], + duration_min=duration_min, + active_calories=active_calories, + total_calories=total_calories, + steps=steps, + exercises_json=json.dumps(exercises or [], ensure_ascii=False), + logged_at=self._resolve_logged_at( + logged_at=logged_at, + day=day, + days_ago=days_ago, + ), + ) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + return {"ok": True, "workout": self._workout_to_dict(row)} + + def get_workout_stats( + self, + *, + days: int = 7, + end_day: date | None = None, + ) -> dict[str, Any]: + days = max(1, min(days, 90)) + end = end_day or datetime.now(timezone.utc).date() + start = end - timedelta(days=days - 1) + start_dt, _ = self._day_bounds(start) + _, end_dt = self._day_bounds(end) + + rows = self.db.scalars( + select(WorkoutLog) + .where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start_dt, WorkoutLog.logged_at <= end_dt) + .order_by(WorkoutLog.logged_at) + ).all() + + profile = self.get_profile() or {} + weekly_target = int(profile.get("weekly_workouts") or 3) + + count = len(rows) + duration_min = sum(r.duration_min or 0 for r in rows) + active_kcal = round( + sum(estimate_workout_active_kcal(self._workout_to_dict(r)) for r in rows), + 1, + ) + + days_with_workout: set[date] = set() + for row in rows: + if row.logged_at: + days_with_workout.add(row.logged_at.astimezone(timezone.utc).date()) + + streak = 0 + cursor = end + while cursor >= start: + if cursor in days_with_workout: + streak += 1 + cursor -= timedelta(days=1) + else: + break + + return { + "days": days, + "start_date": start.isoformat(), + "end_date": end.isoformat(), + "count": count, + "duration_min": duration_min, + "active_kcal": active_kcal, + "weekly_target": weekly_target, + "streak": streak, + } + + + def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]: + rows = self.db.scalars( + select(BodyMetric).where(BodyMetric.user_id == self.user_id).order_by(BodyMetric.recorded_at.desc()).limit(limit) + ).all() + return [self._body_metric_to_dict(r) for r in rows] + + def delete_food_log(self, log_id: int) -> bool: + row = self.db.get(FoodLog, log_id) + if not row or row.user_id != self.user_id: + return False + self.db.delete(row) + self.db.commit() + return True + + def delete_water_log(self, log_id: int) -> bool: + row = self.db.get(WaterLog, log_id) + if not row or row.user_id != self.user_id: + return False + self.db.delete(row) + self.db.commit() + return True + + def delete_workout_log(self, log_id: int) -> bool: + row = self.db.get(WorkoutLog, log_id) + if not row or row.user_id != self.user_id: + return False + self.db.delete(row) + self.db.commit() + return True + + def delete_step_log(self, log_id: int) -> bool: + row = self.db.get(StepLog, log_id) + if not row or row.user_id != self.user_id: + return False + self.db.delete(row) + self.db.commit() + return True + + def list_reminders(self) -> list[dict[str, Any]]: + rows = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id).order_by(FitnessReminder.kind)).all() + return [self._reminder_to_dict(r) for r in rows] + + def set_reminder( + self, + kind: str, + *, + enabled: bool | None = None, + hour: int | None = None, + minute: int | None = None, + interval_hours: int | None = None, + ) -> dict[str, Any]: + row = self.db.scalar( + select(FitnessReminder).where(FitnessReminder.user_id == self.user_id, FitnessReminder.kind == kind) + ) + if not row: + row = FitnessReminder(user_id=self.user_id, kind=kind) + self.db.add(row) + if enabled is not None: + row.enabled = enabled + if hour is not None: + row.hour = hour + if minute is not None: + row.minute = minute + if interval_hours is not None: + row.interval_hours = interval_hours + self.db.commit() + self.db.refresh(row) + return {"ok": True, "reminder": self._reminder_to_dict(row)} + + def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]: + return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)} + + def get_history( + self, + *, + days: int = 7, + end_day: date | None = None, + include_targets_base: bool = True, + ) -> dict[str, Any]: + days = max(1, min(days, 90)) + end = end_day or datetime.now(timezone.utc).date() + start = end - timedelta(days=days - 1) + summaries: list[dict[str, Any]] = [] + + for offset in range(days): + d = start + timedelta(days=offset) + full = self.get_daily_summary(d) + totals = full["totals"] + has_data = bool(full["meals"] or full["water"] or full["workouts"] or full["steps"]) + item: dict[str, Any] = { + "date": full["date"], + "has_data": has_data, + "totals": totals, + "targets": full["targets"], + "meal_count": len(full["meals"]), + "workout_count": len(full["workouts"]), + } + if include_targets_base: + item["targets_base"] = full.get("targets_base") + summaries.append(item) + + return { + "start_date": start.isoformat(), + "end_date": end.isoformat(), + "days": days, + "summaries": summaries, + } + + def snapshot(self) -> dict[str, Any]: + today = datetime.now(timezone.utc).date() + return { + "profile": self.get_profile(), + "today": self.get_daily_summary(today), + "history": self.get_history(days=7, end_day=today), + "workout_stats": self.get_workout_stats(days=7, end_day=today), + "body_metrics": self.list_body_metrics(limit=10), + "reminders": self.list_reminders(), + } + + @staticmethod + def _food_to_dict(row: FoodLog) -> dict[str, Any]: + return { + "id": row.id, + "meal_type": row.meal_type, + "description": row.description, + "calories": row.calories, + "protein_g": row.protein_g, + "fat_g": row.fat_g, + "carbs_g": row.carbs_g, + "source": row.source, + "estimated": row.estimated, + "logged_at": row.logged_at.isoformat() if row.logged_at else None, + } + + @staticmethod + def _water_to_dict(row: WaterLog) -> dict[str, Any]: + return { + "id": row.id, + "amount_ml": row.amount_ml, + "logged_at": row.logged_at.isoformat() if row.logged_at else None, + } + + @staticmethod + def _step_to_dict(row: StepLog) -> dict[str, Any]: + return { + "id": row.id, + "steps": row.steps, + "active_calories": row.active_calories, + "source": row.source, + "notes": row.notes, + "logged_at": row.logged_at.isoformat() if row.logged_at else None, + } + + @staticmethod + def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]: + try: + exercises = json.loads(row.exercises_json or "[]") + except json.JSONDecodeError: + exercises = [] + return { + "id": row.id, + "title": row.title, + "notes": row.notes, + "duration_min": row.duration_min, + "active_calories": row.active_calories, + "total_calories": row.total_calories, + "steps": row.steps, + "exercises": exercises, + "logged_at": row.logged_at.isoformat() if row.logged_at else None, + } + + @staticmethod + def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]: + return { + "id": row.id, + "kind": row.kind, + "hour": row.hour, + "minute": row.minute, + "interval_hours": row.interval_hours, + "enabled": row.enabled, + "last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None, + } diff --git a/backend/app/fitness/service.py.agent_read b/backend/app/fitness/service.py.agent_read new file mode 100644 index 0000000..adea1fa --- /dev/null +++ b/backend/app/fitness/service.py.agent_read @@ -0,0 +1,441 @@ +import json +from datetime import date, datetime, time, timedelta, timezone +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.db.models import ( + BodyMetric, + FitnessProfile, + FitnessReminder, + FoodLog, + WaterLog, + WorkoutLog, +) +from app.fitness.calculators import compute_targets, one_rep_max + +DEFAULT_REMINDERS = [ + {"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2}, + {"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None}, + {"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None}, + {"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None}, +] + + +class FitnessService: + def __init__(self, db: Session): + self.db = db + + def _get_profile_row(self) -> FitnessProfile | None: + return self.db.scalar(select(FitnessProfile).limit(1)) + + def get_profile(self) -> dict[str, Any] | None: + row = self._get_profile_row() + if not row: + return None + return self._profile_to_dict(row) + + def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]: + targets = compute_targets( + { + "sex": row.sex, + "age": row.age, + "height_cm": row.height_cm, + "weight_kg": row.weight_kg, + "activity_level": row.activity_level, + "goal": row.goal, + } + ) + return { + "sex": row.sex, + "age": row.age, + "height_cm": row.height_cm, + "weight_kg": row.weight_kg, + "activity_level": row.activity_level, + "goal": row.goal, + "target_weight_kg": row.target_weight_kg, + "weekly_workouts": row.weekly_workouts, + "calorie_target": row.calorie_target, + "protein_g": row.protein_g, + "fat_g": row.fat_g, + "carbs_g": row.carbs_g, + "water_l": row.water_l, + "computed": targets, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } + + def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]: + row = self._get_profile_row() + is_new = row is None + if is_new: + row = FitnessProfile() + self.db.add(row) + self.db.flush() + + for key in ( + "sex", "age", "height_cm", "weight_kg", "activity_level", + "goal", "target_weight_kg", "weekly_workouts", + ): + if key in updates and updates[key] is not None: + setattr(row, key, updates[key]) + + targets = compute_targets( + { + "sex": row.sex, + "age": row.age, + "height_cm": row.height_cm, + "weight_kg": row.weight_kg, + "activity_level": row.activity_level, + "goal": row.goal, + } + ) + row.calorie_target = targets["calorie_target"] + row.protein_g = targets["protein_g"] + row.fat_g = targets["fat_g"] + row.carbs_g = targets["carbs_g"] + row.water_l = targets["water_l"] + row.updated_at = datetime.now(timezone.utc) + + if is_new: + self._ensure_default_reminders() + + self.db.commit() + self.db.refresh(row) + return {"ok": True, "profile": self._profile_to_dict(row)} + + def _ensure_default_reminders(self) -> None: + existing = self.db.scalars(select(FitnessReminder)).all() + if existing: + return + for item in DEFAULT_REMINDERS: + self.db.add(FitnessReminder(**item)) + + def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]: + return compute_targets(params) + + def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]: + d = day or datetime.now(timezone.utc).date() + start = datetime.combine(d, time.min, tzinfo=timezone.utc) + end = datetime.combine(d, time.max, tzinfo=timezone.utc) + return start, end + + def get_daily_summary(self, day: date | None = None) -> dict[str, Any]: + start, end = self._day_bounds(day) + profile = self.get_profile() + + foods = self.db.scalars( + select(FoodLog) + .where(FoodLog.logged_at >= start, FoodLog.logged_at <= end) + .order_by(FoodLog.logged_at) + ).all() + waters = self.db.scalars( + select(WaterLog) + .where(WaterLog.logged_at >= start, WaterLog.logged_at <= end) + .order_by(WaterLog.logged_at) + ).all() + workouts = self.db.scalars( + select(WorkoutLog) + .where(WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end) + .order_by(WorkoutLog.logged_at) + ).all() + + totals = { + "calories": sum(f.calories for f in foods), + "protein_g": sum(f.protein_g for f in foods), + "fat_g": sum(f.fat_g for f in foods), + "carbs_g": sum(f.carbs_g for f in foods), + "water_ml": sum(w.amount_ml for w in waters), + } + + targets = profile or { + "calorie_target": 2000, + "protein_g": 140, + "fat_g": 65, + "carbs_g": 200, + "water_l": 2.5, + } + + return { + "date": (day or datetime.now(timezone.utc).date()).isoformat(), + "profile_configured": profile is not None, + "totals": totals, + "targets": { + "calories": targets.get("calorie_target", 2000), + "protein_g": targets.get("protein_g", 140), + "fat_g": targets.get("fat_g", 65), + "carbs_g": targets.get("carbs_g", 200), + "water_ml": targets.get("water_l", 2.5) * 1000, + }, + "meals": [self._food_to_dict(f) for f in foods], + "water": [self._water_to_dict(w) for w in waters], + "workouts": [self._workout_to_dict(w) for w in workouts], + } + + def log_meal( + self, + *, + description: str, + meal_type: str = "snack", + calories: float = 0, + protein_g: float = 0, + fat_g: float = 0, + carbs_g: float = 0, + source: str = "llm", + estimated: bool = True, + ) -> dict[str, Any]: + row = FoodLog( + meal_type=meal_type[:32], + description=description[:2000], + calories=calories, + protein_g=protein_g, + fat_g=fat_g, + carbs_g=carbs_g, + source=source[:32], + estimated=estimated, + ) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + return {"ok": True, "meal": self._food_to_dict(row)} + + def log_water(self, amount_ml: int) -> dict[str, Any]: + row = WaterLog(amount_ml=max(0, amount_ml)) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + return {"ok": True, "water": self._water_to_dict(row)} + + def log_weight( + self, + weight_kg: float, + *, + body_fat_pct: float | None = None, + chest_cm: float | None = None, + waist_cm: float | None = None, + notes: str = "", + ) -> dict[str, Any]: + row = BodyMetric( + weight_kg=weight_kg, + body_fat_pct=body_fat_pct, + chest_cm=chest_cm, + waist_cm=waist_cm, + notes=notes[:1000], + ) + self.db.add(row) + profile = self._get_profile_row() + if profile: + profile.weight_kg = weight_kg + targets = compute_targets( + { + "sex": profile.sex, + "age": profile.age, + "height_cm": profile.height_cm, + "weight_kg": weight_kg, + "activity_level": profile.activity_level, + "goal": profile.goal, + } + ) + profile.calorie_target = targets["calorie_target"] + profile.protein_g = targets["protein_g"] + profile.fat_g = targets["fat_g"] + profile.carbs_g = targets["carbs_g"] + profile.water_l = targets["water_l"] + self.db.commit() + self.db.refresh(row) + return { + "ok": True, + "metric": { + "id": row.id, + "weight_kg": row.weight_kg, + "recorded_at": row.recorded_at.isoformat() if row.recorded_at else None, + }, + } + + def log_workout( + self, + *, + title: str, + notes: str = "", + duration_min: int | None = None, + exercises: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + row = WorkoutLog( + title=title[:255], + notes=notes[:2000], + duration_min=duration_min, + exercises_json=json.dumps(exercises or [], ensure_ascii=False), + ) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + return {"ok": True, "workout": self._workout_to_dict(row)} + + def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]: + rows = self.db.scalars( + select(BodyMetric).order_by(BodyMetric.recorded_at.desc()).limit(limit) + ).all() + return [ + { + "id": r.id, + "weight_kg": r.weight_kg, + "body_fat_pct": r.body_fat_pct, + "chest_cm": r.chest_cm, + "waist_cm": r.waist_cm, + "notes": r.notes, + "recorded_at": r.recorded_at.isoformat() if r.recorded_at else None, + } + for r in rows + ] + + def delete_food_log(self, log_id: int) -> bool: + row = self.db.get(FoodLog, log_id) + if not row: + return False + self.db.delete(row) + self.db.commit() + return True + + def delete_water_log(self, log_id: int) -> bool: + row = self.db.get(WaterLog, log_id) + if not row: + return False + self.db.delete(row) + self.db.commit() + return True + + def delete_workout_log(self, log_id: int) -> bool: + row = self.db.get(WorkoutLog, log_id) + if not row: + return False + self.db.delete(row) + self.db.commit() + return True + + def list_reminders(self) -> list[dict[str, Any]]: + rows = self.db.scalars(select(FitnessReminder).order_by(FitnessReminder.kind)).all() + return [self._reminder_to_dict(r) for r in rows] + + def set_reminder( + self, + kind: str, + *, + enabled: bool | None = None, + hour: int | None = None, + minute: int | None = None, + interval_hours: int | None = None, + ) -> dict[str, Any]: + row = self.db.scalar( + select(FitnessReminder).where(FitnessReminder.kind == kind) + ) + if not row: + row = FitnessReminder(kind=kind) + self.db.add(row) + if enabled is not None: + row.enabled = enabled + if hour is not None: + row.hour = hour + if minute is not None: + row.minute = minute + if interval_hours is not None: + row.interval_hours = interval_hours + self.db.commit() + self.db.refresh(row) + return {"ok": True, "reminder": self._reminder_to_dict(row)} + + def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]: + return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)} + + def get_history( + self, + *, + days: int = 7, + end_day: date | None = None, + ) -> dict[str, Any]: + days = max(1, min(days, 90)) + end = end_day or datetime.now(timezone.utc).date() + start = end - timedelta(days=days - 1) + summaries: list[dict[str, Any]] = [] + + for offset in range(days): + d = start + timedelta(days=offset) + full = self.get_daily_summary(d) + totals = full["totals"] + has_data = bool(full["meals"] or full["water"] or full["workouts"]) + summaries.append( + { + "date": full["date"], + "has_data": has_data, + "totals": totals, + "targets": full["targets"], + "meal_count": len(full["meals"]), + "workout_count": len(full["workouts"]), + } + ) + + return { + "start_date": start.isoformat(), + "end_date": end.isoformat(), + "days": days, + "summaries": summaries, + } + + def snapshot(self) -> dict[str, Any]: + today = datetime.now(timezone.utc).date() + return { + "profile": self.get_profile(), + "today": self.get_daily_summary(today), + "history": self.get_history(days=7, end_day=today), + "body_metrics": self.list_body_metrics(limit=10), + "reminders": self.list_reminders(), + } + + @staticmethod + def _food_to_dict(row: FoodLog) -> dict[str, Any]: + return { + "id": row.id, + "meal_type": row.meal_type, + "description": row.description, + "calories": row.calories, + "protein_g": row.protein_g, + "fat_g": row.fat_g, + "carbs_g": row.carbs_g, + "source": row.source, + "estimated": row.estimated, + "logged_at": row.logged_at.isoformat() if row.logged_at else None, + } + + @staticmethod + def _water_to_dict(row: WaterLog) -> dict[str, Any]: + return { + "id": row.id, + "amount_ml": row.amount_ml, + "logged_at": row.logged_at.isoformat() if row.logged_at else None, + } + + @staticmethod + def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]: + try: + exercises = json.loads(row.exercises_json or "[]") + except json.JSONDecodeError: + exercises = [] + return { + "id": row.id, + "title": row.title, + "notes": row.notes, + "duration_min": row.duration_min, + "exercises": exercises, + "logged_at": row.logged_at.isoformat() if row.logged_at else None, + } + + @staticmethod + def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]: + return { + "id": row.id, + "kind": row.kind, + "hour": row.hour, + "minute": row.minute, + "interval_hours": row.interval_hours, + "enabled": row.enabled, + "last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None, + } diff --git a/backend/app/fitness/structuring.py b/backend/app/fitness/structuring.py index 67b7761..530535c 100644 --- a/backend/app/fitness/structuring.py +++ b/backend/app/fitness/structuring.py @@ -1,66 +1,96 @@ -import json -from typing import Any - -from app.llm.client import LLMClient -from app.projects.structuring import strip_markdown_json - -MEAL_PROMPT = """ -Преобразуй описание еды в JSON. Только JSON, без markdown. -Схема: -{ - "meal_type": "breakfast|lunch|dinner|snack", - "description": "краткое описание", - "calories": 0, - "protein_g": 0, - "fat_g": 0, - "carbs_g": 0, - "estimated": true -} -Правила: -- Оцени ккал и БЖУ по типичным значениям для России/СНГ. -- Все числа — float/int, метрическая система (г, ккал). -- meal_type угадай из контекста или snack. -- estimated всегда true для LLM-оценки. -""".strip() - -WORKOUT_PROMPT = """ -Преобразуй описание тренировки в JSON. Только JSON. -Схема: -{ - "title": "название", - "duration_min": null, - "notes": "", - "exercises": [ - {"name": "жим лёжа", "sets": 3, "reps": 8, "weight_kg": 80} - ] -} -Правила: -- weight_kg в кг, метрическая система. -- Если данных нет — null или пустой массив. -""".strip() - - -async def structure_meal(raw_text: str) -> dict[str, Any]: - llm = LLMClient() - result = await llm.complete( - [ - {"role": "system", "content": MEAL_PROMPT}, - {"role": "user", "content": raw_text}, - ], - temperature=0.2, - ) - raw = strip_markdown_json(result.get("content") or "") - return json.loads(raw) - - -async def structure_workout(raw_text: str) -> dict[str, Any]: - llm = LLMClient() - result = await llm.complete( - [ - {"role": "system", "content": WORKOUT_PROMPT}, - {"role": "user", "content": raw_text}, - ], - temperature=0.2, - ) - raw = strip_markdown_json(result.get("content") or "") - return json.loads(raw) +import json +from typing import Any + +from app.llm.client import LLMClient +from app.projects.structuring import strip_markdown_json + +MEAL_PROMPT = """ +Преобразуй описание еды в JSON. Только JSON, без markdown. +Схема: +{ + "meal_type": "breakfast|lunch|dinner|snack", + "description": "краткое описание", + "calories": 0, + "protein_g": 0, + "fat_g": 0, + "carbs_g": 0, + "estimated": true +} +Правила: +- Оцени ккал и БЖУ по типичным значениям для России/СНГ. +- Все числа — float/int, метрическая система (г, ккал). +- meal_type угадай из контекста или snack. +- estimated всегда true для LLM-оценки. +""".strip() + +WORKOUT_PROMPT = """ +Преобразуй описание тренировки в JSON. Только JSON. +Формат: +{ + "title": "название", + "duration_min": null, + "active_calories": null, + "total_calories": null, + "steps": null, + "notes": "", + "exercises": [ + {"name": "имя упраж", "sets": 3, "reps": 8, "weight_kg": 80} + ] +} +Правила: +- weight_kg в кг, округляй разумно. +- active_calories / total_calories / steps — если упомянуты в тексте, иначе null. +- Если данных нет — null или пустой массив. +""".strip() + +STEPS_PROMPT = """ +Преобразуй запись о шагах в JSON. Только JSON. +Формат: +{ + "steps": 0, + "active_calories": null, + "notes": "" +} +Правила: +- steps — целое число шагов за день. +- active_calories — только если явно указаны. +""".strip() + + +async def structure_meal(raw_text: str) -> dict[str, Any]: + llm = LLMClient() + result = await llm.complete( + [ + {"role": "system", "content": MEAL_PROMPT}, + {"role": "user", "content": raw_text}, + ], + temperature=0.2, + ) + raw = strip_markdown_json(result.get("content") or "") + return json.loads(raw) + + +async def structure_workout(raw_text: str) -> dict[str, Any]: + llm = LLMClient() + result = await llm.complete( + [ + {"role": "system", "content": WORKOUT_PROMPT}, + {"role": "user", "content": raw_text}, + ], + temperature=0.2, + ) + raw = strip_markdown_json(result.get("content") or "") + return json.loads(raw) + + +async def structure_steps(raw_text: str) -> dict[str, Any]: + llm = LLMClient() + result = await llm.complete( + [ + {"role": "system", "content": STEPS_PROMPT}, + {"role": "user", "content": raw_text}, + ], + temperature=0.2, + ) + raw = strip_markdown_json(result.get("content") or "") + return json.loads(raw) diff --git a/backend/app/homelab/context.py b/backend/app/homelab/context.py index 630b4ca..f016ac4 100644 --- a/backend/app/homelab/context.py +++ b/backend/app/homelab/context.py @@ -1,42 +1,42 @@ -from datetime import datetime -from zoneinfo import ZoneInfo - -from sqlalchemy.orm import Session - -from app.memory.service import MemoryService - -WEEKDAY_RU = ( - "понедельник", - "вторник", - "среда", - "четверг", - "пятница", - "суббота", - "воскресенье", -) - -DEFAULT_TIMEZONE = "Europe/Moscow" - - -def resolve_timezone(db: Session) -> str: - profile = MemoryService(db).get_profile() - tz = (profile.get("timezone") or "").strip() - return tz or DEFAULT_TIMEZONE - - -def format_datetime_context(db: Session) -> str: - tz_name = resolve_timezone(db) - try: - tz = ZoneInfo(tz_name) - except Exception: - tz = ZoneInfo(DEFAULT_TIMEZONE) - tz_name = DEFAULT_TIMEZONE - - now = datetime.now(tz) - weekday = WEEKDAY_RU[now.weekday()] - lines = [ - "[Текущее время]", - f"Сейчас: {now.strftime('%Y-%m-%d %H:%M')} ({weekday}), часовой пояс {tz_name}.", - "Учитывай время при ответах о «сегодня», «утром», «вечером» и расписании.", - ] - return "\n".join(lines) +from datetime import datetime +from zoneinfo import ZoneInfo + +from sqlalchemy.orm import Session + +from app.memory.service import MemoryService + +WEEKDAY_RU = ( + "понедельник", + "вторник", + "среда", + "четверг", + "пятница", + "суббота", + "воскресенье", +) + +DEFAULT_TIMEZONE = "Europe/Moscow" + + +def resolve_timezone(db: Session, user_id: int) -> str: + profile = MemoryService(db, user_id).get_profile() + tz = (profile.get("timezone") or "").strip() + return tz or DEFAULT_TIMEZONE + + +def format_datetime_context(db: Session, user_id: int) -> str: + tz_name = resolve_timezone(db, user_id) + try: + tz = ZoneInfo(tz_name) + except Exception: + tz = ZoneInfo(DEFAULT_TIMEZONE) + tz_name = DEFAULT_TIMEZONE + + now = datetime.now(tz) + weekday = WEEKDAY_RU[now.weekday()] + lines = [ + "[Текущее время]", + f"Сейчас: {now.strftime('%Y-%m-%d %H:%M')} ({weekday}), часовой пояс {tz_name}.", + "Учитывай время при ответах о «сегодня», «утром», «вечером» и расписании.", + ] + return "\n".join(lines) diff --git a/backend/app/homelab/image_gen.py b/backend/app/homelab/image_gen.py index 3320386..e6c51a3 100644 --- a/backend/app/homelab/image_gen.py +++ b/backend/app/homelab/image_gen.py @@ -1,130 +1,131 @@ -from typing import Any - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.character.service import CharacterService -from app.config import get_settings -from app.db.models import Message -from app.homelab.comfyui import ComfyUIClient -from app.integrations.rp_chat import RpChatClient - - -def _card_image_settings() -> dict[str, Any]: - return CharacterService().get_card().get("data", {}) - - -def _session_messages(db: Session, session_id: int | None, limit: int = 8) -> list[dict[str, str]]: - if not session_id: - return [] - rows = db.scalars( - select(Message) - .where( - Message.session_id == session_id, - Message.role.in_(("user", "assistant")), - ) - .order_by(Message.created_at.desc()) - .limit(limit) - ).all() - rows = list(reversed(rows)) - return [{"role": m.role, "content": (m.content or "").strip()} for m in rows if m.content.strip()] - - -def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str: - if not lora_name or f"" - - -async def generate_image( - db: Session, - *, - session_id: int | None = None, - draw_self: bool = False, - scene_description: str = "", -) -> dict[str, Any]: - card = _card_image_settings() - settings = get_settings() - - if not card.get("sd_enabled", True): - return {"ok": False, "error": "Генерация изображений отключена в настройках персонажа"} - - if not draw_self and not scene_description.strip(): - return {"ok": False, "error": "Нужен draw_self=true или scene_description"} - - appearance = (card.get("appearance_tags") or "").strip() - if draw_self and not appearance: - return { - "ok": False, - "error": "Заполни appearance_tags в настройках персонажа для «нарисуй себя»", - } - - messages = _session_messages(db, session_id) - if scene_description.strip(): - messages = messages + [{"role": "user", "content": scene_description.strip()}] - elif draw_self and messages: - messages = messages + [{"role": "user", "content": "Illustrate the current scene with the character."}] - elif draw_self: - messages = [{"role": "user", "content": "Portrait of the character, looking at viewer, friendly expression."}] - - if settings.rp_chat_enabled: - appearance_override = (card.get("appearance_tags") or "").strip() or None - return await _generate_via_rp_chat(card, messages, appearance_override) - - return await _generate_via_local_comfy(scene_description or "anime character portrait") - - -async def _generate_via_rp_chat( - card: dict[str, Any], - messages: list[dict[str, str]], - appearance_override: str | None, -) -> dict[str, Any]: - client = RpChatClient() - persona_id = (card.get("rp_persona_id") or "").strip() or "default" - override = appearance_override or (card.get("appearance_tags") or "").strip() or None - - prompt_result = await client.sd_prompt( - persona_id, - messages, - appearance_override=override, - ) - if not prompt_result.get("ok"): - return prompt_result - - positive = ( - prompt_result.get("hybrid_positive") - or prompt_result.get("tag_positive") - or "" - ).strip() - negative = (prompt_result.get("negative") or "").strip() - if not positive: - return {"ok": False, "error": "RP-чат не вернул промпт", "raw": prompt_result} - - lora = (card.get("lora_name") or "").strip() - if lora: - weight = float(card.get("lora_weight") or 0.8) - positive = _append_lora(positive, lora, weight) - - gen_result = await client.generate(positive, negative) - if not gen_result.get("ok"): - return gen_result - - saved = await client.save_image_locally(gen_result["image_path"]) - if not saved.get("ok"): - return saved - - return { - "ok": True, - "url": saved["url"], - "filename": saved["filename"], - "prompt": positive, - "backend": "rp_chat", - "persona_id": persona_id, - } - - -async def _generate_via_local_comfy(prompt: str) -> dict[str, Any]: - result = await ComfyUIClient().generate_image(prompt) - if result.get("ok"): - result["backend"] = "comfyui_local" - return result +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.character.service import CharacterService +from app.config import get_settings +from app.db.models import Message +from app.homelab.comfyui import ComfyUIClient +from app.integrations.rp_chat import RpChatClient + + +def _card_image_settings(db: Session, user_id: int) -> dict[str, Any]: + return CharacterService(db, user_id).get_card().get("data", {}) + + +def _session_messages(db: Session, session_id: int | None, limit: int = 8) -> list[dict[str, str]]: + if not session_id: + return [] + rows = db.scalars( + select(Message) + .where( + Message.session_id == session_id, + Message.role.in_(("user", "assistant")), + ) + .order_by(Message.created_at.desc()) + .limit(limit) + ).all() + rows = list(reversed(rows)) + return [{"role": m.role, "content": (m.content or "").strip()} for m in rows if m.content.strip()] + + +def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str: + if not lora_name or f"" + + +async def generate_image( + db: Session, + *, + user_id: int, + session_id: int | None = None, + draw_self: bool = False, + scene_description: str = "", +) -> dict[str, Any]: + card = _card_image_settings(db, user_id) + settings = get_settings() + + if not card.get("sd_enabled", True): + return {"ok": False, "error": "Генерация изображений отключена в настройках персонажа"} + + if not draw_self and not scene_description.strip(): + return {"ok": False, "error": "Нужен draw_self=true или scene_description"} + + appearance = (card.get("appearance_tags") or "").strip() + if draw_self and not appearance: + return { + "ok": False, + "error": "Заполни appearance_tags в настройках персонажа для «нарисуй себя»", + } + + messages = _session_messages(db, session_id) + if scene_description.strip(): + messages = messages + [{"role": "user", "content": scene_description.strip()}] + elif draw_self and messages: + messages = messages + [{"role": "user", "content": "Illustrate the current scene with the character."}] + elif draw_self: + messages = [{"role": "user", "content": "Portrait of the character, looking at viewer, friendly expression."}] + + if settings.rp_chat_enabled: + appearance_override = (card.get("appearance_tags") or "").strip() or None + return await _generate_via_rp_chat(card, messages, appearance_override) + + return await _generate_via_local_comfy(scene_description or "anime character portrait") + + +async def _generate_via_rp_chat( + card: dict[str, Any], + messages: list[dict[str, str]], + appearance_override: str | None, +) -> dict[str, Any]: + client = RpChatClient() + persona_id = (card.get("rp_persona_id") or "").strip() or "default" + override = appearance_override or (card.get("appearance_tags") or "").strip() or None + + prompt_result = await client.sd_prompt( + persona_id, + messages, + appearance_override=override, + ) + if not prompt_result.get("ok"): + return prompt_result + + positive = ( + prompt_result.get("hybrid_positive") + or prompt_result.get("tag_positive") + or "" + ).strip() + negative = (prompt_result.get("negative") or "").strip() + if not positive: + return {"ok": False, "error": "RP-чат не вернул промпт", "raw": prompt_result} + + lora = (card.get("lora_name") or "").strip() + if lora: + weight = float(card.get("lora_weight") or 0.8) + positive = _append_lora(positive, lora, weight) + + gen_result = await client.generate(positive, negative) + if not gen_result.get("ok"): + return gen_result + + saved = await client.save_image_locally(gen_result["image_path"]) + if not saved.get("ok"): + return saved + + return { + "ok": True, + "url": saved["url"], + "filename": saved["filename"], + "prompt": positive, + "backend": "rp_chat", + "persona_id": persona_id, + } + + +async def _generate_via_local_comfy(prompt: str) -> dict[str, Any]: + result = await ComfyUIClient().generate_image(prompt) + if result.get("ok"): + result["backend"] = "comfyui_local" + return result diff --git a/backend/app/homelab_scoped/__init__.py b/backend/app/homelab_scoped/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/homelab_scoped/notices.py b/backend/app/homelab_scoped/notices.py new file mode 100644 index 0000000..d96f3b0 --- /dev/null +++ b/backend/app/homelab_scoped/notices.py @@ -0,0 +1,5 @@ +from app.chat.notice_inbox import post_notice_to_latest_chat + + +def post_chat_notice(content: str, user_id: int) -> None: + post_notice_to_latest_chat(content, user_id) diff --git a/backend/app/homelab_scoped/watcher.py b/backend/app/homelab_scoped/watcher.py new file mode 100644 index 0000000..4d7fbf9 --- /dev/null +++ b/backend/app/homelab_scoped/watcher.py @@ -0,0 +1,164 @@ +import asyncio +import logging +import random +from datetime import datetime +from zoneinfo import ZoneInfo + +import httpx +from sqlalchemy import select + +from app.config import get_settings +from app.db.base import SessionLocal +from app.db.models import User +from app.homelab.comfyui import ComfyUIClient +from app.homelab.context import resolve_timezone +from app.homelab.digest import build_morning_digest +from app.homelab.monitoring import check_netdata_alerts +from app.homelab_scoped.notices import post_chat_notice +from app.homelab.state import get_state, set_state + +logger = logging.getLogger(__name__) + +WATCH_INTERVAL_SEC = 60 +_netdata_tick = 0 + + +async def homelab_watcher_loop() -> None: + global _netdata_tick + while True: + try: + await asyncio.sleep(WATCH_INTERVAL_SEC) + await _tick_morning_digest() + await _tick_rofl() + settings = get_settings() + _netdata_tick += WATCH_INTERVAL_SEC + if _netdata_tick >= settings.netdata_poll_interval_sec: + _netdata_tick = 0 + await _tick_netdata() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Homelab watcher error") + + +async def _tick_morning_digest() -> None: + settings = get_settings() + if not settings.morning_digest_enabled: + return + + db = SessionLocal() + try: + users = db.scalars(select(User).where(User.is_active.is_(True))).all() + digest = build_morning_digest(db, include_news=True) + for user in users: + tz_name = resolve_timezone(db, user.id) + try: + tz = ZoneInfo(tz_name) + except Exception: + tz = ZoneInfo("Europe/Moscow") + + now = datetime.now(tz) + target_min = settings.morning_digest_hour * 60 + settings.morning_digest_minute + current_min = now.hour * 60 + now.minute + if current_min < target_min or current_min >= target_min + 3: + continue + + today = now.date().isoformat() + state_key = f"last_morning_digest_date:{user.id}" + if get_state(db, state_key) == today: + continue + + post_chat_notice(digest, user.id) + set_state(db, state_key, today) + finally: + db.close() + + +async def _tick_netdata() -> None: + db = SessionLocal() + try: + notices = check_netdata_alerts(db) + if not notices: + return + users = db.scalars(select(User).where(User.is_active.is_(True))).all() + for user in users: + for notice in notices: + post_chat_notice(notice, user.id) + finally: + db.close() + + +async def _comfyui_reachable(base_url: str) -> bool: + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(3.0, connect=2.0)) as client: + response = await client.get(f"{base_url.rstrip('/')}/system_stats") + return response.status_code < 500 + except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError): + return False + + +async def _tick_rofl() -> None: + settings = get_settings() + if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled: + return + + db = SessionLocal() + try: + users = db.scalars(select(User).where(User.is_active.is_(True))).all() + for user in users: + tz_name = resolve_timezone(db, user.id) + try: + tz = ZoneInfo(tz_name) + except Exception: + tz = ZoneInfo("Europe/Moscow") + now = datetime.now(tz) + last_raw = get_state(db, f"last_comfy_rofl_at:{user.id}") + if last_raw: + try: + last_at = datetime.fromisoformat(last_raw) + if last_at.tzinfo is None: + last_at = last_at.replace(tzinfo=tz) + if (now - last_at).total_seconds() < settings.comfyui_rofl_min_interval_hours * 3600: + continue + except ValueError: + pass + + if random.random() > settings.comfyui_rofl_probability: + continue + + today = now.date().isoformat() + count_key = f"comfy_rofl_count_{today}:{user.id}" + count_raw = get_state(db, count_key) or "0" + try: + count = int(count_raw) + except ValueError: + count = 0 + if count >= settings.comfyui_rofl_max_per_day: + continue + + client = ComfyUIClient() + if not await _comfyui_reachable(client.base_url): + continue + + prompt = client.random_rofl_prompt() + try: + result = await asyncio.wait_for( + client.generate_image(prompt), + timeout=settings.comfyui_timeout_sec + 15, + ) + except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc: + logger.warning("Rofl image skipped (ComfyUI): %s", exc) + continue + if not result.get("ok"): + logger.warning("Rofl image failed: %s", result.get("error")) + continue + + url = result.get("url", "") + post_chat_notice( + f"🎨 **Рофл дня**\n\n![rofl]({url})\n\n_{prompt}_", + user.id, + ) + set_state(db, count_key, str(count + 1)) + set_state(db, f"last_comfy_rofl_at:{user.id}", now.isoformat()) + finally: + db.close() diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py index 7c67fc8..6bacab3 100644 --- a/backend/app/llm/client.py +++ b/backend/app/llm/client.py @@ -1,269 +1,323 @@ -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 {} +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.tools_enabled = settings.openrouter_tools_enabled + self.client = AsyncOpenAI( + api_key=settings.openrouter_api_key, + base_url=settings.openrouter_base_url, + ) + + def _runtime(self) -> tuple[str, str, str]: + from app.db.base import SessionLocal + from app.settings.service import SettingsService + + settings = get_settings() + db = SessionLocal() + try: + svc = SettingsService(db) + model = str(svc.get_effective("openrouter_model")) + extract = str(svc.get_effective("memory_extract_model")) + effort = str(svc.get_effective("openrouter_reasoning_effort")).strip().lower() + return model, extract, effort + finally: + db.close() + + @property + def model(self) -> str: + return self._runtime()[0] + + @property + def memory_extract_model(self) -> str: + return self._runtime()[1] + + @property + def reasoning_effort(self) -> str: + return self._runtime()[2] + + 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 + + usage = getattr(chunk, "usage", None) + if usage is not None: + logger.info( + "LLM stream usage: prompt=%s completion=%s total=%s", + getattr(usage, "prompt_tokens", None), + getattr(usage, "completion_tokens", None), + getattr(usage, "total_tokens", None), + ) + + 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) + usage = getattr(response, "usage", None) + if usage is not None: + logger.info( + "LLM complete usage: prompt=%s completion=%s total=%s model=%s", + getattr(usage, "prompt_tokens", None), + getattr(usage, "completion_tokens", None), + getattr(usage, "total_tokens", None), + kwargs.get("model"), + ) + 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 {} + + async def embed(self, texts: list[str]) -> list[list[float]]: + settings = get_settings() + if not texts: + return [] + response = await self.client.embeddings.create( + model=settings.embedding_model, + input=texts, + ) + return [item.embedding for item in response.data] + diff --git a/backend/app/llm/client.py.refactor_bak b/backend/app/llm/client.py.refactor_bak new file mode 100644 index 0000000..7c67fc8 --- /dev/null +++ b/backend/app/llm/client.py.refactor_bak @@ -0,0 +1,269 @@ +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 b/backend/app/main.py index 387bac0..9f43925 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,54 +1,65 @@ -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() +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_scoped.watcher import homelab_watcher_loop +from app.pomodoro.watcher import pomodoro_watcher_loop +from app.reminders_scoped.watcher import reminders_watcher_loop + + +@asynccontextmanager +async def lifespan(_: FastAPI): + init_db() + from app.db.migrate_fitness import run_fitness_migrations + + run_fitness_migrations() + from app.db.migrate_multi_user import run_multi_user_migrations + + run_multi_user_migrations() + settings = get_settings() + if settings.rag_enabled: + from app.rag.store import ensure_collections + + ensure_collections() + 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/main.py.refactor_bak b/backend/app/main.py.refactor_bak new file mode 100644 index 0000000..387bac0 --- /dev/null +++ b/backend/app/main.py.refactor_bak @@ -0,0 +1,54 @@ +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 b/backend/app/memory/context.py index c950e00..f6edb84 100644 --- a/backend/app/memory/context.py +++ b/backend/app/memory/context.py @@ -1,83 +1,89 @@ -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) +from typing import Any + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.memory.service import MemoryService + +from app.memory.parse import is_identity_question + +PROFILE_KEYS = ("name", "age", "timezone", "language", "notes") + + +def get_memory_snapshot( + db: Session, + user_id: int, + session_id: int | None = None, + query: str | None = None, +) -> dict[str, Any]: + return MemoryService(db, user_id).snapshot(session_id, query=query) + + +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))}):") + limit = get_settings().memory_facts_in_context + for fact in facts[:limit]: + 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/context.py.refactor_bak b/backend/app/memory/context.py.refactor_bak new file mode 100644 index 0000000..c950e00 --- /dev/null +++ b/backend/app/memory/context.py.refactor_bak @@ -0,0 +1,83 @@ +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/extract.py b/backend/app/memory/extract.py index 7e591bd..f07a405 100644 --- a/backend/app/memory/extract.py +++ b/backend/app/memory/extract.py @@ -1,152 +1,153 @@ -import json -import logging -import re -from typing import Any - -from sqlalchemy.orm import Session - -from app.config import get_settings -from app.llm.client import LLMClient -from app.memory.service import MemoryService -from app.projects.structuring import strip_markdown_json - -logger = logging.getLogger(__name__) - -SKIP_USER_PATTERN = re.compile( - r"^(ок|ok|да|нет|спасибо|thanks|\.{1,3}|👍|\+1)$", - re.IGNORECASE, -) - -EXTRACTION_PROMPT = """ -Ты извлекаешь долгосрочные факты о пользователе из фрагмента диалога. -Ответь ТОЛЬКО JSON без markdown. - -Схема: -{ - "facts": [ - {"content": "текст факта", "category": "preference|person|habit|project|fact", "importance": 1} - ], - "profile": {"name": "", "age": "", "timezone": "", "notes": ""} -} - -Правила: -- Сохраняй устойчивое: имя, возраст, предпочтения, привычки, проекты, семья, работа. -- НЕ сохраняй: статус помидоро, погоду, разовые команды, ролевую игру, выдумки ассистента. -- profile — только поля с новыми значениями (пустые строки не включай). -- facts — короткие утверждения от первого лица пользователя («люблю кофе», «меня зовут …»). -- Если нечего сохранять — {"facts": [], "profile": {}}. -- Не дублируй уже известное (см. текущий профиль и факты ниже). -- importance: 5 критично (имя), 4 важно, 3 обычно, 2 мелочь. -""".strip() - - -def _should_skip_extraction(user_text: str) -> bool: - text = user_text.strip() - if len(text) < 4: - return True - if SKIP_USER_PATTERN.match(text): - return True - return False - - -async def _call_extractor( - user_text: str, - assistant_text: str, - snapshot: dict[str, Any], -) -> dict[str, Any]: - profile = snapshot.get("profile") or {} - facts = snapshot.get("facts") or [] - known = [ - f"Профиль: {json.dumps(profile, ensure_ascii=False)}", - "Факты:", - *[f"- {f.get('content')}" for f in facts[:30]], - ] - - settings = get_settings() - extract_model = settings.memory_extract_model.strip() or None - - llm = LLMClient() - result = await llm.complete( - [ - {"role": "system", "content": EXTRACTION_PROMPT}, - { - "role": "user", - "content": ( - "\n".join(known) - + "\n\n---\nДиалог:\nПользователь: " - + user_text - + "\nАссистент: " - + assistant_text[:1500] - ), - }, - ], - temperature=0.2, - model=extract_model, - for_extraction=True, - ) - raw = strip_markdown_json(result.get("content") or "") - if not raw: - return {"facts": [], "profile": {}} - parsed = json.loads(raw) - if not isinstance(parsed, dict): - return {"facts": [], "profile": {}} - return parsed - - -async def extract_after_turn( - db: Session, - session_id: int, - user_text: str, - assistant_text: str, - *, - force: bool = False, -) -> dict[str, Any]: - if not force and _should_skip_extraction(user_text): - return {"ok": True, "skipped": "short_message", "saved": []} - - if not (assistant_text or "").strip(): - return {"ok": True, "skipped": "no_assistant_reply", "saved": []} - - memory = MemoryService(db) - snapshot = memory.snapshot(session_id) - - try: - parsed = await _call_extractor(user_text, assistant_text, snapshot) - except (json.JSONDecodeError, Exception) as exc: - logger.warning("Memory extraction failed: %s", exc) - return {"ok": False, "error": str(exc), "saved": []} - - saved: list[dict[str, Any]] = [] - - profile_updates = parsed.get("profile") or {} - if isinstance(profile_updates, dict): - filtered = { - k: str(v).strip() - for k, v in profile_updates.items() - if v and str(v).strip() - } - if filtered: - memory.update_profile(filtered) - saved.append({"type": "profile", "updates": filtered}) - - facts = parsed.get("facts") or [] - if isinstance(facts, list): - for item in facts: - if not isinstance(item, dict): - continue - content = (item.get("content") or "").strip() - if not content or len(content) < 3: - continue - try: - result = memory.remember_fact( - content, - category=str(item.get("category") or "fact")[:64], - importance=int(item.get("importance") or 3), - session_id=session_id, - source="auto", - ) - saved.append({"type": "fact", **result}) - except ValueError: - continue - - return {"ok": True, "saved": saved, "count": len(saved)} +import json +import logging +import re +from typing import Any + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.llm.client import LLMClient +from app.memory.service import MemoryService +from app.projects.structuring import strip_markdown_json + +logger = logging.getLogger(__name__) + +SKIP_USER_PATTERN = re.compile( + r"^(ок|ok|да|нет|спасибо|thanks|\.{1,3}|👍|\+1)$", + re.IGNORECASE, +) + +EXTRACTION_PROMPT = """ +Ты извлекаешь долгосрочные факты о пользователе из фрагмента диалога. +Ответь ТОЛЬКО JSON без markdown. + +Схема: +{ + "facts": [ + {"content": "текст факта", "category": "preference|person|habit|project|fact", "importance": 1} + ], + "profile": {"name": "", "age": "", "timezone": "", "notes": ""} +} + +Правила: +- Сохраняй устойчивое: имя, возраст, предпочтения, привычки, проекты, семья, работа. +- НЕ сохраняй: статус помидоро, погоду, разовые команды, ролевую игру, выдумки ассистента. +- profile — только поля с новыми значениями (пустые строки не включай). +- facts — короткие утверждения от первого лица пользователя («люблю кофе», «меня зовут …»). +- Если нечего сохранять — {"facts": [], "profile": {}}. +- Не дублируй уже известное (см. текущий профиль и факты ниже). +- importance: 5 критично (имя), 4 важно, 3 обычно, 2 мелочь. +""".strip() + + +def _should_skip_extraction(user_text: str) -> bool: + text = user_text.strip() + if len(text) < 4: + return True + if SKIP_USER_PATTERN.match(text): + return True + return False + + +async def _call_extractor( + user_text: str, + assistant_text: str, + snapshot: dict[str, Any], +) -> dict[str, Any]: + profile = snapshot.get("profile") or {} + facts = snapshot.get("facts") or [] + known = [ + f"Профиль: {json.dumps(profile, ensure_ascii=False)}", + "Факты:", + *[f"- {f.get('content')}" for f in facts[:30]], + ] + + settings = get_settings() + extract_model = settings.memory_extract_model.strip() or None + + llm = LLMClient() + result = await llm.complete( + [ + {"role": "system", "content": EXTRACTION_PROMPT}, + { + "role": "user", + "content": ( + "\n".join(known) + + "\n\n---\nДиалог:\nПользователь: " + + user_text + + "\nАссистент: " + + assistant_text[:1500] + ), + }, + ], + temperature=0.2, + model=extract_model, + for_extraction=True, + ) + raw = strip_markdown_json(result.get("content") or "") + if not raw: + return {"facts": [], "profile": {}} + parsed = json.loads(raw) + if not isinstance(parsed, dict): + return {"facts": [], "profile": {}} + return parsed + + +async def extract_after_turn( + db: Session, + session_id: int, + user_text: str, + assistant_text: str, + *, + user_id: int, + force: bool = False, +) -> dict[str, Any]: + if not force and _should_skip_extraction(user_text): + return {"ok": True, "skipped": "short_message", "saved": []} + + if not (assistant_text or "").strip(): + return {"ok": True, "skipped": "no_assistant_reply", "saved": []} + + memory = MemoryService(db, user_id) + snapshot = memory.snapshot(session_id) + + try: + parsed = await _call_extractor(user_text, assistant_text, snapshot) + except (json.JSONDecodeError, Exception) as exc: + logger.warning("Memory extraction failed: %s", exc) + return {"ok": False, "error": str(exc), "saved": []} + + saved: list[dict[str, Any]] = [] + + profile_updates = parsed.get("profile") or {} + if isinstance(profile_updates, dict): + filtered = { + k: str(v).strip() + for k, v in profile_updates.items() + if v and str(v).strip() + } + if filtered: + memory.update_profile(filtered) + saved.append({"type": "profile", "updates": filtered}) + + facts = parsed.get("facts") or [] + if isinstance(facts, list): + for item in facts: + if not isinstance(item, dict): + continue + content = (item.get("content") or "").strip() + if not content or len(content) < 3: + continue + try: + result = memory.remember_fact( + content, + category=str(item.get("category") or "fact")[:64], + importance=int(item.get("importance") or 3), + session_id=session_id, + source="auto", + ) + saved.append({"type": "fact", **result}) + except ValueError: + continue + + return {"ok": True, "saved": saved, "count": len(saved)} diff --git a/backend/app/memory/service.py b/backend/app/memory/service.py index f222542..d5c93e9 100644 --- a/backend/app/memory/service.py +++ b/backend/app/memory/service.py @@ -1,228 +1,300 @@ -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), - } +import asyncio +import json +import threading +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, user_id: int): + self.db = db + self.user_id = user_id + + @staticmethod + def _schedule_rag(coro) -> None: + def runner() -> None: + asyncio.run(coro) + + threading.Thread(target=runner, daemon=True).start() + + def get_profile(self) -> dict[str, Any]: + row = self.db.scalar(select(UserProfile).where(UserProfile.user_id == self.user_id).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).where(UserProfile.user_id == self.user_id).limit(1)) + if not row: + row = UserProfile(user_id=self.user_id, 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.user_id == self.user_id, 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() + from app.rag.ingest import index_memory_fact + + self._schedule_rag(index_memory_fact(existing)) + 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( + user_id=self.user_id, + 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) + from app.rag.ingest import index_memory_fact + + self._schedule_rag(index_memory_fact(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).where(MemoryFact.user_id == self.user_id).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 or fact.user_id != self.user_id: + raise ValueError(f"Память #{memory_id} не найдена") + fact.active = False + fact.updated_at = datetime.now(timezone.utc) + self.db.commit() + from app.rag.ingest import deactivate_memory_fact + + self._schedule_rag(deactivate_memory_fact(memory_id)) + 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.user_id == self.user_id, 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: + from app.db.models import ChatSession + + session = self.db.get(ChatSession, session_id) + if not session or session.user_id != self.user_id: + return 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("Пустая сводка") + + from app.db.models import ChatSession + + session = self.db.get(ChatSession, session_id) + if not session or session.user_id != self.user_id: + raise ValueError("Session not found") + + row = self.db.scalar( + select(SessionSummary).where(SessionSummary.session_id == 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() + from app.rag.ingest import index_session_summary + + self._schedule_rag(index_session_summary(session_id, row.summary)) + return {"ok": True, "session_id": session_id, "summary": row.summary} + + def snapshot(self, session_id: int | None = None, query: str | None = None) -> dict[str, Any]: + from app.config import get_settings + from app.settings.service import SettingsService + + settings = get_settings() + svc = SettingsService(self.db) + rag_on = bool(svc.get_effective("rag_enabled")) and settings.rag_enabled + facts_payload: list[dict[str, Any]] + total_facts = len(self.get_active_facts(limit=500)) + if rag_on and (query or "").strip(): + async def _load() -> list[dict[str, Any]]: + from app.rag.retriever import retrieve_memory_facts + + top_k = int(svc.get_effective("rag_top_k")) + return await retrieve_memory_facts(query or "", user_id=self.user_id, top_k=top_k) + + try: + rag_facts = asyncio.run(_load()) + except Exception: + rag_facts = [] + if rag_facts: + facts_payload = rag_facts + else: + facts = self.get_active_facts(limit=settings.memory_facts_in_context) + facts_payload = [ + { + "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 + ] + else: + facts = self.get_active_facts(limit=settings.memory_facts_in_context) + facts_payload = [ + { + "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 + ] + summary_row = self.get_session_summary(session_id) if session_id else None + return { + "profile": self.get_profile(), + "facts": facts_payload, + "session_summary": summary_row.summary if summary_row else "", + "total_facts": total_facts, + } diff --git a/backend/app/memory/service.py.refactor_bak b/backend/app/memory/service.py.refactor_bak new file mode 100644 index 0000000..f222542 --- /dev/null +++ b/backend/app/memory/service.py.refactor_bak @@ -0,0 +1,228 @@ +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/pomodoro/completion.py b/backend/app/pomodoro/completion.py index 1d2fa42..bca1bc4 100644 --- a/backend/app/pomodoro/completion.py +++ b/backend/app/pomodoro/completion.py @@ -1,91 +1,92 @@ -import logging - -from sqlalchemy.orm import Session - -from app.character.service import CharacterService -from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat -from app.chat.notices import format_phase_completed_notice -from app.db.models import PomodoroSession -from app.llm.client import LLMClient -from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager -from app.pomodoro.service import PomodoroService - -logger = logging.getLogger(__name__) - -PHASE_LABELS = { - PHASE_WORK: "работа", - PHASE_SHORT_BREAK: "короткий перерыв", - PHASE_LONG_BREAK: "длинный перерыв", -} - - -class PomodoroCompletionHandler: - def __init__(self, db: Session): - self.db = db - self.pomodoro = PomodoroService(db) - self.cycle = CycleManager(db) - self.llm = LLMClient() - self.character = CharacterService() - - async def _generate_llm_comment( - self, - session: PomodoroSession, - next_phase: str | None, - ) -> str: - cycle = self.cycle.to_dict() - phase_label = PHASE_LABELS.get(session.phase, session.phase) - next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен" - work_done = cycle["completed_work_sessions"] - if session.phase == PHASE_WORK: - work_done += 1 - - system = self.character.get_system_prompt() - user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась. -Задача: {session.task_note or 'без описания'} -Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ. -Следующая фаза: {next_label}. - -Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown и без эмодзи.""" - - result = await self.llm.complete( - [ - {"role": "system", "content": system}, - {"role": "user", "content": user_prompt}, - ], - temperature=0.8, - visible_reply=True, - ) - return (result.get("content") or "").strip() or "Фаза завершена. Хорошая работа." - - def _resolve_next_phase(self, session: PomodoroSession) -> str | None: - phase = session.phase - cycle = self.cycle.get() - if phase == PHASE_WORK: - if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break: - return PHASE_LONG_BREAK - return PHASE_SHORT_BREAK - if phase == PHASE_SHORT_BREAK: - return PHASE_WORK - if phase == PHASE_LONG_BREAK: - return None - return None - - async def process(self, session: PomodoroSession) -> None: - if session.completion_notified: - return - - next_phase = self._resolve_next_phase(session) - notice = format_phase_completed_notice(session, next_phase) - post_notice_to_latest_chat(notice) - - try: - comment = await self._generate_llm_comment(session, next_phase) - if comment: - post_character_comment_to_latest_chat(comment) - except Exception: - logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase) - - self.cycle.bump_notify_seq() - self.pomodoro.mark_notified(session) - self.pomodoro.advance_after_completion(session) - logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase) +import logging + +from sqlalchemy.orm import Session + +from app.character.service import CharacterService +from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat +from app.chat.notices import format_phase_completed_notice +from app.db.models import PomodoroSession +from app.llm.client import LLMClient +from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager +from app.pomodoro.service import PomodoroService + +logger = logging.getLogger(__name__) + +PHASE_LABELS = { + PHASE_WORK: "работа", + PHASE_SHORT_BREAK: "короткий перерыв", + PHASE_LONG_BREAK: "длинный перерыв", +} + + +class PomodoroCompletionHandler: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + self.pomodoro = PomodoroService(db, user_id) + self.cycle = CycleManager(db, user_id) + self.llm = LLMClient() + self.character = CharacterService(db, user_id) + + async def _generate_llm_comment( + self, + session: PomodoroSession, + next_phase: str | None, + ) -> str: + cycle = self.cycle.to_dict() + phase_label = PHASE_LABELS.get(session.phase, session.phase) + next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен" + work_done = cycle["completed_work_sessions"] + if session.phase == PHASE_WORK: + work_done += 1 + + system = self.character.get_system_prompt() + user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась. +Задача: {session.task_note or 'без описания'} +Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ. +Следующая фаза: {next_label}. + +Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown и без эмодзи.""" + + result = await self.llm.complete( + [ + {"role": "system", "content": system}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.8, + visible_reply=True, + ) + return (result.get("content") or "").strip() or "Фаза завершена. Хорошая работа." + + def _resolve_next_phase(self, session: PomodoroSession) -> str | None: + phase = session.phase + cycle = self.cycle.get() + if phase == PHASE_WORK: + if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break: + return PHASE_LONG_BREAK + return PHASE_SHORT_BREAK + if phase == PHASE_SHORT_BREAK: + return PHASE_WORK + if phase == PHASE_LONG_BREAK: + return None + return None + + async def process(self, session: PomodoroSession) -> None: + if session.completion_notified: + return + + next_phase = self._resolve_next_phase(session) + notice = format_phase_completed_notice(session, next_phase) + post_notice_to_latest_chat(notice, self.user_id) + + try: + comment = await self._generate_llm_comment(session, next_phase) + if comment: + post_character_comment_to_latest_chat(comment, self.user_id) + except Exception: + logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase) + + self.cycle.bump_notify_seq() + self.pomodoro.mark_notified(session) + self.pomodoro.advance_after_completion(session) + logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase) diff --git a/backend/app/pomodoro/cycle.py b/backend/app/pomodoro/cycle.py index a0be349..d164775 100644 --- a/backend/app/pomodoro/cycle.py +++ b/backend/app/pomodoro/cycle.py @@ -1,89 +1,90 @@ -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.db.models import PomodoroCycle - -PHASE_WORK = "work" -PHASE_SHORT_BREAK = "short_break" -PHASE_LONG_BREAK = "long_break" - - -class CycleManager: - def __init__(self, db: Session): - self.db = db - - def get(self) -> PomodoroCycle: - cycle = self.db.scalar(select(PomodoroCycle).limit(1)) - if not cycle: - cycle = PomodoroCycle() - self.db.add(cycle) - self.db.commit() - self.db.refresh(cycle) - return cycle - - def to_dict(self, cycle: PomodoroCycle | None = None) -> dict: - c = cycle or self.get() - return { - "completed_work_sessions": c.completed_work_sessions, - "sessions_until_long_break": c.sessions_until_long_break, - "task_note": c.task_note, - "work_duration_min": c.work_duration_min, - "short_break_min": c.short_break_min, - "long_break_min": c.long_break_min, - "auto_advance": c.auto_advance, - "chat_notify_seq": c.chat_notify_seq, - } - - def reset(self, clear_task: bool = False) -> dict: - cycle = self.get() - cycle.completed_work_sessions = 0 - if clear_task: - cycle.task_note = "" - self.db.commit() - self.db.refresh(cycle) - return self.to_dict(cycle) - - def bump_notify_seq(self) -> int: - cycle = self.get() - cycle.chat_notify_seq += 1 - self.db.commit() - self.db.refresh(cycle) - return cycle.chat_notify_seq - - def on_work_completed(self) -> str: - """Returns next phase: short_break or long_break.""" - cycle = self.get() - cycle.completed_work_sessions += 1 - if cycle.completed_work_sessions >= cycle.sessions_until_long_break: - next_phase = PHASE_LONG_BREAK - else: - next_phase = PHASE_SHORT_BREAK - self.db.commit() - return next_phase - - def on_long_break_completed(self) -> None: - cycle = self.get() - cycle.completed_work_sessions = 0 - self.db.commit() - - def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int: - c = cycle or self.get() - if phase == PHASE_WORK: - return c.work_duration_min - if phase == PHASE_SHORT_BREAK: - return c.short_break_min - if phase == PHASE_LONG_BREAK: - return c.long_break_min - return c.work_duration_min - - def next_phase_after(self, completed_phase: str) -> str | None: - if completed_phase == PHASE_WORK: - cycle = self.get() - if cycle.completed_work_sessions >= cycle.sessions_until_long_break: - return PHASE_LONG_BREAK - return PHASE_SHORT_BREAK - if completed_phase == PHASE_SHORT_BREAK: - return PHASE_WORK - if completed_phase == PHASE_LONG_BREAK: - return None - return None +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import PomodoroCycle + +PHASE_WORK = "work" +PHASE_SHORT_BREAK = "short_break" +PHASE_LONG_BREAK = "long_break" + + +class CycleManager: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + + def get(self) -> PomodoroCycle: + cycle = self.db.scalar(select(PomodoroCycle).where(PomodoroCycle.user_id == self.user_id).limit(1)) + if not cycle: + cycle = PomodoroCycle(user_id=self.user_id) + self.db.add(cycle) + self.db.commit() + self.db.refresh(cycle) + return cycle + + def to_dict(self, cycle: PomodoroCycle | None = None) -> dict: + c = cycle or self.get() + return { + "completed_work_sessions": c.completed_work_sessions, + "sessions_until_long_break": c.sessions_until_long_break, + "task_note": c.task_note, + "work_duration_min": c.work_duration_min, + "short_break_min": c.short_break_min, + "long_break_min": c.long_break_min, + "auto_advance": c.auto_advance, + "chat_notify_seq": c.chat_notify_seq, + } + + def reset(self, clear_task: bool = False) -> dict: + cycle = self.get() + cycle.completed_work_sessions = 0 + if clear_task: + cycle.task_note = "" + self.db.commit() + self.db.refresh(cycle) + return self.to_dict(cycle) + + def bump_notify_seq(self) -> int: + cycle = self.get() + cycle.chat_notify_seq += 1 + self.db.commit() + self.db.refresh(cycle) + return cycle.chat_notify_seq + + def on_work_completed(self) -> str: + """Returns next phase: short_break or long_break.""" + cycle = self.get() + cycle.completed_work_sessions += 1 + if cycle.completed_work_sessions >= cycle.sessions_until_long_break: + next_phase = PHASE_LONG_BREAK + else: + next_phase = PHASE_SHORT_BREAK + self.db.commit() + return next_phase + + def on_long_break_completed(self) -> None: + cycle = self.get() + cycle.completed_work_sessions = 0 + self.db.commit() + + def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int: + c = cycle or self.get() + if phase == PHASE_WORK: + return c.work_duration_min + if phase == PHASE_SHORT_BREAK: + return c.short_break_min + if phase == PHASE_LONG_BREAK: + return c.long_break_min + return c.work_duration_min + + def next_phase_after(self, completed_phase: str) -> str | None: + if completed_phase == PHASE_WORK: + cycle = self.get() + if cycle.completed_work_sessions >= cycle.sessions_until_long_break: + return PHASE_LONG_BREAK + return PHASE_SHORT_BREAK + if completed_phase == PHASE_SHORT_BREAK: + return PHASE_WORK + if completed_phase == PHASE_LONG_BREAK: + return None + return None diff --git a/backend/app/pomodoro/service.py b/backend/app/pomodoro/service.py index 3957795..b07cc49 100644 --- a/backend/app/pomodoro/service.py +++ b/backend/app/pomodoro/service.py @@ -1,287 +1,296 @@ -from datetime import datetime, timezone - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.db.models import PomodoroSession -from app.pomodoro.cycle import ( - PHASE_LONG_BREAK, - PHASE_SHORT_BREAK, - PHASE_WORK, - CycleManager, -) - - -def _utcnow() -> datetime: - return datetime.now(timezone.utc) - - -class PomodoroService: - def __init__(self, db: Session): - self.db = db - self.cycle = CycleManager(db) - - def _get_active(self) -> PomodoroSession | None: - stmt = ( - select(PomodoroSession) - .where(PomodoroSession.status.in_(("running", "paused"))) - .order_by(PomodoroSession.id.desc()) - .limit(1) - ) - return self.db.scalar(stmt) - - def _elapsed(self, session: PomodoroSession) -> int: - elapsed = session.elapsed_seconds - if session.status == "running" and session.started_at: - started = session.started_at - if started.tzinfo is None: - started = started.replace(tzinfo=timezone.utc) - delta = _utcnow() - started - elapsed += int(delta.total_seconds()) - return elapsed - - def _remaining(self, session: PomodoroSession) -> int: - total = session.duration_min * 60 - return max(0, total - self._elapsed(session)) - - def _try_auto_complete(self, session: PomodoroSession) -> bool: - if session.status != "running": - return False - if self._remaining(session) > 0: - return False - self._finalize_session(session, auto=True) - return True - - def _finalize_session( - self, - session: PomodoroSession, - *, - auto: bool, - result: str = "", - completed: bool | None = None, - cancelled: bool = False, - ) -> None: - session.elapsed_seconds = self._elapsed(session) - session.started_at = None - session.finished_at = _utcnow() - session.completion_notified = False - session.result = result or None - - if cancelled: - session.status = "cancelled" - session.completed = False - elif completed is not None: - session.status = "completed" - session.completed = completed - else: - session.status = "completed" - session.completed = True - - self.db.commit() - self.db.refresh(session) - - def _start_phase( - self, - phase: str, - *, - duration_min: int | None = None, - task_note: str | None = None, - ) -> PomodoroSession: - active = self._get_active() - if active: - raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.") - - cycle = self.cycle.get() - if task_note is not None: - cycle.task_note = task_note - elif phase == PHASE_WORK and not cycle.task_note: - cycle.task_note = "" - - duration = duration_min or self.cycle.duration_for_phase(phase, cycle) - note = task_note if task_note is not None else cycle.task_note - - session = PomodoroSession( - status="running", - phase=phase, - duration_min=duration, - task_note=note, - started_at=_utcnow(), - ) - self.db.add(session) - self.db.commit() - self.db.refresh(session) - return session - - def _to_status_dict(self, session: PomodoroSession | None) -> dict: - cycle_dict = self.cycle.to_dict() - if not session: - return { - "status": "idle", - "phase": PHASE_WORK, - "duration_min": cycle_dict["work_duration_min"], - "task_note": cycle_dict["task_note"], - "elapsed_seconds": 0, - "remaining_seconds": 0, - "session_id": None, - "cycle": cycle_dict, - } - - elapsed = self._elapsed(session) - total = session.duration_min * 60 - remaining = max(0, total - elapsed) - - return { - "status": session.status, - "phase": session.phase, - "duration_min": session.duration_min, - "task_note": session.task_note, - "elapsed_seconds": elapsed, - "remaining_seconds": remaining, - "session_id": session.id, - "started_at": session.started_at.isoformat() if session.started_at else None, - "finished_at": session.finished_at.isoformat() if session.finished_at else None, - "cycle": cycle_dict, - } - - def get_status(self) -> dict: - active = self._get_active() - if active: - self._try_auto_complete(active) - active = self._get_active() - return self._to_status_dict(active) - - def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict: - session = self._start_phase( - PHASE_WORK, - duration_min=duration_min, - task_note=task_note, - ) - return self._to_status_dict(session) - - def start_short_break(self, duration_min: int | None = None) -> dict: - session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min) - return self._to_status_dict(session) - - def start_long_break(self, duration_min: int | None = None) -> dict: - session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min) - return self._to_status_dict(session) - - def start(self, duration_min: int = 25, task_note: str = "") -> dict: - return self.start_work(duration_min=duration_min, task_note=task_note) - - def pause(self) -> dict: - session = self._get_active() - if not session or session.status != "running": - raise ValueError("Нет активного запущенного таймера.") - - session.elapsed_seconds = self._elapsed(session) - session.status = "paused" - session.paused_at = _utcnow() - session.started_at = None - self.db.commit() - self.db.refresh(session) - return self._to_status_dict(session) - - def resume(self) -> dict: - session = self._get_active() - if not session or session.status != "paused": - raise ValueError("Нет таймера на паузе.") - - session.status = "running" - session.started_at = _utcnow() - session.paused_at = None - self.db.commit() - self.db.refresh(session) - return self._to_status_dict(session) - - def stop(self, result: str = "", completed: bool = False) -> dict: - session = self._get_active() - if not session: - raise ValueError("Нет активного таймера.") - - if completed: - self._finalize_session(session, auto=False, result=result, completed=True) - else: - self._finalize_session(session, auto=False, result=result, cancelled=True) - session.completion_notified = True - self.db.commit() - return self._to_status_dict(None) - - def reset_cycle(self, clear_task: bool = False) -> dict: - active = self._get_active() - if active: - self._finalize_session(active, auto=False, cancelled=True) - active.completion_notified = True - self.db.commit() - cycle = self.cycle.reset(clear_task=clear_task) - status = self._to_status_dict(None) - status["cycle"] = cycle - return status - - def skip_phase(self) -> dict: - session = self._get_active() - if not session: - raise ValueError("Нет активного таймера.") - - self._finalize_session(session, auto=True) - return self._to_status_dict(None) - - def get_pending_completions(self) -> list[PomodoroSession]: - stmt = ( - select(PomodoroSession) - .where( - PomodoroSession.status == "completed", - PomodoroSession.completed.is_(True), - PomodoroSession.completion_notified.is_(False), - ) - .order_by(PomodoroSession.id.asc()) - ) - return list(self.db.scalars(stmt)) - - def mark_notified(self, session: PomodoroSession) -> None: - session.completion_notified = True - self.db.commit() - - def advance_after_completion(self, session: PomodoroSession) -> dict | None: - """Update cycle counters and auto-start next phase. Returns new status or None.""" - phase = session.phase - cycle = self.cycle.get() - - if phase == PHASE_WORK: - next_phase = self.cycle.on_work_completed() - elif phase == PHASE_SHORT_BREAK: - next_phase = PHASE_WORK - elif phase == PHASE_LONG_BREAK: - self.cycle.on_long_break_completed() - next_phase = None - else: - next_phase = None - - if not cycle.auto_advance or next_phase is None: - return None - - new_session = self._start_phase(next_phase) - return self._to_status_dict(new_session) - - def history(self, limit: int = 20) -> list[dict]: - stmt = ( - select(PomodoroSession) - .where(PomodoroSession.status.in_(("completed", "cancelled"))) - .order_by(PomodoroSession.finished_at.desc()) - .limit(limit) - ) - sessions = self.db.scalars(stmt).all() - return [ - { - "id": s.id, - "status": s.status, - "phase": s.phase, - "duration_min": s.duration_min, - "task_note": s.task_note, - "result": s.result, - "completed": s.completed, - "elapsed_seconds": s.elapsed_seconds, - "finished_at": s.finished_at.isoformat() if s.finished_at else None, - } - for s in sessions - ] +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import PomodoroSession +from app.pomodoro.cycle import ( + PHASE_LONG_BREAK, + PHASE_SHORT_BREAK, + PHASE_WORK, + CycleManager, +) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class PomodoroService: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + self.cycle = CycleManager(db, user_id) + + def _get_active(self) -> PomodoroSession | None: + stmt = ( + select(PomodoroSession) + .where( + PomodoroSession.user_id == self.user_id, + PomodoroSession.status.in_(("running", "paused")), + ) + .order_by(PomodoroSession.id.desc()) + .limit(1) + ) + return self.db.scalar(stmt) + + def _elapsed(self, session: PomodoroSession) -> int: + elapsed = session.elapsed_seconds + if session.status == "running" and session.started_at: + started = session.started_at + if started.tzinfo is None: + started = started.replace(tzinfo=timezone.utc) + delta = _utcnow() - started + elapsed += int(delta.total_seconds()) + return elapsed + + def _remaining(self, session: PomodoroSession) -> int: + total = session.duration_min * 60 + return max(0, total - self._elapsed(session)) + + def _try_auto_complete(self, session: PomodoroSession) -> bool: + if session.status != "running": + return False + if self._remaining(session) > 0: + return False + self._finalize_session(session, auto=True) + return True + + def _finalize_session( + self, + session: PomodoroSession, + *, + auto: bool, + result: str = "", + completed: bool | None = None, + cancelled: bool = False, + ) -> None: + session.elapsed_seconds = self._elapsed(session) + session.started_at = None + session.finished_at = _utcnow() + session.completion_notified = False + session.result = result or None + + if cancelled: + session.status = "cancelled" + session.completed = False + elif completed is not None: + session.status = "completed" + session.completed = completed + else: + session.status = "completed" + session.completed = True + + self.db.commit() + self.db.refresh(session) + + def _start_phase( + self, + phase: str, + *, + duration_min: int | None = None, + task_note: str | None = None, + ) -> PomodoroSession: + active = self._get_active() + if active: + raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.") + + cycle = self.cycle.get() + if task_note is not None: + cycle.task_note = task_note + elif phase == PHASE_WORK and not cycle.task_note: + cycle.task_note = "" + + duration = duration_min or self.cycle.duration_for_phase(phase, cycle) + note = task_note if task_note is not None else cycle.task_note + + session = PomodoroSession( + user_id=self.user_id, + status="running", + phase=phase, + duration_min=duration, + task_note=note, + started_at=_utcnow(), + ) + self.db.add(session) + self.db.commit() + self.db.refresh(session) + return session + + def _to_status_dict(self, session: PomodoroSession | None) -> dict: + cycle_dict = self.cycle.to_dict() + if not session: + return { + "status": "idle", + "phase": PHASE_WORK, + "duration_min": cycle_dict["work_duration_min"], + "task_note": cycle_dict["task_note"], + "elapsed_seconds": 0, + "remaining_seconds": 0, + "session_id": None, + "cycle": cycle_dict, + } + + elapsed = self._elapsed(session) + total = session.duration_min * 60 + remaining = max(0, total - elapsed) + + return { + "status": session.status, + "phase": session.phase, + "duration_min": session.duration_min, + "task_note": session.task_note, + "elapsed_seconds": elapsed, + "remaining_seconds": remaining, + "session_id": session.id, + "started_at": session.started_at.isoformat() if session.started_at else None, + "finished_at": session.finished_at.isoformat() if session.finished_at else None, + "cycle": cycle_dict, + } + + def get_status(self) -> dict: + active = self._get_active() + if active: + self._try_auto_complete(active) + active = self._get_active() + return self._to_status_dict(active) + + def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict: + session = self._start_phase( + PHASE_WORK, + duration_min=duration_min, + task_note=task_note, + ) + return self._to_status_dict(session) + + def start_short_break(self, duration_min: int | None = None) -> dict: + session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min) + return self._to_status_dict(session) + + def start_long_break(self, duration_min: int | None = None) -> dict: + session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min) + return self._to_status_dict(session) + + def start(self, duration_min: int = 25, task_note: str = "") -> dict: + return self.start_work(duration_min=duration_min, task_note=task_note) + + def pause(self) -> dict: + session = self._get_active() + if not session or session.status != "running": + raise ValueError("Нет активного запущенного таймера.") + + session.elapsed_seconds = self._elapsed(session) + session.status = "paused" + session.paused_at = _utcnow() + session.started_at = None + self.db.commit() + self.db.refresh(session) + return self._to_status_dict(session) + + def resume(self) -> dict: + session = self._get_active() + if not session or session.status != "paused": + raise ValueError("Нет таймера на паузе.") + + session.status = "running" + session.started_at = _utcnow() + session.paused_at = None + self.db.commit() + self.db.refresh(session) + return self._to_status_dict(session) + + def stop(self, result: str = "", completed: bool = False) -> dict: + session = self._get_active() + if not session: + raise ValueError("Нет активного таймера.") + + if completed: + self._finalize_session(session, auto=False, result=result, completed=True) + else: + self._finalize_session(session, auto=False, result=result, cancelled=True) + session.completion_notified = True + self.db.commit() + return self._to_status_dict(None) + + def reset_cycle(self, clear_task: bool = False) -> dict: + active = self._get_active() + if active: + self._finalize_session(active, auto=False, cancelled=True) + active.completion_notified = True + self.db.commit() + cycle = self.cycle.reset(clear_task=clear_task) + status = self._to_status_dict(None) + status["cycle"] = cycle + return status + + def skip_phase(self) -> dict: + session = self._get_active() + if not session: + raise ValueError("Нет активного таймера.") + + self._finalize_session(session, auto=True) + return self._to_status_dict(None) + + def get_pending_completions(self) -> list[PomodoroSession]: + stmt = ( + select(PomodoroSession) + .where( + PomodoroSession.user_id == self.user_id, + PomodoroSession.status == "completed", + PomodoroSession.completed.is_(True), + PomodoroSession.completion_notified.is_(False), + ) + .order_by(PomodoroSession.id.asc()) + ) + return list(self.db.scalars(stmt)) + + def mark_notified(self, session: PomodoroSession) -> None: + session.completion_notified = True + self.db.commit() + + def advance_after_completion(self, session: PomodoroSession) -> dict | None: + """Update cycle counters and auto-start next phase. Returns new status or None.""" + phase = session.phase + cycle = self.cycle.get() + + if phase == PHASE_WORK: + next_phase = self.cycle.on_work_completed() + elif phase == PHASE_SHORT_BREAK: + next_phase = PHASE_WORK + elif phase == PHASE_LONG_BREAK: + self.cycle.on_long_break_completed() + next_phase = None + else: + next_phase = None + + if not cycle.auto_advance or next_phase is None: + return None + + new_session = self._start_phase(next_phase) + return self._to_status_dict(new_session) + + def history(self, limit: int = 20) -> list[dict]: + stmt = ( + select(PomodoroSession) + .where( + PomodoroSession.user_id == self.user_id, + PomodoroSession.status.in_(("completed", "cancelled")), + ) + .order_by(PomodoroSession.finished_at.desc()) + .limit(limit) + ) + sessions = self.db.scalars(stmt).all() + return [ + { + "id": s.id, + "status": s.status, + "phase": s.phase, + "duration_min": s.duration_min, + "task_note": s.task_note, + "result": s.result, + "completed": s.completed, + "elapsed_seconds": s.elapsed_seconds, + "finished_at": s.finished_at.isoformat() if s.finished_at else None, + } + for s in sessions + ] diff --git a/backend/app/pomodoro/watcher.py b/backend/app/pomodoro/watcher.py index 6094a8c..86293ae 100644 --- a/backend/app/pomodoro/watcher.py +++ b/backend/app/pomodoro/watcher.py @@ -1,38 +1,41 @@ -import asyncio -import logging - -from app.db.base import SessionLocal -from app.pomodoro.completion import PomodoroCompletionHandler -from app.pomodoro.service import PomodoroService - -logger = logging.getLogger(__name__) - -WATCH_INTERVAL_SEC = 2 - - -async def pomodoro_watcher_loop() -> None: - while True: - try: - await asyncio.sleep(WATCH_INTERVAL_SEC) - await _tick() - except asyncio.CancelledError: - raise - except Exception: - logger.exception("Pomodoro watcher error") - - -async def _tick() -> None: - db = SessionLocal() - try: - service = PomodoroService(db) - service.get_status() - - pending = service.get_pending_completions() - if not pending: - return - - handler = PomodoroCompletionHandler(db) - for session in pending: - await handler.process(session) - finally: - db.close() +import asyncio +import logging + +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import User +from app.pomodoro.completion import PomodoroCompletionHandler +from app.pomodoro.service import PomodoroService + +logger = logging.getLogger(__name__) + +WATCH_INTERVAL_SEC = 2 + + +async def pomodoro_watcher_loop() -> None: + while True: + try: + await asyncio.sleep(WATCH_INTERVAL_SEC) + await _tick() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Pomodoro watcher error") + + +async def _tick() -> None: + db = SessionLocal() + try: + users = db.scalars(select(User).where(User.is_active.is_(True))).all() + for user in users: + service = PomodoroService(db, user.id) + service.get_status() + pending = service.get_pending_completions() + if not pending: + continue + handler = PomodoroCompletionHandler(db, user.id) + for session in pending: + await handler.process(session) + finally: + db.close() diff --git a/backend/app/projects/context.py b/backend/app/projects/context.py index 796237d..32d9a33 100644 --- a/backend/app/projects/context.py +++ b/backend/app/projects/context.py @@ -1,153 +1,155 @@ -import time -from typing import Any - -from sqlalchemy.orm import Session - -from app.config import get_settings -from app.integrations.taiga import TaigaClient -from app.projects.service import ProjectService - -MAX_PROJECTS_IN_CONTEXT = 20 -MAX_OPEN_PER_PROJECT = 8 -PROJECTS_CACHE_SEC = 120 - -_cache: dict[str, Any] = {"data": None, "expires_at": 0.0} - - -def invalidate_projects_snapshot_cache() -> None: - _cache["data"] = None - _cache["expires_at"] = 0.0 - - -def get_projects_snapshot(db: Session, *, force: bool = False) -> dict[str, Any]: - now = time.time() - if not force and _cache["data"] is not None and now < _cache["expires_at"]: - return _cache["data"] - - snapshot = _fetch_projects_snapshot(db) - _cache["data"] = snapshot - _cache["expires_at"] = now + PROJECTS_CACHE_SEC - return snapshot - - -def _fetch_projects_snapshot(db: Session) -> dict[str, Any]: - settings = get_settings() - service = ProjectService(db) - - if not settings.taiga_configured: - return {"configured": False, "projects": [], "open_items": [], "taiga_open": []} - - projects = service.list_projects() - if not projects: - try: - projects = service.sync_taiga_projects() - except Exception as exc: - return { - "configured": True, - "projects": [], - "open_items": [], - "taiga_open": [], - "error": str(exc), - } - - open_items = service.list_work_items(limit=15, status="open") - taiga_open: list[dict[str, Any]] = [] - fetch_error: str | None = None - - try: - client = TaigaClient() - for proj in projects[:MAX_PROJECTS_IN_CONTEXT]: - stories = client.list_open_userstories( - proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT - ) - tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT) - taiga_open.append( - { - "slug": proj["slug"], - "name": proj["name"], - "stories": [ - { - "ref": s.get("ref"), - "subject": s.get("subject", "")[:120], - } - for s in stories - ], - "tasks": [ - { - "ref": t.get("ref"), - "subject": t.get("subject", "")[:120], - } - for t in tasks - ], - } - ) - except Exception as exc: - fetch_error = str(exc) - - return { - "configured": True, - "projects": projects, - "open_items": open_items, - "taiga_open": taiga_open, - "error": fetch_error, - } - - -def format_projects_context(snapshot: dict[str, Any]) -> str: - if not snapshot.get("configured"): - return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)." - - lines = ["[Проекты и задачи — снимок на начало ответа]"] - - if snapshot.get("error"): - lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}") - - projects = snapshot.get("projects") or [] - if not projects: - lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.") - else: - lines.append(f"Проекты Taiga ({len(projects)}):") - for p in projects[:MAX_PROJECTS_IN_CONTEXT]: - gitea = ( - f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" - if p.get("gitea_configured") - else "Gitea не привязан" - ) - lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}") - - taiga_open = snapshot.get("taiga_open") or [] - if taiga_open: - lines.append("") - lines.append("Открытые задачи в Taiga (live):") - for block in taiga_open: - stories = block.get("stories") or [] - tasks = block.get("tasks") or [] - if not stories and not tasks: - lines.append(f" `{block.get('slug')}`: нет открытых") - continue - lines.append(f" `{block.get('slug')}`:") - for story in stories: - lines.append(f" story #{story.get('ref')} {story.get('subject')}") - for task in tasks: - lines.append(f" task #{task.get('ref')} {task.get('subject')}") - - open_items = snapshot.get("open_items") or [] - if open_items: - lines.append("") - lines.append("Work items созданные ассистентом (локальная БД):") - for item in open_items[:10]: - gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else "" - lines.append( - f"- #{item.get('taiga_ref')} {item.get('title')} " - f"({item.get('taiga_slug')}{gitea_part})" - ) - - lines.append("") - lines.append( - "Правила: " - "«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. " - "list_work_items — только созданные через ассистента. " - "Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. " - "create_work_item — для новых фич/багов из вольного текста." - ) - return "\n".join(lines) +import time +from typing import Any + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.integrations.taiga import TaigaClient +from app.projects.service import ProjectService + +MAX_PROJECTS_IN_CONTEXT = 20 +MAX_OPEN_PER_PROJECT = 8 +PROJECTS_CACHE_SEC = 120 + +_cache: dict[int, dict[str, Any]] = {} + + +def invalidate_projects_snapshot_cache(user_id: int | None = None) -> None: + if user_id is None: + _cache.clear() + else: + _cache.pop(user_id, None) + + +def get_projects_snapshot(db: Session, user_id: int, *, force: bool = False) -> dict[str, Any]: + now = time.time() + entry = _cache.get(user_id) + if not force and entry and now < entry.get("expires_at", 0): + return entry["data"] + + snapshot = _fetch_projects_snapshot(db, user_id) + _cache[user_id] = {"data": snapshot, "expires_at": now + PROJECTS_CACHE_SEC} + return snapshot + + +def _fetch_projects_snapshot(db: Session, user_id: int) -> dict[str, Any]: + settings = get_settings() + service = ProjectService(db, user_id) + + if not settings.taiga_configured: + return {"configured": False, "projects": [], "open_items": [], "taiga_open": []} + + projects = service.list_projects() + if not projects: + try: + projects = service.sync_taiga_projects() + except Exception as exc: + return { + "configured": True, + "projects": [], + "open_items": [], + "taiga_open": [], + "error": str(exc), + } + + open_items = service.list_work_items(limit=15, status="open") + taiga_open: list[dict[str, Any]] = [] + fetch_error: str | None = None + + try: + client = TaigaClient() + for proj in projects[:MAX_PROJECTS_IN_CONTEXT]: + stories = client.list_open_userstories( + proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT + ) + tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT) + taiga_open.append( + { + "slug": proj["slug"], + "name": proj["name"], + "stories": [ + { + "ref": s.get("ref"), + "subject": s.get("subject", "")[:120], + } + for s in stories + ], + "tasks": [ + { + "ref": t.get("ref"), + "subject": t.get("subject", "")[:120], + } + for t in tasks + ], + } + ) + except Exception as exc: + fetch_error = str(exc) + + return { + "configured": True, + "projects": projects, + "open_items": open_items, + "taiga_open": taiga_open, + "error": fetch_error, + } + + +def format_projects_context(snapshot: dict[str, Any]) -> str: + if not snapshot.get("configured"): + return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)." + + lines = ["[Проекты и задачи — снимок на начало ответа]"] + + if snapshot.get("error"): + lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}") + + projects = snapshot.get("projects") or [] + if not projects: + lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.") + else: + lines.append(f"Проекты Taiga ({len(projects)}):") + for p in projects[:MAX_PROJECTS_IN_CONTEXT]: + gitea = ( + f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" + if p.get("gitea_configured") + else "Gitea не привязан" + ) + lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}") + + taiga_open = snapshot.get("taiga_open") or [] + if taiga_open: + lines.append("") + lines.append("Открытые задачи в Taiga (live):") + for block in taiga_open: + stories = block.get("stories") or [] + tasks = block.get("tasks") or [] + if not stories and not tasks: + lines.append(f" `{block.get('slug')}`: нет открытых") + continue + lines.append(f" `{block.get('slug')}`:") + for story in stories: + lines.append(f" story #{story.get('ref')} {story.get('subject')}") + for task in tasks: + lines.append(f" task #{task.get('ref')} {task.get('subject')}") + + open_items = snapshot.get("open_items") or [] + if open_items: + lines.append("") + lines.append("Work items созданные ассистентом (локальная БД):") + for item in open_items[:10]: + gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else "" + lines.append( + f"- #{item.get('taiga_ref')} {item.get('title')} " + f"({item.get('taiga_slug')}{gitea_part})" + ) + + lines.append("") + lines.append( + "Правила: " + "«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. " + "list_work_items — только созданные через ассистента. " + "Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. " + "create_work_item — для новых фич/багов из вольного текста." + ) + return "\n".join(lines) diff --git a/backend/app/projects/service.py b/backend/app/projects/service.py index 84ca480..832cc40 100644 --- a/backend/app/projects/service.py +++ b/backend/app/projects/service.py @@ -1,466 +1,476 @@ -from datetime import datetime, timezone -from typing import Any - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.config import get_settings -from app.db.models import ProjectBinding, TaigaProject, WorkItem -from app.integrations.gitea import GiteaClient -from app.integrations.taiga import TaigaClient -from app.projects.commit_parser import parse_commit_message -from app.projects.structuring import ( - format_gitea_body, - format_story_description, - slugify_branch, - structure_work_item, -) - - -class ProjectService: - def __init__(self, db: Session): - self.db = db - self.settings = get_settings() - - def sync_taiga_projects(self) -> list[dict[str, Any]]: - if not self.settings.taiga_configured: - raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD") - - client = TaigaClient() - remote = client.list_projects() - now = datetime.now(timezone.utc) - - for item in remote: - slug = item.get("slug") or "" - if not slug: - continue - existing = self.db.scalar( - select(TaigaProject).where(TaigaProject.slug == slug) - ) - if existing: - existing.name = item.get("name", slug) - existing.taiga_id = item["id"] - existing.synced_at = now - else: - self.db.add( - TaigaProject( - taiga_id=item["id"], - name=item.get("name", slug), - slug=slug, - synced_at=now, - ) - ) - self.db.commit() - return self.list_projects() - - def list_projects(self) -> list[dict[str, Any]]: - stmt = ( - select(TaigaProject, ProjectBinding) - .outerjoin(ProjectBinding, ProjectBinding.taiga_slug == TaigaProject.slug) - .order_by(TaigaProject.name) - ) - rows = self.db.execute(stmt).all() - result = [] - for taiga_proj, binding in rows: - result.append( - { - "taiga_id": taiga_proj.taiga_id, - "name": taiga_proj.name, - "slug": taiga_proj.slug, - "gitea_owner": binding.gitea_owner if binding else "", - "gitea_repo": binding.gitea_repo if binding else "", - "default_branch": binding.default_branch if binding else "main", - "gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo), - } - ) - return result - - def bind_gitea( - self, - taiga_slug: str, - gitea_owner: str, - gitea_repo: str, - default_branch: str = "main", - ) -> dict[str, Any]: - if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)): - raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.") - - binding = self.db.scalar( - select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_slug) - ) - if binding: - binding.gitea_owner = gitea_owner - binding.gitea_repo = gitea_repo - binding.default_branch = default_branch - else: - binding = ProjectBinding( - taiga_slug=taiga_slug, - gitea_owner=gitea_owner, - gitea_repo=gitea_repo, - default_branch=default_branch, - ) - self.db.add(binding) - self.db.commit() - - for proj in self.list_projects(): - if proj["slug"] == taiga_slug: - return proj - raise ValueError("Binding failed") - - def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]: - projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all() - if not projects: - raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.") - - taiga_proj: TaigaProject | None = None - if slug: - taiga_proj = self.db.scalar( - select(TaigaProject).where(TaigaProject.slug == slug) - ) - if not taiga_proj: - raise ValueError(f"Проект '{slug}' не найден") - else: - taiga_proj = projects[0] - - binding = self.db.scalar( - select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_proj.slug) - ) - return taiga_proj, binding - - async def create_work_item( - self, raw_text: str, project_slug: str | None = None - ) -> dict[str, Any]: - if not self.settings.taiga_configured: - raise ValueError("Taiga не настроена") - - project_list = self.list_projects() - if not project_list: - self.sync_taiga_projects() - project_list = self.list_projects() - - structured = await structure_work_item(raw_text, project_list) - slug = project_slug or structured.get("project_slug") - taiga_proj, binding = self._resolve_project(slug) - - if binding and not (binding.gitea_owner and binding.gitea_repo): - binding = None - - taiga = TaigaClient() - title = (structured.get("title") or raw_text).strip()[:500] - description = format_story_description(structured, raw_text) - tags = structured.get("tags") or [] - issue_type = structured.get("issue_type", "feature") - if issue_type == "bug" and "bug" not in [t.lower() for t in tags]: - tags.append("bug") - - story = taiga.create_userstory( - taiga_proj.taiga_id, - title, - description, - tags=tags, - ) - - subtasks = [] - for child in structured.get("children") or []: - if isinstance(child, dict): - subtasks.append( - taiga.create_task( - taiga_proj.taiga_id, - story["id"], - child.get("title", "Подзадача"), - child.get("description", ""), - ) - ) - - branch = f"feature/{story['ref']}-{slugify_branch(title)}" - gitea_issue_number = None - gitea_url = "" - - if binding and self.settings.gitea_configured: - gitea = GiteaClient() - gitea_body = format_gitea_body( - structured, - raw_text, - story["ref"], - taiga.story_url(taiga_proj.taiga_id, story["ref"]), - branch, - ) - if issue_type: - gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}" - issue = gitea.create_issue( - binding.gitea_owner, - binding.gitea_repo, - title, - gitea_body, - ) - gitea_issue_number = issue["number"] - gitea_url = gitea.issue_url( - binding.gitea_owner, binding.gitea_repo, gitea_issue_number - ) - - work_item = WorkItem( - taiga_slug=taiga_proj.slug, - taiga_project_id=taiga_proj.taiga_id, - taiga_story_id=story["id"], - taiga_story_ref=story["ref"], - gitea_owner=binding.gitea_owner if binding else "", - gitea_repo=binding.gitea_repo if binding else "", - gitea_issue_number=gitea_issue_number, - suggested_branch=branch, - raw_text=raw_text, - title=title, - status="open", - ) - self.db.add(work_item) - self.db.commit() - self.db.refresh(work_item) - - return { - "ok": True, - "work_item_id": work_item.id, - "taiga": { - "ref": story["ref"], - "id": story["id"], - "subject": story["subject"], - "url": taiga.story_url(taiga_proj.taiga_id, story["ref"]), - }, - "gitea": { - "number": gitea_issue_number, - "url": gitea_url, - }, - "branch": branch, - "issue_type": issue_type, - "subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks], - "questions": structured.get("questions") or [], - "project_slug": taiga_proj.slug, - } - - def process_push( - self, owner: str, repo: str, commits: list[dict[str, Any]] - ) -> list[dict[str, Any]]: - if not self.settings.taiga_configured: - return [] - - taiga = TaigaClient() - gitea = GiteaClient() if self.settings.gitea_configured else None - results: list[dict[str, Any]] = [] - - for commit in commits: - message = commit.get("message", "") - parsed = parse_commit_message(message) - sha = commit.get("id", "")[:8] - - gitea_refs = set(parsed["gitea"]) - taiga_story_refs = set(parsed["taiga_story"]) - taiga_task_refs = set(parsed["taiga_task"]) - - linked_items = self.db.scalars( - select(WorkItem).where( - WorkItem.gitea_owner == owner, - WorkItem.gitea_repo == repo, - WorkItem.status == "open", - ) - ).all() - - for item in linked_items: - if item.gitea_issue_number and item.gitea_issue_number in gitea_refs: - taiga_story_refs.add(item.taiga_story_ref) - if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number: - gitea_refs.add(item.gitea_issue_number) - - for gitea_num in gitea_refs: - if gitea: - try: - gitea.close_issue(owner, repo, gitea_num) - except Exception as exc: - results.append({"error": f"gitea #{gitea_num}: {exc}"}) - continue - - for item in linked_items: - if item.gitea_issue_number == gitea_num: - try: - self._close_work_item(item, taiga) - results.append( - { - "commit": sha, - "closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}", - } - ) - except Exception as exc: - results.append( - {"error": f"work item {item.id} (gitea #{gitea_num}): {exc}"} - ) - - for ref in taiga_story_refs: - project_id = self._project_id_for_ref(owner, repo, ref, linked_items) - if not project_id: - continue - story = taiga.get_by_ref(project_id, ref, kind="userstory") - if story and not story.get("is_closed"): - try: - taiga.close_userstory(story["id"], project_id) - results.append({"commit": sha, "closed": f"taiga #{ref}"}) - except Exception as exc: - results.append({"error": f"taiga #{ref}: {exc}"}) - for item in linked_items: - if item.taiga_story_ref == ref and item.status != "closed": - try: - self._close_work_item(item, taiga, close_gitea=bool(gitea)) - except Exception as exc: - results.append( - {"error": f"work item {item.id} (taiga #{ref}): {exc}"} - ) - - for ref in taiga_task_refs: - binding = self.db.scalar( - select(ProjectBinding).where( - ProjectBinding.gitea_owner == owner, - ProjectBinding.gitea_repo == repo, - ) - ) - if not binding: - continue - taiga_proj = self.db.scalar( - select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug) - ) - if not taiga_proj: - continue - task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task") - if task and not task.get("is_closed"): - try: - taiga.close_task(task["id"], taiga_proj.taiga_id) - results.append({"commit": sha, "closed": f"taiga task #{ref}"}) - except Exception as exc: - results.append({"error": f"taiga task #{ref}: {exc}"}) - - self.db.commit() - return results - - def _project_id_for_ref( - self, - owner: str, - repo: str, - ref: int, - items: list[WorkItem], - ) -> int | None: - for item in items: - if item.taiga_story_ref == ref: - return item.taiga_project_id - binding = self.db.scalar( - select(ProjectBinding).where( - ProjectBinding.gitea_owner == owner, - ProjectBinding.gitea_repo == repo, - ) - ) - if binding: - taiga_proj = self.db.scalar( - select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug) - ) - return taiga_proj.taiga_id if taiga_proj else None - return None - - def _close_work_item( - self, - item: WorkItem, - taiga: TaigaClient, - *, - close_gitea: bool = True, - ) -> None: - if item.status == "closed": - return - story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory") - if story: - taiga.close_userstory(story["id"], item.taiga_project_id) - if ( - close_gitea - and item.gitea_issue_number - and self.settings.gitea_configured - ): - GiteaClient().close_issue( - item.gitea_owner, item.gitea_repo, item.gitea_issue_number - ) - item.status = "closed" - item.closed_at = datetime.now(timezone.utc) - - def list_taiga_open_tasks( - self, - project_slug: str | None = None, - limit: int = 20, - ) -> dict[str, Any]: - if not self.settings.taiga_configured: - raise ValueError("Taiga не настроена") - - projects = self.list_projects() - if not projects: - projects = self.sync_taiga_projects() - - if project_slug: - projects = [p for p in projects if p["slug"] == project_slug] - if not projects: - raise ValueError( - f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects." - ) - - client = TaigaClient() - blocks: list[dict[str, Any]] = [] - - for proj in projects: - stories = client.list_open_userstories(proj["taiga_id"], limit=limit) - tasks = client.list_open_tasks(proj["taiga_id"], limit=limit) - blocks.append( - { - "slug": proj["slug"], - "name": proj["name"], - "taiga_id": proj["taiga_id"], - "stories": [ - { - "ref": s.get("ref"), - "subject": s.get("subject", ""), - "url": client.story_url(proj["taiga_id"], s.get("ref", 0)), - } - for s in stories - ], - "tasks": [ - { - "ref": t.get("ref"), - "subject": t.get("subject", ""), - "user_story": t.get("user_story"), - } - for t in tasks - ], - } - ) - - total_stories = sum(len(b["stories"]) for b in blocks) - total_tasks = sum(len(b["tasks"]) for b in blocks) - return { - "projects": blocks, - "total_stories": total_stories, - "total_tasks": total_tasks, - } - - def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]: - stmt = select(WorkItem).order_by(WorkItem.created_at.desc()).limit(limit) - if status: - stmt = stmt.where(WorkItem.status == status) - items = self.db.scalars(stmt).all() - settings = get_settings() - return [ - { - "id": i.id, - "title": i.title, - "status": i.status, - "taiga_slug": i.taiga_slug, - "taiga_ref": i.taiga_story_ref, - "gitea_issue": i.gitea_issue_number, - "branch": i.suggested_branch, - "taiga_url": f"{settings.taiga_public_url}/project/0/{i.taiga_project_id}/us/{i.taiga_story_ref}", - "gitea_url": ( - f"{settings.gitea_public_url}/{i.gitea_owner}/{i.gitea_repo}/issues/{i.gitea_issue_number}" - if i.gitea_issue_number - else "" - ), - "created_at": i.created_at.isoformat() if i.created_at else None, - } - for i in items - ] +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.db.models import ProjectBinding, TaigaProject, WorkItem +from app.integrations.gitea import GiteaClient +from app.integrations.taiga import TaigaClient +from app.projects.commit_parser import parse_commit_message +from app.projects.structuring import ( + format_gitea_body, + format_story_description, + slugify_branch, + structure_work_item, +) + + +class ProjectService: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + self.settings = get_settings() + + def sync_taiga_projects(self) -> list[dict[str, Any]]: + if not self.settings.taiga_configured: + raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD") + + client = TaigaClient() + remote = client.list_projects() + now = datetime.now(timezone.utc) + + for item in remote: + slug = item.get("slug") or "" + if not slug: + continue + existing = self.db.scalar( + select(TaigaProject).where(TaigaProject.slug == slug) + ) + if existing: + existing.name = item.get("name", slug) + existing.taiga_id = item["id"] + existing.synced_at = now + else: + self.db.add( + TaigaProject( + taiga_id=item["id"], + name=item.get("name", slug), + slug=slug, + synced_at=now, + ) + ) + self.db.commit() + return self.list_projects() + + def list_projects(self) -> list[dict[str, Any]]: + stmt = ( + select(TaigaProject, ProjectBinding) + .outerjoin( + ProjectBinding, + (ProjectBinding.taiga_slug == TaigaProject.slug) + & (ProjectBinding.user_id == self.user_id), + ) + .order_by(TaigaProject.name) + ) + rows = self.db.execute(stmt).all() + result = [] + for taiga_proj, binding in rows: + result.append( + { + "taiga_id": taiga_proj.taiga_id, + "name": taiga_proj.name, + "slug": taiga_proj.slug, + "gitea_owner": binding.gitea_owner if binding else "", + "gitea_repo": binding.gitea_repo if binding else "", + "default_branch": binding.default_branch if binding else "main", + "gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo), + } + ) + return result + + def bind_gitea( + self, + taiga_slug: str, + gitea_owner: str, + gitea_repo: str, + default_branch: str = "main", + ) -> dict[str, Any]: + if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)): + raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.") + + binding = self.db.scalar( + select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_slug) + ) + if binding: + binding.gitea_owner = gitea_owner + binding.gitea_repo = gitea_repo + binding.default_branch = default_branch + else: + binding = ProjectBinding( + user_id=self.user_id, + taiga_slug=taiga_slug, + gitea_owner=gitea_owner, + gitea_repo=gitea_repo, + default_branch=default_branch, + ) + self.db.add(binding) + self.db.commit() + + for proj in self.list_projects(): + if proj["slug"] == taiga_slug: + return proj + raise ValueError("Binding failed") + + def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]: + projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all() + if not projects: + raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.") + + taiga_proj: TaigaProject | None = None + if slug: + taiga_proj = self.db.scalar( + select(TaigaProject).where(TaigaProject.slug == slug) + ) + if not taiga_proj: + raise ValueError(f"Проект '{slug}' не найден") + else: + taiga_proj = projects[0] + + binding = self.db.scalar( + select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_proj.slug) + ) + return taiga_proj, binding + + async def create_work_item( + self, raw_text: str, project_slug: str | None = None + ) -> dict[str, Any]: + if not self.settings.taiga_configured: + raise ValueError("Taiga не настроена") + + project_list = self.list_projects() + if not project_list: + self.sync_taiga_projects() + project_list = self.list_projects() + + structured = await structure_work_item(raw_text, project_list) + slug = project_slug or structured.get("project_slug") + taiga_proj, binding = self._resolve_project(slug) + + if binding and not (binding.gitea_owner and binding.gitea_repo): + binding = None + + taiga = TaigaClient() + title = (structured.get("title") or raw_text).strip()[:500] + description = format_story_description(structured, raw_text) + tags = structured.get("tags") or [] + issue_type = structured.get("issue_type", "feature") + if issue_type == "bug" and "bug" not in [t.lower() for t in tags]: + tags.append("bug") + + story = taiga.create_userstory( + taiga_proj.taiga_id, + title, + description, + tags=tags, + ) + + subtasks = [] + for child in structured.get("children") or []: + if isinstance(child, dict): + subtasks.append( + taiga.create_task( + taiga_proj.taiga_id, + story["id"], + child.get("title", "Подзадача"), + child.get("description", ""), + ) + ) + + branch = f"feature/{story['ref']}-{slugify_branch(title)}" + gitea_issue_number = None + gitea_url = "" + + if binding and self.settings.gitea_configured: + gitea = GiteaClient() + gitea_body = format_gitea_body( + structured, + raw_text, + story["ref"], + taiga.story_url(taiga_proj.taiga_id, story["ref"]), + branch, + ) + if issue_type: + gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}" + issue = gitea.create_issue( + binding.gitea_owner, + binding.gitea_repo, + title, + gitea_body, + ) + gitea_issue_number = issue["number"] + gitea_url = gitea.issue_url( + binding.gitea_owner, binding.gitea_repo, gitea_issue_number + ) + + work_item = WorkItem( + user_id=self.user_id, + taiga_slug=taiga_proj.slug, + taiga_project_id=taiga_proj.taiga_id, + taiga_story_id=story["id"], + taiga_story_ref=story["ref"], + gitea_owner=binding.gitea_owner if binding else "", + gitea_repo=binding.gitea_repo if binding else "", + gitea_issue_number=gitea_issue_number, + suggested_branch=branch, + raw_text=raw_text, + title=title, + status="open", + ) + self.db.add(work_item) + self.db.commit() + self.db.refresh(work_item) + + return { + "ok": True, + "work_item_id": work_item.id, + "taiga": { + "ref": story["ref"], + "id": story["id"], + "subject": story["subject"], + "url": taiga.story_url(taiga_proj.taiga_id, story["ref"]), + }, + "gitea": { + "number": gitea_issue_number, + "url": gitea_url, + }, + "branch": branch, + "issue_type": issue_type, + "subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks], + "questions": structured.get("questions") or [], + "project_slug": taiga_proj.slug, + } + + def process_push( + self, owner: str, repo: str, commits: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + if not self.settings.taiga_configured: + return [] + + taiga = TaigaClient() + gitea = GiteaClient() if self.settings.gitea_configured else None + results: list[dict[str, Any]] = [] + + for commit in commits: + message = commit.get("message", "") + parsed = parse_commit_message(message) + sha = commit.get("id", "")[:8] + + gitea_refs = set(parsed["gitea"]) + taiga_story_refs = set(parsed["taiga_story"]) + taiga_task_refs = set(parsed["taiga_task"]) + + linked_items = self.db.scalars( + select(WorkItem).where( + WorkItem.user_id == self.user_id, + WorkItem.gitea_owner == owner, + WorkItem.gitea_repo == repo, + WorkItem.status == "open", + ) + ).all() + + for item in linked_items: + if item.gitea_issue_number and item.gitea_issue_number in gitea_refs: + taiga_story_refs.add(item.taiga_story_ref) + if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number: + gitea_refs.add(item.gitea_issue_number) + + for gitea_num in gitea_refs: + if gitea: + try: + gitea.close_issue(owner, repo, gitea_num) + except Exception as exc: + results.append({"error": f"gitea #{gitea_num}: {exc}"}) + continue + + for item in linked_items: + if item.gitea_issue_number == gitea_num: + try: + self._close_work_item(item, taiga) + results.append( + { + "commit": sha, + "closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}", + } + ) + except Exception as exc: + results.append( + {"error": f"work item {item.id} (gitea #{gitea_num}): {exc}"} + ) + + for ref in taiga_story_refs: + project_id = self._project_id_for_ref(owner, repo, ref, linked_items) + if not project_id: + continue + story = taiga.get_by_ref(project_id, ref, kind="userstory") + if story and not story.get("is_closed"): + try: + taiga.close_userstory(story["id"], project_id) + results.append({"commit": sha, "closed": f"taiga #{ref}"}) + except Exception as exc: + results.append({"error": f"taiga #{ref}: {exc}"}) + for item in linked_items: + if item.taiga_story_ref == ref and item.status != "closed": + try: + self._close_work_item(item, taiga, close_gitea=bool(gitea)) + except Exception as exc: + results.append( + {"error": f"work item {item.id} (taiga #{ref}): {exc}"} + ) + + for ref in taiga_task_refs: + binding = self.db.scalar( + select(ProjectBinding).where( + ProjectBinding.user_id == self.user_id, + ProjectBinding.gitea_owner == owner, + ProjectBinding.gitea_repo == repo, + ) + ) + if not binding: + continue + taiga_proj = self.db.scalar( + select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug) + ) + if not taiga_proj: + continue + task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task") + if task and not task.get("is_closed"): + try: + taiga.close_task(task["id"], taiga_proj.taiga_id) + results.append({"commit": sha, "closed": f"taiga task #{ref}"}) + except Exception as exc: + results.append({"error": f"taiga task #{ref}: {exc}"}) + + self.db.commit() + return results + + def _project_id_for_ref( + self, + owner: str, + repo: str, + ref: int, + items: list[WorkItem], + ) -> int | None: + for item in items: + if item.taiga_story_ref == ref: + return item.taiga_project_id + binding = self.db.scalar( + select(ProjectBinding).where( + ProjectBinding.user_id == self.user_id, + ProjectBinding.gitea_owner == owner, + ProjectBinding.gitea_repo == repo, + ) + ) + if binding: + taiga_proj = self.db.scalar( + select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug) + ) + return taiga_proj.taiga_id if taiga_proj else None + return None + + def _close_work_item( + self, + item: WorkItem, + taiga: TaigaClient, + *, + close_gitea: bool = True, + ) -> None: + if item.status == "closed": + return + story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory") + if story: + taiga.close_userstory(story["id"], item.taiga_project_id) + if ( + close_gitea + and item.gitea_issue_number + and self.settings.gitea_configured + ): + GiteaClient().close_issue( + item.gitea_owner, item.gitea_repo, item.gitea_issue_number + ) + item.status = "closed" + item.closed_at = datetime.now(timezone.utc) + + def list_taiga_open_tasks( + self, + project_slug: str | None = None, + limit: int = 20, + ) -> dict[str, Any]: + if not self.settings.taiga_configured: + raise ValueError("Taiga не настроена") + + projects = self.list_projects() + if not projects: + projects = self.sync_taiga_projects() + + if project_slug: + projects = [p for p in projects if p["slug"] == project_slug] + if not projects: + raise ValueError( + f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects." + ) + + client = TaigaClient() + blocks: list[dict[str, Any]] = [] + + for proj in projects: + stories = client.list_open_userstories(proj["taiga_id"], limit=limit) + tasks = client.list_open_tasks(proj["taiga_id"], limit=limit) + blocks.append( + { + "slug": proj["slug"], + "name": proj["name"], + "taiga_id": proj["taiga_id"], + "stories": [ + { + "ref": s.get("ref"), + "subject": s.get("subject", ""), + "url": client.story_url(proj["taiga_id"], s.get("ref", 0)), + } + for s in stories + ], + "tasks": [ + { + "ref": t.get("ref"), + "subject": t.get("subject", ""), + "user_story": t.get("user_story"), + } + for t in tasks + ], + } + ) + + total_stories = sum(len(b["stories"]) for b in blocks) + total_tasks = sum(len(b["tasks"]) for b in blocks) + return { + "projects": blocks, + "total_stories": total_stories, + "total_tasks": total_tasks, + } + + def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]: + stmt = select(WorkItem).where(WorkItem.user_id == self.user_id).order_by(WorkItem.created_at.desc()).limit(limit) + if status: + stmt = stmt.where(WorkItem.status == status) + items = self.db.scalars(stmt).all() + settings = get_settings() + return [ + { + "id": i.id, + "title": i.title, + "status": i.status, + "taiga_slug": i.taiga_slug, + "taiga_ref": i.taiga_story_ref, + "gitea_issue": i.gitea_issue_number, + "branch": i.suggested_branch, + "taiga_url": f"{settings.taiga_public_url}/project/0/{i.taiga_project_id}/us/{i.taiga_story_ref}", + "gitea_url": ( + f"{settings.gitea_public_url}/{i.gitea_owner}/{i.gitea_repo}/issues/{i.gitea_issue_number}" + if i.gitea_issue_number + else "" + ), + "created_at": i.created_at.isoformat() if i.created_at else None, + } + for i in items + ] diff --git a/backend/app/rag/__init__.py b/backend/app/rag/__init__.py new file mode 100644 index 0000000..5157a04 --- /dev/null +++ b/backend/app/rag/__init__.py @@ -0,0 +1,5 @@ +"""RAG: embeddings, Qdrant store, retrieval, ingest.""" + +from app.rag import chunker, embeddings, ingest, retriever, store + +__all__ = ["chunker", "embeddings", "ingest", "retriever", "store"] diff --git a/backend/app/rag/chunker.py b/backend/app/rag/chunker.py new file mode 100644 index 0000000..07ade2f --- /dev/null +++ b/backend/app/rag/chunker.py @@ -0,0 +1,20 @@ +from __future__ import annotations + + +def chunk_text(text: str, *, chunk_size: int = 800, overlap: int = 120) -> list[str]: + cleaned = (text or "").strip() + if not cleaned: + return [] + if len(cleaned) <= chunk_size: + return [cleaned] + chunks: list[str] = [] + start = 0 + while start < len(cleaned): + end = min(len(cleaned), start + chunk_size) + piece = cleaned[start:end].strip() + if piece: + chunks.append(piece) + if end >= len(cleaned): + break + start = max(0, end - overlap) + return chunks diff --git a/backend/app/rag/embeddings.py b/backend/app/rag/embeddings.py new file mode 100644 index 0000000..2e057e8 --- /dev/null +++ b/backend/app/rag/embeddings.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from app.llm.client import LLMClient + + +async def embed_texts(texts: list[str]) -> list[list[float]]: + if not texts: + return [] + client = LLMClient() + return await client.embed(texts) diff --git a/backend/app/rag/ingest.py b/backend/app/rag/ingest.py new file mode 100644 index 0000000..451cc14 --- /dev/null +++ b/backend/app/rag/ingest.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from qdrant_client.http import models as qm +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.db.models import ChatSession, Document, DocumentChunk, MemoryFact +from app.rag import embeddings +from app.rag.chunker import chunk_text +from app.rag.store import ( + COLLECTION_DOC_CHUNKS, + COLLECTION_FACTS, + COLLECTION_SUMMARIES, + delete_by_filter, + upsert_points, +) + + +async def index_memory_fact(fact: MemoryFact) -> None: + settings = get_settings() + if not settings.rag_enabled or not fact.active: + return + vectors = await embeddings.embed_texts([fact.content]) + if not vectors: + return + upsert_points( + COLLECTION_FACTS, + [ + qm.PointStruct( + id=int(fact.id), + vector=vectors[0], + payload={ + "user_id": fact.user_id, + "fact_id": fact.id, + "category": fact.category, + "content": fact.content, + "importance": fact.importance, + }, + ) + ], + ) + + +async def deactivate_memory_fact(fact_id: int) -> None: + settings = get_settings() + if not settings.rag_enabled: + return + delete_by_filter( + COLLECTION_FACTS, + [qm.FieldCondition(key="fact_id", match=qm.MatchValue(value=fact_id))], + ) + + +async def index_session_summary(session_id: int, summary: str) -> None: + settings = get_settings() + if not settings.rag_enabled or not summary.strip(): + return + from app.db.base import SessionLocal + + user_id = 1 + db = SessionLocal() + try: + session = db.get(ChatSession, session_id) + if session: + user_id = session.user_id + finally: + db.close() + + vectors = await embeddings.embed_texts([summary]) + if not vectors: + return + upsert_points( + COLLECTION_SUMMARIES, + [ + qm.PointStruct( + id=int(session_id), + vector=vectors[0], + payload={"user_id": user_id, "session_id": session_id, "summary": summary[:4000]}, + ) + ], + ) + + +async def ingest_document_file( + db: Session, + *, + user_id: int, + title: str, + filename: str, + raw_bytes: bytes, +) -> dict[str, Any]: + settings = get_settings() + text = raw_bytes.decode("utf-8", errors="replace").strip() + if not text: + raise ValueError("Пустой документ") + + digest = hashlib.sha256(raw_bytes).hexdigest() + doc = Document( + user_id=user_id, + title=title or filename, + filename=filename, + content_hash=digest, + size_bytes=len(raw_bytes), + ) + db.add(doc) + db.flush() + + chunks = chunk_text(text) + chunk_rows: list[DocumentChunk] = [] + for idx, piece in enumerate(chunks): + row = DocumentChunk(document_id=doc.id, chunk_index=idx, content=piece) + db.add(row) + chunk_rows.append(row) + db.commit() + db.refresh(doc) + + if settings.rag_enabled and chunks: + vectors = await embeddings.embed_texts(chunks) + points: list[qm.PointStruct] = [] + for row, vector in zip(chunk_rows, vectors, strict=False): + db.refresh(row) + point_id = int(row.id) + points.append( + qm.PointStruct( + id=point_id, + vector=vector, + payload={ + "user_id": user_id, + "document_id": doc.id, + "chunk_id": row.id, + "chunk_index": row.chunk_index, + "title": doc.title, + "content": row.content, + }, + ) + ) + upsert_points(COLLECTION_DOC_CHUNKS, points) + + return { + "id": doc.id, + "title": doc.title, + "filename": doc.filename, + "chunk_count": len(chunks), + "size_bytes": doc.size_bytes, + "created_at": doc.created_at.isoformat() if doc.created_at else None, + } diff --git a/backend/app/rag/migrate_memory_to_qdrant.py b/backend/app/rag/migrate_memory_to_qdrant.py new file mode 100644 index 0000000..e137d35 --- /dev/null +++ b/backend/app/rag/migrate_memory_to_qdrant.py @@ -0,0 +1,37 @@ +# Migrate active memory facts into Qdrant +from __future__ import annotations + +import asyncio + +from sqlalchemy import select + +from app.config import get_settings +from app.db.base import SessionLocal, init_db +from app.db.models import MemoryFact, SessionSummary +from app.rag.ingest import index_memory_fact, index_session_summary +from app.rag.store import ensure_collections + + +async def main() -> None: + settings = get_settings() + if not settings.rag_enabled: + print("RAG disabled; set RAG_ENABLED=true") + return + init_db() + ensure_collections() + db = SessionLocal() + try: + facts = db.scalars(select(MemoryFact).where(MemoryFact.active.is_(True))).all() + for fact in facts: + await index_memory_fact(fact) + summaries = db.scalars(select(SessionSummary)).all() + for row in summaries: + if row.summary: + await index_session_summary(row.session_id, row.summary) + print(f"Indexed {len(facts)} facts and {len(summaries)} summaries") + finally: + db.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/app/rag/retriever.py b/backend/app/rag/retriever.py new file mode 100644 index 0000000..cb29bdd --- /dev/null +++ b/backend/app/rag/retriever.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any + +from qdrant_client.http import models as qm + +from app.config import get_settings +from app.rag import embeddings +from app.rag.store import COLLECTION_DOC_CHUNKS, COLLECTION_FACTS, search + + +def _user_filter(user_id: int) -> qm.Filter: + return qm.Filter( + must=[qm.FieldCondition(key="user_id", match=qm.MatchValue(value=user_id))] + ) + + +async def retrieve_memory_facts( + query: str, *, user_id: int, top_k: int | None = None +) -> list[dict[str, Any]]: + settings = get_settings() + if not settings.rag_enabled or not query.strip(): + return [] + k = top_k or settings.rag_top_k + vectors = await embeddings.embed_texts([query]) + if not vectors: + return [] + hits = search(COLLECTION_FACTS, vectors[0], limit=k, query_filter=_user_filter(user_id)) + results: list[dict[str, Any]] = [] + for hit in hits: + payload = hit.payload or {} + results.append( + { + "id": payload.get("fact_id") or hit.id, + "category": payload.get("category", "fact"), + "content": payload.get("content", ""), + "score": hit.score, + } + ) + return results + + +async def retrieve_document_chunks( + query: str, *, user_id: int, top_k: int = 6 +) -> list[dict[str, Any]]: + settings = get_settings() + if not settings.rag_enabled or not query.strip(): + return [] + vectors = await embeddings.embed_texts([query]) + if not vectors: + return [] + hits = search( + COLLECTION_DOC_CHUNKS, vectors[0], limit=top_k, query_filter=_user_filter(user_id) + ) + out: list[dict[str, Any]] = [] + for hit in hits: + payload = hit.payload or {} + out.append( + { + "document_id": payload.get("document_id"), + "chunk_index": payload.get("chunk_index"), + "title": payload.get("title", ""), + "content": payload.get("content", ""), + "score": hit.score, + } + ) + return out diff --git a/backend/app/rag/store.py b/backend/app/rag/store.py new file mode 100644 index 0000000..a1b9442 --- /dev/null +++ b/backend/app/rag/store.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import logging +from typing import Any + +from qdrant_client import QdrantClient +from qdrant_client.http import models as qm + +from app.config import get_settings + +logger = logging.getLogger(__name__) + +COLLECTION_FACTS = "memory_facts" +COLLECTION_SUMMARIES = "session_summaries" +COLLECTION_DOC_CHUNKS = "document_chunks" +VECTOR_SIZE = 1536 + + +def _client() -> QdrantClient: + settings = get_settings() + return QdrantClient(url=settings.qdrant_url) + + +def ensure_collections() -> None: + settings = get_settings() + if not settings.rag_enabled: + return + client = _client() + for name in (COLLECTION_FACTS, COLLECTION_SUMMARIES, COLLECTION_DOC_CHUNKS): + if client.collection_exists(name): + continue + client.create_collection( + collection_name=name, + vectors_config=qm.VectorParams(size=VECTOR_SIZE, distance=qm.Distance.COSINE), + ) + logger.info("Created Qdrant collection %s", name) + + +def upsert_points(collection: str, points: list[qm.PointStruct]) -> None: + if not points: + return + _client().upsert(collection_name=collection, points=points) + + +def delete_by_filter(collection: str, must: list[qm.FieldCondition]) -> None: + _client().delete( + collection_name=collection, + points_selector=qm.FilterSelector(filter=qm.Filter(must=must)), + ) + + +def search( + collection: str, + vector: list[float], + *, + limit: int, + query_filter: qm.Filter | None = None, +) -> list[qm.ScoredPoint]: + return _client().search( + collection_name=collection, + query_vector=vector, + limit=limit, + query_filter=query_filter, + ) diff --git a/backend/app/reminders/completion.py b/backend/app/reminders/completion.py index 59ca651..9b8daef 100644 --- a/backend/app/reminders/completion.py +++ b/backend/app/reminders/completion.py @@ -1,73 +1,74 @@ -import logging -from datetime import datetime, timezone - -from sqlalchemy.orm import Session - -from app.character.service import CharacterService -from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat -from app.db.models import Reminder -from app.llm.client import LLMClient -from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local - -logger = logging.getLogger(__name__) - - -def format_reminder_notice(row: Reminder) -> str: - local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) - notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_" - if row.notes: - notice += f"\n{row.notes}" - return notice - - -class ReminderCompletionHandler: - def __init__(self, db: Session): - self.db = db - self.llm = LLMClient() - self.character = CharacterService() - - async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str: - notes_part = f"\nЗаметки: {row.notes}" if row.notes else "" - rec_part = "" - if row.recurrence and row.recurrence != RECURRENCE_NONE: - rec_part = f"\nПовтор: {row.recurrence}" - - system = self.character.get_system_prompt() - user_prompt = f"""Сработало напоминание. -Заголовок: {row.title} -Время: {local_when}{notes_part}{rec_part} - -Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи.""" - - result = await self.llm.complete( - [ - {"role": "system", "content": system}, - {"role": "user", "content": user_prompt}, - ], - temperature=0.8, - visible_reply=True, - ) - return (result.get("content") or "").strip() or f"Напоминание: {row.title}" - - def _mark_fired(self, row: Reminder, now: datetime) -> None: - row.last_fired_at = now - if row.recurrence == RECURRENCE_NONE: - row.completed_at = now - row.enabled = False - else: - row.due_at = _advance_due(row.due_at, row.recurrence) - row.last_fired_at = None - row.updated_at = now - - async def process(self, row: Reminder) -> None: - local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) - post_notice_to_latest_chat(format_reminder_notice(row)) - - try: - comment = await self._generate_llm_comment(row, local_when) - if comment: - post_character_comment_to_latest_chat(comment) - except Exception: - logger.exception("Reminder LLM comment failed (id=%s)", row.id) - - self._mark_fired(row, datetime.now(timezone.utc)) +import logging +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.character.service import CharacterService +from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat +from app.db.models import Reminder +from app.llm.client import LLMClient +from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local + +logger = logging.getLogger(__name__) + + +def format_reminder_notice(row: Reminder) -> str: + local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) + notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_" + if row.notes: + notice += f"\n{row.notes}" + return notice + + +class ReminderCompletionHandler: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + self.llm = LLMClient() + self.character = CharacterService(db, user_id) + + async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str: + notes_part = f"\nЗаметки: {row.notes}" if row.notes else "" + rec_part = "" + if row.recurrence and row.recurrence != RECURRENCE_NONE: + rec_part = f"\nПовтор: {row.recurrence}" + + system = self.character.get_system_prompt() + user_prompt = f"""Сработало напоминание. +Заголовок: {row.title} +Время: {local_when}{notes_part}{rec_part} + +Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи.""" + + result = await self.llm.complete( + [ + {"role": "system", "content": system}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.8, + visible_reply=True, + ) + return (result.get("content") or "").strip() or f"Напоминание: {row.title}" + + def _mark_fired(self, row: Reminder, now: datetime) -> None: + row.last_fired_at = now + if row.recurrence == RECURRENCE_NONE: + row.completed_at = now + row.enabled = False + else: + row.due_at = _advance_due(row.due_at, row.recurrence) + row.last_fired_at = None + row.updated_at = now + + async def process(self, row: Reminder) -> None: + local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) + post_notice_to_latest_chat(format_reminder_notice(row), self.user_id) + + try: + comment = await self._generate_llm_comment(row, local_when) + if comment: + post_character_comment_to_latest_chat(comment, self.user_id) + except Exception: + logger.exception("Reminder LLM comment failed (id=%s)", row.id) + + self._mark_fired(row, datetime.now(timezone.utc)) diff --git a/backend/app/reminders/context.py b/backend/app/reminders/context.py index 981f4b4..b573aad 100644 --- a/backend/app/reminders/context.py +++ b/backend/app/reminders/context.py @@ -1,33 +1,33 @@ -from typing import Any - -from sqlalchemy.orm import Session - -from app.reminders.service import RemindersService - -MAX_IN_CONTEXT = 10 - - -def get_reminders_snapshot(db: Session) -> dict[str, Any]: - return RemindersService(db).snapshot() - - -def format_reminders_context(snapshot: dict[str, Any]) -> str: - lines = ["[Напоминания]"] - upcoming = snapshot.get("upcoming") or [] - tz = snapshot.get("timezone", "Europe/Moscow") - - if not upcoming: - lines.append( - "Ближайших напоминаний нет. " - "create_reminder для «напомни через 15 минут», «завтра утром», точной даты." - ) - return "\n".join(lines) - - lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.") - for item in upcoming[:MAX_IN_CONTEXT]: - rec = item.get("recurrence", "none") - rec_label = f" · повтор: {rec}" if rec and rec != "none" else "" - lines.append( - f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}" - ) - return "\n".join(lines) +from typing import Any + +from sqlalchemy.orm import Session + +from app.reminders.service import RemindersService + +MAX_IN_CONTEXT = 10 + + +def get_reminders_snapshot(db: Session, user_id: int) -> dict[str, Any]: + return RemindersService(db, user_id).snapshot() + + +def format_reminders_context(snapshot: dict[str, Any]) -> str: + lines = ["[Напоминания]"] + upcoming = snapshot.get("upcoming") or [] + tz = snapshot.get("timezone", "Europe/Moscow") + + if not upcoming: + lines.append( + "Ближайших напоминаний нет. " + "create_reminder для «напомни через 15 минут», «завтра утром», точной даты." + ) + return "\n".join(lines) + + lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.") + for item in upcoming[:MAX_IN_CONTEXT]: + rec = item.get("recurrence", "none") + rec_label = f" · повтор: {rec}" if rec and rec != "none" else "" + lines.append( + f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}" + ) + return "\n".join(lines) diff --git a/backend/app/reminders/fire.py b/backend/app/reminders/fire.py index 866c2c4..bff2bc2 100644 --- a/backend/app/reminders/fire.py +++ b/backend/app/reminders/fire.py @@ -1,45 +1,50 @@ -import logging -from datetime import datetime, timezone - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.db.models import Reminder -from app.reminders.completion import ReminderCompletionHandler -from app.reminders.notify import bump_notify_seq - -logger = logging.getLogger(__name__) - - -def _utcnow() -> datetime: - return datetime.now(timezone.utc) - - -def get_due_reminders(db: Session) -> list[Reminder]: - now = _utcnow() - stmt = ( - select(Reminder) - .where( - Reminder.enabled.is_(True), - Reminder.completed_at.is_(None), - Reminder.due_at <= now, - ) - .order_by(Reminder.due_at.asc()) - ) - rows = list(db.scalars(stmt).all()) - return [row for row in rows if not (row.last_fired_at and row.last_fired_at >= row.due_at)] - - -async def process_due_reminders(db: Session) -> int: - due = get_due_reminders(db) - if not due: - return 0 - - handler = ReminderCompletionHandler(db) - for row in due: - await handler.process(row) - - db.commit() - bump_notify_seq(db) - logger.info("Reminders fired: %d", len(due)) - return len(due) +import logging +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import Reminder, User +from app.reminders.completion import ReminderCompletionHandler +from app.reminders.notify import bump_notify_seq + +logger = logging.getLogger(__name__) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def get_due_reminders(db: Session, user_id: int) -> list[Reminder]: + now = _utcnow() + stmt = ( + select(Reminder) + .where( + Reminder.user_id == user_id, + Reminder.enabled.is_(True), + Reminder.completed_at.is_(None), + Reminder.due_at <= now, + ) + .order_by(Reminder.due_at.asc()) + ) + rows = list(db.scalars(stmt).all()) + return [row for row in rows if not (row.last_fired_at and row.last_fired_at >= row.due_at)] + + +async def process_due_reminders(db: Session) -> int: + users = db.scalars(select(User).where(User.is_active.is_(True))).all() + total = 0 + for user in users: + due = get_due_reminders(db, user.id) + if not due: + continue + handler = ReminderCompletionHandler(db, user.id) + for row in due: + await handler.process(row) + total += len(due) + + if total: + db.commit() + bump_notify_seq(db) + logger.info("Reminders fired: %d", total) + return total diff --git a/backend/app/reminders/service.py b/backend/app/reminders/service.py index 1fccd9c..4cafa64 100644 --- a/backend/app/reminders/service.py +++ b/backend/app/reminders/service.py @@ -1,237 +1,245 @@ -import calendar -from datetime import datetime, timedelta, timezone -from typing import Any -from zoneinfo import ZoneInfo - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.db.models import Reminder -from app.homelab.context import resolve_timezone -from app.reminders.notify import bump_notify_seq, get_notify_seq - -RECURRENCE_NONE = "none" -RECURRENCE_DAILY = "daily" -RECURRENCE_WEEKLY = "weekly" -RECURRENCE_MONTHLY = "monthly" -RECURRENCE_YEARLY = "yearly" -VALID_RECURRENCE = frozenset({ - RECURRENCE_NONE, - RECURRENCE_DAILY, - RECURRENCE_WEEKLY, - RECURRENCE_MONTHLY, - RECURRENCE_YEARLY, -}) - - -def _utcnow() -> datetime: - return datetime.now(timezone.utc) - - -def _parse_due_at(raw: str, tz_name: str) -> datetime: - clean = raw.strip() - if not clean: - raise ValueError("due_at не может быть пустым") - try: - dt = datetime.fromisoformat(clean.replace("Z", "+00:00")) - except ValueError as exc: - raise ValueError(f"Неверный формат даты: {raw}") from exc - if dt.tzinfo is None: - try: - dt = dt.replace(tzinfo=ZoneInfo(tz_name)) - except Exception: - dt = dt.replace(tzinfo=ZoneInfo("Europe/Moscow")) - return dt.astimezone(timezone.utc) - - -def _advance_due(due_at: datetime, recurrence: str) -> datetime: - if recurrence == RECURRENCE_DAILY: - return due_at + timedelta(days=1) - if recurrence == RECURRENCE_WEEKLY: - return due_at + timedelta(weeks=1) - if recurrence == RECURRENCE_MONTHLY: - month = due_at.month + 1 - year = due_at.year - if month > 12: - month = 1 - year += 1 - day = min(due_at.day, calendar.monthrange(year, month)[1]) - return due_at.replace(year=year, month=month, day=day) - if recurrence == RECURRENCE_YEARLY: - year = due_at.year + 1 - day = min(due_at.day, calendar.monthrange(year, due_at.month)[1]) - return due_at.replace(year=year, day=day) - return due_at - - -def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str: - try: - local = dt.astimezone(ZoneInfo(tz_name)) - except Exception: - local = dt.astimezone(ZoneInfo("Europe/Moscow")) - if all_day: - return local.strftime("%Y-%m-%d") - return local.strftime("%Y-%m-%d %H:%M") - - -class RemindersService: - def __init__(self, db: Session): - self.db = db - - def _tz(self) -> str: - return resolve_timezone(self.db) - - def _to_dict(self, row: Reminder) -> dict[str, Any]: - tz = row.timezone or self._tz() - return { - "id": row.id, - "title": row.title, - "notes": row.notes, - "due_at": row.due_at.isoformat(), - "due_at_local": _format_local(row.due_at, tz, all_day=row.all_day), - "all_day": row.all_day, - "recurrence": row.recurrence, - "enabled": row.enabled, - "completed_at": row.completed_at.isoformat() if row.completed_at else None, - "timezone": tz, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - - def snapshot(self) -> dict[str, Any]: - upcoming = self.list_upcoming(limit=12) - return { - "notify_seq": get_notify_seq(self.db), - "upcoming": upcoming, - "upcoming_count": len(upcoming), - "timezone": self._tz(), - } - - def list_upcoming(self, *, limit: int = 30) -> list[dict[str, Any]]: - stmt = ( - select(Reminder) - .where( - Reminder.enabled.is_(True), - Reminder.completed_at.is_(None), - ) - .order_by(Reminder.due_at.asc()) - .limit(limit) - ) - return [self._to_dict(row) for row in self.db.scalars(stmt).all()] - - def list_in_range( - self, - *, - date_from: datetime, - date_to: datetime, - ) -> list[dict[str, Any]]: - stmt = ( - select(Reminder) - .where( - Reminder.enabled.is_(True), - Reminder.completed_at.is_(None), - Reminder.due_at >= date_from, - Reminder.due_at < date_to, - ) - .order_by(Reminder.due_at.asc()) - ) - return [self._to_dict(row) for row in self.db.scalars(stmt).all()] - - def get(self, reminder_id: int) -> dict[str, Any] | None: - row = self.db.get(Reminder, reminder_id) - return self._to_dict(row) if row else None - - def create( - self, - *, - title: str, - due_at: str, - notes: str = "", - all_day: bool = False, - recurrence: str = RECURRENCE_NONE, - ) -> dict[str, Any]: - clean_title = title.strip() - if not clean_title: - raise ValueError("Название напоминания не может быть пустым") - rec = (recurrence or RECURRENCE_NONE).strip().lower() - if rec not in VALID_RECURRENCE: - raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}") - - tz = self._tz() - due = _parse_due_at(due_at, tz) - row = Reminder( - title=clean_title, - notes=notes.strip(), - due_at=due, - all_day=all_day, - recurrence=rec, - timezone=tz, - ) - self.db.add(row) - self.db.commit() - self.db.refresh(row) - bump_notify_seq(self.db) - return {"ok": True, "reminder": self._to_dict(row), "created": True} - - def update( - self, - reminder_id: int, - *, - title: str | None = None, - due_at: str | None = None, - notes: str | None = None, - all_day: bool | None = None, - recurrence: str | None = None, - enabled: bool | None = None, - ) -> dict[str, Any]: - row = self.db.get(Reminder, reminder_id) - if not row: - raise ValueError("Напоминание не найдено") - - if title is not None: - clean = title.strip() - if not clean: - raise ValueError("Название не может быть пустым") - row.title = clean - if notes is not None: - row.notes = notes.strip() - if due_at is not None: - row.due_at = _parse_due_at(due_at, row.timezone or self._tz()) - row.last_fired_at = None - if all_day is not None: - row.all_day = all_day - if recurrence is not None: - rec = recurrence.strip().lower() - if rec not in VALID_RECURRENCE: - raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}") - row.recurrence = rec - if enabled is not None: - row.enabled = enabled - - row.updated_at = _utcnow() - self.db.commit() - self.db.refresh(row) - bump_notify_seq(self.db) - return {"ok": True, "reminder": self._to_dict(row)} - - def delete(self, reminder_id: int) -> dict[str, Any]: - row = self.db.get(Reminder, reminder_id) - if not row: - raise ValueError("Напоминание не найдено") - title = row.title - self.db.delete(row) - self.db.commit() - bump_notify_seq(self.db) - return {"ok": True, "deleted_id": reminder_id, "title": title} - - def complete(self, reminder_id: int) -> dict[str, Any]: - row = self.db.get(Reminder, reminder_id) - if not row: - raise ValueError("Напоминание не найдено") - now = _utcnow() - row.completed_at = now - row.enabled = False - row.updated_at = now - self.db.commit() - self.db.refresh(row) - bump_notify_seq(self.db) - return {"ok": True, "reminder": self._to_dict(row)} +import calendar +from datetime import datetime, timedelta, timezone +from typing import Any +from zoneinfo import ZoneInfo + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import Reminder +from app.memory.service import MemoryService +from app.reminders.notify import bump_notify_seq, get_notify_seq + +RECURRENCE_NONE = "none" +RECURRENCE_DAILY = "daily" +RECURRENCE_WEEKLY = "weekly" +RECURRENCE_MONTHLY = "monthly" +RECURRENCE_YEARLY = "yearly" +VALID_RECURRENCE = frozenset({ + RECURRENCE_NONE, + RECURRENCE_DAILY, + RECURRENCE_WEEKLY, + RECURRENCE_MONTHLY, + RECURRENCE_YEARLY, +}) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_due_at(raw: str, tz_name: str) -> datetime: + clean = raw.strip() + if not clean: + raise ValueError("due_at не может быть пустым") + try: + dt = datetime.fromisoformat(clean.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"Неверный формат даты: {raw}") from exc + if dt.tzinfo is None: + try: + dt = dt.replace(tzinfo=ZoneInfo(tz_name)) + except Exception: + dt = dt.replace(tzinfo=ZoneInfo("Europe/Moscow")) + return dt.astimezone(timezone.utc) + + +def _advance_due(due_at: datetime, recurrence: str) -> datetime: + if recurrence == RECURRENCE_DAILY: + return due_at + timedelta(days=1) + if recurrence == RECURRENCE_WEEKLY: + return due_at + timedelta(weeks=1) + if recurrence == RECURRENCE_MONTHLY: + month = due_at.month + 1 + year = due_at.year + if month > 12: + month = 1 + year += 1 + day = min(due_at.day, calendar.monthrange(year, month)[1]) + return due_at.replace(year=year, month=month, day=day) + if recurrence == RECURRENCE_YEARLY: + year = due_at.year + 1 + day = min(due_at.day, calendar.monthrange(year, due_at.month)[1]) + return due_at.replace(year=year, day=day) + return due_at + + +def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str: + try: + local = dt.astimezone(ZoneInfo(tz_name)) + except Exception: + local = dt.astimezone(ZoneInfo("Europe/Moscow")) + if all_day: + return local.strftime("%Y-%m-%d") + return local.strftime("%Y-%m-%d %H:%M") + + +class RemindersService: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + + def _tz(self) -> str: + profile = MemoryService(self.db, self.user_id).get_profile() + tz = (profile.get("timezone") or "").strip() + return tz or "Europe/Moscow" + + def _to_dict(self, row: Reminder) -> dict[str, Any]: + tz = row.timezone or self._tz() + return { + "id": row.id, + "title": row.title, + "notes": row.notes, + "due_at": row.due_at.isoformat(), + "due_at_local": _format_local(row.due_at, tz, all_day=row.all_day), + "all_day": row.all_day, + "recurrence": row.recurrence, + "enabled": row.enabled, + "completed_at": row.completed_at.isoformat() if row.completed_at else None, + "timezone": tz, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + def snapshot(self) -> dict[str, Any]: + upcoming = self.list_upcoming(limit=12) + return { + "notify_seq": get_notify_seq(self.db), + "upcoming": upcoming, + "upcoming_count": len(upcoming), + "timezone": self._tz(), + } + + def list_upcoming(self, *, limit: int = 30) -> list[dict[str, Any]]: + stmt = ( + select(Reminder) + .where( + Reminder.user_id == self.user_id, + Reminder.enabled.is_(True), + Reminder.completed_at.is_(None), + ) + .order_by(Reminder.due_at.asc()) + .limit(limit) + ) + return [self._to_dict(row) for row in self.db.scalars(stmt).all()] + + def list_in_range( + self, + *, + date_from: datetime, + date_to: datetime, + ) -> list[dict[str, Any]]: + stmt = ( + select(Reminder) + .where( + Reminder.user_id == self.user_id, + Reminder.enabled.is_(True), + Reminder.completed_at.is_(None), + Reminder.due_at >= date_from, + Reminder.due_at < date_to, + ) + .order_by(Reminder.due_at.asc()) + ) + return [self._to_dict(row) for row in self.db.scalars(stmt).all()] + + def get(self, reminder_id: int) -> dict[str, Any] | None: + row = self.db.get(Reminder, reminder_id) + if not row or row.user_id != self.user_id: + return None + return self._to_dict(row) + + def create( + self, + *, + title: str, + due_at: str, + notes: str = "", + all_day: bool = False, + recurrence: str = RECURRENCE_NONE, + ) -> dict[str, Any]: + clean_title = title.strip() + if not clean_title: + raise ValueError("Название напоминания не может быть пустым") + rec = (recurrence or RECURRENCE_NONE).strip().lower() + if rec not in VALID_RECURRENCE: + raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}") + + tz = self._tz() + due = _parse_due_at(due_at, tz) + row = Reminder( + user_id=self.user_id, + title=clean_title, + notes=notes.strip(), + due_at=due, + all_day=all_day, + recurrence=rec, + timezone=tz, + ) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + bump_notify_seq(self.db) + return {"ok": True, "reminder": self._to_dict(row), "created": True} + + def update( + self, + reminder_id: int, + *, + title: str | None = None, + due_at: str | None = None, + notes: str | None = None, + all_day: bool | None = None, + recurrence: str | None = None, + enabled: bool | None = None, + ) -> dict[str, Any]: + row = self.db.get(Reminder, reminder_id) + if not row or row.user_id != self.user_id: + raise ValueError("Напоминание не найдено") + + if title is not None: + clean = title.strip() + if not clean: + raise ValueError("Название не может быть пустым") + row.title = clean + if notes is not None: + row.notes = notes.strip() + if due_at is not None: + row.due_at = _parse_due_at(due_at, row.timezone or self._tz()) + row.last_fired_at = None + if all_day is not None: + row.all_day = all_day + if recurrence is not None: + rec = recurrence.strip().lower() + if rec not in VALID_RECURRENCE: + raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}") + row.recurrence = rec + if enabled is not None: + row.enabled = enabled + + row.updated_at = _utcnow() + self.db.commit() + self.db.refresh(row) + bump_notify_seq(self.db) + return {"ok": True, "reminder": self._to_dict(row)} + + def delete(self, reminder_id: int) -> dict[str, Any]: + row = self.db.get(Reminder, reminder_id) + if not row or row.user_id != self.user_id: + raise ValueError("Напоминание не найдено") + title = row.title + self.db.delete(row) + self.db.commit() + bump_notify_seq(self.db) + return {"ok": True, "deleted_id": reminder_id, "title": title} + + def complete(self, reminder_id: int) -> dict[str, Any]: + row = self.db.get(Reminder, reminder_id) + if not row or row.user_id != self.user_id: + raise ValueError("Напоминание не найдено") + now = _utcnow() + row.completed_at = now + row.enabled = False + row.updated_at = now + self.db.commit() + self.db.refresh(row) + bump_notify_seq(self.db) + return {"ok": True, "reminder": self._to_dict(row)} diff --git a/backend/app/reminders_scoped/__init__.py b/backend/app/reminders_scoped/__init__.py new file mode 100644 index 0000000..e21845d --- /dev/null +++ b/backend/app/reminders_scoped/__init__.py @@ -0,0 +1,3 @@ +from app.reminders_scoped.service import RemindersService + +__all__ = ["RemindersService"] diff --git a/backend/app/reminders_scoped/completion.py b/backend/app/reminders_scoped/completion.py new file mode 100644 index 0000000..bf37634 --- /dev/null +++ b/backend/app/reminders_scoped/completion.py @@ -0,0 +1,74 @@ +import logging +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.character.service import CharacterService +from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat +from app.db.models import Reminder +from app.llm.client import LLMClient +from app.reminders_scoped.service import RECURRENCE_NONE, _advance_due, _format_local + +logger = logging.getLogger(__name__) + + +def format_reminder_notice(row: Reminder) -> str: + local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) + notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_" + if row.notes: + notice += f"\n{row.notes}" + return notice + + +class ReminderCompletionHandler: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + self.llm = LLMClient() + self.character = CharacterService(db, user_id) + + async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str: + notes_part = f"\nЗаметки: {row.notes}" if row.notes else "" + rec_part = "" + if row.recurrence and row.recurrence != RECURRENCE_NONE: + rec_part = f"\nПовтор: {row.recurrence}" + + system = self.character.get_system_prompt() + user_prompt = f"""Сработало напоминание. +Заголовок: {row.title} +Время: {local_when}{notes_part}{rec_part} + +Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи.""" + + result = await self.llm.complete( + [ + {"role": "system", "content": system}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.8, + visible_reply=True, + ) + return (result.get("content") or "").strip() or f"Напоминание: {row.title}" + + def _mark_fired(self, row: Reminder, now: datetime) -> None: + row.last_fired_at = now + if row.recurrence == RECURRENCE_NONE: + row.completed_at = now + row.enabled = False + else: + row.due_at = _advance_due(row.due_at, row.recurrence) + row.last_fired_at = None + row.updated_at = now + + async def process(self, row: Reminder) -> None: + local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) + post_notice_to_latest_chat(format_reminder_notice(row), self.user_id) + + try: + comment = await self._generate_llm_comment(row, local_when) + if comment: + post_character_comment_to_latest_chat(comment, self.user_id) + except Exception: + logger.exception("Reminder LLM comment failed (id=%s)", row.id) + + self._mark_fired(row, datetime.now(timezone.utc)) diff --git a/backend/app/reminders_scoped/context.py b/backend/app/reminders_scoped/context.py new file mode 100644 index 0000000..3bbd91c --- /dev/null +++ b/backend/app/reminders_scoped/context.py @@ -0,0 +1,33 @@ +from typing import Any + +from sqlalchemy.orm import Session + +from app.reminders_scoped.service import RemindersService + +MAX_IN_CONTEXT = 10 + + +def get_reminders_snapshot(db: Session, user_id: int) -> dict[str, Any]: + return RemindersService(db, user_id).snapshot() + + +def format_reminders_context(snapshot: dict[str, Any]) -> str: + lines = ["[Напоминания]"] + upcoming = snapshot.get("upcoming") or [] + tz = snapshot.get("timezone", "Europe/Moscow") + + if not upcoming: + lines.append( + "Ближайших напоминаний нет. " + "create_reminder для «напомни через 15 минут», «завтра утром», точной даты." + ) + return "\n".join(lines) + + lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.") + for item in upcoming[:MAX_IN_CONTEXT]: + rec = item.get("recurrence", "none") + rec_label = f" · повтор: {rec}" if rec and rec != "none" else "" + lines.append( + f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}" + ) + return "\n".join(lines) diff --git a/backend/app/reminders_scoped/fire.py b/backend/app/reminders_scoped/fire.py new file mode 100644 index 0000000..ea148d3 --- /dev/null +++ b/backend/app/reminders_scoped/fire.py @@ -0,0 +1,50 @@ +import logging +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import Reminder, User +from app.reminders_scoped.completion import ReminderCompletionHandler +from app.reminders.notify import bump_notify_seq + +logger = logging.getLogger(__name__) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def get_due_reminders(db: Session, user_id: int) -> list[Reminder]: + now = _utcnow() + stmt = ( + select(Reminder) + .where( + Reminder.user_id == user_id, + Reminder.enabled.is_(True), + Reminder.completed_at.is_(None), + Reminder.due_at <= now, + ) + .order_by(Reminder.due_at.asc()) + ) + rows = list(db.scalars(stmt).all()) + return [row for row in rows if not (row.last_fired_at and row.last_fired_at >= row.due_at)] + + +async def process_due_reminders(db: Session) -> int: + users = db.scalars(select(User).where(User.is_active.is_(True))).all() + total = 0 + for user in users: + due = get_due_reminders(db, user.id) + if not due: + continue + handler = ReminderCompletionHandler(db, user.id) + for row in due: + await handler.process(row) + total += len(due) + + if total: + db.commit() + bump_notify_seq(db) + logger.info("Reminders fired: %d", total) + return total diff --git a/backend/app/reminders_scoped/service.py b/backend/app/reminders_scoped/service.py new file mode 100644 index 0000000..4cafa64 --- /dev/null +++ b/backend/app/reminders_scoped/service.py @@ -0,0 +1,245 @@ +import calendar +from datetime import datetime, timedelta, timezone +from typing import Any +from zoneinfo import ZoneInfo + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import Reminder +from app.memory.service import MemoryService +from app.reminders.notify import bump_notify_seq, get_notify_seq + +RECURRENCE_NONE = "none" +RECURRENCE_DAILY = "daily" +RECURRENCE_WEEKLY = "weekly" +RECURRENCE_MONTHLY = "monthly" +RECURRENCE_YEARLY = "yearly" +VALID_RECURRENCE = frozenset({ + RECURRENCE_NONE, + RECURRENCE_DAILY, + RECURRENCE_WEEKLY, + RECURRENCE_MONTHLY, + RECURRENCE_YEARLY, +}) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_due_at(raw: str, tz_name: str) -> datetime: + clean = raw.strip() + if not clean: + raise ValueError("due_at не может быть пустым") + try: + dt = datetime.fromisoformat(clean.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"Неверный формат даты: {raw}") from exc + if dt.tzinfo is None: + try: + dt = dt.replace(tzinfo=ZoneInfo(tz_name)) + except Exception: + dt = dt.replace(tzinfo=ZoneInfo("Europe/Moscow")) + return dt.astimezone(timezone.utc) + + +def _advance_due(due_at: datetime, recurrence: str) -> datetime: + if recurrence == RECURRENCE_DAILY: + return due_at + timedelta(days=1) + if recurrence == RECURRENCE_WEEKLY: + return due_at + timedelta(weeks=1) + if recurrence == RECURRENCE_MONTHLY: + month = due_at.month + 1 + year = due_at.year + if month > 12: + month = 1 + year += 1 + day = min(due_at.day, calendar.monthrange(year, month)[1]) + return due_at.replace(year=year, month=month, day=day) + if recurrence == RECURRENCE_YEARLY: + year = due_at.year + 1 + day = min(due_at.day, calendar.monthrange(year, due_at.month)[1]) + return due_at.replace(year=year, day=day) + return due_at + + +def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str: + try: + local = dt.astimezone(ZoneInfo(tz_name)) + except Exception: + local = dt.astimezone(ZoneInfo("Europe/Moscow")) + if all_day: + return local.strftime("%Y-%m-%d") + return local.strftime("%Y-%m-%d %H:%M") + + +class RemindersService: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + + def _tz(self) -> str: + profile = MemoryService(self.db, self.user_id).get_profile() + tz = (profile.get("timezone") or "").strip() + return tz or "Europe/Moscow" + + def _to_dict(self, row: Reminder) -> dict[str, Any]: + tz = row.timezone or self._tz() + return { + "id": row.id, + "title": row.title, + "notes": row.notes, + "due_at": row.due_at.isoformat(), + "due_at_local": _format_local(row.due_at, tz, all_day=row.all_day), + "all_day": row.all_day, + "recurrence": row.recurrence, + "enabled": row.enabled, + "completed_at": row.completed_at.isoformat() if row.completed_at else None, + "timezone": tz, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + def snapshot(self) -> dict[str, Any]: + upcoming = self.list_upcoming(limit=12) + return { + "notify_seq": get_notify_seq(self.db), + "upcoming": upcoming, + "upcoming_count": len(upcoming), + "timezone": self._tz(), + } + + def list_upcoming(self, *, limit: int = 30) -> list[dict[str, Any]]: + stmt = ( + select(Reminder) + .where( + Reminder.user_id == self.user_id, + Reminder.enabled.is_(True), + Reminder.completed_at.is_(None), + ) + .order_by(Reminder.due_at.asc()) + .limit(limit) + ) + return [self._to_dict(row) for row in self.db.scalars(stmt).all()] + + def list_in_range( + self, + *, + date_from: datetime, + date_to: datetime, + ) -> list[dict[str, Any]]: + stmt = ( + select(Reminder) + .where( + Reminder.user_id == self.user_id, + Reminder.enabled.is_(True), + Reminder.completed_at.is_(None), + Reminder.due_at >= date_from, + Reminder.due_at < date_to, + ) + .order_by(Reminder.due_at.asc()) + ) + return [self._to_dict(row) for row in self.db.scalars(stmt).all()] + + def get(self, reminder_id: int) -> dict[str, Any] | None: + row = self.db.get(Reminder, reminder_id) + if not row or row.user_id != self.user_id: + return None + return self._to_dict(row) + + def create( + self, + *, + title: str, + due_at: str, + notes: str = "", + all_day: bool = False, + recurrence: str = RECURRENCE_NONE, + ) -> dict[str, Any]: + clean_title = title.strip() + if not clean_title: + raise ValueError("Название напоминания не может быть пустым") + rec = (recurrence or RECURRENCE_NONE).strip().lower() + if rec not in VALID_RECURRENCE: + raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}") + + tz = self._tz() + due = _parse_due_at(due_at, tz) + row = Reminder( + user_id=self.user_id, + title=clean_title, + notes=notes.strip(), + due_at=due, + all_day=all_day, + recurrence=rec, + timezone=tz, + ) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + bump_notify_seq(self.db) + return {"ok": True, "reminder": self._to_dict(row), "created": True} + + def update( + self, + reminder_id: int, + *, + title: str | None = None, + due_at: str | None = None, + notes: str | None = None, + all_day: bool | None = None, + recurrence: str | None = None, + enabled: bool | None = None, + ) -> dict[str, Any]: + row = self.db.get(Reminder, reminder_id) + if not row or row.user_id != self.user_id: + raise ValueError("Напоминание не найдено") + + if title is not None: + clean = title.strip() + if not clean: + raise ValueError("Название не может быть пустым") + row.title = clean + if notes is not None: + row.notes = notes.strip() + if due_at is not None: + row.due_at = _parse_due_at(due_at, row.timezone or self._tz()) + row.last_fired_at = None + if all_day is not None: + row.all_day = all_day + if recurrence is not None: + rec = recurrence.strip().lower() + if rec not in VALID_RECURRENCE: + raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}") + row.recurrence = rec + if enabled is not None: + row.enabled = enabled + + row.updated_at = _utcnow() + self.db.commit() + self.db.refresh(row) + bump_notify_seq(self.db) + return {"ok": True, "reminder": self._to_dict(row)} + + def delete(self, reminder_id: int) -> dict[str, Any]: + row = self.db.get(Reminder, reminder_id) + if not row or row.user_id != self.user_id: + raise ValueError("Напоминание не найдено") + title = row.title + self.db.delete(row) + self.db.commit() + bump_notify_seq(self.db) + return {"ok": True, "deleted_id": reminder_id, "title": title} + + def complete(self, reminder_id: int) -> dict[str, Any]: + row = self.db.get(Reminder, reminder_id) + if not row or row.user_id != self.user_id: + raise ValueError("Напоминание не найдено") + now = _utcnow() + row.completed_at = now + row.enabled = False + row.updated_at = now + self.db.commit() + self.db.refresh(row) + bump_notify_seq(self.db) + return {"ok": True, "reminder": self._to_dict(row)} diff --git a/backend/app/reminders_scoped/watcher.py b/backend/app/reminders_scoped/watcher.py new file mode 100644 index 0000000..b0dd437 --- /dev/null +++ b/backend/app/reminders_scoped/watcher.py @@ -0,0 +1,31 @@ +import asyncio +import logging + +from app.config import get_settings +from app.db.base import SessionLocal +from app.reminders_scoped.fire import process_due_reminders + +logger = logging.getLogger(__name__) + +WATCH_INTERVAL_SEC = 30 + + +async def reminders_watcher_loop() -> None: + while True: + try: + await asyncio.sleep(WATCH_INTERVAL_SEC) + if not get_settings().reminders_enabled: + continue + await _tick() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Reminders watcher error") + + +async def _tick() -> None: + db = SessionLocal() + try: + await process_due_reminders(db) + finally: + db.close() diff --git a/backend/app/settings/__init__.py b/backend/app/settings/__init__.py new file mode 100644 index 0000000..4fd8963 --- /dev/null +++ b/backend/app/settings/__init__.py @@ -0,0 +1 @@ +"""Runtime settings stored in assistant_state.""" diff --git a/backend/app/settings/service.py b/backend/app/settings/service.py new file mode 100644 index 0000000..7f46fae --- /dev/null +++ b/backend/app/settings/service.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import Settings, get_settings +from app.db.models import AssistantState + +SETTING_KEYS = ( + "openrouter_model", + "memory_extract_model", + "openrouter_reasoning_effort", + "rag_enabled", + "rag_top_k", +) + + +class SettingsService: + def __init__(self, db: Session): + self.db = db + self._defaults = get_settings() + + def _get_row(self, key: str) -> AssistantState | None: + return self.db.get(AssistantState, key) + + def get_raw(self, key: str) -> str | None: + row = self._get_row(key) + if not row or not (row.value or "").strip(): + return None + return row.value.strip() + + def set_raw(self, key: str, value: str) -> None: + row = self._get_row(key) + if not row: + row = AssistantState(key=key, value=value) + self.db.add(row) + else: + row.value = value + row.updated_at = datetime.now(timezone.utc) + self.db.commit() + + def _default_for(self, key: str) -> Any: + defaults: Settings = self._defaults + mapping = { + "openrouter_model": defaults.openrouter_model, + "memory_extract_model": defaults.memory_extract_model or defaults.openrouter_model, + "openrouter_reasoning_effort": defaults.openrouter_reasoning_effort, + "rag_enabled": defaults.rag_enabled, + "rag_top_k": defaults.rag_top_k, + } + return mapping[key] + + def get_effective(self, key: str) -> Any: + raw = self.get_raw(key) + if raw is None: + return self._default_for(key) + if key == "rag_enabled": + return raw.lower() in ("1", "true", "yes", "on") + if key == "rag_top_k": + try: + return max(1, min(50, int(raw))) + except ValueError: + return self._default_for(key) + return raw + + def snapshot(self) -> dict[str, Any]: + data: dict[str, Any] = {} + for key in SETTING_KEYS: + data[key] = self.get_effective(key) + data["embedding_model"] = self._defaults.embedding_model + data["memory_facts_in_context"] = self._defaults.memory_facts_in_context + data["qdrant_url"] = self._defaults.qdrant_url + return data + + def patch(self, updates: dict[str, Any]) -> dict[str, Any]: + for key, value in updates.items(): + if key not in SETTING_KEYS: + continue + if value is None: + row = self._get_row(key) + if row: + self.db.delete(row) + self.db.commit() + continue + if key == "rag_enabled": + stored = "true" if bool(value) else "false" + elif key == "rag_top_k": + stored = str(int(value)) + else: + stored = str(value).strip() + if not stored and key != "rag_enabled": + continue + self.set_raw(key, stored) + return self.snapshot() diff --git a/backend/app/shopping/context.py b/backend/app/shopping/context.py index 926262a..7613530 100644 --- a/backend/app/shopping/context.py +++ b/backend/app/shopping/context.py @@ -1,47 +1,47 @@ -from typing import Any - -from sqlalchemy.orm import Session - -from app.shopping.service import ShoppingService - -MAX_LISTS_IN_CONTEXT = 8 -MAX_ITEMS_PER_LIST = 12 - - -def get_shopping_snapshot(db: Session) -> dict[str, Any]: - return ShoppingService(db).snapshot() - - -def format_shopping_context(snapshot: dict[str, Any]) -> str: - lines = ["[Списки покупок]"] - lists = snapshot.get("lists") or [] - - if not lists: - lines.append("Списков пока нет. create_shopping_list или add_shopping_items.") - return "\n".join(lines) - - lines.append( - f"Всего списков: {snapshot.get('list_count', len(lists))}, " - f"неотмеченных позиций: {snapshot.get('unchecked_items', 0)}." - ) - lines.append("Для изменений вызывай tools: list_shopping_lists, add_shopping_items, check_shopping_item.") - - for lst in lists[:MAX_LISTS_IN_CONTEXT]: - items = lst.get("items") or [] - unchecked = [i for i in items if not i.get("checked")] - preview = unchecked[:MAX_ITEMS_PER_LIST] - parts = [] - for item in preview: - qty = item.get("quantity") - unit = (item.get("unit") or "").strip() - label = item["text"] - if qty is not None: - label = f"{label} ({qty}{' ' + unit if unit else ''})" - parts.append(f"#{item['id']} {label}") - tail = f" +{len(unchecked) - len(preview)} ещё" if len(unchecked) > len(preview) else "" - if parts: - lines.append(f"- «{lst['name']}» (#{lst['id']}): {', '.join(parts)}{tail}") - else: - lines.append(f"- «{lst['name']}» (#{lst['id']}): всё отмечено или пусто") - - return "\n".join(lines) +from typing import Any + +from sqlalchemy.orm import Session + +from app.shopping.service import ShoppingService + +MAX_LISTS_IN_CONTEXT = 8 +MAX_ITEMS_PER_LIST = 12 + + +def get_shopping_snapshot(db: Session, user_id: int) -> dict[str, Any]: + return ShoppingService(db, user_id).snapshot() + + +def format_shopping_context(snapshot: dict[str, Any]) -> str: + lines = ["[Списки покупок]"] + lists = snapshot.get("lists") or [] + + if not lists: + lines.append("Списков пока нет. create_shopping_list или add_shopping_items.") + return "\n".join(lines) + + lines.append( + f"Всего списков: {snapshot.get('list_count', len(lists))}, " + f"неотмеченных позиций: {snapshot.get('unchecked_items', 0)}." + ) + lines.append("Для изменений вызывай tools: list_shopping_lists, add_shopping_items, check_shopping_item.") + + for lst in lists[:MAX_LISTS_IN_CONTEXT]: + items = lst.get("items") or [] + unchecked = [i for i in items if not i.get("checked")] + preview = unchecked[:MAX_ITEMS_PER_LIST] + parts = [] + for item in preview: + qty = item.get("quantity") + unit = (item.get("unit") or "").strip() + label = item["text"] + if qty is not None: + label = f"{label} ({qty}{' ' + unit if unit else ''})" + parts.append(f"#{item['id']} {label}") + tail = f" +{len(unchecked) - len(preview)} ещё" if len(unchecked) > len(preview) else "" + if parts: + lines.append(f"- «{lst['name']}» (#{lst['id']}): {', '.join(parts)}{tail}") + else: + lines.append(f"- «{lst['name']}» (#{lst['id']}): всё отмечено или пусто") + + return "\n".join(lines) diff --git a/backend/app/shopping/service.py b/backend/app/shopping/service.py index 2b7804a..81e6326 100644 --- a/backend/app/shopping/service.py +++ b/backend/app/shopping/service.py @@ -1,223 +1,224 @@ -from datetime import datetime, timezone -from typing import Any - -from sqlalchemy import func, select -from sqlalchemy.orm import Session, selectinload - -from app.db.models import ShoppingList, ShoppingListItem - - -class ShoppingService: - def __init__(self, db: Session): - self.db = db - - def snapshot(self) -> dict[str, Any]: - lists = self.list_lists(include_items=True) - total_items = sum(len(lst.get("items") or []) for lst in lists) - unchecked = sum( - 1 - for lst in lists - for item in (lst.get("items") or []) - if not item.get("checked") - ) - return { - "lists": lists, - "list_count": len(lists), - "total_items": total_items, - "unchecked_items": unchecked, - } - - def list_lists(self, *, include_items: bool = False) -> list[dict[str, Any]]: - stmt = select(ShoppingList).order_by(ShoppingList.sort_order, ShoppingList.name) - if include_items: - stmt = stmt.options(selectinload(ShoppingList.items)) - rows = list(self.db.scalars(stmt).all()) - return [self._list_to_dict(row, include_items=include_items) for row in rows] - - def get_list( - self, - list_id: int | None = None, - *, - name: str | None = None, - ) -> dict[str, Any] | None: - row = self._resolve_list(list_id=list_id, name=name) - if not row: - return None - return self._list_to_dict(row, include_items=True) - - def create_list(self, name: str) -> dict[str, Any]: - clean = name.strip() - if not clean: - raise ValueError("Название списка не может быть пустым") - existing = self.db.scalar(select(ShoppingList).where(ShoppingList.name == clean)) - if existing: - return {"ok": True, "list": self._list_to_dict(existing, include_items=True), "created": False} - - max_order = self.db.scalar(select(func.max(ShoppingList.sort_order))) or 0 - row = ShoppingList(name=clean, sort_order=max_order + 1) - self.db.add(row) - self.db.commit() - self.db.refresh(row) - return {"ok": True, "list": self._list_to_dict(row, include_items=True), "created": True} - - def rename_list(self, list_id: int, name: str) -> dict[str, Any]: - row = self._require_list(list_id) - clean = name.strip() - if not clean: - raise ValueError("Название списка не может быть пустым") - conflict = self.db.scalar( - select(ShoppingList).where(ShoppingList.name == clean, ShoppingList.id != list_id) - ) - if conflict: - raise ValueError(f"Список «{clean}» уже существует") - row.name = clean - row.updated_at = datetime.now(timezone.utc) - self.db.commit() - self.db.refresh(row) - return {"ok": True, "list": self._list_to_dict(row, include_items=True)} - - def delete_list(self, list_id: int) -> dict[str, Any]: - row = self._require_list(list_id) - name = row.name - self.db.delete(row) - self.db.commit() - return {"ok": True, "deleted_list_id": list_id, "name": name} - - def add_items( - self, - items: list[dict[str, Any]], - *, - list_id: int | None = None, - list_name: str | None = None, - create_list: bool = True, - ) -> dict[str, Any]: - if not items: - raise ValueError("Нужен хотя бы один товар") - - row = self._resolve_list(list_id=list_id, name=list_name) - if not row and list_name and create_list: - created = self.create_list(list_name) - row = self._require_list(created["list"]["id"]) - if not row: - raise ValueError("Укажи list_id или list_name") - - max_order = self.db.scalar( - select(func.max(ShoppingListItem.sort_order)).where(ShoppingListItem.list_id == row.id) - ) or 0 - added: list[dict[str, Any]] = [] - for idx, raw in enumerate(items, start=1): - text = (raw.get("text") or "").strip() - if not text: - continue - item = ShoppingListItem( - list_id=row.id, - text=text, - quantity=raw.get("quantity"), - unit=(raw.get("unit") or "").strip(), - sort_order=max_order + idx, - ) - self.db.add(item) - added.append(item) - - if not added: - raise ValueError("Нет валидных товаров для добавления") - - row.updated_at = datetime.now(timezone.utc) - self.db.commit() - for item in added: - self.db.refresh(item) - - return { - "ok": True, - "list_id": row.id, - "list_name": row.name, - "added": [self._item_to_dict(i) for i in added], - "list": self._list_to_dict(self._require_list(row.id), include_items=True), - } - - def set_item_checked(self, item_id: int, checked: bool) -> dict[str, Any]: - item = self._require_item(item_id) - item.checked = checked - item.shopping_list.updated_at = datetime.now(timezone.utc) - self.db.commit() - return {"ok": True, "item": self._item_to_dict(item)} - - def remove_item(self, item_id: int) -> dict[str, Any]: - item = self._require_item(item_id) - data = self._item_to_dict(item) - list_id = item.list_id - self.db.delete(item) - self.db.commit() - return {"ok": True, "removed": data, "list_id": list_id} - - def clear_checked(self, list_id: int) -> dict[str, Any]: - row = self._require_list(list_id) - removed = 0 - for item in list(row.items): - if item.checked: - self.db.delete(item) - removed += 1 - row.updated_at = datetime.now(timezone.utc) - self.db.commit() - return { - "ok": True, - "list_id": list_id, - "removed_count": removed, - "list": self._list_to_dict(self._require_list(list_id), include_items=True), - } - - def _resolve_list( - self, - *, - list_id: int | None = None, - name: str | None = None, - ) -> ShoppingList | None: - if list_id is not None: - return self.db.scalar( - select(ShoppingList) - .where(ShoppingList.id == list_id) - .options(selectinload(ShoppingList.items)) - ) - if name: - clean = name.strip() - return self.db.scalar( - select(ShoppingList) - .where(ShoppingList.name == clean) - .options(selectinload(ShoppingList.items)) - ) - return None - - def _require_list(self, list_id: int) -> ShoppingList: - row = self._resolve_list(list_id=list_id) - if not row: - raise ValueError(f"Список #{list_id} не найден") - return row - - def _require_item(self, item_id: int) -> ShoppingListItem: - item = self.db.get(ShoppingListItem, item_id) - if not item: - raise ValueError(f"Позиция #{item_id} не найдена") - return item - - def _item_to_dict(self, item: ShoppingListItem) -> dict[str, Any]: - return { - "id": item.id, - "list_id": item.list_id, - "text": item.text, - "quantity": item.quantity, - "unit": item.unit, - "checked": item.checked, - "sort_order": item.sort_order, - } - - def _list_to_dict(self, row: ShoppingList, *, include_items: bool) -> dict[str, Any]: - data: dict[str, Any] = { - "id": row.id, - "name": row.name, - "sort_order": row.sort_order, - "item_count": len(row.items) if row.items is not None else 0, - "unchecked_count": sum(1 for i in (row.items or []) if not i.checked), - } - if include_items: - data["items"] = [self._item_to_dict(i) for i in (row.items or [])] - return data +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload + +from app.db.models import ShoppingList, ShoppingListItem + + +class ShoppingService: + def __init__(self, db: Session, user_id: int): + self.db = db + self.user_id = user_id + + def snapshot(self) -> dict[str, Any]: + lists = self.list_lists(include_items=True) + total_items = sum(len(lst.get("items") or []) for lst in lists) + unchecked = sum( + 1 + for lst in lists + for item in (lst.get("items") or []) + if not item.get("checked") + ) + return { + "lists": lists, + "list_count": len(lists), + "total_items": total_items, + "unchecked_items": unchecked, + } + + def list_lists(self, *, include_items: bool = False) -> list[dict[str, Any]]: + stmt = select(ShoppingList).where(ShoppingList.user_id == self.user_id).order_by(ShoppingList.sort_order, ShoppingList.name) + if include_items: + stmt = stmt.options(selectinload(ShoppingList.items)) + rows = list(self.db.scalars(stmt).all()) + return [self._list_to_dict(row, include_items=include_items) for row in rows] + + def get_list( + self, + list_id: int | None = None, + *, + name: str | None = None, + ) -> dict[str, Any] | None: + row = self._resolve_list(list_id=list_id, name=name) + if not row: + return None + return self._list_to_dict(row, include_items=True) + + def create_list(self, name: str) -> dict[str, Any]: + clean = name.strip() + if not clean: + raise ValueError("Название списка не может быть пустым") + existing = self.db.scalar(select(ShoppingList).where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean)) + if existing: + return {"ok": True, "list": self._list_to_dict(existing, include_items=True), "created": False} + + max_order = self.db.scalar(select(func.max(ShoppingList.sort_order)).where(ShoppingList.user_id == self.user_id)) or 0 + row = ShoppingList(user_id=self.user_id, name=clean, sort_order=max_order + 1) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + return {"ok": True, "list": self._list_to_dict(row, include_items=True), "created": True} + + def rename_list(self, list_id: int, name: str) -> dict[str, Any]: + row = self._require_list(list_id) + clean = name.strip() + if not clean: + raise ValueError("Название списка не может быть пустым") + conflict = self.db.scalar( + select(ShoppingList).where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean, ShoppingList.id != list_id) + ) + if conflict: + raise ValueError(f"Список «{clean}» уже существует") + row.name = clean + row.updated_at = datetime.now(timezone.utc) + self.db.commit() + self.db.refresh(row) + return {"ok": True, "list": self._list_to_dict(row, include_items=True)} + + def delete_list(self, list_id: int) -> dict[str, Any]: + row = self._require_list(list_id) + name = row.name + self.db.delete(row) + self.db.commit() + return {"ok": True, "deleted_list_id": list_id, "name": name} + + def add_items( + self, + items: list[dict[str, Any]], + *, + list_id: int | None = None, + list_name: str | None = None, + create_list: bool = True, + ) -> dict[str, Any]: + if not items: + raise ValueError("Нужен хотя бы один товар") + + row = self._resolve_list(list_id=list_id, name=list_name) + if not row and list_name and create_list: + created = self.create_list(list_name) + row = self._require_list(created["list"]["id"]) + if not row: + raise ValueError("Укажи list_id или list_name") + + max_order = self.db.scalar( + select(func.max(ShoppingListItem.sort_order)).where(ShoppingListItem.list_id == row.id) + ) or 0 + added: list[dict[str, Any]] = [] + for idx, raw in enumerate(items, start=1): + text = (raw.get("text") or "").strip() + if not text: + continue + item = ShoppingListItem( + list_id=row.id, + text=text, + quantity=raw.get("quantity"), + unit=(raw.get("unit") or "").strip(), + sort_order=max_order + idx, + ) + self.db.add(item) + added.append(item) + + if not added: + raise ValueError("Нет валидных товаров для добавления") + + row.updated_at = datetime.now(timezone.utc) + self.db.commit() + for item in added: + self.db.refresh(item) + + return { + "ok": True, + "list_id": row.id, + "list_name": row.name, + "added": [self._item_to_dict(i) for i in added], + "list": self._list_to_dict(self._require_list(row.id), include_items=True), + } + + def set_item_checked(self, item_id: int, checked: bool) -> dict[str, Any]: + item = self._require_item(item_id) + item.checked = checked + item.shopping_list.updated_at = datetime.now(timezone.utc) + self.db.commit() + return {"ok": True, "item": self._item_to_dict(item)} + + def remove_item(self, item_id: int) -> dict[str, Any]: + item = self._require_item(item_id) + data = self._item_to_dict(item) + list_id = item.list_id + self.db.delete(item) + self.db.commit() + return {"ok": True, "removed": data, "list_id": list_id} + + def clear_checked(self, list_id: int) -> dict[str, Any]: + row = self._require_list(list_id) + removed = 0 + for item in list(row.items): + if item.checked: + self.db.delete(item) + removed += 1 + row.updated_at = datetime.now(timezone.utc) + self.db.commit() + return { + "ok": True, + "list_id": list_id, + "removed_count": removed, + "list": self._list_to_dict(self._require_list(list_id), include_items=True), + } + + def _resolve_list( + self, + *, + list_id: int | None = None, + name: str | None = None, + ) -> ShoppingList | None: + if list_id is not None: + return self.db.scalar( + select(ShoppingList) + .where(ShoppingList.user_id == self.user_id, ShoppingList.id == list_id) + .options(selectinload(ShoppingList.items)) + ) + if name: + clean = name.strip() + return self.db.scalar( + select(ShoppingList) + .where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean) + .options(selectinload(ShoppingList.items)) + ) + return None + + def _require_list(self, list_id: int) -> ShoppingList: + row = self._resolve_list(list_id=list_id) + if not row: + raise ValueError(f"Список #{list_id} не найден") + return row + + def _require_item(self, item_id: int) -> ShoppingListItem: + item = self.db.get(ShoppingListItem, item_id) + if not item or item.shopping_list.user_id != self.user_id: + raise ValueError(f"Позиция #{item_id} не найдена") + return item + + def _item_to_dict(self, item: ShoppingListItem) -> dict[str, Any]: + return { + "id": item.id, + "list_id": item.list_id, + "text": item.text, + "quantity": item.quantity, + "unit": item.unit, + "checked": item.checked, + "sort_order": item.sort_order, + } + + def _list_to_dict(self, row: ShoppingList, *, include_items: bool) -> dict[str, Any]: + data: dict[str, Any] = { + "id": row.id, + "name": row.name, + "sort_order": row.sort_order, + "item_count": len(row.items) if row.items is not None else 0, + "unchecked_count": sum(1 for i in (row.items or []) if not i.checked), + } + if include_items: + data["items"] = [self._item_to_dict(i) for i in (row.items or [])] + return data diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 9b83953..db8d2c1 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -1,961 +1,1073 @@ -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) +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 + +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": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды.", + "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": "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)", + }, + }, + "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, + 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) + + 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", "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 == "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"), + 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 = 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, + 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) + 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/registry.py.refactor_bak b/backend/app/tools/registry.py.refactor_bak new file mode 100644 index 0000000..9b83953 --- /dev/null +++ b/backend/app/tools/registry.py.refactor_bak @@ -0,0 +1,961 @@ +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/backfill_fitness_activity.py b/backend/backfill_fitness_activity.py new file mode 100644 index 0000000..5240b6a --- /dev/null +++ b/backend/backfill_fitness_activity.py @@ -0,0 +1,72 @@ +"""Backfill workout active_calories / steps from notes via regex.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import WorkoutLog + +ACTIVE_PATTERNS = [ + re.compile(r"(?:active|активн(?:ые|ых)?)\s*(?:калор|kcal|ккал)[^\d]*(\d+(?:[.,]\d+)?)", re.I), + re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:ккал|kcal)\s*(?:active|актив)", re.I), + re.compile(r"актив[^\d]{0,20}(\d+(?:[.,]\d+)?)", re.I), +] +TOTAL_PATTERNS = [ + re.compile(r"(?:total|всего|сожжено|burned)[^\d]*(\d+(?:[.,]\d+)?)\s*(?:ккал|kcal)", re.I), + re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:ккал|kcal)\s*(?:total|всего)", re.I), +] +STEPS_PATTERNS = [ + re.compile(r"(?:шаг|step)s?\s*[:\-]?\s*(\d+)", re.I), + re.compile(r"(\d+)\s*(?:шаг|steps)", re.I), +] + + +def _first(patterns: list[re.Pattern[str]], text: str, *, as_int: bool = False): + for pat in patterns: + m = pat.search(text) + if not m: + continue + raw = m.group(1).replace(",", ".") + return int(float(raw)) if as_int else float(raw) + return None + + +def main() -> None: + db = SessionLocal() + updated = 0 + try: + rows = db.scalars(select(WorkoutLog)).all() + for row in rows: + text = f"{row.title or ''}\n{row.notes or ''}" + changed = False + if row.active_calories is None: + val = _first(ACTIVE_PATTERNS, text) + if val is not None: + row.active_calories = float(val) + changed = True + if row.total_calories is None: + val = _first(TOTAL_PATTERNS, text) + if val is not None: + row.total_calories = float(val) + changed = True + if row.steps is None: + val = _first(STEPS_PATTERNS, text, as_int=True) + if val is not None: + row.steps = int(val) + changed = True + if changed: + updated += 1 + db.commit() + print(f"updated {updated} workout rows") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt index 8bfd868..8593d7e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,9 +1,11 @@ -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 +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +python-multipart>=0.0.9 +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 +qdrant-client>=1.12.0,<1.13.0 diff --git a/backend/requirements.txt.refactor_bak b/backend/requirements.txt.refactor_bak new file mode 100644 index 0000000..8bfd868 --- /dev/null +++ b/backend/requirements.txt.refactor_bak @@ -0,0 +1,9 @@ +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/create_user.py b/backend/scripts/create_user.py new file mode 100644 index 0000000..b639869 --- /dev/null +++ b/backend/scripts/create_user.py @@ -0,0 +1,42 @@ +"""Create a user with API token. Usage: + python -m scripts.create_user testuser --display-name "Test User" + python -m scripts.create_user guest --token my-custom-token-32chars-min +""" +from __future__ import annotations + +import argparse +import sys + +from app.db.base import SessionLocal, init_db +from app.auth.service import create_user + + +def main() -> int: + parser = argparse.ArgumentParser(description="Create Home Assistant user") + parser.add_argument("username", help="Unique username (lowercase)") + parser.add_argument("--display-name", default="", help="Display name") + parser.add_argument("--token", default="", help="Custom API token (auto-generated if empty)") + args = parser.parse_args() + + init_db() + db = SessionLocal() + try: + user, plain_token = create_user( + db, + username=args.username, + display_name=args.display_name or args.username, + api_token=args.token or None, + ) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + finally: + db.close() + + print(f"Created user id={user.id} username={user.username}") + print(f"API token (save it, shown once):\n{plain_token}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_activity_budget.py b/backend/tests/test_activity_budget.py new file mode 100644 index 0000000..45a060b --- /dev/null +++ b/backend/tests/test_activity_budget.py @@ -0,0 +1,84 @@ +import unittest + +from app.fitness.activity_budget import ( + compute_activity_bonus, + scale_targets, + steps_bonus_kcal, +) + + +PROFILE = { + "weight_kg": 70, + "activity_level": "moderate", + "weekly_workouts": 3, + "calorie_target": 2000, + "protein_g": 126, + "fat_g": 56, + "carbs_g": 250, + "water_l": 2.5, +} + + +class ActivityBudgetTests(unittest.TestCase): + def test_no_bonus_at_baseline(self) -> None: + bonus = compute_activity_bonus( + PROFILE, + steps_total=9000, + workouts=[{"active_calories": 85}], + ) + self.assertEqual(bonus.steps_bonus_kcal, 0.0) + self.assertEqual(bonus.workout_bonus_kcal, 0.0) + self.assertEqual(bonus.total_bonus_kcal, 0.0) + self.assertEqual(bonus.scale_factor, 1.0) + + def test_steps_and_workout_bonus(self) -> None: + bonus = compute_activity_bonus( + PROFILE, + steps_total=21800, + workouts=[{"active_calories": 155}], + ) + self.assertGreater(bonus.steps_bonus_kcal, 0) + self.assertGreater(bonus.workout_bonus_kcal, 0) + self.assertEqual( + bonus.total_bonus_kcal, + round(bonus.steps_bonus_kcal + bonus.workout_bonus_kcal, 1), + ) + self.assertGreater(bonus.scale_factor, 1.0) + + def test_steps_bonus_formula(self) -> None: + kcal = steps_bonus_kcal(steps=21800, baseline_steps=9000, weight_kg=70) + self.assertEqual(kcal, round(12800 * 70 * 0.0005, 1)) + + def test_proportional_macro_scale(self) -> None: + base = { + "calories": 2000, + "protein_g": 100, + "fat_g": 50, + "carbs_g": 200, + "water_ml": 2500, + } + effective, targets_base = scale_targets(base, 500) + self.assertEqual(effective["calories"], 2500) + self.assertEqual(targets_base, base) + self.assertEqual(effective["water_ml"], 2500) + ratio = effective["calories"] / base["calories"] + self.assertAlmostEqual(effective["protein_g"] / base["protein_g"], ratio, places=1) + self.assertAlmostEqual(effective["fat_g"] / base["fat_g"], ratio, places=1) + self.assertAlmostEqual(effective["carbs_g"] / base["carbs_g"], ratio, places=1) + + def test_floor_at_base_when_no_activity(self) -> None: + effective, _ = scale_targets( + { + "calories": 2045, + "protein_g": 156, + "fat_g": 57, + "carbs_g": 227, + "water_ml": 2900, + }, + 0, + ) + self.assertEqual(effective["calories"], 2045) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_body_composition.py b/backend/tests/test_body_composition.py new file mode 100644 index 0000000..10156e3 --- /dev/null +++ b/backend/tests/test_body_composition.py @@ -0,0 +1,102 @@ +import pytest + +from app.fitness.body_composition import ( + compute_body_composition, + ffmi, + lean_body_mass, + navy_body_fat_pct, + whr, +) + + +def test_navy_male_reasonable_range(): + bf = navy_body_fat_pct( + sex="male", + height_cm=180, + neck_cm=38, + waist_cm=84, + ) + assert bf is not None + assert 5 <= bf <= 35 + + +def test_navy_female_requires_hip(): + assert navy_body_fat_pct( + sex="female", + height_cm=165, + neck_cm=34, + waist_cm=72, + hip_cm=None, + ) is None + + bf = navy_body_fat_pct( + sex="female", + height_cm=165, + neck_cm=34, + waist_cm=72, + hip_cm=98, + ) + assert bf is not None + assert 10 <= bf <= 45 + + +def test_navy_invalid_waist_neck(): + assert navy_body_fat_pct( + sex="male", + height_cm=180, + neck_cm=40, + waist_cm=39, + ) is None + + +def test_whr(): + assert whr(80, 100) == 0.8 + + +def test_lean_body_mass_and_ffmi(): + lbm = lean_body_mass(80, 20) + assert lbm == 64.0 + score = ffmi(80, 180, 20) + assert score is not None + assert 15 <= score <= 25 + + +def test_compute_manual_body_fat(): + result = compute_body_composition( + sex="male", + height_cm=180, + weight_kg=80, + body_fat_pct=18, + waist_cm=84, + hip_cm=100, + ) + assert result["body_fat_pct"] == 18.0 + assert result["body_fat_method"] == "manual" + assert result["whr"] == 0.84 + assert result["lbm_kg"] == 65.6 + assert result["ffmi"] is not None + + +def test_compute_navy_auto(): + result = compute_body_composition( + sex="male", + height_cm=180, + weight_kg=82, + neck_cm=38, + waist_cm=84, + ) + assert result["body_fat_pct"] is not None + assert result["body_fat_method"] == "navy" + assert result["lbm_kg"] is not None + + +def test_compute_female_warning_without_hip(): + result = compute_body_composition( + sex="female", + height_cm=165, + weight_kg=60, + neck_cm=34, + waist_cm=72, + ) + assert result["body_fat_pct"] is None + assert any("бёдер" in w for w in result["warnings"]) diff --git a/backend/tests/test_multi_user.py b/backend/tests/test_multi_user.py new file mode 100644 index 0000000..5f77f94 --- /dev/null +++ b/backend/tests/test_multi_user.py @@ -0,0 +1,154 @@ +import os +import tempfile +import uuid +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.auth.tokens import hash_token +from app.db.base import Base, get_db +from app.db.models import CharacterCard, ChatSession, MemoryFact, ShoppingList, User + + +@pytest.fixture() +def client(): + db_path = Path(tempfile.gettempdir()) / f"test_multi_{uuid.uuid4().hex}.db" + os.environ["DATABASE_URL"] = f"sqlite:///{db_path.as_posix()}" + os.environ["DEFAULT_API_TOKEN"] = "unused-in-tests" + os.environ["AUTH_REQUIRED"] = "true" + os.environ["RAG_ENABLED"] = "false" + + from app.config import get_settings + + get_settings.cache_clear() + + from app.main import create_app + + engine = create_engine( + f"sqlite:///{db_path.as_posix()}", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base.metadata.create_all(bind=engine) + + token_a = "token-user-a" + token_b = "token-user-b" + + db = TestingSessionLocal() + user_a = User( + username="alice", + display_name="Alice", + api_token_hash=hash_token(token_a), + is_active=True, + ) + user_b = User( + username="bob", + display_name="Bob", + api_token_hash=hash_token(token_b), + is_active=True, + ) + db.add_all([user_a, user_b]) + db.commit() + db.refresh(user_a) + db.refresh(user_b) + + db.add(ChatSession(user_id=user_a.id, title="Alice chat")) + db.add(ChatSession(user_id=user_b.id, title="Bob chat")) + db.add(ShoppingList(user_id=user_a.id, name="groceries")) + db.add(ShoppingList(user_id=user_b.id, name="groceries")) + db.add( + CharacterCard( + user_id=user_a.id, + card_json='{"spec":"chara_card_v2","spec_version":"2.0","data":{"name":"A","rp_persona_id":"persona-a"}}', + ) + ) + db.add( + CharacterCard( + user_id=user_b.id, + card_json='{"spec":"chara_card_v2","spec_version":"2.0","data":{"name":"B","rp_persona_id":"persona-b"}}', + ) + ) + db.add( + MemoryFact( + user_id=user_a.id, + category="person", + content="Секрет только для owner", + source="test", + ) + ) + db.commit() + db.close() + + app = create_app() + + def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as test_client: + test_client.tokens = {"a": token_a, "b": token_b} + yield test_client + + app.dependency_overrides.clear() + get_settings.cache_clear() + try: + db_path.unlink(missing_ok=True) + except OSError: + pass + + +def _headers(client: TestClient, who: str) -> dict[str, str]: + return {"Authorization": f"Bearer {client.tokens[who]}"} + + +def test_chat_sessions_isolated(client: TestClient): + res_a = client.get("/api/v1/chat/sessions", headers=_headers(client, "a")) + res_b = client.get("/api/v1/chat/sessions", headers=_headers(client, "b")) + assert res_a.status_code == 200 + assert res_b.status_code == 200 + titles_a = {s["title"] for s in res_a.json()} + titles_b = {s["title"] for s in res_b.json()} + assert titles_a == {"Alice chat"} + assert titles_b == {"Bob chat"} + + +def test_character_cards_isolated(client: TestClient): + res_a = client.get("/api/v1/character", headers=_headers(client, "a")) + res_b = client.get("/api/v1/character", headers=_headers(client, "b")) + assert res_a.json()["data"]["rp_persona_id"] == "persona-a" + assert res_b.json()["data"]["rp_persona_id"] == "persona-b" + + +def test_shopping_same_name_different_users(client: TestClient): + res_a = client.get("/api/v1/shopping", headers=_headers(client, "a")) + res_b = client.get("/api/v1/shopping", headers=_headers(client, "b")) + assert res_a.status_code == 200 + assert res_b.status_code == 200 + assert len(res_a.json()["lists"]) == 1 + assert len(res_b.json()["lists"]) == 1 + + +def test_missing_token_unauthorized(client: TestClient): + res = client.get("/api/v1/chat/sessions") + assert res.status_code == 401 + + +def test_memory_facts_isolated(client: TestClient): + res_a = client.get("/api/v1/memory", headers=_headers(client, "a")) + res_b = client.get("/api/v1/memory", headers=_headers(client, "b")) + assert res_a.status_code == 200 + assert res_b.status_code == 200 + facts_a = res_a.json().get("facts") or [] + facts_b = res_b.json().get("facts") or [] + assert any("Секрет только для owner" in f.get("content", "") for f in facts_a) + assert not any("Секрет только для owner" in f.get("content", "") for f in facts_b) + assert res_b.json().get("total_facts", 0) == 0 diff --git a/docker-compose.yml b/docker-compose.yml index db9e27d..d77fea6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,39 @@ -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 +services: + qdrant: + image: qdrant/qdrant:v1.12.5 + ports: + - "${QDRANT_PORT:-6333}:6333" + - "${QDRANT_GRPC_PORT:-6334}:6334" + volumes: + - qdrant_data:/qdrant/storage + restart: unless-stopped + + backend: + build: ./backend + ports: + - "${BACKEND_PORT:-8080}:${BACKEND_INTERNAL_PORT:-8080}" + env_file: .env + environment: + QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333} + depends_on: + - qdrant + volumes: + - ./data:/app/data + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + VITE_API_URL: "" + VITE_API_TOKEN: ${VITE_API_TOKEN:-} + ports: + - "${FRONTEND_PORT:-3080}:${FRONTEND_INTERNAL_PORT:-80}" + depends_on: + - backend + restart: unless-stopped + +volumes: + qdrant_data: diff --git a/docker-compose.yml.refactor_bak b/docker-compose.yml.refactor_bak new file mode 100644 index 0000000..db9e27d --- /dev/null +++ b/docker-compose.yml.refactor_bak @@ -0,0 +1,22 @@ +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/Dockerfile b/frontend/Dockerfile index 1d354b0..2e6d019 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -5,6 +5,9 @@ WORKDIR /app ARG VITE_API_URL= ENV VITE_API_URL=$VITE_API_URL +ARG VITE_API_TOKEN= +ENV VITE_API_TOKEN=$VITE_API_TOKEN + COPY package.json ./ RUN npm install diff --git a/frontend/package.json b/frontend/package.json index 38d8a2d..80e8c1b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,24 +1,25 @@ -{ - "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" - } -} +{ + "name": "home-assistant-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "node -e \"try{require('fs').unlinkSync('src/pages/Chat.old.tsx')}catch(e){}\" && tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-virtual": "^3.14.2", + "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/package.json.refactor_bak b/frontend/package.json.refactor_bak new file mode 100644 index 0000000..38d8a2d --- /dev/null +++ b/frontend/package.json.refactor_bak @@ -0,0 +1,24 @@ +{ + "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 b/frontend/src/App.css index b1560be..553c2b4 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,81 +1,134 @@ -.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; - } -} +.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-user { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-left: 0.25rem; + padding-left: 0.5rem; + border-left: 1px solid #2a2f3a; + color: #8b939f; + font-size: 0.8rem; + white-space: nowrap; +} + +.app-logout { + padding: 0.35rem 0.6rem; + border: 1px solid #3a4254; + border-radius: 6px; + background: transparent; + color: #c5ccd6; + cursor: pointer; + font-size: 0.75rem; +} + +.app-logout:hover { + background: #2b3445; +} + +.app-main { + position: relative; + 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; + } +} + +.app-main-chat { + overflow: hidden; +} + +.route-panel { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + visibility: hidden; + pointer-events: none; +} + +.route-panel-active { + visibility: visible; + pointer-events: auto; +} + +.app-main:not(.app-main-chat) > .route-panel-active { + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} diff --git a/frontend/src/App.css.refactor_bak b/frontend/src/App.css.refactor_bak new file mode 100644 index 0000000..b1560be --- /dev/null +++ b/frontend/src/App.css.refactor_bak @@ -0,0 +1,81 @@ +.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 b/frontend/src/App.tsx index ef2b1c4..a991eb8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,49 +1,192 @@ -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

- -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
-
- ); -} +import { NavLink, Route, Routes, useLocation, useNavigate } from "react-router-dom"; + +import PomodoroWidget from "./components/PomodoroWidget"; + +import RequireAuth from "./components/RequireAuth"; + +import { AuthProvider, useAuth } from "./context/AuthContext"; + +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 Login from "./pages/Login"; + +import Reminders from "./pages/Reminders"; + +import Shopping from "./pages/Shopping"; + +import Memory from "./pages/Memory"; + +import Pomodoro from "./pages/Pomodoro"; + +import Settings from "./pages/Settings"; + +import "./App.css"; + + + +function AppShell() { + + const location = useLocation(); + + const navigate = useNavigate(); + + const { user, logout } = useAuth(); + + const isChat = location.pathname === "/"; + + const mainClass = isChat ? "app-main app-main-chat" : "app-main"; + + + + return ( + +
+ +
+ +

Home AI Assistant

+ + + +
+ +
+ +
+ + + +
+ +
+ + + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + + +
+ +
+ +
+ + ); + +} + + + +function ProtectedApp() { + + return ( + + + + + + + + + + + + ); + +} + + + +export default function App() { + + useVisualViewportHeight(); + + + + return ( + + + + + + } /> + + } /> + + + + + + ); + +} + + diff --git a/frontend/src/App.tsx.refactor_bak b/frontend/src/App.tsx.refactor_bak new file mode 100644 index 0000000..ef2b1c4 --- /dev/null +++ b/frontend/src/App.tsx.refactor_bak @@ -0,0 +1,49 @@ +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 b/frontend/src/api/client.ts index c9ce3f8..20ccca2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,555 +1,821 @@ -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; -} +const API_BASE = import.meta.env.VITE_API_URL ?? ""; + +const TOKEN_STORAGE_KEY = "ha_api_token"; +const ENV_TOKEN = import.meta.env.VITE_API_TOKEN ?? ""; + +export function getAuthToken(): string { + return localStorage.getItem(TOKEN_STORAGE_KEY)?.trim() || ENV_TOKEN; +} + +export function setAuthToken(token: string): void { + localStorage.setItem(TOKEN_STORAGE_KEY, token.trim()); +} + +export function clearAuthToken(): void { + localStorage.removeItem(TOKEN_STORAGE_KEY); +} + +function authHeaders(extra: Record = {}): HeadersInit { + const headers: Record = { ...extra }; + const token = getAuthToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +export interface AuthUser { + id: number; + username: string; + display_name: string; +} + +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 MessagesPage { + messages: ChatMessage[]; + has_more: boolean; +} + +export interface GenerationStatus { + active: boolean; +} + +export interface ChatStreamChunk { + event: string; + data: Record; +} + +async function* readChatSse(response: Response): AsyncGenerator { + 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) as Record }; + } + } + }; + + 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(); + } +} + +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 FitnessActivityBonus { + steps: number; + steps_baseline: number; + steps_bonus_kcal: number; + workout_active_kcal: number; + workout_baseline_kcal: number; + workout_bonus_kcal: number; + total_bonus_kcal: number; + scale_factor: number; +} + +export interface FitnessTargets { + calories: number; + protein_g: number; + fat_g: number; + carbs_g: number; + water_ml: number; +} + +export interface StepLogItem { + id: number; + steps: number; + active_calories?: number | null; + source?: string; + notes?: string; + logged_at?: string; +} + +export interface FitnessWorkoutStats { + days: number; + start_date: string; + end_date: string; + count: number; + duration_min: number; + active_kcal: number; + weekly_target: number; + streak: number; +} + +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; + baseline_steps?: number | null; + baseline_workout_kcal?: number | null; + 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; + active_calories?: number | null; + total_calories?: number | null; + steps?: 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; + steps?: number; + }; + targets: FitnessTargets; + targets_base?: FitnessTargets; + activity?: FitnessActivityBonus; + steps?: StepLogItem[]; + steps_total?: number; + meals: FoodLogItem[]; + water: WaterLogItem[]; + workouts: WorkoutLogItem[]; +} + +export interface BodyMetric { + id: number; + weight_kg: number; + body_fat_pct?: number | null; + body_fat_method?: string | null; + chest_cm?: number | null; + waist_cm?: number | null; + neck_cm?: number | null; + hip_cm?: number | null; + whr?: number | null; + lbm_kg?: number | null; + ffmi?: number | null; + notes?: string; + recorded_at?: string; +} + +export interface BodyCompositionComputed { + body_fat_pct?: number | null; + body_fat_method?: string | null; + whr?: number | null; + lbm_kg?: number | null; + ffmi?: number | null; + warnings?: 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"]; + targets_base?: FitnessTargets; + 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; + workout_stats?: FitnessWorkoutStats; + 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, + headers: authHeaders( + options?.headers instanceof Headers + ? Object.fromEntries(options.headers.entries()) + : (options?.headers as Record | undefined) ?? {}, + ), + }); + 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"), + + login: async (token: string) => { + const response = await fetch(`${API_BASE}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(text || "Неверный токен"); + } + return response.json() as Promise<{ ok: boolean; user: AuthUser; token: string }>; + }, + + me: () => request<{ ok: boolean; user: AuthUser }>("/api/v1/auth/me"), + + listUsers: () => + request<{ ok: boolean; users: AuthUser[]; current_user_id: number }>("/api/v1/auth/users"), + + createUser: (payload: { username: string; display_name?: string; token?: string }) => + request<{ ok: boolean; user: AuthUser; token: string; created_by: string }>( + "/api/v1/auth/users", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ), + + 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}`), + + getSessionMessages: ( + id: number, + params?: { limit?: number; before_id?: number; after_id?: number }, + ) => { + const query = new URLSearchParams(); + if (params?.limit) query.set("limit", String(params.limit)); + if (params?.before_id) query.set("before_id", String(params.before_id)); + if (params?.after_id) query.set("after_id", String(params.after_id)); + const suffix = query.toString(); + return request( + `/api/v1/chat/sessions/${id}/messages${suffix ? `?${suffix}` : ""}`, + ); + }, + + deleteSession: (id: number) => + request<{ ok: boolean }>(`/api/v1/chat/sessions/${id}`, { method: "DELETE" }), + + getGenerationStatus: (id: number) => + request(`/api/v1/chat/sessions/${id}/generation`), + + sendMessage: async function* (sessionId: number, content: string) { + const response = await fetch(`${API_BASE}/api/v1/chat/sessions/${sessionId}/messages`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ content }), + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(detail || `Ошибка отправки (${response.status})`); + } + + yield* readChatSse(response); + }, + + streamGeneration: async function* (sessionId: number) { + const response = await fetch( + `${API_BASE}/api/v1/chat/sessions/${sessionId}/generation/stream`, + { headers: authHeaders() }, + ); + if (response.status === 404) { + return; + } + yield* readChatSse(response); + }, + + 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)}` : ""}` + ), + + + logFitnessSteps: (payload: { + steps: number; + active_calories?: number; + notes?: string; + day?: string; + days_ago?: number; + logged_at?: string; + }) => + request<{ ok: boolean; step_log: StepLogItem }>("/api/v1/fitness/steps", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + + getFitnessWorkoutStats: (days = 7, end?: string) => { + const params = new URLSearchParams({ days: String(days) }); + if (end) params.set("end", end); + return request(`/api/v1/fitness/workout-stats?${params}`); + }, + + 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), + }), + + calcBodyComposition: (payload: { + weight_kg?: number; + height_cm?: number; + sex?: string; + neck_cm?: number; + waist_cm?: number; + hip_cm?: number; + body_fat_pct?: number; + }) => + request("/api/v1/fitness/body-composition/calc", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + + 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" }), + + +getSettings: () => request("/api/v1/settings"), + +patchSettings: (updates: Partial) => + request("/api/v1/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }), + +listDocuments: () => request("/api/v1/documents"), + +uploadDocument: async (file: File, title = "") => { + const form = new FormData(); + form.append("file", file); + form.append("title", title); + const response = await fetch(`${API_BASE}/api/v1/documents/upload`, { + method: "POST", + headers: authHeaders(), + body: form, + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(detail || response.statusText); + } + return response.json() as Promise<{ ok: boolean; document: DocumentItem }>; +}, + + 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 AssistantSettings { + openrouter_model: string; + memory_extract_model: string; + openrouter_reasoning_effort: string; + rag_enabled: boolean; + rag_top_k: number; + embedding_model: string; + memory_facts_in_context: number; + qdrant_url: string; +} + +export interface DocumentItem { + id: number; + title: string; + filename: string; + size_bytes: number; + created_at: string | null; +} + +export interface ReminderCreatePayload { + title: string; + due_at: string; + notes?: string; + all_day?: boolean; + recurrence?: string; +} diff --git a/frontend/src/api/client.ts.refactor_bak b/frontend/src/api/client.ts.refactor_bak new file mode 100644 index 0000000..c9ce3f8 --- /dev/null +++ b/frontend/src/api/client.ts.refactor_bak @@ -0,0 +1,555 @@ +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/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx new file mode 100644 index 0000000..8fc0271 --- /dev/null +++ b/frontend/src/components/MessageBubble.tsx @@ -0,0 +1,101 @@ +import { memo, useMemo } from "react"; +import type { Components } from "react-markdown"; +import ReactMarkdown from "react-markdown"; +import { ChatMessage } from "../api/client"; + +const API_BASE = import.meta.env.VITE_API_URL ?? ""; + +function resolveMediaUrl(src: string | undefined): string | undefined { + if (!src) return src; + if (/^https?:\/\//i.test(src) || src.startsWith("data:")) { + return src; + } + if (src.startsWith("/")) { + return `${API_BASE}${src}`; + } + return src; +} + +function createMarkdownComponents(onContentResize?: () => void): Components { + return { + img: ({ src, alt }) => ( + {alt + ), + }; +} + +function noticeLabel(content: string): string { + if (content.startsWith("⏱")) return "таймер"; + if (content.startsWith("📋")) return "задачи"; + if (content.startsWith("🔀")) return "git"; + if (content.startsWith("🧠")) return "память"; + if (content.startsWith("💪")) return "фитнес"; + if (content.startsWith("🌤")) return "погода"; + if (content.startsWith("🎨")) return "картинка"; + if (content.startsWith("⚠️")) return "сервер"; + if (content.startsWith("🛒")) return "покупки"; + if (content.startsWith("📅")) return "напоминание"; + return "система"; +} + +function roleLabel(role: string, content = ""): string { + if (role === "notice") return noticeLabel(content); + if (role === "character") return "assistant"; + if (role === "user") return "вы"; + return role; +} + +function messageClassName(role: string): string { + if (role === "character") return "assistant"; + return role; +} + +function usesMarkdown(role: string): boolean { + return role === "assistant" || role === "notice" || role === "character"; +} + +interface MessageBubbleProps { + msg: ChatMessage; + onContentResize?: () => void; +} + +function MessageBubbleInner({ msg, onContentResize }: MessageBubbleProps) { + const markdownComponents = useMemo( + () => createMarkdownComponents(onContentResize), + [onContentResize], + ); + + const markdown = useMemo( + () => + usesMarkdown(msg.role) ? ( + {msg.content} + ) : ( + msg.content + ), + [msg.role, msg.content, markdownComponents], + ); + + return ( +
+
{roleLabel(msg.role, msg.content)}
+
{markdown}
+
+ ); +} + +const MessageBubble = memo(MessageBubbleInner, (prev, next) => { + return ( + prev.msg.id === next.msg.id && + prev.msg.content === next.msg.content && + prev.onContentResize === next.onContentResize + ); +}); + +export default MessageBubble; diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx new file mode 100644 index 0000000..388e14e --- /dev/null +++ b/frontend/src/components/MessageList.tsx @@ -0,0 +1,123 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { forwardRef, RefObject, useCallback, useImperativeHandle, useLayoutEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { ChatMessage } from "../api/client"; +import MessageBubble from "./MessageBubble"; + +const VIRTUALIZE_THRESHOLD = 80; +const MESSAGE_GAP_PX = 16; +const IMAGE_MESSAGE_ESTIMATE_PX = 420; + +function estimateMessageHeight(msg: ChatMessage): number { + if (/!\[[^\]]*\]\([^)]+\)/.test(msg.content)) { + return IMAGE_MESSAGE_ESTIMATE_PX; + } + + const lines = Math.max(1, Math.ceil(msg.content.length / 48)); + const base = msg.role === "notice" ? 72 : 96; + return Math.min(480, base + lines * 22); +} + +export interface MessageListHandle { + scrollToBottom: () => void; +} + +interface MessageListProps { + messages: ChatMessage[]; + containerRef: RefObject; +} + +const MessageList = forwardRef(function MessageList( + { messages, containerRef }, + ref, +) { + const location = useLocation(); + const isChatVisible = location.pathname === "/"; + const wasChatVisibleRef = useRef(isChatVisible); + const useVirtual = messages.length > VIRTUALIZE_THRESHOLD; + + const virtualizer = useVirtualizer({ + count: useVirtual ? messages.length : 0, + getScrollElement: () => containerRef.current, + estimateSize: (index) => estimateMessageHeight(messages[index]), + overscan: 8, + enabled: useVirtual, + gap: MESSAGE_GAP_PX, + }); + + const remeasure = useCallback(() => { + if (!useVirtual) return; + virtualizer.measure(); + requestAnimationFrame(() => virtualizer.measure()); + }, [useVirtual, virtualizer]); + + useImperativeHandle( + ref, + () => ({ + scrollToBottom: () => { + if (useVirtual && messages.length > 0) { + virtualizer.scrollToIndex(messages.length - 1, { align: "end" }); + return; + } + containerRef.current?.scrollTo({ + top: containerRef.current.scrollHeight, + behavior: "auto", + }); + }, + }), + [containerRef, messages.length, useVirtual, virtualizer], + ); + + useLayoutEffect(() => { + const becameVisible = isChatVisible && !wasChatVisibleRef.current; + wasChatVisibleRef.current = isChatVisible; + + if (!useVirtual || !isChatVisible) return; + + if (becameVisible) { + remeasure(); + } + }, [isChatVisible, remeasure, useVirtual, messages.length]); + + if (!useVirtual) { + return ( + <> + {messages.map((msg) => ( + + ))} + + ); + } + + const virtualItems = virtualizer.getVirtualItems(); + + return ( +
+ {virtualItems.map((virtual) => { + const msg = messages[virtual.index]; + return ( +
+ +
+ ); + })} +
+ ); +}); + +export default MessageList; diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx new file mode 100644 index 0000000..9ce649e --- /dev/null +++ b/frontend/src/components/RequireAuth.tsx @@ -0,0 +1,23 @@ +import { Navigate, useLocation } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +export default function RequireAuth({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) { + return ( +
+
+

Загрузка…

+
+
+ ); + } + + if (!user) { + return ; + } + + return children; +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..67e0ede --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,75 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { api, AuthUser, clearAuthToken, getAuthToken, setAuthToken } from "../api/client"; + +interface AuthContextValue { + user: AuthUser | null; + loading: boolean; + login: (token: string) => Promise; + logout: () => void; + refresh: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + const token = getAuthToken(); + if (!token) { + setUser(null); + return; + } + const res = await api.me(); + setUser(res.user); + }, []); + + useEffect(() => { + const token = getAuthToken(); + if (!token) { + setLoading(false); + return; + } + refresh() + .catch(() => { + clearAuthToken(); + setUser(null); + }) + .finally(() => setLoading(false)); + }, [refresh]); + + const login = useCallback(async (token: string) => { + const res = await api.login(token.trim()); + setAuthToken(res.token); + setUser(res.user); + }, []); + + const logout = useCallback(() => { + clearAuthToken(); + setUser(null); + }, []); + + const value = useMemo( + () => ({ user, loading, login, logout, refresh }), + [user, loading, login, logout, refresh], + ); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within AuthProvider"); + } + return ctx; +} diff --git a/frontend/src/hooks/usePomodoroNotify.ts b/frontend/src/hooks/usePomodoroNotify.ts new file mode 100644 index 0000000..18642fe --- /dev/null +++ b/frontend/src/hooks/usePomodoroNotify.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useRef } from "react"; +import { api } from "../api/client"; + +const NOTIFY_POLL_MS = 30000; + +export function usePomodoroRefresh() { + return useCallback(async () => { + await api.pomodoroStatus(); + }, []); +} + +export function usePomodoroNotify(onNotify: (seq: number) => void) { + const handlerRef = useRef(onNotify); + handlerRef.current = onNotify; + const lastSeqRef = useRef(0); + const readyRef = useRef(false); + + useEffect(() => { + let cancelled = false; + + const poll = async () => { + try { + const data = await api.pomodoroStatus(); + if (cancelled) return; + const seq = data.cycle?.chat_notify_seq ?? 0; + if (!readyRef.current) { + readyRef.current = true; + lastSeqRef.current = seq; + return; + } + if (seq > lastSeqRef.current) { + lastSeqRef.current = seq; + handlerRef.current(seq); + } + } catch { + // ignore polling errors + } + }; + + poll().catch(console.error); + const id = setInterval(() => poll().catch(console.error), NOTIFY_POLL_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, []); +} diff --git a/frontend/src/hooks/useThrottledStreaming.ts b/frontend/src/hooks/useThrottledStreaming.ts new file mode 100644 index 0000000..eaab979 --- /dev/null +++ b/frontend/src/hooks/useThrottledStreaming.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +const STREAM_THROTTLE_MS = 80; + +export function useThrottledStreaming() { + const [streaming, setStreamingState] = useState(""); + const pendingRef = useRef(""); + const timerRef = useRef | null>(null); + + const flush = useCallback(() => { + timerRef.current = null; + setStreamingState(pendingRef.current); + }, []); + + const setStreaming = useCallback( + (text: string) => { + pendingRef.current = text; + if (timerRef.current === null) { + timerRef.current = setTimeout(flush, STREAM_THROTTLE_MS); + } + }, + [flush], + ); + + const resetStreaming = useCallback(() => { + pendingRef.current = ""; + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + setStreamingState(""); + }, []); + + const flushStreaming = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + setStreamingState(pendingRef.current); + }, []); + + useEffect(() => { + return () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + } + }; + }, []); + + return { streaming, setStreaming, resetStreaming, flushStreaming }; +} diff --git a/frontend/src/pages/Chat.old.tsx b/frontend/src/pages/Chat.old.tsx new file mode 100644 index 0000000..66e8eab --- /dev/null +++ b/frontend/src/pages/Chat.old.tsx @@ -0,0 +1,417 @@ +import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { api, ChatMessage, ChatSession } from "../api/client"; +import PomodoroWidget from "../components/PomodoroWidget"; +import { usePomodoro } from "../context/PomodoroContext"; +import "./Chat.css"; + +function shouldShowMessage(msg: ChatMessage): boolean { + if (msg.role === "tool") return false; + if (msg.role === "assistant" && msg.tool_calls_json) return false; + if (msg.role === "assistant" && !msg.content.trim()) return false; + return true; +} + +function noticeLabel(content: string): string { + if (content.startsWith("⏱")) return "таймер"; + if (content.startsWith("📋")) return "задачи"; + if (content.startsWith("🔀")) return "git"; + if (content.startsWith("🧠")) return "память"; + if (content.startsWith("💪")) return "фитнес"; + if (content.startsWith("🌤")) return "погода"; + if (content.startsWith("🎨")) return "картинка"; + if (content.startsWith("⚠️")) return "сервер"; + if (content.startsWith("🛒")) return "покупки"; + if (content.startsWith("📅")) return "напоминание"; + return "система"; +} + +function roleLabel(role: string, content = ""): string { + if (role === "notice") return noticeLabel(content); + if (role === "character") return "assistant"; + if (role === "user") return "вы"; + return role; +} + +function messageClassName(role: string): string { + if (role === "character") return "assistant"; + return role; +} + +export default function Chat() { + const [sessions, setSessions] = useState([]); + const [activeId, setActiveId] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [streaming, setStreaming] = useState(""); + const [pendingPhase, setPendingPhase] = useState< + "thinking" | "preparing" | "generating" | "tools" + >("thinking"); + const [chatError, setChatError] = useState(null); + const tempMessageId = useRef(0); + const messagesRef = useRef(null); + const inputRef = useRef(null); + const scrollRafRef = useRef(null); + const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro(); + const [lastNotifySeq, setLastNotifySeq] = useState(0); + const lastReminderNotifySeq = useRef(0); + const remindersNotifyReady = useRef(false); + const pendingHistoryReload = useRef(false); + + const loadSessions = async () => { + const data = await api.listSessions(); + setSessions(data); + if (!activeId && data.length > 0) { + setActiveId(data[0].id); + } + }; + + const loadMessages = async (sessionId: number) => { + const data = await api.getSession(sessionId); + setMessages(data.messages); + }; + + useEffect(() => { + loadSessions().catch(console.error); + }, []); + + useEffect(() => { + if (activeId) { + loadMessages(activeId).catch(console.error); + } + }, [activeId]); + + const scrollToBottom = useCallback((smooth = false) => { + const container = messagesRef.current; + if (!container) return; + container.scrollTo({ + top: container.scrollHeight, + behavior: smooth ? "smooth" : "auto", + }); + }, []); + + useEffect(() => { + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + scrollRafRef.current = requestAnimationFrame(() => { + scrollToBottom(!streaming); + scrollRafRef.current = null; + }); + return () => { + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + }; + }, [messages, streaming, loading, chatError, scrollToBottom]); + + const dismissKeyboard = useCallback(() => { + inputRef.current?.blur(); + }, []); + + const waitingForStream = loading && !streaming; + const nextTempId = () => { + tempMessageId.current -= 1; + return tempMessageId.current; + }; + + const appendNotice = useCallback((content: string) => { + setMessages((prev) => [ + ...prev, + { + id: nextTempId(), + role: "notice", + content, + created_at: new Date().toISOString(), + }, + ]); + }, []); + + const pendingLabel = + pendingPhase === "tools" + ? "Выполняю команды…" + : pendingPhase === "preparing" + ? "Собираю контекст…" + : pendingPhase === "generating" + ? "Генерирую ответ…" + : "Думаю…"; + + useEffect(() => { + const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0; + if (seq > lastNotifySeq) { + setLastNotifySeq(seq); + refreshPomodoro().catch(console.error); + if (activeId) { + if (loading) { + pendingHistoryReload.current = true; + } else { + loadMessages(activeId).catch(console.error); + } + } + } + }, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro, loading]); + + useEffect(() => { + let cancelled = false; + + const poll = async () => { + try { + const data = await api.getRemindersSnapshot(); + if (cancelled) return; + if (!remindersNotifyReady.current) { + remindersNotifyReady.current = true; + lastReminderNotifySeq.current = data.notify_seq; + return; + } + if (data.notify_seq > lastReminderNotifySeq.current) { + lastReminderNotifySeq.current = data.notify_seq; + if (activeId) { + if (loading) { + pendingHistoryReload.current = true; + } else { + loadMessages(activeId).catch(console.error); + } + } + } + } catch { + // ignore polling errors + } + }; + + poll().catch(console.error); + const id = setInterval(() => poll().catch(console.error), 60000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [activeId, loading]); + + const handleNewChat = async () => { + const session = await api.createSession(); + await loadSessions(); + setActiveId(session.id); + setMessages([]); + }; + + const handleDelete = async (id: number) => { + await api.deleteSession(id); + const data = await api.listSessions(); + setSessions(data); + if (activeId === id) { + setActiveId(data[0]?.id ?? null); + setMessages([]); + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!input.trim() || !activeId || loading) return; + + const text = input.trim(); + setInput(""); + dismissKeyboard(); + setLoading(true); + setStreaming(""); + setPendingPhase("thinking"); + setChatError(null); + + const tempUser: ChatMessage = { + id: nextTempId(), + role: "user", + content: text, + created_at: new Date().toISOString(), + }; + setMessages((prev) => [...prev, tempUser]); + + try { + let assistantText = ""; + for await (const chunk of api.sendMessage(activeId, text)) { + if (chunk.event === "status") { + if (chunk.data.phase === "preparing") { + setPendingPhase("preparing"); + } + if (chunk.data.phase === "generating") { + setPendingPhase("generating"); + } + if (chunk.data.phase === "tools") { + setPendingPhase("tools"); + assistantText = ""; + setStreaming(""); + } + } + if (chunk.event === "token") { + assistantText += chunk.data.content; + setPendingPhase("generating"); + setStreaming(assistantText); + } + if (chunk.event === "notice") { + appendNotice(chunk.data.content); + if (String(chunk.data.content).startsWith("⏱")) { + refreshPomodoro(); + } + } + if (chunk.event === "pomodoro") { + refreshPomodoro(); + } + if (chunk.event === "done") { + const tail = assistantText.trim(); + if (tail) { + setMessages((prev) => [ + ...prev, + { + id: nextTempId(), + role: "assistant", + content: tail, + created_at: new Date().toISOString(), + }, + ]); + } + setStreaming(""); + setChatError(null); + await loadMessages(activeId); + await loadSessions(); + } + if (chunk.event === "error") { + throw new Error(chunk.data.message); + } + } + } catch (err) { + console.error(err); + const message = err instanceof Error ? err.message : "Ошибка чата"; + setChatError(message); + setStreaming(""); + if (activeId) { + await loadMessages(activeId); + } + } finally { + setLoading(false); + if (pendingHistoryReload.current && activeId) { + pendingHistoryReload.current = false; + loadMessages(activeId).catch(console.error); + } + } + }; + + const visibleMessages = messages.filter(shouldShowMessage); + + return ( +
+ + +
+ {!activeId ? ( +
Создайте новый чат, чтобы начать
+ ) : ( + <> +
+ + +
+ +
+ {visibleMessages.map((msg) => ( +
+
{roleLabel(msg.role, msg.content)}
+
+ {msg.role === "assistant" || msg.role === "notice" || msg.role === "character" ? ( + {msg.content} + ) : ( + msg.content + )} +
+
+ ))} + + {waitingForStream && ( +
+
assistant
+
+
+
+ )} + + {streaming && ( +
+
assistant
+
+ {streaming} +
+
+ )} + + {chatError && ( +
+
ошибка
+
{chatError}
+
+ )} + + +
+