2 Commits

Author SHA1 Message Date
Grigo 04f745d0a7 test: webhook close
Closes gitea #5, taiga #23
2026-06-09 12:21:40 +00:00
Grigo 62ffc47952 test: webhook close
Closes gitea #3, taiga #15
2026-06-09 12:17:34 +00:00
222 changed files with 3236 additions and 27182 deletions
+40 -131
View File
@@ -1,131 +1,40 @@
# Server (internal bind inside containers) # Server (internal bind inside containers)
HOST=0.0.0.0 HOST=0.0.0.0
BACKEND_INTERNAL_PORT=8080 BACKEND_INTERNAL_PORT=8080
FRONTEND_INTERNAL_PORT=80 FRONTEND_INTERNAL_PORT=80
# External ports on the host (docker compose publish) # External ports on the host (docker compose publish)
BACKEND_PORT=8080 BACKEND_PORT=8080
FRONTEND_PORT=3080 FRONTEND_PORT=3080
VITE_DEV_PORT=5173 VITE_DEV_PORT=5173
# OpenRouter # OpenRouter
OPENROUTER_API_KEY=sk-or-v1-your-key-here OPENROUTER_API_KEY=sk-or-v1-your-key-here
OPENROUTER_MODEL=deepseek/deepseek-chat OPENROUTER_MODEL=deepseek/deepseek-chat
# deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются: OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# OPENROUTER_MODEL=deepseek/deepseek-v4-pro
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # App
OPENROUTER_TOOLS_ENABLED=true DATABASE_URL=sqlite:///./data/assistant.db
# none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning. CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
OPENROUTER_REASONING_EFFORT=none SYSTEM_PROMPT_PATH=./prompts/assistant.md
# Vision (скриншоты Mi Fitness и др.)
OPENROUTER_VISION_MODEL=google/gemini-2.5-flash-lite # Taiga (on host :9000, nginx → taiga.grigowashere.ru)
VISION_MAX_EDGE_PX=1280 TAIGA_BASE_URL=http://host.docker.internal:9000
VISION_JPEG_QUALITY=85 TAIGA_USERNAME=your_taiga_user
VISION_DEBUG_ENABLED=true TAIGA_PASSWORD=your_taiga_password
VISION_MAX_IMAGES=8 TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
# JSON-экстракция памяти отдельной моделью (если основная капризничает):
# MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat # Gitea (on host :3000, nginx → git.grigowashere.ru)
GITEA_BASE_URL=http://host.docker.internal:3000
# App GITEA_TOKEN=your_gitea_api_token
DATABASE_URL=sqlite:///./data/assistant.db GITEA_PUBLIC_URL=https://git.grigowashere.ru
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080 GITEA_WEBHOOK_SECRET=generate_a_random_secret
SYSTEM_PROMPT_PATH=./prompts/assistant.md
MEMORY_AUTO_EXTRACT=true # Gitea webhook URL (configure in repo settings):
# http://127.0.0.1:8080/api/v1/webhooks/gitea
# Multi-user (API token auth)
DEFAULT_USER_USERNAME=owner REPOS_DIR=/data/repos
DEFAULT_USER_DISPLAY_NAME=
DEFAULT_API_TOKEN=change-me-to-long-random-string # Vector DB (phase 3)
AUTH_REQUIRED=true QDRANT_PORT=6333
# Опционально для dev (автовход без /login). В prod оставьте пустым. QDRANT_GRPC_PORT=6334
VITE_API_TOKEN=
# Fitness (wger + Open Food Facts — public HTTPS, no proxy)
WGER_BASE_URL=https://wger.de/api/v2
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
FITNESS_REMINDERS_ENABLED=true
REMINDERS_ENABLED=true
# Taiga (on host :9000, nginx → taiga.grigowashere.ru)
TAIGA_BASE_URL=http://host.docker.internal:9000
TAIGA_USERNAME=your_taiga_user
TAIGA_PASSWORD=your_taiga_password
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
# Gitea (on host :3000, nginx → git.grigowashere.ru)
GITEA_BASE_URL=http://host.docker.internal:3000
GITEA_TOKEN=your_gitea_api_token
GITEA_PUBLIC_URL=https://git.grigowashere.ru
GITEA_WEBHOOK_SECRET=generate_a_random_secret
# Gitea webhook URL (repo Settings → Webhooks):
# https://assistant.your-domain/api/v1/webhooks/gitea ← nginx → 127.0.0.1:BACKEND_PORT
# http://172.17.0.1:8202/api/v1/webhooks/gitea ← если Gitea в Docker (не 127.0.0.1!)
REPOS_DIR=/data/repos
# Homelab — GPU PC 192.168.1.109
OPENMETEO_BASE_URL=http://192.168.1.109:8085
WEATHER_LAT=59.9343
WEATHER_LON=30.3351
WEATHER_LOCATION_NAME=Санкт-Петербург
WEATHER_CACHE_SEC=300
WEATHER_FORECAST_DAYS=7
# Если локальный OpenMeteo отдаёт только temperature_2m — подставить публичный API
OPENMETEO_FALLBACK_URL=https://api.open-meteo.com
OPENMETEO_FALLBACK_ON_PARTIAL=true
# News RSS (comma-separated)
NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss
NEWS_CACHE_SEC=1800
NEWS_MAX_ITEMS=7
# Morning digest (Europe/Moscow or user profile timezone)
MORNING_DIGEST_ENABLED=true
MORNING_DIGEST_HOUR=8
MORNING_DIGEST_MINUTE=0
# ComfyUI on GPU PC (Anima split-model — как в aiChatBot)
COMFYUI_BASE_URL=http://192.168.1.109:8188
COMFYUI_ENABLED=true
# Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET.
COMFYUI_CHECKPOINT=
COMFYUI_UNET=anima-preview3-base.safetensors
COMFYUI_CLIP=qwen_3_06b_base.safetensors
COMFYUI_VAE=qwen_image_vae.safetensors
COMFYUI_STYLE_LORA=anima-preview-3-masterpieces-v5.safetensors
COMFYUI_STYLE_LORA_WEIGHT=0.7
COMFYUI_STEPS=30
COMFYUI_CFG=4
COMFYUI_SAMPLER=er_sde
COMFYUI_SCHEDULER=simple
COMFYUI_WIDTH=1024
COMFYUI_HEIGHT=720
COMFYUI_NEGATIVE_PROMPT=worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia
COMFYUI_ROFL_ENABLED=true
COMFYUI_ROFL_MAX_PER_DAY=1
COMFYUI_ROFL_PROBABILITY=0.15
COMFYUI_ROFL_MIN_INTERVAL_HOURS=12
GENERATED_MEDIA_DIR=./data/generated
# RP Chat (aiChatBot) — генерация картинок + sd-prompt; persona_id в карточке персонажа
RP_CHAT_BASE_URL=http://host.docker.internal:8201
RP_CHAT_ENABLED=true
RP_CHAT_TIMEOUT_SEC=300
# Netdata on server
NETDATA_BASE_URL=http://host.docker.internal:19999
NETDATA_PUBLIC_URL=
NETDATA_ALERTS_ENABLED=true
NETDATA_POLL_INTERVAL_SEC=120
# Vector DB (phase 3)
QDRANT_PORT=6333
QDRANT_GRPC_PORT=6334
# RAG / embeddings
QDRANT_URL=http://qdrant:6333
EMBEDDING_MODEL=openai/text-embedding-3-small
RAG_ENABLED=true
RAG_TOP_K=8
MEMORY_FACTS_IN_CONTEXT=8
-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
-137
View File
@@ -1,137 +0,0 @@
// Home AI Assistant — деплой на Linux (Docker).
//
// Нода: label linux
//
// Реальный путь репо (не symlink): /srv/storage/disk2/services/Home_assistant
// ~/to_services/Home_assistant может быть ссылкой на него.
//
// Права jenkins (один раз):
// sudo usermod -aG docker jenkins
// sudo setfacl -m u:jenkins:rx /home/grigo /home/grigo/to_services
// sudo setfacl -R -m u:jenkins:rwX /srv/storage/disk2/services/Home_assistant
pipeline {
agent {
label 'linux'
}
options {
buildDiscarder(logRotator(numToKeepStr: '25'))
timeout(time: 45, unit: 'MINUTES')
disableConcurrentBuilds()
timestamps()
}
parameters {
string(
name: 'GIT_BRANCH',
defaultValue: 'main',
description: 'Ветка: git reset --hard origin/<branch>'
)
string(
name: 'DEPLOY_DIR',
defaultValue: '/srv/storage/disk2/services/Home_assistant',
description: 'Каталог деплоя (.env, data/, docker-compose.yml)'
)
string(
name: 'BACKEND_HEALTH_URL',
defaultValue: 'http://127.0.0.1:8202/api/v1/health',
description: 'Healthcheck после деплоя'
)
booleanParam(
name: 'DOCKER_PULL',
defaultValue: true,
description: 'docker compose build --pull'
)
}
environment {
GIT_BRANCH = "${params.GIT_BRANCH}"
DEPLOY_DIR = "${params.DEPLOY_DIR}"
BACKEND_HEALTH_URL = "${params.BACKEND_HEALTH_URL}"
DOCKER_PULL = "${params.DOCKER_PULL}"
}
stages {
stage('Preflight') {
steps {
sh '''
set -euxo pipefail
REPO_DIR=$(readlink -f "${DEPLOY_DIR}")
echo "REPO_DIR=${REPO_DIR}"
command -v docker
docker compose version
test -d "${REPO_DIR}"
test -r "${REPO_DIR}"
test -w "${REPO_DIR}"
test -f "${REPO_DIR}/.env"
test -f "${REPO_DIR}/docker-compose.yml"
# git от jenkins: владелец репо — grigo
git config --global --add safe.directory "${REPO_DIR}"
'''
}
}
stage('Deploy') {
steps {
sh '''
set -euxo pipefail
REPO_DIR=$(readlink -f "${DEPLOY_DIR}")
git config --global --add safe.directory "${REPO_DIR}"
cd "${REPO_DIR}"
git fetch --prune origin
git reset --hard "origin/${GIT_BRANCH}"
git clean -fd -e .env -e data -e 'data/**'
if [ "${DOCKER_PULL}" = "true" ]; then
docker compose build --pull
else
docker compose build
fi
docker compose up -d
docker compose ps
'''
}
}
stage('Healthcheck') {
steps {
sh '''
set -euxo pipefail
for i in $(seq 1 30); do
if curl -fsS "${BACKEND_HEALTH_URL}" >/dev/null; then
echo "OK: ${BACKEND_HEALTH_URL}"
exit 0
fi
sleep 2
done
echo "Healthcheck failed: ${BACKEND_HEALTH_URL}"
exit 1
'''
}
}
}
post {
success {
echo "Deployed ${DEPLOY_DIR} @ origin/${GIT_BRANCH}"
}
failure {
sh '''
REPO_DIR=$(readlink -f "${DEPLOY_DIR}" 2>/dev/null || echo "${DEPLOY_DIR}")
if [ -d "${REPO_DIR}" ] && [ -r "${REPO_DIR}" ]; then
cd "${REPO_DIR}"
docker compose ps || true
docker compose logs --tail=100 backend || true
docker compose logs --tail=50 frontend || true
else
echo "Нет доступа к ${REPO_DIR}"
fi
'''
}
}
}
+4 -106
View File
@@ -37,8 +37,6 @@ docker compose up --build
- Web UI: http://localhost:${FRONTEND_PORT:-3080} - Web UI: http://localhost:${FRONTEND_PORT:-3080}
- Healthcheck: http://localhost:8080/api/v1/health - Healthcheck: http://localhost:8080/api/v1/health
**Prod за nginx:** при загрузке скриншотов возможна ошибка `413 Request Entity Too Large` — дефолтный лимит nginx 1 MB. На **host nginx** (Ubuntu перед docker) добавьте `client_max_body_size 64m;` в `server { }` и в `location /api/`. Пример: [`deploy/nginx-host-assistant.conf.example`](deploy/nginx-host-assistant.conf.example). После правки: `sudo nginx -t && sudo systemctl reload nginx`. Контейнер frontend тоже поднимает лимит в `frontend/nginx.conf` — пересоберите образ.
Порты в `.env`: Порты в `.env`:
| Переменная | По умолчанию | Назначение | | Переменная | По умолчанию | Назначение |
@@ -134,16 +132,11 @@ curl -X PUT http://localhost:8080/api/v1/projects/home-assistant/gitea \
В репозитории: **Settings → Webhooks → Add Webhook**: В репозитории: **Settings → Webhooks → Add Webhook**:
- URL (выбери один вариант): - URL: `http://127.0.0.1:8080/api/v1/webhooks/gitea`
- **Рекомендуется:** `https://assistant.example.com/api/v1/webhooks/gitea` — nginx → `127.0.0.1:${BACKEND_PORT}`
- **Если Gitea в Docker:** `http://172.17.0.1:${BACKEND_PORT}/api/v1/webhooks/gitea` — не `127.0.0.1` (это localhost контейнера Gitea)
- Content type: `application/json` - Content type: `application/json`
- Secret: значение `GITEA_WEBHOOK_SECRET` - Secret: значение `GITEA_WEBHOOK_SECRET`
- Events: **Push** - Events: **Push**
Проверка из контейнера Gitea: `docker exec gitea wget -qO- http://172.17.0.1:8202/api/v1/health`
Test delivery в Gitea должен вернуть **200**, не **0**.
### Автозакрытие по коммиту ### Автозакрытие по коммиту
В сообщении коммита: В сообщении коммита:
@@ -167,106 +160,11 @@ frontend/ React + Vite, чат и таймер
data/ SQLite БД (создаётся автоматически) data/ SQLite БД (создаётся автоматически)
``` ```
## Память и контекст (фаза 3a)
Долгосрочная память в SQLite, без векторов:
| Слой | Что хранит |
|------|------------|
| **Профиль** | имя, timezone, language, notes |
| **Факты** | устойчивые знания с категорией и важностью |
| **Сводка чата** | краткое содержание длинной сессии |
В system prompt на каждый ответ: персонаж → **время** → память → фитнес → **погода** → помидоро → проекты.
История чата обрезается до 40 последних сообщений; раннее — в `session_summaries`.
**Автоизвлечение:** после каждого ответа LLM анализирует ход диалога и сохраняет
устойчивые факты (`source=auto`). Отключить: `MEMORY_AUTO_EXTRACT=false`.
**UI:** вкладка `/memory` — профиль, факты, JSON-снимок для отладки.
### Tools
- `remember_fact` — «запомни, что…»
- `recall_memories` — поиск по памяти
- `forget_memory` — удалить факт по id
- `update_profile` — имя, часовой пояс и т.д.
- `update_session_summary` — сжать тему длинного чата
### API
| Method | Path | Описание |
|--------|------|----------|
| GET | `/api/v1/memory` | снимок памяти (+ `?session_id=`) |
| GET/PUT | `/api/v1/profile` | профиль |
| GET/POST | `/api/v1/memory/facts` | список / создать факт |
| DELETE | `/api/v1/memory/facts/{id}` | забыть |
| PUT | `/api/v1/memory/sessions/{id}/summary` | сводка чата |
## Фитнес-трекер
Профиль, дневник (еда/вода/вес/тренировки), калькуляторы TDEE, LLM-оценка ккал/БЖУ,
lookup wger + Open Food Facts, напоминания в чат (`💪`), вкладка `/fitness`.
Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3».
## Списки покупок
Несколько списков, позиции с количеством, отметка «куплено». Вкладка `/shopping`, tools в чате (`add_shopping_items`, `list_shopping_lists`, …).
Чат: «добавь молоко и хлеб в продукты», «что в списке покупок», «отметь молоко купленным».
## Homelab API (фаза 4)
Интеграции с домашней инфраструктурой:
| Сервис | URL по умолчанию | Назначение |
|--------|------------------|------------|
| Open-Meteo | `http://192.168.1.109:8085` | Погода СПб в контексте и tool `get_weather` |
| ComfyUI | `http://192.168.1.109:8188` | fallback / рофл-watcher |
| RP Chat (aiChatBot) | `http://host.docker.internal:8201` | `generate_image`: sd-prompt + Anima; appearance в `/character` |
| Netdata | `http://host.docker.internal:19999` | Алерты warning/critical → notice в чат |
**Утренний дайджест** (`MORNING_DIGEST_HOUR=8`): погода + RSS (Habr, r/programming по умолчанию).
По запросу: «что на улице», «будет ли дождь» → `get_weather`; полный брифинг → `get_morning_briefing`.
Переменные — в `.env.example` (секция Homelab).
### Проверка доступности
В образе backend нет `curl`/`wget`. Удобнее всего — API-диагностика (из контейнера или с хоста):
```bash
curl -s http://localhost:${BACKEND_PORT:-8202}/api/v1/homelab/status | python3 -m json.tool
```
Или изнутри backend через Python:
```bash
docker compose exec backend python -c "
import httpx
for url in [
'http://192.168.1.109:8085/v1/forecast?latitude=59.93&longitude=30.33&current=temperature_2m',
'http://192.168.1.109:8188/system_stats',
'http://host.docker.internal:19999/api/v1/info',
]:
try:
r = httpx.get(url, timeout=10)
print(url, '->', r.status_code, r.text[:120])
except Exception as e:
print(url, '-> ERROR', e)
"
```
По умолчанию **Anima** (как в aiChatBot): `COMFYUI_UNET` + `COMFYUI_CLIP` + `COMFYUI_VAE` + style LoRA.
`COMFYUI_CHECKPOINT` оставь пустым. Для SD1.5/Pony — укажи checkpoint и очисти `COMFYUI_UNET`.
## Следующие фазы ## Следующие фазы
- RAG по файлам (Qdrant) - RAG с Qdrant для документов
- Telegram-бот - Проактивные чаты по расписанию
- Taiga/fitness в утреннем дайджесте - Фитнес-трекер
- Графики веса, LLM-мотивация в напоминаниях
## Модель ## Модель
+1
View File
@@ -0,0 +1 @@
webhook test
-34
View File
@@ -1,34 +0,0 @@
import pathlib
ROOT = pathlib.Path(".").resolve()
svc = ROOT / "app/fitness/service.py"
text = svc.read_text(encoding="utf-8")
old = """from app.db.models import (
BodyMetric,
FitnessProfile,
FitnessReminder,
FoodLog,
WaterLog,
WorkoutLog,
)
from app.fitness.calculators import compute_targets, one_rep_max"""
new = """from app.db.models import (
BodyMetric,
FitnessProfile,
FitnessReminder,
FoodLog,
StepLog,
WaterLog,
WorkoutLog,
)
from app.fitness.activity_budget import (
build_base_targets,
compute_activity_bonus,
estimate_workout_active_kcal,
scale_targets,
)
from app.fitness.calculators import compute_targets, one_rep_max"""
if old not in text:
raise SystemExit("import block missing")
text = text.replace(old, new, 1)
svc.write_text(text, encoding="utf-8")
print("ok imports")
-12
View File
@@ -1,12 +0,0 @@
from pydantic import BaseModel
from app.api.schemas import MessageOut
class MessagesPageOut(BaseModel):
messages: list[MessageOut]
has_more: bool
class GenerationStatusOut(BaseModel):
active: bool
+11 -20
View File
@@ -1,20 +1,11 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.routes import auth, character, chat, documents, fitness, health, homelab, media, memory, pomodoro, projects, reminders, settings, shopping, webhooks from app.api.routes import character, chat, health, pomodoro, projects, webhooks
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"]) api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router) api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(homelab.router, tags=["homelab"]) api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) api_router.include_router(character.router, tags=["character"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"]) api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(character.router, tags=["character"]) api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"])
api_router.include_router(settings.router, tags=["settings"])
api_router.include_router(documents.router, tags=["documents"])
@@ -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"])
-73
View File
@@ -1,73 +0,0 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.auth.service import create_user, find_user_by_token, user_to_dict
from app.db.base import get_db
from app.db.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
token: str = Field(min_length=8, max_length=256)
class CreateUserRequest(BaseModel):
username: str = Field(min_length=2, max_length=64)
display_name: str = ""
token: str | None = Field(default=None, min_length=8, max_length=256)
@router.post("/login")
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict[str, Any]:
user = find_user_by_token(db, payload.token)
if not user:
raise HTTPException(status_code=401, detail="Неверный токен")
return {"ok": True, "user": user_to_dict(user), "token": payload.token.strip()}
@router.get("/me")
def me(user: User = Depends(get_current_user)) -> dict[str, Any]:
return {"ok": True, "user": user_to_dict(user)}
@router.get("/users")
def list_users(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
rows = db.scalars(select(User).where(User.is_active.is_(True)).order_by(User.id)).all()
return {
"ok": True,
"users": [user_to_dict(row) for row in rows],
"current_user_id": user.id,
}
@router.post("/users")
def register_user(
payload: CreateUserRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
new_user, plain_token = create_user(
db,
username=payload.username,
display_name=payload.display_name,
api_token=payload.token,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"ok": True,
"user": user_to_dict(new_user),
"token": plain_token,
"created_by": user.username,
}
+56 -80
View File
@@ -1,80 +1,56 @@
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.auth.deps import get_current_user
from app.character.service import CharacterService router = APIRouter()
from app.db.base import get_db
from app.db.models import User
class CharacterCardData(BaseModel):
router = APIRouter() name: str = "Ассистент"
description: str = ""
personality: str = ""
class CharacterCardData(BaseModel): scenario: str = ""
name: str = "Ассистент" first_mes: str = ""
description: str = "" mes_example: str = ""
personality: str = "" system_prompt: str = ""
scenario: str = "" post_history_instructions: str = ""
first_mes: str = "" tags: list[str] = Field(default_factory=list)
mes_example: str = "" creator: str = ""
system_prompt: str = "" creator_notes: str = ""
post_history_instructions: str = "" alternate_greetings: list[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list) character_version: str = "1.0"
creator: str = ""
creator_notes: str = ""
alternate_greetings: list[str] = Field(default_factory=list) class CharacterCardV2(BaseModel):
character_version: str = "1.0" spec: str = "chara_card_v2"
appearance_tags: str = "" spec_version: str = "2.0"
appearance_prose: str = "" data: CharacterCardData
lora_name: str = ""
lora_weight: float = 0.8
rp_persona_id: str = "" @router.get("/character")
sd_enabled: bool = True def get_character() -> dict[str, Any]:
return CharacterService().get_card()
class CharacterCardV2(BaseModel):
spec: str = "chara_card_v2" @router.put("/character")
spec_version: str = "2.0" def update_character(payload: CharacterCardV2) -> dict[str, Any]:
data: CharacterCardData return CharacterService().save_card(payload.model_dump())
@router.get("/character") @router.get("/character/prompt")
def get_character( def get_character_prompt() -> dict[str, str]:
db: Session = Depends(get_db), service = CharacterService()
user: User = Depends(get_current_user), return {
) -> dict[str, Any]: "system_prompt": service.get_system_prompt(),
return CharacterService(db, user.id).get_card() "first_mes": service.get_card().get("data", {}).get("first_mes", ""),
}
@router.put("/character")
def update_character( @router.post("/character/import")
payload: CharacterCardV2, def import_character(payload: dict[str, Any]) -> dict[str, Any]:
db: Session = Depends(get_db), if not payload:
user: User = Depends(get_current_user), raise HTTPException(status_code=400, detail="Empty card")
) -> dict[str, Any]: return CharacterService().save_card(payload)
return CharacterService(db, user.id).save_card(payload.model_dump())
@router.get("/character/prompt")
def get_character_prompt(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, str]:
service = CharacterService(db, user.id)
return {
"system_prompt": service.get_system_prompt(),
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
}
@router.post("/character/import")
def import_character(
payload: dict[str, Any],
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
if not payload:
raise HTTPException(status_code=400, detail="Empty card")
return CharacterService(db, user.id).save_card(payload)
+55 -252
View File
@@ -1,252 +1,55 @@
import asyncio from fastapi import APIRouter, Depends, HTTPException
import json from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut
from sqlalchemy.orm import Session from app.chat.service import ChatService
from app.db.base import get_db
from app.api.chat_schemas import GenerationStatusOut, MessagesPageOut
from app.api.schemas import ( router = APIRouter()
MessageCreate,
SessionCreate,
SessionDetailOut, @router.post("/sessions", response_model=SessionOut)
SessionOut, def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut:
) service = ChatService(db)
from app.auth.deps import get_current_user return service.create_session(title=payload.title)
from app.chat.generation import (
GenerationBusyError,
get_active_handle, @router.get("/sessions", response_model=list[SessionOut])
is_generation_active, def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]:
start_generation, service = ChatService(db)
subscribe_generation, return service.list_sessions()
)
from app.chat.service import ChatService
from app.config import get_settings @router.get("/sessions/{session_id}", response_model=SessionDetailOut)
from app.db.base import get_db def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut:
from app.db.models import User service = ChatService(db)
from app.vision import VisionService, format_user_messages, vision_debug_payloads session = service.get_session(session_id)
from app.vision.analyze import VisionUnavailableError if not session:
from app.vision.preprocess import prepare_image raise HTTPException(status_code=404, detail="Session not found")
from app.vision.storage import format_upload_images_markdown, save_upload return session
router = APIRouter()
@router.delete("/sessions/{session_id}")
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
service = ChatService(db)
if not service.delete_session(session_id):
@router.post("/sessions", response_model=SessionOut) raise HTTPException(status_code=404, detail="Session not found")
def create_session(payload: SessionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionOut: return {"ok": True}
service = ChatService(db, user.id)
return service.create_session(title=payload.title)
@router.post("/sessions/{session_id}/messages")
async def send_message(
@router.get("/sessions", response_model=list[SessionOut]) session_id: int,
def list_sessions(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[SessionOut]: payload: MessageCreate,
service = ChatService(db, user.id) db: Session = Depends(get_db),
return service.list_sessions() ) -> StreamingResponse:
service = ChatService(db)
if not service.get_session(session_id):
@router.get("/sessions/{session_id}", response_model=SessionDetailOut) raise HTTPException(status_code=404, detail="Session not found")
def get_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionDetailOut:
service = ChatService(db, user.id) async def event_stream():
session = service.get_session(session_id) async for chunk in service.stream_response(session_id, payload.content):
if not session: yield chunk
raise HTTPException(status_code=404, detail="Session not found")
return session return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.get("/sessions/{session_id}/messages", response_model=MessagesPageOut)
def list_messages(
session_id: int,
limit: int = 30,
before_id: int | None = None,
after_id: int | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> MessagesPageOut:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
messages, has_more = service.list_messages(
session_id,
limit=min(max(limit, 1), 100),
before_id=before_id,
after_id=after_id,
)
return MessagesPageOut(messages=messages, has_more=has_more)
@router.get("/sessions/{session_id}/generation", response_model=GenerationStatusOut)
def generation_status(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> GenerationStatusOut:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return GenerationStatusOut(active=is_generation_active(session_id))
@router.get("/sessions/{session_id}/generation/stream")
async def generation_stream(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> StreamingResponse:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
handle = get_active_handle(session_id)
if not handle:
raise HTTPException(status_code=404, detail="No active generation")
async def event_stream():
async for chunk in subscribe_generation(handle):
yield chunk
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
service = ChatService(db, user.id)
if not service.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
def _collect_form_uploads(form) -> list:
uploads: list = []
seen_ids: set[int] = set()
def _append(item) -> None:
if item is None or not hasattr(item, "read"):
return
item_id = id(item)
if item_id in seen_ids:
return
seen_ids.add(item_id)
uploads.append(item)
if hasattr(form, "getlist"):
for item in form.getlist("images"):
_append(item)
single = form.get("image")
_append(single)
return uploads
async def _analyze_upload(raw: bytes, *, caption: str, user_id: int):
prepared = prepare_image(raw)
filename = save_upload(prepared, user_id=user_id)
result = await VisionService().analyze_prepared(prepared, user_hint=caption)
return result, filename
async def _parse_message_request(
request: Request,
*,
user_id: int,
) -> tuple[str, dict | None]:
content_type = (request.headers.get("content-type") or "").lower()
if "multipart/form-data" not in content_type:
try:
body = await request.json()
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="Invalid JSON body") from exc
payload = MessageCreate.model_validate(body)
return payload.content, None
form = await request.form()
caption = str(form.get("content") or "").strip()
uploads = _collect_form_uploads(form)
if not uploads:
raise HTTPException(status_code=400, detail="Field 'images' or 'image' is required for multipart upload")
max_images = max(1, int(get_settings().vision_max_images))
if len(uploads) > max_images:
raise HTTPException(
status_code=400,
detail=f"Too many images (max {max_images})",
)
raw_images: list[bytes] = []
for upload in uploads:
raw = await upload.read()
if not raw:
raise HTTPException(status_code=400, detail="Empty image file")
mime = getattr(upload, "content_type", None) or "application/octet-stream"
if mime not in ALLOWED_IMAGE_TYPES:
raise HTTPException(status_code=400, detail=f"Unsupported image type: {mime}")
raw_images.append(raw)
try:
analyzed = await asyncio.gather(
*(_analyze_upload(raw, caption=caption, user_id=user_id) for raw in raw_images)
)
except VisionUnavailableError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
results = [item[0] for item in analyzed]
filenames = [item[1] for item in analyzed]
debug = vision_debug_payloads(results)
vision_text = format_user_messages(caption, results)
images_md = format_upload_images_markdown(user_id, filenames)
user_text = f"{images_md}\n\n{vision_text}" if images_md else vision_text
if not user_text.strip():
raise HTTPException(status_code=400, detail="Could not build message from image")
return user_text, debug
@router.post("/sessions/{session_id}/messages")
async def send_message(
session_id: int,
request: Request,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StreamingResponse:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
if is_generation_active(session_id):
raise HTTPException(status_code=409, detail="Generation already in progress")
user_text, vision_debug = await _parse_message_request(request, user_id=user.id)
service.save_user_message(session_id, user_text)
try:
handle = await start_generation(session_id, user.id, user_text)
except GenerationBusyError:
raise HTTPException(status_code=409, detail="Generation already in progress") from None
async def event_stream():
try:
if vision_debug:
yield ChatService._sse("vision", vision_debug)
async for chunk in subscribe_generation(handle):
yield chunk
except asyncio.CancelledError:
raise
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/sessions/{session_id}/context-preview")
def context_preview(
session_id: int,
query: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict:
service = ChatService(db, user.id)
return service.context_preview(session_id, query=query)
@@ -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",
},
)
-51
View File
@@ -1,51 +0,0 @@
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.db.models import Document
from app.rag.ingest import ingest_document_file
router = APIRouter()
@router.get("/documents")
def list_documents(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
docs = db.scalars(select(Document).where(Document.user_id == user.id).order_by(Document.created_at.desc())).all()
return [
{
"id": d.id,
"title": d.title,
"filename": d.filename,
"size_bytes": d.size_bytes,
"created_at": d.created_at.isoformat() if d.created_at else None,
}
for d in docs
]
@router.post("/documents/upload")
async def upload_document(
file: UploadFile = File(...),
title: str = Form(""),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="Empty file")
try:
doc = await ingest_document_file(
db,
user_id=user.id,
title=title.strip() or (file.filename or "document"),
filename=file.filename or "upload.txt",
raw_bytes=raw,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "document": doc}
-318
View File
@@ -1,318 +0,0 @@
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.fitness.service import FitnessService
from app.fitness.structuring import structure_meal, structure_workout
from app.integrations.openfoodfacts import OpenFoodFactsClient
from app.integrations.wger import WgerClient
router = APIRouter()
class ProfileUpdate(BaseModel):
sex: str | None = None
age: int | None = None
height_cm: float | None = None
weight_kg: float | None = None
goal: str | None = None
target_weight_kg: float | None = None
neat_base_kcal: float | None = Field(default=None, ge=200, le=300)
class MealCreate(BaseModel):
text: str = Field(min_length=1)
meal_type: str | None = None
class WaterCreate(BaseModel):
amount_ml: int = Field(gt=0)
class WeightCreate(BaseModel):
weight_kg: float = Field(gt=0)
body_fat_pct: float | None = None
chest_cm: float | None = None
waist_cm: float | None = None
neck_cm: float | None = None
hip_cm: float | None = None
notes: str = ""
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
recorded_at: str | None = None
class BodyCompositionCalc(BaseModel):
weight_kg: float | None = None
height_cm: float | None = None
sex: str | None = None
neck_cm: float | None = None
waist_cm: float | None = None
hip_cm: float | None = None
body_fat_pct: float | None = None
class StepsCreate(BaseModel):
steps: int = Field(ge=0)
active_calories: float | None = None
notes: str = ""
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
logged_at: str | None = None
class WorkoutCreate(BaseModel):
text: str = Field(min_length=1)
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
logged_at: str | None = None
class ReminderUpdate(BaseModel):
enabled: bool | None = None
hour: int | None = Field(default=None, ge=0, le=23)
minute: int | None = Field(default=None, ge=0, le=59)
interval_hours: int | None = Field(default=None, ge=1, le=12)
@router.get("/fitness")
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return FitnessService(db, user.id).snapshot()
@router.get("/fitness/summary")
def get_summary(
day: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
d = date.fromisoformat(day) if day else None
return FitnessService(db, user.id).get_daily_summary(d)
@router.get("/fitness/workout-stats")
def get_workout_stats(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_workout_stats(days=days, end_day=end_day)
@router.get("/fitness/history")
def get_history(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_history(days=days, end_day=end_day)
@router.get("/fitness/charts")
def get_charts(
weeks: int = 52,
trend: bool = True,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_charts(weeks=weeks, trend=trend, end_day=end_day)
@router.get("/fitness/profile")
def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
profile = FitnessService(db, user.id).get_profile()
return profile or {"configured": False}
@router.put("/fitness/profile")
def update_profile(
payload: ProfileUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).set_profile(payload.model_dump(exclude_none=True))
@router.post("/fitness/profile/calc")
def calc_targets(
payload: ProfileUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
params = payload.model_dump(exclude_none=True)
if not params:
raise HTTPException(status_code=400, detail="No parameters")
return FitnessService(db, user.id).calc_targets(params)
@router.post("/fitness/meals")
async def create_meal(
payload: MealCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
service = FitnessService(db, user.id)
try:
structured = await structure_meal(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return service.log_meal(
description=structured.get("description") or payload.text,
meal_type=payload.meal_type or structured.get("meal_type") or "snack",
calories=float(structured.get("calories") or 0),
protein_g=float(structured.get("protein_g") or 0),
fat_g=float(structured.get("fat_g") or 0),
carbs_g=float(structured.get("carbs_g") or 0),
source="llm",
estimated=bool(structured.get("estimated", True)),
)
@router.post("/fitness/water")
def create_water(
payload: WaterCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).log_water(payload.amount_ml)
@router.post("/fitness/weight")
def create_weight(
payload: WeightCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
day = date.fromisoformat(payload.day) if payload.day else None
return FitnessService(db, user.id).log_weight(
payload.weight_kg,
body_fat_pct=payload.body_fat_pct,
chest_cm=payload.chest_cm,
waist_cm=payload.waist_cm,
neck_cm=payload.neck_cm,
hip_cm=payload.hip_cm,
notes=payload.notes,
recorded_at=payload.recorded_at,
day=day,
days_ago=payload.days_ago,
)
@router.post("/fitness/body-composition/calc")
def calc_body_composition(
payload: BodyCompositionCalc,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).calc_body_composition(payload.model_dump(exclude_none=True))
@router.post("/fitness/steps")
def create_steps(
payload: StepsCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
day = date.fromisoformat(payload.day) if payload.day else None
return FitnessService(db, user.id).log_steps(
payload.steps,
active_calories=payload.active_calories,
notes=payload.notes,
day=day,
days_ago=payload.days_ago,
logged_at=payload.logged_at,
)
@router.delete("/fitness/steps/{log_id}")
def delete_steps(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_step_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.post("/fitness/workouts")
async def create_workout(
payload: WorkoutCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
service = FitnessService(db, user.id)
try:
structured = await structure_workout(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
day = date.fromisoformat(payload.day) if payload.day else None
return service.log_workout(
title=structured.get("title") or "Тренировка",
notes=structured.get("notes") or payload.text,
duration_min=structured.get("duration_min"),
exercises=structured.get("exercises"),
active_calories=structured.get("active_calories"),
total_calories=structured.get("total_calories"),
steps=structured.get("steps"),
activity_type=structured.get("activity_type"),
met=structured.get("met"),
day=day,
days_ago=payload.days_ago,
logged_at=payload.logged_at,
)
@router.get("/fitness/body-metrics")
def list_metrics(
limit: int = 30,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return FitnessService(db, user.id).list_body_metrics(limit=limit)
@router.delete("/fitness/meals/{log_id}")
def delete_meal(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_food_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/water/{log_id}")
def delete_water(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_water_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/workouts/{log_id}")
def delete_workout(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_workout_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.get("/fitness/reminders")
def list_reminders(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return FitnessService(db, user.id).list_reminders()
@router.put("/fitness/reminders/{kind}")
def update_reminder(
kind: str,
payload: ReminderUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).set_reminder(
kind,
enabled=payload.enabled,
hour=payload.hour,
minute=payload.minute,
interval_hours=payload.interval_hours,
)
@router.get("/fitness/lookup/food")
def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]:
return OpenFoodFactsClient().search(q, limit=limit)
@router.get("/fitness/lookup/exercise")
def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]:
return WgerClient().search_exercises(q, limit=limit)
-56
View File
@@ -1,56 +0,0 @@
import httpx
from fastapi import APIRouter, Depends
from app.auth.deps import get_current_user
from app.config import get_settings
from app.db.models import User
from app.homelab.comfyui import _use_anima
from app.homelab.openmeteo import build_weather_dashboard
router = APIRouter(prefix="/homelab", tags=["homelab"])
def _probe(url: str, *, timeout: float = 10.0) -> dict:
try:
with httpx.Client(timeout=timeout) as client:
response = client.get(url)
body = response.text[:500]
return {
"ok": response.status_code < 400,
"status_code": response.status_code,
"preview": body,
}
except Exception as exc:
return {"ok": False, "error": str(exc)}
@router.get("/status")
def homelab_status() -> dict:
settings = get_settings()
comfy_backend = "anima" if _use_anima(settings) else "checkpoint"
return {
"openmeteo": _probe(f"{settings.openmeteo_base_url.rstrip('/')}/v1/forecast?latitude=0&longitude=0&current=temperature_2m"),
"comfyui": _probe(f"{settings.comfyui_base_url.rstrip('/')}/system_stats"),
"netdata": _probe(f"{settings.netdata_base_url.rstrip('/')}/api/v1/info"),
"rp_chat": _probe(f"{settings.rp_chat_base_url.rstrip('/')}/health"),
"config": {
"openmeteo_base_url": settings.openmeteo_base_url,
"comfyui_base_url": settings.comfyui_base_url,
"comfyui_backend": comfy_backend,
"comfyui_unet": settings.comfyui_unet,
"netdata_base_url": settings.netdata_base_url,
"rp_chat_base_url": settings.rp_chat_base_url,
"rp_chat_enabled": settings.rp_chat_enabled,
},
}
@router.get("/weather")
def weather_dashboard(
hours_ahead: int = 12,
days_ahead: int = 7,
_: User = Depends(get_current_user),
) -> dict:
hours = max(1, min(int(hours_ahead), 168))
days = max(1, min(int(days_ahead), 16))
return build_weather_dashboard(hours_ahead=hours, days_ahead=days)
-42
View File
@@ -1,42 +0,0 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from app.auth.deps import get_current_user
from app.config import get_settings
from app.db.models import User
router = APIRouter(prefix="/media", tags=["media"])
@router.get("/generated/{filename}")
def get_generated_image(filename: str) -> FileResponse:
if ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=400, detail="Invalid filename")
settings = get_settings()
path = Path(settings.generated_media_dir) / filename
if not path.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(path, media_type="image/png")
@router.get("/uploads/{user_id}/{filename}")
def get_upload_image(
user_id: int,
filename: str,
user: User = Depends(get_current_user),
) -> FileResponse:
if user.id != user_id:
raise HTTPException(status_code=403, detail="Forbidden")
if ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=400, detail="Invalid filename")
settings = get_settings()
path = Path(settings.uploads_dir) / str(user_id) / filename
if not path.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(path, media_type="image/jpeg")
-130
View File
@@ -1,130 +0,0 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.db.models import ChatSession
from app.memory.extract import extract_after_turn
from app.memory.service import MemoryService
router = APIRouter()
class ProfileUpdate(BaseModel):
updates: dict[str, Any] = Field(default_factory=dict)
class FactCreate(BaseModel):
content: str = Field(min_length=1)
category: str = "fact"
importance: int = Field(default=3, ge=1, le=5)
session_id: int | None = None
class SessionSummaryUpdate(BaseModel):
summary: str = Field(min_length=1)
message_count: int = 0
class ExtractRequest(BaseModel):
session_id: int
user_text: str = Field(min_length=1)
assistant_text: str = ""
force: bool = False
@router.get("/memory")
def get_memory_snapshot(
session_id: int | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return MemoryService(db, user.id).snapshot(session_id)
@router.get("/profile")
def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return MemoryService(db, user.id).get_profile()
@router.put("/profile")
def update_profile(
payload: ProfileUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return MemoryService(db, user.id).update_profile(payload.updates)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/memory/facts")
def list_facts(
query: str | None = None,
category: str | None = None,
limit: int = 30,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return MemoryService(db, user.id).recall_memories(query=query, category=category, limit=limit)
@router.post("/memory/facts")
def create_fact(
payload: FactCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return MemoryService(db, user.id).remember_fact(
payload.content,
category=payload.category,
session_id=payload.session_id,
importance=payload.importance,
source="api",
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/memory/facts/{memory_id}")
def forget_fact(memory_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return MemoryService(db, user.id).forget_memory(memory_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/memory/extract")
async def extract_memories(
payload: ExtractRequest,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict:
session = db.get(ChatSession, payload.session_id)
if not session or session.user_id != user.id:
raise HTTPException(status_code=404, detail="Session not found")
return await extract_after_turn(
db,
payload.session_id,
payload.user_text,
payload.assistant_text,
user_id=user.id,
force=payload.force,
)
@router.put("/memory/sessions/{session_id}/summary")
def update_session_summary(
session_id: int,
payload: SessionSummaryUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return MemoryService(db, user.id).update_session_summary(
session_id,
payload.summary,
message_count=payload.message_count,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+100 -102
View File
@@ -1,102 +1,100 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.schemas import PomodoroStart, PomodoroStop from app.api.schemas import PomodoroStart, PomodoroStop
from app.auth.deps import get_current_user from app.db.base import get_db
from app.db.base import get_db from app.pomodoro.service import PomodoroService
from app.db.models import User
from app.pomodoro.service import PomodoroService router = APIRouter()
router = APIRouter()
def _handle_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
def _handle_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
@router.get("/status")
def get_status(db: Session = Depends(get_db)) -> dict:
@router.get("/status") return PomodoroService(db).get_status()
def get_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db, user.id).get_status()
@router.post("/start")
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
@router.post("/start") try:
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: return PomodoroService(db).start(
try: duration_min=payload.duration_min,
return PomodoroService(db, user.id).start( task_note=payload.task_note,
duration_min=payload.duration_min, )
task_note=payload.task_note, except ValueError as exc:
) raise _handle_value_error(exc) from exc
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/pause")
def pause_pomodoro(db: Session = Depends(get_db)) -> dict:
@router.post("/pause") try:
def pause_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: return PomodoroService(db).pause()
try: except ValueError as exc:
return PomodoroService(db, user.id).pause() raise _handle_value_error(exc) from exc
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/resume")
def resume_pomodoro(db: Session = Depends(get_db)) -> dict:
@router.post("/resume") try:
def resume_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: return PomodoroService(db).resume()
try: except ValueError as exc:
return PomodoroService(db, user.id).resume() raise _handle_value_error(exc) from exc
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/stop")
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict:
@router.post("/stop") try:
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: return PomodoroService(db).stop(
try: result=payload.result,
return PomodoroService(db, user.id).stop( completed=payload.completed,
result=payload.result, )
completed=payload.completed, except ValueError as exc:
) raise _handle_value_error(exc) from exc
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]:
@router.get("/history") return PomodoroService(db).history(limit=limit)
def get_history(limit: int = 20, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
return PomodoroService(db, user.id).history(limit=limit)
@router.post("/work/start")
def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
@router.post("/work/start") try:
def start_work(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: return PomodoroService(db).start_work(
try: duration_min=payload.duration_min,
return PomodoroService(db, user.id).start_work( task_note=payload.task_note,
duration_min=payload.duration_min, )
task_note=payload.task_note, except ValueError as exc:
) raise _handle_value_error(exc) from exc
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/short/start")
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
@router.post("/break/short/start") try:
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: return PomodoroService(db).start_short_break(duration_min=duration_min)
try: except ValueError as exc:
return PomodoroService(db, user.id).start_short_break(duration_min=duration_min) raise _handle_value_error(exc) from exc
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/long/start")
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
@router.post("/break/long/start") try:
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: return PomodoroService(db).start_long_break(duration_min=duration_min)
try: except ValueError as exc:
return PomodoroService(db, user.id).start_long_break(duration_min=duration_min) raise _handle_value_error(exc) from exc
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/cycle/reset")
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db)) -> dict:
@router.post("/cycle/reset") return PomodoroService(db).reset_cycle(clear_task=clear_task)
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db, user.id).reset_cycle(clear_task=clear_task)
@router.post("/skip")
def skip_phase(db: Session = Depends(get_db)) -> dict:
@router.post("/skip") try:
def skip_phase(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: return PomodoroService(db).skip_phase()
try: except ValueError as exc:
return PomodoroService(db, user.id).skip_phase() raise _handle_value_error(exc) from exc
except ValueError as exc:
raise _handle_value_error(exc) from exc
+76 -78
View File
@@ -1,78 +1,76 @@
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.deps import get_current_user from app.db.base import get_db
from app.db.base import get_db from app.projects.service import ProjectService
from app.db.models import User
from app.projects.service import ProjectService router = APIRouter()
router = APIRouter()
class GiteaBinding(BaseModel):
gitea_owner: str = Field(min_length=1)
class GiteaBinding(BaseModel): gitea_repo: str = Field(min_length=1)
gitea_owner: str = Field(min_length=1) default_branch: str = "main"
gitea_repo: str = Field(min_length=1)
default_branch: str = "main"
class WorkItemCreate(BaseModel):
text: str = Field(min_length=1)
class WorkItemCreate(BaseModel): project_slug: str | None = None
text: str = Field(min_length=1)
project_slug: str | None = None
@router.get("/projects")
def list_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
@router.get("/projects") return ProjectService(db).list_projects()
def list_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return ProjectService(db, user.id).list_projects()
@router.post("/projects/sync-taiga")
def sync_taiga_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
@router.post("/projects/sync-taiga") try:
def sync_taiga_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]: return ProjectService(db).sync_taiga_projects()
try: except ValueError as exc:
return ProjectService(db, user.id).sync_taiga_projects() raise HTTPException(status_code=400, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/projects/{taiga_slug}/gitea")
def bind_gitea(
@router.put("/projects/{taiga_slug}/gitea") taiga_slug: str,
def bind_gitea( payload: GiteaBinding,
taiga_slug: str, db: Session = Depends(get_db),
payload: GiteaBinding, ) -> dict[str, Any]:
db: Session = Depends(get_db), user: User = Depends(get_current_user), try:
) -> dict[str, Any]: return ProjectService(db).bind_gitea(
try: taiga_slug,
return ProjectService(db, user.id).bind_gitea( payload.gitea_owner,
taiga_slug, payload.gitea_repo,
payload.gitea_owner, payload.default_branch,
payload.gitea_repo, )
payload.default_branch, except ValueError as exc:
) raise HTTPException(status_code=400, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/work-items")
async def create_work_item(
@router.post("/work-items") payload: WorkItemCreate,
async def create_work_item( db: Session = Depends(get_db),
payload: WorkItemCreate, ) -> dict[str, Any]:
db: Session = Depends(get_db), user: User = Depends(get_current_user), try:
) -> dict[str, Any]: return await ProjectService(db).create_work_item(
try: payload.text,
return await ProjectService(db, user.id).create_work_item( project_slug=payload.project_slug,
payload.text, )
project_slug=payload.project_slug, except ValueError as exc:
) raise HTTPException(status_code=400, detail=str(exc)) from exc
except ValueError as exc: except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/work-items")
def list_work_items(
@router.get("/work-items") limit: int = 30,
def list_work_items( status: str | None = None,
limit: int = 30, db: Session = Depends(get_db),
status: str | None = None, ) -> list[dict[str, Any]]:
db: Session = Depends(get_db), user: User = Depends(get_current_user), return ProjectService(db).list_work_items(limit=limit, status=status)
) -> list[dict[str, Any]]:
return ProjectService(db, user.id).list_work_items(limit=limit, status=status)
-128
View File
@@ -1,128 +0,0 @@
from datetime import datetime, timezone
from typing import Any
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.homelab.context import resolve_timezone
from app.reminders_scoped.service import RemindersService
router = APIRouter()
class ReminderCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
due_at: str = Field(description="ISO datetime, например 2027-05-12T12:16:00")
notes: str = ""
all_day: bool = False
recurrence: str = "none"
class ReminderUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
due_at: str | None = None
notes: str | None = None
all_day: bool | None = None
recurrence: str | None = None
enabled: bool | None = None
@router.get("")
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return RemindersService(db, user.id).snapshot()
@router.get("/upcoming")
def list_upcoming(
limit: int = Query(30, ge=1, le=100),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return RemindersService(db, user.id).list_upcoming(limit=limit)
@router.get("/calendar")
def calendar(
year: int = Query(..., ge=2000, le=2100),
month: int = Query(..., ge=1, le=12),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
tz_name = resolve_timezone(db, user.id)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
start = datetime(year, month, 1, tzinfo=tz)
if month == 12:
end = datetime(year + 1, 1, 1, tzinfo=tz)
else:
end = datetime(year, month + 1, 1, tzinfo=tz)
service = RemindersService(db, user.id)
items = service.list_in_range(
date_from=start.astimezone(timezone.utc),
date_to=end.astimezone(timezone.utc),
)
return {
"year": year,
"month": month,
"timezone": tz_name,
"reminders": items,
}
@router.post("")
def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return RemindersService(db, user.id).create(
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/{reminder_id}")
def update_reminder(
reminder_id: int,
payload: ReminderUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return RemindersService(db, user.id).update(
reminder_id,
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
enabled=payload.enabled,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/{reminder_id}")
def delete_reminder(reminder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return RemindersService(db, user.id).delete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{reminder_id}/complete")
def complete_reminder(reminder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return RemindersService(db, user.id).complete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
-35
View File
@@ -1,35 +0,0 @@
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.settings.service import SETTING_KEYS, SettingsService
router = APIRouter()
class SettingsPatch(BaseModel):
openrouter_model: str | None = None
memory_extract_model: str | None = None
openrouter_vision_model: str | None = None
openrouter_reasoning_effort: str | None = None
rag_enabled: bool | None = None
rag_top_k: int | None = Field(default=None, ge=1, le=50)
@router.get("/settings")
def get_settings_route(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return SettingsService(db).snapshot()
@router.patch("/settings")
def patch_settings_route(
payload: SettingsPatch,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
updates = payload.model_dump(exclude_unset=True)
return SettingsService(db).patch(updates)
-118
View File
@@ -1,118 +0,0 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.shopping.service import ShoppingService
router = APIRouter()
class ListCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ListRename(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ItemInput(BaseModel):
text: str = Field(min_length=1, max_length=500)
quantity: float | None = None
unit: str = ""
class ItemsAdd(BaseModel):
list_id: int | None = None
list_name: str | None = None
items: list[ItemInput] = Field(min_length=1)
class ItemChecked(BaseModel):
checked: bool
@router.get("")
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return ShoppingService(db, user.id).snapshot()
@router.get("/lists")
def list_lists(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return ShoppingService(db, user.id).list_lists(include_items=True)
@router.post("/lists")
def create_list(payload: ListCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).create_list(payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/lists/{list_id}")
def get_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
data = ShoppingService(db, user.id).get_list(list_id=list_id)
if not data:
raise HTTPException(status_code=404, detail="List not found")
return data
@router.patch("/lists/{list_id}")
def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).rename_list(list_id, payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/lists/{list_id}")
def delete_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).delete_list(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/items")
def add_items(payload: ItemsAdd, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).add_items(
[i.model_dump() for i in payload.items],
list_id=payload.list_id,
list_name=payload.list_name,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/items/{item_id}")
def set_item_checked(
item_id: int,
payload: ItemChecked,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).set_item_checked(item_id, payload.checked)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/items/{item_id}")
def remove_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).remove_item(item_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/lists/{list_id}/clear-checked")
def clear_checked(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).clear_checked(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+94 -106
View File
@@ -1,106 +1,94 @@
import hashlib import hashlib
import hmac import hmac
import json import json
import logging from typing import Any
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select
from sqlalchemy import select from sqlalchemy.orm import Session
from sqlalchemy.orm import Session
from app.config import get_settings
from app.chat.notice_inbox import post_notice_to_latest_chat from app.db.base import SessionLocal, get_db
from app.config import get_settings from app.db.models import ChatSession, Message, ProjectBinding
from app.db.base import get_db from app.projects.service import ProjectService
from app.db.models import ProjectBinding
from app.projects.service import ProjectService router = APIRouter()
router = APIRouter()
logger = logging.getLogger(__name__) def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
if not secret:
return True
def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool: if not signature:
if not secret: return False
return True if signature.startswith("sha256="):
if not signature: signature = signature[7:]
return False expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
if signature.startswith("sha256="): return hmac.compare_digest(expected, signature)
signature = signature[7:]
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature) def _post_close_notice(results: list[dict[str, Any]], owner: str, repo: str) -> None:
if not results:
return
def _post_close_notice( db = SessionLocal()
results: list[dict[str, Any]], owner: str, repo: str, user_id: int try:
) -> None: session = db.scalar(
if not results: select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
return )
lines = [f"🔀 **Push** `{owner}/{repo}`"] if not session:
for item in results: session = ChatSession(title="Git")
if "closed" in item: db.add(session)
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}") db.commit()
elif "error" in item: db.refresh(session)
lines.append(f"- ошибка: {item['error']}")
post_notice_to_latest_chat("\n".join(lines), user_id) lines = [f"🔀 **Push** `{owner}/{repo}`"]
for item in results:
if "closed" in item:
@router.post("/webhooks/gitea") lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]: elif "error" in item:
body = await request.body() lines.append(f"- ошибка: {item['error']}")
settings = get_settings()
signature = ( db.add(Message(session_id=session.id, role="notice", content="\n".join(lines)))
request.headers.get("X-Gitea-Signature") db.commit()
or request.headers.get("X-Gogs-Signature") finally:
or request.headers.get("X-Hub-Signature-256") db.close()
)
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret): @router.post("/webhooks/gitea")
raise HTTPException(status_code=401, detail="Invalid webhook signature") async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
body = await request.body()
payload = json.loads(body) settings = get_settings()
if payload.get("secret") and settings.gitea_webhook_secret: signature = request.headers.get("X-Gitea-Signature")
if payload.get("secret") != settings.gitea_webhook_secret:
raise HTTPException(status_code=401, detail="Invalid webhook secret") if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
event = request.headers.get("X-Gitea-Event", "")
if event != "push": payload = json.loads(body)
return {"ok": True, "skipped": event} if payload.get("secret") and settings.gitea_webhook_secret:
if payload.get("secret") != settings.gitea_webhook_secret:
repo = payload.get("repository", {}) raise HTTPException(status_code=401, detail="Invalid webhook secret")
owner = repo.get("owner", {}).get("login", "")
repo_name = repo.get("name", "") event = request.headers.get("X-Gitea-Event", "")
if not owner or not repo_name: if event != "push":
raise HTTPException(status_code=400, detail="Missing repository info") return {"ok": True, "skipped": event}
binding = db.scalar( repo = payload.get("repository", {})
select(ProjectBinding).where( owner = repo.get("owner", {}).get("login", "")
ProjectBinding.gitea_owner == owner, repo_name = repo.get("name", "")
ProjectBinding.gitea_repo == repo_name, if not owner or not repo_name:
) raise HTTPException(status_code=400, detail="Missing repository info")
)
if not binding: binding = db.scalar(
return {"ok": True, "skipped": "unknown repo"} select(ProjectBinding).where(
ProjectBinding.gitea_owner == owner,
commits = list(payload.get("commits") or []) ProjectBinding.gitea_repo == repo_name,
if not commits: )
head = payload.get("head_commit") )
if head: if not binding:
commits = [head] return {"ok": True, "skipped": "unknown repo"}
logger.info( commits = payload.get("commits") or []
"Gitea push %s/%s ref=%s commits=%d", service = ProjectService(db)
owner, results = service.process_push(owner, repo_name, commits)
repo_name, _post_close_notice(results, owner, repo_name)
payload.get("ref", ""),
len(commits), return {"ok": True, "results": results}
)
service = ProjectService(db, binding.user_id)
results = service.process_push(owner, repo_name, commits)
if results:
logger.info("Gitea push results: %s", results)
else:
logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name)
_post_close_notice(results, owner, repo_name, binding.user_id)
return {"ok": True, "results": results, "commits_processed": len(commits)}
-1
View File
@@ -20,7 +20,6 @@ class MessageOut(BaseModel):
id: int id: int
role: str role: str
content: str content: str
tool_calls_json: str | None = None
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
-5
View File
@@ -1,5 +0,0 @@
from app.auth.deps import get_current_user
from app.auth.service import create_user, find_user_by_token
from app.auth.tokens import hash_token, verify_token
__all__ = ["get_current_user", "hash_token", "verify_token"]
-37
View File
@@ -1,37 +0,0 @@
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.tokens import hash_token
from app.db.base import get_db
from app.db.models import User
def _extract_token(request: Request) -> str | None:
auth = request.headers.get("Authorization", "")
if auth.lower().startswith("bearer "):
token = auth[7:].strip()
if token:
return token
header = request.headers.get("X-API-Token", "").strip()
if header:
return header
query = request.query_params.get("token", "").strip()
return query or None
def get_current_user(
request: Request,
db: Session = Depends(get_db),
) -> User:
token = _extract_token(request)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API token")
token_hash = hash_token(token)
user = db.scalar(
select(User).where(User.api_token_hash == token_hash, User.is_active.is_(True))
)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API token")
return user
-61
View File
@@ -1,61 +0,0 @@
import secrets
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.tokens import hash_token
from app.db.models import CharacterCard, User
from app.character.card import DEFAULT_CARD, normalize_card
import json
def find_user_by_token(db: Session, token: str) -> User | None:
token_hash = hash_token(token.strip())
return db.scalar(
select(User).where(User.api_token_hash == token_hash, User.is_active.is_(True))
)
def user_to_dict(user: User) -> dict[str, Any]:
return {
"id": user.id,
"username": user.username,
"display_name": user.display_name or user.username,
}
def create_user(
db: Session,
*,
username: str,
display_name: str = "",
api_token: str | None = None,
) -> tuple[User, str]:
clean = username.strip().lower()
if not clean:
raise ValueError("username не может быть пустым")
existing = db.scalar(select(User).where(User.username == clean))
if existing:
raise ValueError(f"Пользователь «{clean}» уже существует")
plain_token = (api_token or "").strip() or secrets.token_urlsafe(32)
user = User(
username=clean,
display_name=(display_name or clean).strip(),
api_token_hash=hash_token(plain_token),
is_active=True,
)
db.add(user)
db.flush()
card = normalize_card(DEFAULT_CARD)
db.add(
CharacterCard(
user_id=user.id,
card_json=json.dumps(card, ensure_ascii=False),
)
)
db.commit()
db.refresh(user)
return user, plain_token
-9
View File
@@ -1,9 +0,0 @@
import hashlib
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def verify_token(plain: str, token_hash: str) -> bool:
return hash_token(plain) == token_hash
+77 -104
View File
@@ -1,104 +1,77 @@
from typing import Any from typing import Any
TOOLS_INSTRUCTIONS = """ TOOLS_INSTRUCTIONS = """
Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс). Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс).
Обязательные правила: Обязательные правила:
- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент. - Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент.
- Никогда не выдумывай статус таймера или список задач. - Никогда не выдумывай статус таймера или список задач.
- После вызова инструмента кратко объясни результат пользователю по-человечески. - После вызова инструмента кратко объясни результат пользователю по-человечески.
- Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break, - Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break,
stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history. stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history.
- Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items. - Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items.
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga). - «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД). - list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. - create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
- Фитнес: get_fitness_summary (date/days_ago), get_fitness_history, set_fitness_profile, log_meal, log_water, log_weight (neck_cm/waist_cm/hip_cm → Navy), log_workout, - Снимок проектов/задач есть в контексте, но для актуализации вызывай tools. Никогда не пиши «ожидаю ответа от системы».
- «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history. """.strip()
- Скриншоты и фото: vision-модель уже разобрала каждую картинку ДО твоего ответа. В сообщении один или несколько блоков [Скриншот] / [Скриншот N/M] — это содержимое изображений; отвечай так, будто ты их видишь.
- НЕ говори, что у тебя нет глаз / ты не видишь картинку / нужен Gemini, OpenRouter или curl — распознавание уже выполнено. DEFAULT_CARD: dict[str, Any] = {
- fitness_workout / fitness_steps + fitness_hints: log_workout, log_steps и т.д.; при confidence=low уточни детали. "spec": "chara_card_v2",
- document_type=other: опиши и прокомментируй по блоку [Скриншот], без советов про настройку vision API. "spec_version": "2.0",
calc_fitness_targets, calc_body_composition (расчёт Navy/WHR/LBM/FFMI без записи), lookup_food, lookup_exercise, set_fitness_reminder. "data": {
- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary. "name": "Домашний ассистент",
- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай. "description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.",
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе. "personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.",
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools. "scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.",
- Никогда не пиши «ожидаю ответа от системы». "first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?",
- В текстовых ответах пользователю не используй эмодзи. "mes_example": "",
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай. "system_prompt": "",
- Утренний брифинг (погода + новости) → get_morning_briefing. "post_history_instructions": "",
- Картинки: generate_image — draw_self=true + scene_description (full_body, outfit…); appearance только из карточки. Не злоупотребляй. "alternate_greetings": [],
- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list. "tags": ["assistant", "home", "pomodoro"],
- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки. "creator": "",
- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder. "creator_notes": "",
- «Напомни через 15 минут», «завтра утром», «12 мая в 9:00» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]). "character_version": "1.0",
- День рождения, Новый год и другие праздники → recurrence yearly. },
- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе. }
""".strip()
DEFAULT_CARD: dict[str, Any] = { def normalize_card(raw: dict[str, Any]) -> dict[str, Any]:
"spec": "chara_card_v2", if "data" in raw and isinstance(raw["data"], dict):
"spec_version": "2.0", card = {
"data": { "spec": raw.get("spec", "chara_card_v2"),
"name": "Домашний ассистент", "spec_version": raw.get("spec_version", "2.0"),
"description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.", "data": {**DEFAULT_CARD["data"], **raw["data"]},
"personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.", }
"scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.", return card
"first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?",
"mes_example": "", if "name" in raw or "description" in raw:
"system_prompt": "", return {
"post_history_instructions": "", "spec": "chara_card_v2",
"alternate_greetings": [], "spec_version": "2.0",
"tags": ["assistant", "home", "pomodoro"], "data": {**DEFAULT_CARD["data"], **raw},
"creator": "", }
"creator_notes": "",
"character_version": "1.0", return DEFAULT_CARD.copy()
"appearance_tags": "",
"appearance_prose": "",
"lora_name": "", def build_system_prompt(card: dict[str, Any]) -> str:
"lora_weight": 0.8, data = card.get("data", {})
"rp_persona_id": "", parts: list[str] = []
"sd_enabled": True,
}, name = data.get("name", "Ассистент")
} parts.append(f"Ты — {name}.")
if data.get("system_prompt"):
def normalize_card(raw: dict[str, Any]) -> dict[str, Any]: parts.append(data["system_prompt"])
if "data" in raw and isinstance(raw["data"], dict): if data.get("description"):
card = { parts.append(data["description"])
"spec": raw.get("spec", "chara_card_v2"), if data.get("personality"):
"spec_version": raw.get("spec_version", "2.0"), parts.append(f"Характер: {data['personality']}")
"data": {**DEFAULT_CARD["data"], **raw["data"]}, if data.get("scenario"):
} parts.append(f"Сценарий: {data['scenario']}")
return card if data.get("post_history_instructions"):
parts.append(data["post_history_instructions"])
if "name" in raw or "description" in raw:
return { parts.append(TOOLS_INSTRUCTIONS)
"spec": "chara_card_v2", return "\n\n".join(part for part in parts if part.strip())
"spec_version": "2.0",
"data": {**DEFAULT_CARD["data"], **raw},
}
return DEFAULT_CARD.copy()
def build_system_prompt(card: dict[str, Any]) -> str:
data = card.get("data", {})
parts: list[str] = []
name = data.get("name", "Ассистент")
parts.append(f"Ты — {name}.")
if data.get("system_prompt"):
parts.append(data["system_prompt"])
if data.get("description"):
parts.append(data["description"])
if data.get("personality"):
parts.append(f"Характер: {data['personality']}")
if data.get("scenario"):
parts.append(f"Сценарий: {data['scenario']}")
if data.get("post_history_instructions"):
parts.append(data["post_history_instructions"])
parts.append(TOOLS_INSTRUCTIONS)
return "\n\n".join(part for part in parts if part.strip())
+27 -43
View File
@@ -1,43 +1,27 @@
import json import json
from datetime import datetime, timezone from pathlib import Path
from typing import Any from typing import Any
from sqlalchemy import select from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
from sqlalchemy.orm import Session
CARD_PATH = Path("./data/character.json")
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
from app.db.models import CharacterCard
class CharacterService:
def get_card(self) -> dict[str, Any]:
class CharacterService: if CARD_PATH.is_file():
def __init__(self, db: Session, user_id: int): try:
self.db = db raw = json.loads(CARD_PATH.read_text(encoding="utf-8"))
self.user_id = user_id return normalize_card(raw)
except (json.JSONDecodeError, OSError):
def get_card(self) -> dict[str, Any]: pass
row = self.db.scalar( return normalize_card(DEFAULT_CARD)
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
) def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
if not row: card = normalize_card(raw)
return normalize_card(DEFAULT_CARD) CARD_PATH.parent.mkdir(parents=True, exist_ok=True)
try: CARD_PATH.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8")
return normalize_card(json.loads(row.card_json or "{}")) return card
except json.JSONDecodeError:
return normalize_card(DEFAULT_CARD) def get_system_prompt(self) -> str:
return build_system_prompt(self.get_card())
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
card = normalize_card(raw)
row = self.db.scalar(
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
)
if not row:
row = CharacterCard(user_id=self.user_id, card_json="{}")
self.db.add(row)
self.db.flush()
row.card_json = json.dumps(card, ensure_ascii=False)
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return card
def get_system_prompt(self) -> str:
return build_system_prompt(self.get_card())
-95
View File
@@ -1,95 +0,0 @@
import asyncio
import logging
from dataclasses import dataclass, field
from app.chat.service import ChatService
from app.db.base import SessionLocal
logger = logging.getLogger(__name__)
class GenerationBusyError(Exception):
"""Сессия уже генерирует ответ."""
@dataclass
class GenerationHandle:
session_id: int
user_id: int
user_text: str
task: asyncio.Task | None = None
subscribers: list[asyncio.Queue[str | None]] = field(default_factory=list)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
async def broadcast(self, chunk: str | None) -> None:
async with self._lock:
targets = list(self.subscribers)
for queue in targets:
try:
queue.put_nowait(chunk)
except asyncio.QueueFull:
logger.debug("generation queue full for session=%s, dropping subscriber", self.session_id)
def add_subscriber(self) -> asyncio.Queue[str | None]:
queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=512)
self.subscribers.append(queue)
return queue
def remove_subscriber(self, queue: asyncio.Queue[str | None]) -> None:
if queue in self.subscribers:
self.subscribers.remove(queue)
_registry: dict[int, GenerationHandle] = {}
_registry_lock = asyncio.Lock()
def is_generation_active(session_id: int) -> bool:
return session_id in _registry
def get_active_handle(session_id: int) -> GenerationHandle | None:
return _registry.get(session_id)
async def _run_generation(handle: GenerationHandle) -> None:
db = SessionLocal()
try:
service = ChatService(db, handle.user_id)
async for chunk in service.stream_response(
handle.session_id,
handle.user_text,
user_message_saved=True,
):
await handle.broadcast(chunk)
except Exception as exc:
logger.exception("Background generation failed session=%s", handle.session_id)
await handle.broadcast(ChatService._sse("error", {"message": str(exc)}))
finally:
await handle.broadcast(None)
db.close()
async with _registry_lock:
if _registry.get(handle.session_id) is handle:
_registry.pop(handle.session_id, None)
async def start_generation(session_id: int, user_id: int, user_text: str) -> GenerationHandle:
async with _registry_lock:
if session_id in _registry:
raise GenerationBusyError()
handle = GenerationHandle(session_id=session_id, user_id=user_id, user_text=user_text)
_registry[session_id] = handle
handle.task = asyncio.create_task(_run_generation(handle))
return handle
async def subscribe_generation(handle: GenerationHandle):
queue = handle.add_subscriber()
try:
while True:
chunk = await queue.get()
if chunk is None:
break
yield chunk
finally:
handle.remove_subscriber(queue)
-71
View File
@@ -1,71 +0,0 @@
from typing import Any
def _tool_call_ids(tool_calls: list[dict[str, Any]]) -> list[str]:
return [tc.get("id", "") for tc in tool_calls if tc.get("id")]
def sanitize_openai_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Убирает битые tool-цепочки и подряд идущих assistant без user между ними."""
if not messages:
return messages
system = messages[0] if messages[0].get("role") == "system" else None
rest = messages[1:] if system else list(messages)
cleaned: list[dict[str, Any]] = []
i = 0
while i < len(rest):
msg = rest[i]
role = msg.get("role")
if role == "assistant" and msg.get("tool_calls"):
tool_calls = msg["tool_calls"]
needed_ids = set(_tool_call_ids(tool_calls))
if not needed_ids:
i += 1
continue
block = [msg]
i += 1
found_ids: set[str] = set()
while i < len(rest) and rest[i].get("role") == "tool":
tool_id = rest[i].get("tool_call_id", "")
if tool_id in needed_ids:
block.append(rest[i])
found_ids.add(tool_id)
i += 1
if found_ids == needed_ids:
cleaned.extend(block)
continue
if role == "tool":
# осиротевший tool без assistant tool_calls
i += 1
continue
if role == "assistant" and cleaned and cleaned[-1].get("role") == "assistant":
# два assistant подряд ломают API (старый баг pomodoro)
i += 1
continue
cleaned.append(msg)
i += 1
if system:
return [system, *cleaned]
return cleaned
def strip_historical_reasoning(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Reasoning из БД часто неполный — для старых сообщений убираем."""
result: list[dict[str, Any]] = []
for msg in messages:
entry = dict(msg)
if entry.get("role") == "assistant":
entry.pop("reasoning", None)
entry.pop("reasoning_content", None)
entry.pop("reasoning_details", None)
result.append(entry)
return result
-47
View File
@@ -1,47 +0,0 @@
"""Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю)."""
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import ChatSession, Message
DISPLAY_ONLY_ROLES = frozenset({"notice", "character"})
def _latest_chat_session(db, user_id: int) -> ChatSession:
session = db.scalar(
select(ChatSession)
.where(ChatSession.user_id == user_id)
.order_by(ChatSession.updated_at.desc())
.limit(1)
)
if not session:
session = ChatSession(user_id=user_id, title="Уведомления")
db.add(session)
db.commit()
db.refresh(session)
return session
def post_notice_to_latest_chat(content: str, user_id: int) -> int | None:
"""Сохраняет notice в последний активный чат пользователя. Возвращает session_id."""
db = SessionLocal()
try:
session = _latest_chat_session(db, user_id)
db.add(Message(session_id=session.id, role="notice", content=content))
db.commit()
return session.id
finally:
db.close()
def post_character_comment_to_latest_chat(content: str, user_id: int) -> int | None:
"""Реплика персонажа в UI; не попадает в контекст LLM (в отличие от assistant)."""
db = SessionLocal()
try:
session = _latest_chat_session(db, user_id)
db.add(Message(session_id=session.id, role="character", content=content))
db.commit()
return session.id
finally:
db.close()
+245 -443
View File
@@ -1,443 +1,245 @@
import json import json
from typing import Any 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
PHASE_LABELS = { PHASE_LABELS = {
PHASE_WORK: "Работа", PHASE_WORK: "Работа",
PHASE_SHORT_BREAK: "Короткий перерыв", PHASE_SHORT_BREAK: "Короткий перерыв",
PHASE_LONG_BREAK: "Длинный перерыв", PHASE_LONG_BREAK: "Длинный перерыв",
} }
def _format_time(seconds: int) -> str: def _format_time(seconds: int) -> str:
minutes, secs = divmod(max(0, seconds), 60) minutes, secs = divmod(max(0, seconds), 60)
return f"{minutes:02d}:{secs:02d}" return f"{minutes:02d}:{secs:02d}"
def _format_image_generation_notice(data: dict[str, Any]) -> str: def format_phase_completed_notice(
url = data.get("url", "") session: PomodoroSession,
positive = (data.get("prompt") or "").strip() next_phase: str | None,
negative = (data.get("negative_prompt") or "").strip() ) -> str:
lines = ["🎨 **Картинка готова**", "", f"![image]({url})"] phase_label = PHASE_LABELS.get(session.phase, session.phase)
if positive: task = session.task_note or "без описания"
lines.extend(["", "**Comfy (+):**", f"```\n{positive}\n```"]) lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
if negative:
lines.extend(["", "**Comfy ():**", f"```\n{negative}\n```"]) if next_phase == PHASE_SHORT_BREAK:
return "\n".join(lines) lines.append("Дальше: короткий перерыв ☕")
elif next_phase == PHASE_LONG_BREAK:
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён")
def format_phase_completed_notice( elif next_phase == PHASE_WORK:
session: PomodoroSession, lines.append("Дальше: снова работа 💪")
next_phase: str | None, else:
) -> str: lines.append("Цикл сброшен. Можно отдохнуть и начать заново.")
phase_label = PHASE_LABELS.get(session.phase, session.phase)
task = session.task_note or "без описания" return "\n".join(lines)
lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
if next_phase == PHASE_SHORT_BREAK: POMODORO_TOOL_NAMES = frozenset({
lines.append("Дальше: короткий перерыв ☕") "get_pomodoro_status",
elif next_phase == PHASE_LONG_BREAK: "start_pomodoro",
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён") "start_short_break",
elif next_phase == PHASE_WORK: "start_long_break",
lines.append("Дальше: снова работа 💪") "stop_pomodoro",
else: "skip_pomodoro_phase",
lines.append("Цикл сброшен. Можно отдохнуть и начать заново.") "reset_pomodoro_cycle",
"get_pomodoro_history",
return "\n".join(lines) })
# Не засорять чат служебными ответами
POMODORO_TOOL_NAMES = frozenset({ TOOLS_SKIP_CHAT_NOTICE = frozenset({
"get_pomodoro_status", "get_pomodoro_status",
"start_pomodoro", })
"start_short_break",
"start_long_break",
"stop_pomodoro", def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
"skip_pomodoro_phase", if tool_name in TOOLS_SKIP_CHAT_NOTICE:
"reset_pomodoro_cycle", return None
"get_pomodoro_history",
}) try:
data = json.loads(raw_result)
MEMORY_TOOL_NAMES = frozenset({ except json.JSONDecodeError:
"remember_fact", return None
"recall_memories",
"forget_memory", if isinstance(data, dict) and "error" in data:
"update_profile", prefix = "" if tool_name in POMODORO_TOOL_NAMES else "📋"
"update_session_summary", return f"{prefix} {data['error']}"
})
if tool_name == "reset_pomodoro_cycle":
FITNESS_TOOL_NAMES = frozenset({ cycle = data.get("cycle", data)
"get_fitness_summary", return (
"get_fitness_history", "⏱ **Цикл помидоро сброшен** · "
"set_fitness_profile", f"прогресс: {cycle.get('completed_work_sessions', 0)}/"
"calc_fitness_targets", f"{cycle.get('sessions_until_long_break', 4)}"
"calc_body_composition", )
"log_meal",
"log_water", if tool_name in (
"log_weight", "get_pomodoro_status",
"log_workout", "start_pomodoro",
"lookup_food", "start_work",
"lookup_exercise", "start_short_break",
"set_fitness_reminder", "start_long_break",
}) "stop_pomodoro",
"skip_pomodoro_phase",
# Не засорять чат служебными ответами ):
REMINDER_TOOL_NAMES = frozenset({ return _format_status_notice(data)
"list_reminders",
"create_reminder", if tool_name == "get_pomodoro_history":
"update_reminder", return _format_history_notice(data)
"delete_reminder",
"complete_reminder", if tool_name == "create_work_item":
}) return _format_work_item_notice(data)
SHOPPING_TOOL_NAMES = frozenset({ if tool_name == "list_work_items":
"list_shopping_lists", return _format_work_items_list_notice(data)
"create_shopping_list",
"add_shopping_items", if tool_name == "list_taiga_tasks":
"check_shopping_item", return _format_taiga_tasks_notice(data)
"remove_shopping_item",
"delete_shopping_list", if tool_name == "sync_taiga_projects":
}) return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
TOOLS_SKIP_CHAT_NOTICE = frozenset({ if tool_name == "list_taiga_projects":
"get_pomodoro_status", if not isinstance(data, list) or not data:
"recall_memories", return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects."
"get_fitness_summary", lines = ["📋 **Проекты:**"]
"get_fitness_history", for p in data:
"lookup_food", gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else ""
"lookup_exercise", lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
"calc_fitness_targets", return "\n".join(lines)
"calc_body_composition",
"get_weather", return None
"get_morning_briefing",
"list_shopping_lists",
"list_reminders", def _format_work_item_notice(data: dict[str, Any]) -> str | None:
}) if data.get("error"):
return f"📋 {data['error']}"
if not data.get("ok"):
return None
def _format_body_composition_notice(computed: dict[str, Any], *, headline: str) -> str: taiga = data.get("taiga", {})
parts: list[str] = [] gitea = data.get("gitea", {})
bf = computed.get("body_fat_pct") lines = [
if bf is not None: "📋 **Создано:**",
method = computed.get("body_fat_method") f"- Taiga: #{taiga.get('ref')}{taiga.get('subject')}",
if method == "navy": f"- URL: {taiga.get('url')}",
parts.append(f"жир ≈{bf}% (Navy)") ]
elif method == "manual": if gitea.get("url"):
parts.append(f"жир {bf}%") lines.append(f"- Gitea: {gitea.get('url')}")
else: if data.get("branch"):
parts.append(f"жир ≈{bf}%") lines.append(f"- Ветка: `{data['branch']}`")
if computed.get("whr") is not None: subtasks = data.get("subtasks") or []
parts.append(f"WHR {computed.get('whr')}") if subtasks:
if computed.get("ffmi") is not None: lines.append("**Подзадачи:**")
parts.append(f"FFMI {computed.get('ffmi')}") for t in subtasks:
if parts: lines.append(f"- #{t.get('ref')} {t.get('subject')}")
return f"{headline}{', '.join(parts)}" return "\n".join(lines)
return headline
def format_tool_notice(tool_name: str, raw_result: str) -> str | None: def _format_work_items_list_notice(data: Any) -> str | None:
if tool_name in TOOLS_SKIP_CHAT_NOTICE: if not isinstance(data, list) or not data:
return None return "📋 Локальных work items (созданных ассистентом) нет."
lines = ["📋 **Work items ассистента:**"]
try: for item in data[:15]:
data = json.loads(raw_result) lines.append(
except json.JSONDecodeError: f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} "
return None f"({item.get('taiga_slug')})"
)
if isinstance(data, dict) and "error" in data: return "\n".join(lines)
if tool_name in POMODORO_TOOL_NAMES:
prefix = ""
elif tool_name in MEMORY_TOOL_NAMES: def _format_taiga_tasks_notice(data: Any) -> str | None:
prefix = "🧠" if not isinstance(data, dict):
elif tool_name in FITNESS_TOOL_NAMES: return None
prefix = "💪" if data.get("error"):
elif tool_name in SHOPPING_TOOL_NAMES: return f"📋 {data['error']}"
prefix = "🛒"
elif tool_name in REMINDER_TOOL_NAMES: blocks = data.get("projects") or []
prefix = "📅" total_stories = data.get("total_stories", 0)
else: total_tasks = data.get("total_tasks", 0)
prefix = "📋"
return f"{prefix} {data['error']}" if not blocks or (total_stories == 0 and total_tasks == 0):
slug = blocks[0].get("slug") if len(blocks) == 1 else None
if tool_name == "reset_pomodoro_cycle": if slug:
cycle = data.get("cycle", data) return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga."
return ( return "📋 Открытых задач в Taiga не найдено."
"⏱ **Цикл помидоро сброшен** · "
f"прогресс: {cycle.get('completed_work_sessions', 0)}/" lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"]
f"{cycle.get('sessions_until_long_break', 4)}" for block in blocks:
) stories = block.get("stories") or []
tasks = block.get("tasks") or []
if tool_name in ( if not stories and not tasks:
"get_pomodoro_status", continue
"start_pomodoro", lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):")
"start_work", for s in stories:
"start_short_break", lines.append(f"- story #{s.get('ref')} {s.get('subject')}")
"start_long_break", for t in tasks:
"stop_pomodoro", lines.append(f"- task #{t.get('ref')} {t.get('subject')}")
"skip_pomodoro_phase", return "\n".join(lines)
):
return _format_status_notice(data)
def _format_status_notice(data: dict[str, Any]) -> str:
if tool_name == "get_pomodoro_history": status = data.get("status", "idle")
return _format_history_notice(data) phase = data.get("phase", PHASE_WORK)
phase_label = PHASE_LABELS.get(phase, phase)
if tool_name == "create_work_item": task = data.get("task_note") or "без описания"
return _format_work_item_notice(data) remaining = data.get("remaining_seconds", 0)
duration = data.get("duration_min", 25)
if tool_name == "list_work_items": cycle = data.get("cycle", {})
return _format_work_items_list_notice(data) cycle_info = ""
if cycle:
if tool_name == "list_taiga_tasks": cycle_info = (
return _format_taiga_tasks_notice(data) f" · цикл {cycle.get('completed_work_sessions', 0)}/"
f"{cycle.get('sessions_until_long_break', 4)}"
if tool_name == "sync_taiga_projects": )
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
if status == "idle":
if tool_name == "list_taiga_projects": return f"⏱ **Помидоро:** таймер не запущен{cycle_info}."
if not isinstance(data, list) or not data:
return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects." if status == "running":
lines = ["📋 **Проекты:**"] return (
for p in data: f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** "
gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else "" f"из {duration} мин · _{task}_{cycle_info}"
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}") )
return "\n".join(lines)
if status == "paused":
if tool_name == "remember_fact" and data.get("ok"): elapsed = data.get("elapsed_seconds", 0)
action = "обновлено" if data.get("action") == "updated" else "сохранено" return (
return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}" f" **{phase_label} на паузе** · прошло {_format_time(elapsed)} "
f"из {duration} мин · _{task}_{cycle_info}"
if tool_name == "forget_memory" and data.get("ok"): )
return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}"
if status == "completed":
if tool_name == "update_profile" and data.get("ok"): return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_"
profile = data.get("profile") or {}
parts = [f"{k}={v}" for k, v in profile.items() if v] if status == "cancelled":
return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}" return f" **{phase_label} отменена** · _{task}_"
if tool_name == "update_session_summary" and data.get("ok"): return f"⏱ Помидоро: {status}"
return "🧠 **Сводка чата сохранена**"
if tool_name == "log_meal" and data.get("ok"): def _format_history_notice(data: Any) -> str:
meal = data.get("meal", {}) if not isinstance(data, list) or not data:
est = "" if meal.get("estimated") else "" return "⏱ **История помидоро** пуста."
return (
f"💪 **Приём пищи** · {meal.get('description')} · " lines = [" **История помидоро:**"]
f"{est}{meal.get('calories', 0):.0f} ккал " for item in data[:10]:
f"{meal.get('protein_g', 0):.0f}{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})" task = item.get("task_note") or "без описания"
) phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?"))
duration = item.get("duration_min", "?")
if tool_name == "log_water" and data.get("ok"): lines.append(f"- {phase}: {task} ({duration} мин)")
w = data.get("water", {})
return f"💪 **Вода** +{w.get('amount_ml')} мл" return "\n".join(lines)
if tool_name == "log_weight" and data.get("ok"):
m = data.get("metric", {}) def format_pomodoro_context(status: dict[str, Any]) -> str:
computed = data.get("computed") or {} notice = _format_status_notice(status)
headline = f"💪 **Вес** {m.get('weight_kg')} кг" cycle = status.get("cycle", {})
return _format_body_composition_notice(computed, headline=headline) extra = ""
if cycle:
if tool_name == "calc_body_composition" and isinstance(data, dict) and "error" not in data: extra = (
w = data.get("weight_kg") f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, "
headline = "💪 **Состав тела** (расчёт)" f"перерыв {cycle.get('short_break_min')} мин, "
if w is not None: f"длинный {cycle.get('long_break_min')} мин."
headline += f" · {w} кг" )
msg = _format_body_composition_notice(data, headline=headline) return f"[Актуальный статус помидоро]\n{notice}{extra}"
warnings = data.get("warnings") or []
if warnings:
msg += f" · {'; '.join(warnings[:2])}"
return msg
if tool_name == "log_workout" and data.get("ok"):
wo = data.get("workout", {})
return f"💪 **Тренировка** · {wo.get('title')}"
if tool_name == "set_fitness_profile" and data.get("ok"):
p = data.get("profile", {})
return (
f"💪 **Профиль** · {p.get('calorie_target')} ккал, "
f"вода {p.get('water_l')} л"
)
if tool_name == "set_fitness_reminder" and data.get("ok"):
r = data.get("reminder", {})
state = "вкл" if r.get("enabled") else "выкл"
return f"💪 **Напоминание {r.get('kind')}** · {state}"
if tool_name == "generate_image" and data.get("ok"):
return _format_image_generation_notice(data)
if tool_name == "create_shopping_list" and data.get("ok"):
lst = data.get("list") or {}
action = "создан" if data.get("created") else "уже был"
return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})"
if tool_name == "add_shopping_items" and data.get("ok"):
added = data.get("added") or []
names = ", ".join(i.get("text", "") for i in added[:5])
extra = f" +{len(added) - 5}" if len(added) > 5 else ""
return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}"
if tool_name == "check_shopping_item" and data.get("ok"):
item = data.get("item") or {}
state = "куплено" if item.get("checked") else "снята отметка"
return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}"
if tool_name == "remove_shopping_item" and data.get("ok"):
removed = data.get("removed") or {}
return f"🛒 **Удалено** · {removed.get('text')}"
if tool_name == "delete_shopping_list" and data.get("ok"):
return f"🛒 **Список удалён** · «{data.get('name')}»"
if tool_name == "create_reminder" and data.get("ok"):
r = data.get("reminder") or {}
rec = r.get("recurrence", "none")
rec_label = f" · повтор {rec}" if rec and rec != "none" else ""
return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}"
if tool_name == "update_reminder" and data.get("ok"):
r = data.get("reminder") or {}
return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}"
if tool_name == "delete_reminder" and data.get("ok"):
return f"📅 **Напоминание удалено** · «{data.get('title')}»"
if tool_name == "complete_reminder" and data.get("ok"):
r = data.get("reminder") or {}
return f"📅 **Готово** · {r.get('title')}"
return None
def _format_work_item_notice(data: dict[str, Any]) -> str | None:
if data.get("error"):
return f"📋 {data['error']}"
if not data.get("ok"):
return None
taiga = data.get("taiga", {})
gitea = data.get("gitea", {})
lines = [
"📋 **Создано:**",
f"- Taiga: #{taiga.get('ref')}{taiga.get('subject')}",
f"- URL: {taiga.get('url')}",
]
if gitea.get("url"):
lines.append(f"- Gitea: {gitea.get('url')}")
if data.get("branch"):
lines.append(f"- Ветка: `{data['branch']}`")
subtasks = data.get("subtasks") or []
if subtasks:
lines.append("**Подзадачи:**")
for t in subtasks:
lines.append(f"- #{t.get('ref')} {t.get('subject')}")
return "\n".join(lines)
def _format_work_items_list_notice(data: Any) -> str | None:
if not isinstance(data, list) or not data:
return "📋 Локальных work items (созданных ассистентом) нет."
lines = ["📋 **Work items ассистента:**"]
for item in data[:15]:
lines.append(
f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} "
f"({item.get('taiga_slug')})"
)
return "\n".join(lines)
def _format_taiga_tasks_notice(data: Any) -> str | None:
if not isinstance(data, dict):
return None
if data.get("error"):
return f"📋 {data['error']}"
blocks = data.get("projects") or []
total_stories = data.get("total_stories", 0)
total_tasks = data.get("total_tasks", 0)
if not blocks or (total_stories == 0 and total_tasks == 0):
slug = blocks[0].get("slug") if len(blocks) == 1 else None
if slug:
return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga."
return "📋 Открытых задач в Taiga не найдено."
lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"]
for block in blocks:
stories = block.get("stories") or []
tasks = block.get("tasks") or []
if not stories and not tasks:
continue
lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):")
for s in stories:
lines.append(f"- story #{s.get('ref')} {s.get('subject')}")
for t in tasks:
lines.append(f"- task #{t.get('ref')} {t.get('subject')}")
return "\n".join(lines)
def _format_status_notice(data: dict[str, Any]) -> str:
status = data.get("status", "idle")
phase = data.get("phase", PHASE_WORK)
phase_label = PHASE_LABELS.get(phase, phase)
task = data.get("task_note") or "без описания"
remaining = data.get("remaining_seconds", 0)
duration = data.get("duration_min", 25)
cycle = data.get("cycle", {})
cycle_info = ""
if cycle:
cycle_info = (
f" · цикл {cycle.get('completed_work_sessions', 0)}/"
f"{cycle.get('sessions_until_long_break', 4)}"
)
if status == "idle":
return f"⏱ **Помидоро:** таймер не запущен{cycle_info}."
if status == "running":
return (
f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** "
f"из {duration} мин · _{task}_{cycle_info}"
)
if status == "paused":
elapsed = data.get("elapsed_seconds", 0)
return (
f"⏱ **{phase_label} на паузе** · прошло {_format_time(elapsed)} "
f"из {duration} мин · _{task}_{cycle_info}"
)
if status == "completed":
return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_"
if status == "cancelled":
return f"⏱ **{phase_label} отменена** · _{task}_"
return f"⏱ Помидоро: {status}"
def _format_history_notice(data: Any) -> str:
if not isinstance(data, list) or not data:
return "⏱ **История помидоро** пуста."
lines = ["⏱ **История помидоро:**"]
for item in data[:10]:
task = item.get("task_note") or "без описания"
phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?"))
duration = item.get("duration_min", "?")
lines.append(f"- {phase}: {task} ({duration} мин)")
return "\n".join(lines)
def format_pomodoro_context(status: dict[str, Any]) -> str:
notice = _format_status_notice(status)
cycle = status.get("cycle", {})
extra = ""
if cycle:
extra = (
f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, "
f"перерыв {cycle.get('short_break_min')} мин, "
f"длинный {cycle.get('long_break_min')} мин."
)
return f"[Актуальный статус помидоро]\n{notice}{extra}"
+172 -578
View File
@@ -1,578 +1,172 @@
import asyncio import json
import json from collections.abc import AsyncIterator
import logging from typing import Any
import time
from collections.abc import AsyncIterator from sqlalchemy import select
from typing import Any from sqlalchemy.orm import Session
from sqlalchemy import select from app.character.service import CharacterService
from sqlalchemy.orm import Session from app.chat.notices import (
POMODORO_TOOL_NAMES,
from app.config import get_settings format_pomodoro_context,
from app.db.base import SessionLocal format_tool_notice,
from app.character.service import CharacterService )
from app.chat.history import sanitize_openai_messages, strip_historical_reasoning from app.projects.context import format_projects_context, get_projects_snapshot
from app.chat.notice_inbox import DISPLAY_ONLY_ROLES from app.db.models import ChatSession, Message
from app.chat.notices import ( from app.llm.client import LLMClient
POMODORO_TOOL_NAMES, from app.pomodoro.service import PomodoroService
format_pomodoro_context, from app.tools.registry import TOOL_DEFINITIONS, execute_tool
format_tool_notice,
) MAX_TOOL_ROUNDS = 5
from app.fitness.context import format_fitness_context, get_fitness_snapshot
from app.homelab.context import format_datetime_context
from app.homelab.openmeteo import OpenMeteoClient, format_weather_snapshot class ChatService:
from app.memory.context import ( def __init__(self, db: Session):
format_identity_hint, self.db = db
format_memory_context, self.llm = LLMClient()
get_memory_snapshot, self.character = CharacterService()
)
from app.memory.extract import extract_after_turn def list_sessions(self) -> list[ChatSession]:
from app.projects.context import format_projects_context, get_projects_snapshot stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
from app.reminders_scoped.context import format_reminders_context, get_reminders_snapshot return list(self.db.scalars(stmt).all())
from app.shopping.context import format_shopping_context, get_shopping_snapshot
from app.db.models import ChatSession, Message def get_session(self, session_id: int) -> ChatSession | None:
from app.llm.client import LLMClient return self.db.get(ChatSession, session_id)
from app.pomodoro.service import PomodoroService
from app.tools.registry import TOOL_DEFINITIONS, execute_tool def create_session(self, title: str = "Новый чат") -> ChatSession:
from app.vision.analyze import format_vision_turn_hint session = ChatSession(title=title)
self.db.add(session)
MAX_TOOL_ROUNDS = 5 self.db.commit()
MAX_HISTORY_MESSAGES = 40 self.db.refresh(session)
_DOMAIN_CACHE: dict[str, tuple[float, str]] = {} return session
_DOMAIN_TTL_SEC = 60.0
def delete_session(self, session_id: int) -> bool:
_DOMAIN_KEYWORDS: dict[str, tuple[str, ...]] = { session = self.get_session(session_id)
"fitness": ("фитнес", "тренир", "калори", "еда", "вода", "вес", "workout", "meal", "белок", "жир"), if not session:
"shopping": ("покуп", "магазин", "список", "shopping", "корзин"), return False
"reminders": ("напомин", "календар", "событи", "дедлайн", "встреч", "план"), self.db.delete(session)
"projects": ("taiga", "gitea", "задач", "проект", "git", "issue", "коммит", "ветк"), self.db.commit()
"weather": ( return True
"погод", "дожд", "снег", "ветер", "температур", "градус", "мороз", "жар",
"на улице", "одеть", "зонт", "прогноз", "завтра", "послезавтра", "выходн", def _build_system_prompt(self) -> str:
"weather", "rain", "forecast", "umbrella", "outside", status = PomodoroService(self.db).get_status()
), projects_snapshot = get_projects_snapshot(self.db)
} return (
f"{self.character.get_system_prompt()}\n\n"
logger = logging.getLogger(__name__) f"{format_pomodoro_context(status)}\n\n"
f"{format_projects_context(projects_snapshot)}"
)
def _build_messages_for_session(session_id: int, user_id: int) -> list[dict[str, Any]]:
db = SessionLocal() def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
try: messages: list[dict[str, Any]] = [
service = ChatService(db, user_id) {"role": "system", "content": self._build_system_prompt()}
session = service.get_session(session_id) ]
if not session: for msg in session.messages:
return [] if msg.role == "notice":
return service._build_messages(session) continue
finally:
db.close() content = msg.content or None
entry: dict[str, Any] = {"role": msg.role, "content": content}
if msg.tool_calls_json:
async def _extract_memory_background( entry["tool_calls"] = json.loads(msg.tool_calls_json)
session_id: int, if not content:
user_id: int, entry["content"] = None
user_text: str, if msg.role == "tool" and msg.tool_call_id:
assistant_text: str, entry["tool_call_id"] = msg.tool_call_id
) -> None: messages.append(entry)
db = SessionLocal() return messages
try:
await extract_after_turn(db, session_id, user_text, assistant_text, user_id=user_id) def _save_message(
except Exception as exc: self,
logger.warning("Background memory extraction failed: %s", exc) session_id: int,
finally: role: str,
db.close() content: str = "",
tool_calls: list[dict[str, Any]] | None = None,
tool_call_id: str | None = None,
class ChatService: ) -> Message:
def __init__(self, db: Session, user_id: int): message = Message(
self.db = db session_id=session_id,
self.user_id = user_id role=role,
self.llm = LLMClient() content=content,
self.character = CharacterService(db, user_id) tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None,
tool_call_id=tool_call_id,
def list_sessions(self) -> list[ChatSession]: )
stmt = select(ChatSession).where(ChatSession.user_id == self.user_id).order_by(ChatSession.updated_at.desc()) self.db.add(message)
return list(self.db.scalars(stmt).all()) session = self.get_session(session_id)
if session and role == "user" and session.title == "Новый чат" and content:
def get_session(self, session_id: int) -> ChatSession | None: session.title = content[:60] + ("..." if len(content) > 60 else "")
session = self.db.get(ChatSession, session_id) self.db.commit()
if session and session.user_id != self.user_id: self.db.refresh(message)
return None return message
return session
async def stream_response(self, session_id: int, user_text: str) -> AsyncIterator[str]:
def list_messages( session = self.get_session(session_id)
self, if not session:
session_id: int, yield self._sse("error", {"message": "Session not found"})
limit: int = 30, return
before_id: int | None = None,
after_id: int | None = None, self._save_message(session_id, "user", user_text)
) -> tuple[list[Message], bool]: messages = self._build_messages(session)
if not self.get_session(session_id):
return [], False for _ in range(MAX_TOOL_ROUNDS):
content_parts: list[str] = []
if after_id is not None: tool_calls: list[dict[str, Any]] = []
stmt = (
select(Message) async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
.where(Message.session_id == session_id, Message.id > after_id) if event["type"] == "content":
.order_by(Message.created_at.asc()) content_parts.append(event["content"])
.limit(limit + 1) yield self._sse("token", {"content": event["content"]})
) elif event["type"] == "tool_calls":
rows = list(self.db.scalars(stmt).all()) tool_calls = event["tool_calls"]
has_more = len(rows) > limit
return rows[:limit], has_more if tool_calls:
assistant_msg: dict[str, Any] = {
stmt = select(Message).where(Message.session_id == session_id) "role": "assistant",
"content": "".join(content_parts) or None,
if before_id is not None: "tool_calls": tool_calls,
anchor = self.db.get(Message, before_id) }
if anchor is None or anchor.session_id != session_id: messages.append(assistant_msg)
return [], False self._save_message(
stmt = stmt.where(Message.created_at < anchor.created_at) session_id,
"assistant",
stmt = stmt.order_by(Message.created_at.desc()).limit(limit + 1) "".join(content_parts),
rows = list(self.db.scalars(stmt).all()) tool_calls=tool_calls,
has_more = len(rows) > limit )
page = rows[:limit]
page.reverse() for tool_call in tool_calls:
return page, has_more fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
def create_session(self, title: str = "Новый чат") -> ChatSession: result = await execute_tool(self.db, fn["name"], args)
session = ChatSession(user_id=self.user_id, title=title) tool_message = {
self.db.add(session) "role": "tool",
self.db.commit() "tool_call_id": tool_call["id"],
self.db.refresh(session) "content": result,
return session }
messages.append(tool_message)
def delete_session(self, session_id: int) -> bool: self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
session = self.get_session(session_id)
if not session: notice = format_tool_notice(fn["name"], result)
return False if notice:
self.db.delete(session) self._save_message(session_id, "notice", notice)
self.db.commit() yield self._sse("notice", {"content": notice})
return True
if fn["name"] in POMODORO_TOOL_NAMES:
def _cached_domain(self, key: str, loader, formatter) -> str: yield self._sse(
now = time.monotonic() "pomodoro",
hit = _DOMAIN_CACHE.get(f"{self.user_id}:{key}") {"name": fn["name"], "result": json.loads(result)},
if hit and now < hit[0]: )
return hit[1]
rendered = formatter(loader()) continue
_DOMAIN_CACHE[f"{self.user_id}:{key}"] = (now + _DOMAIN_TTL_SEC, rendered)
return rendered final_content = "".join(content_parts)
if final_content:
def _domain_relevant(self, key: str, user_query: str) -> bool: self._save_message(session_id, "assistant", final_content)
query = user_query.strip().lower()
if not query: yield self._sse("done", {})
return False return
keywords = _DOMAIN_KEYWORDS.get(key, ())
return any(kw in query for kw in keywords) yield self._sse("error", {"message": "Too many tool call rounds"})
def _optional_domain( @staticmethod
self, def _sse(event: str, data: dict[str, Any]) -> str:
key: str, return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
user_query: str,
loader,
formatter,
) -> str:
if not self._domain_relevant(key, user_query):
return ""
return self._cached_domain(key, loader, formatter)
def _build_system_prompt(self, session_id: int | None = None, user_query: str = "") -> str:
status = PomodoroService(self.db, self.user_id).get_status()
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session_id, query=user_query)
fitness_snapshot = get_fitness_snapshot(self.db, self.user_id)
shopping_snapshot = get_shopping_snapshot(self.db, self.user_id)
reminders_snapshot = get_reminders_snapshot(self.db, self.user_id)
projects_snapshot = get_projects_snapshot(self.db, self.user_id)
parts = [
self.character.get_system_prompt(),
format_datetime_context(self.db, self.user_id),
format_memory_context(memory_snapshot),
self._optional_domain("fitness", user_query, lambda: fitness_snapshot, format_fitness_context),
self._optional_domain("shopping", user_query, lambda: shopping_snapshot, format_shopping_context),
self._optional_domain("reminders", user_query, lambda: reminders_snapshot, format_reminders_context),
self._optional_domain(
"weather",
user_query,
lambda: OpenMeteoClient().fetch_forecast(hours_ahead=6, days_ahead=7),
lambda snap: format_weather_snapshot(snap, include_daily=True),
),
format_pomodoro_context(status),
self._optional_domain("projects", user_query, lambda: projects_snapshot, format_projects_context),
]
return "\n\n".join(part for part in parts if part.strip())
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
last_user = next((m.content for m in reversed(all_chat) if m.role == "user"), "")
system_prompt = self._build_system_prompt(session.id, user_query=last_user)
if last_user:
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session.id, query=last_user)
identity_hint = format_identity_hint(memory_snapshot, last_user)
if identity_hint:
system_prompt += f"\n\n{identity_hint}"
vision_hint = format_vision_turn_hint(last_user)
if vision_hint:
system_prompt += f"\n\n{vision_hint}"
if len(all_chat) > MAX_HISTORY_MESSAGES:
system_prompt += (
f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} "
f"из {len(all_chat)} сообщений. Раннее — в сводке сессии, если сохранена.]"
)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt}
]
chat_messages = all_chat[-MAX_HISTORY_MESSAGES:] if len(all_chat) > MAX_HISTORY_MESSAGES else all_chat
for msg in chat_messages:
content = msg.content or None
entry: dict[str, Any] = {"role": msg.role, "content": content}
if msg.tool_calls_json:
entry["tool_calls"] = json.loads(msg.tool_calls_json)
if not content:
entry["content"] = None
reasoning_data = LLMClient.deserialize_reasoning(msg.reasoning_json)
if reasoning_data:
LLMClient.attach_reasoning_to_message(
entry,
reasoning=reasoning_data.get("reasoning", ""),
reasoning_details=reasoning_data.get("reasoning_details"),
)
if msg.role == "tool" and msg.tool_call_id:
entry["tool_call_id"] = msg.tool_call_id
messages.append(entry)
messages = sanitize_openai_messages(messages)
messages = strip_historical_reasoning(messages)
return messages
def _save_message(
self,
session_id: int,
role: str,
content: str = "",
tool_calls: list[dict[str, Any]] | None = None,
tool_call_id: str | None = None,
reasoning_json: str | None = None,
) -> Message:
message = Message(
session_id=session_id,
role=role,
content=content,
tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None,
reasoning_json=reasoning_json,
tool_call_id=tool_call_id,
)
self.db.add(message)
session = self.get_session(session_id)
if session and role == "user" and session.title == "Новый чат" and content:
session.title = content[:60] + ("..." if len(content) > 60 else "")
self.db.commit()
self.db.refresh(message)
return message
def save_user_message(self, session_id: int, user_text: str) -> None:
self._save_message(session_id, "user", user_text)
async def _fallback_complete(
self,
messages: list[dict[str, Any]],
session_id: int,
) -> tuple[str, list[str], list[dict[str, Any]]]:
"""Нестриминговый запасной путь, если stream вернул пустоту."""
logger.info("chat session=%s fallback complete", session_id)
result: dict[str, Any] = {"content": "", "tool_calls": []}
for with_tools in (True, False):
result = await self.llm.complete(
messages,
tools=TOOL_DEFINITIONS if with_tools else None,
temperature=0.5,
visible_reply=True,
)
if (result.get("content") or "").strip() or result.get("tool_calls"):
break
tool_calls = result.get("tool_calls") or []
content = (result.get("content") or "").strip()
notices: list[str] = []
pomodoro_events: list[dict[str, Any]] = []
if tool_calls:
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": content or None,
"tool_calls": tool_calls,
}
messages.append(assistant_msg)
self._save_message(
session_id,
"assistant",
content,
tool_calls=tool_calls,
)
for tool_call in tool_calls:
fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
tool_result = await execute_tool(
self.db, fn["name"], args, session_id=session_id, user_id=self.user_id
)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call["id"],
"content": tool_result,
}
)
self._save_message(
session_id,
"tool",
tool_result,
tool_call_id=tool_call["id"],
)
notice = format_tool_notice(fn["name"], tool_result)
if notice:
self._save_message(session_id, "notice", notice)
notices.append(notice)
if fn["name"] in POMODORO_TOOL_NAMES:
pomodoro_events.append(
{"name": fn["name"], "result": json.loads(tool_result)}
)
followup = await self.llm.complete(
messages,
tools=None,
temperature=0.4,
visible_reply=True,
)
return (followup.get("content") or "").strip(), notices, pomodoro_events
return content, notices, pomodoro_events
def context_preview(self, session_id: int, query: str | None = None) -> dict[str, Any]:
session = self.get_session(session_id)
if not session:
return {"ok": False, "error": "Session not found"}
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
last_user = query or next((m.content for m in reversed(all_chat) if m.role == "user"), "")
system_prompt = self._build_system_prompt(session_id, user_query=last_user)
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session_id, query=last_user)
return {
"ok": True,
"session_id": session_id,
"query": last_user,
"system_prompt_chars": len(system_prompt),
"memory_facts": len(memory_snapshot.get("facts") or []),
"memory_total_facts": memory_snapshot.get("total_facts", 0),
"system_prompt_preview": system_prompt[:4000],
}
async def stream_response(
self,
session_id: int,
user_text: str,
*,
user_message_saved: bool = False,
) -> AsyncIterator[str]:
session = self.get_session(session_id)
if not session:
yield self._sse("error", {"message": "Session not found"})
return
if not user_message_saved:
self._save_message(session_id, "user", user_text)
yield self._sse("status", {"phase": "preparing"})
t0 = time.monotonic()
messages = await asyncio.to_thread(_build_messages_for_session, session_id, self.user_id)
prepare_sec = time.monotonic() - t0
if not messages:
yield self._sse("error", {"message": "Session not found"})
return
yield self._sse("status", {"phase": "generating"})
streamed_reply_parts: list[str] = []
all_tool_notices: list[str] = []
tools_executed = 0
tool_round = 0
for _ in range(MAX_TOOL_ROUNDS):
tool_round += 1
t_round = time.monotonic()
content_parts: list[str] = []
tool_calls: list[dict[str, Any]] = []
reasoning = ""
reasoning_details: list[Any] | None = None
finish_reason = ""
# После tool-раунда стримим вживую; до tools — буфер (иначе текст «переписывает» notice).
stream_live = tools_executed > 0
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
if event["type"] == "content":
content_parts.append(event["content"])
if stream_live:
yield self._sse("token", {"content": event["content"]})
elif event["type"] == "reasoning":
reasoning = event.get("reasoning", "") or reasoning
if event.get("reasoning_details"):
reasoning_details = event["reasoning_details"]
elif event["type"] == "error":
logger.warning(
"chat session=%s llm_error round=%d prepare=%.2fs: %s",
session_id,
tool_round,
prepare_sec,
event.get("content"),
)
yield self._sse("error", {"message": event.get("content", "LLM error")})
return
elif event["type"] == "tool_calls":
tool_calls = event["tool_calls"]
elif event["type"] == "done":
finish_reason = event.get("finish_reason", "")
logger.info(
"chat session=%s round=%d prepare=%.2fs llm=%.2fs "
"content_len=%d tool_calls=%d finish_reason=%s reasoning_len=%d",
session_id,
tool_round,
prepare_sec,
time.monotonic() - t_round,
len("".join(content_parts)),
len(tool_calls),
finish_reason,
len(reasoning),
)
if tool_calls:
round_text = "".join(content_parts)
if round_text.strip():
streamed_reply_parts.append(round_text)
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": round_text or None,
"tool_calls": tool_calls,
}
LLMClient.attach_reasoning_to_message(
assistant_msg,
reasoning=reasoning,
reasoning_details=reasoning_details,
)
reasoning_json = LLMClient.serialize_reasoning(
reasoning=reasoning,
reasoning_details=reasoning_details,
)
messages.append(assistant_msg)
self._save_message(
session_id,
"assistant",
round_text,
tool_calls=tool_calls,
reasoning_json=reasoning_json,
)
round_notices: list[str] = []
for tool_call in tool_calls:
fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
result = await execute_tool(
self.db, fn["name"], args, session_id=session_id, user_id=self.user_id
)
tools_executed += 1
tool_message = {
"role": "tool",
"tool_call_id": tool_call["id"],
"content": result,
}
messages.append(tool_message)
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
notice = format_tool_notice(fn["name"], result)
if notice:
self._save_message(session_id, "notice", notice)
round_notices.append(notice)
all_tool_notices.append(notice)
if fn["name"] in POMODORO_TOOL_NAMES:
yield self._sse(
"pomodoro",
{"name": fn["name"], "result": json.loads(result)},
)
yield self._sse("status", {"phase": "tools"})
for notice in round_notices:
yield self._sse("notice", {"content": notice})
continue
if content_parts and not stream_live:
for part in content_parts:
yield self._sse("token", {"content": part})
final_content = "".join(content_parts).strip()
if not final_content and streamed_reply_parts and tools_executed == 0:
final_content = "".join(streamed_reply_parts).strip()
if not final_content and reasoning:
final_content = reasoning.strip()
if not final_content and tools_executed:
retry = await self.llm.complete(
messages,
tools=None,
temperature=0.4,
visible_reply=True,
)
final_content = (retry.get("content") or "").strip()
if final_content:
yield self._sse("token", {"content": final_content})
# Notices уже в чате как role=notice — не дублируем в assistant.
if not final_content:
final_content, fb_notices, fb_pomodoro = await self._fallback_complete(
messages, session_id
)
if final_content:
yield self._sse("token", {"content": final_content})
for notice in fb_notices:
yield self._sse("notice", {"content": notice})
for event in fb_pomodoro:
yield self._sse("pomodoro", event)
if not final_content:
logger.warning(
"chat session=%s empty_reply tools=%d rounds=%d finish_reason=%s",
session_id,
tools_executed,
tool_round,
finish_reason,
)
yield self._sse(
"error",
{
"message": (
"Модель не вернула ответ (finish_reason="
f"{finish_reason or 'unknown'}). "
"Попробуй новый чат или проверь OPENROUTER_MODEL."
),
},
)
return
self._save_message(session_id, "assistant", final_content)
logger.info(
"chat session=%s done tools=%d reply_len=%d total=%.2fs",
session_id,
tools_executed,
len(final_content),
time.monotonic() - t0,
)
yield self._sse("done", {})
if get_settings().memory_auto_extract:
asyncio.create_task(
_extract_memory_background(session_id, self.user_id, user_text, final_content)
)
return
yield self._sse("error", {"message": "Too many tool call rounds"})
@staticmethod
def _sse(event: str, data: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
-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"
+59 -163
View File
@@ -1,163 +1,59 @@
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings, SettingsConfigDict
DEPRECATED_VISION_MODELS: dict[str, str] = { class Settings(BaseSettings):
"google/gemini-2.0-flash-lite-001": "google/gemini-2.5-flash-lite", model_config = SettingsConfigDict(
"google/gemini-2.0-flash-lite": "google/gemini-2.5-flash-lite", env_file=(".env", "../.env"),
} env_file_encoding="utf-8",
extra="ignore",
)
def resolve_vision_model(model: str) -> str:
stripped = model.strip() host: str = "0.0.0.0"
return DEPRECATED_VISION_MODELS.get(stripped, stripped) port: int = 8080
openrouter_api_key: str = ""
class Settings(BaseSettings): openrouter_model: str = "deepseek/deepseek-chat"
model_config = SettingsConfigDict( openrouter_base_url: str = "https://openrouter.ai/api/v1"
env_file=(".env", "../.env"),
env_file_encoding="utf-8", database_url: str = "sqlite:///./data/assistant.db"
extra="ignore", cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
) system_prompt_path: str = "./prompts/assistant.md"
host: str = "0.0.0.0" # Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
port: int = 8080 taiga_base_url: str = "http://host.docker.internal:9000"
taiga_username: str = ""
openrouter_api_key: str = "" taiga_password: str = ""
openrouter_model: str = "deepseek/deepseek-chat" taiga_public_url: str = "https://taiga.grigowashere.ru"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
# Отдельная модель для JSON-задач (память, фитнес). Пусто = та же, что OPENROUTER_MODEL. gitea_base_url: str = "http://host.docker.internal:3000"
memory_extract_model: str = "" gitea_token: str = ""
# Некоторые модели (reasoning / без function calling) — выключить tools. gitea_public_url: str = "https://git.grigowashere.ru"
openrouter_tools_enabled: bool = True gitea_webhook_secret: str = ""
# DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking.
openrouter_reasoning_effort: str = "none" repos_dir: str = "/data/repos"
openrouter_vision_model: str = "google/gemini-2.5-flash-lite"
vision_max_edge_px: int = 1280 @property
vision_jpeg_quality: int = 85 def cors_origins_list(self) -> list[str]:
vision_debug_enabled: bool = True return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
vision_max_images: int = 8
uploads_dir: str = "./data/uploads" @property
def taiga_configured(self) -> bool:
@field_validator("openrouter_vision_model") return bool(self.taiga_username and self.taiga_password)
@classmethod
def migrate_vision_model(cls, value: str) -> str: @property
return resolve_vision_model(value) def gitea_configured(self) -> bool:
return bool(self.gitea_token)
database_url: str = "sqlite:///./data/assistant.db"
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000" def load_system_prompt(self) -> str:
system_prompt_path: str = "./prompts/assistant.md" path = Path(self.system_prompt_path)
memory_auto_extract: bool = True if path.is_file():
return path.read_text(encoding="utf-8")
default_user_username: str = "owner" return "Ты домашний ИИ-ассистент. Общайся на русском."
default_user_display_name: str = ""
default_api_token: str = ""
auth_required: bool = True @lru_cache
def get_settings() -> Settings:
qdrant_url: str = "http://qdrant:6333" return Settings()
embedding_model: str = "openai/text-embedding-3-small"
rag_enabled: bool = False
rag_top_k: int = 8
memory_facts_in_context: int = 8
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
taiga_base_url: str = "http://host.docker.internal:9000"
taiga_username: str = ""
taiga_password: str = ""
taiga_public_url: str = "https://taiga.grigowashere.ru"
gitea_base_url: str = "http://host.docker.internal:3000"
gitea_token: str = ""
gitea_public_url: str = "https://git.grigowashere.ru"
gitea_webhook_secret: str = ""
repos_dir: str = "/data/repos"
wger_base_url: str = "https://wger.de/api/v2"
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
fitness_reminders_enabled: bool = True
reminders_enabled: bool = True
openmeteo_base_url: str = "http://192.168.1.109:8085"
weather_lat: float = 59.9343
weather_lon: float = 30.3351
weather_location_name: str = "Санкт-Петербург"
weather_cache_sec: int = 300
weather_forecast_days: int = 7
openmeteo_fallback_url: str = "https://api.open-meteo.com"
openmeteo_fallback_on_partial: bool = True
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()
-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()
-6
View File
@@ -21,12 +21,6 @@ def run_migrations() -> None:
) )
) )
if "messages" in inspector.get_table_names():
columns = {col["name"] for col in inspector.get_columns("messages")}
with engine.begin() as conn:
if "reasoning_json" not in columns:
conn.execute(text("ALTER TABLE messages ADD COLUMN reasoning_json TEXT"))
if "pomodoro_cycles" not in inspector.get_table_names(): if "pomodoro_cycles" not in inspector.get_table_names():
return return
-223
View File
@@ -1,223 +0,0 @@
import logging
from sqlalchemy import inspect, select, text
from sqlalchemy.orm import Session
from app.db.base import engine
from app.db.models import FitnessProfile
from app.fitness.calculators import DEFAULT_NEAT_KCAL, compute_targets, macro_targets
logger = logging.getLogger(__name__)
TDEE_V2_BACKFILL = "fitness_tdee_v2_backfill"
MACROS_GKG_BACKFILL = "fitness_macros_gkg_v1"
def _table_exists(table: str) -> bool:
return table in inspect(engine).get_table_names()
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
inspector = inspect(engine)
if table not in inspector.get_table_names():
return
columns = {col["name"] for col in inspector.get_columns(table)}
if column in columns:
return
with engine.begin() as conn:
conn.execute(text(ddl))
def _ensure_schema_migrations_table() -> None:
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE IF NOT EXISTS _schema_migrations ("
"name TEXT PRIMARY KEY, "
"applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
)
)
def _migration_applied(name: str) -> bool:
_ensure_schema_migrations_table()
with engine.begin() as conn:
row = conn.execute(
text("SELECT 1 FROM _schema_migrations WHERE name = :name"),
{"name": name},
).fetchone()
return row is not None
def _mark_migration_applied(name: str) -> None:
with engine.begin() as conn:
conn.execute(
text("INSERT INTO _schema_migrations (name) VALUES (:name)"),
{"name": name},
)
def _profile_targets(row: FitnessProfile) -> dict[str, float]:
neat = row.neat_base_kcal if row.neat_base_kcal is not None else DEFAULT_NEAT_KCAL
return compute_targets(
{
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"goal": row.goal,
"neat_base_kcal": neat,
}
)
def backfill_tdee_targets(*, force: bool = False) -> int:
"""Recalculate stored calorie/macro targets for all profiles (PAL → BMR+NEAT)."""
if not _table_exists("fitness_profiles"):
return 0
_ensure_schema_migrations_table()
if not force and _migration_applied(TDEE_V2_BACKFILL):
return 0
with engine.begin() as conn:
conn.execute(
text(
"UPDATE fitness_profiles "
"SET neat_base_kcal = :neat "
"WHERE neat_base_kcal IS NULL"
),
{"neat": DEFAULT_NEAT_KCAL},
)
updated = 0
with Session(engine) as db:
rows = db.scalars(select(FitnessProfile)).all()
for row in rows:
if row.neat_base_kcal is None:
row.neat_base_kcal = DEFAULT_NEAT_KCAL
targets = _profile_targets(row)
row.calorie_target = targets["calorie_target"]
row.protein_g = targets["protein_g"]
row.fat_g = targets["fat_g"]
row.carbs_g = targets["carbs_g"]
row.water_l = targets["water_l"]
updated += 1
db.commit()
if not force or not _migration_applied(TDEE_V2_BACKFILL):
_mark_migration_applied(TDEE_V2_BACKFILL)
logger.info("TDEE v2 backfill: recalculated %s fitness profile(s)", updated)
return updated
def backfill_macros_gkg(*, force: bool = False) -> int:
"""Recalculate stored BJU from weight (protein/fat g/kg, carbs = remainder)."""
if not _table_exists("fitness_profiles"):
return 0
_ensure_schema_migrations_table()
if not force and _migration_applied(MACROS_GKG_BACKFILL):
return 0
updated = 0
with Session(engine) as db:
rows = db.scalars(select(FitnessProfile)).all()
for row in rows:
macros = macro_targets(row.calorie_target, row.weight_kg, row.goal)
row.protein_g = macros["protein_g"]
row.fat_g = macros["fat_g"]
row.carbs_g = macros["carbs_g"]
updated += 1
db.commit()
_mark_migration_applied(MACROS_GKG_BACKFILL)
logger.info("Macros g/kg backfill: updated %s fitness profile(s)", updated)
return updated
def run_fitness_migrations() -> None:
inspector = inspect(engine)
if "fitness_profiles" in inspector.get_table_names():
_add_column_if_missing(
"fitness_profiles",
"baseline_steps",
"ALTER TABLE fitness_profiles ADD COLUMN baseline_steps INTEGER",
)
_add_column_if_missing(
"fitness_profiles",
"baseline_workout_kcal",
"ALTER TABLE fitness_profiles ADD COLUMN baseline_workout_kcal FLOAT",
)
_add_column_if_missing(
"fitness_profiles",
"neat_base_kcal",
"ALTER TABLE fitness_profiles ADD COLUMN neat_base_kcal FLOAT DEFAULT 200.0",
)
if "workout_logs" in inspector.get_table_names():
_add_column_if_missing(
"workout_logs",
"active_calories",
"ALTER TABLE workout_logs ADD COLUMN active_calories FLOAT",
)
_add_column_if_missing(
"workout_logs",
"total_calories",
"ALTER TABLE workout_logs ADD COLUMN total_calories FLOAT",
)
_add_column_if_missing(
"workout_logs",
"steps",
"ALTER TABLE workout_logs ADD COLUMN steps INTEGER",
)
if "step_logs" not in inspector.get_table_names():
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE step_logs ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"steps INTEGER DEFAULT 0, "
"active_calories FLOAT, "
"source VARCHAR(32) DEFAULT 'manual', "
"notes TEXT DEFAULT ''"
")"
)
)
if "body_metrics" in inspector.get_table_names():
_add_column_if_missing(
"body_metrics",
"neck_cm",
"ALTER TABLE body_metrics ADD COLUMN neck_cm FLOAT",
)
_add_column_if_missing(
"body_metrics",
"hip_cm",
"ALTER TABLE body_metrics ADD COLUMN hip_cm FLOAT",
)
_add_column_if_missing(
"body_metrics",
"body_fat_method",
"ALTER TABLE body_metrics ADD COLUMN body_fat_method VARCHAR(16)",
)
_add_column_if_missing(
"body_metrics",
"whr",
"ALTER TABLE body_metrics ADD COLUMN whr FLOAT",
)
_add_column_if_missing(
"body_metrics",
"lbm_kg",
"ALTER TABLE body_metrics ADD COLUMN lbm_kg FLOAT",
)
_add_column_if_missing(
"body_metrics",
"ffmi",
"ALTER TABLE body_metrics ADD COLUMN ffmi FLOAT",
)
backfill_tdee_targets()
backfill_macros_gkg()
-249
View File
@@ -1,249 +0,0 @@
from __future__ import annotations
import json
import logging
import secrets
from pathlib import Path
from sqlalchemy import inspect, text
from app.auth.tokens import hash_token
from app.character.card import DEFAULT_CARD, normalize_card
from app.config import get_settings
from app.db.base import engine
logger = logging.getLogger(__name__)
TENANT_TABLES = (
"chat_sessions",
"user_profile",
"memory_facts",
"fitness_profiles",
"body_metrics",
"food_logs",
"water_logs",
"workout_logs",
"step_logs",
"fitness_reminders",
"shopping_lists",
"reminders",
"documents",
"pomodoro_cycles",
"pomodoro_sessions",
"project_bindings",
"work_items",
)
LEGACY_CARD_PATH = Path("./data/character.json")
def _table_exists(name: str) -> bool:
return name in inspect(engine).get_table_names()
def _columns(table: str) -> set[str]:
if not _table_exists(table):
return set()
return {col["name"] for col in inspect(engine).get_columns(table)}
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
if column in _columns(table):
return
with engine.begin() as conn:
conn.execute(text(ddl))
def _ensure_users_table() -> None:
if _table_exists("users"):
return
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE users ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"username VARCHAR(64) NOT NULL UNIQUE, "
"display_name VARCHAR(255) DEFAULT '', "
"api_token_hash VARCHAR(64) NOT NULL, "
"is_active BOOLEAN DEFAULT 1, "
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP"
")"
)
)
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_api_token_hash ON users (api_token_hash)"))
def _ensure_character_cards_table() -> None:
if _table_exists("character_cards"):
return
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE character_cards ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, "
"card_json TEXT DEFAULT '{}', "
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP"
")"
)
)
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_character_cards_user_id ON character_cards (user_id)"))
def _add_user_id_columns() -> None:
for table in TENANT_TABLES:
if not _table_exists(table):
continue
_add_column_if_missing(
table,
"user_id",
f"ALTER TABLE {table} ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE",
)
with engine.begin() as conn:
conn.execute(text(f"CREATE INDEX IF NOT EXISTS ix_{table}_user_id ON {table} (user_id)"))
def _ensure_default_user() -> tuple[int, str | None]:
settings = get_settings()
with engine.begin() as conn:
row = conn.execute(text("SELECT id FROM users ORDER BY id LIMIT 1")).fetchone()
if row:
return int(row[0]), None
username = settings.default_user_username or "owner"
display_name = settings.default_user_display_name or username
plain_token = (settings.default_api_token or "").strip()
generated = False
if not plain_token:
plain_token = secrets.token_urlsafe(32)
generated = True
token_hash = hash_token(plain_token)
conn.execute(
text(
"INSERT INTO users (id, username, display_name, api_token_hash, is_active) "
"VALUES (1, :username, :display_name, :token_hash, 1)"
),
{"username": username, "display_name": display_name, "token_hash": token_hash},
)
if generated:
logger.warning(
"DEFAULT_API_TOKEN not set — generated token for user '%s': %s",
username,
plain_token,
)
return 1, plain_token
return 1, None
def _backfill_user_id(default_user_id: int = 1) -> None:
with engine.begin() as conn:
for table in TENANT_TABLES:
if not _table_exists(table):
continue
conn.execute(
text(f"UPDATE {table} SET user_id = :uid WHERE user_id IS NULL"),
{"uid": default_user_id},
)
def _rebuild_shopping_unique() -> None:
if not _table_exists("shopping_lists"):
return
with engine.begin() as conn:
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_shopping_lists_user_name ON shopping_lists (user_id, name)"))
def _rebuild_project_bindings_unique() -> None:
if not _table_exists("project_bindings"):
return
with engine.begin() as conn:
conn.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_project_bindings_user_slug "
"ON project_bindings (user_id, taiga_slug)"
)
)
def _import_character_card(user_id: int) -> None:
with engine.begin() as conn:
existing = conn.execute(
text("SELECT id FROM character_cards WHERE user_id = :uid"),
{"uid": user_id},
).fetchone()
if existing:
return
card = normalize_card(DEFAULT_CARD)
if LEGACY_CARD_PATH.is_file():
try:
raw = json.loads(LEGACY_CARD_PATH.read_text(encoding="utf-8"))
card = normalize_card(raw)
except (json.JSONDecodeError, OSError):
pass
conn.execute(
text("INSERT INTO character_cards (user_id, card_json) VALUES (:uid, :json)"),
{"uid": user_id, "json": json.dumps(card, ensure_ascii=False)},
)
def _backfill_qdrant_user_id(default_user_id: int = 1) -> None:
settings = get_settings()
if not settings.rag_enabled:
return
try:
from app.rag.store import COLLECTION_DOC_CHUNKS, COLLECTION_FACTS, COLLECTION_SUMMARIES, _client
except Exception:
logger.exception("Qdrant backfill skipped")
return
try:
client = _client()
except Exception:
logger.warning('Qdrant unavailable, skipping user_id backfill')
return
for collection in (COLLECTION_FACTS, COLLECTION_SUMMARIES, COLLECTION_DOC_CHUNKS):
try:
if not client.collection_exists(collection):
continue
except Exception:
logger.warning('Qdrant unavailable for collection %s', collection)
continue
offset = None
while True:
points, offset = client.scroll(
collection_name=collection,
limit=100,
offset=offset,
with_payload=True,
with_vectors=False,
)
if not points:
break
missing = [point.id for point in points if (point.payload or {}).get("user_id") is None]
if missing:
client.set_payload(
collection_name=collection,
payload={"user_id": default_user_id},
points=missing,
)
if offset is None:
break
logger.info("Qdrant user_id backfill completed for user_id=%s", default_user_id)
def run_multi_user_migrations() -> str | None:
"""Returns newly generated API token if any."""
_ensure_users_table()
_ensure_character_cards_table()
_add_user_id_columns()
user_id, new_token = _ensure_default_user()
_backfill_user_id(user_id)
_rebuild_shopping_unique()
_rebuild_project_bindings_unique()
_import_character_card(user_id)
_backfill_qdrant_user_id(user_id)
return new_token
+112 -397
View File
@@ -1,397 +1,112 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base from app.db.base import Base
class User(Base): class ChatSession(Base):
__tablename__ = "users" __tablename__ = "chat_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True) title: Mapped[str] = mapped_column(String(255), default="Новый чат")
display_name: Mapped[str] = mapped_column(String(255), default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
api_token_hash: Mapped[str] = mapped_column(String(64), index=True) updated_at: Mapped[datetime] = mapped_column(
is_active: Mapped[bool] = mapped_column(Boolean, default=True) DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) )
messages: Mapped[list["Message"]] = relationship(
class CharacterCard(Base): back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
__tablename__ = "character_cards" )
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column( class Message(Base):
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True __tablename__ = "messages"
)
card_json: Mapped[str] = mapped_column(Text, default="{}") id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
updated_at: Mapped[datetime] = mapped_column( session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() 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)
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
class ChatSession(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__tablename__ = "chat_sessions"
session: Mapped["ChatSession"] = relationship(back_populates="messages")
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255), default="Новый чат") class PomodoroCycle(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) __tablename__ = "pomodoro_cycles"
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() 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)
messages: Mapped[list["Message"]] = relationship( long_break_min: Mapped[int] = mapped_column(Integer, default=15)
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at" 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)
class Message(Base): chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
__tablename__ = "messages" updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
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="") class PomodoroSession(Base):
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True) __tablename__ = "pomodoro_sessions"
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) status: Mapped[str] = mapped_column(String(32), default="idle")
phase: Mapped[str] = mapped_column(String(32), default="work")
session: Mapped["ChatSession"] = relationship(back_populates="messages") 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)
class PomodoroCycle(Base): completed: Mapped[bool] = mapped_column(Boolean, default=False)
__tablename__ = "pomodoro_cycles" completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
work_duration_min: Mapped[int] = mapped_column(Integer, default=25) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
short_break_min: Mapped[int] = mapped_column(Integer, default=5) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
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) class TaigaProject(Base):
task_note: Mapped[str] = mapped_column(Text, default="") __tablename__ = "taiga_projects"
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
updated_at: Mapped[datetime] = mapped_column( taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() 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 PomodoroSession(Base):
__tablename__ = "pomodoro_sessions" class ProjectBinding(Base):
__tablename__ = "project_bindings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
status: Mapped[str] = mapped_column(String(32), default="idle") taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
phase: Mapped[str] = mapped_column(String(32), default="work") gitea_owner: Mapped[str] = mapped_column(String(255), default="")
duration_min: Mapped[int] = mapped_column(Integer, default=25) gitea_repo: Mapped[str] = mapped_column(String(255), default="")
task_note: Mapped[str] = mapped_column(Text, default="") default_branch: Mapped[str] = mapped_column(String(64), default="main")
result: Mapped[str | None] = mapped_column(Text, nullable=True) updated_at: Mapped[datetime] = mapped_column(
completed: Mapped[bool] = mapped_column(Boolean, default=False) DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
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) class WorkItem(Base):
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) __tablename__ = "work_items"
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
class TaigaProject(Base): taiga_project_id: Mapped[int] = mapped_column(Integer)
__tablename__ = "taiga_projects" taiga_story_id: Mapped[int] = mapped_column(Integer)
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) gitea_owner: Mapped[str] = mapped_column(String(255), default="")
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True) gitea_repo: Mapped[str] = mapped_column(String(255), default="")
name: Mapped[str] = mapped_column(String(255)) gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True) suggested_branch: Mapped[str] = mapped_column(String(255), default="")
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) 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")
class ProjectBinding(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__tablename__ = "project_bindings" closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (UniqueConstraint("user_id", "taiga_slug", name="uq_project_bindings_user_slug"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
default_branch: Mapped[str] = mapped_column(String(64), default="main")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class UserProfile(Base):
__tablename__ = "user_profile"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
data_json: Mapped[str] = mapped_column(Text, default="{}")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class MemoryFact(Base):
__tablename__ = "memory_facts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
content: Mapped[str] = mapped_column(Text)
source: Mapped[str] = mapped_column(String(32), default="user")
session_id: Mapped[int | None] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
)
importance: Mapped[int] = mapped_column(Integer, default=3)
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class SessionSummary(Base):
__tablename__ = "session_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
)
summary: Mapped[str] = mapped_column(Text, default="")
message_count: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class FitnessProfile(Base):
__tablename__ = "fitness_profiles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
sex: Mapped[str] = mapped_column(String(16), default="male")
age: Mapped[int] = mapped_column(Integer, default=30)
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
goal: Mapped[str] = mapped_column(String(32), default="maintain")
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
neat_base_kcal: Mapped[float] = mapped_column(Float, default=200.0)
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
baseline_steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
baseline_workout_kcal: Mapped[float | None] = mapped_column(Float, nullable=True)
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
water_l: Mapped[float] = mapped_column(Float, default=2.5)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class BodyMetric(Base):
__tablename__ = "body_metrics"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
weight_kg: Mapped[float] = mapped_column(Float)
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
body_fat_method: Mapped[str | None] = mapped_column(String(16), nullable=True)
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
neck_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
hip_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
whr: Mapped[float | None] = mapped_column(Float, nullable=True)
lbm_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
ffmi: Mapped[float | None] = mapped_column(Float, nullable=True)
notes: Mapped[str] = mapped_column(Text, default="")
class FoodLog(Base):
__tablename__ = "food_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
description: Mapped[str] = mapped_column(Text, default="")
calories: Mapped[float] = mapped_column(Float, default=0)
protein_g: Mapped[float] = mapped_column(Float, default=0)
fat_g: Mapped[float] = mapped_column(Float, default=0)
carbs_g: Mapped[float] = mapped_column(Float, default=0)
source: Mapped[str] = mapped_column(String(32), default="llm")
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
class StepLog(Base):
__tablename__ = "step_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
steps: Mapped[int] = mapped_column(Integer, default=0)
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
source: Mapped[str] = mapped_column(String(32), default="manual")
notes: Mapped[str] = mapped_column(Text, default="")
class WaterLog(Base):
__tablename__ = "water_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
amount_ml: Mapped[int] = mapped_column(Integer)
class WorkoutLog(Base):
__tablename__ = "workout_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
notes: Mapped[str] = mapped_column(Text, default="")
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
total_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
class FitnessReminder(Base):
__tablename__ = "fitness_reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
kind: Mapped[str] = mapped_column(String(32))
hour: Mapped[int] = mapped_column(Integer, default=12)
minute: Mapped[int] = mapped_column(Integer, default=0)
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ShoppingList(Base):
__tablename__ = "shopping_lists"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_shopping_lists_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(255), index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
items: Mapped[list["ShoppingListItem"]] = relationship(
back_populates="shopping_list",
cascade="all, delete-orphan",
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
)
class ShoppingListItem(Base):
__tablename__ = "shopping_list_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
list_id: Mapped[int] = mapped_column(
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
)
text: Mapped[str] = mapped_column(String(500))
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
unit: Mapped[str] = mapped_column(String(64), default="")
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
class Reminder(Base):
__tablename__ = "reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255))
notes: Mapped[str] = mapped_column(Text, default="")
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
recurrence: Mapped[str] = mapped_column(String(16), default="none")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AssistantState(Base):
__tablename__ = "assistant_state"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[str] = mapped_column(Text, default="")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class WorkItem(Base):
__tablename__ = "work_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
taiga_project_id: Mapped[int] = mapped_column(Integer)
taiga_story_id: Mapped[int] = mapped_column(Integer)
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
raw_text: Mapped[str] = mapped_column(Text, default="")
title: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(32), default="open")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Document(Base):
__tablename__ = "documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255), default="")
filename: Mapped[str] = mapped_column(String(255), default="")
content_hash: Mapped[str] = mapped_column(String(64), default="", index=True)
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
chunks: Mapped[list["DocumentChunk"]] = relationship(
back_populates="document",
cascade="all, delete-orphan",
order_by="DocumentChunk.chunk_index",
)
class DocumentChunk(Base):
__tablename__ = "document_chunks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
document_id: Mapped[int] = mapped_column(
ForeignKey("documents.id", ondelete="CASCADE"), index=True
)
chunk_index: Mapped[int] = mapped_column(Integer, default=0)
content: Mapped[str] = mapped_column(Text, default="")
document: Mapped["Document"] = relationship(back_populates="chunks")
-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)
View File
-68
View File
@@ -1,68 +0,0 @@
from __future__ import annotations
from typing import Any
DEFAULT_MET = 5.0
MET_BY_KEYWORD: list[tuple[str, float]] = [
("триатлон", 10.0),
("марафон", 9.8),
("бег", 9.8),
("running", 9.8),
("run", 9.0),
("плаван", 8.0),
("swim", 8.0),
("велосипед", 7.5),
("cycling", 7.5),
("вел", 7.5),
("hiit", 8.0),
("кроссфит", 8.0),
("силов", 6.0),
("strength", 6.0),
("зал", 5.5),
("gym", 5.5),
("йога", 3.0),
("yoga", 3.0),
("ходьб", 3.5),
("walk", 3.5),
("прогул", 3.5),
]
def infer_met(workout: dict[str, Any]) -> float | None:
explicit = workout.get("met")
if explicit is not None:
return float(explicit)
activity_type = str(workout.get("activity_type") or "").lower()
title = str(workout.get("title") or "").lower()
notes = str(workout.get("notes") or "").lower()
haystack = f"{activity_type} {title} {notes}"
for keyword, met in MET_BY_KEYWORD:
if keyword in haystack:
return met
return None
def estimate_workout_active_kcal(workout: dict[str, Any], *, weight_kg: float) -> float:
active = workout.get("active_calories")
if active is not None:
return round(float(active), 1)
duration = workout.get("duration_min")
if not duration:
return 0.0
met = infer_met(workout)
if met is None:
return 0.0
hours = float(duration) / 60.0
return round(met * weight_kg * hours, 1)
def workouts_kcal_total(workouts: list[dict[str, Any]], *, weight_kg: float) -> float:
if not workouts:
return 0.0
return round(sum(estimate_workout_active_kcal(w, weight_kg=weight_kg) for w in workouts), 1)
-128
View File
@@ -1,128 +0,0 @@
import math
from typing import Any
def _is_female(sex: str) -> bool:
return sex.lower() in ("f", "female", "ж", "женский", "woman")
def _cm_to_inches(cm: float) -> float:
return cm / 2.54
def _clamp_bf(value: float) -> float:
return round(max(3.0, min(50.0, value)), 1)
def navy_body_fat_pct(
*,
sex: str,
height_cm: float,
neck_cm: float,
waist_cm: float,
hip_cm: float | None = None,
) -> float | None:
if height_cm <= 0 or neck_cm <= 0 or waist_cm <= 0:
return None
height_in = _cm_to_inches(height_cm)
neck_in = _cm_to_inches(neck_cm)
waist_in = _cm_to_inches(waist_cm)
if _is_female(sex):
if hip_cm is None or hip_cm <= 0:
return None
hip_in = _cm_to_inches(hip_cm)
sum_in = waist_in + hip_in - neck_in
if sum_in <= 0:
return None
denom = (
1.29579
- 0.35004 * math.log10(sum_in)
+ 0.22100 * math.log10(height_in)
)
else:
diff_in = waist_in - neck_in
if diff_in <= 0:
return None
denom = (
1.0324
- 0.19077 * math.log10(diff_in)
+ 0.15456 * math.log10(height_in)
)
if denom <= 0:
return None
return _clamp_bf(495.0 / denom - 450.0)
def whr(waist_cm: float, hip_cm: float) -> float | None:
if waist_cm <= 0 or hip_cm <= 0:
return None
return round(waist_cm / hip_cm, 2)
def lean_body_mass(weight_kg: float, body_fat_pct: float) -> float:
return round(weight_kg * (1.0 - body_fat_pct / 100.0), 1)
def ffmi(weight_kg: float, height_cm: float, body_fat_pct: float) -> float | None:
if height_cm <= 0:
return None
height_m = height_cm / 100.0
lbm = weight_kg * (1.0 - body_fat_pct / 100.0)
raw = lbm / (height_m * height_m)
normalized = raw + 6.1 * (1.8 - height_m)
return round(normalized, 1)
def compute_body_composition(
*,
sex: str,
height_cm: float,
weight_kg: float,
neck_cm: float | None = None,
waist_cm: float | None = None,
hip_cm: float | None = None,
body_fat_pct: float | None = None,
) -> dict[str, Any]:
warnings: list[str] = []
result: dict[str, Any] = {
"body_fat_pct": None,
"body_fat_method": None,
"whr": None,
"lbm_kg": None,
"ffmi": None,
"warnings": warnings,
}
bf = body_fat_pct
method: str | None = "manual" if bf is not None else None
if bf is None and neck_cm and waist_cm:
navy_bf = navy_body_fat_pct(
sex=sex,
height_cm=height_cm,
neck_cm=neck_cm,
waist_cm=waist_cm,
hip_cm=hip_cm,
)
if navy_bf is not None:
bf = navy_bf
method = "navy"
elif _is_female(sex) and not hip_cm:
warnings.append("Для Navy у женщин нужен обхват бёдер (hip_cm).")
elif neck_cm and waist_cm and waist_cm <= neck_cm:
warnings.append("Обхват талии должен быть больше шеи для Navy.")
if bf is not None:
result["body_fat_pct"] = round(float(bf), 1)
result["body_fat_method"] = method
result["lbm_kg"] = lean_body_mass(weight_kg, float(bf))
result["ffmi"] = ffmi(weight_kg, height_cm, float(bf))
if waist_cm and hip_cm:
result["whr"] = whr(waist_cm, hip_cm)
return result
-168
View File
@@ -1,168 +0,0 @@
from typing import Any
from app.fitness.activity_budget import workouts_kcal_total
DEFAULT_NEAT_KCAL = 200.0
NEAT_KCAL_MIN = 200.0
NEAT_KCAL_MAX = 300.0
KCAL_PER_STEP_REF = 0.04 / 86 # ~0.04 kcal/step at 86 kg
WATER_ML_PER_KG = 33 # middle of 3035 ml/kg range
GOAL_CALORIE_ADJUST = {
"lose": -500,
"maintain": 0,
"gain": 300,
}
PROTEIN_G_PER_KG = {
"lose": 2.2,
"maintain": 1.8,
"gain": 1.8,
}
FAT_G_PER_KG = 1.0
def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> float:
base = 10 * weight_kg + 6.25 * height_cm - 5 * age
if sex.lower() in ("m", "male", "м", "мужской"):
return base + 5
return base - 161
def neat_base_kcal(profile: dict[str, Any]) -> float:
raw = profile.get("neat_base_kcal")
if raw is not None:
return max(NEAT_KCAL_MIN, min(NEAT_KCAL_MAX, float(raw)))
return DEFAULT_NEAT_KCAL
def steps_kcal(*, steps: int, weight_kg: float) -> float:
if steps <= 0:
return 0.0
return round(steps * weight_kg * KCAL_PER_STEP_REF, 1)
def bmi(weight_kg: float, height_cm: float) -> float:
if height_cm <= 0:
return 0.0
h = height_cm / 100
return weight_kg / (h * h)
def water_target_l(weight_kg: float) -> float:
return round(weight_kg * WATER_ML_PER_KG / 1000, 1)
def macro_targets(
calorie_target: float,
weight_kg: float,
goal: str = "maintain",
) -> dict[str, float]:
protein_g = round(weight_kg * PROTEIN_G_PER_KG.get(goal, 1.8), 0)
fat_g = round(weight_kg * FAT_G_PER_KG, 0)
protein_cal = protein_g * 4
fat_cal = fat_g * 9
carbs_g = max(0, round((calorie_target - protein_cal - fat_cal) / 4, 0))
return {"protein_g": protein_g, "fat_g": fat_g, "carbs_g": carbs_g}
def one_rep_max(weight_kg: float, reps: int) -> float:
if reps <= 0:
return weight_kg
if reps == 1:
return weight_kg
return round(weight_kg * (1 + reps / 30), 1)
def _profile_fields(profile: dict[str, Any]) -> tuple[float, float, int, str, str]:
weight = float(profile.get("weight_kg") or 70)
height = float(profile.get("height_cm") or 170)
age = int(profile.get("age") or 30)
sex = str(profile.get("sex") or "male")
goal = str(profile.get("goal") or "maintain")
return weight, height, age, sex, goal
def compute_tdee(
profile: dict[str, Any],
*,
steps_total: int = 0,
workouts: list[dict[str, Any]] | None = None,
) -> dict[str, float]:
weight, height, age, sex, _ = _profile_fields(profile)
bmr = bmr_mifflin(sex=sex, weight_kg=weight, height_cm=height, age=age)
neat = neat_base_kcal(profile)
s_kcal = steps_kcal(steps=steps_total, weight_kg=weight)
w_kcal = workouts_kcal_total(workouts or [], weight_kg=weight)
tdee_val = bmr + neat + s_kcal + w_kcal
return {
"bmr": round(bmr, 0),
"neat_kcal": round(neat, 0),
"steps_kcal": s_kcal,
"workout_kcal": w_kcal,
"tdee": round(tdee_val, 0),
}
def compute_daily_targets(
profile: dict[str, Any],
*,
steps_total: int = 0,
workouts: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
weight, height, age, sex, goal = _profile_fields(profile)
breakdown = compute_tdee(profile, steps_total=steps_total, workouts=workouts)
calorie_target = round(breakdown["tdee"] + GOAL_CALORIE_ADJUST.get(goal, 0), 0)
macros = macro_targets(calorie_target, weight, goal)
water = water_target_l(weight)
return {
**breakdown,
"calorie_target": calorie_target,
"protein_g": macros["protein_g"],
"fat_g": macros["fat_g"],
"carbs_g": macros["carbs_g"],
"water_l": water,
"bmi": round(bmi(weight, height), 1),
"steps": steps_total,
}
def targets_to_api(daily: dict[str, Any]) -> dict[str, float]:
return {
"calories": daily["calorie_target"],
"protein_g": daily["protein_g"],
"fat_g": daily["fat_g"],
"carbs_g": daily["carbs_g"],
"water_ml": round(daily["water_l"] * 1000),
}
def tdee_breakdown_to_api(daily: dict[str, Any]) -> dict[str, Any]:
return {
"bmr": daily["bmr"],
"neat_kcal": daily["neat_kcal"],
"steps_kcal": daily["steps_kcal"],
"workout_kcal": daily["workout_kcal"],
"tdee": daily["tdee"],
"calorie_target": daily["calorie_target"],
"steps": daily.get("steps", 0),
}
def compute_targets(profile: dict[str, Any]) -> dict[str, Any]:
"""Rest-day targets (BMR + NEAT, no steps/workouts) for profile storage."""
daily = compute_daily_targets(profile, steps_total=0, workouts=[])
return {
"bmr": daily["bmr"],
"tdee": daily["tdee"],
"bmi": daily["bmi"],
"neat_kcal": daily["neat_kcal"],
"steps_kcal": 0,
"workout_kcal": 0,
"calorie_target": daily["calorie_target"],
"protein_g": daily["protein_g"],
"fat_g": daily["fat_g"],
"carbs_g": daily["carbs_g"],
"water_l": daily["water_l"],
}
-355
View File
@@ -1,355 +0,0 @@
"""Weekly fitness chart data and least-squares trend lines."""
from __future__ import annotations
from collections import defaultdict
from datetime import date, datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import BodyMetric, FoodLog, StepLog, WaterLog
METRIC_DEFS: dict[str, dict[str, str]] = {
"weight_kg": {"label": "Вес", "unit": "кг"},
"body_fat_pct": {"label": "Жир", "unit": "%"},
"calories": {"label": "Калории", "unit": "ккал/день"},
"protein_g": {"label": "Белок", "unit": "г/день"},
"water_l": {"label": "Вода", "unit": "л/день"},
"steps": {"label": "Шаги", "unit": "шаг/день"},
}
def week_start(day: date) -> date:
return day - timedelta(days=day.weekday())
def linear_regression(points: list[tuple[float, float]]) -> dict[str, float] | None:
"""Ordinary least squares y = slope * x + intercept."""
n = len(points)
if n < 2:
return None
sum_x = sum(x for x, _ in points)
sum_y = sum(y for _, y in points)
sum_xx = sum(x * x for x, _ in points)
sum_xy = sum(x * y for x, y in points)
denom = n * sum_xx - sum_x * sum_x
if abs(denom) < 1e-12:
return None
slope = (n * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n
return {"slope": slope, "intercept": intercept}
def _avg(values: list[float]) -> float | None:
if not values:
return None
return sum(values) / len(values)
def _last(values: list[tuple[date, float]]) -> float | None:
if not values:
return None
values.sort(key=lambda item: item[0])
return values[-1][1]
def build_fitness_charts(
db: Session,
user_id: int,
*,
weeks: int = 52,
trend: bool = True,
end_day: date | None = None,
) -> dict[str, Any]:
weeks = max(4, min(int(weeks), 52))
end = end_day or datetime.now(timezone.utc).date()
last_week_start = week_start(end)
first_week_start = last_week_start - timedelta(weeks=weeks - 1)
range_start = datetime.combine(first_week_start, datetime.min.time(), tzinfo=timezone.utc)
range_end = datetime.combine(end, datetime.max.time(), tzinfo=timezone.utc)
daily: dict[date, dict[str, float]] = defaultdict(lambda: {
"calories": 0.0,
"protein_g": 0.0,
"fat_g": 0.0,
"carbs_g": 0.0,
"water_ml": 0.0,
"steps": 0.0,
})
daily_flags: dict[date, set[str]] = defaultdict(set)
foods = db.scalars(
select(FoodLog).where(
FoodLog.user_id == user_id,
FoodLog.logged_at >= range_start,
FoodLog.logged_at <= range_end,
)
).all()
for row in foods:
d = row.logged_at.date()
daily[d]["calories"] += row.calories
daily[d]["protein_g"] += row.protein_g
daily[d]["fat_g"] += row.fat_g
daily[d]["carbs_g"] += row.carbs_g
daily_flags[d].add("nutrition")
waters = db.scalars(
select(WaterLog).where(
WaterLog.user_id == user_id,
WaterLog.logged_at >= range_start,
WaterLog.logged_at <= range_end,
)
).all()
for row in waters:
d = row.logged_at.date()
daily[d]["water_ml"] += float(row.amount_ml)
daily_flags[d].add("water")
steps_rows = db.scalars(
select(StepLog).where(
StepLog.user_id == user_id,
StepLog.logged_at >= range_start,
StepLog.logged_at <= range_end,
)
).all()
for row in steps_rows:
d = row.logged_at.date()
daily[d]["steps"] += float(row.steps)
daily_flags[d].add("steps")
body_rows = db.scalars(
select(BodyMetric).where(
BodyMetric.user_id == user_id,
BodyMetric.recorded_at >= range_start,
BodyMetric.recorded_at <= range_end,
)
).all()
body_by_day: dict[date, list[tuple[date, float, float | None]]] = defaultdict(list)
for row in body_rows:
d = row.recorded_at.date()
body_by_day[d].append((d, row.weight_kg, row.body_fat_pct))
daily_flags[d].add("body")
week_slots: list[dict[str, Any]] = []
cursor = first_week_start
while cursor <= last_week_start:
week_slots.append(
{
"week_start": cursor.isoformat(),
"week_end": (cursor + timedelta(days=6)).isoformat(),
}
)
cursor += timedelta(weeks=1)
days_with_data = len(daily_flags)
weeks_with_data = 0
def rollup_week(metric: str) -> list[dict[str, Any]]:
nonlocal weeks_with_data
points: list[dict[str, Any]] = []
local_weeks_with_data = 0
for idx, slot in enumerate(week_slots):
ws = date.fromisoformat(slot["week_start"])
we = date.fromisoformat(slot["week_end"])
day_cursor = ws
week_daily_values: list[float] = []
week_body_weight: list[tuple[date, float]] = []
week_body_fat: list[tuple[date, float]] = []
while day_cursor <= we:
if day_cursor > end:
break
flags = daily_flags.get(day_cursor, set())
totals = daily.get(day_cursor)
if metric == "weight_kg":
for _, w, _ in body_by_day.get(day_cursor, []):
week_body_weight.append((day_cursor, w))
elif metric == "body_fat_pct":
for _, _, bf in body_by_day.get(day_cursor, []):
if bf is not None:
week_body_fat.append((day_cursor, bf))
elif metric == "calories" and totals and "nutrition" in flags:
week_daily_values.append(totals["calories"])
elif metric == "protein_g" and totals and "nutrition" in flags:
week_daily_values.append(totals["protein_g"])
elif metric == "water_l" and totals and "water" in flags:
week_daily_values.append(totals["water_ml"] / 1000.0)
elif metric == "steps" and totals and "steps" in flags:
week_daily_values.append(totals["steps"])
day_cursor += timedelta(days=1)
value: float | None
days_in_week = 0
if metric == "weight_kg":
value = _last(week_body_weight)
days_in_week = len(week_body_weight)
elif metric == "body_fat_pct":
value = _last(week_body_fat)
days_in_week = len(week_body_fat)
else:
value = _avg(week_daily_values)
days_in_week = len(week_daily_values)
has_data = value is not None
if has_data:
local_weeks_with_data += 1
points.append(
{
"index": idx,
"week_start": slot["week_start"],
"week_end": slot["week_end"],
"value": round(value, 2) if value is not None else None,
"days_with_data": days_in_week,
"has_data": has_data,
}
)
weeks_with_data = max(weeks_with_data, local_weeks_with_data)
return points
series: dict[str, Any] = {}
for key, meta in METRIC_DEFS.items():
points = rollup_week(key)
reg_points = [(float(p["index"]), float(p["value"])) for p in points if p["has_data"] and p["value"] is not None]
trend_payload: dict[str, Any] | None = None
if trend and len(reg_points) >= 2:
fit = linear_regression(reg_points)
if fit:
line = [
{
"index": p["index"],
"week_start": p["week_start"],
"value": round(fit["slope"] * p["index"] + fit["intercept"], 2),
}
for p in points
]
trend_payload = {
"slope_per_week": round(fit["slope"], 4),
"intercept": round(fit["intercept"], 2),
"points_with_data": len(reg_points),
"line": line,
}
series[key] = {
"key": key,
"label": meta["label"],
"unit": meta["unit"],
"points": points,
"trend": trend_payload,
"data_points": sum(1 for p in points if p["has_data"]),
}
use_daily = days_with_data > 0 and days_with_data <= 14 and weeks_with_data <= 2
daily_series: dict[str, Any] | None = None
if use_daily:
daily_series = _build_daily_series(
daily,
daily_flags,
body_by_day,
end,
trend=trend,
lookback_days=min(30, max(days_with_data, 7)),
)
return {
"end_date": end.isoformat(),
"weeks": weeks,
"granularity": "day" if use_daily else "week",
"first_week_start": first_week_start.isoformat(),
"last_week_start": last_week_start.isoformat(),
"days_with_data": days_with_data,
"weeks_with_data": weeks_with_data,
"series": series,
"daily_series": daily_series,
}
def _build_daily_series(
daily: dict[date, dict[str, float]],
daily_flags: dict[date, set[str]],
body_by_day: dict[date, list[tuple[date, float, float | None]]],
end: date,
*,
trend: bool,
lookback_days: int,
) -> dict[str, Any]:
start = end - timedelta(days=lookback_days - 1)
day_points: list[date] = []
cursor = start
while cursor <= end:
day_points.append(cursor)
cursor += timedelta(days=1)
result: dict[str, Any] = {}
for key, meta in METRIC_DEFS.items():
points: list[dict[str, Any]] = []
for idx, d in enumerate(day_points):
value: float | None = None
has_data = False
if key == "weight_kg":
body = body_by_day.get(d, [])
pairs = [(x, w) for x, w, _ in body]
value = _last(pairs) if pairs else None
has_data = value is not None
elif key == "body_fat_pct":
fat_vals = [(x, bf) for x, _, bf in body_by_day.get(d, []) if bf is not None]
value = _last(fat_vals) if fat_vals else None
has_data = value is not None
else:
flags = daily_flags.get(d, set())
totals = daily.get(d)
if key == "calories" and totals and "nutrition" in flags:
value = totals["calories"]
has_data = True
elif key == "protein_g" and totals and "nutrition" in flags:
value = totals["protein_g"]
has_data = True
elif key == "water_l" and totals and "water" in flags:
value = totals["water_ml"] / 1000.0
has_data = True
elif key == "steps" and totals and "steps" in flags:
value = totals["steps"]
has_data = True
points.append(
{
"index": idx,
"date": d.isoformat(),
"value": round(value, 2) if value is not None else None,
"has_data": has_data,
}
)
reg_points = [(float(p["index"]), float(p["value"])) for p in points if p["has_data"] and p["value"] is not None]
trend_payload: dict[str, Any] | None = None
if trend and len(reg_points) >= 2:
fit = linear_regression(reg_points)
if fit:
trend_payload = {
"slope_per_day": round(fit["slope"], 4),
"intercept": round(fit["intercept"], 2),
"points_with_data": len(reg_points),
"line": [
{
"index": p["index"],
"date": p["date"],
"value": round(fit["slope"] * p["index"] + fit["intercept"], 2),
}
for p in points
],
}
result[key] = {
"key": key,
"label": meta["label"],
"unit": meta["unit"],
"points": points,
"trend": trend_payload,
"data_points": sum(1 for p in points if p["has_data"]),
}
return result
-106
View File
@@ -1,106 +0,0 @@
from typing import Any
from sqlalchemy.orm import Session
from app.fitness.service import FitnessService
def get_fitness_snapshot(db: Session, user_id: int) -> dict[str, Any]:
return FitnessService(db, user_id).snapshot()
def format_fitness_context(snapshot: dict[str, Any]) -> str:
lines = ["[Фитнес — сводка на сегодня]"]
profile = snapshot.get("profile")
if not profile:
lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.")
else:
computed = profile.get("computed") or {}
lines.append(
f"Цели (база, без шагов/тренировок): {profile.get('calorie_target')} ккал, "
f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, "
f"вода {profile.get('water_l')} л"
)
lines.append(
f"BMR {computed.get('bmr', '?')} + NEAT {computed.get('neat_kcal', 200)} = "
f"TDEE база {computed.get('tdee', '?')} ккал"
)
if profile.get("goal"):
lines.append(
f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, "
f"рост {profile.get('height_cm')} см"
)
today = snapshot.get("today") or {}
totals = today.get("totals") or {}
targets = today.get("targets") or {}
breakdown = today.get("tdee_breakdown") or {}
steps_total = today.get("steps_total") or 0
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
if breakdown:
lines.append(
f"TDEE за день: BMR {breakdown.get('bmr')} + NEAT {breakdown.get('neat_kcal')} + "
f"шаги {breakdown.get('steps_kcal')} ({steps_total} шаг.) + "
f"тренировки {breakdown.get('workout_kcal')} = {breakdown.get('tdee')} ккал → "
f"цель {breakdown.get('calorie_target')} ккал"
)
elif steps_total == 0:
lines.append(
"Шаги/тренировки не внесены — TDEE считается как BMR + NEAT. "
"log_steps / log_workout для точной дневной цели."
)
lines.append("")
lines.append(
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г"
)
lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л")
workouts = today.get("workouts") or []
if workouts:
lines.append(f"Тренировок сегодня: {len(workouts)}")
stats = snapshot.get("workout_stats") or {}
if stats.get("count"):
lines.append(
f"Тренировки за {stats.get('days', 7)} дн.: {stats.get('count')} "
f"(серия {stats.get('streak')} дн., {stats.get('active_kcal')} ккал активных)"
)
latest = (snapshot.get("body_metrics") or [None])[0]
if latest:
lines.append("")
lines.append("Антропометрия (последняя):")
parts = [f"{latest.get('weight_kg')} кг"]
if latest.get("body_fat_pct") is not None:
method = latest.get("body_fat_method") or "?"
parts.append(f"жир {latest.get('body_fat_pct')}% ({method})")
if latest.get("neck_cm"):
parts.append(f"шея {latest.get('neck_cm')}")
if latest.get("waist_cm"):
parts.append(f"талия {latest.get('waist_cm')}")
if latest.get("hip_cm"):
parts.append(f"бёдра {latest.get('hip_cm')}")
if latest.get("whr"):
parts.append(f"WHR {latest.get('whr')}")
if latest.get("ffmi"):
parts.append(f"FFMI {latest.get('ffmi')}")
lines.append(" · ".join(parts))
lines.append("")
lines.append(
"Правила: log_meal, log_water, log_weight (обхваты → Navy), log_steps, log_workout (date/days_ago), "
"calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"TDEE = BMR + NEAT (200 ккал) + шаги + тренировки. "
"БЖУ: белок 2.2 г/кг (сушка) / 1.8 г/кг (поддержание/набор), жир 1.0 г/кг, угли — остаток от целевых ккал. "
"Скриншоты Mi Fitness: vision уже извлекла данные в блок [Скриншот] с fitness_hints — используй их, не говори что не видишь картинку. "
"Еда — оценка LLM (≈)."
)
return chr(10).join(lines)
-111
View File
@@ -1,111 +0,0 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.chat.notice_inbox import post_notice_to_latest_chat
from app.config import get_settings
from app.db.models import FitnessReminder, User
from app.fitness.service import FitnessService
KIND_LABELS = {
"water": "Вода",
"meal": "Еда",
"workout": "Тренировка",
"weigh_in": "Взвешивание",
}
def _build_notice(kind: str, summary: dict) -> str:
label = KIND_LABELS.get(kind, kind)
totals = summary.get("totals") or {}
targets = summary.get("targets") or {}
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
cals = totals.get("calories", 0)
cal_target = targets.get("calories", 2000)
if kind == "water":
return (
f"💪 **{label}** · выпито {water_l:.1f}/{water_target:.1f} л сегодня. "
"Пора выпить стакан воды."
)
if kind == "meal":
return (
f"💪 **{label}** · {cals:.0f}/{cal_target:.0f} ккал за день. "
"Не забудь залогировать приём пищи."
)
if kind == "workout":
workouts = summary.get("workouts") or []
if workouts:
return f"💪 **{label}** · сегодня уже была тренировка. Отдыхай или лёгкая активность."
return "💪 **Тренировка** · запланирована на сегодня. Время двигаться!"
if kind == "weigh_in":
return "💪 **Взвешивание** · пора записать вес (log_weight)."
return f"💪 **{label}** · напоминание"
def _check_user_reminders(db: Session, user_id: int) -> list[str]:
now = datetime.now(timezone.utc)
service = FitnessService(db, user_id)
summary = service.get_daily_summary()
fired: list[str] = []
reminders = db.scalars(
select(FitnessReminder).where(
FitnessReminder.user_id == user_id,
FitnessReminder.enabled.is_(True),
)
).all()
for rem in reminders:
should_fire = False
if rem.interval_hours:
if rem.last_fired_at is None:
should_fire = now.hour >= rem.hour
else:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
should_fire = delta >= timedelta(hours=rem.interval_hours)
else:
if rem.kind == "weigh_in":
if rem.last_fired_at:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
should_fire = delta >= timedelta(days=7)
else:
should_fire = now.hour == rem.hour and now.minute >= rem.minute
else:
if rem.last_fired_at:
last = rem.last_fired_at.replace(tzinfo=timezone.utc)
already_today = last.date() == now.date()
if already_today:
continue
should_fire = now.hour == rem.hour and now.minute >= rem.minute
if not should_fire:
continue
notice = _build_notice(rem.kind, summary)
rem.last_fired_at = now
fired.append(notice)
if fired:
for notice in fired:
post_notice_to_latest_chat(notice, user_id)
return fired
def check_reminders(db: Session) -> list[str]:
if not get_settings().fitness_reminders_enabled:
return []
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
all_fired: list[str] = []
for user in users:
all_fired.extend(_check_user_reminders(db, user.id))
if all_fired:
db.commit()
return all_fired
-710
View File
@@ -1,710 +0,0 @@
import json
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import (
BodyMetric,
FitnessProfile,
FitnessReminder,
FoodLog,
StepLog,
WaterLog,
WorkoutLog,
)
from app.fitness.activity_budget import estimate_workout_active_kcal
from app.fitness.calculators import (
compute_daily_targets,
compute_targets,
one_rep_max,
targets_to_api,
tdee_breakdown_to_api,
)
from app.fitness.body_composition import compute_body_composition
DEFAULT_REMINDERS = [
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
{"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None},
{"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None},
{"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None},
]
class FitnessService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
def _get_profile_row(self) -> FitnessProfile | None:
return self.db.scalar(select(FitnessProfile).where(FitnessProfile.user_id == self.user_id).limit(1))
def get_profile(self) -> dict[str, Any] | None:
row = self._get_profile_row()
if not row:
return None
return self._profile_to_dict(row)
def _profile_params(self, row: FitnessProfile) -> dict[str, Any]:
return {
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"goal": row.goal,
"neat_base_kcal": row.neat_base_kcal,
}
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
targets = compute_targets(self._profile_params(row))
return {
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"goal": row.goal,
"target_weight_kg": row.target_weight_kg,
"neat_base_kcal": row.neat_base_kcal,
"calorie_target": row.calorie_target,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
"carbs_g": row.carbs_g,
"water_l": row.water_l,
"computed": targets,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
row = self._get_profile_row()
is_new = row is None
if is_new:
row = FitnessProfile(user_id=self.user_id)
self.db.add(row)
self.db.flush()
for key in (
"sex", "age", "height_cm", "weight_kg",
"goal", "target_weight_kg", "neat_base_kcal",
):
if key in updates and updates[key] is not None:
setattr(row, key, updates[key])
targets = compute_targets(self._profile_params(row))
row.calorie_target = targets["calorie_target"]
row.protein_g = targets["protein_g"]
row.fat_g = targets["fat_g"]
row.carbs_g = targets["carbs_g"]
row.water_l = targets["water_l"]
row.updated_at = datetime.now(timezone.utc)
if is_new:
self._ensure_default_reminders()
self.db.commit()
self.db.refresh(row)
return {"ok": True, "profile": self._profile_to_dict(row)}
def _ensure_default_reminders(self) -> None:
existing = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id)).all()
if existing:
return
for item in DEFAULT_REMINDERS:
self.db.add(FitnessReminder(user_id=self.user_id, **item))
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
return compute_targets(params)
def calc_body_composition(self, params: dict[str, Any]) -> dict[str, Any]:
profile = self.get_profile() or {}
sex = params.get("sex") or profile.get("sex") or "male"
height_cm = float(params.get("height_cm") or profile.get("height_cm") or 170)
weight_kg = float(params.get("weight_kg") or profile.get("weight_kg") or 70)
return compute_body_composition(
sex=str(sex),
height_cm=height_cm,
weight_kg=weight_kg,
neck_cm=params.get("neck_cm"),
waist_cm=params.get("waist_cm"),
hip_cm=params.get("hip_cm"),
body_fat_pct=params.get("body_fat_pct"),
)
def get_latest_body_composition(self) -> dict[str, Any] | None:
rows = self.list_body_metrics(limit=1)
return rows[0] if rows else None
@staticmethod
def _body_metric_to_dict(row: BodyMetric) -> dict[str, Any]:
return {
"id": row.id,
"weight_kg": row.weight_kg,
"body_fat_pct": row.body_fat_pct,
"body_fat_method": row.body_fat_method,
"chest_cm": row.chest_cm,
"waist_cm": row.waist_cm,
"neck_cm": row.neck_cm,
"hip_cm": row.hip_cm,
"whr": row.whr,
"lbm_kg": row.lbm_kg,
"ffmi": row.ffmi,
"notes": row.notes,
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
}
@staticmethod
def _resolve_logged_at(
*,
logged_at: datetime | str | None = None,
day: date | None = None,
days_ago: int | None = None,
) -> datetime:
if logged_at is not None:
if isinstance(logged_at, str):
dt = datetime.fromisoformat(logged_at.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
if logged_at.tzinfo is None:
return logged_at.replace(tzinfo=timezone.utc)
return logged_at
target_day = day
if target_day is None and days_ago is not None:
target_day = datetime.now(timezone.utc).date() - timedelta(days=int(days_ago))
if target_day is None:
return datetime.now(timezone.utc)
return datetime.combine(target_day, time(12, 0), tzinfo=timezone.utc)
def _profile_for_budget(self, profile: dict[str, Any] | None) -> dict[str, Any]:
if profile:
return profile
return {
"weight_kg": 70,
"height_cm": 170,
"age": 30,
"sex": "male",
"goal": "maintain",
"neat_base_kcal": 200,
}
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
d = day or datetime.now(timezone.utc).date()
start = datetime.combine(d, time.min, tzinfo=timezone.utc)
end = datetime.combine(d, time.max, tzinfo=timezone.utc)
return start, end
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
start, end = self._day_bounds(day)
profile_row = self.get_profile()
profile = self._profile_for_budget(profile_row)
foods = self.db.scalars(
select(FoodLog)
.where(FoodLog.user_id == self.user_id, FoodLog.logged_at >= start, FoodLog.logged_at <= end)
.order_by(FoodLog.logged_at)
).all()
waters = self.db.scalars(
select(WaterLog)
.where(WaterLog.user_id == self.user_id, WaterLog.logged_at >= start, WaterLog.logged_at <= end)
.order_by(WaterLog.logged_at)
).all()
workouts_rows = self.db.scalars(
select(WorkoutLog)
.where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
.order_by(WorkoutLog.logged_at)
).all()
steps_rows = self.db.scalars(
select(StepLog)
.where(StepLog.user_id == self.user_id, StepLog.logged_at >= start, StepLog.logged_at <= end)
.order_by(StepLog.logged_at)
).all()
workouts = [self._workout_to_dict(w) for w in workouts_rows]
steps_total = sum(s.steps for s in steps_rows)
totals = {
"calories": sum(f.calories for f in foods),
"protein_g": sum(f.protein_g for f in foods),
"fat_g": sum(f.fat_g for f in foods),
"carbs_g": sum(f.carbs_g for f in foods),
"water_ml": sum(w.amount_ml for w in waters),
"steps": steps_total,
}
daily = compute_daily_targets(
profile,
steps_total=steps_total,
workouts=workouts,
)
targets = targets_to_api(daily)
return {
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
"profile_configured": profile_row is not None,
"totals": totals,
"targets": targets,
"tdee_breakdown": tdee_breakdown_to_api(daily),
"meals": [self._food_to_dict(f) for f in foods],
"water": [self._water_to_dict(w) for w in waters],
"workouts": workouts,
"steps": [self._step_to_dict(s) for s in steps_rows],
"steps_total": steps_total,
}
def log_meal(
self,
*,
description: str,
meal_type: str = "snack",
calories: float = 0,
protein_g: float = 0,
fat_g: float = 0,
carbs_g: float = 0,
source: str = "llm",
estimated: bool = True,
) -> dict[str, Any]:
row = FoodLog(
user_id=self.user_id,
meal_type=meal_type[:32],
description=description[:2000],
calories=calories,
protein_g=protein_g,
fat_g=fat_g,
carbs_g=carbs_g,
source=source[:32],
estimated=estimated,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "meal": self._food_to_dict(row)}
def log_water(self, amount_ml: int) -> dict[str, Any]:
row = WaterLog(user_id=self.user_id, amount_ml=max(0, amount_ml))
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "water": self._water_to_dict(row)}
def log_steps(
self,
steps: int,
*,
active_calories: float | None = None,
logged_at: datetime | str | None = None,
day: date | None = None,
days_ago: int | None = None,
notes: str = "",
source: str = "manual",
) -> dict[str, Any]:
row = StepLog(
user_id=self.user_id,
steps=max(0, int(steps)),
active_calories=active_calories,
notes=notes[:2000],
source=source[:32],
logged_at=self._resolve_logged_at(
logged_at=logged_at,
day=day,
days_ago=days_ago,
),
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "step_log": self._step_to_dict(row)}
def log_weight(
self,
weight_kg: float,
*,
body_fat_pct: float | None = None,
chest_cm: float | None = None,
waist_cm: float | None = None,
neck_cm: float | None = None,
hip_cm: float | None = None,
notes: str = "",
recorded_at: datetime | str | None = None,
day: date | None = None,
days_ago: int | None = None,
) -> dict[str, Any]:
profile = self.get_profile() or {}
sex = profile.get("sex") or "male"
height_cm = float(profile.get("height_cm") or 170)
computed = compute_body_composition(
sex=str(sex),
height_cm=height_cm,
weight_kg=weight_kg,
neck_cm=neck_cm,
waist_cm=waist_cm,
hip_cm=hip_cm,
body_fat_pct=body_fat_pct,
)
row = BodyMetric(
user_id=self.user_id,
weight_kg=weight_kg,
body_fat_pct=computed.get("body_fat_pct"),
body_fat_method=computed.get("body_fat_method"),
chest_cm=chest_cm,
waist_cm=waist_cm,
neck_cm=neck_cm,
hip_cm=hip_cm,
whr=computed.get("whr"),
lbm_kg=computed.get("lbm_kg"),
ffmi=computed.get("ffmi"),
notes=notes[:1000],
recorded_at=self._resolve_logged_at(
logged_at=recorded_at,
day=day,
days_ago=days_ago,
),
)
self.db.add(row)
profile_row = self._get_profile_row()
if profile_row:
profile_row.weight_kg = weight_kg
targets = compute_targets(
{
"sex": profile_row.sex,
"age": profile_row.age,
"height_cm": profile_row.height_cm,
"weight_kg": weight_kg,
"goal": profile_row.goal,
"neat_base_kcal": profile_row.neat_base_kcal,
}
)
profile_row.calorie_target = targets["calorie_target"]
profile_row.protein_g = targets["protein_g"]
profile_row.fat_g = targets["fat_g"]
profile_row.carbs_g = targets["carbs_g"]
profile_row.water_l = targets["water_l"]
self.db.commit()
self.db.refresh(row)
metric = self._body_metric_to_dict(row)
return {
"ok": True,
"metric": metric,
"computed": {
"body_fat_pct": computed.get("body_fat_pct"),
"body_fat_method": computed.get("body_fat_method"),
"whr": computed.get("whr"),
"lbm_kg": computed.get("lbm_kg"),
"ffmi": computed.get("ffmi"),
"warnings": computed.get("warnings") or [],
},
}
def log_workout(
self,
*,
title: str,
notes: str = "",
duration_min: int | None = None,
exercises: list[dict[str, Any]] | None = None,
active_calories: float | None = None,
total_calories: float | None = None,
steps: int | None = None,
activity_type: str | None = None,
met: float | None = None,
logged_at: datetime | str | None = None,
day: date | None = None,
days_ago: int | None = None,
) -> dict[str, Any]:
profile = self.get_profile() or {}
weight_kg = float(profile.get("weight_kg") or 70)
if active_calories is None and duration_min and met is not None:
active_calories = round(met * weight_kg * (float(duration_min) / 60.0), 1)
elif active_calories is None and duration_min:
draft = {
"title": title,
"notes": notes,
"activity_type": activity_type,
"met": met,
"duration_min": duration_min,
}
active_calories = estimate_workout_active_kcal(draft, weight_kg=weight_kg) or None
row = WorkoutLog(
user_id=self.user_id,
title=title[:255],
notes=notes[:2000],
duration_min=duration_min,
active_calories=active_calories,
total_calories=total_calories,
steps=steps,
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
logged_at=self._resolve_logged_at(
logged_at=logged_at,
day=day,
days_ago=days_ago,
),
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "workout": self._workout_to_dict(row)}
def get_workout_stats(
self,
*,
days: int = 7,
end_day: date | None = None,
) -> dict[str, Any]:
days = max(1, min(days, 90))
end = end_day or datetime.now(timezone.utc).date()
start = end - timedelta(days=days - 1)
start_dt, _ = self._day_bounds(start)
_, end_dt = self._day_bounds(end)
rows = self.db.scalars(
select(WorkoutLog)
.where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start_dt, WorkoutLog.logged_at <= end_dt)
.order_by(WorkoutLog.logged_at)
).all()
profile = self.get_profile() or {}
weight_kg = float(profile.get("weight_kg") or 70)
weekly_target = 3
count = len(rows)
duration_min = sum(r.duration_min or 0 for r in rows)
active_kcal = round(
sum(
estimate_workout_active_kcal(self._workout_to_dict(r), weight_kg=weight_kg)
for r in rows
),
1,
)
days_with_workout: set[date] = set()
for row in rows:
if row.logged_at:
days_with_workout.add(row.logged_at.astimezone(timezone.utc).date())
streak = 0
cursor = end
while cursor >= start:
if cursor in days_with_workout:
streak += 1
cursor -= timedelta(days=1)
else:
break
return {
"days": days,
"start_date": start.isoformat(),
"end_date": end.isoformat(),
"count": count,
"duration_min": duration_min,
"active_kcal": active_kcal,
"weekly_target": weekly_target,
"streak": streak,
}
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
rows = self.db.scalars(
select(BodyMetric).where(BodyMetric.user_id == self.user_id).order_by(BodyMetric.recorded_at.desc()).limit(limit)
).all()
return [self._body_metric_to_dict(r) for r in rows]
def delete_food_log(self, log_id: int) -> bool:
row = self.db.get(FoodLog, log_id)
if not row or row.user_id != self.user_id:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_water_log(self, log_id: int) -> bool:
row = self.db.get(WaterLog, log_id)
if not row or row.user_id != self.user_id:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_workout_log(self, log_id: int) -> bool:
row = self.db.get(WorkoutLog, log_id)
if not row or row.user_id != self.user_id:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_step_log(self, log_id: int) -> bool:
row = self.db.get(StepLog, log_id)
if not row or row.user_id != self.user_id:
return False
self.db.delete(row)
self.db.commit()
return True
def list_reminders(self) -> list[dict[str, Any]]:
rows = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id).order_by(FitnessReminder.kind)).all()
return [self._reminder_to_dict(r) for r in rows]
def set_reminder(
self,
kind: str,
*,
enabled: bool | None = None,
hour: int | None = None,
minute: int | None = None,
interval_hours: int | None = None,
) -> dict[str, Any]:
row = self.db.scalar(
select(FitnessReminder).where(FitnessReminder.user_id == self.user_id, FitnessReminder.kind == kind)
)
if not row:
row = FitnessReminder(user_id=self.user_id, kind=kind)
self.db.add(row)
if enabled is not None:
row.enabled = enabled
if hour is not None:
row.hour = hour
if minute is not None:
row.minute = minute
if interval_hours is not None:
row.interval_hours = interval_hours
self.db.commit()
self.db.refresh(row)
return {"ok": True, "reminder": self._reminder_to_dict(row)}
def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]:
return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)}
def get_history(
self,
*,
days: int = 7,
end_day: date | None = None,
include_tdee_breakdown: bool = True,
) -> dict[str, Any]:
days = max(1, min(days, 90))
end = end_day or datetime.now(timezone.utc).date()
start = end - timedelta(days=days - 1)
summaries: list[dict[str, Any]] = []
for offset in range(days):
d = start + timedelta(days=offset)
full = self.get_daily_summary(d)
totals = full["totals"]
has_data = bool(full["meals"] or full["water"] or full["workouts"] or full["steps"])
item: dict[str, Any] = {
"date": full["date"],
"has_data": has_data,
"totals": totals,
"targets": full["targets"],
"meal_count": len(full["meals"]),
"workout_count": len(full["workouts"]),
}
if include_tdee_breakdown:
item["tdee_breakdown"] = full.get("tdee_breakdown")
summaries.append(item)
return {
"start_date": start.isoformat(),
"end_date": end.isoformat(),
"days": days,
"summaries": summaries,
}
def snapshot(self) -> dict[str, Any]:
today = datetime.now(timezone.utc).date()
return {
"profile": self.get_profile(),
"today": self.get_daily_summary(today),
"history": self.get_history(days=7, end_day=today),
"workout_stats": self.get_workout_stats(days=7, end_day=today),
"body_metrics": self.list_body_metrics(limit=10),
"reminders": self.list_reminders(),
}
def get_charts(
self,
*,
weeks: int = 52,
trend: bool = True,
end_day: date | None = None,
) -> dict[str, Any]:
from app.fitness.charts import build_fitness_charts
return build_fitness_charts(
self.db,
self.user_id,
weeks=weeks,
trend=trend,
end_day=end_day,
)
@staticmethod
def _food_to_dict(row: FoodLog) -> dict[str, Any]:
return {
"id": row.id,
"meal_type": row.meal_type,
"description": row.description,
"calories": row.calories,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
"carbs_g": row.carbs_g,
"source": row.source,
"estimated": row.estimated,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _water_to_dict(row: WaterLog) -> dict[str, Any]:
return {
"id": row.id,
"amount_ml": row.amount_ml,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _step_to_dict(row: StepLog) -> dict[str, Any]:
return {
"id": row.id,
"steps": row.steps,
"active_calories": row.active_calories,
"source": row.source,
"notes": row.notes,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]:
try:
exercises = json.loads(row.exercises_json or "[]")
except json.JSONDecodeError:
exercises = []
return {
"id": row.id,
"title": row.title,
"notes": row.notes,
"duration_min": row.duration_min,
"active_calories": row.active_calories,
"total_calories": row.total_calories,
"steps": row.steps,
"exercises": exercises,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]:
return {
"id": row.id,
"kind": row.kind,
"hour": row.hour,
"minute": row.minute,
"interval_hours": row.interval_hours,
"enabled": row.enabled,
"last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None,
}
-441
View File
@@ -1,441 +0,0 @@
import json
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db.models import (
BodyMetric,
FitnessProfile,
FitnessReminder,
FoodLog,
WaterLog,
WorkoutLog,
)
from app.fitness.calculators import compute_targets, one_rep_max
DEFAULT_REMINDERS = [
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
{"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None},
{"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None},
{"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None},
]
class FitnessService:
def __init__(self, db: Session):
self.db = db
def _get_profile_row(self) -> FitnessProfile | None:
return self.db.scalar(select(FitnessProfile).limit(1))
def get_profile(self) -> dict[str, Any] | None:
row = self._get_profile_row()
if not row:
return None
return self._profile_to_dict(row)
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
targets = compute_targets(
{
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
}
)
return {
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
"target_weight_kg": row.target_weight_kg,
"weekly_workouts": row.weekly_workouts,
"calorie_target": row.calorie_target,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
"carbs_g": row.carbs_g,
"water_l": row.water_l,
"computed": targets,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
row = self._get_profile_row()
is_new = row is None
if is_new:
row = FitnessProfile()
self.db.add(row)
self.db.flush()
for key in (
"sex", "age", "height_cm", "weight_kg", "activity_level",
"goal", "target_weight_kg", "weekly_workouts",
):
if key in updates and updates[key] is not None:
setattr(row, key, updates[key])
targets = compute_targets(
{
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
}
)
row.calorie_target = targets["calorie_target"]
row.protein_g = targets["protein_g"]
row.fat_g = targets["fat_g"]
row.carbs_g = targets["carbs_g"]
row.water_l = targets["water_l"]
row.updated_at = datetime.now(timezone.utc)
if is_new:
self._ensure_default_reminders()
self.db.commit()
self.db.refresh(row)
return {"ok": True, "profile": self._profile_to_dict(row)}
def _ensure_default_reminders(self) -> None:
existing = self.db.scalars(select(FitnessReminder)).all()
if existing:
return
for item in DEFAULT_REMINDERS:
self.db.add(FitnessReminder(**item))
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
return compute_targets(params)
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
d = day or datetime.now(timezone.utc).date()
start = datetime.combine(d, time.min, tzinfo=timezone.utc)
end = datetime.combine(d, time.max, tzinfo=timezone.utc)
return start, end
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
start, end = self._day_bounds(day)
profile = self.get_profile()
foods = self.db.scalars(
select(FoodLog)
.where(FoodLog.logged_at >= start, FoodLog.logged_at <= end)
.order_by(FoodLog.logged_at)
).all()
waters = self.db.scalars(
select(WaterLog)
.where(WaterLog.logged_at >= start, WaterLog.logged_at <= end)
.order_by(WaterLog.logged_at)
).all()
workouts = self.db.scalars(
select(WorkoutLog)
.where(WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
.order_by(WorkoutLog.logged_at)
).all()
totals = {
"calories": sum(f.calories for f in foods),
"protein_g": sum(f.protein_g for f in foods),
"fat_g": sum(f.fat_g for f in foods),
"carbs_g": sum(f.carbs_g for f in foods),
"water_ml": sum(w.amount_ml for w in waters),
}
targets = profile or {
"calorie_target": 2000,
"protein_g": 140,
"fat_g": 65,
"carbs_g": 200,
"water_l": 2.5,
}
return {
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
"profile_configured": profile is not None,
"totals": totals,
"targets": {
"calories": targets.get("calorie_target", 2000),
"protein_g": targets.get("protein_g", 140),
"fat_g": targets.get("fat_g", 65),
"carbs_g": targets.get("carbs_g", 200),
"water_ml": targets.get("water_l", 2.5) * 1000,
},
"meals": [self._food_to_dict(f) for f in foods],
"water": [self._water_to_dict(w) for w in waters],
"workouts": [self._workout_to_dict(w) for w in workouts],
}
def log_meal(
self,
*,
description: str,
meal_type: str = "snack",
calories: float = 0,
protein_g: float = 0,
fat_g: float = 0,
carbs_g: float = 0,
source: str = "llm",
estimated: bool = True,
) -> dict[str, Any]:
row = FoodLog(
meal_type=meal_type[:32],
description=description[:2000],
calories=calories,
protein_g=protein_g,
fat_g=fat_g,
carbs_g=carbs_g,
source=source[:32],
estimated=estimated,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "meal": self._food_to_dict(row)}
def log_water(self, amount_ml: int) -> dict[str, Any]:
row = WaterLog(amount_ml=max(0, amount_ml))
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "water": self._water_to_dict(row)}
def log_weight(
self,
weight_kg: float,
*,
body_fat_pct: float | None = None,
chest_cm: float | None = None,
waist_cm: float | None = None,
notes: str = "",
) -> dict[str, Any]:
row = BodyMetric(
weight_kg=weight_kg,
body_fat_pct=body_fat_pct,
chest_cm=chest_cm,
waist_cm=waist_cm,
notes=notes[:1000],
)
self.db.add(row)
profile = self._get_profile_row()
if profile:
profile.weight_kg = weight_kg
targets = compute_targets(
{
"sex": profile.sex,
"age": profile.age,
"height_cm": profile.height_cm,
"weight_kg": weight_kg,
"activity_level": profile.activity_level,
"goal": profile.goal,
}
)
profile.calorie_target = targets["calorie_target"]
profile.protein_g = targets["protein_g"]
profile.fat_g = targets["fat_g"]
profile.carbs_g = targets["carbs_g"]
profile.water_l = targets["water_l"]
self.db.commit()
self.db.refresh(row)
return {
"ok": True,
"metric": {
"id": row.id,
"weight_kg": row.weight_kg,
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
},
}
def log_workout(
self,
*,
title: str,
notes: str = "",
duration_min: int | None = None,
exercises: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
row = WorkoutLog(
title=title[:255],
notes=notes[:2000],
duration_min=duration_min,
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "workout": self._workout_to_dict(row)}
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
rows = self.db.scalars(
select(BodyMetric).order_by(BodyMetric.recorded_at.desc()).limit(limit)
).all()
return [
{
"id": r.id,
"weight_kg": r.weight_kg,
"body_fat_pct": r.body_fat_pct,
"chest_cm": r.chest_cm,
"waist_cm": r.waist_cm,
"notes": r.notes,
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None,
}
for r in rows
]
def delete_food_log(self, log_id: int) -> bool:
row = self.db.get(FoodLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_water_log(self, log_id: int) -> bool:
row = self.db.get(WaterLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_workout_log(self, log_id: int) -> bool:
row = self.db.get(WorkoutLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def list_reminders(self) -> list[dict[str, Any]]:
rows = self.db.scalars(select(FitnessReminder).order_by(FitnessReminder.kind)).all()
return [self._reminder_to_dict(r) for r in rows]
def set_reminder(
self,
kind: str,
*,
enabled: bool | None = None,
hour: int | None = None,
minute: int | None = None,
interval_hours: int | None = None,
) -> dict[str, Any]:
row = self.db.scalar(
select(FitnessReminder).where(FitnessReminder.kind == kind)
)
if not row:
row = FitnessReminder(kind=kind)
self.db.add(row)
if enabled is not None:
row.enabled = enabled
if hour is not None:
row.hour = hour
if minute is not None:
row.minute = minute
if interval_hours is not None:
row.interval_hours = interval_hours
self.db.commit()
self.db.refresh(row)
return {"ok": True, "reminder": self._reminder_to_dict(row)}
def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]:
return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)}
def get_history(
self,
*,
days: int = 7,
end_day: date | None = None,
) -> dict[str, Any]:
days = max(1, min(days, 90))
end = end_day or datetime.now(timezone.utc).date()
start = end - timedelta(days=days - 1)
summaries: list[dict[str, Any]] = []
for offset in range(days):
d = start + timedelta(days=offset)
full = self.get_daily_summary(d)
totals = full["totals"]
has_data = bool(full["meals"] or full["water"] or full["workouts"])
summaries.append(
{
"date": full["date"],
"has_data": has_data,
"totals": totals,
"targets": full["targets"],
"meal_count": len(full["meals"]),
"workout_count": len(full["workouts"]),
}
)
return {
"start_date": start.isoformat(),
"end_date": end.isoformat(),
"days": days,
"summaries": summaries,
}
def snapshot(self) -> dict[str, Any]:
today = datetime.now(timezone.utc).date()
return {
"profile": self.get_profile(),
"today": self.get_daily_summary(today),
"history": self.get_history(days=7, end_day=today),
"body_metrics": self.list_body_metrics(limit=10),
"reminders": self.list_reminders(),
}
@staticmethod
def _food_to_dict(row: FoodLog) -> dict[str, Any]:
return {
"id": row.id,
"meal_type": row.meal_type,
"description": row.description,
"calories": row.calories,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
"carbs_g": row.carbs_g,
"source": row.source,
"estimated": row.estimated,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _water_to_dict(row: WaterLog) -> dict[str, Any]:
return {
"id": row.id,
"amount_ml": row.amount_ml,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]:
try:
exercises = json.loads(row.exercises_json or "[]")
except json.JSONDecodeError:
exercises = []
return {
"id": row.id,
"title": row.title,
"notes": row.notes,
"duration_min": row.duration_min,
"exercises": exercises,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]:
return {
"id": row.id,
"kind": row.kind,
"hour": row.hour,
"minute": row.minute,
"interval_hours": row.interval_hours,
"enabled": row.enabled,
"last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None,
}
-102
View File
@@ -1,102 +0,0 @@
import json
from typing import Any
from app.llm.client import LLMClient
from app.projects.structuring import strip_markdown_json
MEAL_PROMPT = """
Преобразуй описание еды в JSON. Только JSON, без markdown.
Схема:
{
"meal_type": "breakfast|lunch|dinner|snack",
"description": "краткое описание",
"calories": 0,
"protein_g": 0,
"fat_g": 0,
"carbs_g": 0,
"estimated": true
}
Правила:
- Оцени ккал и БЖУ по типичным значениям для России/СНГ.
- Все числа — float/int, метрическая система (г, ккал).
- meal_type угадай из контекста или snack.
- estimated всегда true для LLM-оценки.
""".strip()
WORKOUT_PROMPT = """
Преобразуй описание тренировки в JSON. Только JSON.
Формат:
{
"title": "название",
"activity_type": "ходьба|бег|силовая|велосипед|плавание|йога|hiit|другое",
"duration_min": null,
"active_calories": null,
"met": null,
"total_calories": null,
"steps": null,
"notes": "",
"exercises": [
{"name": "имя упраж", "sets": 3, "reps": 8, "weight_kg": 80}
]
}
Правила:
- weight_kg в кг, округляй разумно.
- active_calories — только если явно указаны в тексте, иначе null.
- duration_min — длительность в минутах, если можно оценить из текста.
- met — MET по Compendium of Physical Activities, если ккал не указаны (ходьба ~3.5, бег ~9.8, силовая ~6, велосипед ~7.5, плавание ~8, йога ~3, hiit ~8).
- activity_type — тип активности для расчёта MET.
- total_calories / steps — если упомянуты в тексте, иначе null.
- Если данных нет — null или пустой массив.
""".strip()
STEPS_PROMPT = """
Преобразуй запись о шагах в JSON. Только JSON.
Формат:
{
"steps": 0,
"active_calories": null,
"notes": ""
}
Правила:
- steps — целое число шагов за день.
- active_calories — только если явно указаны.
""".strip()
async def structure_meal(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": MEAL_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
async def structure_workout(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": WORKOUT_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
async def structure_steps(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": STEPS_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
-28
View File
@@ -1,28 +0,0 @@
import asyncio
import logging
from app.db.base import SessionLocal
from app.fitness.reminders import check_reminders
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 60
async def fitness_watcher_loop() -> None:
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Fitness watcher error")
async def _tick() -> None:
db = SessionLocal()
try:
check_reminders(db)
finally:
db.close()
View File
-120
View File
@@ -1,120 +0,0 @@
"""Сборка Anima-промптов: appearance из карточки + action/outfit из контекста."""
from __future__ import annotations
from dataclasses import dataclass
ANIMA_QUALITY = "masterpiece, best quality, score_7, anime"
ANIMA_NEGATIVE = "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
_INVALID_TAGS = frozenset({
"pumped_up", "pumped", "looking_at_each_other", "couple",
"2girls", "2boys", "multiple_girls", "multiple_boys",
})
_JUNK_STANDALONE_TAGS = frozenset({
"white", "black", "skin", "ear", "ears", "girl", "boy", "fox", "wolf", "cat",
"short", "tall", "golden", "silver", "red", "blue", "green", "purple",
"pink", "brown", "eye", "eyes", "hair",
})
@dataclass(frozen=True)
class AnimaPromptBundle:
positive: str
negative: str
def _sanitize_tags(tag_str: str) -> str:
if not tag_str:
return ""
out: list[str] = []
seen: set[str] = set()
for raw in tag_str.split(","):
t = raw.strip()
if not t:
continue
key = t.lower().replace(" ", "_")
if key in seen or len(key) <= 2:
continue
if key in _INVALID_TAGS:
continue
if "_" not in key and key in _JUNK_STANDALONE_TAGS:
continue
seen.add(key)
out.append(t if "_" in t else key)
return ", ".join(out)
def _append_lora(parts: list[str], lora_name: str, lora_weight: float) -> None:
lora = (lora_name or "").strip()
if not lora:
return
weight = lora_weight if lora_weight > 0 else 0.8
parts.append(f"<lora:{lora}:{weight}>")
def build_character_image_prompt(
appearance_tags: str,
*,
action_tags: str = "",
outfit_tags: str = "",
environment_tags: str = "",
lora_name: str = "",
lora_weight: float = 0.8,
) -> AnimaPromptBundle:
"""Appearance (карточка) + action/outfit/env (контекст), только теги."""
appearance = _sanitize_tags(appearance_tags)
outfit = _sanitize_tags(outfit_tags)
action = _sanitize_tags(action_tags) or "looking_at_viewer, smile"
environment = _sanitize_tags(environment_tags) or "simple_background, soft_lighting"
parts = [ANIMA_QUALITY]
if appearance:
parts.append(appearance)
if outfit:
parts.append(outfit)
parts.append(action)
parts.append(environment)
_append_lora(parts, lora_name, lora_weight)
positive = ", ".join(p.strip() for p in parts if p.strip())
return AnimaPromptBundle(positive=positive, negative=ANIMA_NEGATIVE)
def build_draw_self_prompt(
appearance_tags: str,
*,
action_tags: str = "",
outfit_tags: str = "",
environment_tags: str = "",
lora_name: str = "",
lora_weight: float = 0.8,
) -> AnimaPromptBundle:
return build_character_image_prompt(
appearance_tags,
action_tags=action_tags,
outfit_tags=outfit_tags,
environment_tags=environment_tags,
lora_name=lora_name,
lora_weight=lora_weight,
)
def build_scene_tags_prompt(
scene_tags: str,
appearance_tags: str,
*,
lora_name: str = "",
lora_weight: float = 0.8,
) -> AnimaPromptBundle:
"""Готовые booru-теги сцены + appearance."""
scene = _sanitize_tags(scene_tags)
return build_character_image_prompt(
appearance_tags,
action_tags=scene,
outfit_tags="",
environment_tags="simple_background, soft_lighting",
lora_name=lora_name,
lora_weight=lora_weight,
)
-277
View File
@@ -1,277 +0,0 @@
import asyncio
import random
import uuid
from pathlib import Path
from typing import Any
import httpx
from app.config import get_settings
ANIMA_QUALITY_PREFIX = "masterpiece, best quality, score_7, anime"
ANIMA_DEFAULT_NEGATIVE = (
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
)
ROFL_PROMPTS = [
f"{ANIMA_QUALITY_PREFIX}, confused cat in tiny business suit, server room, meme, chibi",
f"{ANIMA_QUALITY_PREFIX}, potato with sunglasses on skateboard, absurd cartoon, silly",
f"{ANIMA_QUALITY_PREFIX}, astronaut watering houseplant on the moon, wholesome, cute",
f"{ANIMA_QUALITY_PREFIX}, rubber duck as judge at programming contest, comic style",
f"{ANIMA_QUALITY_PREFIX}, llama DJ at house party, neon lights, party, silly",
]
def _use_anima(settings) -> bool:
return bool(settings.comfyui_unet.strip()) and not settings.comfyui_checkpoint.strip()
def _build_anima_workflow(
positive: str,
negative: str,
seed: int,
settings,
) -> dict[str, Any]:
workflow: dict[str, Any] = {
"44": {
"class_type": "UNETLoader",
"inputs": {"unet_name": settings.comfyui_unet, "weight_dtype": "default"},
},
"45": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": settings.comfyui_clip,
"type": "stable_diffusion",
"device": "default",
},
},
"15": {
"class_type": "VAELoader",
"inputs": {"vae_name": settings.comfyui_vae},
},
"28": {
"class_type": "EmptyLatentImage",
"inputs": {
"width": settings.comfyui_width,
"height": settings.comfyui_height,
"batch_size": 1,
},
},
"11": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["45", 0]},
},
"12": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["45", 0]},
},
"19": {
"class_type": "KSampler",
"inputs": {
"model": ["44", 0],
"positive": ["11", 0],
"negative": ["12", 0],
"latent_image": ["28", 0],
"seed": seed,
"steps": settings.comfyui_steps,
"cfg": settings.comfyui_cfg,
"sampler_name": settings.comfyui_sampler,
"scheduler": settings.comfyui_scheduler,
"denoise": 1.0,
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["19", 0], "vae": ["15", 0]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "assistant", "images": ["8", 0]},
},
}
lora = settings.comfyui_style_lora.strip()
if lora:
workflow["46"] = {
"class_type": "LoraLoader",
"inputs": {
"lora_name": lora,
"model": ["44", 0],
"clip": ["45", 0],
"strength_model": settings.comfyui_style_lora_weight,
"strength_clip": settings.comfyui_style_lora_weight,
},
}
workflow["19"]["inputs"]["model"] = ["46", 0]
workflow["11"]["inputs"]["clip"] = ["46", 1]
workflow["12"]["inputs"]["clip"] = ["46", 1]
return workflow
def _build_checkpoint_workflow(
positive: str,
negative: str,
seed: int,
settings,
) -> dict[str, Any]:
return {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": settings.comfyui_checkpoint},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {
"width": settings.comfyui_width,
"height": settings.comfyui_height,
"batch_size": 1,
},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["4", 1]},
},
"10": {
"class_type": "KSampler",
"inputs": {
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
"seed": seed,
"steps": settings.comfyui_steps,
"cfg": settings.comfyui_cfg,
"sampler_name": settings.comfyui_sampler,
"scheduler": settings.comfyui_scheduler,
"denoise": 1.0,
},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["10", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "assistant", "images": ["8", 0]},
},
}
def _build_workflow(positive: str, negative: str, seed: int, settings) -> dict[str, Any]:
if _use_anima(settings):
return _build_anima_workflow(positive, negative, seed, settings)
return _build_checkpoint_workflow(positive, negative, seed, settings)
def _wrap_positive_prompt(prompt: str, settings) -> str:
text = prompt.strip()
if not text:
return text
if _use_anima(settings) and ANIMA_QUALITY_PREFIX.lower() not in text.lower():
return f"{ANIMA_QUALITY_PREFIX}, {text}"
return text
class ComfyUIClient:
def __init__(self) -> None:
settings = get_settings()
self.base_url = settings.comfyui_base_url.rstrip("/")
self.enabled = settings.comfyui_enabled
self.settings = settings
self.output_dir = Path(settings.generated_media_dir)
self.poll_interval = settings.comfyui_poll_interval_sec
self.timeout = settings.comfyui_timeout_sec
def _default_negative(self) -> str:
if _use_anima(self.settings):
return self.settings.comfyui_negative_prompt or ANIMA_DEFAULT_NEGATIVE
return self.settings.comfyui_negative_prompt
def _ensure_output_dir(self) -> None:
self.output_dir.mkdir(parents=True, exist_ok=True)
async def generate_image(
self,
prompt: str,
*,
negative_prompt: str | None = None,
seed: int | None = None,
) -> dict[str, Any]:
if not self.enabled:
return {"ok": False, "error": "ComfyUI отключён (COMFYUI_ENABLED=false)"}
if not _use_anima(self.settings) and not self.settings.comfyui_checkpoint.strip():
return {
"ok": False,
"error": "Не задан COMFYUI_UNET (Anima) или COMFYUI_CHECKPOINT",
}
self._ensure_output_dir()
seed = seed if seed is not None else random.randint(1, 2**31 - 1)
positive = _wrap_positive_prompt(prompt, self.settings)
negative = negative_prompt or self._default_negative()
workflow = _build_workflow(positive, negative, seed, self.settings)
client_id = str(uuid.uuid4())
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.base_url}/prompt",
json={"prompt": workflow, "client_id": client_id},
)
if response.status_code >= 400:
return {"ok": False, "error": f"ComfyUI prompt error: {response.text[:300]}"}
prompt_id = response.json().get("prompt_id")
if not prompt_id:
return {"ok": False, "error": "ComfyUI не вернул prompt_id"}
elapsed = 0.0
while elapsed < self.timeout:
await asyncio.sleep(self.poll_interval)
elapsed += self.poll_interval
hist_resp = await client.get(f"{self.base_url}/history/{prompt_id}")
if hist_resp.status_code != 200:
continue
history = hist_resp.json()
if prompt_id not in history:
continue
entry = history[prompt_id]
status = (entry.get("status") or {}).get("status_str")
if status == "error":
msgs = entry.get("status", {}).get("messages", [])
return {"ok": False, "error": f"ComfyUI workflow error: {msgs}"}
outputs = entry.get("outputs") or {}
for node_output in outputs.values():
images = node_output.get("images") or []
if not images:
continue
image_info = images[0]
view_params = {
"filename": image_info["filename"],
"subfolder": image_info.get("subfolder", ""),
"type": image_info.get("type", "output"),
}
img_resp = await client.get(f"{self.base_url}/view", params=view_params)
if img_resp.status_code != 200:
continue
filename = f"{uuid.uuid4().hex}.png"
out_path = self.output_dir / filename
out_path.write_bytes(img_resp.content)
return {
"ok": True,
"filename": filename,
"url": f"/api/v1/media/generated/{filename}",
"prompt": positive,
"negative_prompt": negative,
"backend": "anima" if _use_anima(self.settings) else "checkpoint",
}
return {"ok": False, "error": f"Таймаут генерации ({self.timeout}s)"}
def random_rofl_prompt(self) -> str:
return random.choice(ROFL_PROMPTS)
-42
View File
@@ -1,42 +0,0 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from app.memory.service import MemoryService
WEEKDAY_RU = (
"понедельник",
"вторник",
"среда",
"четверг",
"пятница",
"суббота",
"воскресенье",
)
DEFAULT_TIMEZONE = "Europe/Moscow"
def resolve_timezone(db: Session, user_id: int) -> str:
profile = MemoryService(db, user_id).get_profile()
tz = (profile.get("timezone") or "").strip()
return tz or DEFAULT_TIMEZONE
def format_datetime_context(db: Session, user_id: int) -> str:
tz_name = resolve_timezone(db, user_id)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo(DEFAULT_TIMEZONE)
tz_name = DEFAULT_TIMEZONE
now = datetime.now(tz)
weekday = WEEKDAY_RU[now.weekday()]
lines = [
"[Текущее время]",
f"Сейчас: {now.strftime('%Y-%m-%d %H:%M')} ({weekday}), часовой пояс {tz_name}.",
"Учитывай время при ответах о «сегодня», «утром», «вечером» и расписании.",
]
return "\n".join(lines)
-58
View File
@@ -1,58 +0,0 @@
from sqlalchemy.orm import Session
from app.homelab.openmeteo import OpenMeteoClient, format_weather_snapshot
from app.homelab.rss import RssClient
def build_morning_digest(db: Session, *, include_news: bool = True) -> str:
del db # timezone resolved via weather client / profile in future extensions
weather_client = OpenMeteoClient()
weather = weather_client.fetch_forecast(hours_ahead=12, days_ahead=7)
lines = ["🌤 **Утренний дайджест**", ""]
if weather.get("ok"):
cur = weather.get("current") or {}
lines.append(
f"**Погода ({weather.get('location')})**: "
f"{cur.get('temperature_c')}°C, {cur.get('conditions')}, "
f"ветер {cur.get('wind_speed_kmh')} км/ч."
)
lines.append(weather_client.rain_summary(hours_ahead=12, daily=weather.get("daily")))
daily = weather_client.daily_summary(days_ahead=7)
if daily:
lines.append(f"**На неделю**: {daily}")
else:
lines.append(f"**Погода**: недоступна ({weather.get('error', 'ошибка')}).")
if include_news:
headlines = RssClient().fetch_headlines(limit=7)
lines.append("")
if headlines:
lines.append("**Новости:**")
for item in headlines:
title = item.get("title", "")
link = item.get("link", "")
source = item.get("source", "")
if link:
lines.append(f"- [{title}]({link}) — {source}")
else:
lines.append(f"- {title}{source}")
else:
lines.append("**Новости**: ленты временно недоступны.")
return "\n".join(lines)
def build_weather_briefing(hours_ahead: int = 12, days_ahead: int = 7, include_news: bool = False) -> dict:
client = OpenMeteoClient()
weather = client.fetch_forecast(hours_ahead=hours_ahead, days_ahead=days_ahead)
result = {
"weather": weather,
"rain_summary": client.rain_summary(hours_ahead=hours_ahead, daily=weather.get("daily")) if weather.get("ok") else "",
"daily_summary": client.daily_summary(days_ahead=days_ahead) if weather.get("ok") else "",
"context": format_weather_snapshot(weather),
}
if include_news:
result["news"] = RssClient().fetch_headlines(limit=7)
return result
-251
View File
@@ -1,251 +0,0 @@
from typing import Any
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.config import get_settings
from app.homelab.anima_prompt import AnimaPromptBundle, build_character_image_prompt, build_scene_tags_prompt
from app.homelab.comfyui import ComfyUIClient
from app.homelab.scene_tags import extract_scene_tags, looks_like_booru_tags
from app.integrations.rp_chat import RpChatClient
def _card_image_settings(db: Session, user_id: int) -> dict[str, Any]:
return CharacterService(db, user_id).get_card().get("data", {})
def _session_messages(db: Session, session_id: int | None, limit: int = 8) -> list[dict[str, str]]:
if not session_id:
return []
from sqlalchemy import select
from app.db.models import Message
rows = db.scalars(
select(Message)
.where(
Message.session_id == session_id,
Message.role.in_(("user", "assistant")),
)
.order_by(Message.created_at.desc())
.limit(limit)
).all()
rows = list(reversed(rows))
return [{"role": m.role, "content": (m.content or "").strip()} for m in rows if m.content.strip()]
def _last_user_message(messages: list[dict[str, str]]) -> str:
for msg in reversed(messages):
if msg.get("role") == "user" and (msg.get("content") or "").strip():
return str(msg["content"]).strip()
return ""
def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str:
if not lora_name or f"<lora:{lora_name}" in positive:
return positive
return f"{positive} <lora:{lora_name}:{lora_weight}>"
async def _generate_from_bundle(
bundle: AnimaPromptBundle,
*,
backend: str,
persona_id: str = "",
prompt_mode: str = "direct",
tag_source: str = "",
) -> dict[str, Any]:
if backend == "rp_chat":
client = RpChatClient()
gen_result = await client.generate(bundle.positive, bundle.negative)
if not gen_result.get("ok"):
return gen_result
saved = await client.save_image_locally(gen_result["image_path"])
if not saved.get("ok"):
return saved
return {
"ok": True,
"url": saved["url"],
"filename": saved["filename"],
"prompt": bundle.positive,
"negative_prompt": bundle.negative,
"backend": "rp_chat",
"persona_id": persona_id,
"prompt_mode": prompt_mode,
"tag_source": tag_source,
}
result = await ComfyUIClient().generate_image(
bundle.positive,
negative_prompt=bundle.negative,
)
if result.get("ok"):
result["backend"] = "comfyui_local"
result["prompt_mode"] = prompt_mode
result["negative_prompt"] = bundle.negative
result["tag_source"] = tag_source
return result
async def _build_contextual_bundle(
appearance: str,
*,
request: str,
messages: list[dict[str, str]],
lora_name: str,
lora_weight: float,
) -> tuple[AnimaPromptBundle, str]:
tags = await extract_scene_tags(request, messages, appearance_tags=appearance)
bundle = build_character_image_prompt(
appearance,
action_tags=tags.get("action_tags", ""),
outfit_tags=tags.get("outfit_tags", ""),
environment_tags=tags.get("environment_tags", ""),
lora_name=lora_name,
lora_weight=lora_weight,
)
return bundle, str(tags.get("source") or "")
async def generate_image(
db: Session,
*,
user_id: int,
session_id: int | None = None,
draw_self: bool = False,
scene_description: str = "",
) -> dict[str, Any]:
card = _card_image_settings(db, user_id)
settings = get_settings()
if not card.get("sd_enabled", True):
return {"ok": False, "error": "Генерация изображений отключена в настройках персонажа"}
if not draw_self and not scene_description.strip():
return {"ok": False, "error": "Нужен draw_self=true или scene_description"}
-70
View File
@@ -1,70 +0,0 @@
import hashlib
import json
import time
from typing import Any
from sqlalchemy.orm import Session
from app.config import get_settings
from app.homelab.netdata import NetdataClient
from app.homelab.state import get_state, set_state
ALERT_COOLDOWN_SEC = 1800
def _alarm_key(alarm: dict[str, Any]) -> str:
return f"{alarm.get('name')}:{alarm.get('status')}"
def check_netdata_alerts(db: Session) -> list[str]:
settings = get_settings()
if not settings.netdata_alerts_enabled:
return []
result = NetdataClient().fetch_alarms()
if not result.get("ok"):
return []
alarms = result.get("alarms") or []
significant = [
a for a in alarms
if (a.get("status") or "").lower() in ("warning", "critical", "raised")
]
if not significant:
return []
prev_raw = get_state(db, "netdata_alarm_hashes") or "{}"
try:
prev_map: dict[str, float] = json.loads(prev_raw)
except json.JSONDecodeError:
prev_map = {}
now = time.time()
notices: list[str] = []
new_map = dict(prev_map)
for alarm in significant:
key = _alarm_key(alarm)
digest = hashlib.sha256(json.dumps(alarm, sort_keys=True).encode()).hexdigest()[:16]
state_key = f"netdata:{key}:{digest}"
last_sent = prev_map.get(state_key, 0)
if now - last_sent < ALERT_COOLDOWN_SEC:
continue
host = alarm.get("host") or "server"
value = alarm.get("value_string") or ""
info = alarm.get("info") or alarm.get("name") or "алерт"
status = alarm.get("status") or "alert"
link = settings.netdata_public_url
link_part = f" [Netdata]({link})" if link else ""
notices.append(
f"⚠️ **Netdata** · {host}: {info}{status}"
+ (f" ({value})" if value else "")
+ link_part
)
new_map[state_key] = now
if notices:
set_state(db, "netdata_alarm_hashes", json.dumps(new_map))
return notices
-53
View File
@@ -1,53 +0,0 @@
from typing import Any
import httpx
from app.config import get_settings
class NetdataClient:
def __init__(self) -> None:
settings = get_settings()
self.base_url = settings.netdata_base_url.rstrip("/")
self.enabled = settings.netdata_alerts_enabled
def fetch_alarms(self) -> dict[str, Any]:
if not self.enabled:
return {"ok": False, "error": "Netdata alerts disabled", "alarms": []}
try:
with httpx.Client(timeout=15.0) as client:
response = client.get(f"{self.base_url}/api/v1/alarms", params={"all": "true"})
response.raise_for_status()
data = response.json()
except Exception as exc:
return {"ok": False, "error": str(exc), "alarms": []}
alarms_raw = data.get("alarms") or {}
alarms: list[dict[str, Any]] = []
if isinstance(alarms_raw, dict):
for name, info in alarms_raw.items():
if not isinstance(info, dict):
continue
status = (info.get("status") or "").lower()
if status in ("clear", "undefined", "uninitialized", ""):
continue
alarms.append({
"name": name,
"status": status,
"value_string": info.get("value_string") or info.get("value") or "",
"chart": info.get("chart") or "",
"host": info.get("hostname") or info.get("host") or "localhost",
"info": info.get("info") or "",
})
return {"ok": True, "alarms": alarms}
def fetch_info(self) -> dict[str, Any]:
try:
with httpx.Client(timeout=10.0) as client:
response = client.get(f"{self.base_url}/api/v1/info")
response.raise_for_status()
return {"ok": True, "info": response.json()}
except Exception as exc:
return {"ok": False, "error": str(exc)}
-5
View File
@@ -1,5 +0,0 @@
from app.chat.notice_inbox import post_notice_to_latest_chat
def post_chat_notice(content: str) -> None:
post_notice_to_latest_chat(content)
-525
View File
@@ -1,525 +0,0 @@
import time
from typing import Any
import httpx
from app.config import get_settings
WEATHER_CODES: dict[int, str] = {
0: "ясно",
1: "преимущественно ясно",
2: "переменная облачность",
3: "пасмурно",
45: "туман",
48: "изморозь",
51: "морось",
53: "морось",
55: "морось",
61: "дождь",
63: "дождь",
65: "сильный дождь",
71: "снег",
73: "снег",
75: "сильный снег",
80: "ливень",
81: "ливень",
82: "сильный ливень",
95: "гроза",
96: "гроза с градом",
99: "гроза с градом",
}
WEATHER_QUERY_KEYWORDS = (
"погод", "дожд", "снег", "ветер", "температур", "градус", "мороз", "жар",
"на улице", "одеть", "зонт", "прогноз", "завтра", "послезавтра", "выходн",
"weather", "rain", "forecast", "umbrella", "outside",
)
_cache: dict[str, Any] = {
"data": None,
"fetched_at": 0.0,
"expires_at": 0.0,
"source": "local",
"local_coverage": {"current": [], "hourly": [], "daily": []},
"merged_fields": [],
}
CURRENT_FIELDS = (
"temperature_2m",
"apparent_temperature",
"relative_humidity_2m",
"precipitation",
"weather_code",
"wind_speed_10m",
)
HOURLY_FIELDS = (
"temperature_2m",
"precipitation_probability",
"precipitation",
"weather_code",
)
DAILY_FIELDS = (
"weather_code",
"temperature_2m_max",
"temperature_2m_min",
"precipitation_sum",
"precipitation_probability_max",
"wind_speed_10m_max",
)
RECOMMENDED_SYNC_DOMAINS = "dwd_icon,ncep_gfs013,ncep_gefs025"
RECOMMENDED_SYNC_VARIABLES = (
"temperature_2m,dew_point_2m,relative_humidity_2m,precipitation_probability,"
"precipitation,rain,cloud_cover,weather_code,wind_u_component_10m,wind_v_component_10m"
)
SYNC_HINT = (
"Локальный open-meteo-sync отдаёт неполные данные. "
f"SYNC_DOMAINS={RECOMMENDED_SYNC_DOMAINS} "
f"SYNC_VARIABLES={RECOMMENDED_SYNC_VARIABLES} (~12 GB). "
"Документация: github.com/open-meteo/open-data/tree/main/tutorial_weather_api"
)
PRECIP_PROB_HINT = (
"Для вероятности дождя добавь ncep_gefs025 в SYNC_DOMAINS "
"и precipitation_probability в SYNC_VARIABLES."
)
def weather_query_relevant(query: str) -> bool:
q = (query or "").lower()
return any(kw in q for kw in WEATHER_QUERY_KEYWORDS)
def _hourly_series(hourly: dict[str, Any], key: str) -> list[Any]:
values = hourly.get(key)
return values if isinstance(values, list) else []
def _daily_series(daily: dict[str, Any], key: str) -> list[Any]:
values = daily.get(key)
return values if isinstance(values, list) else []
def _hourly_start_index(times: list[str], anchor_time: str | None) -> int:
if not times:
return 0
if not anchor_time:
return 0
best = 0
for i, t in enumerate(times):
if t <= anchor_time:
best = i
else:
break
return best
def _field_coverage(raw: dict[str, Any]) -> dict[str, list[str]]:
current = raw.get("current") or {}
hourly = raw.get("hourly") or {}
daily = raw.get("daily") or {}
current_present = [key for key in CURRENT_FIELDS if current.get(key) is not None]
hourly_present = []
for key in HOURLY_FIELDS:
series = _hourly_series(hourly, key)
if any(v is not None for v in series):
hourly_present.append(key)
daily_present = []
for key in DAILY_FIELDS:
series = _daily_series(daily, key)
if any(v is not None for v in series):
daily_present.append(key)
return {"current": current_present, "hourly": hourly_present, "daily": daily_present}
def _coverage_sufficient(coverage: dict[str, list[str]]) -> bool:
current = set(coverage.get("current") or [])
hourly = set(coverage.get("hourly") or [])
if "weather_code" not in current:
return False
if len(current) < 3:
return False
if "weather_code" not in hourly and "temperature_2m" not in hourly:
return False
return True
def _local_needs_sync_hint(local_coverage: dict[str, list[str]]) -> bool:
current = set(local_coverage.get("current") or [])
hourly = set(local_coverage.get("hourly") or [])
if "temperature_2m" not in current:
return True
if "weather_code" not in current:
return True
if "temperature_2m" not in hourly:
return True
return False
def _missing_precip_probability(coverage: dict[str, list[str]]) -> bool:
return "precipitation_probability" not in set(coverage.get("hourly") or [])
def _fmt_num(value: Any, *, suffix: str = "") -> str:
if value is None:
return ""
if isinstance(value, float):
text = f"{value:.1f}".rstrip("0").rstrip(".")
else:
text = str(value)
return f"{text}{suffix}" if suffix else text
def _conditions(code: Any) -> str:
if code is None:
return "неизвестно"
return WEATHER_CODES.get(int(code), "неизвестно")
def _format_day_label(date_str: str, index: int) -> str:
if index == 0:
return "Сегодня"
if index == 1:
return "Завтра"
if not date_str:
return f"День {index + 1}"
parts = date_str.split("-")
if len(parts) == 3:
return f"{parts[2]}.{parts[1]}"
return date_str
def _merge_hourly_field(target: dict[str, Any], source: dict[str, Any], field: str) -> bool:
hourly_t = target.setdefault("hourly", {})
hourly_s = source.get("hourly") or {}
src = hourly_s.get(field)
if not isinstance(src, list) or not any(v is not None for v in src):
return False
dst = hourly_t.get(field)
if isinstance(dst, list) and len(dst) == len(src):
hourly_t[field] = [
dst[i] if dst[i] is not None else src[i]
for i in range(len(src))
]
else:
hourly_t[field] = src
return True
class OpenMeteoClient:
def __init__(self) -> None:
settings = get_settings()
self.base_url = settings.openmeteo_base_url.rstrip("/")
self.fallback_url = (settings.openmeteo_fallback_url or "").strip().rstrip("/")
self.fallback_on_partial = settings.openmeteo_fallback_on_partial
self.lat = settings.weather_lat
self.lon = settings.weather_lon
self.location_name = settings.weather_location_name
self.cache_ttl = settings.weather_cache_sec
self.forecast_days = max(2, int(settings.weather_forecast_days or 7))
def _request_params(self) -> dict[str, Any]:
return {
"latitude": self.lat,
"longitude": self.lon,
"current": ",".join(CURRENT_FIELDS),
"hourly": ",".join(HOURLY_FIELDS),
"daily": ",".join(DAILY_FIELDS),
"timezone": "auto",
"forecast_days": self.forecast_days,
}
def _fetch_from_url(self, base_url: str) -> dict[str, Any]:
with httpx.Client(timeout=20.0) as client:
response = client.get(f"{base_url.rstrip('/')}/v1/forecast", params=self._request_params())
response.raise_for_status()
return response.json()
def _fetch_raw(self) -> dict[str, Any]:
now = time.time()
if _cache["data"] and now < _cache["expires_at"]:
return _cache["data"]
local_raw = self._fetch_from_url(self.base_url)
local_coverage = _field_coverage(local_raw)
source = "local"
raw = local_raw
merged_fields: list[str] = []
need_fallback = (
self.fallback_on_partial
and self.fallback_url
and self.fallback_url.rstrip("/") != self.base_url
)
if need_fallback:
try:
fallback_raw = self._fetch_from_url(self.fallback_url)
fallback_coverage = _field_coverage(fallback_raw)
if not _coverage_sufficient(local_coverage) and _coverage_sufficient(fallback_coverage):
raw = fallback_raw
source = "fallback"
elif _missing_precip_probability(local_coverage) and not _missing_precip_probability(fallback_coverage):
if _merge_hourly_field(raw, fallback_raw, "precipitation_probability"):
merged_fields.append("precipitation_probability")
source = "merged"
except Exception:
pass
_cache["data"] = raw
_cache["fetched_at"] = now
_cache["expires_at"] = now + self.cache_ttl
_cache["source"] = source
_cache["local_coverage"] = local_coverage
_cache["merged_fields"] = merged_fields
return raw
def cache_status(self) -> dict[str, Any]:
now = time.time()
fetched_at = float(_cache.get("fetched_at") or 0)
expires_at = float(_cache.get("expires_at") or 0)
has_data = _cache.get("data") is not None
age_sec = int(now - fetched_at) if fetched_at else None
expires_in_sec = max(0, int(expires_at - now)) if expires_at else None
return {
"has_data": has_data,
"cached": bool(has_data and expires_at and now < expires_at),
"fetched_at": fetched_at or None,
"age_sec": age_sec,
"ttl_sec": self.cache_ttl,
"expires_in_sec": expires_in_sec,
"source": _cache.get("source") or "local",
"merged_fields": list(_cache.get("merged_fields") or []),
}
def _build_hourly_slice(self, raw: dict[str, Any], hours_ahead: int) -> list[dict[str, Any]]:
current = raw.get("current") or {}
hourly = raw.get("hourly") or {}
times = hourly.get("time") or []
start = _hourly_start_index(times, current.get("time"))
end = min(start + hours_ahead, len(times))
rows: list[dict[str, Any]] = []
for i in range(start, end):
code = _hourly_series(hourly, "weather_code")[i] if i < len(_hourly_series(hourly, "weather_code")) else None
temp_series = _hourly_series(hourly, "temperature_2m")
precip_series = _hourly_series(hourly, "precipitation")
prob_series = _hourly_series(hourly, "precipitation_probability")
rows.append({
"time": times[i],
"temperature_c": temp_series[i] if i < len(temp_series) else None,
"precipitation_mm": precip_series[i] if i < len(precip_series) else None,
"precipitation_probability": prob_series[i] if i < len(prob_series) else None,
"weather_code": code,
"conditions": _conditions(code),
})
return rows
def _build_daily_slice(self, raw: dict[str, Any], days_ahead: int) -> list[dict[str, Any]]:
daily = raw.get("daily") or {}
times = daily.get("time") or []
limit = min(days_ahead, len(times))
rows: list[dict[str, Any]] = []
for i in range(limit):
code = _daily_series(daily, "weather_code")[i] if i < len(_daily_series(daily, "weather_code")) else None
rows.append({
"date": times[i],
"label": _format_day_label(times[i], i),
"temperature_max_c": _daily_series(daily, "temperature_2m_max")[i] if i < len(_daily_series(daily, "temperature_2m_max")) else None,
"temperature_min_c": _daily_series(daily, "temperature_2m_min")[i] if i < len(_daily_series(daily, "temperature_2m_min")) else None,
"precipitation_sum_mm": _daily_series(daily, "precipitation_sum")[i] if i < len(_daily_series(daily, "precipitation_sum")) else None,
"precipitation_probability_max": _daily_series(daily, "precipitation_probability_max")[i] if i < len(_daily_series(daily, "precipitation_probability_max")) else None,
"wind_speed_max_kmh": _daily_series(daily, "wind_speed_10m_max")[i] if i < len(_daily_series(daily, "wind_speed_10m_max")) else None,
"weather_code": code,
"conditions": _conditions(code),
})
return rows
def fetch_forecast(self, hours_ahead: int = 12, days_ahead: int = 7) -> dict[str, Any]:
hours_ahead = max(1, min(int(hours_ahead), 168))
days_ahead = max(1, min(int(days_ahead), self.forecast_days))
try:
raw = self._fetch_raw()
except Exception as exc:
return {"ok": False, "error": str(exc), "location": self.location_name}
current = raw.get("current") or {}
code = current.get("weather_code")
coverage = _field_coverage(raw)
local_coverage = _cache.get("local_coverage") or coverage
sync_hint = ""
if _local_needs_sync_hint(local_coverage):
sync_hint = SYNC_HINT
elif _missing_precip_probability(local_coverage):
sync_hint = PRECIP_PROB_HINT
return {
"ok": True,
"location": self.location_name,
"data_source": _cache.get("source") or "local",
"merged_fields": list(_cache.get("merged_fields") or []),
"local_field_coverage": local_coverage,
"field_coverage": coverage,
"sync_hint": sync_hint,
"current": {
"time": current.get("time"),
"temperature_c": current.get("temperature_2m"),
"apparent_temperature_c": current.get("apparent_temperature"),
"humidity_pct": current.get("relative_humidity_2m"),
"precipitation_mm": current.get("precipitation"),
"wind_speed_kmh": current.get("wind_speed_10m"),
"weather_code": code,
"conditions": _conditions(code),
},
"hourly": self._build_hourly_slice(raw, hours_ahead),
"daily": self._build_daily_slice(raw, days_ahead),
}
def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]:
return self.fetch_forecast(hours_ahead=hours_ahead, days_ahead=min(7, self.forecast_days))
def rain_summary(self, hours_ahead: int = 12, daily: list[dict[str, Any]] | None = None) -> str:
data = self.fetch_forecast(hours_ahead=hours_ahead, days_ahead=2)
if not data.get("ok"):
return f"Погода недоступна: {data.get('error', 'ошибка')}"
rainy_hours = []
for hour in data.get("hourly") or []:
prob = hour.get("precipitation_probability")
precip = hour.get("precipitation_mm") or 0
if (prob is not None and prob >= 40) or precip > 0:
time_str = (hour.get("time") or "")[11:16]
prob_text = f"{prob}%" if prob is not None else ""
rainy_hours.append(f"{time_str} ({prob_text}, {precip} мм)")
lines: list[str] = []
if rainy_hours:
lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6]))
else:
lines.append("Существенных осадков в ближайшие часы не ожидается.")
days = daily if daily is not None else data.get("daily") or []
if len(days) > 1:
tomorrow = days[1]
tmax = tomorrow.get("temperature_max_c")
tmin = tomorrow.get("temperature_min_c")
prob = tomorrow.get("precipitation_probability_max")
precip = tomorrow.get("precipitation_sum_mm") or 0
cond = tomorrow.get("conditions") or "неизвестно"
prob_part = f", дождь до {prob}%" if prob is not None and prob >= 30 else ""
precip_part = f", {precip} мм" if precip > 0 else ""
lines.append(
f"Завтра: {_fmt_num(tmin)}{_fmt_num(tmax, suffix='°C')}, {cond}{prob_part}{precip_part}."
)
return " ".join(lines)
def daily_summary(self, days_ahead: int = 7) -> str:
data = self.fetch_forecast(hours_ahead=1, days_ahead=days_ahead)
if not data.get("ok"):
return ""
parts = []
for day in data.get("daily") or []:
label = day.get("label") or day.get("date")
tmax = day.get("temperature_max_c")
tmin = day.get("temperature_min_c")
cond = day.get("conditions") or "неизвестно"
prob = day.get("precipitation_probability_max")
prob_part = f", дождь до {prob}%" if prob is not None and prob >= 30 else ""
parts.append(f"{label}: {_fmt_num(tmin)}{_fmt_num(tmax, suffix='°C')}, {cond}{prob_part}")
return "; ".join(parts)
def format_weather_snapshot(data: dict[str, Any] | None = None, *, include_daily: bool = True) -> str:
client = OpenMeteoClient()
snapshot = data if data is not None else client.fetch_forecast(hours_ahead=6, days_ahead=3)
lines = ["[Погода]"]
if not snapshot.get("ok"):
lines.append(f"Данные недоступны ({snapshot.get('error', 'ошибка')}).")
lines.append("Для точного ответа вызови get_weather.")
return "\n".join(lines)
cur = snapshot.get("current") or {}
apparent = cur.get("apparent_temperature_c")
wind = cur.get("wind_speed_kmh")
apparent_part = f", ощущается {_fmt_num(apparent, suffix='°C')}" if apparent is not None else ""
wind_part = f", ветер {_fmt_num(wind, suffix=' км/ч')}" if wind is not None else ""
lines.append(
f"{snapshot.get('location')}: {_fmt_num(cur.get('temperature_c'), suffix='°C')}"
f"{apparent_part}, {cur.get('conditions') or 'неизвестно'}{wind_part}."
)
rainy_hours = []
for hour in snapshot.get("hourly") or []:
prob = hour.get("precipitation_probability")
precip = hour.get("precipitation_mm") or 0
if (prob is not None and prob >= 40) or precip > 0:
time_str = (hour.get("time") or "")[11:16]
prob_text = f"{prob}%" if prob is not None else ""
rainy_hours.append(f"{time_str} ({prob_text}, {precip} мм)")
if rainy_hours:
lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6]))
else:
lines.append("Существенных осадков в ближайшие часы не ожидается.")
if include_daily:
days = snapshot.get("daily") or []
if len(days) > 1:
tomorrow = days[1]
lines.append(
f"Завтра: {_fmt_num(tomorrow.get('temperature_min_c'))}"
f"{_fmt_num(tomorrow.get('temperature_max_c'), suffix='°C')}, "
f"{tomorrow.get('conditions') or 'неизвестно'}."
)
if len(days) > 2:
week_bits = []
for day in days[2:7]:
week_bits.append(
f"{day.get('label')}: {_fmt_num(day.get('temperature_min_c'))}"
f"{_fmt_num(day.get('temperature_max_c'), suffix='°C')}"
)
if week_bits:
lines.append("Далее: " + "; ".join(week_bits) + ".")
lines.append("Подробнее — get_weather (hours_ahead, days_ahead).")
return "\n".join(lines)
def build_weather_dashboard(hours_ahead: int = 12, days_ahead: int = 7) -> dict[str, Any]:
client = OpenMeteoClient()
weather = client.fetch_forecast(hours_ahead=hours_ahead, days_ahead=days_ahead)
return {
"weather": weather,
"rain_summary": client.rain_summary(hours_ahead=hours_ahead, daily=weather.get("daily")) if weather.get("ok") else "",
"daily_summary": client.daily_summary(days_ahead=days_ahead) if weather.get("ok") else "",
"assistant_context": format_weather_snapshot(weather),
"cache": client.cache_status(),
"config": {
"location": client.location_name,
"latitude": client.lat,
"longitude": client.lon,
"openmeteo_base_url": client.base_url,
"cache_ttl_sec": client.cache_ttl,
"forecast_days": client.forecast_days,
"timezone": "auto",
},
"available_fields": {
"current": list(CURRENT_FIELDS),
"hourly": list(HOURLY_FIELDS),
"daily": list(DAILY_FIELDS),
},
"field_coverage": weather.get("field_coverage") if weather.get("ok") else {"current": [], "hourly": [], "daily": []},
"local_field_coverage": weather.get("local_field_coverage") if weather.get("ok") else {"current": [], "hourly": [], "daily": []},
"data_source": weather.get("data_source", "local") if weather.get("ok") else "local",
"merged_fields": weather.get("merged_fields", []) if weather.get("ok") else [],
"sync_hint": weather.get("sync_hint", "") if weather.get("ok") else SYNC_HINT,
"recommended_sync": {
"domains": RECOMMENDED_SYNC_DOMAINS,
"variables": RECOMMENDED_SYNC_VARIABLES,
},
"assistant_tools": {
"get_weather": "Сейчас + почасово (hours_ahead до 168) + по дням (days_ahead до 16)",
"get_morning_briefing": "Погода + заголовки RSS-новостей",
},
"system_prompt": "Блок [Погода] в system prompt — только если запрос про погоду/одежду/прогноз.",
}
-64
View File
@@ -1,64 +0,0 @@
import time
from typing import Any
from urllib.parse import urlparse
import feedparser
import httpx
from app.config import get_settings
_cache: dict[str, Any] = {"items": [], "expires_at": 0.0}
class RssClient:
def __init__(self) -> None:
settings = get_settings()
self.urls = settings.news_rss_urls_list
self.cache_ttl = settings.news_cache_sec
self.max_items = settings.news_max_items
def _fetch_feed(self, url: str) -> list[dict[str, str]]:
headers = {"User-Agent": "HomeAIAssistant/1.0 (+https://assistant.grigowashere.ru)"}
with httpx.Client(timeout=20.0, headers=headers, follow_redirects=True) as client:
response = client.get(url)
response.raise_for_status()
parsed = feedparser.parse(response.content)
source = urlparse(url).netloc or url
items: list[dict[str, str]] = []
for entry in parsed.entries[: self.max_items]:
link = (entry.get("link") or "").strip()
title = (entry.get("title") or "").strip()
if not title:
continue
items.append({
"title": title,
"link": link,
"source": source,
"published": (entry.get("published") or entry.get("updated") or "").strip(),
})
return items
def fetch_headlines(self, limit: int | None = None) -> list[dict[str, str]]:
now = time.time()
if _cache["items"] and now < _cache["expires_at"]:
items = _cache["items"]
else:
merged: list[dict[str, str]] = []
seen_links: set[str] = set()
for url in self.urls:
try:
for item in self._fetch_feed(url):
link = item.get("link") or item["title"]
if link in seen_links:
continue
seen_links.add(link)
merged.append(item)
except Exception:
continue
_cache["items"] = merged
_cache["expires_at"] = now + self.cache_ttl
items = merged
cap = limit or self.max_items
return items[:cap]
-189
View File
@@ -1,189 +0,0 @@
"""Извлечение action/outfit/environment в danbooru-теги из запроса и чата."""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from app.config import get_settings
from app.llm.client import LLMClient
from app.projects.structuring import strip_markdown_json
logger = logging.getLogger(__name__)
SCENE_TAGS_PROMPT = """
Ты переводишь запрос на иллюстрацию персонажа в теги Stable Diffusion (danbooru/e621).
Ответь ТОЛЬКО JSON без markdown:
{
"action_tags": "pose, framing, expression, activity — 3-10 тегов через запятую",
"outfit_tags": "одежда и аксессуары или пустая строка",
"environment_tags": "локация, освещение, время суток — 2-6 тегов или пустая строка"
}
Правила:
- Только настоящие booru-теги. Пробелы в тегах underscore (full_body, looking_at_viewer).
- НЕ дублируй внешность персонажа (волосы, глаза, уши, хвост, телосложение) они уже в appearance_tags.
- НЕ включай quality-теги, 1girl, имена моделей.
- «полный рост» / full body full_body, standing (НЕ upper_body, НЕ portrait).
- «портрет» / крупный план upper_body, portrait или close-up.
- Одежду бери из запроса и контекста чата (фартук, платье, домашняя одежда соответствующие теги).
- Если фон не указан simple_background, soft_lighting.
- Запрещённые теги: pumped_up, looking_at_each_other, couple, 2girls.
""".strip()
def _chat_excerpt(messages: list[dict[str, str]], limit: int = 6) -> str:
lines: list[str] = []
for msg in messages[-limit:]:
role = msg.get("role", "user")
content = (msg.get("content") or "").strip()
if not content or role not in ("user", "assistant"):
continue
label = "Пользователь" if role == "user" else "Персонаж"
if len(content) > 600:
content = content[:597] + "..."
lines.append(f"{label}: {content}")
return "\n".join(lines)
def rule_based_scene_tags(request: str, messages: list[dict[str, str]] | None = None) -> dict[str, str]:
"""Быстрый fallback без LLM."""
blob = " ".join(
[
request or "",
_chat_excerpt(messages or [], limit=4),
]
).lower()
action: list[str] = []
if re.search(r"полный\s+рост|full[\s_-]?body|в\s+полный\s+рост|целиком|head\s+to\s+toe", blob):
action.extend(["full_body", "standing", "looking_at_viewer"])
elif re.search(r"портрет|portrait|крупн|upper[\s_-]?body|бust|бюст", blob):
action.extend(["upper_body", "portrait", "looking_at_viewer"])
elif re.search(r"сидит|sitting|на стуле", blob):
action.extend(["sitting", "looking_at_viewer"])
elif re.search(r"лежит|lying|на кровати", blob):
action.extend(["lying", "on_bed", "looking_at_viewer"])
else:
action.extend(["looking_at_viewer", "smile"])
if re.search(r"смущ|embarrass|blush|стесн", blob):
action.append("blush")
if re.search(r"улыб|smile|happy", blob):
action.append("smile")
outfit: list[str] = []
outfit_map = (
(r"фартук|apron", "apron"),
(r"плать|dress", "dress"),
(r"халат|robe|bathrobe", "robe"),
(r"купальник|swimsuit|bikini", "swimsuit"),
(r"школьн|school uniform|serafuku", "school_uniform"),
(r"обнаж|nude|голая|topless", "nude"),
(r"джинс|jeans", "jeans"),
(r"свитер|sweater", "sweater"),
)
for pattern, tag in outfit_map:
if re.search(pattern, blob):
outfit.append(tag)
env: list[str] = []
if re.search(r"комнат|bedroom|дома|indoors|room", blob):
env.extend(["indoors", "soft_lighting"])
elif re.search(r"улиц|outdoors|street|парк|park", blob):
env.extend(["outdoors", "daylight"])
else:
env.extend(["simple_background", "soft_lighting"])
return {
"action_tags": ", ".join(dict.fromkeys(action)),
"outfit_tags": ", ".join(dict.fromkeys(outfit)),
"environment_tags": ", ".join(dict.fromkeys(env)),
}
def _parse_tags_json(raw: str) -> dict[str, str] | None:
try:
data = json.loads(strip_markdown_json(raw))
except json.JSONDecodeError:
return None
if not isinstance(data, dict):
return None
return {
"action_tags": str(data.get("action_tags") or "").strip(),
"outfit_tags": str(data.get("outfit_tags") or "").strip(),
"environment_tags": str(data.get("environment_tags") or "").strip(),
}
async def extract_scene_tags(
request: str,
messages: list[dict[str, str]] | None = None,
*,
appearance_tags: str = "",
) -> dict[str, Any]:
"""
action/outfit/environment в booru-тегах.
Возвращает dict с полями action_tags, outfit_tags, environment_tags, source.
"""
req = (request or "").strip()
if not req and messages:
for msg in reversed(messages):
if msg.get("role") == "user" and (msg.get("content") or "").strip():
req = str(msg["content"]).strip()
break
if looks_like_booru_tags(req):
parts = [p.strip() for p in req.split(",") if p.strip()]
return {
"action_tags": ", ".join(parts),
"outfit_tags": "",
"environment_tags": "simple_background, soft_lighting",
"source": "booru_literal",
}
fallback = rule_based_scene_tags(req, messages)
settings = get_settings()
extract_model = settings.memory_extract_model.strip() or None
excerpt = _chat_excerpt(messages or [])
user_block = f"Запрос на иллюстрацию:\n{req or '(не указан — выведи нейтральную позу)'}"
if appearance_tags.strip():
user_block += f"\n\nAppearance (НЕ повторять в action/outfit): {appearance_tags.strip()}"
if excerpt:
user_block += f"\n\nКонтекст чата:\n{excerpt}"
try:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": SCENE_TAGS_PROMPT},
{"role": "user", "content": user_block},
],
temperature=0.2,
model=extract_model,
for_extraction=True,
)
parsed = _parse_tags_json(result.get("content") or "")
if parsed and parsed.get("action_tags"):
parsed["source"] = "llm"
if not parsed.get("environment_tags"):
parsed["environment_tags"] = fallback["environment_tags"]
return parsed
except Exception:
logger.exception("scene tag LLM extraction failed")
fallback["source"] = "rules"
return fallback
def looks_like_booru_tags(text: str) -> bool:
raw = (text or "").strip()
if not raw or len(raw) > 400:
return False
if raw.count(",") >= 2:
return True
return bool(re.search(r"\b\d+(girl|boy)s?\b", raw, re.I))
-22
View File
@@ -1,22 +0,0 @@
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import AssistantState
def get_state(db: Session, key: str) -> str | None:
row = db.get(AssistantState, key)
return row.value if row else None
def set_state(db: Session, key: str, value: str) -> None:
row = db.get(AssistantState, key)
now = datetime.now(timezone.utc)
if row:
row.value = value
row.updated_at = now
else:
db.add(AssistantState(key=key, value=value, updated_at=now))
db.commit()
-151
View File
@@ -1,151 +0,0 @@
import asyncio
import logging
import random
from datetime import datetime
from zoneinfo import ZoneInfo
import httpx
from app.config import get_settings
from app.db.base import SessionLocal
from app.homelab.comfyui import ComfyUIClient
from app.homelab.context import resolve_timezone
from app.homelab.digest import build_morning_digest
from app.homelab.monitoring import check_netdata_alerts
from app.homelab.notices import post_chat_notice
from app.homelab.state import get_state, set_state
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 60
_netdata_tick = 0
async def homelab_watcher_loop() -> None:
global _netdata_tick
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick_morning_digest()
await _tick_rofl()
settings = get_settings()
_netdata_tick += WATCH_INTERVAL_SEC
if _netdata_tick >= settings.netdata_poll_interval_sec:
_netdata_tick = 0
await _tick_netdata()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Homelab watcher error")
async def _tick_morning_digest() -> None:
settings = get_settings()
if not settings.morning_digest_enabled:
return
db = SessionLocal()
try:
tz_name = resolve_timezone(db)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
now = datetime.now(tz)
target_min = settings.morning_digest_hour * 60 + settings.morning_digest_minute
current_min = now.hour * 60 + now.minute
if current_min < target_min or current_min >= target_min + 3:
return
today = now.date().isoformat()
if get_state(db, "last_morning_digest_date") == today:
return
digest = build_morning_digest(db, include_news=True)
post_chat_notice(digest)
set_state(db, "last_morning_digest_date", today)
finally:
db.close()
async def _tick_netdata() -> None:
db = SessionLocal()
try:
for notice in check_netdata_alerts(db):
post_chat_notice(notice)
finally:
db.close()
async def _comfyui_reachable(base_url: str) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(3.0, connect=2.0)) as client:
response = await client.get(f"{base_url.rstrip('/')}/system_stats")
return response.status_code < 500
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError):
return False
async def _tick_rofl() -> None:
settings = get_settings()
if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled:
return
db = SessionLocal()
try:
tz_name = resolve_timezone(db)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
now = datetime.now(tz)
last_raw = get_state(db, "last_comfy_rofl_at")
if last_raw:
try:
last_at = datetime.fromisoformat(last_raw)
if last_at.tzinfo is None:
last_at = last_at.replace(tzinfo=tz)
if (now - last_at).total_seconds() < settings.comfyui_rofl_min_interval_hours * 3600:
return
except ValueError:
pass
if random.random() > settings.comfyui_rofl_probability:
return
today = now.date().isoformat()
count_raw = get_state(db, f"comfy_rofl_count_{today}") or "0"
try:
count = int(count_raw)
except ValueError:
count = 0
if count >= settings.comfyui_rofl_max_per_day:
return
client = ComfyUIClient()
if not await _comfyui_reachable(client.base_url):
return
prompt = client.random_rofl_prompt()
try:
result = await asyncio.wait_for(
client.generate_image(prompt),
timeout=settings.comfyui_timeout_sec + 15,
)
except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc:
logger.warning("Rofl image skipped (ComfyUI): %s", exc)
return
if not result.get("ok"):
logger.warning("Rofl image failed: %s", result.get("error"))
return
url = result.get("url", "")
post_chat_notice(
f"🎨 **Рофл дня**\n\n![rofl]({url})\n\n_{prompt}_"
)
set_state(db, f"comfy_rofl_count_{today}", str(count + 1))
set_state(db, "last_comfy_rofl_at", now.isoformat())
finally:
db.close()
-5
View File
@@ -1,5 +0,0 @@
from app.chat.notice_inbox import post_notice_to_latest_chat
def post_chat_notice(content: str, user_id: int) -> None:
post_notice_to_latest_chat(content, user_id)
-164
View File
@@ -1,164 +0,0 @@
import asyncio
import logging
import random
from datetime import datetime
from zoneinfo import ZoneInfo
import httpx
from sqlalchemy import select
from app.config import get_settings
from app.db.base import SessionLocal
from app.db.models import User
from app.homelab.comfyui import ComfyUIClient
from app.homelab.context import resolve_timezone
from app.homelab.digest import build_morning_digest
from app.homelab.monitoring import check_netdata_alerts
from app.homelab_scoped.notices import post_chat_notice
from app.homelab.state import get_state, set_state
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 60
_netdata_tick = 0
async def homelab_watcher_loop() -> None:
global _netdata_tick
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick_morning_digest()
await _tick_rofl()
settings = get_settings()
_netdata_tick += WATCH_INTERVAL_SEC
if _netdata_tick >= settings.netdata_poll_interval_sec:
_netdata_tick = 0
await _tick_netdata()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Homelab watcher error")
async def _tick_morning_digest() -> None:
settings = get_settings()
if not settings.morning_digest_enabled:
return
db = SessionLocal()
try:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
digest = build_morning_digest(db, include_news=True)
for user in users:
tz_name = resolve_timezone(db, user.id)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
now = datetime.now(tz)
target_min = settings.morning_digest_hour * 60 + settings.morning_digest_minute
current_min = now.hour * 60 + now.minute
if current_min < target_min or current_min >= target_min + 3:
continue
today = now.date().isoformat()
state_key = f"last_morning_digest_date:{user.id}"
if get_state(db, state_key) == today:
continue
post_chat_notice(digest, user.id)
set_state(db, state_key, today)
finally:
db.close()
async def _tick_netdata() -> None:
db = SessionLocal()
try:
notices = check_netdata_alerts(db)
if not notices:
return
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
for user in users:
for notice in notices:
post_chat_notice(notice, user.id)
finally:
db.close()
async def _comfyui_reachable(base_url: str) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(3.0, connect=2.0)) as client:
response = await client.get(f"{base_url.rstrip('/')}/system_stats")
return response.status_code < 500
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError):
return False
async def _tick_rofl() -> None:
settings = get_settings()
if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled:
return
db = SessionLocal()
try:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
for user in users:
tz_name = resolve_timezone(db, user.id)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
now = datetime.now(tz)
last_raw = get_state(db, f"last_comfy_rofl_at:{user.id}")
if last_raw:
try:
last_at = datetime.fromisoformat(last_raw)
if last_at.tzinfo is None:
last_at = last_at.replace(tzinfo=tz)
if (now - last_at).total_seconds() < settings.comfyui_rofl_min_interval_hours * 3600:
continue
except ValueError:
pass
if random.random() > settings.comfyui_rofl_probability:
continue
today = now.date().isoformat()
count_key = f"comfy_rofl_count_{today}:{user.id}"
count_raw = get_state(db, count_key) or "0"
try:
count = int(count_raw)
except ValueError:
count = 0
if count >= settings.comfyui_rofl_max_per_day:
continue
client = ComfyUIClient()
if not await _comfyui_reachable(client.base_url):
continue
prompt = client.random_rofl_prompt()
try:
result = await asyncio.wait_for(
client.generate_image(prompt),
timeout=settings.comfyui_timeout_sec + 15,
)
except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc:
logger.warning("Rofl image skipped (ComfyUI): %s", exc)
continue
if not result.get("ok"):
logger.warning("Rofl image failed: %s", result.get("error"))
continue
url = result.get("url", "")
post_chat_notice(
f"🎨 **Рофл дня**\n\n![rofl]({url})\n\n_{prompt}_",
user.id,
)
set_state(db, count_key, str(count + 1))
set_state(db, f"last_comfy_rofl_at:{user.id}", now.isoformat())
finally:
db.close()
-62
View File
@@ -1,62 +0,0 @@
from typing import Any
import httpx
from app.config import get_settings
class OpenFoodFactsClient:
def __init__(self) -> None:
settings = get_settings()
self.base_url = settings.openfoodfacts_base_url.rstrip("/")
def search(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
with httpx.Client(timeout=20.0) as client:
response = client.get(
f"{self.base_url}/cgi/search.pl",
params={
"search_terms": query,
"search_simple": 1,
"action": "process",
"json": 1,
"page_size": limit,
"lc": "ru",
},
)
response.raise_for_status()
products = response.json().get("products") or []
out: list[dict[str, Any]] = []
for p in products[:limit]:
nutriments = p.get("nutriments") or {}
out.append(
{
"name": p.get("product_name") or p.get("product_name_ru") or query,
"brand": p.get("brands", ""),
"barcode": p.get("code"),
"calories_per_100g": nutriments.get("energy-kcal_100g"),
"protein_g_per_100g": nutriments.get("proteins_100g"),
"fat_g_per_100g": nutriments.get("fat_100g"),
"carbs_g_per_100g": nutriments.get("carbohydrates_100g"),
}
)
return out
def get_by_barcode(self, barcode: str) -> dict[str, Any] | None:
with httpx.Client(timeout=20.0) as client:
response = client.get(f"{self.base_url}/api/v2/product/{barcode}.json")
if response.status_code == 404:
return None
response.raise_for_status()
product = response.json().get("product")
if not product:
return None
nutriments = product.get("nutriments") or {}
return {
"name": product.get("product_name") or product.get("product_name_ru"),
"barcode": barcode,
"calories_per_100g": nutriments.get("energy-kcal_100g"),
"protein_g_per_100g": nutriments.get("proteins_100g"),
"fat_g_per_100g": nutriments.get("fat_100g"),
"carbs_g_per_100g": nutriments.get("carbohydrates_100g"),
}
-89
View File
@@ -1,89 +0,0 @@
import logging
import uuid
from pathlib import Path
from typing import Any
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
class RpChatClient:
def __init__(self) -> None:
settings = get_settings()
self.base_url = settings.rp_chat_base_url.rstrip("/")
self.enabled = settings.rp_chat_enabled
self.timeout = settings.rp_chat_timeout_sec
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=self.timeout)
async def health(self) -> dict[str, Any]:
async with self._client() as client:
response = await client.get(f"{self.base_url}/health")
return {"ok": response.status_code == 200, "status_code": response.status_code}
async def sd_prompt(
self,
persona_id: str,
messages: list[dict[str, str]],
*,
appearance_override: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"persona_id": persona_id,
"messages": messages,
"outfit_json": "[]",
"use_prose": False,
}
if appearance_override:
payload["appearance_override"] = appearance_override
async with self._client() as client:
response = await client.post(f"{self.base_url}/api/sd-prompt", json=payload)
if response.status_code >= 400:
return {"ok": False, "error": response.text[:500]}
data = response.json()
if data.get("skipped") or data.get("error"):
return {"ok": False, "error": data.get("error", "should_generate=false"), "raw": data}
return {"ok": True, **data}
async def generate(self, positive: str, negative: str = "") -> dict[str, Any]:
async with self._client() as client:
response = await client.post(
f"{self.base_url}/api/generate",
json={"positive": positive, "negative": negative},
)
if response.status_code >= 400:
return {"ok": False, "error": response.text[:500]}
data = response.json()
if data.get("status") != "ok" or not data.get("image_path"):
return {"ok": False, "error": data.get("detail", "generation failed")}
return {"ok": True, **data}
async def download_image(self, image_path: str) -> bytes | None:
path = image_path if image_path.startswith("/") else f"/{image_path}"
async with self._client() as client:
response = await client.get(f"{self.base_url}{path}")
if response.status_code != 200:
return None
return response.content
async def save_image_locally(self, image_path: str) -> dict[str, Any]:
content = await self.download_image(image_path)
if not content:
return {"ok": False, "error": f"Не удалось скачать {image_path}"}
settings = get_settings()
out_dir = Path(settings.generated_media_dir)
out_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex}.png"
(out_dir / filename).write_bytes(content)
return {
"ok": True,
"filename": filename,
"url": f"/api/v1/media/generated/{filename}",
"source_path": image_path,
}
-72
View File
@@ -1,72 +0,0 @@
from typing import Any
import httpx
from app.config import get_settings
# wger language ids (https://wger.de/api/v2/language/)
_LANG_RU = 5
_LANG_EN = 2
class WgerClient:
def __init__(self) -> None:
settings = get_settings()
self.base_url = settings.wger_base_url.rstrip("/")
@staticmethod
def _pick_name(item: dict[str, Any]) -> str:
translations = item.get("translations") or []
for lang_id in (_LANG_RU, _LANG_EN):
for tr in translations:
if tr.get("language") == lang_id and tr.get("name"):
return str(tr["name"])
for tr in translations:
if tr.get("name"):
return str(tr["name"])
return f"#{item.get('id')}"
def _fetch_exerciseinfo(
self,
client: httpx.Client,
*,
query: str,
languagecode: str,
limit: int,
) -> list[dict[str, Any]]:
response = client.get(
f"{self.base_url}/exerciseinfo/",
params={
"name__search": query,
"languagecode": languagecode,
"limit": limit,
},
)
response.raise_for_status()
return response.json().get("results") or []
def search_exercises(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
query = query.strip()
if not query:
return []
with httpx.Client(timeout=20.0) as client:
results = self._fetch_exerciseinfo(
client, query=query, languagecode="ru", limit=limit
)
if not results:
results = self._fetch_exerciseinfo(
client, query=query, languagecode="en", limit=limit
)
out: list[dict[str, Any]] = []
for item in results[:limit]:
category = item.get("category") or {}
out.append(
{
"id": item.get("id"),
"name": self._pick_name(item),
"category": category.get("name") if isinstance(category, dict) else category,
}
)
return out
+112 -374
View File
@@ -1,374 +1,112 @@
import json import json
import logging from collections.abc import AsyncIterator
from collections.abc import AsyncIterator from typing import Any
from typing import Any
from openai import AsyncOpenAI
from openai import AsyncOpenAI
from app.config import get_settings
from app.config import get_settings
logger = logging.getLogger(__name__) class LLMClient:
def __init__(self) -> None:
settings = get_settings()
class LLMClient: self.model = settings.openrouter_model
def __init__(self) -> None: self.client = AsyncOpenAI(
settings = get_settings() api_key=settings.openrouter_api_key,
self.tools_enabled = settings.openrouter_tools_enabled base_url=settings.openrouter_base_url,
self.client = AsyncOpenAI( )
api_key=settings.openrouter_api_key,
base_url=settings.openrouter_base_url, async def stream_chat(
) self,
messages: list[dict[str, Any]],
def _runtime(self) -> tuple[str, str, str]: tools: list[dict[str, Any]] | None = None,
from app.db.base import SessionLocal ) -> AsyncIterator[dict[str, Any]]:
from app.settings.service import SettingsService kwargs: dict[str, Any] = {
"model": self.model,
settings = get_settings() "messages": messages,
db = SessionLocal() "stream": True,
try: "temperature": 0.7,
svc = SettingsService(db) }
model = str(svc.get_effective("openrouter_model")) if tools:
extract = str(svc.get_effective("memory_extract_model")) kwargs["tools"] = tools
effort = str(svc.get_effective("openrouter_reasoning_effort")).strip().lower()
return model, extract, effort stream = await self.client.chat.completions.create(**kwargs)
finally:
db.close() tool_calls: dict[int, dict[str, Any]] = {}
def _vision_model_runtime(self) -> str: async for chunk in stream:
from app.db.base import SessionLocal if not chunk.choices:
from app.settings.service import SettingsService continue
db = SessionLocal() choice = chunk.choices[0]
try: delta = choice.delta
return str(SettingsService(db).get_effective("openrouter_vision_model"))
finally: if delta.content:
db.close() yield {"type": "content", "content": delta.content}
@property if delta.tool_calls:
def model(self) -> str: for tool_call in delta.tool_calls:
return self._runtime()[0] idx = tool_call.index
if idx not in tool_calls:
@property tool_calls[idx] = {
def memory_extract_model(self) -> str: "id": tool_call.id or "",
return self._runtime()[1] "type": "function",
"function": {"name": "", "arguments": ""},
@property }
def reasoning_effort(self) -> str: if tool_call.id:
return self._runtime()[2] tool_calls[idx]["id"] = tool_call.id
if tool_call.function:
@property if tool_call.function.name:
def vision_model(self) -> str: tool_calls[idx]["function"]["name"] = tool_call.function.name
return self._vision_model_runtime() if tool_call.function.arguments:
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
def _reasoning_extra_body(self) -> dict[str, Any] | None:
if not self.reasoning_effort: if choice.finish_reason:
return None if tool_calls:
if self.reasoning_effort == "none": yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
return {"reasoning": {"effort": "none", "exclude": True}} yield {"type": "done", "finish_reason": choice.finish_reason}
return {"reasoning": {"effort": self.reasoning_effort}}
async def complete(
@staticmethod self,
def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]: messages: list[dict[str, Any]],
parts: list[str] = [] tools: list[dict[str, Any]] | None = None,
for attr in ("reasoning", "reasoning_content"): ) -> dict[str, Any]:
value = getattr(delta, attr, None) kwargs: dict[str, Any] = {
if value: "model": self.model,
parts.append(str(value)) "messages": messages,
"temperature": 0.7,
details: list[Any] = [] }
raw_details = getattr(delta, "reasoning_details", None) if tools:
if raw_details: kwargs["tools"] = tools
if isinstance(raw_details, list):
details.extend(raw_details) response = await self.client.chat.completions.create(**kwargs)
else: message = response.choices[0].message
details.append(raw_details)
result: dict[str, Any] = {
return "".join(parts), details "content": message.content or "",
"tool_calls": [],
@staticmethod }
def _normalize_reasoning_details(details: Any) -> list[Any] | None:
if not details: if message.tool_calls:
return None result["tool_calls"] = [
items = details if isinstance(details, list) else [details] {
normalized: list[Any] = [] "id": tc.id,
for item in items: "type": "function",
if hasattr(item, "model_dump"): "function": {
normalized.append(item.model_dump()) "name": tc.function.name,
elif isinstance(item, dict): "arguments": tc.function.arguments,
normalized.append(item) },
else: }
normalized.append(item) for tc in message.tool_calls
return normalized or None ]
@staticmethod return result
def attach_reasoning_to_message(
message: dict[str, Any], @staticmethod
*, def parse_tool_arguments(arguments: str) -> dict[str, Any]:
reasoning: str = "", if not arguments:
reasoning_details: list[Any] | None = None, return {}
) -> dict[str, Any]: try:
if reasoning: return json.loads(arguments)
message["reasoning"] = reasoning except json.JSONDecodeError:
message["reasoning_content"] = reasoning return {}
normalized = LLMClient._normalize_reasoning_details(reasoning_details)
if normalized:
message["reasoning_details"] = normalized
return message
async def stream_chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
*,
model: str | None = None,
) -> AsyncIterator[dict[str, Any]]:
use_tools = bool(tools) and self.tools_enabled
kwargs: dict[str, Any] = {
"model": model or self.model,
"messages": messages,
"stream": True,
"temperature": 0.7,
}
if use_tools:
kwargs["tools"] = tools
extra_body = self._reasoning_extra_body()
if extra_body:
kwargs["extra_body"] = extra_body
try:
stream = await self.client.chat.completions.create(**kwargs)
except Exception as exc:
logger.exception("LLM stream failed: %s", exc)
yield {"type": "error", "content": str(exc)}
yield {"type": "done", "finish_reason": "error"}
return
tool_calls: dict[int, dict[str, Any]] = {}
reasoning_parts: list[str] = []
reasoning_details: list[Any] = []
try:
async for chunk in stream:
if not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
if delta.content:
yield {"type": "content", "content": delta.content}
reasoning_text, details = self._delta_reasoning(delta)
if reasoning_text:
reasoning_parts.append(reasoning_text)
if details:
reasoning_details.extend(details)
if delta.tool_calls:
for tool_call in delta.tool_calls:
idx = tool_call.index
if idx not in tool_calls:
tool_calls[idx] = {
"id": tool_call.id or "",
"type": "function",
"function": {"name": "", "arguments": ""},
}
if tool_call.id:
tool_calls[idx]["id"] = tool_call.id
if tool_call.function:
if tool_call.function.name:
tool_calls[idx]["function"]["name"] = tool_call.function.name
if tool_call.function.arguments:
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
usage = getattr(chunk, "usage", None)
if usage is not None:
logger.info(
"LLM stream usage: prompt=%s completion=%s total=%s",
getattr(usage, "prompt_tokens", None),
getattr(usage, "completion_tokens", None),
getattr(usage, "total_tokens", None),
)
if choice.finish_reason:
reasoning = "".join(reasoning_parts)
normalized_details = self._normalize_reasoning_details(reasoning_details)
if reasoning or normalized_details:
yield {
"type": "reasoning",
"reasoning": reasoning,
"reasoning_details": normalized_details,
}
if tool_calls:
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
logger.info(
"LLM stream done: model=%s finish_reason=%s tool_calls=%d "
"content_in_stream=%d reasoning_len=%d",
model or self.model,
choice.finish_reason,
len(tool_calls),
len(reasoning_parts),
len(reasoning),
)
yield {"type": "done", "finish_reason": choice.finish_reason}
except Exception as exc:
logger.exception("LLM stream read failed: %s", exc)
yield {"type": "error", "content": str(exc)}
yield {"type": "done", "finish_reason": "error"}
async def complete(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
*,
temperature: float = 0.7,
model: str | None = None,
for_extraction: bool = False,
visible_reply: bool = False,
) -> dict[str, Any]:
use_tools = bool(tools) and self.tools_enabled and not for_extraction
kwargs: dict[str, Any] = {
"model": model or self.model,
"messages": messages,
"temperature": temperature,
}
if use_tools:
kwargs["tools"] = tools
if for_extraction:
kwargs["extra_body"] = {"reasoning": {"effort": "none"}}
else:
extra_body = self._reasoning_extra_body()
if extra_body:
kwargs["extra_body"] = extra_body
response = await self.client.chat.completions.create(**kwargs)
usage = getattr(response, "usage", None)
if usage is not None:
logger.info(
"LLM complete usage: prompt=%s completion=%s total=%s model=%s",
getattr(usage, "prompt_tokens", None),
getattr(usage, "completion_tokens", None),
getattr(usage, "total_tokens", None),
kwargs.get("model"),
)
message = response.choices[0].message
content = message.content or ""
reasoning = ""
for attr in ("reasoning", "reasoning_content"):
value = getattr(message, attr, None)
if value:
reasoning = str(value)
break
if not content and reasoning and not visible_reply:
content = reasoning
result: dict[str, Any] = {
"content": content,
"tool_calls": [],
"reasoning": reasoning,
"reasoning_details": getattr(message, "reasoning_details", None),
}
if message.tool_calls:
result["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in message.tool_calls
]
return result
async def complete_vision(
self,
messages: list[dict[str, Any]],
*,
temperature: float = 0.1,
model: str | None = None,
) -> dict[str, Any]:
use_model = model or self.vision_model
kwargs: dict[str, Any] = {
"model": use_model,
"messages": messages,
"temperature": temperature,
"extra_body": {"reasoning": {"effort": "none", "exclude": True}},
}
response = await self.client.chat.completions.create(**kwargs)
usage = getattr(response, "usage", None)
usage_dict: dict[str, Any] = {}
if usage is not None:
usage_dict = {
"prompt_tokens": getattr(usage, "prompt_tokens", None),
"completion_tokens": getattr(usage, "completion_tokens", None),
"total_tokens": getattr(usage, "total_tokens", None),
}
logger.info(
"LLM vision usage: prompt=%s completion=%s total=%s model=%s",
usage_dict.get("prompt_tokens"),
usage_dict.get("completion_tokens"),
usage_dict.get("total_tokens"),
use_model,
)
message = response.choices[0].message
return {
"content": message.content or "",
"model": use_model,
"usage": usage_dict,
}
@staticmethod
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
if not arguments:
return {}
try:
return json.loads(arguments)
except json.JSONDecodeError:
return {}
@staticmethod
def serialize_reasoning(
*,
reasoning: str = "",
reasoning_details: list[Any] | None = None,
) -> str | None:
payload: dict[str, Any] = {}
if reasoning:
payload["reasoning"] = reasoning
payload["reasoning_content"] = reasoning
if reasoning_details:
payload["reasoning_details"] = reasoning_details
if not payload:
return None
return json.dumps(payload, ensure_ascii=False)
@staticmethod
def deserialize_reasoning(raw: str | None) -> dict[str, Any]:
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {"reasoning": raw}
if isinstance(data, str):
return {"reasoning": data, "reasoning_content": data}
if isinstance(data, dict):
return data
return {}
async def embed(self, texts: list[str]) -> list[list[float]]:
settings = get_settings()
if not texts:
return []
response = await self.client.embeddings.create(
model=settings.embedding_model,
input=texts,
)
return [item.embedding for item in response.data]
-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 {}
+39 -65
View File
@@ -1,65 +1,39 @@
import asyncio import asyncio
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router from app.api.routes import api_router
from app.config import get_settings from app.config import get_settings
from app.db.base import init_db from app.db.base import init_db
from app.fitness.watcher import fitness_watcher_loop from app.pomodoro.watcher import pomodoro_watcher_loop
from app.homelab_scoped.watcher import homelab_watcher_loop
from app.pomodoro.watcher import pomodoro_watcher_loop
from app.reminders_scoped.watcher import reminders_watcher_loop @asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
@asynccontextmanager watcher_task = asyncio.create_task(pomodoro_watcher_loop())
async def lifespan(_: FastAPI): yield
init_db() watcher_task.cancel()
from app.db.migrate_fitness import run_fitness_migrations with suppress(asyncio.CancelledError):
await watcher_task
run_fitness_migrations()
from app.db.migrate_multi_user import run_multi_user_migrations
def create_app() -> FastAPI:
run_multi_user_migrations() settings = get_settings()
settings = get_settings() app = FastAPI(title="Home AI Assistant", lifespan=lifespan)
if settings.rag_enabled:
from app.rag.store import ensure_collections app.add_middleware(
CORSMiddleware,
ensure_collections() allow_origins=settings.cors_origins_list,
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop()) allow_credentials=True,
fitness_task = asyncio.create_task(fitness_watcher_loop()) allow_methods=["*"],
homelab_task = asyncio.create_task(homelab_watcher_loop()) allow_headers=["*"],
reminders_task = asyncio.create_task(reminders_watcher_loop()) )
yield
pomodoro_task.cancel() app.include_router(api_router)
fitness_task.cancel() return app
homelab_task.cancel()
reminders_task.cancel()
with suppress(asyncio.CancelledError): app = create_app()
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()
-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()
View File
-89
View File
@@ -1,89 +0,0 @@
from typing import Any
from sqlalchemy.orm import Session
from app.config import get_settings
from app.memory.service import MemoryService
from app.memory.parse import is_identity_question
PROFILE_KEYS = ("name", "age", "timezone", "language", "notes")
def get_memory_snapshot(
db: Session,
user_id: int,
session_id: int | None = None,
query: str | None = None,
) -> dict[str, Any]:
return MemoryService(db, user_id).snapshot(session_id, query=query)
def format_memory_context(snapshot: dict[str, Any]) -> str:
lines = ["[Память и профиль — долгосрочный контекст]"]
profile = snapshot.get("profile") or {}
profile_lines = []
for key in PROFILE_KEYS:
value = (profile.get(key) or "").strip()
if value:
profile_lines.append(f"- {key}: {value}")
if profile_lines:
lines.append("Профиль пользователя:")
lines.extend(profile_lines)
else:
lines.append("Профиль: не заполнен (можно уточнить имя, часовой пояс).")
summary = (snapshot.get("session_summary") or "").strip()
if summary:
lines.append("")
lines.append("Сводка текущего чата (ранние сообщения):")
lines.append(summary)
facts = snapshot.get("facts") or []
if facts:
lines.append("")
lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):")
limit = get_settings().memory_facts_in_context
for fact in facts[:limit]:
lines.append(
f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}"
)
else:
lines.append("")
lines.append("Запомненные факты: пока нет.")
lines.append("")
lines.append(
"Правила памяти: "
"«запомни» → remember_fact (имя/возраст также пишутся в профиль). "
"«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. "
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. "
"«забудь #N» → forget_memory. "
"Длинный чат — update_session_summary."
)
return "\n".join(lines)
def format_identity_hint(snapshot: dict[str, Any], user_text: str) -> str:
if not is_identity_question(user_text):
return ""
profile = snapshot.get("profile") or {}
facts = snapshot.get("facts") or []
lines = [
"[Вопрос об идентичности пользователя]",
"Ответь ТОЛЬКО из данных ниже. Не придумывай роли из сценария персонажа.",
]
name = (profile.get("name") or "").strip()
age = (profile.get("age") or "").strip()
if name:
lines.append(f"Имя: {name}")
if age:
lines.append(f"Возраст: {age} лет")
for fact in facts:
lines.append(f"Факт: {fact.get('content')}")
if not name and not age and not facts:
lines.append("Данных нет — скажи, что не помнишь.")
return "\n".join(lines)
@@ -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)
-153
View File
@@ -1,153 +0,0 @@
import json
import logging
import re
from typing import Any
from sqlalchemy.orm import Session
from app.config import get_settings
from app.llm.client import LLMClient
from app.memory.service import MemoryService
from app.projects.structuring import strip_markdown_json
logger = logging.getLogger(__name__)
SKIP_USER_PATTERN = re.compile(
r"^(ок|ok|да|нет|спасибо|thanks|\.{1,3}|👍|\+1)$",
re.IGNORECASE,
)
EXTRACTION_PROMPT = """
Ты извлекаешь долгосрочные факты о пользователе из фрагмента диалога.
Ответь ТОЛЬКО JSON без markdown.
Схема:
{
"facts": [
{"content": "текст факта", "category": "preference|person|habit|project|fact", "importance": 1}
],
"profile": {"name": "", "age": "", "timezone": "", "notes": ""}
}
Правила:
- Сохраняй устойчивое: имя, возраст, предпочтения, привычки, проекты, семья, работа.
- НЕ сохраняй: статус помидоро, погоду, разовые команды, ролевую игру, выдумки ассистента.
- profile только поля с новыми значениями (пустые строки не включай).
- facts короткие утверждения от первого лица пользователя («люблю кофе», «меня зовут »).
- Если нечего сохранять {"facts": [], "profile": {}}.
- Не дублируй уже известное (см. текущий профиль и факты ниже).
- importance: 5 критично (имя), 4 важно, 3 обычно, 2 мелочь.
""".strip()
def _should_skip_extraction(user_text: str) -> bool:
text = user_text.strip()
if len(text) < 4:
return True
if SKIP_USER_PATTERN.match(text):
return True
return False
async def _call_extractor(
user_text: str,
assistant_text: str,
snapshot: dict[str, Any],
) -> dict[str, Any]:
profile = snapshot.get("profile") or {}
facts = snapshot.get("facts") or []
known = [
f"Профиль: {json.dumps(profile, ensure_ascii=False)}",
"Факты:",
*[f"- {f.get('content')}" for f in facts[:30]],
]
settings = get_settings()
extract_model = settings.memory_extract_model.strip() or None
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": EXTRACTION_PROMPT},
{
"role": "user",
"content": (
"\n".join(known)
+ "\n\n---\nДиалог:\nПользователь: "
+ user_text
+ "\nАссистент: "
+ assistant_text[:1500]
),
},
],
temperature=0.2,
model=extract_model,
for_extraction=True,
)
raw = strip_markdown_json(result.get("content") or "")
if not raw:
return {"facts": [], "profile": {}}
parsed = json.loads(raw)
if not isinstance(parsed, dict):
return {"facts": [], "profile": {}}
return parsed
async def extract_after_turn(
db: Session,
session_id: int,
user_text: str,
assistant_text: str,
*,
user_id: int,
force: bool = False,
) -> dict[str, Any]:
if not force and _should_skip_extraction(user_text):
return {"ok": True, "skipped": "short_message", "saved": []}
if not (assistant_text or "").strip():
return {"ok": True, "skipped": "no_assistant_reply", "saved": []}
memory = MemoryService(db, user_id)
snapshot = memory.snapshot(session_id)
try:
parsed = await _call_extractor(user_text, assistant_text, snapshot)
except (json.JSONDecodeError, Exception) as exc:
logger.warning("Memory extraction failed: %s", exc)
return {"ok": False, "error": str(exc), "saved": []}
saved: list[dict[str, Any]] = []
profile_updates = parsed.get("profile") or {}
if isinstance(profile_updates, dict):
filtered = {
k: str(v).strip()
for k, v in profile_updates.items()
if v and str(v).strip()
}
if filtered:
memory.update_profile(filtered)
saved.append({"type": "profile", "updates": filtered})
facts = parsed.get("facts") or []
if isinstance(facts, list):
for item in facts:
if not isinstance(item, dict):
continue
content = (item.get("content") or "").strip()
if not content or len(content) < 3:
continue
try:
result = memory.remember_fact(
content,
category=str(item.get("category") or "fact")[:64],
importance=int(item.get("importance") or 3),
session_id=session_id,
source="auto",
)
saved.append({"type": "fact", **result})
except ValueError:
continue
return {"ok": True, "saved": saved, "count": len(saved)}
-40
View File
@@ -1,40 +0,0 @@
import re
IDENTITY_QUESTION = re.compile(
r"(кто\s+я|как\s+меня\s+зовут|сколько\s+мне\s+лет|"
r"что\s+ты\s+(помнишь|знаешь)\s+(обо\s+мне|про\s+меня)|"
r"напомни\s+(кто\s+я|про\s+меня))",
re.IGNORECASE,
)
NAME_PATTERN = re.compile(
r"(?:меня\s+зовут|имя[:\s]+|зовут)\s+([A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё\-]*)",
re.IGNORECASE,
)
AGE_PATTERN = re.compile(r"(?:мне\s+(\d{1,3})\s+лет|возраст[:\s]+(\d{1,3}))", re.IGNORECASE)
def normalize_text(text: str) -> str:
return " ".join(text.casefold().split())
def is_identity_question(text: str) -> bool:
return bool(IDENTITY_QUESTION.search(text))
def parse_identity(text: str) -> dict[str, str]:
result: dict[str, str] = {}
name_match = NAME_PATTERN.search(text)
if name_match:
result["name"] = name_match.group(1)
age_match = AGE_PATTERN.search(text)
if age_match:
result["age"] = age_match.group(1) or age_match.group(2)
return result
def texts_are_similar(a: str, b: str) -> bool:
na, nb = normalize_text(a), normalize_text(b)
if na == nb:
return True
return na in nb or nb in na
-300
View File
@@ -1,300 +0,0 @@
import asyncio
import json
import threading
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import MemoryFact, SessionSummary, UserProfile
from app.memory.parse import normalize_text, parse_identity, texts_are_similar
DEFAULT_PROFILE: dict[str, Any] = {
"name": "",
"age": "",
"timezone": "",
"language": "ru",
"notes": "",
}
class MemoryService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
@staticmethod
def _schedule_rag(coro) -> None:
def runner() -> None:
asyncio.run(coro)
threading.Thread(target=runner, daemon=True).start()
def get_profile(self) -> dict[str, Any]:
row = self.db.scalar(select(UserProfile).where(UserProfile.user_id == self.user_id).limit(1))
if not row:
return dict(DEFAULT_PROFILE)
try:
data = json.loads(row.data_json or "{}")
except json.JSONDecodeError:
data = {}
merged = dict(DEFAULT_PROFILE)
merged.update(data)
return merged
def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
row = self.db.scalar(select(UserProfile).where(UserProfile.user_id == self.user_id).limit(1))
if not row:
row = UserProfile(user_id=self.user_id, data_json="{}")
self.db.add(row)
self.db.flush()
current = self.get_profile()
for key, value in updates.items():
if value is None:
current.pop(key, None)
else:
current[key] = value
row.data_json = json.dumps(current, ensure_ascii=False)
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {"ok": True, "profile": current}
def _find_similar_fact(self, text: str) -> MemoryFact | None:
for fact in self.db.scalars(
select(MemoryFact).where(MemoryFact.user_id == self.user_id, MemoryFact.active.is_(True))
):
if texts_are_similar(fact.content, text):
return fact
return None
def _sync_identity_to_profile(self, text: str) -> dict[str, Any] | None:
parsed = parse_identity(text)
if not parsed:
return None
return self.update_profile(parsed)
def remember_fact(
self,
content: str,
*,
category: str = "fact",
source: str = "user",
session_id: int | None = None,
importance: int = 3,
) -> dict[str, Any]:
text = content.strip()
if not text:
raise ValueError("Пустой факт")
profile_sync = self._sync_identity_to_profile(text)
existing = self._find_similar_fact(text)
if existing:
if len(text) > len(existing.content):
existing.content = text[:2000]
existing.category = category or existing.category
existing.importance = max(existing.importance, min(5, max(1, importance)))
existing.updated_at = datetime.now(timezone.utc)
if session_id:
existing.session_id = session_id
self.db.commit()
from app.rag.ingest import index_memory_fact
self._schedule_rag(index_memory_fact(existing))
result = {
"ok": True,
"action": "updated",
"memory_id": existing.id,
"content": existing.content,
"category": existing.category,
}
if profile_sync:
result["profile"] = profile_sync.get("profile")
return result
fact = MemoryFact(
user_id=self.user_id,
category=(category or "fact")[:64],
content=text[:2000],
source=source[:32],
session_id=session_id,
importance=min(5, max(1, importance)),
)
self.db.add(fact)
self.db.commit()
self.db.refresh(fact)
from app.rag.ingest import index_memory_fact
self._schedule_rag(index_memory_fact(fact))
result = {
"ok": True,
"action": "created",
"memory_id": fact.id,
"content": fact.content,
"category": fact.category,
}
if profile_sync:
result["profile"] = profile_sync.get("profile")
return result
def recall_memories(
self,
*,
query: str | None = None,
category: str | None = None,
limit: int = 20,
active_only: bool = True,
) -> list[dict[str, Any]]:
stmt = select(MemoryFact).where(MemoryFact.user_id == self.user_id).order_by(
MemoryFact.importance.desc(),
MemoryFact.updated_at.desc(),
)
if active_only:
stmt = stmt.where(MemoryFact.active.is_(True))
if category:
stmt = stmt.where(MemoryFact.category == category)
facts = self.db.scalars(stmt.limit(100)).all()
if query:
qnorm = normalize_text(query)
facts = [
f
for f in facts
if qnorm in normalize_text(f.content)
or qnorm in normalize_text(f.category)
]
facts = facts[: min(limit, 50)]
return [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
]
def forget_memory(self, memory_id: int) -> dict[str, Any]:
fact = self.db.get(MemoryFact, memory_id)
if not fact or fact.user_id != self.user_id:
raise ValueError(f"Память #{memory_id} не найдена")
fact.active = False
fact.updated_at = datetime.now(timezone.utc)
self.db.commit()
from app.rag.ingest import deactivate_memory_fact
self._schedule_rag(deactivate_memory_fact(memory_id))
return {"ok": True, "memory_id": memory_id, "forgotten": fact.content}
def get_active_facts(self, limit: int = 25) -> list[MemoryFact]:
return list(
self.db.scalars(
select(MemoryFact)
.where(MemoryFact.user_id == self.user_id, MemoryFact.active.is_(True))
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc())
.limit(limit)
).all()
)
def get_session_summary(self, session_id: int) -> SessionSummary | None:
from app.db.models import ChatSession
session = self.db.get(ChatSession, session_id)
if not session or session.user_id != self.user_id:
return None
return self.db.scalar(
select(SessionSummary).where(SessionSummary.session_id == session_id)
)
def update_session_summary(
self,
session_id: int,
summary: str,
*,
message_count: int = 0,
) -> dict[str, Any]:
text = summary.strip()
if not text:
raise ValueError("Пустая сводка")
from app.db.models import ChatSession
session = self.db.get(ChatSession, session_id)
if not session or session.user_id != self.user_id:
raise ValueError("Session not found")
row = self.db.scalar(
select(SessionSummary).where(SessionSummary.session_id == session_id)
)
if not row:
row = SessionSummary(session_id=session_id)
self.db.add(row)
row.summary = text[:4000]
row.message_count = message_count
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
from app.rag.ingest import index_session_summary
self._schedule_rag(index_session_summary(session_id, row.summary))
return {"ok": True, "session_id": session_id, "summary": row.summary}
def snapshot(self, session_id: int | None = None, query: str | None = None) -> dict[str, Any]:
from app.config import get_settings
from app.settings.service import SettingsService
settings = get_settings()
svc = SettingsService(self.db)
rag_on = bool(svc.get_effective("rag_enabled")) and settings.rag_enabled
facts_payload: list[dict[str, Any]]
total_facts = len(self.get_active_facts(limit=500))
if rag_on and (query or "").strip():
async def _load() -> list[dict[str, Any]]:
from app.rag.retriever import retrieve_memory_facts
top_k = int(svc.get_effective("rag_top_k"))
return await retrieve_memory_facts(query or "", user_id=self.user_id, top_k=top_k)
try:
rag_facts = asyncio.run(_load())
except Exception:
rag_facts = []
if rag_facts:
facts_payload = rag_facts
else:
facts = self.get_active_facts(limit=settings.memory_facts_in_context)
facts_payload = [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
]
else:
facts = self.get_active_facts(limit=settings.memory_facts_in_context)
facts_payload = [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
]
summary_row = self.get_session_summary(session_id) if session_id else None
return {
"profile": self.get_profile(),
"facts": facts_payload,
"session_summary": summary_row.summary if summary_row else "",
"total_facts": total_facts,
}
-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),
}
+101 -92
View File
@@ -1,92 +1,101 @@
import logging from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.character.service import CharacterService from app.chat.notices import format_phase_completed_notice
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat from app.db.models import ChatSession, Message, PomodoroSession
from app.chat.notices import format_phase_completed_notice from app.llm.client import LLMClient
from app.db.models import PomodoroSession from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
from app.llm.client import LLMClient from app.pomodoro.service import PomodoroService
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
from app.pomodoro.service import PomodoroService PHASE_LABELS = {
PHASE_WORK: "работа",
logger = logging.getLogger(__name__) PHASE_SHORT_BREAK: "короткий перерыв",
PHASE_LONG_BREAK: "длинный перерыв",
PHASE_LABELS = { }
PHASE_WORK: "работа",
PHASE_SHORT_BREAK: "короткий перерыв",
PHASE_LONG_BREAK: "длинный перерыв", class PomodoroCompletionHandler:
} def __init__(self, db: Session):
self.db = db
self.pomodoro = PomodoroService(db)
class PomodoroCompletionHandler: self.cycle = CycleManager(db)
def __init__(self, db: Session, user_id: int): self.llm = LLMClient()
self.db = db self.character = CharacterService()
self.user_id = user_id
self.pomodoro = PomodoroService(db, user_id) def _latest_chat_session_id(self) -> int | None:
self.cycle = CycleManager(db, user_id) stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
self.llm = LLMClient() session = self.db.scalar(stmt)
self.character = CharacterService(db, user_id) return session.id if session else None
async def _generate_llm_comment( def _save_chat_message(self, session_id: int, role: str, content: str) -> None:
self, self.db.add(Message(session_id=session_id, role=role, content=content))
session: PomodoroSession, chat = self.db.get(ChatSession, session_id)
next_phase: str | None, if chat:
) -> str: chat.updated_at = chat.updated_at # trigger onupdate
cycle = self.cycle.to_dict() self.db.commit()
phase_label = PHASE_LABELS.get(session.phase, session.phase)
next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен" async def _generate_llm_comment(
work_done = cycle["completed_work_sessions"] self,
if session.phase == PHASE_WORK: session: PomodoroSession,
work_done += 1 next_phase: str | None,
) -> str:
system = self.character.get_system_prompt() cycle = self.cycle.to_dict()
user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась. phase_label = PHASE_LABELS.get(session.phase, session.phase)
Задача: {session.task_note or 'без описания'} next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен"
Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ. work_done = cycle["completed_work_sessions"]
Следующая фаза: {next_label}. if session.phase == PHASE_WORK:
work_done += 1
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown и без эмодзи."""
system = self.character.get_system_prompt()
result = await self.llm.complete( user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась.
[ Задача: {session.task_note or 'без описания'}
{"role": "system", "content": system}, Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ.
{"role": "user", "content": user_prompt}, Следующая фаза: {next_label}.
],
temperature=0.8, Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown."""
visible_reply=True,
) result = await self.llm.complete(
return (result.get("content") or "").strip() or "Фаза завершена. Хорошая работа." [
{"role": "system", "content": system},
def _resolve_next_phase(self, session: PomodoroSession) -> str | None: {"role": "user", "content": user_prompt},
phase = session.phase ]
cycle = self.cycle.get() )
if phase == PHASE_WORK: return (result.get("content") or "").strip() or "Фаза завершена! Отличная работа."
if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break:
return PHASE_LONG_BREAK def _resolve_next_phase(self, session: PomodoroSession) -> str | None:
return PHASE_SHORT_BREAK phase = session.phase
if phase == PHASE_SHORT_BREAK: cycle = self.cycle.get()
return PHASE_WORK if phase == PHASE_WORK:
if phase == PHASE_LONG_BREAK: if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break:
return None return PHASE_LONG_BREAK
return None return PHASE_SHORT_BREAK
if phase == PHASE_SHORT_BREAK:
async def process(self, session: PomodoroSession) -> None: return PHASE_WORK
if session.completion_notified: if phase == PHASE_LONG_BREAK:
return return None
return None
next_phase = self._resolve_next_phase(session)
notice = format_phase_completed_notice(session, next_phase) async def process(self, session: PomodoroSession) -> None:
post_notice_to_latest_chat(notice, self.user_id) if session.completion_notified:
return
try:
comment = await self._generate_llm_comment(session, next_phase) next_phase = self._resolve_next_phase(session)
if comment: notice = format_phase_completed_notice(session, next_phase)
post_character_comment_to_latest_chat(comment, self.user_id)
except Exception: chat_id = self._latest_chat_session_id()
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase) if not chat_id:
chat = ChatSession(title="Помидоро")
self.cycle.bump_notify_seq() self.db.add(chat)
self.pomodoro.mark_notified(session) self.db.commit()
self.pomodoro.advance_after_completion(session) self.db.refresh(chat)
logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase) chat_id = chat.id
self._save_chat_message(chat_id, "notice", notice)
comment = await self._generate_llm_comment(session, next_phase)
self._save_chat_message(chat_id, "assistant", comment)
self.cycle.bump_notify_seq()
self.pomodoro.mark_notified(session)
self.pomodoro.advance_after_completion(session)
+89 -90
View File
@@ -1,90 +1,89 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import PomodoroCycle from app.db.models import PomodoroCycle
PHASE_WORK = "work" PHASE_WORK = "work"
PHASE_SHORT_BREAK = "short_break" PHASE_SHORT_BREAK = "short_break"
PHASE_LONG_BREAK = "long_break" PHASE_LONG_BREAK = "long_break"
class CycleManager: class CycleManager:
def __init__(self, db: Session, user_id: int): def __init__(self, db: Session):
self.db = db self.db = db
self.user_id = user_id
def get(self) -> PomodoroCycle:
def get(self) -> PomodoroCycle: cycle = self.db.scalar(select(PomodoroCycle).limit(1))
cycle = self.db.scalar(select(PomodoroCycle).where(PomodoroCycle.user_id == self.user_id).limit(1)) if not cycle:
if not cycle: cycle = PomodoroCycle()
cycle = PomodoroCycle(user_id=self.user_id) self.db.add(cycle)
self.db.add(cycle) self.db.commit()
self.db.commit() self.db.refresh(cycle)
self.db.refresh(cycle) return cycle
return cycle
def to_dict(self, cycle: PomodoroCycle | None = None) -> dict:
def to_dict(self, cycle: PomodoroCycle | None = None) -> dict: c = cycle or self.get()
c = cycle or self.get() return {
return { "completed_work_sessions": c.completed_work_sessions,
"completed_work_sessions": c.completed_work_sessions, "sessions_until_long_break": c.sessions_until_long_break,
"sessions_until_long_break": c.sessions_until_long_break, "task_note": c.task_note,
"task_note": c.task_note, "work_duration_min": c.work_duration_min,
"work_duration_min": c.work_duration_min, "short_break_min": c.short_break_min,
"short_break_min": c.short_break_min, "long_break_min": c.long_break_min,
"long_break_min": c.long_break_min, "auto_advance": c.auto_advance,
"auto_advance": c.auto_advance, "chat_notify_seq": c.chat_notify_seq,
"chat_notify_seq": c.chat_notify_seq, }
}
def reset(self, clear_task: bool = False) -> dict:
def reset(self, clear_task: bool = False) -> dict: cycle = self.get()
cycle = self.get() cycle.completed_work_sessions = 0
cycle.completed_work_sessions = 0 if clear_task:
if clear_task: cycle.task_note = ""
cycle.task_note = "" self.db.commit()
self.db.commit() self.db.refresh(cycle)
self.db.refresh(cycle) return self.to_dict(cycle)
return self.to_dict(cycle)
def bump_notify_seq(self) -> int:
def bump_notify_seq(self) -> int: cycle = self.get()
cycle = self.get() cycle.chat_notify_seq += 1
cycle.chat_notify_seq += 1 self.db.commit()
self.db.commit() self.db.refresh(cycle)
self.db.refresh(cycle) return cycle.chat_notify_seq
return cycle.chat_notify_seq
def on_work_completed(self) -> str:
def on_work_completed(self) -> str: """Returns next phase: short_break or long_break."""
"""Returns next phase: short_break or long_break.""" cycle = self.get()
cycle = self.get() cycle.completed_work_sessions += 1
cycle.completed_work_sessions += 1 if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
if cycle.completed_work_sessions >= cycle.sessions_until_long_break: next_phase = PHASE_LONG_BREAK
next_phase = PHASE_LONG_BREAK else:
else: next_phase = PHASE_SHORT_BREAK
next_phase = PHASE_SHORT_BREAK self.db.commit()
self.db.commit() return next_phase
return next_phase
def on_long_break_completed(self) -> None:
def on_long_break_completed(self) -> None: cycle = self.get()
cycle = self.get() cycle.completed_work_sessions = 0
cycle.completed_work_sessions = 0 self.db.commit()
self.db.commit()
def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int:
def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int: c = cycle or self.get()
c = cycle or self.get() if phase == PHASE_WORK:
if phase == PHASE_WORK: return c.work_duration_min
return c.work_duration_min if phase == PHASE_SHORT_BREAK:
if phase == PHASE_SHORT_BREAK: return c.short_break_min
return c.short_break_min if phase == PHASE_LONG_BREAK:
if phase == PHASE_LONG_BREAK: return c.long_break_min
return c.long_break_min return c.work_duration_min
return c.work_duration_min
def next_phase_after(self, completed_phase: str) -> str | None:
def next_phase_after(self, completed_phase: str) -> str | None: if completed_phase == PHASE_WORK:
if completed_phase == PHASE_WORK: cycle = self.get()
cycle = self.get() if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
if cycle.completed_work_sessions >= cycle.sessions_until_long_break: return PHASE_LONG_BREAK
return PHASE_LONG_BREAK return PHASE_SHORT_BREAK
return PHASE_SHORT_BREAK if completed_phase == PHASE_SHORT_BREAK:
if completed_phase == PHASE_SHORT_BREAK: return PHASE_WORK
return PHASE_WORK if completed_phase == PHASE_LONG_BREAK:
if completed_phase == PHASE_LONG_BREAK: return None
return None return None
return None
+287 -296
View File
@@ -1,296 +1,287 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import PomodoroSession from app.db.models import PomodoroSession
from app.pomodoro.cycle import ( from app.pomodoro.cycle import (
PHASE_LONG_BREAK, PHASE_LONG_BREAK,
PHASE_SHORT_BREAK, PHASE_SHORT_BREAK,
PHASE_WORK, PHASE_WORK,
CycleManager, CycleManager,
) )
def _utcnow() -> datetime: def _utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
class PomodoroService: class PomodoroService:
def __init__(self, db: Session, user_id: int): def __init__(self, db: Session):
self.db = db self.db = db
self.user_id = user_id self.cycle = CycleManager(db)
self.cycle = CycleManager(db, user_id)
def _get_active(self) -> PomodoroSession | None:
def _get_active(self) -> PomodoroSession | None: stmt = (
stmt = ( select(PomodoroSession)
select(PomodoroSession) .where(PomodoroSession.status.in_(("running", "paused")))
.where( .order_by(PomodoroSession.id.desc())
PomodoroSession.user_id == self.user_id, .limit(1)
PomodoroSession.status.in_(("running", "paused")), )
) return self.db.scalar(stmt)
.order_by(PomodoroSession.id.desc())
.limit(1) def _elapsed(self, session: PomodoroSession) -> int:
) elapsed = session.elapsed_seconds
return self.db.scalar(stmt) if session.status == "running" and session.started_at:
started = session.started_at
def _elapsed(self, session: PomodoroSession) -> int: if started.tzinfo is None:
elapsed = session.elapsed_seconds started = started.replace(tzinfo=timezone.utc)
if session.status == "running" and session.started_at: delta = _utcnow() - started
started = session.started_at elapsed += int(delta.total_seconds())
if started.tzinfo is None: return elapsed
started = started.replace(tzinfo=timezone.utc)
delta = _utcnow() - started def _remaining(self, session: PomodoroSession) -> int:
elapsed += int(delta.total_seconds()) total = session.duration_min * 60
return elapsed return max(0, total - self._elapsed(session))
def _remaining(self, session: PomodoroSession) -> int: def _try_auto_complete(self, session: PomodoroSession) -> bool:
total = session.duration_min * 60 if session.status != "running":
return max(0, total - self._elapsed(session)) return False
if self._remaining(session) > 0:
def _try_auto_complete(self, session: PomodoroSession) -> bool: return False
if session.status != "running": self._finalize_session(session, auto=True)
return False return True
if self._remaining(session) > 0:
return False def _finalize_session(
self._finalize_session(session, auto=True) self,
return True session: PomodoroSession,
*,
def _finalize_session( auto: bool,
self, result: str = "",
session: PomodoroSession, completed: bool | None = None,
*, cancelled: bool = False,
auto: bool, ) -> None:
result: str = "", session.elapsed_seconds = self._elapsed(session)
completed: bool | None = None, session.started_at = None
cancelled: bool = False, session.finished_at = _utcnow()
) -> None: session.completion_notified = False
session.elapsed_seconds = self._elapsed(session) session.result = result or None
session.started_at = None
session.finished_at = _utcnow() if cancelled:
session.completion_notified = False session.status = "cancelled"
session.result = result or None session.completed = False
elif completed is not None:
if cancelled: session.status = "completed"
session.status = "cancelled" session.completed = completed
session.completed = False else:
elif completed is not None: session.status = "completed"
session.status = "completed" session.completed = True
session.completed = completed
else: self.db.commit()
session.status = "completed" self.db.refresh(session)
session.completed = True
def _start_phase(
self.db.commit() self,
self.db.refresh(session) phase: str,
*,
def _start_phase( duration_min: int | None = None,
self, task_note: str | None = None,
phase: str, ) -> PomodoroSession:
*, active = self._get_active()
duration_min: int | None = None, if active:
task_note: str | None = None, raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
) -> PomodoroSession:
active = self._get_active() cycle = self.cycle.get()
if active: if task_note is not None:
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.") cycle.task_note = task_note
elif phase == PHASE_WORK and not cycle.task_note:
cycle = self.cycle.get() cycle.task_note = ""
if task_note is not None:
cycle.task_note = task_note duration = duration_min or self.cycle.duration_for_phase(phase, cycle)
elif phase == PHASE_WORK and not cycle.task_note: note = task_note if task_note is not None else cycle.task_note
cycle.task_note = ""
session = PomodoroSession(
duration = duration_min or self.cycle.duration_for_phase(phase, cycle) status="running",
note = task_note if task_note is not None else cycle.task_note phase=phase,
duration_min=duration,
session = PomodoroSession( task_note=note,
user_id=self.user_id, started_at=_utcnow(),
status="running", )
phase=phase, self.db.add(session)
duration_min=duration, self.db.commit()
task_note=note, self.db.refresh(session)
started_at=_utcnow(), return session
)
self.db.add(session) def _to_status_dict(self, session: PomodoroSession | None) -> dict:
self.db.commit() cycle_dict = self.cycle.to_dict()
self.db.refresh(session) if not session:
return session return {
"status": "idle",
def _to_status_dict(self, session: PomodoroSession | None) -> dict: "phase": PHASE_WORK,
cycle_dict = self.cycle.to_dict() "duration_min": cycle_dict["work_duration_min"],
if not session: "task_note": cycle_dict["task_note"],
return { "elapsed_seconds": 0,
"status": "idle", "remaining_seconds": 0,
"phase": PHASE_WORK, "session_id": None,
"duration_min": cycle_dict["work_duration_min"], "cycle": cycle_dict,
"task_note": cycle_dict["task_note"], }
"elapsed_seconds": 0,
"remaining_seconds": 0, elapsed = self._elapsed(session)
"session_id": None, total = session.duration_min * 60
"cycle": cycle_dict, remaining = max(0, total - elapsed)
}
return {
elapsed = self._elapsed(session) "status": session.status,
total = session.duration_min * 60 "phase": session.phase,
remaining = max(0, total - elapsed) "duration_min": session.duration_min,
"task_note": session.task_note,
return { "elapsed_seconds": elapsed,
"status": session.status, "remaining_seconds": remaining,
"phase": session.phase, "session_id": session.id,
"duration_min": session.duration_min, "started_at": session.started_at.isoformat() if session.started_at else None,
"task_note": session.task_note, "finished_at": session.finished_at.isoformat() if session.finished_at else None,
"elapsed_seconds": elapsed, "cycle": cycle_dict,
"remaining_seconds": remaining, }
"session_id": session.id,
"started_at": session.started_at.isoformat() if session.started_at else None, def get_status(self) -> dict:
"finished_at": session.finished_at.isoformat() if session.finished_at else None, active = self._get_active()
"cycle": cycle_dict, if active:
} self._try_auto_complete(active)
active = self._get_active()
def get_status(self) -> dict: return self._to_status_dict(active)
active = self._get_active()
if active: def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict:
self._try_auto_complete(active) session = self._start_phase(
active = self._get_active() PHASE_WORK,
return self._to_status_dict(active) duration_min=duration_min,
task_note=task_note,
def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict: )
session = self._start_phase( return self._to_status_dict(session)
PHASE_WORK,
duration_min=duration_min, def start_short_break(self, duration_min: int | None = None) -> dict:
task_note=task_note, session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min)
) return self._to_status_dict(session)
return self._to_status_dict(session)
def start_long_break(self, duration_min: int | None = None) -> dict:
def start_short_break(self, duration_min: int | None = None) -> dict: session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min)
session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min) return self._to_status_dict(session)
return self._to_status_dict(session)
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
def start_long_break(self, duration_min: int | None = None) -> dict: return self.start_work(duration_min=duration_min, task_note=task_note)
session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min)
return self._to_status_dict(session) def pause(self) -> dict:
session = self._get_active()
def start(self, duration_min: int = 25, task_note: str = "") -> dict: if not session or session.status != "running":
return self.start_work(duration_min=duration_min, task_note=task_note) raise ValueError("Нет активного запущенного таймера.")
def pause(self) -> dict: session.elapsed_seconds = self._elapsed(session)
session = self._get_active() session.status = "paused"
if not session or session.status != "running": session.paused_at = _utcnow()
raise ValueError("Нет активного запущенного таймера.") session.started_at = None
self.db.commit()
session.elapsed_seconds = self._elapsed(session) self.db.refresh(session)
session.status = "paused" return self._to_status_dict(session)
session.paused_at = _utcnow()
session.started_at = None def resume(self) -> dict:
self.db.commit() session = self._get_active()
self.db.refresh(session) if not session or session.status != "paused":
return self._to_status_dict(session) raise ValueError("Нет таймера на паузе.")
def resume(self) -> dict: session.status = "running"
session = self._get_active() session.started_at = _utcnow()
if not session or session.status != "paused": session.paused_at = None
raise ValueError("Нет таймера на паузе.") self.db.commit()
self.db.refresh(session)
session.status = "running" return self._to_status_dict(session)
session.started_at = _utcnow()
session.paused_at = None def stop(self, result: str = "", completed: bool = False) -> dict:
self.db.commit() session = self._get_active()
self.db.refresh(session) if not session:
return self._to_status_dict(session) raise ValueError("Нет активного таймера.")
def stop(self, result: str = "", completed: bool = False) -> dict: if completed:
session = self._get_active() self._finalize_session(session, auto=False, result=result, completed=True)
if not session: else:
raise ValueError("Нет активного таймера.") self._finalize_session(session, auto=False, result=result, cancelled=True)
session.completion_notified = True
if completed: self.db.commit()
self._finalize_session(session, auto=False, result=result, completed=True) return self._to_status_dict(None)
else:
self._finalize_session(session, auto=False, result=result, cancelled=True) def reset_cycle(self, clear_task: bool = False) -> dict:
session.completion_notified = True active = self._get_active()
self.db.commit() if active:
return self._to_status_dict(None) self._finalize_session(active, auto=False, cancelled=True)
active.completion_notified = True
def reset_cycle(self, clear_task: bool = False) -> dict: self.db.commit()
active = self._get_active() cycle = self.cycle.reset(clear_task=clear_task)
if active: status = self._to_status_dict(None)
self._finalize_session(active, auto=False, cancelled=True) status["cycle"] = cycle
active.completion_notified = True return status
self.db.commit()
cycle = self.cycle.reset(clear_task=clear_task) def skip_phase(self) -> dict:
status = self._to_status_dict(None) session = self._get_active()
status["cycle"] = cycle if not session:
return status raise ValueError("Нет активного таймера.")
def skip_phase(self) -> dict: self._finalize_session(session, auto=True)
session = self._get_active() return self._to_status_dict(None)
if not session:
raise ValueError("Нет активного таймера.") def get_pending_completions(self) -> list[PomodoroSession]:
stmt = (
self._finalize_session(session, auto=True) select(PomodoroSession)
return self._to_status_dict(None) .where(
PomodoroSession.status == "completed",
def get_pending_completions(self) -> list[PomodoroSession]: PomodoroSession.completed.is_(True),
stmt = ( PomodoroSession.completion_notified.is_(False),
select(PomodoroSession) )
.where( .order_by(PomodoroSession.id.asc())
PomodoroSession.user_id == self.user_id, )
PomodoroSession.status == "completed", return list(self.db.scalars(stmt))
PomodoroSession.completed.is_(True),
PomodoroSession.completion_notified.is_(False), def mark_notified(self, session: PomodoroSession) -> None:
) session.completion_notified = True
.order_by(PomodoroSession.id.asc()) self.db.commit()
)
return list(self.db.scalars(stmt)) def advance_after_completion(self, session: PomodoroSession) -> dict | None:
"""Update cycle counters and auto-start next phase. Returns new status or None."""
def mark_notified(self, session: PomodoroSession) -> None: phase = session.phase
session.completion_notified = True cycle = self.cycle.get()
self.db.commit()
if phase == PHASE_WORK:
def advance_after_completion(self, session: PomodoroSession) -> dict | None: next_phase = self.cycle.on_work_completed()
"""Update cycle counters and auto-start next phase. Returns new status or None.""" elif phase == PHASE_SHORT_BREAK:
phase = session.phase next_phase = PHASE_WORK
cycle = self.cycle.get() elif phase == PHASE_LONG_BREAK:
self.cycle.on_long_break_completed()
if phase == PHASE_WORK: next_phase = None
next_phase = self.cycle.on_work_completed() else:
elif phase == PHASE_SHORT_BREAK: next_phase = None
next_phase = PHASE_WORK
elif phase == PHASE_LONG_BREAK: if not cycle.auto_advance or next_phase is None:
self.cycle.on_long_break_completed() return None
next_phase = None
else: new_session = self._start_phase(next_phase)
next_phase = None return self._to_status_dict(new_session)
if not cycle.auto_advance or next_phase is None: def history(self, limit: int = 20) -> list[dict]:
return None stmt = (
select(PomodoroSession)
new_session = self._start_phase(next_phase) .where(PomodoroSession.status.in_(("completed", "cancelled")))
return self._to_status_dict(new_session) .order_by(PomodoroSession.finished_at.desc())
.limit(limit)
def history(self, limit: int = 20) -> list[dict]: )
stmt = ( sessions = self.db.scalars(stmt).all()
select(PomodoroSession) return [
.where( {
PomodoroSession.user_id == self.user_id, "id": s.id,
PomodoroSession.status.in_(("completed", "cancelled")), "status": s.status,
) "phase": s.phase,
.order_by(PomodoroSession.finished_at.desc()) "duration_min": s.duration_min,
.limit(limit) "task_note": s.task_note,
) "result": s.result,
sessions = self.db.scalars(stmt).all() "completed": s.completed,
return [ "elapsed_seconds": s.elapsed_seconds,
{ "finished_at": s.finished_at.isoformat() if s.finished_at else None,
"id": s.id, }
"status": s.status, for s in sessions
"phase": s.phase, ]
"duration_min": s.duration_min,
"task_note": s.task_note,
"result": s.result,
"completed": s.completed,
"elapsed_seconds": s.elapsed_seconds,
"finished_at": s.finished_at.isoformat() if s.finished_at else None,
}
for s in sessions
]
+38 -41
View File
@@ -1,41 +1,38 @@
import asyncio import asyncio
import logging import logging
from sqlalchemy import select from app.db.base import SessionLocal
from app.pomodoro.completion import PomodoroCompletionHandler
from app.db.base import SessionLocal from app.pomodoro.service import PomodoroService
from app.db.models import User
from app.pomodoro.completion import PomodoroCompletionHandler logger = logging.getLogger(__name__)
from app.pomodoro.service import PomodoroService
WATCH_INTERVAL_SEC = 2
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 2 async def pomodoro_watcher_loop() -> None:
while True:
try:
async def pomodoro_watcher_loop() -> None: await asyncio.sleep(WATCH_INTERVAL_SEC)
while True: await _tick()
try: except asyncio.CancelledError:
await asyncio.sleep(WATCH_INTERVAL_SEC) raise
await _tick() except Exception:
except asyncio.CancelledError: logger.exception("Pomodoro watcher error")
raise
except Exception:
logger.exception("Pomodoro watcher error") async def _tick() -> None:
db = SessionLocal()
try:
async def _tick() -> None: service = PomodoroService(db)
db = SessionLocal() service.get_status()
try:
users = db.scalars(select(User).where(User.is_active.is_(True))).all() pending = service.get_pending_completions()
for user in users: if not pending:
service = PomodoroService(db, user.id) return
service.get_status()
pending = service.get_pending_completions() handler = PomodoroCompletionHandler(db)
if not pending: for session in pending:
continue await handler.process(session)
handler = PomodoroCompletionHandler(db, user.id) finally:
for session in pending: db.close()
await handler.process(session)
finally:
db.close()
+133 -155
View File
@@ -1,155 +1,133 @@
import time from typing import Any
from typing import Any
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session
from app.config import get_settings
from app.config import get_settings from app.integrations.taiga import TaigaClient
from app.integrations.taiga import TaigaClient from app.projects.service import ProjectService
from app.projects.service import ProjectService
MAX_PROJECTS_IN_CONTEXT = 20
MAX_PROJECTS_IN_CONTEXT = 20 MAX_OPEN_PER_PROJECT = 8
MAX_OPEN_PER_PROJECT = 8
PROJECTS_CACHE_SEC = 120
def get_projects_snapshot(db: Session) -> dict[str, Any]:
_cache: dict[int, dict[str, Any]] = {} settings = get_settings()
service = ProjectService(db)
def invalidate_projects_snapshot_cache(user_id: int | None = None) -> None: if not settings.taiga_configured:
if user_id is None: return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
_cache.clear()
else: projects = service.list_projects()
_cache.pop(user_id, None) if not projects:
try:
projects = service.sync_taiga_projects()
def get_projects_snapshot(db: Session, user_id: int, *, force: bool = False) -> dict[str, Any]: except Exception as exc:
now = time.time() return {
entry = _cache.get(user_id) "configured": True,
if not force and entry and now < entry.get("expires_at", 0): "projects": [],
return entry["data"] "open_items": [],
"taiga_open": [],
snapshot = _fetch_projects_snapshot(db, user_id) "error": str(exc),
_cache[user_id] = {"data": snapshot, "expires_at": now + PROJECTS_CACHE_SEC} }
return snapshot
open_items = service.list_work_items(limit=15, status="open")
taiga_open: list[dict[str, Any]] = []
def _fetch_projects_snapshot(db: Session, user_id: int) -> dict[str, Any]: fetch_error: str | None = None
settings = get_settings()
service = ProjectService(db, user_id) try:
client = TaigaClient()
if not settings.taiga_configured: for proj in projects[:MAX_PROJECTS_IN_CONTEXT]:
return {"configured": False, "projects": [], "open_items": [], "taiga_open": []} stories = client.list_open_userstories(
proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT
projects = service.list_projects() )
if not projects: tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT)
try: taiga_open.append(
projects = service.sync_taiga_projects() {
except Exception as exc: "slug": proj["slug"],
return { "name": proj["name"],
"configured": True, "stories": [
"projects": [], {
"open_items": [], "ref": s.get("ref"),
"taiga_open": [], "subject": s.get("subject", "")[:120],
"error": str(exc), }
} for s in stories
],
open_items = service.list_work_items(limit=15, status="open") "tasks": [
taiga_open: list[dict[str, Any]] = [] {
fetch_error: str | None = None "ref": t.get("ref"),
"subject": t.get("subject", "")[:120],
try: }
client = TaigaClient() for t in tasks
for proj in projects[:MAX_PROJECTS_IN_CONTEXT]: ],
stories = client.list_open_userstories( }
proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT )
) except Exception as exc:
tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT) fetch_error = str(exc)
taiga_open.append(
{ return {
"slug": proj["slug"], "configured": True,
"name": proj["name"], "projects": projects,
"stories": [ "open_items": open_items,
{ "taiga_open": taiga_open,
"ref": s.get("ref"), "error": fetch_error,
"subject": s.get("subject", "")[:120], }
}
for s in stories
], def format_projects_context(snapshot: dict[str, Any]) -> str:
"tasks": [ if not snapshot.get("configured"):
{ return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)."
"ref": t.get("ref"),
"subject": t.get("subject", "")[:120], lines = ["[Проекты и задачи — снимок на начало ответа]"]
}
for t in tasks if snapshot.get("error"):
], lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}")
}
) projects = snapshot.get("projects") or []
except Exception as exc: if not projects:
fetch_error = str(exc) lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.")
else:
return { lines.append(f"Проекты Taiga ({len(projects)}):")
"configured": True, for p in projects[:MAX_PROJECTS_IN_CONTEXT]:
"projects": projects, gitea = (
"open_items": open_items, f"{p.get('gitea_owner')}/{p.get('gitea_repo')}"
"taiga_open": taiga_open, if p.get("gitea_configured")
"error": fetch_error, else "Gitea не привязан"
} )
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}")
def format_projects_context(snapshot: dict[str, Any]) -> str: taiga_open = snapshot.get("taiga_open") or []
if not snapshot.get("configured"): if taiga_open:
return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)." lines.append("")
lines.append("Открытые задачи в Taiga (live):")
lines = ["[Проекты и задачи — снимок на начало ответа]"] for block in taiga_open:
stories = block.get("stories") or []
if snapshot.get("error"): tasks = block.get("tasks") or []
lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}") if not stories and not tasks:
lines.append(f" `{block.get('slug')}`: нет открытых")
projects = snapshot.get("projects") or [] continue
if not projects: lines.append(f" `{block.get('slug')}`:")
lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.") for story in stories:
else: lines.append(f" story #{story.get('ref')} {story.get('subject')}")
lines.append(f"Проекты Taiga ({len(projects)}):") for task in tasks:
for p in projects[:MAX_PROJECTS_IN_CONTEXT]: lines.append(f" task #{task.get('ref')} {task.get('subject')}")
gitea = (
f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" open_items = snapshot.get("open_items") or []
if p.get("gitea_configured") if open_items:
else "Gitea не привязан" lines.append("")
) lines.append("Work items созданные ассистентом (локальная БД):")
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}") for item in open_items[:10]:
gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else ""
taiga_open = snapshot.get("taiga_open") or [] lines.append(
if taiga_open: f"- #{item.get('taiga_ref')} {item.get('title')} "
lines.append("") f"({item.get('taiga_slug')}{gitea_part})"
lines.append("Открытые задачи в Taiga (live):") )
for block in taiga_open:
stories = block.get("stories") or [] lines.append("")
tasks = block.get("tasks") or [] lines.append(
if not stories and not tasks: "Правила: "
lines.append(f" `{block.get('slug')}`: нет открытых") "«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. "
continue "list_work_items — только созданные через ассистента. "
lines.append(f" `{block.get('slug')}`:") "Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. "
for story in stories: "create_work_item — для новых фич/багов из вольного текста."
lines.append(f" story #{story.get('ref')} {story.get('subject')}") )
for task in tasks: return "\n".join(lines)
lines.append(f" task #{task.get('ref')} {task.get('subject')}")
open_items = snapshot.get("open_items") or []
if open_items:
lines.append("")
lines.append("Work items созданные ассистентом (локальная БД):")
for item in open_items[:10]:
gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else ""
lines.append(
f"- #{item.get('taiga_ref')} {item.get('title')} "
f"({item.get('taiga_slug')}{gitea_part})"
)
lines.append("")
lines.append(
"Правила: "
"«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. "
"list_work_items — только созданные через ассистента. "
"Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. "
"create_work_item — для новых фич/багов из вольного текста."
)
return "\n".join(lines)
+450 -476
View File
@@ -1,476 +1,450 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.db.models import ProjectBinding, TaigaProject, WorkItem from app.db.models import ProjectBinding, TaigaProject, WorkItem
from app.integrations.gitea import GiteaClient from app.integrations.gitea import GiteaClient
from app.integrations.taiga import TaigaClient from app.integrations.taiga import TaigaClient
from app.projects.commit_parser import parse_commit_message from app.projects.commit_parser import parse_commit_message
from app.projects.structuring import ( from app.projects.structuring import (
format_gitea_body, format_gitea_body,
format_story_description, format_story_description,
slugify_branch, slugify_branch,
structure_work_item, structure_work_item,
) )
class ProjectService: class ProjectService:
def __init__(self, db: Session, user_id: int): def __init__(self, db: Session):
self.db = db self.db = db
self.user_id = user_id self.settings = get_settings()
self.settings = get_settings()
def sync_taiga_projects(self) -> list[dict[str, Any]]:
def sync_taiga_projects(self) -> list[dict[str, Any]]: if not self.settings.taiga_configured:
if not self.settings.taiga_configured: raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD")
raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD")
client = TaigaClient()
client = TaigaClient() remote = client.list_projects()
remote = client.list_projects() now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc)
for item in remote:
for item in remote: slug = item.get("slug") or ""
slug = item.get("slug") or "" if not slug:
if not slug: continue
continue existing = self.db.scalar(
existing = self.db.scalar( select(TaigaProject).where(TaigaProject.slug == slug)
select(TaigaProject).where(TaigaProject.slug == slug) )
) if existing:
if existing: existing.name = item.get("name", slug)
existing.name = item.get("name", slug) existing.taiga_id = item["id"]
existing.taiga_id = item["id"] existing.synced_at = now
existing.synced_at = now else:
else: self.db.add(
self.db.add( TaigaProject(
TaigaProject( taiga_id=item["id"],
taiga_id=item["id"], name=item.get("name", slug),
name=item.get("name", slug), slug=slug,
slug=slug, synced_at=now,
synced_at=now, )
) )
) self.db.commit()
self.db.commit() return self.list_projects()
return self.list_projects()
def list_projects(self) -> list[dict[str, Any]]:
def list_projects(self) -> list[dict[str, Any]]: stmt = (
stmt = ( select(TaigaProject, ProjectBinding)
select(TaigaProject, ProjectBinding) .outerjoin(ProjectBinding, ProjectBinding.taiga_slug == TaigaProject.slug)
.outerjoin( .order_by(TaigaProject.name)
ProjectBinding, )
(ProjectBinding.taiga_slug == TaigaProject.slug) rows = self.db.execute(stmt).all()
& (ProjectBinding.user_id == self.user_id), result = []
) for taiga_proj, binding in rows:
.order_by(TaigaProject.name) result.append(
) {
rows = self.db.execute(stmt).all() "taiga_id": taiga_proj.taiga_id,
result = [] "name": taiga_proj.name,
for taiga_proj, binding in rows: "slug": taiga_proj.slug,
result.append( "gitea_owner": binding.gitea_owner if binding else "",
{ "gitea_repo": binding.gitea_repo if binding else "",
"taiga_id": taiga_proj.taiga_id, "default_branch": binding.default_branch if binding else "main",
"name": taiga_proj.name, "gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo),
"slug": taiga_proj.slug, }
"gitea_owner": binding.gitea_owner if binding else "", )
"gitea_repo": binding.gitea_repo if binding else "", return result
"default_branch": binding.default_branch if binding else "main",
"gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo), def bind_gitea(
} self,
) taiga_slug: str,
return result gitea_owner: str,
gitea_repo: str,
def bind_gitea( default_branch: str = "main",
self, ) -> dict[str, Any]:
taiga_slug: str, if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)):
gitea_owner: str, raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.")
gitea_repo: str,
default_branch: str = "main", binding = self.db.scalar(
) -> dict[str, Any]: select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_slug)
if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)): )
raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.") if binding:
binding.gitea_owner = gitea_owner
binding = self.db.scalar( binding.gitea_repo = gitea_repo
select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_slug) binding.default_branch = default_branch
) else:
if binding: binding = ProjectBinding(
binding.gitea_owner = gitea_owner taiga_slug=taiga_slug,
binding.gitea_repo = gitea_repo gitea_owner=gitea_owner,
binding.default_branch = default_branch gitea_repo=gitea_repo,
else: default_branch=default_branch,
binding = ProjectBinding( )
user_id=self.user_id, self.db.add(binding)
taiga_slug=taiga_slug, self.db.commit()
gitea_owner=gitea_owner,
gitea_repo=gitea_repo, for proj in self.list_projects():
default_branch=default_branch, if proj["slug"] == taiga_slug:
) return proj
self.db.add(binding) raise ValueError("Binding failed")
self.db.commit()
def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]:
for proj in self.list_projects(): projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all()
if proj["slug"] == taiga_slug: if not projects:
return proj raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.")
raise ValueError("Binding failed")
taiga_proj: TaigaProject | None = None
def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]: if slug:
projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all() taiga_proj = self.db.scalar(
if not projects: select(TaigaProject).where(TaigaProject.slug == slug)
raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.") )
if not taiga_proj:
taiga_proj: TaigaProject | None = None raise ValueError(f"Проект '{slug}' не найден")
if slug: else:
taiga_proj = self.db.scalar( taiga_proj = projects[0]
select(TaigaProject).where(TaigaProject.slug == slug)
) binding = self.db.scalar(
if not taiga_proj: select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_proj.slug)
raise ValueError(f"Проект '{slug}' не найден") )
else: return taiga_proj, binding
taiga_proj = projects[0]
async def create_work_item(
binding = self.db.scalar( self, raw_text: str, project_slug: str | None = None
select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_proj.slug) ) -> dict[str, Any]:
) if not self.settings.taiga_configured:
return taiga_proj, binding raise ValueError("Taiga не настроена")
async def create_work_item( project_list = self.list_projects()
self, raw_text: str, project_slug: str | None = None if not project_list:
) -> dict[str, Any]: self.sync_taiga_projects()
if not self.settings.taiga_configured: project_list = self.list_projects()
raise ValueError("Taiga не настроена")
structured = await structure_work_item(raw_text, project_list)
project_list = self.list_projects() slug = project_slug or structured.get("project_slug")
if not project_list: taiga_proj, binding = self._resolve_project(slug)
self.sync_taiga_projects()
project_list = self.list_projects() if binding and not (binding.gitea_owner and binding.gitea_repo):
binding = None
structured = await structure_work_item(raw_text, project_list)
slug = project_slug or structured.get("project_slug") taiga = TaigaClient()
taiga_proj, binding = self._resolve_project(slug) title = (structured.get("title") or raw_text).strip()[:500]
description = format_story_description(structured, raw_text)
if binding and not (binding.gitea_owner and binding.gitea_repo): tags = structured.get("tags") or []
binding = None issue_type = structured.get("issue_type", "feature")
if issue_type == "bug" and "bug" not in [t.lower() for t in tags]:
taiga = TaigaClient() tags.append("bug")
title = (structured.get("title") or raw_text).strip()[:500]
description = format_story_description(structured, raw_text) story = taiga.create_userstory(
tags = structured.get("tags") or [] taiga_proj.taiga_id,
issue_type = structured.get("issue_type", "feature") title,
if issue_type == "bug" and "bug" not in [t.lower() for t in tags]: description,
tags.append("bug") tags=tags,
)
story = taiga.create_userstory(
taiga_proj.taiga_id, subtasks = []
title, for child in structured.get("children") or []:
description, if isinstance(child, dict):
tags=tags, subtasks.append(
) taiga.create_task(
taiga_proj.taiga_id,
subtasks = [] story["id"],
for child in structured.get("children") or []: child.get("title", "Подзадача"),
if isinstance(child, dict): child.get("description", ""),
subtasks.append( )
taiga.create_task( )
taiga_proj.taiga_id,
story["id"], branch = f"feature/{story['ref']}-{slugify_branch(title)}"
child.get("title", "Подзадача"), gitea_issue_number = None
child.get("description", ""), gitea_url = ""
)
) if binding and self.settings.gitea_configured:
gitea = GiteaClient()
branch = f"feature/{story['ref']}-{slugify_branch(title)}" gitea_body = format_gitea_body(
gitea_issue_number = None structured,
gitea_url = "" raw_text,
story["ref"],
if binding and self.settings.gitea_configured: taiga.story_url(taiga_proj.taiga_id, story["ref"]),
gitea = GiteaClient() branch,
gitea_body = format_gitea_body( )
structured, if issue_type:
raw_text, gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}"
story["ref"], issue = gitea.create_issue(
taiga.story_url(taiga_proj.taiga_id, story["ref"]), binding.gitea_owner,
branch, binding.gitea_repo,
) title,
if issue_type: gitea_body,
gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}" )
issue = gitea.create_issue( gitea_issue_number = issue["number"]
binding.gitea_owner, gitea_url = gitea.issue_url(
binding.gitea_repo, binding.gitea_owner, binding.gitea_repo, gitea_issue_number
title, )
gitea_body,
) work_item = WorkItem(
gitea_issue_number = issue["number"] taiga_slug=taiga_proj.slug,
gitea_url = gitea.issue_url( taiga_project_id=taiga_proj.taiga_id,
binding.gitea_owner, binding.gitea_repo, gitea_issue_number taiga_story_id=story["id"],
) taiga_story_ref=story["ref"],
gitea_owner=binding.gitea_owner if binding else "",
work_item = WorkItem( gitea_repo=binding.gitea_repo if binding else "",
user_id=self.user_id, gitea_issue_number=gitea_issue_number,
taiga_slug=taiga_proj.slug, suggested_branch=branch,
taiga_project_id=taiga_proj.taiga_id, raw_text=raw_text,
taiga_story_id=story["id"], title=title,
taiga_story_ref=story["ref"], status="open",
gitea_owner=binding.gitea_owner if binding else "", )
gitea_repo=binding.gitea_repo if binding else "", self.db.add(work_item)
gitea_issue_number=gitea_issue_number, self.db.commit()
suggested_branch=branch, self.db.refresh(work_item)
raw_text=raw_text,
title=title, return {
status="open", "ok": True,
) "work_item_id": work_item.id,
self.db.add(work_item) "taiga": {
self.db.commit() "ref": story["ref"],
self.db.refresh(work_item) "id": story["id"],
"subject": story["subject"],
return { "url": taiga.story_url(taiga_proj.taiga_id, story["ref"]),
"ok": True, },
"work_item_id": work_item.id, "gitea": {
"taiga": { "number": gitea_issue_number,
"ref": story["ref"], "url": gitea_url,
"id": story["id"], },
"subject": story["subject"], "branch": branch,
"url": taiga.story_url(taiga_proj.taiga_id, story["ref"]), "issue_type": issue_type,
}, "subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks],
"gitea": { "questions": structured.get("questions") or [],
"number": gitea_issue_number, "project_slug": taiga_proj.slug,
"url": gitea_url, }
},
"branch": branch, def process_push(
"issue_type": issue_type, self, owner: str, repo: str, commits: list[dict[str, Any]]
"subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks], ) -> list[dict[str, Any]]:
"questions": structured.get("questions") or [], if not self.settings.taiga_configured:
"project_slug": taiga_proj.slug, return []
}
taiga = TaigaClient()
def process_push( gitea = GiteaClient() if self.settings.gitea_configured else None
self, owner: str, repo: str, commits: list[dict[str, Any]] results: list[dict[str, Any]] = []
) -> list[dict[str, Any]]:
if not self.settings.taiga_configured: for commit in commits:
return [] message = commit.get("message", "")
parsed = parse_commit_message(message)
taiga = TaigaClient() sha = commit.get("id", "")[:8]
gitea = GiteaClient() if self.settings.gitea_configured else None
results: list[dict[str, Any]] = [] gitea_refs = set(parsed["gitea"])
taiga_story_refs = set(parsed["taiga_story"])
for commit in commits: taiga_task_refs = set(parsed["taiga_task"])
message = commit.get("message", "")
parsed = parse_commit_message(message) linked_items = self.db.scalars(
sha = commit.get("id", "")[:8] select(WorkItem).where(
WorkItem.gitea_owner == owner,
gitea_refs = set(parsed["gitea"]) WorkItem.gitea_repo == repo,
taiga_story_refs = set(parsed["taiga_story"]) WorkItem.status == "open",
taiga_task_refs = set(parsed["taiga_task"]) )
).all()
linked_items = self.db.scalars(
select(WorkItem).where( for item in linked_items:
WorkItem.user_id == self.user_id, if item.gitea_issue_number and item.gitea_issue_number in gitea_refs:
WorkItem.gitea_owner == owner, taiga_story_refs.add(item.taiga_story_ref)
WorkItem.gitea_repo == repo, if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number:
WorkItem.status == "open", gitea_refs.add(item.gitea_issue_number)
)
).all() for gitea_num in gitea_refs:
if gitea:
for item in linked_items: try:
if item.gitea_issue_number and item.gitea_issue_number in gitea_refs: gitea.close_issue(owner, repo, gitea_num)
taiga_story_refs.add(item.taiga_story_ref) except Exception as exc:
if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number: results.append({"error": f"gitea #{gitea_num}: {exc}"})
gitea_refs.add(item.gitea_issue_number) continue
for gitea_num in gitea_refs: for item in linked_items:
if gitea: if item.gitea_issue_number == gitea_num:
try: self._close_work_item(item, taiga)
gitea.close_issue(owner, repo, gitea_num) results.append(
except Exception as exc: {
results.append({"error": f"gitea #{gitea_num}: {exc}"}) "commit": sha,
continue "closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}",
}
for item in linked_items: )
if item.gitea_issue_number == gitea_num:
try: for ref in taiga_story_refs:
self._close_work_item(item, taiga) project_id = self._project_id_for_ref(owner, repo, ref, linked_items)
results.append( if not project_id:
{ continue
"commit": sha, story = taiga.get_by_ref(project_id, ref, kind="userstory")
"closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}", if story:
} taiga.close_userstory(story["id"], project_id)
) for item in linked_items:
except Exception as exc: if item.taiga_story_ref == ref:
results.append( self._close_work_item(item, taiga, close_gitea=bool(gitea))
{"error": f"work item {item.id} (gitea #{gitea_num}): {exc}"} results.append({"commit": sha, "closed": f"taiga #{ref}"})
)
for ref in taiga_task_refs:
for ref in taiga_story_refs: binding = self.db.scalar(
project_id = self._project_id_for_ref(owner, repo, ref, linked_items) select(ProjectBinding).where(
if not project_id: ProjectBinding.gitea_owner == owner,
continue ProjectBinding.gitea_repo == repo,
story = taiga.get_by_ref(project_id, ref, kind="userstory") )
if story and not story.get("is_closed"): )
try: if not binding:
taiga.close_userstory(story["id"], project_id) continue
results.append({"commit": sha, "closed": f"taiga #{ref}"}) taiga_proj = self.db.scalar(
except Exception as exc: select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
results.append({"error": f"taiga #{ref}: {exc}"}) )
for item in linked_items: if not taiga_proj:
if item.taiga_story_ref == ref and item.status != "closed": continue
try: task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task")
self._close_work_item(item, taiga, close_gitea=bool(gitea)) if task:
except Exception as exc: taiga.close_task(task["id"], taiga_proj.taiga_id)
results.append( results.append({"commit": sha, "closed": f"taiga task #{ref}"})
{"error": f"work item {item.id} (taiga #{ref}): {exc}"}
) self.db.commit()
return results
for ref in taiga_task_refs:
binding = self.db.scalar( def _project_id_for_ref(
select(ProjectBinding).where( self,
ProjectBinding.user_id == self.user_id, owner: str,
ProjectBinding.gitea_owner == owner, repo: str,
ProjectBinding.gitea_repo == repo, ref: int,
) items: list[WorkItem],
) ) -> int | None:
if not binding: for item in items:
continue if item.taiga_story_ref == ref:
taiga_proj = self.db.scalar( return item.taiga_project_id
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug) binding = self.db.scalar(
) select(ProjectBinding).where(
if not taiga_proj: ProjectBinding.gitea_owner == owner,
continue ProjectBinding.gitea_repo == repo,
task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task") )
if task and not task.get("is_closed"): )
try: if binding:
taiga.close_task(task["id"], taiga_proj.taiga_id) taiga_proj = self.db.scalar(
results.append({"commit": sha, "closed": f"taiga task #{ref}"}) select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
except Exception as exc: )
results.append({"error": f"taiga task #{ref}: {exc}"}) return taiga_proj.taiga_id if taiga_proj else None
return None
self.db.commit()
return results def _close_work_item(
self,
def _project_id_for_ref( item: WorkItem,
self, taiga: TaigaClient,
owner: str, *,
repo: str, close_gitea: bool = True,
ref: int, ) -> None:
items: list[WorkItem], if item.status == "closed":
) -> int | None: return
for item in items: story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory")
if item.taiga_story_ref == ref: if story:
return item.taiga_project_id taiga.close_userstory(story["id"], item.taiga_project_id)
binding = self.db.scalar( if (
select(ProjectBinding).where( close_gitea
ProjectBinding.user_id == self.user_id, and item.gitea_issue_number
ProjectBinding.gitea_owner == owner, and self.settings.gitea_configured
ProjectBinding.gitea_repo == repo, ):
) GiteaClient().close_issue(
) item.gitea_owner, item.gitea_repo, item.gitea_issue_number
if binding: )
taiga_proj = self.db.scalar( item.status = "closed"
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug) item.closed_at = datetime.now(timezone.utc)
)
return taiga_proj.taiga_id if taiga_proj else None def list_taiga_open_tasks(
return None self,
project_slug: str | None = None,
def _close_work_item( limit: int = 20,
self, ) -> dict[str, Any]:
item: WorkItem, if not self.settings.taiga_configured:
taiga: TaigaClient, raise ValueError("Taiga не настроена")
*,
close_gitea: bool = True, projects = self.list_projects()
) -> None: if not projects:
if item.status == "closed": projects = self.sync_taiga_projects()
return
story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory") if project_slug:
if story: projects = [p for p in projects if p["slug"] == project_slug]
taiga.close_userstory(story["id"], item.taiga_project_id) if not projects:
if ( raise ValueError(
close_gitea f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects."
and item.gitea_issue_number )
and self.settings.gitea_configured
): client = TaigaClient()
GiteaClient().close_issue( blocks: list[dict[str, Any]] = []
item.gitea_owner, item.gitea_repo, item.gitea_issue_number
) for proj in projects:
item.status = "closed" stories = client.list_open_userstories(proj["taiga_id"], limit=limit)
item.closed_at = datetime.now(timezone.utc) tasks = client.list_open_tasks(proj["taiga_id"], limit=limit)
blocks.append(
def list_taiga_open_tasks( {
self, "slug": proj["slug"],
project_slug: str | None = None, "name": proj["name"],
limit: int = 20, "taiga_id": proj["taiga_id"],
) -> dict[str, Any]: "stories": [
if not self.settings.taiga_configured: {
raise ValueError("Taiga не настроена") "ref": s.get("ref"),
"subject": s.get("subject", ""),
projects = self.list_projects() "url": client.story_url(proj["taiga_id"], s.get("ref", 0)),
if not projects: }
projects = self.sync_taiga_projects() for s in stories
],
if project_slug: "tasks": [
projects = [p for p in projects if p["slug"] == project_slug] {
if not projects: "ref": t.get("ref"),
raise ValueError( "subject": t.get("subject", ""),
f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects." "user_story": t.get("user_story"),
) }
for t in tasks
client = TaigaClient() ],
blocks: list[dict[str, Any]] = [] }
)
for proj in projects:
stories = client.list_open_userstories(proj["taiga_id"], limit=limit) total_stories = sum(len(b["stories"]) for b in blocks)
tasks = client.list_open_tasks(proj["taiga_id"], limit=limit) total_tasks = sum(len(b["tasks"]) for b in blocks)
blocks.append( return {
{ "projects": blocks,
"slug": proj["slug"], "total_stories": total_stories,
"name": proj["name"], "total_tasks": total_tasks,
"taiga_id": proj["taiga_id"], }
"stories": [
{ def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]:
"ref": s.get("ref"), stmt = select(WorkItem).order_by(WorkItem.created_at.desc()).limit(limit)
"subject": s.get("subject", ""), if status:
"url": client.story_url(proj["taiga_id"], s.get("ref", 0)), stmt = stmt.where(WorkItem.status == status)
} items = self.db.scalars(stmt).all()
for s in stories settings = get_settings()
], return [
"tasks": [ {
{ "id": i.id,
"ref": t.get("ref"), "title": i.title,
"subject": t.get("subject", ""), "status": i.status,
"user_story": t.get("user_story"), "taiga_slug": i.taiga_slug,
} "taiga_ref": i.taiga_story_ref,
for t in tasks "gitea_issue": i.gitea_issue_number,
], "branch": i.suggested_branch,
} "taiga_url": f"{settings.taiga_public_url}/project/0/{i.taiga_project_id}/us/{i.taiga_story_ref}",
) "gitea_url": (
f"{settings.gitea_public_url}/{i.gitea_owner}/{i.gitea_repo}/issues/{i.gitea_issue_number}"
total_stories = sum(len(b["stories"]) for b in blocks) if i.gitea_issue_number
total_tasks = sum(len(b["tasks"]) for b in blocks) else ""
return { ),
"projects": blocks, "created_at": i.created_at.isoformat() if i.created_at else None,
"total_stories": total_stories, }
"total_tasks": total_tasks, for i in items
} ]
def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]:
stmt = select(WorkItem).where(WorkItem.user_id == self.user_id).order_by(WorkItem.created_at.desc()).limit(limit)
if status:
stmt = stmt.where(WorkItem.status == status)
items = self.db.scalars(stmt).all()
settings = get_settings()
return [
{
"id": i.id,
"title": i.title,
"status": i.status,
"taiga_slug": i.taiga_slug,
"taiga_ref": i.taiga_story_ref,
"gitea_issue": i.gitea_issue_number,
"branch": i.suggested_branch,
"taiga_url": f"{settings.taiga_public_url}/project/0/{i.taiga_project_id}/us/{i.taiga_story_ref}",
"gitea_url": (
f"{settings.gitea_public_url}/{i.gitea_owner}/{i.gitea_repo}/issues/{i.gitea_issue_number}"
if i.gitea_issue_number
else ""
),
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in items
]
-5
View File
@@ -1,5 +0,0 @@
"""RAG: embeddings, Qdrant store, retrieval, ingest."""
from app.rag import chunker, embeddings, ingest, retriever, store
__all__ = ["chunker", "embeddings", "ingest", "retriever", "store"]
-20
View File
@@ -1,20 +0,0 @@
from __future__ import annotations
def chunk_text(text: str, *, chunk_size: int = 800, overlap: int = 120) -> list[str]:
cleaned = (text or "").strip()
if not cleaned:
return []
if len(cleaned) <= chunk_size:
return [cleaned]
chunks: list[str] = []
start = 0
while start < len(cleaned):
end = min(len(cleaned), start + chunk_size)
piece = cleaned[start:end].strip()
if piece:
chunks.append(piece)
if end >= len(cleaned):
break
start = max(0, end - overlap)
return chunks
-10
View File
@@ -1,10 +0,0 @@
from __future__ import annotations
from app.llm.client import LLMClient
async def embed_texts(texts: list[str]) -> list[list[float]]:
if not texts:
return []
client = LLMClient()
return await client.embed(texts)
-152
View File
@@ -1,152 +0,0 @@
from __future__ import annotations
import hashlib
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from qdrant_client.http import models as qm
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.models import ChatSession, Document, DocumentChunk, MemoryFact
from app.rag import embeddings
from app.rag.chunker import chunk_text
from app.rag.store import (
COLLECTION_DOC_CHUNKS,
COLLECTION_FACTS,
COLLECTION_SUMMARIES,
delete_by_filter,
upsert_points,
)
async def index_memory_fact(fact: MemoryFact) -> None:
settings = get_settings()
if not settings.rag_enabled or not fact.active:
return
vectors = await embeddings.embed_texts([fact.content])
if not vectors:
return
upsert_points(
COLLECTION_FACTS,
[
qm.PointStruct(
id=int(fact.id),
vector=vectors[0],
payload={
"user_id": fact.user_id,
"fact_id": fact.id,
"category": fact.category,
"content": fact.content,
"importance": fact.importance,
},
)
],
)
async def deactivate_memory_fact(fact_id: int) -> None:
settings = get_settings()
if not settings.rag_enabled:
return
delete_by_filter(
COLLECTION_FACTS,
[qm.FieldCondition(key="fact_id", match=qm.MatchValue(value=fact_id))],
)
async def index_session_summary(session_id: int, summary: str) -> None:
settings = get_settings()
if not settings.rag_enabled or not summary.strip():
return
from app.db.base import SessionLocal
user_id = 1
db = SessionLocal()
try:
session = db.get(ChatSession, session_id)
if session:
user_id = session.user_id
finally:
db.close()
vectors = await embeddings.embed_texts([summary])
if not vectors:
return
upsert_points(
COLLECTION_SUMMARIES,
[
qm.PointStruct(
id=int(session_id),
vector=vectors[0],
payload={"user_id": user_id, "session_id": session_id, "summary": summary[:4000]},
)
],
)
async def ingest_document_file(
db: Session,
*,
user_id: int,
title: str,
filename: str,
raw_bytes: bytes,
) -> dict[str, Any]:
settings = get_settings()
text = raw_bytes.decode("utf-8", errors="replace").strip()
if not text:
raise ValueError("Пустой документ")
digest = hashlib.sha256(raw_bytes).hexdigest()
doc = Document(
user_id=user_id,
title=title or filename,
filename=filename,
content_hash=digest,
size_bytes=len(raw_bytes),
)
db.add(doc)
db.flush()
chunks = chunk_text(text)
chunk_rows: list[DocumentChunk] = []
for idx, piece in enumerate(chunks):
row = DocumentChunk(document_id=doc.id, chunk_index=idx, content=piece)
db.add(row)
chunk_rows.append(row)
db.commit()
db.refresh(doc)
if settings.rag_enabled and chunks:
vectors = await embeddings.embed_texts(chunks)
points: list[qm.PointStruct] = []
for row, vector in zip(chunk_rows, vectors, strict=False):
db.refresh(row)
point_id = int(row.id)
points.append(
qm.PointStruct(
id=point_id,
vector=vector,
payload={
"user_id": user_id,
"document_id": doc.id,
"chunk_id": row.id,
"chunk_index": row.chunk_index,
"title": doc.title,
"content": row.content,
},
)
)
upsert_points(COLLECTION_DOC_CHUNKS, points)
return {
"id": doc.id,
"title": doc.title,
"filename": doc.filename,
"chunk_count": len(chunks),
"size_bytes": doc.size_bytes,
"created_at": doc.created_at.isoformat() if doc.created_at else None,
}
@@ -1,37 +0,0 @@
# Migrate active memory facts into Qdrant
from __future__ import annotations
import asyncio
from sqlalchemy import select
from app.config import get_settings
from app.db.base import SessionLocal, init_db
from app.db.models import MemoryFact, SessionSummary
from app.rag.ingest import index_memory_fact, index_session_summary
from app.rag.store import ensure_collections
async def main() -> None:
settings = get_settings()
if not settings.rag_enabled:
print("RAG disabled; set RAG_ENABLED=true")
return
init_db()
ensure_collections()
db = SessionLocal()
try:
facts = db.scalars(select(MemoryFact).where(MemoryFact.active.is_(True))).all()
for fact in facts:
await index_memory_fact(fact)
summaries = db.scalars(select(SessionSummary)).all()
for row in summaries:
if row.summary:
await index_session_summary(row.session_id, row.summary)
print(f"Indexed {len(facts)} facts and {len(summaries)} summaries")
finally:
db.close()
if __name__ == "__main__":
asyncio.run(main())
-67
View File
@@ -1,67 +0,0 @@
from __future__ import annotations
from typing import Any
from qdrant_client.http import models as qm
from app.config import get_settings
from app.rag import embeddings
from app.rag.store import COLLECTION_DOC_CHUNKS, COLLECTION_FACTS, search
def _user_filter(user_id: int) -> qm.Filter:
return qm.Filter(
must=[qm.FieldCondition(key="user_id", match=qm.MatchValue(value=user_id))]
)
async def retrieve_memory_facts(
query: str, *, user_id: int, top_k: int | None = None
) -> list[dict[str, Any]]:
settings = get_settings()
if not settings.rag_enabled or not query.strip():
return []
k = top_k or settings.rag_top_k
vectors = await embeddings.embed_texts([query])
if not vectors:
return []
hits = search(COLLECTION_FACTS, vectors[0], limit=k, query_filter=_user_filter(user_id))
results: list[dict[str, Any]] = []
for hit in hits:
payload = hit.payload or {}
results.append(
{
"id": payload.get("fact_id") or hit.id,
"category": payload.get("category", "fact"),
"content": payload.get("content", ""),
"score": hit.score,
}
)
return results
async def retrieve_document_chunks(
query: str, *, user_id: int, top_k: int = 6
) -> list[dict[str, Any]]:
settings = get_settings()
if not settings.rag_enabled or not query.strip():
return []
vectors = await embeddings.embed_texts([query])
if not vectors:
return []
hits = search(
COLLECTION_DOC_CHUNKS, vectors[0], limit=top_k, query_filter=_user_filter(user_id)
)
out: list[dict[str, Any]] = []
for hit in hits:
payload = hit.payload or {}
out.append(
{
"document_id": payload.get("document_id"),
"chunk_index": payload.get("chunk_index"),
"title": payload.get("title", ""),
"content": payload.get("content", ""),
"score": hit.score,
}
)
return out
-64
View File
@@ -1,64 +0,0 @@
from __future__ import annotations
import logging
from typing import Any
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
from app.config import get_settings
logger = logging.getLogger(__name__)
COLLECTION_FACTS = "memory_facts"
COLLECTION_SUMMARIES = "session_summaries"
COLLECTION_DOC_CHUNKS = "document_chunks"
VECTOR_SIZE = 1536
def _client() -> QdrantClient:
settings = get_settings()
return QdrantClient(url=settings.qdrant_url)
def ensure_collections() -> None:
settings = get_settings()
if not settings.rag_enabled:
return
client = _client()
for name in (COLLECTION_FACTS, COLLECTION_SUMMARIES, COLLECTION_DOC_CHUNKS):
if client.collection_exists(name):
continue
client.create_collection(
collection_name=name,
vectors_config=qm.VectorParams(size=VECTOR_SIZE, distance=qm.Distance.COSINE),
)
logger.info("Created Qdrant collection %s", name)
def upsert_points(collection: str, points: list[qm.PointStruct]) -> None:
if not points:
return
_client().upsert(collection_name=collection, points=points)
def delete_by_filter(collection: str, must: list[qm.FieldCondition]) -> None:
_client().delete(
collection_name=collection,
points_selector=qm.FilterSelector(filter=qm.Filter(must=must)),
)
def search(
collection: str,
vector: list[float],
*,
limit: int,
query_filter: qm.Filter | None = None,
) -> list[qm.ScoredPoint]:
return _client().search(
collection_name=collection,
query_vector=vector,
limit=limit,
query_filter=query_filter,
)
-3
View File
@@ -1,3 +0,0 @@
from app.reminders.service import RemindersService
__all__ = ["RemindersService"]

Some files were not shown because too many files have changed in this diff Show More