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
207 changed files with 3233 additions and 25251 deletions
+40 -124
View File
@@ -1,124 +1,40 @@
# Server (internal bind inside containers)
HOST=0.0.0.0
BACKEND_INTERNAL_PORT=8080
FRONTEND_INTERNAL_PORT=80
# External ports on the host (docker compose publish)
BACKEND_PORT=8080
FRONTEND_PORT=3080
VITE_DEV_PORT=5173
# OpenRouter
OPENROUTER_API_KEY=sk-or-v1-your-key-here
OPENROUTER_MODEL=deepseek/deepseek-chat
# deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются:
# OPENROUTER_MODEL=deepseek/deepseek-v4-pro
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_TOOLS_ENABLED=true
# none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning.
OPENROUTER_REASONING_EFFORT=none
# JSON-экстракция памяти отдельной моделью (если основная капризничает):
# MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat
# App
DATABASE_URL=sqlite:///./data/assistant.db
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
SYSTEM_PROMPT_PATH=./prompts/assistant.md
MEMORY_AUTO_EXTRACT=true
# Multi-user (API token auth)
DEFAULT_USER_USERNAME=owner
DEFAULT_USER_DISPLAY_NAME=
DEFAULT_API_TOKEN=change-me-to-long-random-string
AUTH_REQUIRED=true
# Опционально для dev (автовход без /login). В prod оставьте пустым.
VITE_API_TOKEN=
# Fitness (wger + Open Food Facts — public HTTPS, no proxy)
WGER_BASE_URL=https://wger.de/api/v2
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
FITNESS_REMINDERS_ENABLED=true
REMINDERS_ENABLED=true
# Taiga (on host :9000, nginx → taiga.grigowashere.ru)
TAIGA_BASE_URL=http://host.docker.internal:9000
TAIGA_USERNAME=your_taiga_user
TAIGA_PASSWORD=your_taiga_password
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
# Gitea (on host :3000, nginx → git.grigowashere.ru)
GITEA_BASE_URL=http://host.docker.internal:3000
GITEA_TOKEN=your_gitea_api_token
GITEA_PUBLIC_URL=https://git.grigowashere.ru
GITEA_WEBHOOK_SECRET=generate_a_random_secret
# Gitea webhook URL (repo Settings → Webhooks):
# https://assistant.your-domain/api/v1/webhooks/gitea ← nginx → 127.0.0.1:BACKEND_PORT
# http://172.17.0.1:8202/api/v1/webhooks/gitea ← если Gitea в Docker (не 127.0.0.1!)
REPOS_DIR=/data/repos
# Homelab — GPU PC 192.168.1.109
OPENMETEO_BASE_URL=http://192.168.1.109:8085
WEATHER_LAT=59.9343
WEATHER_LON=30.3351
WEATHER_LOCATION_NAME=Санкт-Петербург
WEATHER_CACHE_SEC=300
# Если локальный 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
# 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
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# App
DATABASE_URL=sqlite:///./data/assistant.db
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
SYSTEM_PROMPT_PATH=./prompts/assistant.md
# 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 (configure in repo settings):
# http://127.0.0.1:8080/api/v1/webhooks/gitea
REPOS_DIR=/data/repos
# Vector DB (phase 3)
QDRANT_PORT=6333
QDRANT_GRPC_PORT=6334
-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 -104
View File
@@ -132,16 +132,11 @@ curl -X PUT http://localhost:8080/api/v1/projects/home-assistant/gitea \
В репозитории: **Settings → Webhooks → Add Webhook**:
- URL (выбери один вариант):
- **Рекомендуется:** `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)
- URL: `http://127.0.0.1:8080/api/v1/webhooks/gitea`
- Content type: `application/json`
- Secret: значение `GITEA_WEBHOOK_SECRET`
- Events: **Push**
Проверка из контейнера Gitea: `docker exec gitea wget -qO- http://172.17.0.1:8202/api/v1/health`
Test delivery в Gitea должен вернуть **200**, не **0**.
### Автозакрытие по коммиту
В сообщении коммита:
@@ -165,106 +160,11 @@ frontend/ React + Vite, чат и таймер
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)
- Telegram-бот
- Taiga/fitness в утреннем дайджесте
- Графики веса, LLM-мотивация в напоминаниях
- RAG с Qdrant для документов
- Проактивные чаты по расписанию
- Фитнес-трекер
## Модель
+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 app.api.routes import auth, character, chat, documents, fitness, health, homelab, media, memory, pomodoro, projects, reminders, settings, shopping, webhooks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router)
api_router.include_router(homelab.router, tags=["homelab"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(character.router, tags=["character"])
api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"])
api_router.include_router(settings.router, tags=["settings"])
api_router.include_router(documents.router, tags=["documents"])
from fastapi import APIRouter
from app.api.routes import character, chat, health, pomodoro, projects, webhooks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"])
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(webhooks.router, tags=["webhooks"])
@@ -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 fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.character.service import CharacterService
from app.db.base import get_db
from app.db.models import User
router = APIRouter()
class CharacterCardData(BaseModel):
name: str = "Ассистент"
description: str = ""
personality: str = ""
scenario: str = ""
first_mes: str = ""
mes_example: str = ""
system_prompt: str = ""
post_history_instructions: str = ""
tags: list[str] = Field(default_factory=list)
creator: str = ""
creator_notes: str = ""
alternate_greetings: list[str] = Field(default_factory=list)
character_version: str = "1.0"
appearance_tags: str = ""
appearance_prose: str = ""
lora_name: str = ""
lora_weight: float = 0.8
rp_persona_id: str = ""
sd_enabled: bool = True
class CharacterCardV2(BaseModel):
spec: str = "chara_card_v2"
spec_version: str = "2.0"
data: CharacterCardData
@router.get("/character")
def get_character(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
return CharacterService(db, user.id).get_card()
@router.put("/character")
def update_character(
payload: CharacterCardV2,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
return CharacterService(db, user.id).save_card(payload.model_dump())
@router.get("/character/prompt")
def get_character_prompt(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, str]:
service = CharacterService(db, user.id)
return {
"system_prompt": service.get_system_prompt(),
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
}
@router.post("/character/import")
def import_character(
payload: dict[str, Any],
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
if not payload:
raise HTTPException(status_code=400, detail="Empty card")
return CharacterService(db, user.id).save_card(payload)
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.character.service import CharacterService
router = APIRouter()
class CharacterCardData(BaseModel):
name: str = "Ассистент"
description: str = ""
personality: str = ""
scenario: str = ""
first_mes: str = ""
mes_example: str = ""
system_prompt: str = ""
post_history_instructions: str = ""
tags: list[str] = Field(default_factory=list)
creator: str = ""
creator_notes: str = ""
alternate_greetings: list[str] = Field(default_factory=list)
character_version: str = "1.0"
class CharacterCardV2(BaseModel):
spec: str = "chara_card_v2"
spec_version: str = "2.0"
data: CharacterCardData
@router.get("/character")
def get_character() -> dict[str, Any]:
return CharacterService().get_card()
@router.put("/character")
def update_character(payload: CharacterCardV2) -> dict[str, Any]:
return CharacterService().save_card(payload.model_dump())
@router.get("/character/prompt")
def get_character_prompt() -> dict[str, str]:
service = CharacterService()
return {
"system_prompt": service.get_system_prompt(),
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
}
@router.post("/character/import")
def import_character(payload: dict[str, Any]) -> dict[str, Any]:
if not payload:
raise HTTPException(status_code=400, detail="Empty card")
return CharacterService().save_card(payload)
+55 -158
View File
@@ -1,158 +1,55 @@
import asyncio
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.api.chat_schemas import GenerationStatusOut, MessagesPageOut
from app.api.schemas import (
MessageCreate,
SessionCreate,
SessionDetailOut,
SessionOut,
)
from app.chat.generation import (
GenerationBusyError,
get_active_handle,
is_generation_active,
start_generation,
subscribe_generation,
)
from app.chat.service import ChatService
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
router = APIRouter()
@router.post("/sessions", response_model=SessionOut)
def create_session(payload: SessionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionOut:
service = ChatService(db, user.id)
return service.create_session(title=payload.title)
@router.get("/sessions", response_model=list[SessionOut])
def list_sessions(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[SessionOut]:
service = ChatService(db, user.id)
return service.list_sessions()
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
def get_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionDetailOut:
service = ChatService(db, user.id)
session = service.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.get("/sessions/{session_id}/messages", response_model=MessagesPageOut)
def list_messages(
session_id: int,
limit: int = 30,
before_id: int | None = None,
after_id: int | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> MessagesPageOut:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
messages, has_more = service.list_messages(
session_id,
limit=min(max(limit, 1), 100),
before_id=before_id,
after_id=after_id,
)
return MessagesPageOut(messages=messages, has_more=has_more)
@router.get("/sessions/{session_id}/generation", response_model=GenerationStatusOut)
def generation_status(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> GenerationStatusOut:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return GenerationStatusOut(active=is_generation_active(session_id))
@router.get("/sessions/{session_id}/generation/stream")
async def generation_stream(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> StreamingResponse:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
handle = get_active_handle(session_id)
if not handle:
raise HTTPException(status_code=404, detail="No active generation")
async def event_stream():
async for chunk in subscribe_generation(handle):
yield chunk
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
service = ChatService(db, user.id)
if not service.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@router.post("/sessions/{session_id}/messages")
async def send_message(
session_id: int,
payload: MessageCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> StreamingResponse:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
if is_generation_active(session_id):
raise HTTPException(status_code=409, detail="Generation already in progress")
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД.
service.save_user_message(session_id, payload.content)
try:
handle = await start_generation(session_id, user.id, payload.content)
except GenerationBusyError:
raise HTTPException(status_code=409, detail="Generation already in progress") from None
async def event_stream():
try:
async for chunk in subscribe_generation(handle):
yield chunk
except asyncio.CancelledError:
raise
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/sessions/{session_id}/context-preview")
def context_preview(
session_id: int,
query: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict:
service = ChatService(db, user.id)
return service.context_preview(session_id, query=query)
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")
async def event_stream():
async for chunk in service.stream_response(session_id, payload.content):
yield chunk
return StreamingResponse(event_stream(), media_type="text/event-stream")
@@ -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}
-319
View File
@@ -1,319 +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
activity_level: str | None = None
goal: str | None = None
target_weight_kg: float | None = None
weekly_workouts: int | None = None
baseline_steps: int | None = None
baseline_workout_kcal: float | None = None
class MealCreate(BaseModel):
text: str = Field(min_length=1)
meal_type: str | None = None
class WaterCreate(BaseModel):
amount_ml: int = Field(gt=0)
class WeightCreate(BaseModel):
weight_kg: float = Field(gt=0)
body_fat_pct: float | None = None
chest_cm: float | None = None
waist_cm: float | None = None
neck_cm: float | None = None
hip_cm: float | None = None
notes: str = ""
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
recorded_at: str | None = None
class BodyCompositionCalc(BaseModel):
weight_kg: float | None = None
height_cm: float | None = None
sex: str | None = None
neck_cm: float | None = None
waist_cm: float | None = None
hip_cm: float | None = None
body_fat_pct: float | None = None
class StepsCreate(BaseModel):
steps: int = Field(ge=0)
active_calories: float | None = None
notes: str = ""
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
logged_at: str | None = None
class WorkoutCreate(BaseModel):
text: str = Field(min_length=1)
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
logged_at: str | None = None
class ReminderUpdate(BaseModel):
enabled: bool | None = None
hour: int | None = Field(default=None, ge=0, le=23)
minute: int | None = Field(default=None, ge=0, le=59)
interval_hours: int | None = Field(default=None, ge=1, le=12)
@router.get("/fitness")
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return FitnessService(db, user.id).snapshot()
@router.get("/fitness/summary")
def get_summary(
day: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
d = date.fromisoformat(day) if day else None
return FitnessService(db, user.id).get_daily_summary(d)
@router.get("/fitness/workout-stats")
def get_workout_stats(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_workout_stats(days=days, end_day=end_day)
@router.get("/fitness/history")
def get_history(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_history(days=days, end_day=end_day)
@router.get("/fitness/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"),
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)
-54
View File
@@ -1,54 +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,
_: User = Depends(get_current_user),
) -> dict:
hours = max(1, min(int(hours_ahead), 48))
return build_weather_dashboard(hours_ahead=hours)
-21
View File
@@ -1,21 +0,0 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from app.config import get_settings
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")
-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 sqlalchemy.orm import Session
from app.api.schemas import PomodoroStart, PomodoroStop
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.pomodoro.service import PomodoroService
router = APIRouter()
def _handle_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
@router.get("/status")
def get_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db, user.id).get_status()
@router.post("/start")
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/pause")
def pause_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).pause()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/resume")
def resume_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).resume()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/stop")
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).stop(
result=payload.result,
completed=payload.completed,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
return PomodoroService(db, user.id).history(limit=limit)
@router.post("/work/start")
def start_work(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start_work(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/short/start")
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start_short_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/long/start")
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start_long_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/cycle/reset")
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db, user.id).reset_cycle(clear_task=clear_task)
@router.post("/skip")
def skip_phase(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).skip_phase()
except ValueError as exc:
raise _handle_value_error(exc) from exc
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.schemas import PomodoroStart, PomodoroStop
from app.db.base import get_db
from app.pomodoro.service import PomodoroService
router = APIRouter()
def _handle_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
@router.get("/status")
def get_status(db: Session = Depends(get_db)) -> dict:
return PomodoroService(db).get_status()
@router.post("/start")
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/pause")
def pause_pomodoro(db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).pause()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/resume")
def resume_pomodoro(db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).resume()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/stop")
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).stop(
result=payload.result,
completed=payload.completed,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]:
return PomodoroService(db).history(limit=limit)
@router.post("/work/start")
def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start_work(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/short/start")
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start_short_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/long/start")
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start_long_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/cycle/reset")
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db)) -> dict:
return PomodoroService(db).reset_cycle(clear_task=clear_task)
@router.post("/skip")
def skip_phase(db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).skip_phase()
except ValueError as exc:
raise _handle_value_error(exc) from exc
+76 -78
View File
@@ -1,78 +1,76 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.projects.service import ProjectService
router = APIRouter()
class GiteaBinding(BaseModel):
gitea_owner: str = Field(min_length=1)
gitea_repo: str = Field(min_length=1)
default_branch: str = "main"
class WorkItemCreate(BaseModel):
text: str = Field(min_length=1)
project_slug: str | None = None
@router.get("/projects")
def list_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return ProjectService(db, user.id).list_projects()
@router.post("/projects/sync-taiga")
def sync_taiga_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
try:
return ProjectService(db, user.id).sync_taiga_projects()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/projects/{taiga_slug}/gitea")
def bind_gitea(
taiga_slug: str,
payload: GiteaBinding,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return ProjectService(db, user.id).bind_gitea(
taiga_slug,
payload.gitea_owner,
payload.gitea_repo,
payload.default_branch,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/work-items")
async def create_work_item(
payload: WorkItemCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return await ProjectService(db, user.id).create_work_item(
payload.text,
project_slug=payload.project_slug,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/work-items")
def list_work_items(
limit: int = 30,
status: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return ProjectService(db, user.id).list_work_items(limit=limit, status=status)
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.projects.service import ProjectService
router = APIRouter()
class GiteaBinding(BaseModel):
gitea_owner: str = Field(min_length=1)
gitea_repo: str = Field(min_length=1)
default_branch: str = "main"
class WorkItemCreate(BaseModel):
text: str = Field(min_length=1)
project_slug: str | None = None
@router.get("/projects")
def list_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
return ProjectService(db).list_projects()
@router.post("/projects/sync-taiga")
def sync_taiga_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
try:
return ProjectService(db).sync_taiga_projects()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/projects/{taiga_slug}/gitea")
def bind_gitea(
taiga_slug: str,
payload: GiteaBinding,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return ProjectService(db).bind_gitea(
taiga_slug,
payload.gitea_owner,
payload.gitea_repo,
payload.default_branch,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/work-items")
async def create_work_item(
payload: WorkItemCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return await ProjectService(db).create_work_item(
payload.text,
project_slug=payload.project_slug,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/work-items")
def list_work_items(
limit: int = 30,
status: str | None = None,
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
return ProjectService(db).list_work_items(limit=limit, status=status)
-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
-34
View File
@@ -1,34 +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_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 hmac
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.chat.notice_inbox import post_notice_to_latest_chat
from app.config import get_settings
from app.db.base import get_db
from app.db.models import ProjectBinding
from app.projects.service import ProjectService
router = APIRouter()
logger = logging.getLogger(__name__)
def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
if not secret:
return True
if not signature:
return False
if signature.startswith("sha256="):
signature = signature[7:]
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def _post_close_notice(
results: list[dict[str, Any]], owner: str, repo: str, user_id: int
) -> None:
if not results:
return
lines = [f"🔀 **Push** `{owner}/{repo}`"]
for item in results:
if "closed" in item:
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
elif "error" in item:
lines.append(f"- ошибка: {item['error']}")
post_notice_to_latest_chat("\n".join(lines), user_id)
@router.post("/webhooks/gitea")
async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
body = await request.body()
settings = get_settings()
signature = (
request.headers.get("X-Gitea-Signature")
or request.headers.get("X-Gogs-Signature")
or request.headers.get("X-Hub-Signature-256")
)
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
payload = json.loads(body)
if payload.get("secret") and settings.gitea_webhook_secret:
if payload.get("secret") != settings.gitea_webhook_secret:
raise HTTPException(status_code=401, detail="Invalid webhook secret")
event = request.headers.get("X-Gitea-Event", "")
if event != "push":
return {"ok": True, "skipped": event}
repo = payload.get("repository", {})
owner = repo.get("owner", {}).get("login", "")
repo_name = repo.get("name", "")
if not owner or not repo_name:
raise HTTPException(status_code=400, detail="Missing repository info")
binding = db.scalar(
select(ProjectBinding).where(
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo_name,
)
)
if not binding:
return {"ok": True, "skipped": "unknown repo"}
commits = list(payload.get("commits") or [])
if not commits:
head = payload.get("head_commit")
if head:
commits = [head]
logger.info(
"Gitea push %s/%s ref=%s commits=%d",
owner,
repo_name,
payload.get("ref", ""),
len(commits),
)
service = ProjectService(db, binding.user_id)
results = service.process_push(owner, repo_name, commits)
if results:
logger.info("Gitea push results: %s", results)
else:
logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name)
_post_close_notice(results, owner, repo_name, binding.user_id)
return {"ok": True, "results": results, "commits_processed": len(commits)}
import hashlib
import hmac
import json
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.base import SessionLocal, get_db
from app.db.models import ChatSession, Message, ProjectBinding
from app.projects.service import ProjectService
router = APIRouter()
def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
if not secret:
return True
if not signature:
return False
if signature.startswith("sha256="):
signature = signature[7:]
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def _post_close_notice(results: list[dict[str, Any]], owner: str, repo: str) -> None:
if not results:
return
db = SessionLocal()
try:
session = db.scalar(
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
)
if not session:
session = ChatSession(title="Git")
db.add(session)
db.commit()
db.refresh(session)
lines = [f"🔀 **Push** `{owner}/{repo}`"]
for item in results:
if "closed" in item:
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
elif "error" in item:
lines.append(f"- ошибка: {item['error']}")
db.add(Message(session_id=session.id, role="notice", content="\n".join(lines)))
db.commit()
finally:
db.close()
@router.post("/webhooks/gitea")
async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
body = await request.body()
settings = get_settings()
signature = request.headers.get("X-Gitea-Signature")
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
payload = json.loads(body)
if payload.get("secret") and settings.gitea_webhook_secret:
if payload.get("secret") != settings.gitea_webhook_secret:
raise HTTPException(status_code=401, detail="Invalid webhook secret")
event = request.headers.get("X-Gitea-Event", "")
if event != "push":
return {"ok": True, "skipped": event}
repo = payload.get("repository", {})
owner = repo.get("owner", {}).get("login", "")
repo_name = repo.get("name", "")
if not owner or not repo_name:
raise HTTPException(status_code=400, detail="Missing repository info")
binding = db.scalar(
select(ProjectBinding).where(
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo_name,
)
)
if not binding:
return {"ok": True, "skipped": "unknown repo"}
commits = payload.get("commits") or []
service = ProjectService(db)
results = service.process_push(owner, repo_name, commits)
_post_close_notice(results, owner, repo_name)
return {"ok": True, "results": results}
-1
View File
@@ -20,7 +20,6 @@ class MessageOut(BaseModel):
id: int
role: str
content: str
tool_calls_json: str | None = None
created_at: datetime
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"]
-34
View File
@@ -1,34 +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()
return header or None
def get_current_user(
request: Request,
db: Session = Depends(get_db),
) -> User:
token = _extract_token(request)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API token")
token_hash = hash_token(token)
user = db.scalar(
select(User).where(User.api_token_hash == token_hash, User.is_active.is_(True))
)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API token")
return user
-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 -100
View File
@@ -1,100 +1,77 @@
from typing import Any
TOOLS_INSTRUCTIONS = """
Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс).
Обязательные правила:
- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент.
- Никогда не выдумывай статус таймера или список задач.
- После вызова инструмента кратко объясни результат пользователю по-человечески.
- Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break,
stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history.
- Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items.
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
- Фитнес: get_fitness_summary (date/days_ago), get_fitness_history, set_fitness_profile, log_meal, log_water, log_weight (neck_cm/waist_cm/hip_cm → Navy), log_workout,
- «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history.
calc_fitness_targets, calc_body_composition (расчёт Navy/WHR/LBM/FFMI без записи), lookup_food, lookup_exercise, set_fitness_reminder.
- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary.
- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
- Никогда не пиши «ожидаю ответа от системы».
- В текстовых ответах пользователю не используй эмодзи.
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
- Утренний брифинг (погода + новости) → get_morning_briefing.
- Картинки: generate_image — draw_self=true + scene_description (full_body, outfit…); appearance только из карточки. Не злоупотребляй.
- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list.
- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки.
- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.
- «Напомни через 15 минут», «завтра утром», «12 мая в 9:00» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]).
- День рождения, Новый год и другие праздники → recurrence yearly.
- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе.
""".strip()
DEFAULT_CARD: dict[str, Any] = {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {
"name": "Домашний ассистент",
"description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.",
"personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.",
"scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.",
"first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?",
"mes_example": "",
"system_prompt": "",
"post_history_instructions": "",
"alternate_greetings": [],
"tags": ["assistant", "home", "pomodoro"],
"creator": "",
"creator_notes": "",
"character_version": "1.0",
"appearance_tags": "",
"appearance_prose": "",
"lora_name": "",
"lora_weight": 0.8,
"rp_persona_id": "",
"sd_enabled": True,
},
}
def normalize_card(raw: dict[str, Any]) -> dict[str, Any]:
if "data" in raw and isinstance(raw["data"], dict):
card = {
"spec": raw.get("spec", "chara_card_v2"),
"spec_version": raw.get("spec_version", "2.0"),
"data": {**DEFAULT_CARD["data"], **raw["data"]},
}
return card
if "name" in raw or "description" in raw:
return {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {**DEFAULT_CARD["data"], **raw},
}
return DEFAULT_CARD.copy()
def build_system_prompt(card: dict[str, Any]) -> str:
data = card.get("data", {})
parts: list[str] = []
name = data.get("name", "Ассистент")
parts.append(f"Ты — {name}.")
if data.get("system_prompt"):
parts.append(data["system_prompt"])
if data.get("description"):
parts.append(data["description"])
if data.get("personality"):
parts.append(f"Характер: {data['personality']}")
if data.get("scenario"):
parts.append(f"Сценарий: {data['scenario']}")
if data.get("post_history_instructions"):
parts.append(data["post_history_instructions"])
parts.append(TOOLS_INSTRUCTIONS)
return "\n\n".join(part for part in parts if part.strip())
from typing import Any
TOOLS_INSTRUCTIONS = """
Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс).
Обязательные правила:
- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент.
- Никогда не выдумывай статус таймера или список задач.
- После вызова инструмента кратко объясни результат пользователю по-человечески.
- Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break,
stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history.
- Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items.
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
- Снимок проектов/задач есть в контексте, но для актуализации вызывай tools. Никогда не пиши «ожидаю ответа от системы».
""".strip()
DEFAULT_CARD: dict[str, Any] = {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {
"name": "Домашний ассистент",
"description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.",
"personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.",
"scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.",
"first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?",
"mes_example": "",
"system_prompt": "",
"post_history_instructions": "",
"alternate_greetings": [],
"tags": ["assistant", "home", "pomodoro"],
"creator": "",
"creator_notes": "",
"character_version": "1.0",
},
}
def normalize_card(raw: dict[str, Any]) -> dict[str, Any]:
if "data" in raw and isinstance(raw["data"], dict):
card = {
"spec": raw.get("spec", "chara_card_v2"),
"spec_version": raw.get("spec_version", "2.0"),
"data": {**DEFAULT_CARD["data"], **raw["data"]},
}
return card
if "name" in raw or "description" in raw:
return {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {**DEFAULT_CARD["data"], **raw},
}
return DEFAULT_CARD.copy()
def build_system_prompt(card: dict[str, Any]) -> str:
data = card.get("data", {})
parts: list[str] = []
name = data.get("name", "Ассистент")
parts.append(f"Ты — {name}.")
if data.get("system_prompt"):
parts.append(data["system_prompt"])
if data.get("description"):
parts.append(data["description"])
if data.get("personality"):
parts.append(f"Характер: {data['personality']}")
if data.get("scenario"):
parts.append(f"Сценарий: {data['scenario']}")
if data.get("post_history_instructions"):
parts.append(data["post_history_instructions"])
parts.append(TOOLS_INSTRUCTIONS)
return "\n\n".join(part for part in parts if part.strip())
+27 -43
View File
@@ -1,43 +1,27 @@
import json
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
from app.db.models import CharacterCard
class CharacterService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
def get_card(self) -> dict[str, Any]:
row = self.db.scalar(
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
)
if not row:
return normalize_card(DEFAULT_CARD)
try:
return normalize_card(json.loads(row.card_json or "{}"))
except json.JSONDecodeError:
return normalize_card(DEFAULT_CARD)
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
card = normalize_card(raw)
row = self.db.scalar(
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
)
if not row:
row = CharacterCard(user_id=self.user_id, card_json="{}")
self.db.add(row)
self.db.flush()
row.card_json = json.dumps(card, ensure_ascii=False)
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return card
def get_system_prompt(self) -> str:
return build_system_prompt(self.get_card())
import json
from pathlib import Path
from typing import Any
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
CARD_PATH = Path("./data/character.json")
class CharacterService:
def get_card(self) -> dict[str, Any]:
if CARD_PATH.is_file():
try:
raw = json.loads(CARD_PATH.read_text(encoding="utf-8"))
return normalize_card(raw)
except (json.JSONDecodeError, OSError):
pass
return normalize_card(DEFAULT_CARD)
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
card = normalize_card(raw)
CARD_PATH.parent.mkdir(parents=True, exist_ok=True)
CARD_PATH.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8")
return card
def get_system_prompt(self) -> str:
return build_system_prompt(self.get_card())
-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
from typing import Any
from app.db.models import PomodoroSession
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
PHASE_LABELS = {
PHASE_WORK: "Работа",
PHASE_SHORT_BREAK: "Короткий перерыв",
PHASE_LONG_BREAK: "Длинный перерыв",
}
def _format_time(seconds: int) -> str:
minutes, secs = divmod(max(0, seconds), 60)
return f"{minutes:02d}:{secs:02d}"
def _format_image_generation_notice(data: dict[str, Any]) -> str:
url = data.get("url", "")
positive = (data.get("prompt") or "").strip()
negative = (data.get("negative_prompt") or "").strip()
lines = ["🎨 **Картинка готова**", "", f"![image]({url})"]
if positive:
lines.extend(["", "**Comfy (+):**", f"```\n{positive}\n```"])
if negative:
lines.extend(["", "**Comfy ():**", f"```\n{negative}\n```"])
return "\n".join(lines)
def format_phase_completed_notice(
session: PomodoroSession,
next_phase: str | None,
) -> str:
phase_label = PHASE_LABELS.get(session.phase, session.phase)
task = session.task_note or "без описания"
lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
if next_phase == PHASE_SHORT_BREAK:
lines.append("Дальше: короткий перерыв ☕")
elif next_phase == PHASE_LONG_BREAK:
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён")
elif next_phase == PHASE_WORK:
lines.append("Дальше: снова работа 💪")
else:
lines.append("Цикл сброшен. Можно отдохнуть и начать заново.")
return "\n".join(lines)
POMODORO_TOOL_NAMES = frozenset({
"get_pomodoro_status",
"start_pomodoro",
"start_short_break",
"start_long_break",
"stop_pomodoro",
"skip_pomodoro_phase",
"reset_pomodoro_cycle",
"get_pomodoro_history",
})
MEMORY_TOOL_NAMES = frozenset({
"remember_fact",
"recall_memories",
"forget_memory",
"update_profile",
"update_session_summary",
})
FITNESS_TOOL_NAMES = frozenset({
"get_fitness_summary",
"get_fitness_history",
"set_fitness_profile",
"calc_fitness_targets",
"calc_body_composition",
"log_meal",
"log_water",
"log_weight",
"log_workout",
"lookup_food",
"lookup_exercise",
"set_fitness_reminder",
})
# Не засорять чат служебными ответами
REMINDER_TOOL_NAMES = frozenset({
"list_reminders",
"create_reminder",
"update_reminder",
"delete_reminder",
"complete_reminder",
})
SHOPPING_TOOL_NAMES = frozenset({
"list_shopping_lists",
"create_shopping_list",
"add_shopping_items",
"check_shopping_item",
"remove_shopping_item",
"delete_shopping_list",
})
TOOLS_SKIP_CHAT_NOTICE = frozenset({
"get_pomodoro_status",
"recall_memories",
"get_fitness_summary",
"get_fitness_history",
"lookup_food",
"lookup_exercise",
"calc_fitness_targets",
"calc_body_composition",
"get_weather",
"get_morning_briefing",
"list_shopping_lists",
"list_reminders",
})
def _format_body_composition_notice(computed: dict[str, Any], *, headline: str) -> str:
parts: list[str] = []
bf = computed.get("body_fat_pct")
if bf is not None:
method = computed.get("body_fat_method")
if method == "navy":
parts.append(f"жир ≈{bf}% (Navy)")
elif method == "manual":
parts.append(f"жир {bf}%")
else:
parts.append(f"жир ≈{bf}%")
if computed.get("whr") is not None:
parts.append(f"WHR {computed.get('whr')}")
if computed.get("ffmi") is not None:
parts.append(f"FFMI {computed.get('ffmi')}")
if parts:
return f"{headline}{', '.join(parts)}"
return headline
def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
return None
try:
data = json.loads(raw_result)
except json.JSONDecodeError:
return None
if isinstance(data, dict) and "error" in data:
if tool_name in POMODORO_TOOL_NAMES:
prefix = ""
elif tool_name in MEMORY_TOOL_NAMES:
prefix = "🧠"
elif tool_name in FITNESS_TOOL_NAMES:
prefix = "💪"
elif tool_name in SHOPPING_TOOL_NAMES:
prefix = "🛒"
elif tool_name in REMINDER_TOOL_NAMES:
prefix = "📅"
else:
prefix = "📋"
return f"{prefix} {data['error']}"
if tool_name == "reset_pomodoro_cycle":
cycle = data.get("cycle", data)
return (
"⏱ **Цикл помидоро сброшен** · "
f"прогресс: {cycle.get('completed_work_sessions', 0)}/"
f"{cycle.get('sessions_until_long_break', 4)}"
)
if tool_name in (
"get_pomodoro_status",
"start_pomodoro",
"start_work",
"start_short_break",
"start_long_break",
"stop_pomodoro",
"skip_pomodoro_phase",
):
return _format_status_notice(data)
if tool_name == "get_pomodoro_history":
return _format_history_notice(data)
if tool_name == "create_work_item":
return _format_work_item_notice(data)
if tool_name == "list_work_items":
return _format_work_items_list_notice(data)
if tool_name == "list_taiga_tasks":
return _format_taiga_tasks_notice(data)
if tool_name == "sync_taiga_projects":
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
if tool_name == "list_taiga_projects":
if not isinstance(data, list) or not data:
return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects."
lines = ["📋 **Проекты:**"]
for p in data:
gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else ""
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
return "\n".join(lines)
if tool_name == "remember_fact" and data.get("ok"):
action = "обновлено" if data.get("action") == "updated" else "сохранено"
return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}"
if tool_name == "forget_memory" and data.get("ok"):
return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}"
if tool_name == "update_profile" and data.get("ok"):
profile = data.get("profile") or {}
parts = [f"{k}={v}" for k, v in profile.items() if v]
return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}"
if tool_name == "update_session_summary" and data.get("ok"):
return "🧠 **Сводка чата сохранена**"
if tool_name == "log_meal" and data.get("ok"):
meal = data.get("meal", {})
est = "" if meal.get("estimated") else ""
return (
f"💪 **Приём пищи** · {meal.get('description')} · "
f"{est}{meal.get('calories', 0):.0f} ккал "
f"{meal.get('protein_g', 0):.0f}{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})"
)
if tool_name == "log_water" and data.get("ok"):
w = data.get("water", {})
return f"💪 **Вода** +{w.get('amount_ml')} мл"
if tool_name == "log_weight" and data.get("ok"):
m = data.get("metric", {})
computed = data.get("computed") or {}
headline = f"💪 **Вес** {m.get('weight_kg')} кг"
return _format_body_composition_notice(computed, headline=headline)
if tool_name == "calc_body_composition" and isinstance(data, dict) and "error" not in data:
w = data.get("weight_kg")
headline = "💪 **Состав тела** (расчёт)"
if w is not None:
headline += f" · {w} кг"
msg = _format_body_composition_notice(data, headline=headline)
warnings = data.get("warnings") or []
if warnings:
msg += f" · {'; '.join(warnings[:2])}"
return msg
if tool_name == "log_workout" and data.get("ok"):
wo = data.get("workout", {})
return f"💪 **Тренировка** · {wo.get('title')}"
if tool_name == "set_fitness_profile" and data.get("ok"):
p = data.get("profile", {})
return (
f"💪 **Профиль** · {p.get('calorie_target')} ккал, "
f"вода {p.get('water_l')} л"
)
if tool_name == "set_fitness_reminder" and data.get("ok"):
r = data.get("reminder", {})
state = "вкл" if r.get("enabled") else "выкл"
return f"💪 **Напоминание {r.get('kind')}** · {state}"
if tool_name == "generate_image" and data.get("ok"):
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}"
import json
from typing import Any
from app.db.models import PomodoroSession
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
PHASE_LABELS = {
PHASE_WORK: "Работа",
PHASE_SHORT_BREAK: "Короткий перерыв",
PHASE_LONG_BREAK: "Длинный перерыв",
}
def _format_time(seconds: int) -> str:
minutes, secs = divmod(max(0, seconds), 60)
return f"{minutes:02d}:{secs:02d}"
def format_phase_completed_notice(
session: PomodoroSession,
next_phase: str | None,
) -> str:
phase_label = PHASE_LABELS.get(session.phase, session.phase)
task = session.task_note or "без описания"
lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
if next_phase == PHASE_SHORT_BREAK:
lines.append("Дальше: короткий перерыв ☕")
elif next_phase == PHASE_LONG_BREAK:
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён")
elif next_phase == PHASE_WORK:
lines.append("Дальше: снова работа 💪")
else:
lines.append("Цикл сброшен. Можно отдохнуть и начать заново.")
return "\n".join(lines)
POMODORO_TOOL_NAMES = frozenset({
"get_pomodoro_status",
"start_pomodoro",
"start_short_break",
"start_long_break",
"stop_pomodoro",
"skip_pomodoro_phase",
"reset_pomodoro_cycle",
"get_pomodoro_history",
})
# Не засорять чат служебными ответами
TOOLS_SKIP_CHAT_NOTICE = frozenset({
"get_pomodoro_status",
})
def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
return None
try:
data = json.loads(raw_result)
except json.JSONDecodeError:
return None
if isinstance(data, dict) and "error" in data:
prefix = "" if tool_name in POMODORO_TOOL_NAMES else "📋"
return f"{prefix} {data['error']}"
if tool_name == "reset_pomodoro_cycle":
cycle = data.get("cycle", data)
return (
"⏱ **Цикл помидоро сброшен** · "
f"прогресс: {cycle.get('completed_work_sessions', 0)}/"
f"{cycle.get('sessions_until_long_break', 4)}"
)
if tool_name in (
"get_pomodoro_status",
"start_pomodoro",
"start_work",
"start_short_break",
"start_long_break",
"stop_pomodoro",
"skip_pomodoro_phase",
):
return _format_status_notice(data)
if tool_name == "get_pomodoro_history":
return _format_history_notice(data)
if tool_name == "create_work_item":
return _format_work_item_notice(data)
if tool_name == "list_work_items":
return _format_work_items_list_notice(data)
if tool_name == "list_taiga_tasks":
return _format_taiga_tasks_notice(data)
if tool_name == "sync_taiga_projects":
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
if tool_name == "list_taiga_projects":
if not isinstance(data, list) or not data:
return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects."
lines = ["📋 **Проекты:**"]
for p in data:
gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else ""
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
return "\n".join(lines)
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 -564
View File
@@ -1,564 +1,172 @@
import asyncio
import json
import logging
import time
from collections.abc import AsyncIterator
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.base import SessionLocal
from app.character.service import CharacterService
from app.chat.history import sanitize_openai_messages, strip_historical_reasoning
from app.chat.notice_inbox import DISPLAY_ONLY_ROLES
from app.chat.notices import (
POMODORO_TOOL_NAMES,
format_pomodoro_context,
format_tool_notice,
)
from app.fitness.context import format_fitness_context, get_fitness_snapshot
from app.homelab.context import format_datetime_context
from app.homelab.openmeteo import format_weather_snapshot
from app.memory.context import (
format_identity_hint,
format_memory_context,
get_memory_snapshot,
)
from app.memory.extract import extract_after_turn
from app.projects.context import format_projects_context, get_projects_snapshot
from app.reminders_scoped.context import format_reminders_context, get_reminders_snapshot
from app.shopping.context import format_shopping_context, get_shopping_snapshot
from app.db.models import ChatSession, Message
from app.llm.client import LLMClient
from app.pomodoro.service import PomodoroService
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
MAX_TOOL_ROUNDS = 5
MAX_HISTORY_MESSAGES = 40
_DOMAIN_CACHE: dict[str, tuple[float, str]] = {}
_DOMAIN_TTL_SEC = 60.0
_DOMAIN_KEYWORDS: dict[str, tuple[str, ...]] = {
"fitness": ("фитнес", "тренир", "калори", "еда", "вода", "вес", "workout", "meal", "белок", "жир"),
"shopping": ("покуп", "магазин", "список", "shopping", "корзин"),
"reminders": ("напомин", "календар", "событи", "дедлайн", "встреч", "план"),
"projects": ("taiga", "gitea", "задач", "проект", "git", "issue", "коммит", "ветк"),
}
logger = logging.getLogger(__name__)
def _build_messages_for_session(session_id: int, user_id: int) -> list[dict[str, Any]]:
db = SessionLocal()
try:
service = ChatService(db, user_id)
session = service.get_session(session_id)
if not session:
return []
return service._build_messages(session)
finally:
db.close()
async def _extract_memory_background(
session_id: int,
user_id: int,
user_text: str,
assistant_text: str,
) -> None:
db = SessionLocal()
try:
await extract_after_turn(db, session_id, user_text, assistant_text, user_id=user_id)
except Exception as exc:
logger.warning("Background memory extraction failed: %s", exc)
finally:
db.close()
class ChatService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self.llm = LLMClient()
self.character = CharacterService(db, user_id)
def list_sessions(self) -> list[ChatSession]:
stmt = select(ChatSession).where(ChatSession.user_id == self.user_id).order_by(ChatSession.updated_at.desc())
return list(self.db.scalars(stmt).all())
def get_session(self, session_id: int) -> ChatSession | None:
session = self.db.get(ChatSession, session_id)
if session and session.user_id != self.user_id:
return None
return session
def list_messages(
self,
session_id: int,
limit: int = 30,
before_id: int | None = None,
after_id: int | None = None,
) -> tuple[list[Message], bool]:
if not self.get_session(session_id):
return [], False
if after_id is not None:
stmt = (
select(Message)
.where(Message.session_id == session_id, Message.id > after_id)
.order_by(Message.created_at.asc())
.limit(limit + 1)
)
rows = list(self.db.scalars(stmt).all())
has_more = len(rows) > limit
return rows[:limit], has_more
stmt = select(Message).where(Message.session_id == session_id)
if before_id is not None:
anchor = self.db.get(Message, before_id)
if anchor is None or anchor.session_id != session_id:
return [], False
stmt = stmt.where(Message.created_at < anchor.created_at)
stmt = stmt.order_by(Message.created_at.desc()).limit(limit + 1)
rows = list(self.db.scalars(stmt).all())
has_more = len(rows) > limit
page = rows[:limit]
page.reverse()
return page, has_more
def create_session(self, title: str = "Новый чат") -> ChatSession:
session = ChatSession(user_id=self.user_id, title=title)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return session
def delete_session(self, session_id: int) -> bool:
session = self.get_session(session_id)
if not session:
return False
self.db.delete(session)
self.db.commit()
return True
def _cached_domain(self, key: str, loader, formatter) -> str:
now = time.monotonic()
hit = _DOMAIN_CACHE.get(f"{self.user_id}:{key}")
if hit and now < hit[0]:
return hit[1]
rendered = formatter(loader())
_DOMAIN_CACHE[f"{self.user_id}:{key}"] = (now + _DOMAIN_TTL_SEC, rendered)
return rendered
def _domain_relevant(self, key: str, user_query: str) -> bool:
query = user_query.strip().lower()
if not query:
return False
keywords = _DOMAIN_KEYWORDS.get(key, ())
return any(kw in query for kw in keywords)
def _optional_domain(
self,
key: str,
user_query: str,
loader,
formatter,
) -> str:
if not self._domain_relevant(key, user_query):
return ""
return self._cached_domain(key, loader, formatter)
def _build_system_prompt(self, session_id: int | None = None, user_query: str = "") -> str:
status = PomodoroService(self.db, self.user_id).get_status()
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session_id, query=user_query)
fitness_snapshot = get_fitness_snapshot(self.db, self.user_id)
shopping_snapshot = get_shopping_snapshot(self.db, self.user_id)
reminders_snapshot = get_reminders_snapshot(self.db, self.user_id)
projects_snapshot = get_projects_snapshot(self.db, self.user_id)
parts = [
self.character.get_system_prompt(),
format_datetime_context(self.db, self.user_id),
format_memory_context(memory_snapshot),
self._optional_domain("fitness", user_query, lambda: fitness_snapshot, format_fitness_context),
self._optional_domain("shopping", user_query, lambda: shopping_snapshot, format_shopping_context),
self._optional_domain("reminders", user_query, lambda: reminders_snapshot, format_reminders_context),
format_weather_snapshot(),
format_pomodoro_context(status),
self._optional_domain("projects", user_query, lambda: projects_snapshot, format_projects_context),
]
return "\n\n".join(part for part in parts if part.strip())
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
last_user = next((m.content for m in reversed(all_chat) if m.role == "user"), "")
system_prompt = self._build_system_prompt(session.id, user_query=last_user)
if last_user:
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session.id, query=last_user)
identity_hint = format_identity_hint(memory_snapshot, last_user)
if identity_hint:
system_prompt += f"\n\n{identity_hint}"
if len(all_chat) > MAX_HISTORY_MESSAGES:
system_prompt += (
f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} "
f"из {len(all_chat)} сообщений. Раннее — в сводке сессии, если сохранена.]"
)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt}
]
chat_messages = all_chat[-MAX_HISTORY_MESSAGES:] if len(all_chat) > MAX_HISTORY_MESSAGES else all_chat
for msg in chat_messages:
content = msg.content or None
entry: dict[str, Any] = {"role": msg.role, "content": content}
if msg.tool_calls_json:
entry["tool_calls"] = json.loads(msg.tool_calls_json)
if not content:
entry["content"] = None
reasoning_data = LLMClient.deserialize_reasoning(msg.reasoning_json)
if reasoning_data:
LLMClient.attach_reasoning_to_message(
entry,
reasoning=reasoning_data.get("reasoning", ""),
reasoning_details=reasoning_data.get("reasoning_details"),
)
if msg.role == "tool" and msg.tool_call_id:
entry["tool_call_id"] = msg.tool_call_id
messages.append(entry)
messages = sanitize_openai_messages(messages)
messages = strip_historical_reasoning(messages)
return messages
def _save_message(
self,
session_id: int,
role: str,
content: str = "",
tool_calls: list[dict[str, Any]] | None = None,
tool_call_id: str | None = None,
reasoning_json: str | None = None,
) -> Message:
message = Message(
session_id=session_id,
role=role,
content=content,
tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None,
reasoning_json=reasoning_json,
tool_call_id=tool_call_id,
)
self.db.add(message)
session = self.get_session(session_id)
if session and role == "user" and session.title == "Новый чат" and content:
session.title = content[:60] + ("..." if len(content) > 60 else "")
self.db.commit()
self.db.refresh(message)
return message
def save_user_message(self, session_id: int, user_text: str) -> None:
self._save_message(session_id, "user", user_text)
async def _fallback_complete(
self,
messages: list[dict[str, Any]],
session_id: int,
) -> tuple[str, list[str], list[dict[str, Any]]]:
"""Нестриминговый запасной путь, если stream вернул пустоту."""
logger.info("chat session=%s fallback complete", session_id)
result: dict[str, Any] = {"content": "", "tool_calls": []}
for with_tools in (True, False):
result = await self.llm.complete(
messages,
tools=TOOL_DEFINITIONS if with_tools else None,
temperature=0.5,
visible_reply=True,
)
if (result.get("content") or "").strip() or result.get("tool_calls"):
break
tool_calls = result.get("tool_calls") or []
content = (result.get("content") or "").strip()
notices: list[str] = []
pomodoro_events: list[dict[str, Any]] = []
if tool_calls:
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": content or None,
"tool_calls": tool_calls,
}
messages.append(assistant_msg)
self._save_message(
session_id,
"assistant",
content,
tool_calls=tool_calls,
)
for tool_call in tool_calls:
fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
tool_result = await execute_tool(
self.db, fn["name"], args, session_id=session_id, user_id=self.user_id
)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call["id"],
"content": tool_result,
}
)
self._save_message(
session_id,
"tool",
tool_result,
tool_call_id=tool_call["id"],
)
notice = format_tool_notice(fn["name"], tool_result)
if notice:
self._save_message(session_id, "notice", notice)
notices.append(notice)
if fn["name"] in POMODORO_TOOL_NAMES:
pomodoro_events.append(
{"name": fn["name"], "result": json.loads(tool_result)}
)
followup = await self.llm.complete(
messages,
tools=None,
temperature=0.4,
visible_reply=True,
)
return (followup.get("content") or "").strip(), notices, pomodoro_events
return content, notices, pomodoro_events
def context_preview(self, session_id: int, query: str | None = None) -> dict[str, Any]:
session = self.get_session(session_id)
if not session:
return {"ok": False, "error": "Session not found"}
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
last_user = query or next((m.content for m in reversed(all_chat) if m.role == "user"), "")
system_prompt = self._build_system_prompt(session_id, user_query=last_user)
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session_id, query=last_user)
return {
"ok": True,
"session_id": session_id,
"query": last_user,
"system_prompt_chars": len(system_prompt),
"memory_facts": len(memory_snapshot.get("facts") or []),
"memory_total_facts": memory_snapshot.get("total_facts", 0),
"system_prompt_preview": system_prompt[:4000],
}
async def stream_response(
self,
session_id: int,
user_text: str,
*,
user_message_saved: bool = False,
) -> AsyncIterator[str]:
session = self.get_session(session_id)
if not session:
yield self._sse("error", {"message": "Session not found"})
return
if not user_message_saved:
self._save_message(session_id, "user", user_text)
yield self._sse("status", {"phase": "preparing"})
t0 = time.monotonic()
messages = await asyncio.to_thread(_build_messages_for_session, session_id, self.user_id)
prepare_sec = time.monotonic() - t0
if not messages:
yield self._sse("error", {"message": "Session not found"})
return
yield self._sse("status", {"phase": "generating"})
streamed_reply_parts: list[str] = []
all_tool_notices: list[str] = []
tools_executed = 0
tool_round = 0
for _ in range(MAX_TOOL_ROUNDS):
tool_round += 1
t_round = time.monotonic()
content_parts: list[str] = []
tool_calls: list[dict[str, Any]] = []
reasoning = ""
reasoning_details: list[Any] | None = None
finish_reason = ""
# После tool-раунда стримим вживую; до tools — буфер (иначе текст «переписывает» notice).
stream_live = tools_executed > 0
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
if event["type"] == "content":
content_parts.append(event["content"])
if stream_live:
yield self._sse("token", {"content": event["content"]})
elif event["type"] == "reasoning":
reasoning = event.get("reasoning", "") or reasoning
if event.get("reasoning_details"):
reasoning_details = event["reasoning_details"]
elif event["type"] == "error":
logger.warning(
"chat session=%s llm_error round=%d prepare=%.2fs: %s",
session_id,
tool_round,
prepare_sec,
event.get("content"),
)
yield self._sse("error", {"message": event.get("content", "LLM error")})
return
elif event["type"] == "tool_calls":
tool_calls = event["tool_calls"]
elif event["type"] == "done":
finish_reason = event.get("finish_reason", "")
logger.info(
"chat session=%s round=%d prepare=%.2fs llm=%.2fs "
"content_len=%d tool_calls=%d finish_reason=%s reasoning_len=%d",
session_id,
tool_round,
prepare_sec,
time.monotonic() - t_round,
len("".join(content_parts)),
len(tool_calls),
finish_reason,
len(reasoning),
)
if tool_calls:
round_text = "".join(content_parts)
if round_text.strip():
streamed_reply_parts.append(round_text)
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": round_text or None,
"tool_calls": tool_calls,
}
LLMClient.attach_reasoning_to_message(
assistant_msg,
reasoning=reasoning,
reasoning_details=reasoning_details,
)
reasoning_json = LLMClient.serialize_reasoning(
reasoning=reasoning,
reasoning_details=reasoning_details,
)
messages.append(assistant_msg)
self._save_message(
session_id,
"assistant",
round_text,
tool_calls=tool_calls,
reasoning_json=reasoning_json,
)
round_notices: list[str] = []
for tool_call in tool_calls:
fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
result = await execute_tool(
self.db, fn["name"], args, session_id=session_id, user_id=self.user_id
)
tools_executed += 1
tool_message = {
"role": "tool",
"tool_call_id": tool_call["id"],
"content": result,
}
messages.append(tool_message)
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
notice = format_tool_notice(fn["name"], result)
if notice:
self._save_message(session_id, "notice", notice)
round_notices.append(notice)
all_tool_notices.append(notice)
if fn["name"] in POMODORO_TOOL_NAMES:
yield self._sse(
"pomodoro",
{"name": fn["name"], "result": json.loads(result)},
)
yield self._sse("status", {"phase": "tools"})
for notice in round_notices:
yield self._sse("notice", {"content": notice})
continue
if content_parts and not stream_live:
for part in content_parts:
yield self._sse("token", {"content": part})
final_content = "".join(content_parts).strip()
if not final_content and streamed_reply_parts and tools_executed == 0:
final_content = "".join(streamed_reply_parts).strip()
if not final_content and reasoning:
final_content = reasoning.strip()
if not final_content and tools_executed:
retry = await self.llm.complete(
messages,
tools=None,
temperature=0.4,
visible_reply=True,
)
final_content = (retry.get("content") or "").strip()
if final_content:
yield self._sse("token", {"content": final_content})
# Notices уже в чате как role=notice — не дублируем в assistant.
if not final_content:
final_content, fb_notices, fb_pomodoro = await self._fallback_complete(
messages, session_id
)
if final_content:
yield self._sse("token", {"content": final_content})
for notice in fb_notices:
yield self._sse("notice", {"content": notice})
for event in fb_pomodoro:
yield self._sse("pomodoro", event)
if not final_content:
logger.warning(
"chat session=%s empty_reply tools=%d rounds=%d finish_reason=%s",
session_id,
tools_executed,
tool_round,
finish_reason,
)
yield self._sse(
"error",
{
"message": (
"Модель не вернула ответ (finish_reason="
f"{finish_reason or 'unknown'}). "
"Попробуй новый чат или проверь OPENROUTER_MODEL."
),
},
)
return
self._save_message(session_id, "assistant", final_content)
logger.info(
"chat session=%s done tools=%d reply_len=%d total=%.2fs",
session_id,
tools_executed,
len(final_content),
time.monotonic() - t0,
)
yield self._sse("done", {})
if get_settings().memory_auto_extract:
asyncio.create_task(
_extract_memory_background(session_id, self.user_id, user_text, final_content)
)
return
yield self._sse("error", {"message": "Too many tool call rounds"})
@staticmethod
def _sse(event: str, data: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
import json
from collections.abc import AsyncIterator
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.chat.notices import (
POMODORO_TOOL_NAMES,
format_pomodoro_context,
format_tool_notice,
)
from app.projects.context import format_projects_context, get_projects_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
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) -> str:
status = PomodoroService(self.db).get_status()
projects_snapshot = get_projects_snapshot(self.db)
return (
f"{self.character.get_system_prompt()}\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]]:
messages: list[dict[str, Any]] = [
{"role": "system", "content": self._build_system_prompt()}
]
for msg in session.messages:
if msg.role == "notice":
continue
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
if msg.role == "tool" and msg.tool_call_id:
entry["tool_call_id"] = msg.tool_call_id
messages.append(entry)
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,
) -> 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,
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
async def stream_response(self, session_id: int, user_text: str) -> AsyncIterator[str]:
session = self.get_session(session_id)
if not session:
yield self._sse("error", {"message": "Session not found"})
return
self._save_message(session_id, "user", user_text)
messages = self._build_messages(session)
for _ in range(MAX_TOOL_ROUNDS):
content_parts: list[str] = []
tool_calls: list[dict[str, Any]] = []
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
if event["type"] == "content":
content_parts.append(event["content"])
yield self._sse("token", {"content": event["content"]})
elif event["type"] == "tool_calls":
tool_calls = event["tool_calls"]
if tool_calls:
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": "".join(content_parts) or None,
"tool_calls": tool_calls,
}
messages.append(assistant_msg)
self._save_message(
session_id,
"assistant",
"".join(content_parts),
tool_calls=tool_calls,
)
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)
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)
yield self._sse("notice", {"content": notice})
if fn["name"] in POMODORO_TOOL_NAMES:
yield self._sse(
"pomodoro",
{"name": fn["name"], "result": json.loads(result)},
)
continue
final_content = "".join(content_parts)
if final_content:
self._save_message(session_id, "assistant", final_content)
yield self._sse("done", {})
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 -140
View File
@@ -1,140 +1,59 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file_encoding="utf-8",
extra="ignore",
)
host: str = "0.0.0.0"
port: int = 8080
openrouter_api_key: str = ""
openrouter_model: str = "deepseek/deepseek-chat"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
# Отдельная модель для JSON-задач (память, фитнес). Пусто = та же, что OPENROUTER_MODEL.
memory_extract_model: str = ""
# Некоторые модели (reasoning / без function calling) — выключить tools.
openrouter_tools_enabled: bool = True
# DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking.
openrouter_reasoning_effort: str = "none"
database_url: str = "sqlite:///./data/assistant.db"
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
system_prompt_path: str = "./prompts/assistant.md"
memory_auto_extract: bool = True
default_user_username: str = "owner"
default_user_display_name: str = ""
default_api_token: str = ""
auth_required: bool = True
qdrant_url: str = "http://qdrant:6333"
embedding_model: str = "openai/text-embedding-3-small"
rag_enabled: bool = False
rag_top_k: int = 8
memory_facts_in_context: int = 8
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
taiga_base_url: str = "http://host.docker.internal:9000"
taiga_username: str = ""
taiga_password: str = ""
taiga_public_url: str = "https://taiga.grigowashere.ru"
gitea_base_url: str = "http://host.docker.internal:3000"
gitea_token: str = ""
gitea_public_url: str = "https://git.grigowashere.ru"
gitea_webhook_secret: str = ""
repos_dir: str = "/data/repos"
wger_base_url: str = "https://wger.de/api/v2"
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
fitness_reminders_enabled: bool = True
reminders_enabled: bool = True
openmeteo_base_url: str = "http://192.168.1.109:8085"
weather_lat: float = 59.9343
weather_lon: float = 30.3351
weather_location_name: str = "Санкт-Петербург"
weather_cache_sec: int = 300
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()
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"
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"
# 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"
@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)
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():
return
-94
View File
@@ -1,94 +0,0 @@
from sqlalchemy import inspect, text
from app.db.base import engine
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
inspector = inspect(engine)
if table not in inspector.get_table_names():
return
columns = {col["name"] for col in inspector.get_columns(table)}
if column in columns:
return
with engine.begin() as conn:
conn.execute(text(ddl))
def run_fitness_migrations() -> None:
inspector = inspect(engine)
if "fitness_profiles" in inspector.get_table_names():
_add_column_if_missing(
"fitness_profiles",
"baseline_steps",
"ALTER TABLE fitness_profiles ADD COLUMN baseline_steps INTEGER",
)
_add_column_if_missing(
"fitness_profiles",
"baseline_workout_kcal",
"ALTER TABLE fitness_profiles ADD COLUMN baseline_workout_kcal FLOAT",
)
if "workout_logs" in inspector.get_table_names():
_add_column_if_missing(
"workout_logs",
"active_calories",
"ALTER TABLE workout_logs ADD COLUMN active_calories FLOAT",
)
_add_column_if_missing(
"workout_logs",
"total_calories",
"ALTER TABLE workout_logs ADD COLUMN total_calories FLOAT",
)
_add_column_if_missing(
"workout_logs",
"steps",
"ALTER TABLE workout_logs ADD COLUMN steps INTEGER",
)
if "step_logs" not in inspector.get_table_names():
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE step_logs ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"steps INTEGER DEFAULT 0, "
"active_calories FLOAT, "
"source VARCHAR(32) DEFAULT 'manual', "
"notes TEXT DEFAULT ''"
")"
)
)
if "body_metrics" in inspector.get_table_names():
_add_column_if_missing(
"body_metrics",
"neck_cm",
"ALTER TABLE body_metrics ADD COLUMN neck_cm FLOAT",
)
_add_column_if_missing(
"body_metrics",
"hip_cm",
"ALTER TABLE body_metrics ADD COLUMN hip_cm FLOAT",
)
_add_column_if_missing(
"body_metrics",
"body_fat_method",
"ALTER TABLE body_metrics ADD COLUMN body_fat_method VARCHAR(16)",
)
_add_column_if_missing(
"body_metrics",
"whr",
"ALTER TABLE body_metrics ADD COLUMN whr FLOAT",
)
_add_column_if_missing(
"body_metrics",
"lbm_kg",
"ALTER TABLE body_metrics ADD COLUMN lbm_kg FLOAT",
)
_add_column_if_missing(
"body_metrics",
"ffmi",
"ALTER TABLE body_metrics ADD COLUMN ffmi FLOAT",
)
-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 -396
View File
@@ -1,396 +1,112 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(255), default="")
api_token_hash: Mapped[str] = mapped_column(String(64), index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class CharacterCard(Base):
__tablename__ = "character_cards"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
)
card_json: Mapped[str] = mapped_column(Text, default="{}")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class ChatSession(Base):
__tablename__ = "chat_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
messages: Mapped[list["Message"]] = relationship(
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
)
class Message(Base):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
role: Mapped[str] = mapped_column(String(32))
content: Mapped[str] = mapped_column(Text, default="")
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
session: Mapped["ChatSession"] = relationship(back_populates="messages")
class PomodoroCycle(Base):
__tablename__ = "pomodoro_cycles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
task_note: Mapped[str] = mapped_column(Text, default="")
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class PomodoroSession(Base):
__tablename__ = "pomodoro_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
status: Mapped[str] = mapped_column(String(32), default="idle")
phase: Mapped[str] = mapped_column(String(32), default="work")
duration_min: Mapped[int] = mapped_column(Integer, default=25)
task_note: Mapped[str] = mapped_column(Text, default="")
result: Mapped[str | None] = mapped_column(Text, nullable=True)
completed: Mapped[bool] = mapped_column(Boolean, default=False)
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class TaigaProject(Base):
__tablename__ = "taiga_projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
name: Mapped[str] = mapped_column(String(255))
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ProjectBinding(Base):
__tablename__ = "project_bindings"
__table_args__ = (UniqueConstraint("user_id", "taiga_slug", name="uq_project_bindings_user_slug"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
default_branch: Mapped[str] = mapped_column(String(64), default="main")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class UserProfile(Base):
__tablename__ = "user_profile"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
data_json: Mapped[str] = mapped_column(Text, default="{}")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class MemoryFact(Base):
__tablename__ = "memory_facts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
content: Mapped[str] = mapped_column(Text)
source: Mapped[str] = mapped_column(String(32), default="user")
session_id: Mapped[int | None] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
)
importance: Mapped[int] = mapped_column(Integer, default=3)
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class SessionSummary(Base):
__tablename__ = "session_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
)
summary: Mapped[str] = mapped_column(Text, default="")
message_count: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class FitnessProfile(Base):
__tablename__ = "fitness_profiles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
sex: Mapped[str] = mapped_column(String(16), default="male")
age: Mapped[int] = mapped_column(Integer, default=30)
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
goal: Mapped[str] = mapped_column(String(32), default="maintain")
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
baseline_steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
baseline_workout_kcal: Mapped[float | None] = mapped_column(Float, nullable=True)
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
water_l: Mapped[float] = mapped_column(Float, default=2.5)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class BodyMetric(Base):
__tablename__ = "body_metrics"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
weight_kg: Mapped[float] = mapped_column(Float)
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
body_fat_method: Mapped[str | None] = mapped_column(String(16), nullable=True)
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
neck_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
hip_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
whr: Mapped[float | None] = mapped_column(Float, nullable=True)
lbm_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
ffmi: Mapped[float | None] = mapped_column(Float, nullable=True)
notes: Mapped[str] = mapped_column(Text, default="")
class FoodLog(Base):
__tablename__ = "food_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
description: Mapped[str] = mapped_column(Text, default="")
calories: Mapped[float] = mapped_column(Float, default=0)
protein_g: Mapped[float] = mapped_column(Float, default=0)
fat_g: Mapped[float] = mapped_column(Float, default=0)
carbs_g: Mapped[float] = mapped_column(Float, default=0)
source: Mapped[str] = mapped_column(String(32), default="llm")
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
class StepLog(Base):
__tablename__ = "step_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
steps: Mapped[int] = mapped_column(Integer, default=0)
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
source: Mapped[str] = mapped_column(String(32), default="manual")
notes: Mapped[str] = mapped_column(Text, default="")
class WaterLog(Base):
__tablename__ = "water_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
amount_ml: Mapped[int] = mapped_column(Integer)
class WorkoutLog(Base):
__tablename__ = "workout_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
notes: Mapped[str] = mapped_column(Text, default="")
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
total_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
class FitnessReminder(Base):
__tablename__ = "fitness_reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
kind: Mapped[str] = mapped_column(String(32))
hour: Mapped[int] = mapped_column(Integer, default=12)
minute: Mapped[int] = mapped_column(Integer, default=0)
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ShoppingList(Base):
__tablename__ = "shopping_lists"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_shopping_lists_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(255), index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
items: Mapped[list["ShoppingListItem"]] = relationship(
back_populates="shopping_list",
cascade="all, delete-orphan",
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
)
class ShoppingListItem(Base):
__tablename__ = "shopping_list_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
list_id: Mapped[int] = mapped_column(
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
)
text: Mapped[str] = mapped_column(String(500))
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
unit: Mapped[str] = mapped_column(String(64), default="")
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
class Reminder(Base):
__tablename__ = "reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255))
notes: Mapped[str] = mapped_column(Text, default="")
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
recurrence: Mapped[str] = mapped_column(String(16), default="none")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AssistantState(Base):
__tablename__ = "assistant_state"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[str] = mapped_column(Text, default="")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class WorkItem(Base):
__tablename__ = "work_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
taiga_project_id: Mapped[int] = mapped_column(Integer)
taiga_story_id: Mapped[int] = mapped_column(Integer)
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
raw_text: Mapped[str] = mapped_column(Text, default="")
title: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(32), default="open")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Document(Base):
__tablename__ = "documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255), default="")
filename: Mapped[str] = mapped_column(String(255), default="")
content_hash: Mapped[str] = mapped_column(String(64), default="", index=True)
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
chunks: Mapped[list["DocumentChunk"]] = relationship(
back_populates="document",
cascade="all, delete-orphan",
order_by="DocumentChunk.chunk_index",
)
class DocumentChunk(Base):
__tablename__ = "document_chunks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
document_id: Mapped[int] = mapped_column(
ForeignKey("documents.id", ondelete="CASCADE"), index=True
)
chunk_index: Mapped[int] = mapped_column(Integer, default=0)
content: Mapped[str] = mapped_column(Text, default="")
document: Mapped["Document"] = relationship(back_populates="chunks")
from datetime import datetime
from sqlalchemy import Boolean, DateTime, 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)
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 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)
-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
-143
View File
@@ -1,143 +0,0 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
BASELINE_STEPS_BY_LEVEL: dict[str, int] = {
"sedentary": 5000,
"light": 7000,
"moderate": 9000,
"active": 11000,
"very_active": 13000,
}
WORKOUT_KCAL_PER_SESSION = 200
KCAL_PER_STEP_PER_KG = 0.0005
FALLBACK_KCAL_PER_MIN = 6
@dataclass
class ActivityBonus:
steps: int
steps_baseline: int
steps_bonus_kcal: float
workout_active_kcal: float
workout_baseline_kcal: float
workout_bonus_kcal: float
total_bonus_kcal: float
scale_factor: float
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def baseline_steps(profile: dict[str, Any]) -> int:
override = profile.get("baseline_steps")
if override is not None:
return int(override)
level = str(profile.get("activity_level") or "moderate")
return BASELINE_STEPS_BY_LEVEL.get(level, 9000)
def baseline_workout_kcal_day(profile: dict[str, Any]) -> float:
override = profile.get("baseline_workout_kcal")
if override is not None:
return float(override)
weekly = int(profile.get("weekly_workouts") or 3)
return round(weekly * WORKOUT_KCAL_PER_SESSION / 7, 1)
def estimate_workout_active_kcal(workout: dict[str, Any]) -> float:
active = workout.get("active_calories")
if active is not None:
return float(active)
duration = workout.get("duration_min")
if duration:
return float(duration) * FALLBACK_KCAL_PER_MIN
return 0.0
def steps_bonus_kcal(*, steps: int, baseline_steps: int, weight_kg: float) -> float:
extra_steps = max(0, steps - baseline_steps)
return round(extra_steps * weight_kg * KCAL_PER_STEP_PER_KG, 1)
def compute_activity_bonus(
profile: dict[str, Any],
*,
steps_total: int,
workouts: list[dict[str, Any]],
) -> ActivityBonus:
weight_kg = float(profile.get("weight_kg") or 70)
steps_base = baseline_steps(profile)
workout_base = baseline_workout_kcal_day(profile)
s_bonus = steps_bonus_kcal(steps=steps_total, baseline_steps=steps_base, weight_kg=weight_kg)
workout_active = round(sum(estimate_workout_active_kcal(w) for w in workouts), 1)
w_bonus = max(0.0, round(workout_active - workout_base, 1))
total_bonus = round(s_bonus + w_bonus, 1)
base_cal = float(profile.get("calorie_target") or 2000)
scale_factor = 1.0 if base_cal <= 0 else round((base_cal + total_bonus) / base_cal, 4)
return ActivityBonus(
steps=steps_total,
steps_baseline=steps_base,
steps_bonus_kcal=s_bonus,
workout_active_kcal=workout_active,
workout_baseline_kcal=workout_base,
workout_bonus_kcal=w_bonus,
total_bonus_kcal=total_bonus,
scale_factor=scale_factor,
)
def _targets_dict(
*,
calories: float,
protein_g: float,
fat_g: float,
carbs_g: float,
water_ml: float,
) -> dict[str, float]:
return {
"calories": round(calories),
"protein_g": round(protein_g),
"fat_g": round(fat_g),
"carbs_g": round(carbs_g),
"water_ml": round(water_ml),
}
def build_base_targets(profile: dict[str, Any]) -> dict[str, float]:
water_l = float(profile.get("water_l") or 2.5)
return _targets_dict(
calories=float(profile.get("calorie_target") or 2000),
protein_g=float(profile.get("protein_g") or 140),
fat_g=float(profile.get("fat_g") or 65),
carbs_g=float(profile.get("carbs_g") or 200),
water_ml=water_l * 1000,
)
def scale_targets(
base_targets: dict[str, float],
bonus_kcal: float,
) -> tuple[dict[str, float], dict[str, float]]:
"""Return (effective_targets, targets_base). Water is not scaled."""
targets_base = dict(base_targets)
base_cal = float(base_targets["calories"])
if bonus_kcal <= 0 or base_cal <= 0:
return dict(base_targets), targets_base
scale = (base_cal + bonus_kcal) / base_cal
effective = _targets_dict(
calories=base_cal + bonus_kcal,
protein_g=float(base_targets["protein_g"]) * scale,
fat_g=float(base_targets["fat_g"]) * scale,
carbs_g=float(base_targets["carbs_g"]) * scale,
water_ml=float(base_targets["water_ml"]),
)
return effective, targets_base
-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
-94
View File
@@ -1,94 +0,0 @@
from typing import Any
ACTIVITY_MULTIPLIERS = {
"sedentary": 1.2,
"light": 1.375,
"moderate": 1.55,
"active": 1.725,
"very_active": 1.9,
}
GOAL_CALORIE_ADJUST = {
"lose": -500,
"maintain": 0,
"gain": 300,
}
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 tdee(
*,
sex: str,
weight_kg: float,
height_cm: float,
age: int,
activity_level: str = "moderate",
) -> float:
bmr = bmr_mifflin(sex=sex, weight_kg=weight_kg, height_cm=height_cm, age=age)
mult = ACTIVITY_MULTIPLIERS.get(activity_level, 1.55)
return bmr * mult
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 * 0.033, 1)
def macro_targets(
calorie_target: float,
weight_kg: float,
goal: str = "maintain",
) -> dict[str, float]:
protein_g = round(weight_kg * (2.0 if goal == "gain" else 1.8), 0)
fat_g = round((calorie_target * 0.25) / 9, 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 compute_targets(profile: dict[str, Any]) -> dict[str, Any]:
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")
activity = str(profile.get("activity_level") or "moderate")
goal = str(profile.get("goal") or "maintain")
tdee_val = tdee(
sex=sex, weight_kg=weight, height_cm=height, age=age, activity_level=activity
)
calorie_target = round(tdee_val + GOAL_CALORIE_ADJUST.get(goal, 0), 0)
macros = macro_targets(calorie_target, weight, goal)
water = water_target_l(weight)
return {
"bmr": round(bmr_mifflin(sex=sex, weight_kg=weight, height_cm=height, age=age), 0),
"tdee": round(tdee_val, 0),
"bmi": round(bmi(weight, height), 1),
"calorie_target": calorie_target,
"protein_g": macros["protein_g"],
"fat_g": macros["fat_g"],
"carbs_g": macros["carbs_g"],
"water_l": water,
}
-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
-94
View File
@@ -1,94 +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:
lines.append(
f"Цели (база): {profile.get('calorie_target')} ккал, "
f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, "
f"вода {profile.get('water_l')} л"
)
if profile.get("goal"):
lines.append(
f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, "
f"рост {profile.get('height_cm')} см"
)
today = snapshot.get("today") or {}
totals = today.get("totals") or {}
targets = today.get("targets") or {}
targets_base = today.get("targets_base") or {}
activity = today.get("activity") or {}
steps_total = today.get("steps_total") or 0
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
if profile and (activity.get("total_bonus_kcal") or steps_total):
lines.append(
f"Активность: шаги {steps_total} (база {activity.get('steps_baseline', 0)}), "
f"бонус +{activity.get('total_bonus_kcal', 0)} ккал"
)
base_cal = targets_base.get("calories", profile.get("calorie_target"))
lines.append(f"Эффективная цель ккал: {base_cal}{targets.get('calories', base_cal)}")
lines.append("")
lines.append(
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г"
)
lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л")
workouts = today.get("workouts") or []
if workouts:
lines.append(f"Тренировок сегодня: {len(workouts)}")
stats = snapshot.get("workout_stats") or {}
if stats.get("count"):
lines.append(
f"Тренировки за {stats.get('days', 7)} дн.: {stats.get('count')} "
f"(цель/нед {stats.get('weekly_target')}, серия {stats.get('streak')} дн.)"
)
latest = (snapshot.get("body_metrics") or [None])[0]
if latest:
lines.append("")
lines.append("Антропометрия (последняя):")
parts = [f"{latest.get('weight_kg')} кг"]
if latest.get("body_fat_pct") is not None:
method = latest.get("body_fat_method") or "?"
parts.append(f"жир {latest.get('body_fat_pct')}% ({method})")
if latest.get("neck_cm"):
parts.append(f"шея {latest.get('neck_cm')}")
if latest.get("waist_cm"):
parts.append(f"талия {latest.get('waist_cm')}")
if latest.get("hip_cm"):
parts.append(f"бёдра {latest.get('hip_cm')}")
if latest.get("whr"):
parts.append(f"WHR {latest.get('whr')}")
if latest.get("ffmi"):
parts.append(f"FFMI {latest.get('ffmi')}")
lines.append(" · ".join(parts))
lines.append("")
lines.append(
"Правила: log_meal, log_water, log_weight (обхваты → Navy), log_steps, log_workout (date/days_ago), "
"calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"Еда — оценка LLM (≈), пользователь может уточнить."
)
return chr(10).join(lines)
-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
-707
View File
@@ -1,707 +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 (
build_base_targets,
compute_activity_bonus,
estimate_workout_active_kcal,
scale_targets,
)
from app.fitness.calculators import compute_targets, one_rep_max
from app.fitness.body_composition import compute_body_composition
DEFAULT_REMINDERS = [
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
{"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None},
{"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None},
{"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None},
]
class FitnessService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
def _get_profile_row(self) -> FitnessProfile | None:
return self.db.scalar(select(FitnessProfile).where(FitnessProfile.user_id == self.user_id).limit(1))
def get_profile(self) -> dict[str, Any] | None:
row = self._get_profile_row()
if not row:
return None
return self._profile_to_dict(row)
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
targets = compute_targets(
{
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
}
)
return {
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
"target_weight_kg": row.target_weight_kg,
"weekly_workouts": row.weekly_workouts,
"baseline_steps": row.baseline_steps,
"baseline_workout_kcal": row.baseline_workout_kcal,
"calorie_target": row.calorie_target,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
"carbs_g": row.carbs_g,
"water_l": row.water_l,
"computed": targets,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
row = self._get_profile_row()
is_new = row is None
if is_new:
row = FitnessProfile(user_id=self.user_id)
self.db.add(row)
self.db.flush()
for key in (
"sex", "age", "height_cm", "weight_kg", "activity_level",
"goal", "target_weight_kg", "weekly_workouts",
"baseline_steps", "baseline_workout_kcal",
):
if key in updates and updates[key] is not None:
setattr(row, key, updates[key])
targets = compute_targets(
{
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
}
)
row.calorie_target = targets["calorie_target"]
row.protein_g = targets["protein_g"]
row.fat_g = targets["fat_g"]
row.carbs_g = targets["carbs_g"]
row.water_l = targets["water_l"]
row.updated_at = datetime.now(timezone.utc)
if is_new:
self._ensure_default_reminders()
self.db.commit()
self.db.refresh(row)
return {"ok": True, "profile": self._profile_to_dict(row)}
def _ensure_default_reminders(self) -> None:
existing = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id)).all()
if existing:
return
for item in DEFAULT_REMINDERS:
self.db.add(FitnessReminder(user_id=self.user_id, **item))
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
return compute_targets(params)
def calc_body_composition(self, params: dict[str, Any]) -> dict[str, Any]:
profile = self.get_profile() or {}
sex = params.get("sex") or profile.get("sex") or "male"
height_cm = float(params.get("height_cm") or profile.get("height_cm") or 170)
weight_kg = float(params.get("weight_kg") or profile.get("weight_kg") or 70)
return compute_body_composition(
sex=str(sex),
height_cm=height_cm,
weight_kg=weight_kg,
neck_cm=params.get("neck_cm"),
waist_cm=params.get("waist_cm"),
hip_cm=params.get("hip_cm"),
body_fat_pct=params.get("body_fat_pct"),
)
def get_latest_body_composition(self) -> dict[str, Any] | None:
rows = self.list_body_metrics(limit=1)
return rows[0] if rows else None
@staticmethod
def _body_metric_to_dict(row: BodyMetric) -> dict[str, Any]:
return {
"id": row.id,
"weight_kg": row.weight_kg,
"body_fat_pct": row.body_fat_pct,
"body_fat_method": row.body_fat_method,
"chest_cm": row.chest_cm,
"waist_cm": row.waist_cm,
"neck_cm": row.neck_cm,
"hip_cm": row.hip_cm,
"whr": row.whr,
"lbm_kg": row.lbm_kg,
"ffmi": row.ffmi,
"notes": row.notes,
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
}
@staticmethod
def _resolve_logged_at(
*,
logged_at: datetime | str | None = None,
day: date | None = None,
days_ago: int | None = None,
) -> datetime:
if logged_at is not None:
if isinstance(logged_at, str):
dt = datetime.fromisoformat(logged_at.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
if logged_at.tzinfo is None:
return logged_at.replace(tzinfo=timezone.utc)
return logged_at
target_day = day
if target_day is None and days_ago is not None:
target_day = datetime.now(timezone.utc).date() - timedelta(days=int(days_ago))
if target_day is None:
return datetime.now(timezone.utc)
return datetime.combine(target_day, time(12, 0), tzinfo=timezone.utc)
def _profile_for_budget(self, profile: dict[str, Any] | None) -> dict[str, Any]:
if profile:
return profile
return {
"calorie_target": 2000,
"protein_g": 140,
"fat_g": 65,
"carbs_g": 200,
"water_l": 2.5,
"weight_kg": 70,
"activity_level": "moderate",
"weekly_workouts": 3,
}
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
d = day or datetime.now(timezone.utc).date()
start = datetime.combine(d, time.min, tzinfo=timezone.utc)
end = datetime.combine(d, time.max, tzinfo=timezone.utc)
return start, end
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
start, end = self._day_bounds(day)
profile_row = self.get_profile()
profile = self._profile_for_budget(profile_row)
foods = self.db.scalars(
select(FoodLog)
.where(FoodLog.user_id == self.user_id, FoodLog.logged_at >= start, FoodLog.logged_at <= end)
.order_by(FoodLog.logged_at)
).all()
waters = self.db.scalars(
select(WaterLog)
.where(WaterLog.user_id == self.user_id, WaterLog.logged_at >= start, WaterLog.logged_at <= end)
.order_by(WaterLog.logged_at)
).all()
workouts_rows = self.db.scalars(
select(WorkoutLog)
.where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
.order_by(WorkoutLog.logged_at)
).all()
steps_rows = self.db.scalars(
select(StepLog)
.where(StepLog.user_id == self.user_id, StepLog.logged_at >= start, StepLog.logged_at <= end)
.order_by(StepLog.logged_at)
).all()
workouts = [self._workout_to_dict(w) for w in workouts_rows]
steps_total = sum(s.steps for s in steps_rows)
totals = {
"calories": sum(f.calories for f in foods),
"protein_g": sum(f.protein_g for f in foods),
"fat_g": sum(f.fat_g for f in foods),
"carbs_g": sum(f.carbs_g for f in foods),
"water_ml": sum(w.amount_ml for w in waters),
"steps": steps_total,
}
base_targets = build_base_targets(profile)
activity = compute_activity_bonus(
profile,
steps_total=steps_total,
workouts=workouts,
)
effective_targets, targets_base = scale_targets(
base_targets,
activity.total_bonus_kcal,
)
return {
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
"profile_configured": profile_row is not None,
"totals": totals,
"targets": effective_targets,
"targets_base": targets_base,
"activity": activity.to_dict(),
"meals": [self._food_to_dict(f) for f in foods],
"water": [self._water_to_dict(w) for w in waters],
"workouts": workouts,
"steps": [self._step_to_dict(s) for s in steps_rows],
"steps_total": steps_total,
}
def log_meal(
self,
*,
description: str,
meal_type: str = "snack",
calories: float = 0,
protein_g: float = 0,
fat_g: float = 0,
carbs_g: float = 0,
source: str = "llm",
estimated: bool = True,
) -> dict[str, Any]:
row = FoodLog(
user_id=self.user_id,
meal_type=meal_type[:32],
description=description[:2000],
calories=calories,
protein_g=protein_g,
fat_g=fat_g,
carbs_g=carbs_g,
source=source[:32],
estimated=estimated,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "meal": self._food_to_dict(row)}
def log_water(self, amount_ml: int) -> dict[str, Any]:
row = WaterLog(user_id=self.user_id, amount_ml=max(0, amount_ml))
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "water": self._water_to_dict(row)}
def log_steps(
self,
steps: int,
*,
active_calories: float | None = None,
logged_at: datetime | str | None = None,
day: date | None = None,
days_ago: int | None = None,
notes: str = "",
source: str = "manual",
) -> dict[str, Any]:
row = StepLog(
user_id=self.user_id,
steps=max(0, int(steps)),
active_calories=active_calories,
notes=notes[:2000],
source=source[:32],
logged_at=self._resolve_logged_at(
logged_at=logged_at,
day=day,
days_ago=days_ago,
),
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "step_log": self._step_to_dict(row)}
def log_weight(
self,
weight_kg: float,
*,
body_fat_pct: float | None = None,
chest_cm: float | None = None,
waist_cm: float | None = None,
neck_cm: float | None = None,
hip_cm: float | None = None,
notes: str = "",
recorded_at: datetime | str | None = None,
day: date | None = None,
days_ago: int | None = None,
) -> dict[str, Any]:
profile = self.get_profile() or {}
sex = profile.get("sex") or "male"
height_cm = float(profile.get("height_cm") or 170)
computed = compute_body_composition(
sex=str(sex),
height_cm=height_cm,
weight_kg=weight_kg,
neck_cm=neck_cm,
waist_cm=waist_cm,
hip_cm=hip_cm,
body_fat_pct=body_fat_pct,
)
row = BodyMetric(
user_id=self.user_id,
weight_kg=weight_kg,
body_fat_pct=computed.get("body_fat_pct"),
body_fat_method=computed.get("body_fat_method"),
chest_cm=chest_cm,
waist_cm=waist_cm,
neck_cm=neck_cm,
hip_cm=hip_cm,
whr=computed.get("whr"),
lbm_kg=computed.get("lbm_kg"),
ffmi=computed.get("ffmi"),
notes=notes[:1000],
recorded_at=self._resolve_logged_at(
logged_at=recorded_at,
day=day,
days_ago=days_ago,
),
)
self.db.add(row)
profile_row = self._get_profile_row()
if profile_row:
profile_row.weight_kg = weight_kg
targets = compute_targets(
{
"sex": profile_row.sex,
"age": profile_row.age,
"height_cm": profile_row.height_cm,
"weight_kg": weight_kg,
"activity_level": profile_row.activity_level,
"goal": profile_row.goal,
}
)
profile_row.calorie_target = targets["calorie_target"]
profile_row.protein_g = targets["protein_g"]
profile_row.fat_g = targets["fat_g"]
profile_row.carbs_g = targets["carbs_g"]
profile_row.water_l = targets["water_l"]
self.db.commit()
self.db.refresh(row)
metric = self._body_metric_to_dict(row)
return {
"ok": True,
"metric": metric,
"computed": {
"body_fat_pct": computed.get("body_fat_pct"),
"body_fat_method": computed.get("body_fat_method"),
"whr": computed.get("whr"),
"lbm_kg": computed.get("lbm_kg"),
"ffmi": computed.get("ffmi"),
"warnings": computed.get("warnings") or [],
},
}
def log_workout(
self,
*,
title: str,
notes: str = "",
duration_min: int | None = None,
exercises: list[dict[str, Any]] | None = None,
active_calories: float | None = None,
total_calories: float | None = None,
steps: int | None = None,
logged_at: datetime | str | None = None,
day: date | None = None,
days_ago: int | None = None,
) -> dict[str, Any]:
row = WorkoutLog(
user_id=self.user_id,
title=title[:255],
notes=notes[:2000],
duration_min=duration_min,
active_calories=active_calories,
total_calories=total_calories,
steps=steps,
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
logged_at=self._resolve_logged_at(
logged_at=logged_at,
day=day,
days_ago=days_ago,
),
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "workout": self._workout_to_dict(row)}
def get_workout_stats(
self,
*,
days: int = 7,
end_day: date | None = None,
) -> dict[str, Any]:
days = max(1, min(days, 90))
end = end_day or datetime.now(timezone.utc).date()
start = end - timedelta(days=days - 1)
start_dt, _ = self._day_bounds(start)
_, end_dt = self._day_bounds(end)
rows = self.db.scalars(
select(WorkoutLog)
.where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start_dt, WorkoutLog.logged_at <= end_dt)
.order_by(WorkoutLog.logged_at)
).all()
profile = self.get_profile() or {}
weekly_target = int(profile.get("weekly_workouts") or 3)
count = len(rows)
duration_min = sum(r.duration_min or 0 for r in rows)
active_kcal = round(
sum(estimate_workout_active_kcal(self._workout_to_dict(r)) for r in rows),
1,
)
days_with_workout: set[date] = set()
for row in rows:
if row.logged_at:
days_with_workout.add(row.logged_at.astimezone(timezone.utc).date())
streak = 0
cursor = end
while cursor >= start:
if cursor in days_with_workout:
streak += 1
cursor -= timedelta(days=1)
else:
break
return {
"days": days,
"start_date": start.isoformat(),
"end_date": end.isoformat(),
"count": count,
"duration_min": duration_min,
"active_kcal": active_kcal,
"weekly_target": weekly_target,
"streak": streak,
}
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
rows = self.db.scalars(
select(BodyMetric).where(BodyMetric.user_id == self.user_id).order_by(BodyMetric.recorded_at.desc()).limit(limit)
).all()
return [self._body_metric_to_dict(r) for r in rows]
def delete_food_log(self, log_id: int) -> bool:
row = self.db.get(FoodLog, log_id)
if not row or row.user_id != self.user_id:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_water_log(self, log_id: int) -> bool:
row = self.db.get(WaterLog, log_id)
if not row or row.user_id != self.user_id:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_workout_log(self, log_id: int) -> bool:
row = self.db.get(WorkoutLog, log_id)
if not row or row.user_id != self.user_id:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_step_log(self, log_id: int) -> bool:
row = self.db.get(StepLog, log_id)
if not row or row.user_id != self.user_id:
return False
self.db.delete(row)
self.db.commit()
return True
def list_reminders(self) -> list[dict[str, Any]]:
rows = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id).order_by(FitnessReminder.kind)).all()
return [self._reminder_to_dict(r) for r in rows]
def set_reminder(
self,
kind: str,
*,
enabled: bool | None = None,
hour: int | None = None,
minute: int | None = None,
interval_hours: int | None = None,
) -> dict[str, Any]:
row = self.db.scalar(
select(FitnessReminder).where(FitnessReminder.user_id == self.user_id, FitnessReminder.kind == kind)
)
if not row:
row = FitnessReminder(user_id=self.user_id, kind=kind)
self.db.add(row)
if enabled is not None:
row.enabled = enabled
if hour is not None:
row.hour = hour
if minute is not None:
row.minute = minute
if interval_hours is not None:
row.interval_hours = interval_hours
self.db.commit()
self.db.refresh(row)
return {"ok": True, "reminder": self._reminder_to_dict(row)}
def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]:
return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)}
def get_history(
self,
*,
days: int = 7,
end_day: date | None = None,
include_targets_base: bool = True,
) -> dict[str, Any]:
days = max(1, min(days, 90))
end = end_day or datetime.now(timezone.utc).date()
start = end - timedelta(days=days - 1)
summaries: list[dict[str, Any]] = []
for offset in range(days):
d = start + timedelta(days=offset)
full = self.get_daily_summary(d)
totals = full["totals"]
has_data = bool(full["meals"] or full["water"] or full["workouts"] or full["steps"])
item: dict[str, Any] = {
"date": full["date"],
"has_data": has_data,
"totals": totals,
"targets": full["targets"],
"meal_count": len(full["meals"]),
"workout_count": len(full["workouts"]),
}
if include_targets_base:
item["targets_base"] = full.get("targets_base")
summaries.append(item)
return {
"start_date": start.isoformat(),
"end_date": end.isoformat(),
"days": days,
"summaries": summaries,
}
def snapshot(self) -> dict[str, Any]:
today = datetime.now(timezone.utc).date()
return {
"profile": self.get_profile(),
"today": self.get_daily_summary(today),
"history": self.get_history(days=7, end_day=today),
"workout_stats": self.get_workout_stats(days=7, end_day=today),
"body_metrics": self.list_body_metrics(limit=10),
"reminders": self.list_reminders(),
}
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,
}
-96
View File
@@ -1,96 +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": "название",
"duration_min": null,
"active_calories": null,
"total_calories": null,
"steps": null,
"notes": "",
"exercises": [
{"name": "имя упраж", "sets": 3, "reps": 8, "weight_kg": 80}
]
}
Правила:
- weight_kg в кг, округляй разумно.
- active_calories / total_calories / steps — если упомянуты в тексте, иначе null.
- Если данных нет — null или пустой массив.
""".strip()
STEPS_PROMPT = """
Преобразуй запись о шагах в JSON. Только JSON.
Формат:
{
"steps": 0,
"active_calories": null,
"notes": ""
}
Правила:
- steps — целое число шагов за день.
- active_calories — только если явно указаны.
""".strip()
async def structure_meal(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": MEAL_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
async def structure_workout(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": WORKOUT_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
async def structure_steps(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": STEPS_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
-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)
-54
View File
@@ -1,54 +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_current_and_hourly(hours_ahead=12)
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))
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, include_news: bool = False) -> dict:
client = OpenMeteoClient()
weather = client.fetch_current_and_hourly(hours_ahead=hours_ahead)
result = {
"weather": weather,
"rain_summary": client.rain_summary(hours_ahead=hours_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)
-336
View File
@@ -1,336 +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: "гроза с градом",
}
_cache: dict[str, Any] = {
"data": None,
"fetched_at": 0.0,
"expires_at": 0.0,
"source": "local",
"local_coverage": {"current": [], "hourly": []},
}
CURRENT_FIELDS = (
"temperature_2m",
"apparent_temperature",
"relative_humidity_2m",
"precipitation",
"weather_code",
"wind_speed_10m",
)
HOURLY_FIELDS = (
"temperature_2m",
"precipitation_probability",
"precipitation",
"weather_code",
)
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, скорее всего, качает только temperature_2m. "
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"
)
def _hourly_series(hourly: dict[str, Any], key: str) -> list[Any]:
values = hourly.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]]:
"""Какие поля реально пришли от OpenMeteo (не null)."""
current = raw.get("current") or {}
hourly = raw.get("hourly") 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)
return {"current": current_present, "hourly": hourly_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 "precipitation_probability" not in hourly and "weather_code" not in hourly:
return False
return True
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
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
def _request_params(self) -> dict[str, Any]:
return {
"latitude": self.lat,
"longitude": self.lon,
"current": ",".join(CURRENT_FIELDS),
"hourly": ",".join(HOURLY_FIELDS),
"timezone": "auto",
"forecast_days": 2,
}
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
if (
self.fallback_on_partial
and self.fallback_url
and self.fallback_url.rstrip("/") != self.base_url
and not _coverage_sufficient(local_coverage)
):
try:
fallback_raw = self._fetch_from_url(self.fallback_url)
if _coverage_sufficient(_field_coverage(fallback_raw)):
raw = fallback_raw
source = "fallback"
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
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",
}
def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]:
try:
raw = self._fetch_raw()
except Exception as exc:
return {"ok": False, "error": str(exc), "location": self.location_name}
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))
hourly_slice = []
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")
hourly_slice.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": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно",
})
code = current.get("weather_code")
coverage = _field_coverage(raw)
return {
"ok": True,
"location": self.location_name,
"data_source": _cache.get("source") or "local",
"local_field_coverage": _cache.get("local_coverage") or coverage,
"field_coverage": coverage,
"sync_hint": SYNC_HINT if not _coverage_sufficient(_cache.get("local_coverage") or coverage) else "",
"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": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно",
},
"hourly": hourly_slice,
}
def rain_summary(self, hours_ahead: int = 12) -> str:
data = self.fetch_current_and_hourly(hours_ahead=hours_ahead)
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]
rainy_hours.append(f"{time_str} ({prob}% вероятность, {precip} мм)")
if rainy_hours:
return "Ожидаются осадки: " + ", ".join(rainy_hours[:6])
return "Существенных осадков в ближайшие часы не ожидается."
def format_weather_snapshot(data: dict[str, Any] | None = None) -> str:
client = OpenMeteoClient()
snapshot = data if data is not None else client.fetch_current_and_hourly(hours_ahead=6)
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}."
)
hourly = snapshot.get("hourly") or []
rainy_hours = []
for hour in hourly:
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]
rainy_hours.append(f"{time_str} ({prob}% вероятность, {precip} мм)")
if rainy_hours:
lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6]))
else:
lines.append("Существенных осадков в ближайшие часы не ожидается.")
lines.append("Вопросы «что на улице» / «будет ли дождь» — get_weather.")
return "\n".join(lines)
def build_weather_dashboard(hours_ahead: int = 12) -> dict[str, Any]:
"""Полный снимок для UI: данные OpenMeteo + контекст ассистента."""
client = OpenMeteoClient()
weather = client.fetch_current_and_hourly(hours_ahead=hours_ahead)
settings = get_settings()
return {
"weather": weather,
"rain_summary": client.rain_summary(hours_ahead=hours_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": 2,
"timezone": "auto",
},
"available_fields": {
"current": list(CURRENT_FIELDS),
"hourly": list(HOURLY_FIELDS),
},
"field_coverage": weather.get("field_coverage") if weather.get("ok") else {"current": [], "hourly": []},
"local_field_coverage": weather.get("local_field_coverage") if weather.get("ok") else {"current": [], "hourly": []},
"data_source": weather.get("data_source", "local") if weather.get("ok") else "local",
"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 до 48)",
"get_morning_briefing": "Погода + заголовки RSS-новостей",
},
"system_prompt": "Краткий блок [Погода] в system prompt каждого сообщения (6 ч почасово).",
}
-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 -323
View File
@@ -1,323 +1,112 @@
import json
import logging
from collections.abc import AsyncIterator
from typing import Any
from openai import AsyncOpenAI
from app.config import get_settings
logger = logging.getLogger(__name__)
class LLMClient:
def __init__(self) -> None:
settings = get_settings()
self.tools_enabled = settings.openrouter_tools_enabled
self.client = AsyncOpenAI(
api_key=settings.openrouter_api_key,
base_url=settings.openrouter_base_url,
)
def _runtime(self) -> tuple[str, str, str]:
from app.db.base import SessionLocal
from app.settings.service import SettingsService
settings = get_settings()
db = SessionLocal()
try:
svc = SettingsService(db)
model = str(svc.get_effective("openrouter_model"))
extract = str(svc.get_effective("memory_extract_model"))
effort = str(svc.get_effective("openrouter_reasoning_effort")).strip().lower()
return model, extract, effort
finally:
db.close()
@property
def model(self) -> str:
return self._runtime()[0]
@property
def memory_extract_model(self) -> str:
return self._runtime()[1]
@property
def reasoning_effort(self) -> str:
return self._runtime()[2]
def _reasoning_extra_body(self) -> dict[str, Any] | None:
if not self.reasoning_effort:
return None
if self.reasoning_effort == "none":
return {"reasoning": {"effort": "none", "exclude": True}}
return {"reasoning": {"effort": self.reasoning_effort}}
@staticmethod
def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]:
parts: list[str] = []
for attr in ("reasoning", "reasoning_content"):
value = getattr(delta, attr, None)
if value:
parts.append(str(value))
details: list[Any] = []
raw_details = getattr(delta, "reasoning_details", None)
if raw_details:
if isinstance(raw_details, list):
details.extend(raw_details)
else:
details.append(raw_details)
return "".join(parts), details
@staticmethod
def _normalize_reasoning_details(details: Any) -> list[Any] | None:
if not details:
return None
items = details if isinstance(details, list) else [details]
normalized: list[Any] = []
for item in items:
if hasattr(item, "model_dump"):
normalized.append(item.model_dump())
elif isinstance(item, dict):
normalized.append(item)
else:
normalized.append(item)
return normalized or None
@staticmethod
def attach_reasoning_to_message(
message: dict[str, Any],
*,
reasoning: str = "",
reasoning_details: list[Any] | None = None,
) -> dict[str, Any]:
if reasoning:
message["reasoning"] = reasoning
message["reasoning_content"] = reasoning
normalized = LLMClient._normalize_reasoning_details(reasoning_details)
if normalized:
message["reasoning_details"] = normalized
return message
async def stream_chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
*,
model: str | None = None,
) -> AsyncIterator[dict[str, Any]]:
use_tools = bool(tools) and self.tools_enabled
kwargs: dict[str, Any] = {
"model": model or self.model,
"messages": messages,
"stream": True,
"temperature": 0.7,
}
if use_tools:
kwargs["tools"] = tools
extra_body = self._reasoning_extra_body()
if extra_body:
kwargs["extra_body"] = extra_body
try:
stream = await self.client.chat.completions.create(**kwargs)
except Exception as exc:
logger.exception("LLM stream failed: %s", exc)
yield {"type": "error", "content": str(exc)}
yield {"type": "done", "finish_reason": "error"}
return
tool_calls: dict[int, dict[str, Any]] = {}
reasoning_parts: list[str] = []
reasoning_details: list[Any] = []
try:
async for chunk in stream:
if not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
if delta.content:
yield {"type": "content", "content": delta.content}
reasoning_text, details = self._delta_reasoning(delta)
if reasoning_text:
reasoning_parts.append(reasoning_text)
if details:
reasoning_details.extend(details)
if delta.tool_calls:
for tool_call in delta.tool_calls:
idx = tool_call.index
if idx not in tool_calls:
tool_calls[idx] = {
"id": tool_call.id or "",
"type": "function",
"function": {"name": "", "arguments": ""},
}
if tool_call.id:
tool_calls[idx]["id"] = tool_call.id
if tool_call.function:
if tool_call.function.name:
tool_calls[idx]["function"]["name"] = tool_call.function.name
if tool_call.function.arguments:
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
usage = getattr(chunk, "usage", None)
if usage is not None:
logger.info(
"LLM stream usage: prompt=%s completion=%s total=%s",
getattr(usage, "prompt_tokens", None),
getattr(usage, "completion_tokens", None),
getattr(usage, "total_tokens", None),
)
if choice.finish_reason:
reasoning = "".join(reasoning_parts)
normalized_details = self._normalize_reasoning_details(reasoning_details)
if reasoning or normalized_details:
yield {
"type": "reasoning",
"reasoning": reasoning,
"reasoning_details": normalized_details,
}
if tool_calls:
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
logger.info(
"LLM stream done: model=%s finish_reason=%s tool_calls=%d "
"content_in_stream=%d reasoning_len=%d",
model or self.model,
choice.finish_reason,
len(tool_calls),
len(reasoning_parts),
len(reasoning),
)
yield {"type": "done", "finish_reason": choice.finish_reason}
except Exception as exc:
logger.exception("LLM stream read failed: %s", exc)
yield {"type": "error", "content": str(exc)}
yield {"type": "done", "finish_reason": "error"}
async def complete(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
*,
temperature: float = 0.7,
model: str | None = None,
for_extraction: bool = False,
visible_reply: bool = False,
) -> dict[str, Any]:
use_tools = bool(tools) and self.tools_enabled and not for_extraction
kwargs: dict[str, Any] = {
"model": model or self.model,
"messages": messages,
"temperature": temperature,
}
if use_tools:
kwargs["tools"] = tools
if for_extraction:
kwargs["extra_body"] = {"reasoning": {"effort": "none"}}
else:
extra_body = self._reasoning_extra_body()
if extra_body:
kwargs["extra_body"] = extra_body
response = await self.client.chat.completions.create(**kwargs)
usage = getattr(response, "usage", None)
if usage is not None:
logger.info(
"LLM complete usage: prompt=%s completion=%s total=%s model=%s",
getattr(usage, "prompt_tokens", None),
getattr(usage, "completion_tokens", None),
getattr(usage, "total_tokens", None),
kwargs.get("model"),
)
message = response.choices[0].message
content = message.content or ""
reasoning = ""
for attr in ("reasoning", "reasoning_content"):
value = getattr(message, attr, None)
if value:
reasoning = str(value)
break
if not content and reasoning and not visible_reply:
content = reasoning
result: dict[str, Any] = {
"content": content,
"tool_calls": [],
"reasoning": reasoning,
"reasoning_details": getattr(message, "reasoning_details", None),
}
if message.tool_calls:
result["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in message.tool_calls
]
return result
@staticmethod
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
if not arguments:
return {}
try:
return json.loads(arguments)
except json.JSONDecodeError:
return {}
@staticmethod
def serialize_reasoning(
*,
reasoning: str = "",
reasoning_details: list[Any] | None = None,
) -> str | None:
payload: dict[str, Any] = {}
if reasoning:
payload["reasoning"] = reasoning
payload["reasoning_content"] = reasoning
if reasoning_details:
payload["reasoning_details"] = reasoning_details
if not payload:
return None
return json.dumps(payload, ensure_ascii=False)
@staticmethod
def deserialize_reasoning(raw: str | None) -> dict[str, Any]:
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {"reasoning": raw}
if isinstance(data, str):
return {"reasoning": data, "reasoning_content": data}
if isinstance(data, dict):
return data
return {}
async def embed(self, texts: list[str]) -> list[list[float]]:
settings = get_settings()
if not texts:
return []
response = await self.client.embeddings.create(
model=settings.embedding_model,
input=texts,
)
return [item.embedding for item in response.data]
import json
from collections.abc import AsyncIterator
from typing import Any
from openai import AsyncOpenAI
from app.config import get_settings
class LLMClient:
def __init__(self) -> None:
settings = get_settings()
self.model = settings.openrouter_model
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]],
tools: list[dict[str, Any]] | None = None,
) -> AsyncIterator[dict[str, Any]]:
kwargs: dict[str, Any] = {
"model": self.model,
"messages": messages,
"stream": True,
"temperature": 0.7,
}
if tools:
kwargs["tools"] = tools
stream = await self.client.chat.completions.create(**kwargs)
tool_calls: dict[int, dict[str, Any]] = {}
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}
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:
if tool_calls:
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
yield {"type": "done", "finish_reason": choice.finish_reason}
async def complete(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": 0.7,
}
if tools:
kwargs["tools"] = tools
response = await self.client.chat.completions.create(**kwargs)
message = response.choices[0].message
result: dict[str, Any] = {
"content": message.content or "",
"tool_calls": [],
}
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 {}
-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
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router
from app.config import get_settings
from app.db.base import init_db
from app.fitness.watcher import fitness_watcher_loop
from app.homelab_scoped.watcher import homelab_watcher_loop
from app.pomodoro.watcher import pomodoro_watcher_loop
from app.reminders_scoped.watcher import reminders_watcher_loop
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
from app.db.migrate_fitness import run_fitness_migrations
run_fitness_migrations()
from app.db.migrate_multi_user import run_multi_user_migrations
run_multi_user_migrations()
settings = get_settings()
if settings.rag_enabled:
from app.rag.store import ensure_collections
ensure_collections()
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
fitness_task = asyncio.create_task(fitness_watcher_loop())
homelab_task = asyncio.create_task(homelab_watcher_loop())
reminders_task = asyncio.create_task(reminders_watcher_loop())
yield
pomodoro_task.cancel()
fitness_task.cancel()
homelab_task.cancel()
reminders_task.cancel()
with suppress(asyncio.CancelledError):
await pomodoro_task
with suppress(asyncio.CancelledError):
await fitness_task
with suppress(asyncio.CancelledError):
await homelab_task
with suppress(asyncio.CancelledError):
await reminders_task
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title="Home AI Assistant", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
return app
app = create_app()
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.pomodoro.watcher import pomodoro_watcher_loop
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
watcher_task = asyncio.create_task(pomodoro_watcher_loop())
yield
watcher_task.cancel()
with suppress(asyncio.CancelledError):
await watcher_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.orm import Session
from app.character.service import CharacterService
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
from app.chat.notices import format_phase_completed_notice
from app.db.models import PomodoroSession
from app.llm.client import LLMClient
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
from app.pomodoro.service import PomodoroService
logger = logging.getLogger(__name__)
PHASE_LABELS = {
PHASE_WORK: "работа",
PHASE_SHORT_BREAK: "короткий перерыв",
PHASE_LONG_BREAK: "длинный перерыв",
}
class PomodoroCompletionHandler:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self.pomodoro = PomodoroService(db, user_id)
self.cycle = CycleManager(db, user_id)
self.llm = LLMClient()
self.character = CharacterService(db, user_id)
async def _generate_llm_comment(
self,
session: PomodoroSession,
next_phase: str | None,
) -> str:
cycle = self.cycle.to_dict()
phase_label = PHASE_LABELS.get(session.phase, session.phase)
next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен"
work_done = cycle["completed_work_sessions"]
if session.phase == PHASE_WORK:
work_done += 1
system = self.character.get_system_prompt()
user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась.
Задача: {session.task_note or 'без описания'}
Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ.
Следующая фаза: {next_label}.
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown и без эмодзи."""
result = await self.llm.complete(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
],
temperature=0.8,
visible_reply=True,
)
return (result.get("content") or "").strip() or "Фаза завершена. Хорошая работа."
def _resolve_next_phase(self, session: PomodoroSession) -> str | None:
phase = session.phase
cycle = self.cycle.get()
if phase == PHASE_WORK:
if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break:
return PHASE_LONG_BREAK
return PHASE_SHORT_BREAK
if phase == PHASE_SHORT_BREAK:
return PHASE_WORK
if phase == PHASE_LONG_BREAK:
return None
return None
async def process(self, session: PomodoroSession) -> None:
if session.completion_notified:
return
next_phase = self._resolve_next_phase(session)
notice = format_phase_completed_notice(session, next_phase)
post_notice_to_latest_chat(notice, self.user_id)
try:
comment = await self._generate_llm_comment(session, next_phase)
if comment:
post_character_comment_to_latest_chat(comment, self.user_id)
except Exception:
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase)
self.cycle.bump_notify_seq()
self.pomodoro.mark_notified(session)
self.pomodoro.advance_after_completion(session)
logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase)
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.chat.notices import format_phase_completed_notice
from app.db.models import ChatSession, Message, PomodoroSession
from app.llm.client import LLMClient
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
from app.pomodoro.service import PomodoroService
PHASE_LABELS = {
PHASE_WORK: "работа",
PHASE_SHORT_BREAK: "короткий перерыв",
PHASE_LONG_BREAK: "длинный перерыв",
}
class PomodoroCompletionHandler:
def __init__(self, db: Session):
self.db = db
self.pomodoro = PomodoroService(db)
self.cycle = CycleManager(db)
self.llm = LLMClient()
self.character = CharacterService()
def _latest_chat_session_id(self) -> int | None:
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
session = self.db.scalar(stmt)
return session.id if session else None
def _save_chat_message(self, session_id: int, role: str, content: str) -> None:
self.db.add(Message(session_id=session_id, role=role, content=content))
chat = self.db.get(ChatSession, session_id)
if chat:
chat.updated_at = chat.updated_at # trigger onupdate
self.db.commit()
async def _generate_llm_comment(
self,
session: PomodoroSession,
next_phase: str | None,
) -> str:
cycle = self.cycle.to_dict()
phase_label = PHASE_LABELS.get(session.phase, session.phase)
next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен"
work_done = cycle["completed_work_sessions"]
if session.phase == PHASE_WORK:
work_done += 1
system = self.character.get_system_prompt()
user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась.
Задача: {session.task_note or 'без описания'}
Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ.
Следующая фаза: {next_label}.
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown."""
result = await self.llm.complete(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
]
)
return (result.get("content") or "").strip() or "Фаза завершена! Отличная работа."
def _resolve_next_phase(self, session: PomodoroSession) -> str | None:
phase = session.phase
cycle = self.cycle.get()
if phase == PHASE_WORK:
if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break:
return PHASE_LONG_BREAK
return PHASE_SHORT_BREAK
if phase == PHASE_SHORT_BREAK:
return PHASE_WORK
if phase == PHASE_LONG_BREAK:
return None
return None
async def process(self, session: PomodoroSession) -> None:
if session.completion_notified:
return
next_phase = self._resolve_next_phase(session)
notice = format_phase_completed_notice(session, next_phase)
chat_id = self._latest_chat_session_id()
if not chat_id:
chat = ChatSession(title="Помидоро")
self.db.add(chat)
self.db.commit()
self.db.refresh(chat)
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.orm import Session
from app.db.models import PomodoroCycle
PHASE_WORK = "work"
PHASE_SHORT_BREAK = "short_break"
PHASE_LONG_BREAK = "long_break"
class CycleManager:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
def get(self) -> PomodoroCycle:
cycle = self.db.scalar(select(PomodoroCycle).where(PomodoroCycle.user_id == self.user_id).limit(1))
if not cycle:
cycle = PomodoroCycle(user_id=self.user_id)
self.db.add(cycle)
self.db.commit()
self.db.refresh(cycle)
return cycle
def to_dict(self, cycle: PomodoroCycle | None = None) -> dict:
c = cycle or self.get()
return {
"completed_work_sessions": c.completed_work_sessions,
"sessions_until_long_break": c.sessions_until_long_break,
"task_note": c.task_note,
"work_duration_min": c.work_duration_min,
"short_break_min": c.short_break_min,
"long_break_min": c.long_break_min,
"auto_advance": c.auto_advance,
"chat_notify_seq": c.chat_notify_seq,
}
def reset(self, clear_task: bool = False) -> dict:
cycle = self.get()
cycle.completed_work_sessions = 0
if clear_task:
cycle.task_note = ""
self.db.commit()
self.db.refresh(cycle)
return self.to_dict(cycle)
def bump_notify_seq(self) -> int:
cycle = self.get()
cycle.chat_notify_seq += 1
self.db.commit()
self.db.refresh(cycle)
return cycle.chat_notify_seq
def on_work_completed(self) -> str:
"""Returns next phase: short_break or long_break."""
cycle = self.get()
cycle.completed_work_sessions += 1
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
next_phase = PHASE_LONG_BREAK
else:
next_phase = PHASE_SHORT_BREAK
self.db.commit()
return next_phase
def on_long_break_completed(self) -> None:
cycle = self.get()
cycle.completed_work_sessions = 0
self.db.commit()
def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int:
c = cycle or self.get()
if phase == PHASE_WORK:
return c.work_duration_min
if phase == PHASE_SHORT_BREAK:
return c.short_break_min
if phase == PHASE_LONG_BREAK:
return c.long_break_min
return c.work_duration_min
def next_phase_after(self, completed_phase: str) -> str | None:
if completed_phase == PHASE_WORK:
cycle = self.get()
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
return PHASE_LONG_BREAK
return PHASE_SHORT_BREAK
if completed_phase == PHASE_SHORT_BREAK:
return PHASE_WORK
if completed_phase == PHASE_LONG_BREAK:
return None
return None
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import PomodoroCycle
PHASE_WORK = "work"
PHASE_SHORT_BREAK = "short_break"
PHASE_LONG_BREAK = "long_break"
class CycleManager:
def __init__(self, db: Session):
self.db = db
def get(self) -> PomodoroCycle:
cycle = self.db.scalar(select(PomodoroCycle).limit(1))
if not cycle:
cycle = PomodoroCycle()
self.db.add(cycle)
self.db.commit()
self.db.refresh(cycle)
return cycle
def to_dict(self, cycle: PomodoroCycle | None = None) -> dict:
c = cycle or self.get()
return {
"completed_work_sessions": c.completed_work_sessions,
"sessions_until_long_break": c.sessions_until_long_break,
"task_note": c.task_note,
"work_duration_min": c.work_duration_min,
"short_break_min": c.short_break_min,
"long_break_min": c.long_break_min,
"auto_advance": c.auto_advance,
"chat_notify_seq": c.chat_notify_seq,
}
def reset(self, clear_task: bool = False) -> dict:
cycle = self.get()
cycle.completed_work_sessions = 0
if clear_task:
cycle.task_note = ""
self.db.commit()
self.db.refresh(cycle)
return self.to_dict(cycle)
def bump_notify_seq(self) -> int:
cycle = self.get()
cycle.chat_notify_seq += 1
self.db.commit()
self.db.refresh(cycle)
return cycle.chat_notify_seq
def on_work_completed(self) -> str:
"""Returns next phase: short_break or long_break."""
cycle = self.get()
cycle.completed_work_sessions += 1
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
next_phase = PHASE_LONG_BREAK
else:
next_phase = PHASE_SHORT_BREAK
self.db.commit()
return next_phase
def on_long_break_completed(self) -> None:
cycle = self.get()
cycle.completed_work_sessions = 0
self.db.commit()
def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int:
c = cycle or self.get()
if phase == PHASE_WORK:
return c.work_duration_min
if phase == PHASE_SHORT_BREAK:
return c.short_break_min
if phase == PHASE_LONG_BREAK:
return c.long_break_min
return c.work_duration_min
def next_phase_after(self, completed_phase: str) -> str | None:
if completed_phase == PHASE_WORK:
cycle = self.get()
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
return PHASE_LONG_BREAK
return PHASE_SHORT_BREAK
if completed_phase == PHASE_SHORT_BREAK:
return PHASE_WORK
if completed_phase == PHASE_LONG_BREAK:
return None
return None
+287 -296
View File
@@ -1,296 +1,287 @@
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import PomodoroSession
from app.pomodoro.cycle import (
PHASE_LONG_BREAK,
PHASE_SHORT_BREAK,
PHASE_WORK,
CycleManager,
)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class PomodoroService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self.cycle = CycleManager(db, user_id)
def _get_active(self) -> PomodoroSession | None:
stmt = (
select(PomodoroSession)
.where(
PomodoroSession.user_id == self.user_id,
PomodoroSession.status.in_(("running", "paused")),
)
.order_by(PomodoroSession.id.desc())
.limit(1)
)
return self.db.scalar(stmt)
def _elapsed(self, session: PomodoroSession) -> int:
elapsed = session.elapsed_seconds
if session.status == "running" and session.started_at:
started = session.started_at
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
delta = _utcnow() - started
elapsed += int(delta.total_seconds())
return elapsed
def _remaining(self, session: PomodoroSession) -> int:
total = session.duration_min * 60
return max(0, total - self._elapsed(session))
def _try_auto_complete(self, session: PomodoroSession) -> bool:
if session.status != "running":
return False
if self._remaining(session) > 0:
return False
self._finalize_session(session, auto=True)
return True
def _finalize_session(
self,
session: PomodoroSession,
*,
auto: bool,
result: str = "",
completed: bool | None = None,
cancelled: bool = False,
) -> None:
session.elapsed_seconds = self._elapsed(session)
session.started_at = None
session.finished_at = _utcnow()
session.completion_notified = False
session.result = result or None
if cancelled:
session.status = "cancelled"
session.completed = False
elif completed is not None:
session.status = "completed"
session.completed = completed
else:
session.status = "completed"
session.completed = True
self.db.commit()
self.db.refresh(session)
def _start_phase(
self,
phase: str,
*,
duration_min: int | None = None,
task_note: str | None = None,
) -> PomodoroSession:
active = self._get_active()
if active:
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
cycle = self.cycle.get()
if task_note is not None:
cycle.task_note = task_note
elif phase == PHASE_WORK and not cycle.task_note:
cycle.task_note = ""
duration = duration_min or self.cycle.duration_for_phase(phase, cycle)
note = task_note if task_note is not None else cycle.task_note
session = PomodoroSession(
user_id=self.user_id,
status="running",
phase=phase,
duration_min=duration,
task_note=note,
started_at=_utcnow(),
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return session
def _to_status_dict(self, session: PomodoroSession | None) -> dict:
cycle_dict = self.cycle.to_dict()
if not session:
return {
"status": "idle",
"phase": PHASE_WORK,
"duration_min": cycle_dict["work_duration_min"],
"task_note": cycle_dict["task_note"],
"elapsed_seconds": 0,
"remaining_seconds": 0,
"session_id": None,
"cycle": cycle_dict,
}
elapsed = self._elapsed(session)
total = session.duration_min * 60
remaining = max(0, total - elapsed)
return {
"status": session.status,
"phase": session.phase,
"duration_min": session.duration_min,
"task_note": session.task_note,
"elapsed_seconds": elapsed,
"remaining_seconds": remaining,
"session_id": session.id,
"started_at": session.started_at.isoformat() if session.started_at else None,
"finished_at": session.finished_at.isoformat() if session.finished_at else None,
"cycle": cycle_dict,
}
def get_status(self) -> dict:
active = self._get_active()
if active:
self._try_auto_complete(active)
active = self._get_active()
return self._to_status_dict(active)
def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict:
session = self._start_phase(
PHASE_WORK,
duration_min=duration_min,
task_note=task_note,
)
return self._to_status_dict(session)
def start_short_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min)
return self._to_status_dict(session)
def start_long_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min)
return self._to_status_dict(session)
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
return self.start_work(duration_min=duration_min, task_note=task_note)
def pause(self) -> dict:
session = self._get_active()
if not session or session.status != "running":
raise ValueError("Нет активного запущенного таймера.")
session.elapsed_seconds = self._elapsed(session)
session.status = "paused"
session.paused_at = _utcnow()
session.started_at = None
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def resume(self) -> dict:
session = self._get_active()
if not session or session.status != "paused":
raise ValueError("Нет таймера на паузе.")
session.status = "running"
session.started_at = _utcnow()
session.paused_at = None
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def stop(self, result: str = "", completed: bool = False) -> dict:
session = self._get_active()
if not session:
raise ValueError("Нет активного таймера.")
if completed:
self._finalize_session(session, auto=False, result=result, completed=True)
else:
self._finalize_session(session, auto=False, result=result, cancelled=True)
session.completion_notified = True
self.db.commit()
return self._to_status_dict(None)
def reset_cycle(self, clear_task: bool = False) -> dict:
active = self._get_active()
if active:
self._finalize_session(active, auto=False, cancelled=True)
active.completion_notified = True
self.db.commit()
cycle = self.cycle.reset(clear_task=clear_task)
status = self._to_status_dict(None)
status["cycle"] = cycle
return status
def skip_phase(self) -> dict:
session = self._get_active()
if not session:
raise ValueError("Нет активного таймера.")
self._finalize_session(session, auto=True)
return self._to_status_dict(None)
def get_pending_completions(self) -> list[PomodoroSession]:
stmt = (
select(PomodoroSession)
.where(
PomodoroSession.user_id == self.user_id,
PomodoroSession.status == "completed",
PomodoroSession.completed.is_(True),
PomodoroSession.completion_notified.is_(False),
)
.order_by(PomodoroSession.id.asc())
)
return list(self.db.scalars(stmt))
def mark_notified(self, session: PomodoroSession) -> None:
session.completion_notified = True
self.db.commit()
def advance_after_completion(self, session: PomodoroSession) -> dict | None:
"""Update cycle counters and auto-start next phase. Returns new status or None."""
phase = session.phase
cycle = self.cycle.get()
if phase == PHASE_WORK:
next_phase = self.cycle.on_work_completed()
elif phase == PHASE_SHORT_BREAK:
next_phase = PHASE_WORK
elif phase == PHASE_LONG_BREAK:
self.cycle.on_long_break_completed()
next_phase = None
else:
next_phase = None
if not cycle.auto_advance or next_phase is None:
return None
new_session = self._start_phase(next_phase)
return self._to_status_dict(new_session)
def history(self, limit: int = 20) -> list[dict]:
stmt = (
select(PomodoroSession)
.where(
PomodoroSession.user_id == self.user_id,
PomodoroSession.status.in_(("completed", "cancelled")),
)
.order_by(PomodoroSession.finished_at.desc())
.limit(limit)
)
sessions = self.db.scalars(stmt).all()
return [
{
"id": s.id,
"status": s.status,
"phase": s.phase,
"duration_min": s.duration_min,
"task_note": s.task_note,
"result": s.result,
"completed": s.completed,
"elapsed_seconds": s.elapsed_seconds,
"finished_at": s.finished_at.isoformat() if s.finished_at else None,
}
for s in sessions
]
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import PomodoroSession
from app.pomodoro.cycle import (
PHASE_LONG_BREAK,
PHASE_SHORT_BREAK,
PHASE_WORK,
CycleManager,
)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class PomodoroService:
def __init__(self, db: Session):
self.db = db
self.cycle = CycleManager(db)
def _get_active(self) -> PomodoroSession | None:
stmt = (
select(PomodoroSession)
.where(PomodoroSession.status.in_(("running", "paused")))
.order_by(PomodoroSession.id.desc())
.limit(1)
)
return self.db.scalar(stmt)
def _elapsed(self, session: PomodoroSession) -> int:
elapsed = session.elapsed_seconds
if session.status == "running" and session.started_at:
started = session.started_at
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
delta = _utcnow() - started
elapsed += int(delta.total_seconds())
return elapsed
def _remaining(self, session: PomodoroSession) -> int:
total = session.duration_min * 60
return max(0, total - self._elapsed(session))
def _try_auto_complete(self, session: PomodoroSession) -> bool:
if session.status != "running":
return False
if self._remaining(session) > 0:
return False
self._finalize_session(session, auto=True)
return True
def _finalize_session(
self,
session: PomodoroSession,
*,
auto: bool,
result: str = "",
completed: bool | None = None,
cancelled: bool = False,
) -> None:
session.elapsed_seconds = self._elapsed(session)
session.started_at = None
session.finished_at = _utcnow()
session.completion_notified = False
session.result = result or None
if cancelled:
session.status = "cancelled"
session.completed = False
elif completed is not None:
session.status = "completed"
session.completed = completed
else:
session.status = "completed"
session.completed = True
self.db.commit()
self.db.refresh(session)
def _start_phase(
self,
phase: str,
*,
duration_min: int | None = None,
task_note: str | None = None,
) -> PomodoroSession:
active = self._get_active()
if active:
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
cycle = self.cycle.get()
if task_note is not None:
cycle.task_note = task_note
elif phase == PHASE_WORK and not cycle.task_note:
cycle.task_note = ""
duration = duration_min or self.cycle.duration_for_phase(phase, cycle)
note = task_note if task_note is not None else cycle.task_note
session = PomodoroSession(
status="running",
phase=phase,
duration_min=duration,
task_note=note,
started_at=_utcnow(),
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return session
def _to_status_dict(self, session: PomodoroSession | None) -> dict:
cycle_dict = self.cycle.to_dict()
if not session:
return {
"status": "idle",
"phase": PHASE_WORK,
"duration_min": cycle_dict["work_duration_min"],
"task_note": cycle_dict["task_note"],
"elapsed_seconds": 0,
"remaining_seconds": 0,
"session_id": None,
"cycle": cycle_dict,
}
elapsed = self._elapsed(session)
total = session.duration_min * 60
remaining = max(0, total - elapsed)
return {
"status": session.status,
"phase": session.phase,
"duration_min": session.duration_min,
"task_note": session.task_note,
"elapsed_seconds": elapsed,
"remaining_seconds": remaining,
"session_id": session.id,
"started_at": session.started_at.isoformat() if session.started_at else None,
"finished_at": session.finished_at.isoformat() if session.finished_at else None,
"cycle": cycle_dict,
}
def get_status(self) -> dict:
active = self._get_active()
if active:
self._try_auto_complete(active)
active = self._get_active()
return self._to_status_dict(active)
def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict:
session = self._start_phase(
PHASE_WORK,
duration_min=duration_min,
task_note=task_note,
)
return self._to_status_dict(session)
def start_short_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min)
return self._to_status_dict(session)
def start_long_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min)
return self._to_status_dict(session)
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
return self.start_work(duration_min=duration_min, task_note=task_note)
def pause(self) -> dict:
session = self._get_active()
if not session or session.status != "running":
raise ValueError("Нет активного запущенного таймера.")
session.elapsed_seconds = self._elapsed(session)
session.status = "paused"
session.paused_at = _utcnow()
session.started_at = None
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def resume(self) -> dict:
session = self._get_active()
if not session or session.status != "paused":
raise ValueError("Нет таймера на паузе.")
session.status = "running"
session.started_at = _utcnow()
session.paused_at = None
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def stop(self, result: str = "", completed: bool = False) -> dict:
session = self._get_active()
if not session:
raise ValueError("Нет активного таймера.")
if completed:
self._finalize_session(session, auto=False, result=result, completed=True)
else:
self._finalize_session(session, auto=False, result=result, cancelled=True)
session.completion_notified = True
self.db.commit()
return self._to_status_dict(None)
def reset_cycle(self, clear_task: bool = False) -> dict:
active = self._get_active()
if active:
self._finalize_session(active, auto=False, cancelled=True)
active.completion_notified = True
self.db.commit()
cycle = self.cycle.reset(clear_task=clear_task)
status = self._to_status_dict(None)
status["cycle"] = cycle
return status
def skip_phase(self) -> dict:
session = self._get_active()
if not session:
raise ValueError("Нет активного таймера.")
self._finalize_session(session, auto=True)
return self._to_status_dict(None)
def get_pending_completions(self) -> list[PomodoroSession]:
stmt = (
select(PomodoroSession)
.where(
PomodoroSession.status == "completed",
PomodoroSession.completed.is_(True),
PomodoroSession.completion_notified.is_(False),
)
.order_by(PomodoroSession.id.asc())
)
return list(self.db.scalars(stmt))
def mark_notified(self, session: PomodoroSession) -> None:
session.completion_notified = True
self.db.commit()
def advance_after_completion(self, session: PomodoroSession) -> dict | None:
"""Update cycle counters and auto-start next phase. Returns new status or None."""
phase = session.phase
cycle = self.cycle.get()
if phase == PHASE_WORK:
next_phase = self.cycle.on_work_completed()
elif phase == PHASE_SHORT_BREAK:
next_phase = PHASE_WORK
elif phase == PHASE_LONG_BREAK:
self.cycle.on_long_break_completed()
next_phase = None
else:
next_phase = None
if not cycle.auto_advance or next_phase is None:
return None
new_session = self._start_phase(next_phase)
return self._to_status_dict(new_session)
def history(self, limit: int = 20) -> list[dict]:
stmt = (
select(PomodoroSession)
.where(PomodoroSession.status.in_(("completed", "cancelled")))
.order_by(PomodoroSession.finished_at.desc())
.limit(limit)
)
sessions = self.db.scalars(stmt).all()
return [
{
"id": s.id,
"status": s.status,
"phase": s.phase,
"duration_min": s.duration_min,
"task_note": s.task_note,
"result": s.result,
"completed": s.completed,
"elapsed_seconds": s.elapsed_seconds,
"finished_at": s.finished_at.isoformat() if s.finished_at else None,
}
for s in sessions
]
+38 -41
View File
@@ -1,41 +1,38 @@
import asyncio
import logging
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import User
from app.pomodoro.completion import PomodoroCompletionHandler
from app.pomodoro.service import PomodoroService
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 2
async def pomodoro_watcher_loop() -> None:
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Pomodoro watcher error")
async def _tick() -> None:
db = SessionLocal()
try:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
for user in users:
service = PomodoroService(db, user.id)
service.get_status()
pending = service.get_pending_completions()
if not pending:
continue
handler = PomodoroCompletionHandler(db, user.id)
for session in pending:
await handler.process(session)
finally:
db.close()
import asyncio
import logging
from app.db.base import SessionLocal
from app.pomodoro.completion import PomodoroCompletionHandler
from app.pomodoro.service import PomodoroService
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 2
async def pomodoro_watcher_loop() -> None:
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Pomodoro watcher error")
async def _tick() -> None:
db = SessionLocal()
try:
service = PomodoroService(db)
service.get_status()
pending = service.get_pending_completions()
if not pending:
return
handler = PomodoroCompletionHandler(db)
for session in pending:
await handler.process(session)
finally:
db.close()
+133 -155
View File
@@ -1,155 +1,133 @@
import time
from typing import Any
from sqlalchemy.orm import Session
from app.config import get_settings
from app.integrations.taiga import TaigaClient
from app.projects.service import ProjectService
MAX_PROJECTS_IN_CONTEXT = 20
MAX_OPEN_PER_PROJECT = 8
PROJECTS_CACHE_SEC = 120
_cache: dict[int, dict[str, Any]] = {}
def invalidate_projects_snapshot_cache(user_id: int | None = None) -> None:
if user_id is None:
_cache.clear()
else:
_cache.pop(user_id, None)
def get_projects_snapshot(db: Session, user_id: int, *, force: bool = False) -> dict[str, Any]:
now = time.time()
entry = _cache.get(user_id)
if not force and entry and now < entry.get("expires_at", 0):
return entry["data"]
snapshot = _fetch_projects_snapshot(db, user_id)
_cache[user_id] = {"data": snapshot, "expires_at": now + PROJECTS_CACHE_SEC}
return snapshot
def _fetch_projects_snapshot(db: Session, user_id: int) -> dict[str, Any]:
settings = get_settings()
service = ProjectService(db, user_id)
if not settings.taiga_configured:
return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
projects = service.list_projects()
if not projects:
try:
projects = service.sync_taiga_projects()
except Exception as exc:
return {
"configured": True,
"projects": [],
"open_items": [],
"taiga_open": [],
"error": str(exc),
}
open_items = service.list_work_items(limit=15, status="open")
taiga_open: list[dict[str, Any]] = []
fetch_error: str | None = None
try:
client = TaigaClient()
for proj in projects[:MAX_PROJECTS_IN_CONTEXT]:
stories = client.list_open_userstories(
proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT
)
tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT)
taiga_open.append(
{
"slug": proj["slug"],
"name": proj["name"],
"stories": [
{
"ref": s.get("ref"),
"subject": s.get("subject", "")[:120],
}
for s in stories
],
"tasks": [
{
"ref": t.get("ref"),
"subject": t.get("subject", "")[:120],
}
for t in tasks
],
}
)
except Exception as exc:
fetch_error = str(exc)
return {
"configured": True,
"projects": projects,
"open_items": open_items,
"taiga_open": taiga_open,
"error": fetch_error,
}
def format_projects_context(snapshot: dict[str, Any]) -> str:
if not snapshot.get("configured"):
return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)."
lines = ["[Проекты и задачи — снимок на начало ответа]"]
if snapshot.get("error"):
lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}")
projects = snapshot.get("projects") or []
if not projects:
lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.")
else:
lines.append(f"Проекты Taiga ({len(projects)}):")
for p in projects[:MAX_PROJECTS_IN_CONTEXT]:
gitea = (
f"{p.get('gitea_owner')}/{p.get('gitea_repo')}"
if p.get("gitea_configured")
else "Gitea не привязан"
)
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}")
taiga_open = snapshot.get("taiga_open") or []
if taiga_open:
lines.append("")
lines.append("Открытые задачи в Taiga (live):")
for block in taiga_open:
stories = block.get("stories") or []
tasks = block.get("tasks") or []
if not stories and not tasks:
lines.append(f" `{block.get('slug')}`: нет открытых")
continue
lines.append(f" `{block.get('slug')}`:")
for story in stories:
lines.append(f" story #{story.get('ref')} {story.get('subject')}")
for task in tasks:
lines.append(f" task #{task.get('ref')} {task.get('subject')}")
open_items = snapshot.get("open_items") or []
if open_items:
lines.append("")
lines.append("Work items созданные ассистентом (локальная БД):")
for item in open_items[:10]:
gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else ""
lines.append(
f"- #{item.get('taiga_ref')} {item.get('title')} "
f"({item.get('taiga_slug')}{gitea_part})"
)
lines.append("")
lines.append(
"Правила: "
"«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. "
"list_work_items — только созданные через ассистента. "
"Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. "
"create_work_item — для новых фич/багов из вольного текста."
)
return "\n".join(lines)
from typing import Any
from sqlalchemy.orm import Session
from app.config import get_settings
from app.integrations.taiga import TaigaClient
from app.projects.service import ProjectService
MAX_PROJECTS_IN_CONTEXT = 20
MAX_OPEN_PER_PROJECT = 8
def get_projects_snapshot(db: Session) -> dict[str, Any]:
settings = get_settings()
service = ProjectService(db)
if not settings.taiga_configured:
return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
projects = service.list_projects()
if not projects:
try:
projects = service.sync_taiga_projects()
except Exception as exc:
return {
"configured": True,
"projects": [],
"open_items": [],
"taiga_open": [],
"error": str(exc),
}
open_items = service.list_work_items(limit=15, status="open")
taiga_open: list[dict[str, Any]] = []
fetch_error: str | None = None
try:
client = TaigaClient()
for proj in projects[:MAX_PROJECTS_IN_CONTEXT]:
stories = client.list_open_userstories(
proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT
)
tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT)
taiga_open.append(
{
"slug": proj["slug"],
"name": proj["name"],
"stories": [
{
"ref": s.get("ref"),
"subject": s.get("subject", "")[:120],
}
for s in stories
],
"tasks": [
{
"ref": t.get("ref"),
"subject": t.get("subject", "")[:120],
}
for t in tasks
],
}
)
except Exception as exc:
fetch_error = str(exc)
return {
"configured": True,
"projects": projects,
"open_items": open_items,
"taiga_open": taiga_open,
"error": fetch_error,
}
def format_projects_context(snapshot: dict[str, Any]) -> str:
if not snapshot.get("configured"):
return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)."
lines = ["[Проекты и задачи — снимок на начало ответа]"]
if snapshot.get("error"):
lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}")
projects = snapshot.get("projects") or []
if not projects:
lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.")
else:
lines.append(f"Проекты Taiga ({len(projects)}):")
for p in projects[:MAX_PROJECTS_IN_CONTEXT]:
gitea = (
f"{p.get('gitea_owner')}/{p.get('gitea_repo')}"
if p.get("gitea_configured")
else "Gitea не привязан"
)
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}")
taiga_open = snapshot.get("taiga_open") or []
if taiga_open:
lines.append("")
lines.append("Открытые задачи в Taiga (live):")
for block in taiga_open:
stories = block.get("stories") or []
tasks = block.get("tasks") or []
if not stories and not tasks:
lines.append(f" `{block.get('slug')}`: нет открытых")
continue
lines.append(f" `{block.get('slug')}`:")
for story in stories:
lines.append(f" story #{story.get('ref')} {story.get('subject')}")
for task in tasks:
lines.append(f" task #{task.get('ref')} {task.get('subject')}")
open_items = snapshot.get("open_items") or []
if open_items:
lines.append("")
lines.append("Work items созданные ассистентом (локальная БД):")
for item in open_items[:10]:
gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else ""
lines.append(
f"- #{item.get('taiga_ref')} {item.get('title')} "
f"({item.get('taiga_slug')}{gitea_part})"
)
lines.append("")
lines.append(
"Правила: "
"«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. "
"list_work_items — только созданные через ассистента. "
"Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. "
"create_work_item — для новых фич/багов из вольного текста."
)
return "\n".join(lines)
+450 -476
View File
@@ -1,476 +1,450 @@
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.models import ProjectBinding, TaigaProject, WorkItem
from app.integrations.gitea import GiteaClient
from app.integrations.taiga import TaigaClient
from app.projects.commit_parser import parse_commit_message
from app.projects.structuring import (
format_gitea_body,
format_story_description,
slugify_branch,
structure_work_item,
)
class ProjectService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self.settings = get_settings()
def sync_taiga_projects(self) -> list[dict[str, Any]]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD")
client = TaigaClient()
remote = client.list_projects()
now = datetime.now(timezone.utc)
for item in remote:
slug = item.get("slug") or ""
if not slug:
continue
existing = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == slug)
)
if existing:
existing.name = item.get("name", slug)
existing.taiga_id = item["id"]
existing.synced_at = now
else:
self.db.add(
TaigaProject(
taiga_id=item["id"],
name=item.get("name", slug),
slug=slug,
synced_at=now,
)
)
self.db.commit()
return self.list_projects()
def list_projects(self) -> list[dict[str, Any]]:
stmt = (
select(TaigaProject, ProjectBinding)
.outerjoin(
ProjectBinding,
(ProjectBinding.taiga_slug == TaigaProject.slug)
& (ProjectBinding.user_id == self.user_id),
)
.order_by(TaigaProject.name)
)
rows = self.db.execute(stmt).all()
result = []
for taiga_proj, binding in rows:
result.append(
{
"taiga_id": taiga_proj.taiga_id,
"name": taiga_proj.name,
"slug": taiga_proj.slug,
"gitea_owner": binding.gitea_owner if binding else "",
"gitea_repo": binding.gitea_repo if binding else "",
"default_branch": binding.default_branch if binding else "main",
"gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo),
}
)
return result
def bind_gitea(
self,
taiga_slug: str,
gitea_owner: str,
gitea_repo: str,
default_branch: str = "main",
) -> dict[str, Any]:
if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)):
raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.")
binding = self.db.scalar(
select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_slug)
)
if binding:
binding.gitea_owner = gitea_owner
binding.gitea_repo = gitea_repo
binding.default_branch = default_branch
else:
binding = ProjectBinding(
user_id=self.user_id,
taiga_slug=taiga_slug,
gitea_owner=gitea_owner,
gitea_repo=gitea_repo,
default_branch=default_branch,
)
self.db.add(binding)
self.db.commit()
for proj in self.list_projects():
if proj["slug"] == taiga_slug:
return proj
raise ValueError("Binding failed")
def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]:
projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all()
if not projects:
raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.")
taiga_proj: TaigaProject | None = None
if slug:
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == slug)
)
if not taiga_proj:
raise ValueError(f"Проект '{slug}' не найден")
else:
taiga_proj = projects[0]
binding = self.db.scalar(
select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_proj.slug)
)
return taiga_proj, binding
async def create_work_item(
self, raw_text: str, project_slug: str | None = None
) -> dict[str, Any]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена")
project_list = self.list_projects()
if not project_list:
self.sync_taiga_projects()
project_list = self.list_projects()
structured = await structure_work_item(raw_text, project_list)
slug = project_slug or structured.get("project_slug")
taiga_proj, binding = self._resolve_project(slug)
if binding and not (binding.gitea_owner and binding.gitea_repo):
binding = None
taiga = TaigaClient()
title = (structured.get("title") or raw_text).strip()[:500]
description = format_story_description(structured, raw_text)
tags = structured.get("tags") or []
issue_type = structured.get("issue_type", "feature")
if issue_type == "bug" and "bug" not in [t.lower() for t in tags]:
tags.append("bug")
story = taiga.create_userstory(
taiga_proj.taiga_id,
title,
description,
tags=tags,
)
subtasks = []
for child in structured.get("children") or []:
if isinstance(child, dict):
subtasks.append(
taiga.create_task(
taiga_proj.taiga_id,
story["id"],
child.get("title", "Подзадача"),
child.get("description", ""),
)
)
branch = f"feature/{story['ref']}-{slugify_branch(title)}"
gitea_issue_number = None
gitea_url = ""
if binding and self.settings.gitea_configured:
gitea = GiteaClient()
gitea_body = format_gitea_body(
structured,
raw_text,
story["ref"],
taiga.story_url(taiga_proj.taiga_id, story["ref"]),
branch,
)
if issue_type:
gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}"
issue = gitea.create_issue(
binding.gitea_owner,
binding.gitea_repo,
title,
gitea_body,
)
gitea_issue_number = issue["number"]
gitea_url = gitea.issue_url(
binding.gitea_owner, binding.gitea_repo, gitea_issue_number
)
work_item = WorkItem(
user_id=self.user_id,
taiga_slug=taiga_proj.slug,
taiga_project_id=taiga_proj.taiga_id,
taiga_story_id=story["id"],
taiga_story_ref=story["ref"],
gitea_owner=binding.gitea_owner if binding else "",
gitea_repo=binding.gitea_repo if binding else "",
gitea_issue_number=gitea_issue_number,
suggested_branch=branch,
raw_text=raw_text,
title=title,
status="open",
)
self.db.add(work_item)
self.db.commit()
self.db.refresh(work_item)
return {
"ok": True,
"work_item_id": work_item.id,
"taiga": {
"ref": story["ref"],
"id": story["id"],
"subject": story["subject"],
"url": taiga.story_url(taiga_proj.taiga_id, story["ref"]),
},
"gitea": {
"number": gitea_issue_number,
"url": gitea_url,
},
"branch": branch,
"issue_type": issue_type,
"subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks],
"questions": structured.get("questions") or [],
"project_slug": taiga_proj.slug,
}
def process_push(
self, owner: str, repo: str, commits: list[dict[str, Any]]
) -> list[dict[str, Any]]:
if not self.settings.taiga_configured:
return []
taiga = TaigaClient()
gitea = GiteaClient() if self.settings.gitea_configured else None
results: list[dict[str, Any]] = []
for commit in commits:
message = commit.get("message", "")
parsed = parse_commit_message(message)
sha = commit.get("id", "")[:8]
gitea_refs = set(parsed["gitea"])
taiga_story_refs = set(parsed["taiga_story"])
taiga_task_refs = set(parsed["taiga_task"])
linked_items = self.db.scalars(
select(WorkItem).where(
WorkItem.user_id == self.user_id,
WorkItem.gitea_owner == owner,
WorkItem.gitea_repo == repo,
WorkItem.status == "open",
)
).all()
for item in linked_items:
if item.gitea_issue_number and item.gitea_issue_number in gitea_refs:
taiga_story_refs.add(item.taiga_story_ref)
if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number:
gitea_refs.add(item.gitea_issue_number)
for gitea_num in gitea_refs:
if gitea:
try:
gitea.close_issue(owner, repo, gitea_num)
except Exception as exc:
results.append({"error": f"gitea #{gitea_num}: {exc}"})
continue
for item in linked_items:
if item.gitea_issue_number == gitea_num:
try:
self._close_work_item(item, taiga)
results.append(
{
"commit": sha,
"closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}",
}
)
except Exception as exc:
results.append(
{"error": f"work item {item.id} (gitea #{gitea_num}): {exc}"}
)
for ref in taiga_story_refs:
project_id = self._project_id_for_ref(owner, repo, ref, linked_items)
if not project_id:
continue
story = taiga.get_by_ref(project_id, ref, kind="userstory")
if story and not story.get("is_closed"):
try:
taiga.close_userstory(story["id"], project_id)
results.append({"commit": sha, "closed": f"taiga #{ref}"})
except Exception as exc:
results.append({"error": f"taiga #{ref}: {exc}"})
for item in linked_items:
if item.taiga_story_ref == ref and item.status != "closed":
try:
self._close_work_item(item, taiga, close_gitea=bool(gitea))
except Exception as exc:
results.append(
{"error": f"work item {item.id} (taiga #{ref}): {exc}"}
)
for ref in taiga_task_refs:
binding = self.db.scalar(
select(ProjectBinding).where(
ProjectBinding.user_id == self.user_id,
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo,
)
)
if not binding:
continue
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
)
if not taiga_proj:
continue
task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task")
if task and not task.get("is_closed"):
try:
taiga.close_task(task["id"], taiga_proj.taiga_id)
results.append({"commit": sha, "closed": f"taiga task #{ref}"})
except Exception as exc:
results.append({"error": f"taiga task #{ref}: {exc}"})
self.db.commit()
return results
def _project_id_for_ref(
self,
owner: str,
repo: str,
ref: int,
items: list[WorkItem],
) -> int | None:
for item in items:
if item.taiga_story_ref == ref:
return item.taiga_project_id
binding = self.db.scalar(
select(ProjectBinding).where(
ProjectBinding.user_id == self.user_id,
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo,
)
)
if binding:
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
)
return taiga_proj.taiga_id if taiga_proj else None
return None
def _close_work_item(
self,
item: WorkItem,
taiga: TaigaClient,
*,
close_gitea: bool = True,
) -> None:
if item.status == "closed":
return
story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory")
if story:
taiga.close_userstory(story["id"], item.taiga_project_id)
if (
close_gitea
and item.gitea_issue_number
and self.settings.gitea_configured
):
GiteaClient().close_issue(
item.gitea_owner, item.gitea_repo, item.gitea_issue_number
)
item.status = "closed"
item.closed_at = datetime.now(timezone.utc)
def list_taiga_open_tasks(
self,
project_slug: str | None = None,
limit: int = 20,
) -> dict[str, Any]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена")
projects = self.list_projects()
if not projects:
projects = self.sync_taiga_projects()
if project_slug:
projects = [p for p in projects if p["slug"] == project_slug]
if not projects:
raise ValueError(
f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects."
)
client = TaigaClient()
blocks: list[dict[str, Any]] = []
for proj in projects:
stories = client.list_open_userstories(proj["taiga_id"], limit=limit)
tasks = client.list_open_tasks(proj["taiga_id"], limit=limit)
blocks.append(
{
"slug": proj["slug"],
"name": proj["name"],
"taiga_id": proj["taiga_id"],
"stories": [
{
"ref": s.get("ref"),
"subject": s.get("subject", ""),
"url": client.story_url(proj["taiga_id"], s.get("ref", 0)),
}
for s in stories
],
"tasks": [
{
"ref": t.get("ref"),
"subject": t.get("subject", ""),
"user_story": t.get("user_story"),
}
for t in tasks
],
}
)
total_stories = sum(len(b["stories"]) for b in blocks)
total_tasks = sum(len(b["tasks"]) for b in blocks)
return {
"projects": blocks,
"total_stories": total_stories,
"total_tasks": total_tasks,
}
def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]:
stmt = select(WorkItem).where(WorkItem.user_id == self.user_id).order_by(WorkItem.created_at.desc()).limit(limit)
if status:
stmt = stmt.where(WorkItem.status == status)
items = self.db.scalars(stmt).all()
settings = get_settings()
return [
{
"id": i.id,
"title": i.title,
"status": i.status,
"taiga_slug": i.taiga_slug,
"taiga_ref": i.taiga_story_ref,
"gitea_issue": i.gitea_issue_number,
"branch": i.suggested_branch,
"taiga_url": f"{settings.taiga_public_url}/project/0/{i.taiga_project_id}/us/{i.taiga_story_ref}",
"gitea_url": (
f"{settings.gitea_public_url}/{i.gitea_owner}/{i.gitea_repo}/issues/{i.gitea_issue_number}"
if i.gitea_issue_number
else ""
),
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in items
]
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.models import ProjectBinding, TaigaProject, WorkItem
from app.integrations.gitea import GiteaClient
from app.integrations.taiga import TaigaClient
from app.projects.commit_parser import parse_commit_message
from app.projects.structuring import (
format_gitea_body,
format_story_description,
slugify_branch,
structure_work_item,
)
class ProjectService:
def __init__(self, db: Session):
self.db = db
self.settings = get_settings()
def sync_taiga_projects(self) -> list[dict[str, Any]]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD")
client = TaigaClient()
remote = client.list_projects()
now = datetime.now(timezone.utc)
for item in remote:
slug = item.get("slug") or ""
if not slug:
continue
existing = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == slug)
)
if existing:
existing.name = item.get("name", slug)
existing.taiga_id = item["id"]
existing.synced_at = now
else:
self.db.add(
TaigaProject(
taiga_id=item["id"],
name=item.get("name", slug),
slug=slug,
synced_at=now,
)
)
self.db.commit()
return self.list_projects()
def list_projects(self) -> list[dict[str, Any]]:
stmt = (
select(TaigaProject, ProjectBinding)
.outerjoin(ProjectBinding, ProjectBinding.taiga_slug == TaigaProject.slug)
.order_by(TaigaProject.name)
)
rows = self.db.execute(stmt).all()
result = []
for taiga_proj, binding in rows:
result.append(
{
"taiga_id": taiga_proj.taiga_id,
"name": taiga_proj.name,
"slug": taiga_proj.slug,
"gitea_owner": binding.gitea_owner if binding else "",
"gitea_repo": binding.gitea_repo if binding else "",
"default_branch": binding.default_branch if binding else "main",
"gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo),
}
)
return result
def bind_gitea(
self,
taiga_slug: str,
gitea_owner: str,
gitea_repo: str,
default_branch: str = "main",
) -> dict[str, Any]:
if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)):
raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.")
binding = self.db.scalar(
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_slug)
)
if binding:
binding.gitea_owner = gitea_owner
binding.gitea_repo = gitea_repo
binding.default_branch = default_branch
else:
binding = ProjectBinding(
taiga_slug=taiga_slug,
gitea_owner=gitea_owner,
gitea_repo=gitea_repo,
default_branch=default_branch,
)
self.db.add(binding)
self.db.commit()
for proj in self.list_projects():
if proj["slug"] == taiga_slug:
return proj
raise ValueError("Binding failed")
def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]:
projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all()
if not projects:
raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.")
taiga_proj: TaigaProject | None = None
if slug:
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == slug)
)
if not taiga_proj:
raise ValueError(f"Проект '{slug}' не найден")
else:
taiga_proj = projects[0]
binding = self.db.scalar(
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_proj.slug)
)
return taiga_proj, binding
async def create_work_item(
self, raw_text: str, project_slug: str | None = None
) -> dict[str, Any]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена")
project_list = self.list_projects()
if not project_list:
self.sync_taiga_projects()
project_list = self.list_projects()
structured = await structure_work_item(raw_text, project_list)
slug = project_slug or structured.get("project_slug")
taiga_proj, binding = self._resolve_project(slug)
if binding and not (binding.gitea_owner and binding.gitea_repo):
binding = None
taiga = TaigaClient()
title = (structured.get("title") or raw_text).strip()[:500]
description = format_story_description(structured, raw_text)
tags = structured.get("tags") or []
issue_type = structured.get("issue_type", "feature")
if issue_type == "bug" and "bug" not in [t.lower() for t in tags]:
tags.append("bug")
story = taiga.create_userstory(
taiga_proj.taiga_id,
title,
description,
tags=tags,
)
subtasks = []
for child in structured.get("children") or []:
if isinstance(child, dict):
subtasks.append(
taiga.create_task(
taiga_proj.taiga_id,
story["id"],
child.get("title", "Подзадача"),
child.get("description", ""),
)
)
branch = f"feature/{story['ref']}-{slugify_branch(title)}"
gitea_issue_number = None
gitea_url = ""
if binding and self.settings.gitea_configured:
gitea = GiteaClient()
gitea_body = format_gitea_body(
structured,
raw_text,
story["ref"],
taiga.story_url(taiga_proj.taiga_id, story["ref"]),
branch,
)
if issue_type:
gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}"
issue = gitea.create_issue(
binding.gitea_owner,
binding.gitea_repo,
title,
gitea_body,
)
gitea_issue_number = issue["number"]
gitea_url = gitea.issue_url(
binding.gitea_owner, binding.gitea_repo, gitea_issue_number
)
work_item = WorkItem(
taiga_slug=taiga_proj.slug,
taiga_project_id=taiga_proj.taiga_id,
taiga_story_id=story["id"],
taiga_story_ref=story["ref"],
gitea_owner=binding.gitea_owner if binding else "",
gitea_repo=binding.gitea_repo if binding else "",
gitea_issue_number=gitea_issue_number,
suggested_branch=branch,
raw_text=raw_text,
title=title,
status="open",
)
self.db.add(work_item)
self.db.commit()
self.db.refresh(work_item)
return {
"ok": True,
"work_item_id": work_item.id,
"taiga": {
"ref": story["ref"],
"id": story["id"],
"subject": story["subject"],
"url": taiga.story_url(taiga_proj.taiga_id, story["ref"]),
},
"gitea": {
"number": gitea_issue_number,
"url": gitea_url,
},
"branch": branch,
"issue_type": issue_type,
"subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks],
"questions": structured.get("questions") or [],
"project_slug": taiga_proj.slug,
}
def process_push(
self, owner: str, repo: str, commits: list[dict[str, Any]]
) -> list[dict[str, Any]]:
if not self.settings.taiga_configured:
return []
taiga = TaigaClient()
gitea = GiteaClient() if self.settings.gitea_configured else None
results: list[dict[str, Any]] = []
for commit in commits:
message = commit.get("message", "")
parsed = parse_commit_message(message)
sha = commit.get("id", "")[:8]
gitea_refs = set(parsed["gitea"])
taiga_story_refs = set(parsed["taiga_story"])
taiga_task_refs = set(parsed["taiga_task"])
linked_items = self.db.scalars(
select(WorkItem).where(
WorkItem.gitea_owner == owner,
WorkItem.gitea_repo == repo,
WorkItem.status == "open",
)
).all()
for item in linked_items:
if item.gitea_issue_number and item.gitea_issue_number in gitea_refs:
taiga_story_refs.add(item.taiga_story_ref)
if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number:
gitea_refs.add(item.gitea_issue_number)
for gitea_num in gitea_refs:
if gitea:
try:
gitea.close_issue(owner, repo, gitea_num)
except Exception as exc:
results.append({"error": f"gitea #{gitea_num}: {exc}"})
continue
for item in linked_items:
if item.gitea_issue_number == gitea_num:
self._close_work_item(item, taiga)
results.append(
{
"commit": sha,
"closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}",
}
)
for ref in taiga_story_refs:
project_id = self._project_id_for_ref(owner, repo, ref, linked_items)
if not project_id:
continue
story = taiga.get_by_ref(project_id, ref, kind="userstory")
if story:
taiga.close_userstory(story["id"], project_id)
for item in linked_items:
if item.taiga_story_ref == ref:
self._close_work_item(item, taiga, close_gitea=bool(gitea))
results.append({"commit": sha, "closed": f"taiga #{ref}"})
for ref in taiga_task_refs:
binding = self.db.scalar(
select(ProjectBinding).where(
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo,
)
)
if not binding:
continue
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
)
if not taiga_proj:
continue
task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task")
if task:
taiga.close_task(task["id"], taiga_proj.taiga_id)
results.append({"commit": sha, "closed": f"taiga task #{ref}"})
self.db.commit()
return results
def _project_id_for_ref(
self,
owner: str,
repo: str,
ref: int,
items: list[WorkItem],
) -> int | None:
for item in items:
if item.taiga_story_ref == ref:
return item.taiga_project_id
binding = self.db.scalar(
select(ProjectBinding).where(
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo,
)
)
if binding:
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
)
return taiga_proj.taiga_id if taiga_proj else None
return None
def _close_work_item(
self,
item: WorkItem,
taiga: TaigaClient,
*,
close_gitea: bool = True,
) -> None:
if item.status == "closed":
return
story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory")
if story:
taiga.close_userstory(story["id"], item.taiga_project_id)
if (
close_gitea
and item.gitea_issue_number
and self.settings.gitea_configured
):
GiteaClient().close_issue(
item.gitea_owner, item.gitea_repo, item.gitea_issue_number
)
item.status = "closed"
item.closed_at = datetime.now(timezone.utc)
def list_taiga_open_tasks(
self,
project_slug: str | None = None,
limit: int = 20,
) -> dict[str, Any]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена")
projects = self.list_projects()
if not projects:
projects = self.sync_taiga_projects()
if project_slug:
projects = [p for p in projects if p["slug"] == project_slug]
if not projects:
raise ValueError(
f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects."
)
client = TaigaClient()
blocks: list[dict[str, Any]] = []
for proj in projects:
stories = client.list_open_userstories(proj["taiga_id"], limit=limit)
tasks = client.list_open_tasks(proj["taiga_id"], limit=limit)
blocks.append(
{
"slug": proj["slug"],
"name": proj["name"],
"taiga_id": proj["taiga_id"],
"stories": [
{
"ref": s.get("ref"),
"subject": s.get("subject", ""),
"url": client.story_url(proj["taiga_id"], s.get("ref", 0)),
}
for s in stories
],
"tasks": [
{
"ref": t.get("ref"),
"subject": t.get("subject", ""),
"user_story": t.get("user_story"),
}
for t in tasks
],
}
)
total_stories = sum(len(b["stories"]) for b in blocks)
total_tasks = sum(len(b["tasks"]) for b in blocks)
return {
"projects": blocks,
"total_stories": total_stories,
"total_tasks": total_tasks,
}
def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]:
stmt = select(WorkItem).order_by(WorkItem.created_at.desc()).limit(limit)
if status:
stmt = stmt.where(WorkItem.status == status)
items = self.db.scalars(stmt).all()
settings = get_settings()
return [
{
"id": i.id,
"title": i.title,
"status": i.status,
"taiga_slug": i.taiga_slug,
"taiga_ref": i.taiga_story_ref,
"gitea_issue": i.gitea_issue_number,
"branch": i.suggested_branch,
"taiga_url": f"{settings.taiga_public_url}/project/0/{i.taiga_project_id}/us/{i.taiga_story_ref}",
"gitea_url": (
f"{settings.gitea_public_url}/{i.gitea_owner}/{i.gitea_repo}/issues/{i.gitea_issue_number}"
if i.gitea_issue_number
else ""
),
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in items
]
-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