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