Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f1516c9c9 | |||
| 0f2827030b | |||
| a3f01cd850 | |||
| f2e98942ff | |||
| 0c8ab6018a | |||
| c8a9429bed | |||
| 66e1b0e29e | |||
| 41cbef61a9 | |||
| 4108d737e3 | |||
| 533f047e45 | |||
| 1aa39fc4b2 | |||
| b70ac1899c | |||
| 603fcc58e3 | |||
| f7cc238308 | |||
| 363aca293a | |||
| 52ab7e1ac4 | |||
| 54ed9ba791 | |||
| 0ccf19a1cc | |||
| b5a1831b8e | |||
| 481b93e84a | |||
| 06e09cd728 | |||
| 827f9016cd | |||
| e9762d7921 | |||
| 89158930ee | |||
| 905d756a25 | |||
| 320f7c7195 | |||
| 07e9ef6e04 | |||
| 8eb6505724 | |||
| 5844551038 | |||
| f407e41b6d | |||
| 73baf4dbe1 | |||
| d0bdd1e95c | |||
| 0b39692300 | |||
| c56471050c | |||
| 5a9d26fbf4 | |||
| 2c86a634bb | |||
| 19d8e50505 |
+131
-40
@@ -1,40 +1,131 @@
|
|||||||
# Server (internal bind inside containers)
|
# Server (internal bind inside containers)
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
BACKEND_INTERNAL_PORT=8080
|
BACKEND_INTERNAL_PORT=8080
|
||||||
FRONTEND_INTERNAL_PORT=80
|
FRONTEND_INTERNAL_PORT=80
|
||||||
|
|
||||||
# External ports on the host (docker compose publish)
|
# External ports on the host (docker compose publish)
|
||||||
BACKEND_PORT=8080
|
BACKEND_PORT=8080
|
||||||
FRONTEND_PORT=3080
|
FRONTEND_PORT=3080
|
||||||
VITE_DEV_PORT=5173
|
VITE_DEV_PORT=5173
|
||||||
|
|
||||||
# OpenRouter
|
# OpenRouter
|
||||||
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
||||||
OPENROUTER_MODEL=deepseek/deepseek-chat
|
OPENROUTER_MODEL=deepseek/deepseek-chat
|
||||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
# deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются:
|
||||||
|
# OPENROUTER_MODEL=deepseek/deepseek-v4-pro
|
||||||
# App
|
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
DATABASE_URL=sqlite:///./data/assistant.db
|
OPENROUTER_TOOLS_ENABLED=true
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
|
# none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning.
|
||||||
SYSTEM_PROMPT_PATH=./prompts/assistant.md
|
OPENROUTER_REASONING_EFFORT=none
|
||||||
|
# Vision (скриншоты Mi Fitness и др.)
|
||||||
# Taiga (on host :9000, nginx → taiga.grigowashere.ru)
|
OPENROUTER_VISION_MODEL=google/gemini-2.5-flash-lite
|
||||||
TAIGA_BASE_URL=http://host.docker.internal:9000
|
VISION_MAX_EDGE_PX=1280
|
||||||
TAIGA_USERNAME=your_taiga_user
|
VISION_JPEG_QUALITY=85
|
||||||
TAIGA_PASSWORD=your_taiga_password
|
VISION_DEBUG_ENABLED=true
|
||||||
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
|
VISION_MAX_IMAGES=8
|
||||||
|
# JSON-экстракция памяти отдельной моделью (если основная капризничает):
|
||||||
# Gitea (on host :3000, nginx → git.grigowashere.ru)
|
# MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat
|
||||||
GITEA_BASE_URL=http://host.docker.internal:3000
|
|
||||||
GITEA_TOKEN=your_gitea_api_token
|
# App
|
||||||
GITEA_PUBLIC_URL=https://git.grigowashere.ru
|
DATABASE_URL=sqlite:///./data/assistant.db
|
||||||
GITEA_WEBHOOK_SECRET=generate_a_random_secret
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
|
||||||
|
SYSTEM_PROMPT_PATH=./prompts/assistant.md
|
||||||
# Gitea webhook URL (configure in repo settings):
|
MEMORY_AUTO_EXTRACT=true
|
||||||
# http://127.0.0.1:8080/api/v1/webhooks/gitea
|
|
||||||
|
# Multi-user (API token auth)
|
||||||
REPOS_DIR=/data/repos
|
DEFAULT_USER_USERNAME=owner
|
||||||
|
DEFAULT_USER_DISPLAY_NAME=
|
||||||
# Vector DB (phase 3)
|
DEFAULT_API_TOKEN=change-me-to-long-random-string
|
||||||
QDRANT_PORT=6333
|
AUTH_REQUIRED=true
|
||||||
QDRANT_GRPC_PORT=6334
|
# Опционально для 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
|
||||||
|
WEATHER_FORECAST_DAYS=7
|
||||||
|
# Если локальный OpenMeteo отдаёт только temperature_2m — подставить публичный API
|
||||||
|
OPENMETEO_FALLBACK_URL=https://api.open-meteo.com
|
||||||
|
OPENMETEO_FALLBACK_ON_PARTIAL=true
|
||||||
|
|
||||||
|
# News RSS (comma-separated)
|
||||||
|
NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss
|
||||||
|
NEWS_CACHE_SEC=1800
|
||||||
|
NEWS_MAX_ITEMS=7
|
||||||
|
|
||||||
|
# Morning digest (Europe/Moscow or user profile timezone)
|
||||||
|
MORNING_DIGEST_ENABLED=true
|
||||||
|
MORNING_DIGEST_HOUR=8
|
||||||
|
MORNING_DIGEST_MINUTE=0
|
||||||
|
|
||||||
|
# ComfyUI on GPU PC (Anima split-model — как в aiChatBot)
|
||||||
|
COMFYUI_BASE_URL=http://192.168.1.109:8188
|
||||||
|
COMFYUI_ENABLED=true
|
||||||
|
# Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET.
|
||||||
|
COMFYUI_CHECKPOINT=
|
||||||
|
COMFYUI_UNET=anima-preview3-base.safetensors
|
||||||
|
COMFYUI_CLIP=qwen_3_06b_base.safetensors
|
||||||
|
COMFYUI_VAE=qwen_image_vae.safetensors
|
||||||
|
COMFYUI_STYLE_LORA=anima-preview-3-masterpieces-v5.safetensors
|
||||||
|
COMFYUI_STYLE_LORA_WEIGHT=0.7
|
||||||
|
COMFYUI_STEPS=30
|
||||||
|
COMFYUI_CFG=4
|
||||||
|
COMFYUI_SAMPLER=er_sde
|
||||||
|
COMFYUI_SCHEDULER=simple
|
||||||
|
COMFYUI_WIDTH=1024
|
||||||
|
COMFYUI_HEIGHT=720
|
||||||
|
COMFYUI_NEGATIVE_PROMPT=worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia
|
||||||
|
COMFYUI_ROFL_ENABLED=true
|
||||||
|
COMFYUI_ROFL_MAX_PER_DAY=1
|
||||||
|
COMFYUI_ROFL_PROBABILITY=0.15
|
||||||
|
COMFYUI_ROFL_MIN_INTERVAL_HOURS=12
|
||||||
|
GENERATED_MEDIA_DIR=./data/generated
|
||||||
|
|
||||||
|
# RP Chat (aiChatBot) — генерация картинок + sd-prompt; persona_id в карточке персонажа
|
||||||
|
RP_CHAT_BASE_URL=http://host.docker.internal:8201
|
||||||
|
RP_CHAT_ENABLED=true
|
||||||
|
RP_CHAT_TIMEOUT_SEC=300
|
||||||
|
|
||||||
|
# Netdata on server
|
||||||
|
NETDATA_BASE_URL=http://host.docker.internal:19999
|
||||||
|
NETDATA_PUBLIC_URL=
|
||||||
|
NETDATA_ALERTS_ENABLED=true
|
||||||
|
NETDATA_POLL_INTERVAL_SEC=120
|
||||||
|
|
||||||
|
# Vector DB (phase 3)
|
||||||
|
QDRANT_PORT=6333
|
||||||
|
QDRANT_GRPC_PORT=6334
|
||||||
|
|
||||||
|
# RAG / embeddings
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||||
|
RAG_ENABLED=true
|
||||||
|
RAG_TOP_K=8
|
||||||
|
MEMORY_FACTS_IN_CONTEXT=8
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Server (internal bind inside containers)
|
||||||
|
HOST=0.0.0.0
|
||||||
|
BACKEND_INTERNAL_PORT=8080
|
||||||
|
FRONTEND_INTERNAL_PORT=80
|
||||||
|
|
||||||
|
# External ports on the host (docker compose publish)
|
||||||
|
BACKEND_PORT=8080
|
||||||
|
FRONTEND_PORT=3080
|
||||||
|
VITE_DEV_PORT=5173
|
||||||
|
|
||||||
|
# OpenRouter
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1-your-key-here
|
||||||
|
OPENROUTER_MODEL=deepseek/deepseek-chat
|
||||||
|
# deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются:
|
||||||
|
# OPENROUTER_MODEL=deepseek/deepseek-v4-pro
|
||||||
|
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
OPENROUTER_TOOLS_ENABLED=true
|
||||||
|
# none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning.
|
||||||
|
OPENROUTER_REASONING_EFFORT=none
|
||||||
|
# JSON-экстракция памяти отдельной моделью (если основная капризничает):
|
||||||
|
# MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat
|
||||||
|
|
||||||
|
# App
|
||||||
|
DATABASE_URL=sqlite:///./data/assistant.db
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
|
||||||
|
SYSTEM_PROMPT_PATH=./prompts/assistant.md
|
||||||
|
MEMORY_AUTO_EXTRACT=true
|
||||||
|
|
||||||
|
# Fitness (wger + Open Food Facts — public HTTPS, no proxy)
|
||||||
|
WGER_BASE_URL=https://wger.de/api/v2
|
||||||
|
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
|
||||||
|
FITNESS_REMINDERS_ENABLED=true
|
||||||
|
REMINDERS_ENABLED=true
|
||||||
|
|
||||||
|
# Taiga (on host :9000, nginx → taiga.grigowashere.ru)
|
||||||
|
TAIGA_BASE_URL=http://host.docker.internal:9000
|
||||||
|
TAIGA_USERNAME=your_taiga_user
|
||||||
|
TAIGA_PASSWORD=your_taiga_password
|
||||||
|
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
|
||||||
|
|
||||||
|
# Gitea (on host :3000, nginx → git.grigowashere.ru)
|
||||||
|
GITEA_BASE_URL=http://host.docker.internal:3000
|
||||||
|
GITEA_TOKEN=your_gitea_api_token
|
||||||
|
GITEA_PUBLIC_URL=https://git.grigowashere.ru
|
||||||
|
GITEA_WEBHOOK_SECRET=generate_a_random_secret
|
||||||
|
|
||||||
|
# Gitea webhook URL (repo Settings → Webhooks):
|
||||||
|
# https://assistant.your-domain/api/v1/webhooks/gitea ← nginx → 127.0.0.1:BACKEND_PORT
|
||||||
|
# http://172.17.0.1:8202/api/v1/webhooks/gitea ← если Gitea в Docker (не 127.0.0.1!)
|
||||||
|
|
||||||
|
REPOS_DIR=/data/repos
|
||||||
|
|
||||||
|
# Homelab — GPU PC 192.168.1.109
|
||||||
|
OPENMETEO_BASE_URL=http://192.168.1.109:8085
|
||||||
|
WEATHER_LAT=59.9343
|
||||||
|
WEATHER_LON=30.3351
|
||||||
|
WEATHER_LOCATION_NAME=Санкт-Петербург
|
||||||
|
WEATHER_CACHE_SEC=300
|
||||||
|
|
||||||
|
# News RSS (comma-separated)
|
||||||
|
NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss
|
||||||
|
NEWS_CACHE_SEC=1800
|
||||||
|
NEWS_MAX_ITEMS=7
|
||||||
|
|
||||||
|
# Morning digest (Europe/Moscow or user profile timezone)
|
||||||
|
MORNING_DIGEST_ENABLED=true
|
||||||
|
MORNING_DIGEST_HOUR=8
|
||||||
|
MORNING_DIGEST_MINUTE=0
|
||||||
|
|
||||||
|
# ComfyUI on GPU PC (Anima split-model — как в aiChatBot)
|
||||||
|
COMFYUI_BASE_URL=http://192.168.1.109:8188
|
||||||
|
COMFYUI_ENABLED=true
|
||||||
|
# Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET.
|
||||||
|
COMFYUI_CHECKPOINT=
|
||||||
|
COMFYUI_UNET=anima-preview3-base.safetensors
|
||||||
|
COMFYUI_CLIP=qwen_3_06b_base.safetensors
|
||||||
|
COMFYUI_VAE=qwen_image_vae.safetensors
|
||||||
|
COMFYUI_STYLE_LORA=anima-preview-3-masterpieces-v5.safetensors
|
||||||
|
COMFYUI_STYLE_LORA_WEIGHT=0.7
|
||||||
|
COMFYUI_STEPS=30
|
||||||
|
COMFYUI_CFG=4
|
||||||
|
COMFYUI_SAMPLER=er_sde
|
||||||
|
COMFYUI_SCHEDULER=simple
|
||||||
|
COMFYUI_WIDTH=1024
|
||||||
|
COMFYUI_HEIGHT=720
|
||||||
|
COMFYUI_NEGATIVE_PROMPT=worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia
|
||||||
|
COMFYUI_ROFL_ENABLED=true
|
||||||
|
COMFYUI_ROFL_MAX_PER_DAY=1
|
||||||
|
COMFYUI_ROFL_PROBABILITY=0.15
|
||||||
|
COMFYUI_ROFL_MIN_INTERVAL_HOURS=12
|
||||||
|
GENERATED_MEDIA_DIR=./data/generated
|
||||||
|
|
||||||
|
# RP Chat (aiChatBot) — генерация картинок + sd-prompt; persona_id в карточке персонажа
|
||||||
|
RP_CHAT_BASE_URL=http://host.docker.internal:8201
|
||||||
|
RP_CHAT_ENABLED=true
|
||||||
|
RP_CHAT_TIMEOUT_SEC=300
|
||||||
|
|
||||||
|
# Netdata on server
|
||||||
|
NETDATA_BASE_URL=http://host.docker.internal:19999
|
||||||
|
NETDATA_PUBLIC_URL=
|
||||||
|
NETDATA_ALERTS_ENABLED=true
|
||||||
|
NETDATA_POLL_INTERVAL_SEC=120
|
||||||
|
|
||||||
|
# Vector DB (phase 3)
|
||||||
|
QDRANT_PORT=6333
|
||||||
|
QDRANT_GRPC_PORT=6334
|
||||||
Vendored
+137
@@ -0,0 +1,137 @@
|
|||||||
|
// 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
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ docker compose up --build
|
|||||||
- Web UI: http://localhost:${FRONTEND_PORT:-3080}
|
- Web UI: http://localhost:${FRONTEND_PORT:-3080}
|
||||||
- Healthcheck: http://localhost:8080/api/v1/health
|
- Healthcheck: http://localhost:8080/api/v1/health
|
||||||
|
|
||||||
|
**Prod за nginx:** при загрузке скриншотов возможна ошибка `413 Request Entity Too Large` — дефолтный лимит nginx 1 MB. На **host nginx** (Ubuntu перед docker) добавьте `client_max_body_size 64m;` в `server { }` и в `location /api/`. Пример: [`deploy/nginx-host-assistant.conf.example`](deploy/nginx-host-assistant.conf.example). После правки: `sudo nginx -t && sudo systemctl reload nginx`. Контейнер frontend тоже поднимает лимит в `frontend/nginx.conf` — пересоберите образ.
|
||||||
|
|
||||||
Порты в `.env`:
|
Порты в `.env`:
|
||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
| Переменная | По умолчанию | Назначение |
|
||||||
@@ -132,11 +134,16 @@ curl -X PUT http://localhost:8080/api/v1/projects/home-assistant/gitea \
|
|||||||
|
|
||||||
В репозитории: **Settings → Webhooks → Add Webhook**:
|
В репозитории: **Settings → Webhooks → Add Webhook**:
|
||||||
|
|
||||||
- URL: `http://127.0.0.1:8080/api/v1/webhooks/gitea`
|
- 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)
|
||||||
- Content type: `application/json`
|
- Content type: `application/json`
|
||||||
- Secret: значение `GITEA_WEBHOOK_SECRET`
|
- Secret: значение `GITEA_WEBHOOK_SECRET`
|
||||||
- Events: **Push**
|
- Events: **Push**
|
||||||
|
|
||||||
|
Проверка из контейнера Gitea: `docker exec gitea wget -qO- http://172.17.0.1:8202/api/v1/health`
|
||||||
|
Test delivery в Gitea должен вернуть **200**, не **0**.
|
||||||
|
|
||||||
### Автозакрытие по коммиту
|
### Автозакрытие по коммиту
|
||||||
|
|
||||||
В сообщении коммита:
|
В сообщении коммита:
|
||||||
@@ -160,11 +167,106 @@ frontend/ React + Vite, чат и таймер
|
|||||||
data/ SQLite БД (создаётся автоматически)
|
data/ SQLite БД (создаётся автоматически)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Память и контекст (фаза 3a)
|
||||||
|
|
||||||
|
Долгосрочная память в SQLite, без векторов:
|
||||||
|
|
||||||
|
| Слой | Что хранит |
|
||||||
|
|------|------------|
|
||||||
|
| **Профиль** | имя, timezone, language, notes |
|
||||||
|
| **Факты** | устойчивые знания с категорией и важностью |
|
||||||
|
| **Сводка чата** | краткое содержание длинной сессии |
|
||||||
|
|
||||||
|
В system prompt на каждый ответ: персонаж → **время** → память → фитнес → **погода** → помидоро → проекты.
|
||||||
|
История чата обрезается до 40 последних сообщений; раннее — в `session_summaries`.
|
||||||
|
|
||||||
|
**Автоизвлечение:** после каждого ответа LLM анализирует ход диалога и сохраняет
|
||||||
|
устойчивые факты (`source=auto`). Отключить: `MEMORY_AUTO_EXTRACT=false`.
|
||||||
|
|
||||||
|
**UI:** вкладка `/memory` — профиль, факты, JSON-снимок для отладки.
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
- `remember_fact` — «запомни, что…»
|
||||||
|
- `recall_memories` — поиск по памяти
|
||||||
|
- `forget_memory` — удалить факт по id
|
||||||
|
- `update_profile` — имя, часовой пояс и т.д.
|
||||||
|
- `update_session_summary` — сжать тему длинного чата
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
| Method | Path | Описание |
|
||||||
|
|--------|------|----------|
|
||||||
|
| GET | `/api/v1/memory` | снимок памяти (+ `?session_id=`) |
|
||||||
|
| GET/PUT | `/api/v1/profile` | профиль |
|
||||||
|
| GET/POST | `/api/v1/memory/facts` | список / создать факт |
|
||||||
|
| DELETE | `/api/v1/memory/facts/{id}` | забыть |
|
||||||
|
| PUT | `/api/v1/memory/sessions/{id}/summary` | сводка чата |
|
||||||
|
|
||||||
|
## Фитнес-трекер
|
||||||
|
|
||||||
|
Профиль, дневник (еда/вода/вес/тренировки), калькуляторы TDEE, LLM-оценка ккал/БЖУ,
|
||||||
|
lookup wger + Open Food Facts, напоминания в чат (`💪`), вкладка `/fitness`.
|
||||||
|
|
||||||
|
Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3».
|
||||||
|
|
||||||
|
## Списки покупок
|
||||||
|
|
||||||
|
Несколько списков, позиции с количеством, отметка «куплено». Вкладка `/shopping`, tools в чате (`add_shopping_items`, `list_shopping_lists`, …).
|
||||||
|
|
||||||
|
Чат: «добавь молоко и хлеб в продукты», «что в списке покупок», «отметь молоко купленным».
|
||||||
|
|
||||||
|
## Homelab API (фаза 4)
|
||||||
|
|
||||||
|
Интеграции с домашней инфраструктурой:
|
||||||
|
|
||||||
|
| Сервис | URL по умолчанию | Назначение |
|
||||||
|
|--------|------------------|------------|
|
||||||
|
| Open-Meteo | `http://192.168.1.109:8085` | Погода СПб в контексте и tool `get_weather` |
|
||||||
|
| ComfyUI | `http://192.168.1.109:8188` | fallback / рофл-watcher |
|
||||||
|
| RP Chat (aiChatBot) | `http://host.docker.internal:8201` | `generate_image`: sd-prompt + Anima; appearance в `/character` |
|
||||||
|
| Netdata | `http://host.docker.internal:19999` | Алерты warning/critical → notice в чат |
|
||||||
|
|
||||||
|
**Утренний дайджест** (`MORNING_DIGEST_HOUR=8`): погода + RSS (Habr, r/programming по умолчанию).
|
||||||
|
По запросу: «что на улице», «будет ли дождь» → `get_weather`; полный брифинг → `get_morning_briefing`.
|
||||||
|
|
||||||
|
Переменные — в `.env.example` (секция Homelab).
|
||||||
|
|
||||||
|
### Проверка доступности
|
||||||
|
|
||||||
|
В образе backend нет `curl`/`wget`. Удобнее всего — API-диагностика (из контейнера или с хоста):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:${BACKEND_PORT:-8202}/api/v1/homelab/status | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Или изнутри backend через Python:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backend python -c "
|
||||||
|
import httpx
|
||||||
|
for url in [
|
||||||
|
'http://192.168.1.109:8085/v1/forecast?latitude=59.93&longitude=30.33¤t=temperature_2m',
|
||||||
|
'http://192.168.1.109:8188/system_stats',
|
||||||
|
'http://host.docker.internal:19999/api/v1/info',
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
r = httpx.get(url, timeout=10)
|
||||||
|
print(url, '->', r.status_code, r.text[:120])
|
||||||
|
except Exception as e:
|
||||||
|
print(url, '-> ERROR', e)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
По умолчанию **Anima** (как в aiChatBot): `COMFYUI_UNET` + `COMFYUI_CLIP` + `COMFYUI_VAE` + style LoRA.
|
||||||
|
`COMFYUI_CHECKPOINT` оставь пустым. Для SD1.5/Pony — укажи checkpoint и очисти `COMFYUI_UNET`.
|
||||||
|
|
||||||
## Следующие фазы
|
## Следующие фазы
|
||||||
|
|
||||||
- RAG с Qdrant для документов
|
- RAG по файлам (Qdrant)
|
||||||
- Проактивные чаты по расписанию
|
- Telegram-бот
|
||||||
- Фитнес-трекер
|
- Taiga/fitness в утреннем дайджесте
|
||||||
|
- Графики веса, LLM-мотивация в напоминаниях
|
||||||
|
|
||||||
## Модель
|
## Модель
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
webhook test
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import pathlib
|
||||||
|
ROOT = pathlib.Path(".").resolve()
|
||||||
|
svc = ROOT / "app/fitness/service.py"
|
||||||
|
text = svc.read_text(encoding="utf-8")
|
||||||
|
old = """from app.db.models import (
|
||||||
|
BodyMetric,
|
||||||
|
FitnessProfile,
|
||||||
|
FitnessReminder,
|
||||||
|
FoodLog,
|
||||||
|
WaterLog,
|
||||||
|
WorkoutLog,
|
||||||
|
)
|
||||||
|
from app.fitness.calculators import compute_targets, one_rep_max"""
|
||||||
|
new = """from app.db.models import (
|
||||||
|
BodyMetric,
|
||||||
|
FitnessProfile,
|
||||||
|
FitnessReminder,
|
||||||
|
FoodLog,
|
||||||
|
StepLog,
|
||||||
|
WaterLog,
|
||||||
|
WorkoutLog,
|
||||||
|
)
|
||||||
|
from app.fitness.activity_budget import (
|
||||||
|
build_base_targets,
|
||||||
|
compute_activity_bonus,
|
||||||
|
estimate_workout_active_kcal,
|
||||||
|
scale_targets,
|
||||||
|
)
|
||||||
|
from app.fitness.calculators import compute_targets, one_rep_max"""
|
||||||
|
if old not in text:
|
||||||
|
raise SystemExit("import block missing")
|
||||||
|
text = text.replace(old, new, 1)
|
||||||
|
svc.write_text(text, encoding="utf-8")
|
||||||
|
print("ok imports")
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.api.schemas import MessageOut
|
||||||
|
|
||||||
|
|
||||||
|
class MessagesPageOut(BaseModel):
|
||||||
|
messages: list[MessageOut]
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationStatusOut(BaseModel):
|
||||||
|
active: bool
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import character, chat, health, pomodoro, projects, webhooks
|
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 = APIRouter(prefix="/api/v1")
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
api_router.include_router(homelab.router, tags=["homelab"])
|
||||||
api_router.include_router(character.router, tags=["character"])
|
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||||
api_router.include_router(projects.router, tags=["projects"])
|
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
||||||
api_router.include_router(webhooks.router, tags=["webhooks"])
|
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"])
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, reminders, shopping, webhooks
|
||||||
|
|
||||||
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
|
api_router.include_router(health.router, tags=["health"])
|
||||||
|
api_router.include_router(homelab.router, tags=["homelab"])
|
||||||
|
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||||
|
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
||||||
|
api_router.include_router(character.router, tags=["character"])
|
||||||
|
api_router.include_router(projects.router, tags=["projects"])
|
||||||
|
api_router.include_router(memory.router, tags=["memory"])
|
||||||
|
api_router.include_router(fitness.router, tags=["fitness"])
|
||||||
|
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
|
||||||
|
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
|
||||||
|
api_router.include_router(webhooks.router, tags=["webhooks"])
|
||||||
|
api_router.include_router(media.router, tags=["media"])
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.auth.service import create_user, find_user_by_token, user_to_dict
|
||||||
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
token: str = Field(min_length=8, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=2, max_length=64)
|
||||||
|
display_name: str = ""
|
||||||
|
token: str | None = Field(default=None, min_length=8, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
user = find_user_by_token(db, payload.token)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Неверный токен")
|
||||||
|
return {"ok": True, "user": user_to_dict(user), "token": payload.token.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
return {"ok": True, "user": user_to_dict(user)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
def list_users(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
rows = db.scalars(select(User).where(User.is_active.is_(True)).order_by(User.id)).all()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"users": [user_to_dict(row) for row in rows],
|
||||||
|
"current_user_id": user.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
def register_user(
|
||||||
|
payload: CreateUserRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
new_user, plain_token = create_user(
|
||||||
|
db,
|
||||||
|
username=payload.username,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
api_token=payload.token,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"user": user_to_dict(new_user),
|
||||||
|
"token": plain_token,
|
||||||
|
"created_by": user.username,
|
||||||
|
}
|
||||||
@@ -1,56 +1,80 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
from app.character.service import CharacterService
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
router = APIRouter()
|
from app.character.service import CharacterService
|
||||||
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
class CharacterCardData(BaseModel):
|
|
||||||
name: str = "Ассистент"
|
router = APIRouter()
|
||||||
description: str = ""
|
|
||||||
personality: str = ""
|
|
||||||
scenario: str = ""
|
class CharacterCardData(BaseModel):
|
||||||
first_mes: str = ""
|
name: str = "Ассистент"
|
||||||
mes_example: str = ""
|
description: str = ""
|
||||||
system_prompt: str = ""
|
personality: str = ""
|
||||||
post_history_instructions: str = ""
|
scenario: str = ""
|
||||||
tags: list[str] = Field(default_factory=list)
|
first_mes: str = ""
|
||||||
creator: str = ""
|
mes_example: str = ""
|
||||||
creator_notes: str = ""
|
system_prompt: str = ""
|
||||||
alternate_greetings: list[str] = Field(default_factory=list)
|
post_history_instructions: str = ""
|
||||||
character_version: str = "1.0"
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
creator: str = ""
|
||||||
|
creator_notes: str = ""
|
||||||
class CharacterCardV2(BaseModel):
|
alternate_greetings: list[str] = Field(default_factory=list)
|
||||||
spec: str = "chara_card_v2"
|
character_version: str = "1.0"
|
||||||
spec_version: str = "2.0"
|
appearance_tags: str = ""
|
||||||
data: CharacterCardData
|
appearance_prose: str = ""
|
||||||
|
lora_name: str = ""
|
||||||
|
lora_weight: float = 0.8
|
||||||
@router.get("/character")
|
rp_persona_id: str = ""
|
||||||
def get_character() -> dict[str, Any]:
|
sd_enabled: bool = True
|
||||||
return CharacterService().get_card()
|
|
||||||
|
|
||||||
|
class CharacterCardV2(BaseModel):
|
||||||
@router.put("/character")
|
spec: str = "chara_card_v2"
|
||||||
def update_character(payload: CharacterCardV2) -> dict[str, Any]:
|
spec_version: str = "2.0"
|
||||||
return CharacterService().save_card(payload.model_dump())
|
data: CharacterCardData
|
||||||
|
|
||||||
|
|
||||||
@router.get("/character/prompt")
|
@router.get("/character")
|
||||||
def get_character_prompt() -> dict[str, str]:
|
def get_character(
|
||||||
service = CharacterService()
|
db: Session = Depends(get_db),
|
||||||
return {
|
user: User = Depends(get_current_user),
|
||||||
"system_prompt": service.get_system_prompt(),
|
) -> dict[str, Any]:
|
||||||
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
|
return CharacterService(db, user.id).get_card()
|
||||||
}
|
|
||||||
|
|
||||||
|
@router.put("/character")
|
||||||
@router.post("/character/import")
|
def update_character(
|
||||||
def import_character(payload: dict[str, Any]) -> dict[str, Any]:
|
payload: CharacterCardV2,
|
||||||
if not payload:
|
db: Session = Depends(get_db),
|
||||||
raise HTTPException(status_code=400, detail="Empty card")
|
user: User = Depends(get_current_user),
|
||||||
return CharacterService().save_card(payload)
|
) -> 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)
|
||||||
|
|||||||
+252
-55
@@ -1,55 +1,252 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
import asyncio
|
||||||
from fastapi.responses import StreamingResponse
|
import json
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut
|
from fastapi.responses import StreamingResponse
|
||||||
from app.chat.service import ChatService
|
from sqlalchemy.orm import Session
|
||||||
from app.db.base import get_db
|
|
||||||
|
from app.api.chat_schemas import GenerationStatusOut, MessagesPageOut
|
||||||
router = APIRouter()
|
from app.api.schemas import (
|
||||||
|
MessageCreate,
|
||||||
|
SessionCreate,
|
||||||
@router.post("/sessions", response_model=SessionOut)
|
SessionDetailOut,
|
||||||
def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut:
|
SessionOut,
|
||||||
service = ChatService(db)
|
)
|
||||||
return service.create_session(title=payload.title)
|
from app.auth.deps import get_current_user
|
||||||
|
from app.chat.generation import (
|
||||||
|
GenerationBusyError,
|
||||||
@router.get("/sessions", response_model=list[SessionOut])
|
get_active_handle,
|
||||||
def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]:
|
is_generation_active,
|
||||||
service = ChatService(db)
|
start_generation,
|
||||||
return service.list_sessions()
|
subscribe_generation,
|
||||||
|
)
|
||||||
|
from app.chat.service import ChatService
|
||||||
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
|
from app.config import get_settings
|
||||||
def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut:
|
from app.db.base import get_db
|
||||||
service = ChatService(db)
|
from app.db.models import User
|
||||||
session = service.get_session(session_id)
|
from app.vision import VisionService, format_user_messages, vision_debug_payloads
|
||||||
if not session:
|
from app.vision.analyze import VisionUnavailableError
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
from app.vision.preprocess import prepare_image
|
||||||
return session
|
from app.vision.storage import format_upload_images_markdown, save_upload
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
@router.delete("/sessions/{session_id}")
|
|
||||||
def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
|
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||||
service = ChatService(db)
|
|
||||||
if not service.delete_session(session_id):
|
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
@router.post("/sessions", response_model=SessionOut)
|
||||||
return {"ok": True}
|
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.post("/sessions/{session_id}/messages")
|
|
||||||
async def send_message(
|
|
||||||
session_id: int,
|
@router.get("/sessions", response_model=list[SessionOut])
|
||||||
payload: MessageCreate,
|
def list_sessions(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[SessionOut]:
|
||||||
db: Session = Depends(get_db),
|
service = ChatService(db, user.id)
|
||||||
) -> StreamingResponse:
|
return service.list_sessions()
|
||||||
service = ChatService(db)
|
|
||||||
if not service.get_session(session_id):
|
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
@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:
|
||||||
async def event_stream():
|
service = ChatService(db, user.id)
|
||||||
async for chunk in service.stream_response(session_id, payload.content):
|
session = service.get_session(session_id)
|
||||||
yield chunk
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_form_uploads(form) -> list:
|
||||||
|
uploads: list = []
|
||||||
|
seen_ids: set[int] = set()
|
||||||
|
|
||||||
|
def _append(item) -> None:
|
||||||
|
if item is None or not hasattr(item, "read"):
|
||||||
|
return
|
||||||
|
item_id = id(item)
|
||||||
|
if item_id in seen_ids:
|
||||||
|
return
|
||||||
|
seen_ids.add(item_id)
|
||||||
|
uploads.append(item)
|
||||||
|
|
||||||
|
if hasattr(form, "getlist"):
|
||||||
|
for item in form.getlist("images"):
|
||||||
|
_append(item)
|
||||||
|
single = form.get("image")
|
||||||
|
_append(single)
|
||||||
|
return uploads
|
||||||
|
|
||||||
|
|
||||||
|
async def _analyze_upload(raw: bytes, *, caption: str, user_id: int):
|
||||||
|
prepared = prepare_image(raw)
|
||||||
|
filename = save_upload(prepared, user_id=user_id)
|
||||||
|
result = await VisionService().analyze_prepared(prepared, user_hint=caption)
|
||||||
|
return result, filename
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_message_request(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
) -> tuple[str, dict | None]:
|
||||||
|
content_type = (request.headers.get("content-type") or "").lower()
|
||||||
|
if "multipart/form-data" not in content_type:
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body") from exc
|
||||||
|
payload = MessageCreate.model_validate(body)
|
||||||
|
return payload.content, None
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
caption = str(form.get("content") or "").strip()
|
||||||
|
uploads = _collect_form_uploads(form)
|
||||||
|
if not uploads:
|
||||||
|
raise HTTPException(status_code=400, detail="Field 'images' or 'image' is required for multipart upload")
|
||||||
|
|
||||||
|
max_images = max(1, int(get_settings().vision_max_images))
|
||||||
|
if len(uploads) > max_images:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Too many images (max {max_images})",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_images: list[bytes] = []
|
||||||
|
for upload in uploads:
|
||||||
|
raw = await upload.read()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty image file")
|
||||||
|
mime = getattr(upload, "content_type", None) or "application/octet-stream"
|
||||||
|
if mime not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported image type: {mime}")
|
||||||
|
raw_images.append(raw)
|
||||||
|
|
||||||
|
try:
|
||||||
|
analyzed = await asyncio.gather(
|
||||||
|
*(_analyze_upload(raw, caption=caption, user_id=user_id) for raw in raw_images)
|
||||||
|
)
|
||||||
|
except VisionUnavailableError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
results = [item[0] for item in analyzed]
|
||||||
|
filenames = [item[1] for item in analyzed]
|
||||||
|
debug = vision_debug_payloads(results)
|
||||||
|
vision_text = format_user_messages(caption, results)
|
||||||
|
images_md = format_upload_images_markdown(user_id, filenames)
|
||||||
|
user_text = f"{images_md}\n\n{vision_text}" if images_md else vision_text
|
||||||
|
if not user_text.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Could not build message from image")
|
||||||
|
return user_text, debug
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/messages")
|
||||||
|
async def send_message(
|
||||||
|
session_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
service = ChatService(db, user.id)
|
||||||
|
if not service.get_session(session_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
if is_generation_active(session_id):
|
||||||
|
raise HTTPException(status_code=409, detail="Generation already in progress")
|
||||||
|
|
||||||
|
user_text, vision_debug = await _parse_message_request(request, user_id=user.id)
|
||||||
|
|
||||||
|
service.save_user_message(session_id, user_text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
handle = await start_generation(session_id, user.id, user_text)
|
||||||
|
except GenerationBusyError:
|
||||||
|
raise HTTPException(status_code=409, detail="Generation already in progress") from None
|
||||||
|
|
||||||
|
async def event_stream():
|
||||||
|
try:
|
||||||
|
if vision_debug:
|
||||||
|
yield ChatService._sse("vision", vision_debug)
|
||||||
|
async for chunk in subscribe_generation(handle):
|
||||||
|
yield chunk
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/context-preview")
|
||||||
|
def context_preview(
|
||||||
|
session_id: int,
|
||||||
|
query: str | None = None,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
service = ChatService(db, user.id)
|
||||||
|
return service.context_preview(session_id, query=query)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut
|
||||||
|
from app.chat.service import ChatService
|
||||||
|
from app.db.base import get_db
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions", response_model=SessionOut)
|
||||||
|
def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut:
|
||||||
|
service = ChatService(db)
|
||||||
|
return service.create_session(title=payload.title)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions", response_model=list[SessionOut])
|
||||||
|
def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]:
|
||||||
|
service = ChatService(db)
|
||||||
|
return service.list_sessions()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
|
||||||
|
def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut:
|
||||||
|
service = ChatService(db)
|
||||||
|
session = service.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sessions/{session_id}")
|
||||||
|
def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
|
||||||
|
service = ChatService(db)
|
||||||
|
if not service.delete_session(session_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/messages")
|
||||||
|
async def send_message(
|
||||||
|
session_id: int,
|
||||||
|
payload: MessageCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
service = ChatService(db)
|
||||||
|
if not service.get_session(session_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД.
|
||||||
|
service.save_user_message(session_id, payload.content)
|
||||||
|
|
||||||
|
async def event_stream():
|
||||||
|
async for chunk in service.stream_response(
|
||||||
|
session_id,
|
||||||
|
payload.content,
|
||||||
|
user_message_saved=True,
|
||||||
|
):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
|
from app.db.models import Document
|
||||||
|
from app.rag.ingest import ingest_document_file
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents")
|
||||||
|
def list_documents(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
|
||||||
|
docs = db.scalars(select(Document).where(Document.user_id == user.id).order_by(Document.created_at.desc())).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": d.id,
|
||||||
|
"title": d.title,
|
||||||
|
"filename": d.filename,
|
||||||
|
"size_bytes": d.size_bytes,
|
||||||
|
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||||
|
}
|
||||||
|
for d in docs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/documents/upload")
|
||||||
|
async def upload_document(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
title: str = Form(""),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
raw = await file.read()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty file")
|
||||||
|
try:
|
||||||
|
doc = await ingest_document_file(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
title=title.strip() or (file.filename or "document"),
|
||||||
|
filename=file.filename or "upload.txt",
|
||||||
|
raw_bytes=raw,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, "document": doc}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
|
from app.fitness.service import FitnessService
|
||||||
|
from app.fitness.structuring import structure_meal, structure_workout
|
||||||
|
from app.integrations.openfoodfacts import OpenFoodFactsClient
|
||||||
|
from app.integrations.wger import WgerClient
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
sex: str | None = None
|
||||||
|
age: int | None = None
|
||||||
|
height_cm: float | None = None
|
||||||
|
weight_kg: float | None = None
|
||||||
|
goal: str | None = None
|
||||||
|
target_weight_kg: float | None = None
|
||||||
|
neat_base_kcal: float | None = Field(default=None, ge=200, le=300)
|
||||||
|
activity_level: str | None = None
|
||||||
|
weekly_workouts: int | None = Field(default=None, ge=0, le=14)
|
||||||
|
baseline_steps: int | None = Field(default=None, ge=0)
|
||||||
|
baseline_workout_kcal: float | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class MealCreate(BaseModel):
|
||||||
|
text: str = Field(min_length=1)
|
||||||
|
meal_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WaterCreate(BaseModel):
|
||||||
|
amount_ml: int = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class WeightCreate(BaseModel):
|
||||||
|
weight_kg: float = Field(gt=0)
|
||||||
|
body_fat_pct: float | None = None
|
||||||
|
chest_cm: float | None = None
|
||||||
|
waist_cm: float | None = None
|
||||||
|
neck_cm: float | None = None
|
||||||
|
hip_cm: float | None = None
|
||||||
|
notes: str = ""
|
||||||
|
day: str | None = None
|
||||||
|
days_ago: int | None = Field(default=None, ge=0, le=90)
|
||||||
|
recorded_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BodyCompositionCalc(BaseModel):
|
||||||
|
weight_kg: float | None = None
|
||||||
|
height_cm: float | None = None
|
||||||
|
sex: str | None = None
|
||||||
|
neck_cm: float | None = None
|
||||||
|
waist_cm: float | None = None
|
||||||
|
hip_cm: float | None = None
|
||||||
|
body_fat_pct: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StepsCreate(BaseModel):
|
||||||
|
steps: int = Field(ge=0)
|
||||||
|
active_calories: float | None = None
|
||||||
|
notes: str = ""
|
||||||
|
day: str | None = None
|
||||||
|
days_ago: int | None = Field(default=None, ge=0, le=90)
|
||||||
|
logged_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkoutCreate(BaseModel):
|
||||||
|
text: str = Field(min_length=1)
|
||||||
|
day: str | None = None
|
||||||
|
days_ago: int | None = Field(default=None, ge=0, le=90)
|
||||||
|
logged_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderUpdate(BaseModel):
|
||||||
|
enabled: bool | None = None
|
||||||
|
hour: int | None = Field(default=None, ge=0, le=23)
|
||||||
|
minute: int | None = Field(default=None, ge=0, le=59)
|
||||||
|
interval_hours: int | None = Field(default=None, ge=1, le=12)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness")
|
||||||
|
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
return FitnessService(db, user.id).snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/summary")
|
||||||
|
def get_summary(
|
||||||
|
day: str | None = None,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
d = date.fromisoformat(day) if day else None
|
||||||
|
return FitnessService(db, user.id).get_daily_summary(d)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/workout-stats")
|
||||||
|
def get_workout_stats(
|
||||||
|
days: int = 7,
|
||||||
|
end: str | None = None,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
end_day = date.fromisoformat(end) if end else None
|
||||||
|
return FitnessService(db, user.id).get_workout_stats(days=days, end_day=end_day)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/history")
|
||||||
|
def get_history(
|
||||||
|
days: int = 7,
|
||||||
|
end: str | None = None,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
end_day = date.fromisoformat(end) if end else None
|
||||||
|
return FitnessService(db, user.id).get_history(days=days, end_day=end_day)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/charts")
|
||||||
|
def get_charts(
|
||||||
|
weeks: int = 52,
|
||||||
|
trend: bool = True,
|
||||||
|
end: str | None = None,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
end_day = date.fromisoformat(end) if end else None
|
||||||
|
return FitnessService(db, user.id).get_charts(weeks=weeks, trend=trend, end_day=end_day)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/profile")
|
||||||
|
def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
profile = FitnessService(db, user.id).get_profile()
|
||||||
|
return profile or {"configured": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/fitness/profile")
|
||||||
|
def update_profile(
|
||||||
|
payload: ProfileUpdate,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return FitnessService(db, user.id).set_profile(payload.model_dump(exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/profile/calc")
|
||||||
|
def calc_targets(
|
||||||
|
payload: ProfileUpdate,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
params = payload.model_dump(exclude_none=True)
|
||||||
|
if not params:
|
||||||
|
raise HTTPException(status_code=400, detail="No parameters")
|
||||||
|
return FitnessService(db, user.id).calc_targets(params)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/meals")
|
||||||
|
async def create_meal(
|
||||||
|
payload: MealCreate,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
service = FitnessService(db, user.id)
|
||||||
|
try:
|
||||||
|
structured = await structure_meal(payload.text)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
return service.log_meal(
|
||||||
|
description=structured.get("description") or payload.text,
|
||||||
|
meal_type=payload.meal_type or structured.get("meal_type") or "snack",
|
||||||
|
calories=float(structured.get("calories") or 0),
|
||||||
|
protein_g=float(structured.get("protein_g") or 0),
|
||||||
|
fat_g=float(structured.get("fat_g") or 0),
|
||||||
|
carbs_g=float(structured.get("carbs_g") or 0),
|
||||||
|
source="llm",
|
||||||
|
estimated=bool(structured.get("estimated", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/water")
|
||||||
|
def create_water(
|
||||||
|
payload: WaterCreate,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return FitnessService(db, user.id).log_water(payload.amount_ml)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/weight")
|
||||||
|
def create_weight(
|
||||||
|
payload: WeightCreate,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
day = date.fromisoformat(payload.day) if payload.day else None
|
||||||
|
return FitnessService(db, user.id).log_weight(
|
||||||
|
payload.weight_kg,
|
||||||
|
body_fat_pct=payload.body_fat_pct,
|
||||||
|
chest_cm=payload.chest_cm,
|
||||||
|
waist_cm=payload.waist_cm,
|
||||||
|
neck_cm=payload.neck_cm,
|
||||||
|
hip_cm=payload.hip_cm,
|
||||||
|
notes=payload.notes,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
day=day,
|
||||||
|
days_ago=payload.days_ago,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/body-composition/calc")
|
||||||
|
def calc_body_composition(
|
||||||
|
payload: BodyCompositionCalc,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return FitnessService(db, user.id).calc_body_composition(payload.model_dump(exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/steps")
|
||||||
|
def create_steps(
|
||||||
|
payload: StepsCreate,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
day = date.fromisoformat(payload.day) if payload.day else None
|
||||||
|
return FitnessService(db, user.id).log_steps(
|
||||||
|
payload.steps,
|
||||||
|
active_calories=payload.active_calories,
|
||||||
|
notes=payload.notes,
|
||||||
|
day=day,
|
||||||
|
days_ago=payload.days_ago,
|
||||||
|
logged_at=payload.logged_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/fitness/steps/{log_id}")
|
||||||
|
def delete_steps(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
|
||||||
|
if not FitnessService(db, user.id).delete_step_log(log_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/workouts")
|
||||||
|
async def create_workout(
|
||||||
|
payload: WorkoutCreate,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
service = FitnessService(db, user.id)
|
||||||
|
try:
|
||||||
|
structured = await structure_workout(payload.text)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
day = date.fromisoformat(payload.day) if payload.day else None
|
||||||
|
return service.log_workout(
|
||||||
|
title=structured.get("title") or "Тренировка",
|
||||||
|
notes=structured.get("notes") or payload.text,
|
||||||
|
duration_min=structured.get("duration_min"),
|
||||||
|
exercises=structured.get("exercises"),
|
||||||
|
active_calories=structured.get("active_calories"),
|
||||||
|
total_calories=structured.get("total_calories"),
|
||||||
|
steps=structured.get("steps"),
|
||||||
|
activity_type=structured.get("activity_type"),
|
||||||
|
met=structured.get("met"),
|
||||||
|
day=day,
|
||||||
|
days_ago=payload.days_ago,
|
||||||
|
logged_at=payload.logged_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/body-metrics")
|
||||||
|
def list_metrics(
|
||||||
|
limit: int = 30,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return FitnessService(db, user.id).list_body_metrics(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/fitness/meals/{log_id}")
|
||||||
|
def delete_meal(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
|
||||||
|
if not FitnessService(db, user.id).delete_food_log(log_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/fitness/water/{log_id}")
|
||||||
|
def delete_water(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
|
||||||
|
if not FitnessService(db, user.id).delete_water_log(log_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/fitness/workouts/{log_id}")
|
||||||
|
def delete_workout(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
|
||||||
|
if not FitnessService(db, user.id).delete_workout_log(log_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/reminders")
|
||||||
|
def list_reminders(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
|
||||||
|
return FitnessService(db, user.id).list_reminders()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/fitness/reminders/{kind}")
|
||||||
|
def update_reminder(
|
||||||
|
kind: str,
|
||||||
|
payload: ReminderUpdate,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return FitnessService(db, user.id).set_reminder(
|
||||||
|
kind,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
hour=payload.hour,
|
||||||
|
minute=payload.minute,
|
||||||
|
interval_hours=payload.interval_hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/lookup/food")
|
||||||
|
def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]:
|
||||||
|
return OpenFoodFactsClient().search(q, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/lookup/exercise")
|
||||||
|
def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]:
|
||||||
|
return WgerClient().search_exercises(q, limit=limit)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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¤t=temperature_2m"),
|
||||||
|
"comfyui": _probe(f"{settings.comfyui_base_url.rstrip('/')}/system_stats"),
|
||||||
|
"netdata": _probe(f"{settings.netdata_base_url.rstrip('/')}/api/v1/info"),
|
||||||
|
"rp_chat": _probe(f"{settings.rp_chat_base_url.rstrip('/')}/health"),
|
||||||
|
"config": {
|
||||||
|
"openmeteo_base_url": settings.openmeteo_base_url,
|
||||||
|
"comfyui_base_url": settings.comfyui_base_url,
|
||||||
|
"comfyui_backend": comfy_backend,
|
||||||
|
"comfyui_unet": settings.comfyui_unet,
|
||||||
|
"netdata_base_url": settings.netdata_base_url,
|
||||||
|
"rp_chat_base_url": settings.rp_chat_base_url,
|
||||||
|
"rp_chat_enabled": settings.rp_chat_enabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/weather")
|
||||||
|
def weather_dashboard(
|
||||||
|
hours_ahead: int = 12,
|
||||||
|
days_ahead: int = 7,
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
hours = max(1, min(int(hours_ahead), 168))
|
||||||
|
days = max(1, min(int(days_ahead), 16))
|
||||||
|
return build_weather_dashboard(hours_ahead=hours, days_ahead=days)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/media", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/generated/{filename}")
|
||||||
|
def get_generated_image(filename: str) -> FileResponse:
|
||||||
|
if ".." in filename or "/" in filename or "\\" in filename:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
path = Path(settings.generated_media_dir) / filename
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
return FileResponse(path, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/uploads/{user_id}/{filename}")
|
||||||
|
def get_upload_image(
|
||||||
|
user_id: int,
|
||||||
|
filename: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> FileResponse:
|
||||||
|
if user.id != user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
if ".." in filename or "/" in filename or "\\" in filename:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
path = Path(settings.uploads_dir) / str(user_id) / filename
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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
|
||||||
+102
-100
@@ -1,100 +1,102 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.schemas import PomodoroStart, PomodoroStop
|
from app.api.schemas import PomodoroStart, PomodoroStop
|
||||||
from app.db.base import get_db
|
from app.auth.deps import get_current_user
|
||||||
from app.pomodoro.service import PomodoroService
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
router = APIRouter()
|
from app.pomodoro.service import PomodoroService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
def _handle_value_error(exc: ValueError) -> HTTPException:
|
|
||||||
return HTTPException(status_code=400, detail=str(exc))
|
|
||||||
|
def _handle_value_error(exc: ValueError) -> HTTPException:
|
||||||
|
return HTTPException(status_code=400, detail=str(exc))
|
||||||
@router.get("/status")
|
|
||||||
def get_status(db: Session = Depends(get_db)) -> dict:
|
|
||||||
return PomodoroService(db).get_status()
|
@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)) -> dict:
|
|
||||||
try:
|
@router.post("/start")
|
||||||
return PomodoroService(db).start(
|
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
duration_min=payload.duration_min,
|
try:
|
||||||
task_note=payload.task_note,
|
return PomodoroService(db, user.id).start(
|
||||||
)
|
duration_min=payload.duration_min,
|
||||||
except ValueError as exc:
|
task_note=payload.task_note,
|
||||||
raise _handle_value_error(exc) from exc
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _handle_value_error(exc) from exc
|
||||||
@router.post("/pause")
|
|
||||||
def pause_pomodoro(db: Session = Depends(get_db)) -> dict:
|
|
||||||
try:
|
@router.post("/pause")
|
||||||
return PomodoroService(db).pause()
|
def pause_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
except ValueError as exc:
|
try:
|
||||||
raise _handle_value_error(exc) from exc
|
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)) -> dict:
|
|
||||||
try:
|
@router.post("/resume")
|
||||||
return PomodoroService(db).resume()
|
def resume_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
except ValueError as exc:
|
try:
|
||||||
raise _handle_value_error(exc) from exc
|
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)) -> dict:
|
|
||||||
try:
|
@router.post("/stop")
|
||||||
return PomodoroService(db).stop(
|
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
result=payload.result,
|
try:
|
||||||
completed=payload.completed,
|
return PomodoroService(db, user.id).stop(
|
||||||
)
|
result=payload.result,
|
||||||
except ValueError as exc:
|
completed=payload.completed,
|
||||||
raise _handle_value_error(exc) from exc
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _handle_value_error(exc) from exc
|
||||||
@router.get("/history")
|
|
||||||
def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]:
|
|
||||||
return PomodoroService(db).history(limit=limit)
|
@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)) -> dict:
|
|
||||||
try:
|
@router.post("/work/start")
|
||||||
return PomodoroService(db).start_work(
|
def start_work(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
duration_min=payload.duration_min,
|
try:
|
||||||
task_note=payload.task_note,
|
return PomodoroService(db, user.id).start_work(
|
||||||
)
|
duration_min=payload.duration_min,
|
||||||
except ValueError as exc:
|
task_note=payload.task_note,
|
||||||
raise _handle_value_error(exc) from exc
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _handle_value_error(exc) from exc
|
||||||
@router.post("/break/short/start")
|
|
||||||
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
|
|
||||||
try:
|
@router.post("/break/short/start")
|
||||||
return PomodoroService(db).start_short_break(duration_min=duration_min)
|
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
except ValueError as exc:
|
try:
|
||||||
raise _handle_value_error(exc) from exc
|
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)) -> dict:
|
|
||||||
try:
|
@router.post("/break/long/start")
|
||||||
return PomodoroService(db).start_long_break(duration_min=duration_min)
|
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
except ValueError as exc:
|
try:
|
||||||
raise _handle_value_error(exc) from exc
|
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)) -> dict:
|
|
||||||
return PomodoroService(db).reset_cycle(clear_task=clear_task)
|
@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)) -> dict:
|
|
||||||
try:
|
@router.post("/skip")
|
||||||
return PomodoroService(db).skip_phase()
|
def skip_phase(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
except ValueError as exc:
|
try:
|
||||||
raise _handle_value_error(exc) from exc
|
return PomodoroService(db, user.id).skip_phase()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _handle_value_error(exc) from exc
|
||||||
|
|||||||
@@ -1,76 +1,78 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.base import get_db
|
from app.auth.deps import get_current_user
|
||||||
from app.projects.service import ProjectService
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
router = APIRouter()
|
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)
|
class GiteaBinding(BaseModel):
|
||||||
default_branch: str = "main"
|
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
|
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.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)) -> list[dict[str, Any]]:
|
|
||||||
try:
|
@router.post("/projects/sync-taiga")
|
||||||
return ProjectService(db).sync_taiga_projects()
|
def sync_taiga_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
|
||||||
except ValueError as exc:
|
try:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
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,
|
@router.put("/projects/{taiga_slug}/gitea")
|
||||||
payload: GiteaBinding,
|
def bind_gitea(
|
||||||
db: Session = Depends(get_db),
|
taiga_slug: str,
|
||||||
) -> dict[str, Any]:
|
payload: GiteaBinding,
|
||||||
try:
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
return ProjectService(db).bind_gitea(
|
) -> dict[str, Any]:
|
||||||
taiga_slug,
|
try:
|
||||||
payload.gitea_owner,
|
return ProjectService(db, user.id).bind_gitea(
|
||||||
payload.gitea_repo,
|
taiga_slug,
|
||||||
payload.default_branch,
|
payload.gitea_owner,
|
||||||
)
|
payload.gitea_repo,
|
||||||
except ValueError as exc:
|
payload.default_branch,
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
@router.post("/work-items")
|
|
||||||
async def create_work_item(
|
|
||||||
payload: WorkItemCreate,
|
@router.post("/work-items")
|
||||||
db: Session = Depends(get_db),
|
async def create_work_item(
|
||||||
) -> dict[str, Any]:
|
payload: WorkItemCreate,
|
||||||
try:
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
return await ProjectService(db).create_work_item(
|
) -> dict[str, Any]:
|
||||||
payload.text,
|
try:
|
||||||
project_slug=payload.project_slug,
|
return await ProjectService(db, user.id).create_work_item(
|
||||||
)
|
payload.text,
|
||||||
except ValueError as exc:
|
project_slug=payload.project_slug,
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
)
|
||||||
except Exception as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from 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,
|
@router.get("/work-items")
|
||||||
status: str | None = None,
|
def list_work_items(
|
||||||
db: Session = Depends(get_db),
|
limit: int = 30,
|
||||||
) -> list[dict[str, Any]]:
|
status: str | None = None,
|
||||||
return ProjectService(db).list_work_items(limit=limit, status=status)
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.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
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
|
from app.settings.service import SETTING_KEYS, SettingsService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsPatch(BaseModel):
|
||||||
|
openrouter_model: str | None = None
|
||||||
|
memory_extract_model: str | None = None
|
||||||
|
openrouter_vision_model: str | None = None
|
||||||
|
openrouter_reasoning_effort: str | None = None
|
||||||
|
rag_enabled: bool | None = None
|
||||||
|
rag_top_k: int | None = Field(default=None, ge=1, le=50)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
def get_settings_route(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
return SettingsService(db).snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/settings")
|
||||||
|
def patch_settings_route(
|
||||||
|
payload: SettingsPatch,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
return SettingsService(db).patch(updates)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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
|
||||||
@@ -1,94 +1,106 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
import logging
|
||||||
|
from typing import Any
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
||||||
from sqlalchemy import select
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy.orm import Session
|
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.chat.notice_inbox import post_notice_to_latest_chat
|
||||||
from app.db.models import ChatSession, Message, ProjectBinding
|
from app.config import get_settings
|
||||||
from app.projects.service import ProjectService
|
from app.db.base import get_db
|
||||||
|
from app.db.models import ProjectBinding
|
||||||
router = APIRouter()
|
from app.projects.service import ProjectService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
|
logger = logging.getLogger(__name__)
|
||||||
if not secret:
|
|
||||||
return True
|
|
||||||
if not signature:
|
def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
|
||||||
return False
|
if not secret:
|
||||||
if signature.startswith("sha256="):
|
return True
|
||||||
signature = signature[7:]
|
if not signature:
|
||||||
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
return False
|
||||||
return hmac.compare_digest(expected, signature)
|
if signature.startswith("sha256="):
|
||||||
|
signature = signature[7:]
|
||||||
|
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
def _post_close_notice(results: list[dict[str, Any]], owner: str, repo: str) -> None:
|
return hmac.compare_digest(expected, signature)
|
||||||
if not results:
|
|
||||||
return
|
|
||||||
db = SessionLocal()
|
def _post_close_notice(
|
||||||
try:
|
results: list[dict[str, Any]], owner: str, repo: str, user_id: int
|
||||||
session = db.scalar(
|
) -> None:
|
||||||
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
|
if not results:
|
||||||
)
|
return
|
||||||
if not session:
|
lines = [f"🔀 **Push** `{owner}/{repo}`"]
|
||||||
session = ChatSession(title="Git")
|
for item in results:
|
||||||
db.add(session)
|
if "closed" in item:
|
||||||
db.commit()
|
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
|
||||||
db.refresh(session)
|
elif "error" in item:
|
||||||
|
lines.append(f"- ошибка: {item['error']}")
|
||||||
lines = [f"🔀 **Push** `{owner}/{repo}`"]
|
post_notice_to_latest_chat("\n".join(lines), user_id)
|
||||||
for item in results:
|
|
||||||
if "closed" in item:
|
|
||||||
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
|
@router.post("/webhooks/gitea")
|
||||||
elif "error" in item:
|
async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
lines.append(f"- ошибка: {item['error']}")
|
body = await request.body()
|
||||||
|
settings = get_settings()
|
||||||
db.add(Message(session_id=session.id, role="notice", content="\n".join(lines)))
|
signature = (
|
||||||
db.commit()
|
request.headers.get("X-Gitea-Signature")
|
||||||
finally:
|
or request.headers.get("X-Gogs-Signature")
|
||||||
db.close()
|
or request.headers.get("X-Hub-Signature-256")
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/webhooks/gitea")
|
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret):
|
||||||
async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
|
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
||||||
body = await request.body()
|
|
||||||
settings = get_settings()
|
payload = json.loads(body)
|
||||||
signature = request.headers.get("X-Gitea-Signature")
|
if payload.get("secret") and settings.gitea_webhook_secret:
|
||||||
|
if payload.get("secret") != settings.gitea_webhook_secret:
|
||||||
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret):
|
raise HTTPException(status_code=401, detail="Invalid webhook secret")
|
||||||
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
|
||||||
|
event = request.headers.get("X-Gitea-Event", "")
|
||||||
payload = json.loads(body)
|
if event != "push":
|
||||||
if payload.get("secret") and settings.gitea_webhook_secret:
|
return {"ok": True, "skipped": event}
|
||||||
if payload.get("secret") != settings.gitea_webhook_secret:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid webhook secret")
|
repo = payload.get("repository", {})
|
||||||
|
owner = repo.get("owner", {}).get("login", "")
|
||||||
event = request.headers.get("X-Gitea-Event", "")
|
repo_name = repo.get("name", "")
|
||||||
if event != "push":
|
if not owner or not repo_name:
|
||||||
return {"ok": True, "skipped": event}
|
raise HTTPException(status_code=400, detail="Missing repository info")
|
||||||
|
|
||||||
repo = payload.get("repository", {})
|
binding = db.scalar(
|
||||||
owner = repo.get("owner", {}).get("login", "")
|
select(ProjectBinding).where(
|
||||||
repo_name = repo.get("name", "")
|
ProjectBinding.gitea_owner == owner,
|
||||||
if not owner or not repo_name:
|
ProjectBinding.gitea_repo == repo_name,
|
||||||
raise HTTPException(status_code=400, detail="Missing repository info")
|
)
|
||||||
|
)
|
||||||
binding = db.scalar(
|
if not binding:
|
||||||
select(ProjectBinding).where(
|
return {"ok": True, "skipped": "unknown repo"}
|
||||||
ProjectBinding.gitea_owner == owner,
|
|
||||||
ProjectBinding.gitea_repo == repo_name,
|
commits = list(payload.get("commits") or [])
|
||||||
)
|
if not commits:
|
||||||
)
|
head = payload.get("head_commit")
|
||||||
if not binding:
|
if head:
|
||||||
return {"ok": True, "skipped": "unknown repo"}
|
commits = [head]
|
||||||
|
|
||||||
commits = payload.get("commits") or []
|
logger.info(
|
||||||
service = ProjectService(db)
|
"Gitea push %s/%s ref=%s commits=%d",
|
||||||
results = service.process_push(owner, repo_name, commits)
|
owner,
|
||||||
_post_close_notice(results, owner, repo_name)
|
repo_name,
|
||||||
|
payload.get("ref", ""),
|
||||||
return {"ok": True, "results": results}
|
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)}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class MessageOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
role: str
|
role: str
|
||||||
content: str
|
content: str
|
||||||
|
tool_calls_json: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.auth.service import create_user, find_user_by_token
|
||||||
|
from app.auth.tokens import hash_token, verify_token
|
||||||
|
|
||||||
|
__all__ = ["get_current_user", "hash_token", "verify_token"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.tokens import hash_token
|
||||||
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_token(request: Request) -> str | None:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if auth.lower().startswith("bearer "):
|
||||||
|
token = auth[7:].strip()
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
header = request.headers.get("X-API-Token", "").strip()
|
||||||
|
if header:
|
||||||
|
return header
|
||||||
|
query = request.query_params.get("token", "").strip()
|
||||||
|
return query or None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
token = _extract_token(request)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API token")
|
||||||
|
|
||||||
|
token_hash = hash_token(token)
|
||||||
|
user = db.scalar(
|
||||||
|
select(User).where(User.api_token_hash == token_hash, User.is_active.is_(True))
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API token")
|
||||||
|
return user
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.tokens import hash_token
|
||||||
|
from app.db.models import CharacterCard, User
|
||||||
|
from app.character.card import DEFAULT_CARD, normalize_card
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def find_user_by_token(db: Session, token: str) -> User | None:
|
||||||
|
token_hash = hash_token(token.strip())
|
||||||
|
return db.scalar(
|
||||||
|
select(User).where(User.api_token_hash == token_hash, User.is_active.is_(True))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_to_dict(user: User) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"display_name": user.display_name or user.username,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
display_name: str = "",
|
||||||
|
api_token: str | None = None,
|
||||||
|
) -> tuple[User, str]:
|
||||||
|
clean = username.strip().lower()
|
||||||
|
if not clean:
|
||||||
|
raise ValueError("username не может быть пустым")
|
||||||
|
existing = db.scalar(select(User).where(User.username == clean))
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"Пользователь «{clean}» уже существует")
|
||||||
|
|
||||||
|
plain_token = (api_token or "").strip() or secrets.token_urlsafe(32)
|
||||||
|
user = User(
|
||||||
|
username=clean,
|
||||||
|
display_name=(display_name or clean).strip(),
|
||||||
|
api_token_hash=hash_token(plain_token),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
card = normalize_card(DEFAULT_CARD)
|
||||||
|
db.add(
|
||||||
|
CharacterCard(
|
||||||
|
user_id=user.id,
|
||||||
|
card_json=json.dumps(card, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user, plain_token
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(plain: str, token_hash: str) -> bool:
|
||||||
|
return hash_token(plain) == token_hash
|
||||||
+104
-77
@@ -1,77 +1,104 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
TOOLS_INSTRUCTIONS = """
|
TOOLS_INSTRUCTIONS = """
|
||||||
Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс).
|
Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс).
|
||||||
Обязательные правила:
|
Обязательные правила:
|
||||||
- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент.
|
- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент.
|
||||||
- Никогда не выдумывай статус таймера или список задач.
|
- Никогда не выдумывай статус таймера или список задач.
|
||||||
- После вызова инструмента кратко объясни результат пользователю по-человечески.
|
- После вызова инструмента кратко объясни результат пользователю по-человечески.
|
||||||
- Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break,
|
- Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break,
|
||||||
stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history.
|
stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history.
|
||||||
- Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items.
|
- Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items.
|
||||||
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
|
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
|
||||||
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
|
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
|
||||||
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
|
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
|
||||||
- Снимок проектов/задач есть в контексте, но для актуализации вызывай tools. Никогда не пиши «ожидаю ответа от системы».
|
- Фитнес: 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,
|
||||||
""".strip()
|
- «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history.
|
||||||
|
- Скриншоты и фото: vision-модель уже разобрала каждую картинку ДО твоего ответа. В сообщении один или несколько блоков [Скриншот] / [Скриншот N/M] — это содержимое изображений; отвечай так, будто ты их видишь.
|
||||||
DEFAULT_CARD: dict[str, Any] = {
|
- НЕ говори, что у тебя нет глаз / ты не видишь картинку / нужен Gemini, OpenRouter или curl — распознавание уже выполнено.
|
||||||
"spec": "chara_card_v2",
|
- fitness_workout / fitness_steps + fitness_hints: log_workout, log_steps и т.д.; при confidence=low уточни детали.
|
||||||
"spec_version": "2.0",
|
- document_type=other: опиши и прокомментируй по блоку [Скриншот], без советов про настройку vision API.
|
||||||
"data": {
|
calc_fitness_targets, calc_body_composition (расчёт Navy/WHR/LBM/FFMI без записи), lookup_food, lookup_exercise, set_fitness_reminder.
|
||||||
"name": "Домашний ассистент",
|
- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary.
|
||||||
"description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.",
|
- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
|
||||||
"personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.",
|
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
|
||||||
"scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.",
|
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
|
||||||
"first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?",
|
- Никогда не пиши «ожидаю ответа от системы».
|
||||||
"mes_example": "",
|
- В текстовых ответах пользователю не используй эмодзи.
|
||||||
"system_prompt": "",
|
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
|
||||||
"post_history_instructions": "",
|
- Утренний брифинг (погода + новости) → get_morning_briefing.
|
||||||
"alternate_greetings": [],
|
- Картинки: generate_image — draw_self=true + scene_description (full_body, outfit…); appearance только из карточки. Не злоупотребляй.
|
||||||
"tags": ["assistant", "home", "pomodoro"],
|
- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list.
|
||||||
"creator": "",
|
- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки.
|
||||||
"creator_notes": "",
|
- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.
|
||||||
"character_version": "1.0",
|
- «Напомни через 15 минут», «завтра утром», «12 мая в 9:00» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]).
|
||||||
},
|
- День рождения, Новый год и другие праздники → recurrence yearly.
|
||||||
}
|
- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе.
|
||||||
|
""".strip()
|
||||||
|
|
||||||
def normalize_card(raw: dict[str, Any]) -> dict[str, Any]:
|
DEFAULT_CARD: dict[str, Any] = {
|
||||||
if "data" in raw and isinstance(raw["data"], dict):
|
"spec": "chara_card_v2",
|
||||||
card = {
|
"spec_version": "2.0",
|
||||||
"spec": raw.get("spec", "chara_card_v2"),
|
"data": {
|
||||||
"spec_version": raw.get("spec_version", "2.0"),
|
"name": "Домашний ассистент",
|
||||||
"data": {**DEFAULT_CARD["data"], **raw["data"]},
|
"description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.",
|
||||||
}
|
"personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.",
|
||||||
return card
|
"scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.",
|
||||||
|
"first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?",
|
||||||
if "name" in raw or "description" in raw:
|
"mes_example": "",
|
||||||
return {
|
"system_prompt": "",
|
||||||
"spec": "chara_card_v2",
|
"post_history_instructions": "",
|
||||||
"spec_version": "2.0",
|
"alternate_greetings": [],
|
||||||
"data": {**DEFAULT_CARD["data"], **raw},
|
"tags": ["assistant", "home", "pomodoro"],
|
||||||
}
|
"creator": "",
|
||||||
|
"creator_notes": "",
|
||||||
return DEFAULT_CARD.copy()
|
"character_version": "1.0",
|
||||||
|
"appearance_tags": "",
|
||||||
|
"appearance_prose": "",
|
||||||
def build_system_prompt(card: dict[str, Any]) -> str:
|
"lora_name": "",
|
||||||
data = card.get("data", {})
|
"lora_weight": 0.8,
|
||||||
parts: list[str] = []
|
"rp_persona_id": "",
|
||||||
|
"sd_enabled": True,
|
||||||
name = data.get("name", "Ассистент")
|
},
|
||||||
parts.append(f"Ты — {name}.")
|
}
|
||||||
|
|
||||||
if data.get("system_prompt"):
|
|
||||||
parts.append(data["system_prompt"])
|
def normalize_card(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
if data.get("description"):
|
if "data" in raw and isinstance(raw["data"], dict):
|
||||||
parts.append(data["description"])
|
card = {
|
||||||
if data.get("personality"):
|
"spec": raw.get("spec", "chara_card_v2"),
|
||||||
parts.append(f"Характер: {data['personality']}")
|
"spec_version": raw.get("spec_version", "2.0"),
|
||||||
if data.get("scenario"):
|
"data": {**DEFAULT_CARD["data"], **raw["data"]},
|
||||||
parts.append(f"Сценарий: {data['scenario']}")
|
}
|
||||||
if data.get("post_history_instructions"):
|
return card
|
||||||
parts.append(data["post_history_instructions"])
|
|
||||||
|
if "name" in raw or "description" in raw:
|
||||||
parts.append(TOOLS_INSTRUCTIONS)
|
return {
|
||||||
return "\n\n".join(part for part in parts if part.strip())
|
"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())
|
||||||
|
|||||||
@@ -1,27 +1,43 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
CARD_PATH = Path("./data/character.json")
|
|
||||||
|
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
|
||||||
|
from app.db.models import CharacterCard
|
||||||
class CharacterService:
|
|
||||||
def get_card(self) -> dict[str, Any]:
|
|
||||||
if CARD_PATH.is_file():
|
class CharacterService:
|
||||||
try:
|
def __init__(self, db: Session, user_id: int):
|
||||||
raw = json.loads(CARD_PATH.read_text(encoding="utf-8"))
|
self.db = db
|
||||||
return normalize_card(raw)
|
self.user_id = user_id
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
pass
|
def get_card(self) -> dict[str, Any]:
|
||||||
return normalize_card(DEFAULT_CARD)
|
row = self.db.scalar(
|
||||||
|
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
|
||||||
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
|
)
|
||||||
card = normalize_card(raw)
|
if not row:
|
||||||
CARD_PATH.parent.mkdir(parents=True, exist_ok=True)
|
return normalize_card(DEFAULT_CARD)
|
||||||
CARD_PATH.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8")
|
try:
|
||||||
return card
|
return normalize_card(json.loads(row.card_json or "{}"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
def get_system_prompt(self) -> str:
|
return normalize_card(DEFAULT_CARD)
|
||||||
return build_system_prompt(self.get_card())
|
|
||||||
|
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
card = normalize_card(raw)
|
||||||
|
row = self.db.scalar(
|
||||||
|
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
row = CharacterCard(user_id=self.user_id, card_json="{}")
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.flush()
|
||||||
|
row.card_json = json.dumps(card, ensure_ascii=False)
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return card
|
||||||
|
|
||||||
|
def get_system_prompt(self) -> str:
|
||||||
|
return build_system_prompt(self.get_card())
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.chat.service import ChatService
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationBusyError(Exception):
|
||||||
|
"""Сессия уже генерирует ответ."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GenerationHandle:
|
||||||
|
session_id: int
|
||||||
|
user_id: int
|
||||||
|
user_text: str
|
||||||
|
task: asyncio.Task | None = None
|
||||||
|
subscribers: list[asyncio.Queue[str | None]] = field(default_factory=list)
|
||||||
|
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||||
|
|
||||||
|
async def broadcast(self, chunk: str | None) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
targets = list(self.subscribers)
|
||||||
|
for queue in targets:
|
||||||
|
try:
|
||||||
|
queue.put_nowait(chunk)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
logger.debug("generation queue full for session=%s, dropping subscriber", self.session_id)
|
||||||
|
|
||||||
|
def add_subscriber(self) -> asyncio.Queue[str | None]:
|
||||||
|
queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=512)
|
||||||
|
self.subscribers.append(queue)
|
||||||
|
return queue
|
||||||
|
|
||||||
|
def remove_subscriber(self, queue: asyncio.Queue[str | None]) -> None:
|
||||||
|
if queue in self.subscribers:
|
||||||
|
self.subscribers.remove(queue)
|
||||||
|
|
||||||
|
|
||||||
|
_registry: dict[int, GenerationHandle] = {}
|
||||||
|
_registry_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def is_generation_active(session_id: int) -> bool:
|
||||||
|
return session_id in _registry
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_handle(session_id: int) -> GenerationHandle | None:
|
||||||
|
return _registry.get(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_generation(handle: GenerationHandle) -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
service = ChatService(db, handle.user_id)
|
||||||
|
async for chunk in service.stream_response(
|
||||||
|
handle.session_id,
|
||||||
|
handle.user_text,
|
||||||
|
user_message_saved=True,
|
||||||
|
):
|
||||||
|
await handle.broadcast(chunk)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Background generation failed session=%s", handle.session_id)
|
||||||
|
await handle.broadcast(ChatService._sse("error", {"message": str(exc)}))
|
||||||
|
finally:
|
||||||
|
await handle.broadcast(None)
|
||||||
|
db.close()
|
||||||
|
async with _registry_lock:
|
||||||
|
if _registry.get(handle.session_id) is handle:
|
||||||
|
_registry.pop(handle.session_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_generation(session_id: int, user_id: int, user_text: str) -> GenerationHandle:
|
||||||
|
async with _registry_lock:
|
||||||
|
if session_id in _registry:
|
||||||
|
raise GenerationBusyError()
|
||||||
|
handle = GenerationHandle(session_id=session_id, user_id=user_id, user_text=user_text)
|
||||||
|
_registry[session_id] = handle
|
||||||
|
handle.task = asyncio.create_task(_run_generation(handle))
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
async def subscribe_generation(handle: GenerationHandle):
|
||||||
|
queue = handle.add_subscriber()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = await queue.get()
|
||||||
|
if chunk is None:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
handle.remove_subscriber(queue)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю)."""
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
from app.db.models import ChatSession, Message
|
||||||
|
|
||||||
|
DISPLAY_ONLY_ROLES = frozenset({"notice", "character"})
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_chat_session(db, 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()
|
||||||
+443
-245
@@ -1,245 +1,443 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.db.models import PomodoroSession
|
from app.db.models import PomodoroSession
|
||||||
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
|
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
|
||||||
|
|
||||||
PHASE_LABELS = {
|
PHASE_LABELS = {
|
||||||
PHASE_WORK: "Работа",
|
PHASE_WORK: "Работа",
|
||||||
PHASE_SHORT_BREAK: "Короткий перерыв",
|
PHASE_SHORT_BREAK: "Короткий перерыв",
|
||||||
PHASE_LONG_BREAK: "Длинный перерыв",
|
PHASE_LONG_BREAK: "Длинный перерыв",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _format_time(seconds: int) -> str:
|
def _format_time(seconds: int) -> str:
|
||||||
minutes, secs = divmod(max(0, seconds), 60)
|
minutes, secs = divmod(max(0, seconds), 60)
|
||||||
return f"{minutes:02d}:{secs:02d}"
|
return f"{minutes:02d}:{secs:02d}"
|
||||||
|
|
||||||
|
|
||||||
def format_phase_completed_notice(
|
def _format_image_generation_notice(data: dict[str, Any]) -> str:
|
||||||
session: PomodoroSession,
|
url = data.get("url", "")
|
||||||
next_phase: str | None,
|
positive = (data.get("prompt") or "").strip()
|
||||||
) -> str:
|
negative = (data.get("negative_prompt") or "").strip()
|
||||||
phase_label = PHASE_LABELS.get(session.phase, session.phase)
|
lines = ["🎨 **Картинка готова**", "", f""]
|
||||||
task = session.task_note or "без описания"
|
if positive:
|
||||||
lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
|
lines.extend(["", "**Comfy (+):**", f"```\n{positive}\n```"])
|
||||||
|
if negative:
|
||||||
if next_phase == PHASE_SHORT_BREAK:
|
lines.extend(["", "**Comfy (−):**", f"```\n{negative}\n```"])
|
||||||
lines.append("Дальше: короткий перерыв ☕")
|
return "\n".join(lines)
|
||||||
elif next_phase == PHASE_LONG_BREAK:
|
|
||||||
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён")
|
|
||||||
elif next_phase == PHASE_WORK:
|
def format_phase_completed_notice(
|
||||||
lines.append("Дальше: снова работа 💪")
|
session: PomodoroSession,
|
||||||
else:
|
next_phase: str | None,
|
||||||
lines.append("Цикл сброшен. Можно отдохнуть и начать заново.")
|
) -> str:
|
||||||
|
phase_label = PHASE_LABELS.get(session.phase, session.phase)
|
||||||
return "\n".join(lines)
|
task = session.task_note or "без описания"
|
||||||
|
lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
|
||||||
|
|
||||||
POMODORO_TOOL_NAMES = frozenset({
|
if next_phase == PHASE_SHORT_BREAK:
|
||||||
"get_pomodoro_status",
|
lines.append("Дальше: короткий перерыв ☕")
|
||||||
"start_pomodoro",
|
elif next_phase == PHASE_LONG_BREAK:
|
||||||
"start_short_break",
|
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён")
|
||||||
"start_long_break",
|
elif next_phase == PHASE_WORK:
|
||||||
"stop_pomodoro",
|
lines.append("Дальше: снова работа 💪")
|
||||||
"skip_pomodoro_phase",
|
else:
|
||||||
"reset_pomodoro_cycle",
|
lines.append("Цикл сброшен. Можно отдохнуть и начать заново.")
|
||||||
"get_pomodoro_history",
|
|
||||||
})
|
return "\n".join(lines)
|
||||||
|
|
||||||
# Не засорять чат служебными ответами
|
|
||||||
TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
POMODORO_TOOL_NAMES = frozenset({
|
||||||
"get_pomodoro_status",
|
"get_pomodoro_status",
|
||||||
})
|
"start_pomodoro",
|
||||||
|
"start_short_break",
|
||||||
|
"start_long_break",
|
||||||
def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
"stop_pomodoro",
|
||||||
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
|
"skip_pomodoro_phase",
|
||||||
return None
|
"reset_pomodoro_cycle",
|
||||||
|
"get_pomodoro_history",
|
||||||
try:
|
})
|
||||||
data = json.loads(raw_result)
|
|
||||||
except json.JSONDecodeError:
|
MEMORY_TOOL_NAMES = frozenset({
|
||||||
return None
|
"remember_fact",
|
||||||
|
"recall_memories",
|
||||||
if isinstance(data, dict) and "error" in data:
|
"forget_memory",
|
||||||
prefix = "⏱" if tool_name in POMODORO_TOOL_NAMES else "📋"
|
"update_profile",
|
||||||
return f"{prefix} {data['error']}"
|
"update_session_summary",
|
||||||
|
})
|
||||||
if tool_name == "reset_pomodoro_cycle":
|
|
||||||
cycle = data.get("cycle", data)
|
FITNESS_TOOL_NAMES = frozenset({
|
||||||
return (
|
"get_fitness_summary",
|
||||||
"⏱ **Цикл помидоро сброшен** · "
|
"get_fitness_history",
|
||||||
f"прогресс: {cycle.get('completed_work_sessions', 0)}/"
|
"set_fitness_profile",
|
||||||
f"{cycle.get('sessions_until_long_break', 4)}"
|
"calc_fitness_targets",
|
||||||
)
|
"calc_body_composition",
|
||||||
|
"log_meal",
|
||||||
if tool_name in (
|
"log_water",
|
||||||
"get_pomodoro_status",
|
"log_weight",
|
||||||
"start_pomodoro",
|
"log_workout",
|
||||||
"start_work",
|
"lookup_food",
|
||||||
"start_short_break",
|
"lookup_exercise",
|
||||||
"start_long_break",
|
"set_fitness_reminder",
|
||||||
"stop_pomodoro",
|
})
|
||||||
"skip_pomodoro_phase",
|
|
||||||
):
|
# Не засорять чат служебными ответами
|
||||||
return _format_status_notice(data)
|
REMINDER_TOOL_NAMES = frozenset({
|
||||||
|
"list_reminders",
|
||||||
if tool_name == "get_pomodoro_history":
|
"create_reminder",
|
||||||
return _format_history_notice(data)
|
"update_reminder",
|
||||||
|
"delete_reminder",
|
||||||
if tool_name == "create_work_item":
|
"complete_reminder",
|
||||||
return _format_work_item_notice(data)
|
})
|
||||||
|
|
||||||
if tool_name == "list_work_items":
|
SHOPPING_TOOL_NAMES = frozenset({
|
||||||
return _format_work_items_list_notice(data)
|
"list_shopping_lists",
|
||||||
|
"create_shopping_list",
|
||||||
if tool_name == "list_taiga_tasks":
|
"add_shopping_items",
|
||||||
return _format_taiga_tasks_notice(data)
|
"check_shopping_item",
|
||||||
|
"remove_shopping_item",
|
||||||
if tool_name == "sync_taiga_projects":
|
"delete_shopping_list",
|
||||||
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
|
})
|
||||||
|
|
||||||
if tool_name == "list_taiga_projects":
|
TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
||||||
if not isinstance(data, list) or not data:
|
"get_pomodoro_status",
|
||||||
return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects."
|
"recall_memories",
|
||||||
lines = ["📋 **Проекты:**"]
|
"get_fitness_summary",
|
||||||
for p in data:
|
"get_fitness_history",
|
||||||
gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else "—"
|
"lookup_food",
|
||||||
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
|
"lookup_exercise",
|
||||||
return "\n".join(lines)
|
"calc_fitness_targets",
|
||||||
|
"calc_body_composition",
|
||||||
return None
|
"get_weather",
|
||||||
|
"get_morning_briefing",
|
||||||
|
"list_shopping_lists",
|
||||||
def _format_work_item_notice(data: dict[str, Any]) -> str | None:
|
"list_reminders",
|
||||||
if data.get("error"):
|
})
|
||||||
return f"📋 {data['error']}"
|
|
||||||
if not data.get("ok"):
|
|
||||||
return None
|
|
||||||
taiga = data.get("taiga", {})
|
def _format_body_composition_notice(computed: dict[str, Any], *, headline: str) -> str:
|
||||||
gitea = data.get("gitea", {})
|
parts: list[str] = []
|
||||||
lines = [
|
bf = computed.get("body_fat_pct")
|
||||||
"📋 **Создано:**",
|
if bf is not None:
|
||||||
f"- Taiga: #{taiga.get('ref')} — {taiga.get('subject')}",
|
method = computed.get("body_fat_method")
|
||||||
f"- URL: {taiga.get('url')}",
|
if method == "navy":
|
||||||
]
|
parts.append(f"жир ≈{bf}% (Navy)")
|
||||||
if gitea.get("url"):
|
elif method == "manual":
|
||||||
lines.append(f"- Gitea: {gitea.get('url')}")
|
parts.append(f"жир {bf}%")
|
||||||
if data.get("branch"):
|
else:
|
||||||
lines.append(f"- Ветка: `{data['branch']}`")
|
parts.append(f"жир ≈{bf}%")
|
||||||
subtasks = data.get("subtasks") or []
|
if computed.get("whr") is not None:
|
||||||
if subtasks:
|
parts.append(f"WHR {computed.get('whr')}")
|
||||||
lines.append("**Подзадачи:**")
|
if computed.get("ffmi") is not None:
|
||||||
for t in subtasks:
|
parts.append(f"FFMI {computed.get('ffmi')}")
|
||||||
lines.append(f"- #{t.get('ref')} {t.get('subject')}")
|
if parts:
|
||||||
return "\n".join(lines)
|
return f"{headline} — {', '.join(parts)}"
|
||||||
|
return headline
|
||||||
|
|
||||||
def _format_work_items_list_notice(data: Any) -> str | None:
|
def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
||||||
if not isinstance(data, list) or not data:
|
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
|
||||||
return "📋 Локальных work items (созданных ассистентом) нет."
|
return None
|
||||||
lines = ["📋 **Work items ассистента:**"]
|
|
||||||
for item in data[:15]:
|
try:
|
||||||
lines.append(
|
data = json.loads(raw_result)
|
||||||
f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} "
|
except json.JSONDecodeError:
|
||||||
f"({item.get('taiga_slug')})"
|
return None
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
if isinstance(data, dict) and "error" in data:
|
||||||
|
if tool_name in POMODORO_TOOL_NAMES:
|
||||||
|
prefix = "⏱"
|
||||||
def _format_taiga_tasks_notice(data: Any) -> str | None:
|
elif tool_name in MEMORY_TOOL_NAMES:
|
||||||
if not isinstance(data, dict):
|
prefix = "🧠"
|
||||||
return None
|
elif tool_name in FITNESS_TOOL_NAMES:
|
||||||
if data.get("error"):
|
prefix = "💪"
|
||||||
return f"📋 {data['error']}"
|
elif tool_name in SHOPPING_TOOL_NAMES:
|
||||||
|
prefix = "🛒"
|
||||||
blocks = data.get("projects") or []
|
elif tool_name in REMINDER_TOOL_NAMES:
|
||||||
total_stories = data.get("total_stories", 0)
|
prefix = "📅"
|
||||||
total_tasks = data.get("total_tasks", 0)
|
else:
|
||||||
|
prefix = "📋"
|
||||||
if not blocks or (total_stories == 0 and total_tasks == 0):
|
return f"{prefix} {data['error']}"
|
||||||
slug = blocks[0].get("slug") if len(blocks) == 1 else None
|
|
||||||
if slug:
|
if tool_name == "reset_pomodoro_cycle":
|
||||||
return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga."
|
cycle = data.get("cycle", data)
|
||||||
return "📋 Открытых задач в Taiga не найдено."
|
return (
|
||||||
|
"⏱ **Цикл помидоро сброшен** · "
|
||||||
lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"]
|
f"прогресс: {cycle.get('completed_work_sessions', 0)}/"
|
||||||
for block in blocks:
|
f"{cycle.get('sessions_until_long_break', 4)}"
|
||||||
stories = block.get("stories") or []
|
)
|
||||||
tasks = block.get("tasks") or []
|
|
||||||
if not stories and not tasks:
|
if tool_name in (
|
||||||
continue
|
"get_pomodoro_status",
|
||||||
lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):")
|
"start_pomodoro",
|
||||||
for s in stories:
|
"start_work",
|
||||||
lines.append(f"- story #{s.get('ref')} {s.get('subject')}")
|
"start_short_break",
|
||||||
for t in tasks:
|
"start_long_break",
|
||||||
lines.append(f"- task #{t.get('ref')} {t.get('subject')}")
|
"stop_pomodoro",
|
||||||
return "\n".join(lines)
|
"skip_pomodoro_phase",
|
||||||
|
):
|
||||||
|
return _format_status_notice(data)
|
||||||
def _format_status_notice(data: dict[str, Any]) -> str:
|
|
||||||
status = data.get("status", "idle")
|
if tool_name == "get_pomodoro_history":
|
||||||
phase = data.get("phase", PHASE_WORK)
|
return _format_history_notice(data)
|
||||||
phase_label = PHASE_LABELS.get(phase, phase)
|
|
||||||
task = data.get("task_note") or "без описания"
|
if tool_name == "create_work_item":
|
||||||
remaining = data.get("remaining_seconds", 0)
|
return _format_work_item_notice(data)
|
||||||
duration = data.get("duration_min", 25)
|
|
||||||
cycle = data.get("cycle", {})
|
if tool_name == "list_work_items":
|
||||||
cycle_info = ""
|
return _format_work_items_list_notice(data)
|
||||||
if cycle:
|
|
||||||
cycle_info = (
|
if tool_name == "list_taiga_tasks":
|
||||||
f" · цикл {cycle.get('completed_work_sessions', 0)}/"
|
return _format_taiga_tasks_notice(data)
|
||||||
f"{cycle.get('sessions_until_long_break', 4)}"
|
|
||||||
)
|
if tool_name == "sync_taiga_projects":
|
||||||
|
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
|
||||||
if status == "idle":
|
|
||||||
return f"⏱ **Помидоро:** таймер не запущен{cycle_info}."
|
if tool_name == "list_taiga_projects":
|
||||||
|
if not isinstance(data, list) or not data:
|
||||||
if status == "running":
|
return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects."
|
||||||
return (
|
lines = ["📋 **Проекты:**"]
|
||||||
f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** "
|
for p in data:
|
||||||
f"из {duration} мин · _{task}_{cycle_info}"
|
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 status == "paused":
|
|
||||||
elapsed = data.get("elapsed_seconds", 0)
|
if tool_name == "remember_fact" and data.get("ok"):
|
||||||
return (
|
action = "обновлено" if data.get("action") == "updated" else "сохранено"
|
||||||
f"⏱ **{phase_label} на паузе** · прошло {_format_time(elapsed)} "
|
return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}"
|
||||||
f"из {duration} мин · _{task}_{cycle_info}"
|
|
||||||
)
|
if tool_name == "forget_memory" and data.get("ok"):
|
||||||
|
return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}"
|
||||||
if status == "completed":
|
|
||||||
return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_"
|
if tool_name == "update_profile" and data.get("ok"):
|
||||||
|
profile = data.get("profile") or {}
|
||||||
if status == "cancelled":
|
parts = [f"{k}={v}" for k, v in profile.items() if v]
|
||||||
return f"⏱ **{phase_label} отменена** · _{task}_"
|
return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}"
|
||||||
|
|
||||||
return f"⏱ Помидоро: {status}"
|
if tool_name == "update_session_summary" and data.get("ok"):
|
||||||
|
return "🧠 **Сводка чата сохранена**"
|
||||||
|
|
||||||
def _format_history_notice(data: Any) -> str:
|
if tool_name == "log_meal" and data.get("ok"):
|
||||||
if not isinstance(data, list) or not data:
|
meal = data.get("meal", {})
|
||||||
return "⏱ **История помидоро** пуста."
|
est = "≈" if meal.get("estimated") else ""
|
||||||
|
return (
|
||||||
lines = ["⏱ **История помидоро:**"]
|
f"💪 **Приём пищи** · {meal.get('description')} · "
|
||||||
for item in data[:10]:
|
f"{est}{meal.get('calories', 0):.0f} ккал "
|
||||||
task = item.get("task_note") or "без описания"
|
f"(Б{meal.get('protein_g', 0):.0f}/Ж{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})"
|
||||||
phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?"))
|
)
|
||||||
duration = item.get("duration_min", "?")
|
|
||||||
lines.append(f"- {phase}: {task} ({duration} мин)")
|
if tool_name == "log_water" and data.get("ok"):
|
||||||
|
w = data.get("water", {})
|
||||||
return "\n".join(lines)
|
return f"💪 **Вода** +{w.get('amount_ml')} мл"
|
||||||
|
|
||||||
|
if tool_name == "log_weight" and data.get("ok"):
|
||||||
def format_pomodoro_context(status: dict[str, Any]) -> str:
|
m = data.get("metric", {})
|
||||||
notice = _format_status_notice(status)
|
computed = data.get("computed") or {}
|
||||||
cycle = status.get("cycle", {})
|
headline = f"💪 **Вес** {m.get('weight_kg')} кг"
|
||||||
extra = ""
|
return _format_body_composition_notice(computed, headline=headline)
|
||||||
if cycle:
|
|
||||||
extra = (
|
if tool_name == "calc_body_composition" and isinstance(data, dict) and "error" not in data:
|
||||||
f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, "
|
w = data.get("weight_kg")
|
||||||
f"перерыв {cycle.get('short_break_min')} мин, "
|
headline = "💪 **Состав тела** (расчёт)"
|
||||||
f"длинный {cycle.get('long_break_min')} мин."
|
if w is not None:
|
||||||
)
|
headline += f" · {w} кг"
|
||||||
return f"[Актуальный статус помидоро]\n{notice}{extra}"
|
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}"
|
||||||
|
|||||||
+578
-172
@@ -1,172 +1,578 @@
|
|||||||
import json
|
import asyncio
|
||||||
from collections.abc import AsyncIterator
|
import json
|
||||||
from typing import Any
|
import logging
|
||||||
|
import time
|
||||||
from sqlalchemy import select
|
from collections.abc import AsyncIterator
|
||||||
from sqlalchemy.orm import Session
|
from typing import Any
|
||||||
|
|
||||||
from app.character.service import CharacterService
|
from sqlalchemy import select
|
||||||
from app.chat.notices import (
|
from sqlalchemy.orm import Session
|
||||||
POMODORO_TOOL_NAMES,
|
|
||||||
format_pomodoro_context,
|
from app.config import get_settings
|
||||||
format_tool_notice,
|
from app.db.base import SessionLocal
|
||||||
)
|
from app.character.service import CharacterService
|
||||||
from app.projects.context import format_projects_context, get_projects_snapshot
|
from app.chat.history import sanitize_openai_messages, strip_historical_reasoning
|
||||||
from app.db.models import ChatSession, Message
|
from app.chat.notice_inbox import DISPLAY_ONLY_ROLES
|
||||||
from app.llm.client import LLMClient
|
from app.chat.notices import (
|
||||||
from app.pomodoro.service import PomodoroService
|
POMODORO_TOOL_NAMES,
|
||||||
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
format_pomodoro_context,
|
||||||
|
format_tool_notice,
|
||||||
MAX_TOOL_ROUNDS = 5
|
)
|
||||||
|
from app.fitness.context import format_fitness_context, get_fitness_snapshot
|
||||||
|
from app.homelab.context import format_datetime_context
|
||||||
class ChatService:
|
from app.homelab.openmeteo import OpenMeteoClient, format_weather_snapshot
|
||||||
def __init__(self, db: Session):
|
from app.memory.context import (
|
||||||
self.db = db
|
format_identity_hint,
|
||||||
self.llm = LLMClient()
|
format_memory_context,
|
||||||
self.character = CharacterService()
|
get_memory_snapshot,
|
||||||
|
)
|
||||||
def list_sessions(self) -> list[ChatSession]:
|
from app.memory.extract import extract_after_turn
|
||||||
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
|
from app.projects.context import format_projects_context, get_projects_snapshot
|
||||||
return list(self.db.scalars(stmt).all())
|
from app.reminders_scoped.context import format_reminders_context, get_reminders_snapshot
|
||||||
|
from app.shopping.context import format_shopping_context, get_shopping_snapshot
|
||||||
def get_session(self, session_id: int) -> ChatSession | None:
|
from app.db.models import ChatSession, Message
|
||||||
return self.db.get(ChatSession, session_id)
|
from app.llm.client import LLMClient
|
||||||
|
from app.pomodoro.service import PomodoroService
|
||||||
def create_session(self, title: str = "Новый чат") -> ChatSession:
|
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
||||||
session = ChatSession(title=title)
|
from app.vision.analyze import format_vision_turn_hint
|
||||||
self.db.add(session)
|
|
||||||
self.db.commit()
|
MAX_TOOL_ROUNDS = 5
|
||||||
self.db.refresh(session)
|
MAX_HISTORY_MESSAGES = 40
|
||||||
return session
|
_DOMAIN_CACHE: dict[str, tuple[float, str]] = {}
|
||||||
|
_DOMAIN_TTL_SEC = 60.0
|
||||||
def delete_session(self, session_id: int) -> bool:
|
|
||||||
session = self.get_session(session_id)
|
_DOMAIN_KEYWORDS: dict[str, tuple[str, ...]] = {
|
||||||
if not session:
|
"fitness": ("фитнес", "тренир", "калори", "еда", "вода", "вес", "workout", "meal", "белок", "жир"),
|
||||||
return False
|
"shopping": ("покуп", "магазин", "список", "shopping", "корзин"),
|
||||||
self.db.delete(session)
|
"reminders": ("напомин", "календар", "событи", "дедлайн", "встреч", "план"),
|
||||||
self.db.commit()
|
"projects": ("taiga", "gitea", "задач", "проект", "git", "issue", "коммит", "ветк"),
|
||||||
return True
|
"weather": (
|
||||||
|
"погод", "дожд", "снег", "ветер", "температур", "градус", "мороз", "жар",
|
||||||
def _build_system_prompt(self) -> str:
|
"на улице", "одеть", "зонт", "прогноз", "завтра", "послезавтра", "выходн",
|
||||||
status = PomodoroService(self.db).get_status()
|
"weather", "rain", "forecast", "umbrella", "outside",
|
||||||
projects_snapshot = get_projects_snapshot(self.db)
|
),
|
||||||
return (
|
}
|
||||||
f"{self.character.get_system_prompt()}\n\n"
|
|
||||||
f"{format_pomodoro_context(status)}\n\n"
|
logger = logging.getLogger(__name__)
|
||||||
f"{format_projects_context(projects_snapshot)}"
|
|
||||||
)
|
|
||||||
|
def _build_messages_for_session(session_id: int, user_id: int) -> list[dict[str, Any]]:
|
||||||
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
|
db = SessionLocal()
|
||||||
messages: list[dict[str, Any]] = [
|
try:
|
||||||
{"role": "system", "content": self._build_system_prompt()}
|
service = ChatService(db, user_id)
|
||||||
]
|
session = service.get_session(session_id)
|
||||||
for msg in session.messages:
|
if not session:
|
||||||
if msg.role == "notice":
|
return []
|
||||||
continue
|
return service._build_messages(session)
|
||||||
|
finally:
|
||||||
content = msg.content or None
|
db.close()
|
||||||
entry: dict[str, Any] = {"role": msg.role, "content": content}
|
|
||||||
if msg.tool_calls_json:
|
|
||||||
entry["tool_calls"] = json.loads(msg.tool_calls_json)
|
async def _extract_memory_background(
|
||||||
if not content:
|
session_id: int,
|
||||||
entry["content"] = None
|
user_id: int,
|
||||||
if msg.role == "tool" and msg.tool_call_id:
|
user_text: str,
|
||||||
entry["tool_call_id"] = msg.tool_call_id
|
assistant_text: str,
|
||||||
messages.append(entry)
|
) -> None:
|
||||||
return messages
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
def _save_message(
|
await extract_after_turn(db, session_id, user_text, assistant_text, user_id=user_id)
|
||||||
self,
|
except Exception as exc:
|
||||||
session_id: int,
|
logger.warning("Background memory extraction failed: %s", exc)
|
||||||
role: str,
|
finally:
|
||||||
content: str = "",
|
db.close()
|
||||||
tool_calls: list[dict[str, Any]] | None = None,
|
|
||||||
tool_call_id: str | None = None,
|
|
||||||
) -> Message:
|
class ChatService:
|
||||||
message = Message(
|
def __init__(self, db: Session, user_id: int):
|
||||||
session_id=session_id,
|
self.db = db
|
||||||
role=role,
|
self.user_id = user_id
|
||||||
content=content,
|
self.llm = LLMClient()
|
||||||
tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None,
|
self.character = CharacterService(db, user_id)
|
||||||
tool_call_id=tool_call_id,
|
|
||||||
)
|
def list_sessions(self) -> list[ChatSession]:
|
||||||
self.db.add(message)
|
stmt = select(ChatSession).where(ChatSession.user_id == self.user_id).order_by(ChatSession.updated_at.desc())
|
||||||
session = self.get_session(session_id)
|
return list(self.db.scalars(stmt).all())
|
||||||
if session and role == "user" and session.title == "Новый чат" and content:
|
|
||||||
session.title = content[:60] + ("..." if len(content) > 60 else "")
|
def get_session(self, session_id: int) -> ChatSession | None:
|
||||||
self.db.commit()
|
session = self.db.get(ChatSession, session_id)
|
||||||
self.db.refresh(message)
|
if session and session.user_id != self.user_id:
|
||||||
return message
|
return None
|
||||||
|
return session
|
||||||
async def stream_response(self, session_id: int, user_text: str) -> AsyncIterator[str]:
|
|
||||||
session = self.get_session(session_id)
|
def list_messages(
|
||||||
if not session:
|
self,
|
||||||
yield self._sse("error", {"message": "Session not found"})
|
session_id: int,
|
||||||
return
|
limit: int = 30,
|
||||||
|
before_id: int | None = None,
|
||||||
self._save_message(session_id, "user", user_text)
|
after_id: int | None = None,
|
||||||
messages = self._build_messages(session)
|
) -> tuple[list[Message], bool]:
|
||||||
|
if not self.get_session(session_id):
|
||||||
for _ in range(MAX_TOOL_ROUNDS):
|
return [], False
|
||||||
content_parts: list[str] = []
|
|
||||||
tool_calls: list[dict[str, Any]] = []
|
if after_id is not None:
|
||||||
|
stmt = (
|
||||||
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
|
select(Message)
|
||||||
if event["type"] == "content":
|
.where(Message.session_id == session_id, Message.id > after_id)
|
||||||
content_parts.append(event["content"])
|
.order_by(Message.created_at.asc())
|
||||||
yield self._sse("token", {"content": event["content"]})
|
.limit(limit + 1)
|
||||||
elif event["type"] == "tool_calls":
|
)
|
||||||
tool_calls = event["tool_calls"]
|
rows = list(self.db.scalars(stmt).all())
|
||||||
|
has_more = len(rows) > limit
|
||||||
if tool_calls:
|
return rows[:limit], has_more
|
||||||
assistant_msg: dict[str, Any] = {
|
|
||||||
"role": "assistant",
|
stmt = select(Message).where(Message.session_id == session_id)
|
||||||
"content": "".join(content_parts) or None,
|
|
||||||
"tool_calls": tool_calls,
|
if before_id is not None:
|
||||||
}
|
anchor = self.db.get(Message, before_id)
|
||||||
messages.append(assistant_msg)
|
if anchor is None or anchor.session_id != session_id:
|
||||||
self._save_message(
|
return [], False
|
||||||
session_id,
|
stmt = stmt.where(Message.created_at < anchor.created_at)
|
||||||
"assistant",
|
|
||||||
"".join(content_parts),
|
stmt = stmt.order_by(Message.created_at.desc()).limit(limit + 1)
|
||||||
tool_calls=tool_calls,
|
rows = list(self.db.scalars(stmt).all())
|
||||||
)
|
has_more = len(rows) > limit
|
||||||
|
page = rows[:limit]
|
||||||
for tool_call in tool_calls:
|
page.reverse()
|
||||||
fn = tool_call["function"]
|
return page, has_more
|
||||||
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
|
||||||
result = await execute_tool(self.db, fn["name"], args)
|
def create_session(self, title: str = "Новый чат") -> ChatSession:
|
||||||
tool_message = {
|
session = ChatSession(user_id=self.user_id, title=title)
|
||||||
"role": "tool",
|
self.db.add(session)
|
||||||
"tool_call_id": tool_call["id"],
|
self.db.commit()
|
||||||
"content": result,
|
self.db.refresh(session)
|
||||||
}
|
return session
|
||||||
messages.append(tool_message)
|
|
||||||
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
|
def delete_session(self, session_id: int) -> bool:
|
||||||
|
session = self.get_session(session_id)
|
||||||
notice = format_tool_notice(fn["name"], result)
|
if not session:
|
||||||
if notice:
|
return False
|
||||||
self._save_message(session_id, "notice", notice)
|
self.db.delete(session)
|
||||||
yield self._sse("notice", {"content": notice})
|
self.db.commit()
|
||||||
|
return True
|
||||||
if fn["name"] in POMODORO_TOOL_NAMES:
|
|
||||||
yield self._sse(
|
def _cached_domain(self, key: str, loader, formatter) -> str:
|
||||||
"pomodoro",
|
now = time.monotonic()
|
||||||
{"name": fn["name"], "result": json.loads(result)},
|
hit = _DOMAIN_CACHE.get(f"{self.user_id}:{key}")
|
||||||
)
|
if hit and now < hit[0]:
|
||||||
|
return hit[1]
|
||||||
continue
|
rendered = formatter(loader())
|
||||||
|
_DOMAIN_CACHE[f"{self.user_id}:{key}"] = (now + _DOMAIN_TTL_SEC, rendered)
|
||||||
final_content = "".join(content_parts)
|
return rendered
|
||||||
if final_content:
|
|
||||||
self._save_message(session_id, "assistant", final_content)
|
def _domain_relevant(self, key: str, user_query: str) -> bool:
|
||||||
|
query = user_query.strip().lower()
|
||||||
yield self._sse("done", {})
|
if not query:
|
||||||
return
|
return False
|
||||||
|
keywords = _DOMAIN_KEYWORDS.get(key, ())
|
||||||
yield self._sse("error", {"message": "Too many tool call rounds"})
|
return any(kw in query for kw in keywords)
|
||||||
|
|
||||||
@staticmethod
|
def _optional_domain(
|
||||||
def _sse(event: str, data: dict[str, Any]) -> str:
|
self,
|
||||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
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),
|
||||||
|
self._optional_domain(
|
||||||
|
"weather",
|
||||||
|
user_query,
|
||||||
|
lambda: OpenMeteoClient().fetch_forecast(hours_ahead=6, days_ahead=7),
|
||||||
|
lambda snap: format_weather_snapshot(snap, include_daily=True),
|
||||||
|
),
|
||||||
|
format_pomodoro_context(status),
|
||||||
|
self._optional_domain("projects", user_query, lambda: projects_snapshot, format_projects_context),
|
||||||
|
]
|
||||||
|
return "\n\n".join(part for part in parts if part.strip())
|
||||||
|
|
||||||
|
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
|
||||||
|
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
|
||||||
|
last_user = next((m.content for m in reversed(all_chat) if m.role == "user"), "")
|
||||||
|
system_prompt = self._build_system_prompt(session.id, user_query=last_user)
|
||||||
|
if last_user:
|
||||||
|
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session.id, query=last_user)
|
||||||
|
identity_hint = format_identity_hint(memory_snapshot, last_user)
|
||||||
|
if identity_hint:
|
||||||
|
system_prompt += f"\n\n{identity_hint}"
|
||||||
|
vision_hint = format_vision_turn_hint(last_user)
|
||||||
|
if vision_hint:
|
||||||
|
system_prompt += f"\n\n{vision_hint}"
|
||||||
|
if len(all_chat) > MAX_HISTORY_MESSAGES:
|
||||||
|
system_prompt += (
|
||||||
|
f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} "
|
||||||
|
f"из {len(all_chat)} сообщений. Раннее — в сводке сессии, если сохранена.]"
|
||||||
|
)
|
||||||
|
messages: list[dict[str, Any]] = [
|
||||||
|
{"role": "system", "content": system_prompt}
|
||||||
|
]
|
||||||
|
chat_messages = all_chat[-MAX_HISTORY_MESSAGES:] if len(all_chat) > MAX_HISTORY_MESSAGES else all_chat
|
||||||
|
|
||||||
|
for msg in chat_messages:
|
||||||
|
content = msg.content or None
|
||||||
|
entry: dict[str, Any] = {"role": msg.role, "content": content}
|
||||||
|
if msg.tool_calls_json:
|
||||||
|
entry["tool_calls"] = json.loads(msg.tool_calls_json)
|
||||||
|
if not content:
|
||||||
|
entry["content"] = None
|
||||||
|
reasoning_data = LLMClient.deserialize_reasoning(msg.reasoning_json)
|
||||||
|
if reasoning_data:
|
||||||
|
LLMClient.attach_reasoning_to_message(
|
||||||
|
entry,
|
||||||
|
reasoning=reasoning_data.get("reasoning", ""),
|
||||||
|
reasoning_details=reasoning_data.get("reasoning_details"),
|
||||||
|
)
|
||||||
|
if msg.role == "tool" and msg.tool_call_id:
|
||||||
|
entry["tool_call_id"] = msg.tool_call_id
|
||||||
|
messages.append(entry)
|
||||||
|
messages = sanitize_openai_messages(messages)
|
||||||
|
messages = strip_historical_reasoning(messages)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def _save_message(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
role: str,
|
||||||
|
content: str = "",
|
||||||
|
tool_calls: list[dict[str, Any]] | None = None,
|
||||||
|
tool_call_id: str | None = None,
|
||||||
|
reasoning_json: str | None = None,
|
||||||
|
) -> Message:
|
||||||
|
message = Message(
|
||||||
|
session_id=session_id,
|
||||||
|
role=role,
|
||||||
|
content=content,
|
||||||
|
tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None,
|
||||||
|
reasoning_json=reasoning_json,
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
)
|
||||||
|
self.db.add(message)
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if session and role == "user" and session.title == "Новый чат" and content:
|
||||||
|
session.title = content[:60] + ("..." if len(content) > 60 else "")
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(message)
|
||||||
|
return message
|
||||||
|
|
||||||
|
def save_user_message(self, session_id: int, user_text: str) -> None:
|
||||||
|
self._save_message(session_id, "user", user_text)
|
||||||
|
|
||||||
|
async def _fallback_complete(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
session_id: int,
|
||||||
|
) -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||||
|
"""Нестриминговый запасной путь, если stream вернул пустоту."""
|
||||||
|
logger.info("chat session=%s fallback complete", session_id)
|
||||||
|
result: dict[str, Any] = {"content": "", "tool_calls": []}
|
||||||
|
for with_tools in (True, False):
|
||||||
|
result = await self.llm.complete(
|
||||||
|
messages,
|
||||||
|
tools=TOOL_DEFINITIONS if with_tools else None,
|
||||||
|
temperature=0.5,
|
||||||
|
visible_reply=True,
|
||||||
|
)
|
||||||
|
if (result.get("content") or "").strip() or result.get("tool_calls"):
|
||||||
|
break
|
||||||
|
|
||||||
|
tool_calls = result.get("tool_calls") or []
|
||||||
|
content = (result.get("content") or "").strip()
|
||||||
|
notices: list[str] = []
|
||||||
|
pomodoro_events: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
if tool_calls:
|
||||||
|
assistant_msg: dict[str, Any] = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": content or None,
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
}
|
||||||
|
messages.append(assistant_msg)
|
||||||
|
self._save_message(
|
||||||
|
session_id,
|
||||||
|
"assistant",
|
||||||
|
content,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
)
|
||||||
|
for tool_call in tool_calls:
|
||||||
|
fn = tool_call["function"]
|
||||||
|
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||||
|
tool_result = await execute_tool(
|
||||||
|
self.db, fn["name"], args, session_id=session_id, user_id=self.user_id
|
||||||
|
)
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_call["id"],
|
||||||
|
"content": tool_result,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._save_message(
|
||||||
|
session_id,
|
||||||
|
"tool",
|
||||||
|
tool_result,
|
||||||
|
tool_call_id=tool_call["id"],
|
||||||
|
)
|
||||||
|
notice = format_tool_notice(fn["name"], tool_result)
|
||||||
|
if notice:
|
||||||
|
self._save_message(session_id, "notice", notice)
|
||||||
|
notices.append(notice)
|
||||||
|
if fn["name"] in POMODORO_TOOL_NAMES:
|
||||||
|
pomodoro_events.append(
|
||||||
|
{"name": fn["name"], "result": json.loads(tool_result)}
|
||||||
|
)
|
||||||
|
|
||||||
|
followup = await self.llm.complete(
|
||||||
|
messages,
|
||||||
|
tools=None,
|
||||||
|
temperature=0.4,
|
||||||
|
visible_reply=True,
|
||||||
|
)
|
||||||
|
return (followup.get("content") or "").strip(), notices, pomodoro_events
|
||||||
|
|
||||||
|
return content, notices, pomodoro_events
|
||||||
|
|
||||||
|
def context_preview(self, session_id: int, query: str | None = None) -> dict[str, Any]:
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
return {"ok": False, "error": "Session not found"}
|
||||||
|
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
|
||||||
|
last_user = query or next((m.content for m in reversed(all_chat) if m.role == "user"), "")
|
||||||
|
system_prompt = self._build_system_prompt(session_id, user_query=last_user)
|
||||||
|
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session_id, query=last_user)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"session_id": session_id,
|
||||||
|
"query": last_user,
|
||||||
|
"system_prompt_chars": len(system_prompt),
|
||||||
|
"memory_facts": len(memory_snapshot.get("facts") or []),
|
||||||
|
"memory_total_facts": memory_snapshot.get("total_facts", 0),
|
||||||
|
"system_prompt_preview": system_prompt[:4000],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def stream_response(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
user_text: str,
|
||||||
|
*,
|
||||||
|
user_message_saved: bool = False,
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
yield self._sse("error", {"message": "Session not found"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not user_message_saved:
|
||||||
|
self._save_message(session_id, "user", user_text)
|
||||||
|
yield self._sse("status", {"phase": "preparing"})
|
||||||
|
t0 = time.monotonic()
|
||||||
|
messages = await asyncio.to_thread(_build_messages_for_session, session_id, self.user_id)
|
||||||
|
prepare_sec = time.monotonic() - t0
|
||||||
|
if not messages:
|
||||||
|
yield self._sse("error", {"message": "Session not found"})
|
||||||
|
return
|
||||||
|
yield self._sse("status", {"phase": "generating"})
|
||||||
|
streamed_reply_parts: list[str] = []
|
||||||
|
all_tool_notices: list[str] = []
|
||||||
|
tools_executed = 0
|
||||||
|
tool_round = 0
|
||||||
|
|
||||||
|
for _ in range(MAX_TOOL_ROUNDS):
|
||||||
|
tool_round += 1
|
||||||
|
t_round = time.monotonic()
|
||||||
|
content_parts: list[str] = []
|
||||||
|
tool_calls: list[dict[str, Any]] = []
|
||||||
|
reasoning = ""
|
||||||
|
reasoning_details: list[Any] | None = None
|
||||||
|
finish_reason = ""
|
||||||
|
|
||||||
|
# После tool-раунда стримим вживую; до tools — буфер (иначе текст «переписывает» notice).
|
||||||
|
stream_live = tools_executed > 0
|
||||||
|
|
||||||
|
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
|
||||||
|
if event["type"] == "content":
|
||||||
|
content_parts.append(event["content"])
|
||||||
|
if stream_live:
|
||||||
|
yield self._sse("token", {"content": event["content"]})
|
||||||
|
elif event["type"] == "reasoning":
|
||||||
|
reasoning = event.get("reasoning", "") or reasoning
|
||||||
|
if event.get("reasoning_details"):
|
||||||
|
reasoning_details = event["reasoning_details"]
|
||||||
|
elif event["type"] == "error":
|
||||||
|
logger.warning(
|
||||||
|
"chat session=%s llm_error round=%d prepare=%.2fs: %s",
|
||||||
|
session_id,
|
||||||
|
tool_round,
|
||||||
|
prepare_sec,
|
||||||
|
event.get("content"),
|
||||||
|
)
|
||||||
|
yield self._sse("error", {"message": event.get("content", "LLM error")})
|
||||||
|
return
|
||||||
|
elif event["type"] == "tool_calls":
|
||||||
|
tool_calls = event["tool_calls"]
|
||||||
|
elif event["type"] == "done":
|
||||||
|
finish_reason = event.get("finish_reason", "")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"chat session=%s round=%d prepare=%.2fs llm=%.2fs "
|
||||||
|
"content_len=%d tool_calls=%d finish_reason=%s reasoning_len=%d",
|
||||||
|
session_id,
|
||||||
|
tool_round,
|
||||||
|
prepare_sec,
|
||||||
|
time.monotonic() - t_round,
|
||||||
|
len("".join(content_parts)),
|
||||||
|
len(tool_calls),
|
||||||
|
finish_reason,
|
||||||
|
len(reasoning),
|
||||||
|
)
|
||||||
|
|
||||||
|
if tool_calls:
|
||||||
|
round_text = "".join(content_parts)
|
||||||
|
if round_text.strip():
|
||||||
|
streamed_reply_parts.append(round_text)
|
||||||
|
|
||||||
|
assistant_msg: dict[str, Any] = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": round_text or None,
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
}
|
||||||
|
LLMClient.attach_reasoning_to_message(
|
||||||
|
assistant_msg,
|
||||||
|
reasoning=reasoning,
|
||||||
|
reasoning_details=reasoning_details,
|
||||||
|
)
|
||||||
|
reasoning_json = LLMClient.serialize_reasoning(
|
||||||
|
reasoning=reasoning,
|
||||||
|
reasoning_details=reasoning_details,
|
||||||
|
)
|
||||||
|
messages.append(assistant_msg)
|
||||||
|
self._save_message(
|
||||||
|
session_id,
|
||||||
|
"assistant",
|
||||||
|
round_text,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
reasoning_json=reasoning_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
round_notices: list[str] = []
|
||||||
|
for tool_call in tool_calls:
|
||||||
|
fn = tool_call["function"]
|
||||||
|
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||||
|
result = await execute_tool(
|
||||||
|
self.db, fn["name"], args, session_id=session_id, user_id=self.user_id
|
||||||
|
)
|
||||||
|
tools_executed += 1
|
||||||
|
tool_message = {
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_call["id"],
|
||||||
|
"content": result,
|
||||||
|
}
|
||||||
|
messages.append(tool_message)
|
||||||
|
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
|
||||||
|
|
||||||
|
notice = format_tool_notice(fn["name"], result)
|
||||||
|
if notice:
|
||||||
|
self._save_message(session_id, "notice", notice)
|
||||||
|
round_notices.append(notice)
|
||||||
|
all_tool_notices.append(notice)
|
||||||
|
|
||||||
|
if fn["name"] in POMODORO_TOOL_NAMES:
|
||||||
|
yield self._sse(
|
||||||
|
"pomodoro",
|
||||||
|
{"name": fn["name"], "result": json.loads(result)},
|
||||||
|
)
|
||||||
|
|
||||||
|
yield self._sse("status", {"phase": "tools"})
|
||||||
|
for notice in round_notices:
|
||||||
|
yield self._sse("notice", {"content": notice})
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
if content_parts and not stream_live:
|
||||||
|
for part in content_parts:
|
||||||
|
yield self._sse("token", {"content": part})
|
||||||
|
|
||||||
|
final_content = "".join(content_parts).strip()
|
||||||
|
if not final_content and streamed_reply_parts and tools_executed == 0:
|
||||||
|
final_content = "".join(streamed_reply_parts).strip()
|
||||||
|
if not final_content and reasoning:
|
||||||
|
final_content = reasoning.strip()
|
||||||
|
if not final_content and tools_executed:
|
||||||
|
retry = await self.llm.complete(
|
||||||
|
messages,
|
||||||
|
tools=None,
|
||||||
|
temperature=0.4,
|
||||||
|
visible_reply=True,
|
||||||
|
)
|
||||||
|
final_content = (retry.get("content") or "").strip()
|
||||||
|
if final_content:
|
||||||
|
yield self._sse("token", {"content": final_content})
|
||||||
|
# Notices уже в чате как role=notice — не дублируем в assistant.
|
||||||
|
if not final_content:
|
||||||
|
final_content, fb_notices, fb_pomodoro = await self._fallback_complete(
|
||||||
|
messages, session_id
|
||||||
|
)
|
||||||
|
if final_content:
|
||||||
|
yield self._sse("token", {"content": final_content})
|
||||||
|
for notice in fb_notices:
|
||||||
|
yield self._sse("notice", {"content": notice})
|
||||||
|
for event in fb_pomodoro:
|
||||||
|
yield self._sse("pomodoro", event)
|
||||||
|
|
||||||
|
if not final_content:
|
||||||
|
logger.warning(
|
||||||
|
"chat session=%s empty_reply tools=%d rounds=%d finish_reason=%s",
|
||||||
|
session_id,
|
||||||
|
tools_executed,
|
||||||
|
tool_round,
|
||||||
|
finish_reason,
|
||||||
|
)
|
||||||
|
yield self._sse(
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"message": (
|
||||||
|
"Модель не вернула ответ (finish_reason="
|
||||||
|
f"{finish_reason or 'unknown'}). "
|
||||||
|
"Попробуй новый чат или проверь OPENROUTER_MODEL."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._save_message(session_id, "assistant", final_content)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"chat session=%s done tools=%d reply_len=%d total=%.2fs",
|
||||||
|
session_id,
|
||||||
|
tools_executed,
|
||||||
|
len(final_content),
|
||||||
|
time.monotonic() - t0,
|
||||||
|
)
|
||||||
|
yield self._sse("done", {})
|
||||||
|
if get_settings().memory_auto_extract:
|
||||||
|
asyncio.create_task(
|
||||||
|
_extract_memory_background(session_id, self.user_id, user_text, final_content)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
yield self._sse("error", {"message": "Too many tool call rounds"})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sse(event: str, data: dict[str, Any]) -> str:
|
||||||
|
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
from app.character.service import CharacterService
|
||||||
|
from app.chat.history import sanitize_openai_messages, strip_historical_reasoning
|
||||||
|
from app.chat.notice_inbox import DISPLAY_ONLY_ROLES
|
||||||
|
from app.chat.notices import (
|
||||||
|
POMODORO_TOOL_NAMES,
|
||||||
|
format_pomodoro_context,
|
||||||
|
format_tool_notice,
|
||||||
|
)
|
||||||
|
from app.fitness.context import format_fitness_context, get_fitness_snapshot
|
||||||
|
from app.homelab.context import format_datetime_context
|
||||||
|
from app.homelab.openmeteo import format_weather_snapshot
|
||||||
|
from app.memory.context import (
|
||||||
|
format_identity_hint,
|
||||||
|
format_memory_context,
|
||||||
|
get_memory_snapshot,
|
||||||
|
)
|
||||||
|
from app.memory.extract import extract_after_turn
|
||||||
|
from app.projects.context import format_projects_context, get_projects_snapshot
|
||||||
|
from app.reminders.context import format_reminders_context, get_reminders_snapshot
|
||||||
|
from app.shopping.context import format_shopping_context, get_shopping_snapshot
|
||||||
|
from app.db.models import ChatSession, Message
|
||||||
|
from app.llm.client import LLMClient
|
||||||
|
from app.pomodoro.service import PomodoroService
|
||||||
|
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
||||||
|
|
||||||
|
MAX_TOOL_ROUNDS = 5
|
||||||
|
MAX_HISTORY_MESSAGES = 40
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_messages_for_session(session_id: int) -> list[dict[str, Any]]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
service = ChatService(db)
|
||||||
|
session = service.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
return []
|
||||||
|
return service._build_messages(session)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_memory_background(
|
||||||
|
session_id: int,
|
||||||
|
user_text: str,
|
||||||
|
assistant_text: str,
|
||||||
|
) -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
await extract_after_turn(db, session_id, user_text, assistant_text)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Background memory extraction failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
class ChatService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
self.llm = LLMClient()
|
||||||
|
self.character = CharacterService()
|
||||||
|
|
||||||
|
def list_sessions(self) -> list[ChatSession]:
|
||||||
|
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
|
||||||
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
|
def get_session(self, session_id: int) -> ChatSession | None:
|
||||||
|
return self.db.get(ChatSession, session_id)
|
||||||
|
|
||||||
|
def create_session(self, title: str = "Новый чат") -> ChatSession:
|
||||||
|
session = ChatSession(title=title)
|
||||||
|
self.db.add(session)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def delete_session(self, session_id: int) -> bool:
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
return False
|
||||||
|
self.db.delete(session)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _build_system_prompt(self, session_id: int | None = None) -> str:
|
||||||
|
status = PomodoroService(self.db).get_status()
|
||||||
|
memory_snapshot = get_memory_snapshot(self.db, session_id)
|
||||||
|
fitness_snapshot = get_fitness_snapshot(self.db)
|
||||||
|
shopping_snapshot = get_shopping_snapshot(self.db)
|
||||||
|
reminders_snapshot = get_reminders_snapshot(self.db)
|
||||||
|
projects_snapshot = get_projects_snapshot(self.db)
|
||||||
|
return (
|
||||||
|
f"{self.character.get_system_prompt()}\n\n"
|
||||||
|
f"{format_datetime_context(self.db)}\n\n"
|
||||||
|
f"{format_memory_context(memory_snapshot)}\n\n"
|
||||||
|
f"{format_fitness_context(fitness_snapshot)}\n\n"
|
||||||
|
f"{format_shopping_context(shopping_snapshot)}\n\n"
|
||||||
|
f"{format_reminders_context(reminders_snapshot)}\n\n"
|
||||||
|
f"{format_weather_snapshot()}\n\n"
|
||||||
|
f"{format_pomodoro_context(status)}\n\n"
|
||||||
|
f"{format_projects_context(projects_snapshot)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
|
||||||
|
system_prompt = self._build_system_prompt(session.id)
|
||||||
|
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
|
||||||
|
last_user = next((m.content for m in reversed(all_chat) if m.role == "user"), "")
|
||||||
|
if last_user:
|
||||||
|
memory_snapshot = get_memory_snapshot(self.db, session.id)
|
||||||
|
identity_hint = format_identity_hint(memory_snapshot, last_user)
|
||||||
|
if identity_hint:
|
||||||
|
system_prompt += f"\n\n{identity_hint}"
|
||||||
|
if len(all_chat) > MAX_HISTORY_MESSAGES:
|
||||||
|
system_prompt += (
|
||||||
|
f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} "
|
||||||
|
f"из {len(all_chat)} сообщений. Раннее — в сводке сессии, если сохранена.]"
|
||||||
|
)
|
||||||
|
messages: list[dict[str, Any]] = [
|
||||||
|
{"role": "system", "content": system_prompt}
|
||||||
|
]
|
||||||
|
chat_messages = all_chat[-MAX_HISTORY_MESSAGES:] if len(all_chat) > MAX_HISTORY_MESSAGES else all_chat
|
||||||
|
|
||||||
|
for msg in chat_messages:
|
||||||
|
content = msg.content or None
|
||||||
|
entry: dict[str, Any] = {"role": msg.role, "content": content}
|
||||||
|
if msg.tool_calls_json:
|
||||||
|
entry["tool_calls"] = json.loads(msg.tool_calls_json)
|
||||||
|
if not content:
|
||||||
|
entry["content"] = None
|
||||||
|
reasoning_data = LLMClient.deserialize_reasoning(msg.reasoning_json)
|
||||||
|
if reasoning_data:
|
||||||
|
LLMClient.attach_reasoning_to_message(
|
||||||
|
entry,
|
||||||
|
reasoning=reasoning_data.get("reasoning", ""),
|
||||||
|
reasoning_details=reasoning_data.get("reasoning_details"),
|
||||||
|
)
|
||||||
|
if msg.role == "tool" and msg.tool_call_id:
|
||||||
|
entry["tool_call_id"] = msg.tool_call_id
|
||||||
|
messages.append(entry)
|
||||||
|
messages = sanitize_openai_messages(messages)
|
||||||
|
messages = strip_historical_reasoning(messages)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def _save_message(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
role: str,
|
||||||
|
content: str = "",
|
||||||
|
tool_calls: list[dict[str, Any]] | None = None,
|
||||||
|
tool_call_id: str | None = None,
|
||||||
|
reasoning_json: str | None = None,
|
||||||
|
) -> Message:
|
||||||
|
message = Message(
|
||||||
|
session_id=session_id,
|
||||||
|
role=role,
|
||||||
|
content=content,
|
||||||
|
tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None,
|
||||||
|
reasoning_json=reasoning_json,
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
)
|
||||||
|
self.db.add(message)
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if session and role == "user" and session.title == "Новый чат" and content:
|
||||||
|
session.title = content[:60] + ("..." if len(content) > 60 else "")
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(message)
|
||||||
|
return message
|
||||||
|
|
||||||
|
def save_user_message(self, session_id: int, user_text: str) -> None:
|
||||||
|
self._save_message(session_id, "user", user_text)
|
||||||
|
|
||||||
|
async def _fallback_complete(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
session_id: int,
|
||||||
|
) -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||||
|
"""Нестриминговый запасной путь, если stream вернул пустоту."""
|
||||||
|
logger.info("chat session=%s fallback complete", session_id)
|
||||||
|
result: dict[str, Any] = {"content": "", "tool_calls": []}
|
||||||
|
for with_tools in (True, False):
|
||||||
|
result = await self.llm.complete(
|
||||||
|
messages,
|
||||||
|
tools=TOOL_DEFINITIONS if with_tools else None,
|
||||||
|
temperature=0.5,
|
||||||
|
visible_reply=True,
|
||||||
|
)
|
||||||
|
if (result.get("content") or "").strip() or result.get("tool_calls"):
|
||||||
|
break
|
||||||
|
|
||||||
|
tool_calls = result.get("tool_calls") or []
|
||||||
|
content = (result.get("content") or "").strip()
|
||||||
|
notices: list[str] = []
|
||||||
|
pomodoro_events: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
if tool_calls:
|
||||||
|
assistant_msg: dict[str, Any] = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": content or None,
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
}
|
||||||
|
messages.append(assistant_msg)
|
||||||
|
self._save_message(
|
||||||
|
session_id,
|
||||||
|
"assistant",
|
||||||
|
content,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
)
|
||||||
|
for tool_call in tool_calls:
|
||||||
|
fn = tool_call["function"]
|
||||||
|
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||||
|
tool_result = await execute_tool(
|
||||||
|
self.db, fn["name"], args, session_id=session_id
|
||||||
|
)
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_call["id"],
|
||||||
|
"content": tool_result,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._save_message(
|
||||||
|
session_id,
|
||||||
|
"tool",
|
||||||
|
tool_result,
|
||||||
|
tool_call_id=tool_call["id"],
|
||||||
|
)
|
||||||
|
notice = format_tool_notice(fn["name"], tool_result)
|
||||||
|
if notice:
|
||||||
|
self._save_message(session_id, "notice", notice)
|
||||||
|
notices.append(notice)
|
||||||
|
if fn["name"] in POMODORO_TOOL_NAMES:
|
||||||
|
pomodoro_events.append(
|
||||||
|
{"name": fn["name"], "result": json.loads(tool_result)}
|
||||||
|
)
|
||||||
|
|
||||||
|
followup = await self.llm.complete(
|
||||||
|
messages,
|
||||||
|
tools=None,
|
||||||
|
temperature=0.4,
|
||||||
|
visible_reply=True,
|
||||||
|
)
|
||||||
|
return (followup.get("content") or "").strip(), notices, pomodoro_events
|
||||||
|
|
||||||
|
return content, notices, pomodoro_events
|
||||||
|
|
||||||
|
async def stream_response(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
user_text: str,
|
||||||
|
*,
|
||||||
|
user_message_saved: bool = False,
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
yield self._sse("error", {"message": "Session not found"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not user_message_saved:
|
||||||
|
self._save_message(session_id, "user", user_text)
|
||||||
|
yield self._sse("status", {"phase": "preparing"})
|
||||||
|
t0 = time.monotonic()
|
||||||
|
messages = await asyncio.to_thread(_build_messages_for_session, session_id)
|
||||||
|
prepare_sec = time.monotonic() - t0
|
||||||
|
if not messages:
|
||||||
|
yield self._sse("error", {"message": "Session not found"})
|
||||||
|
return
|
||||||
|
yield self._sse("status", {"phase": "generating"})
|
||||||
|
streamed_reply_parts: list[str] = []
|
||||||
|
all_tool_notices: list[str] = []
|
||||||
|
tools_executed = 0
|
||||||
|
tool_round = 0
|
||||||
|
|
||||||
|
for _ in range(MAX_TOOL_ROUNDS):
|
||||||
|
tool_round += 1
|
||||||
|
t_round = time.monotonic()
|
||||||
|
content_parts: list[str] = []
|
||||||
|
tool_calls: list[dict[str, Any]] = []
|
||||||
|
reasoning = ""
|
||||||
|
reasoning_details: list[Any] | None = None
|
||||||
|
finish_reason = ""
|
||||||
|
|
||||||
|
# После tool-раунда стримим вживую; до tools — буфер (иначе текст «переписывает» notice).
|
||||||
|
stream_live = tools_executed > 0
|
||||||
|
|
||||||
|
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
|
||||||
|
if event["type"] == "content":
|
||||||
|
content_parts.append(event["content"])
|
||||||
|
if stream_live:
|
||||||
|
yield self._sse("token", {"content": event["content"]})
|
||||||
|
elif event["type"] == "reasoning":
|
||||||
|
reasoning = event.get("reasoning", "") or reasoning
|
||||||
|
if event.get("reasoning_details"):
|
||||||
|
reasoning_details = event["reasoning_details"]
|
||||||
|
elif event["type"] == "error":
|
||||||
|
logger.warning(
|
||||||
|
"chat session=%s llm_error round=%d prepare=%.2fs: %s",
|
||||||
|
session_id,
|
||||||
|
tool_round,
|
||||||
|
prepare_sec,
|
||||||
|
event.get("content"),
|
||||||
|
)
|
||||||
|
yield self._sse("error", {"message": event.get("content", "LLM error")})
|
||||||
|
return
|
||||||
|
elif event["type"] == "tool_calls":
|
||||||
|
tool_calls = event["tool_calls"]
|
||||||
|
elif event["type"] == "done":
|
||||||
|
finish_reason = event.get("finish_reason", "")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"chat session=%s round=%d prepare=%.2fs llm=%.2fs "
|
||||||
|
"content_len=%d tool_calls=%d finish_reason=%s reasoning_len=%d",
|
||||||
|
session_id,
|
||||||
|
tool_round,
|
||||||
|
prepare_sec,
|
||||||
|
time.monotonic() - t_round,
|
||||||
|
len("".join(content_parts)),
|
||||||
|
len(tool_calls),
|
||||||
|
finish_reason,
|
||||||
|
len(reasoning),
|
||||||
|
)
|
||||||
|
|
||||||
|
if tool_calls:
|
||||||
|
round_text = "".join(content_parts)
|
||||||
|
if round_text.strip():
|
||||||
|
streamed_reply_parts.append(round_text)
|
||||||
|
|
||||||
|
assistant_msg: dict[str, Any] = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": round_text or None,
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
}
|
||||||
|
LLMClient.attach_reasoning_to_message(
|
||||||
|
assistant_msg,
|
||||||
|
reasoning=reasoning,
|
||||||
|
reasoning_details=reasoning_details,
|
||||||
|
)
|
||||||
|
reasoning_json = LLMClient.serialize_reasoning(
|
||||||
|
reasoning=reasoning,
|
||||||
|
reasoning_details=reasoning_details,
|
||||||
|
)
|
||||||
|
messages.append(assistant_msg)
|
||||||
|
self._save_message(
|
||||||
|
session_id,
|
||||||
|
"assistant",
|
||||||
|
round_text,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
reasoning_json=reasoning_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
round_notices: list[str] = []
|
||||||
|
for tool_call in tool_calls:
|
||||||
|
fn = tool_call["function"]
|
||||||
|
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||||
|
result = await execute_tool(
|
||||||
|
self.db, fn["name"], args, session_id=session_id
|
||||||
|
)
|
||||||
|
tools_executed += 1
|
||||||
|
tool_message = {
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_call["id"],
|
||||||
|
"content": result,
|
||||||
|
}
|
||||||
|
messages.append(tool_message)
|
||||||
|
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
|
||||||
|
|
||||||
|
notice = format_tool_notice(fn["name"], result)
|
||||||
|
if notice:
|
||||||
|
self._save_message(session_id, "notice", notice)
|
||||||
|
round_notices.append(notice)
|
||||||
|
all_tool_notices.append(notice)
|
||||||
|
|
||||||
|
if fn["name"] in POMODORO_TOOL_NAMES:
|
||||||
|
yield self._sse(
|
||||||
|
"pomodoro",
|
||||||
|
{"name": fn["name"], "result": json.loads(result)},
|
||||||
|
)
|
||||||
|
|
||||||
|
yield self._sse("status", {"phase": "tools"})
|
||||||
|
for notice in round_notices:
|
||||||
|
yield self._sse("notice", {"content": notice})
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
if content_parts and not stream_live:
|
||||||
|
for part in content_parts:
|
||||||
|
yield self._sse("token", {"content": part})
|
||||||
|
|
||||||
|
final_content = "".join(content_parts).strip()
|
||||||
|
if not final_content and streamed_reply_parts and tools_executed == 0:
|
||||||
|
final_content = "".join(streamed_reply_parts).strip()
|
||||||
|
if not final_content and reasoning:
|
||||||
|
final_content = reasoning.strip()
|
||||||
|
if not final_content and tools_executed:
|
||||||
|
retry = await self.llm.complete(
|
||||||
|
messages,
|
||||||
|
tools=None,
|
||||||
|
temperature=0.4,
|
||||||
|
visible_reply=True,
|
||||||
|
)
|
||||||
|
final_content = (retry.get("content") or "").strip()
|
||||||
|
if final_content:
|
||||||
|
yield self._sse("token", {"content": final_content})
|
||||||
|
# Notices уже в чате как role=notice — не дублируем в assistant.
|
||||||
|
if not final_content:
|
||||||
|
final_content, fb_notices, fb_pomodoro = await self._fallback_complete(
|
||||||
|
messages, session_id
|
||||||
|
)
|
||||||
|
if final_content:
|
||||||
|
yield self._sse("token", {"content": final_content})
|
||||||
|
for notice in fb_notices:
|
||||||
|
yield self._sse("notice", {"content": notice})
|
||||||
|
for event in fb_pomodoro:
|
||||||
|
yield self._sse("pomodoro", event)
|
||||||
|
|
||||||
|
if not final_content:
|
||||||
|
logger.warning(
|
||||||
|
"chat session=%s empty_reply tools=%d rounds=%d finish_reason=%s",
|
||||||
|
session_id,
|
||||||
|
tools_executed,
|
||||||
|
tool_round,
|
||||||
|
finish_reason,
|
||||||
|
)
|
||||||
|
yield self._sse(
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"message": (
|
||||||
|
"Модель не вернула ответ (finish_reason="
|
||||||
|
f"{finish_reason or 'unknown'}). "
|
||||||
|
"Попробуй новый чат или проверь OPENROUTER_MODEL."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._save_message(session_id, "assistant", final_content)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"chat session=%s done tools=%d reply_len=%d total=%.2fs",
|
||||||
|
session_id,
|
||||||
|
tools_executed,
|
||||||
|
len(final_content),
|
||||||
|
time.monotonic() - t0,
|
||||||
|
)
|
||||||
|
yield self._sse("done", {})
|
||||||
|
if get_settings().memory_auto_extract:
|
||||||
|
asyncio.create_task(
|
||||||
|
_extract_memory_background(session_id, user_text, final_content)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
yield self._sse("error", {"message": "Too many tool call rounds"})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sse(event: str, data: dict[str, Any]) -> str:
|
||||||
|
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
+163
-59
@@ -1,59 +1,163 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic import field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
DEPRECATED_VISION_MODELS: dict[str, str] = {
|
||||||
model_config = SettingsConfigDict(
|
"google/gemini-2.0-flash-lite-001": "google/gemini-2.5-flash-lite",
|
||||||
env_file=(".env", "../.env"),
|
"google/gemini-2.0-flash-lite": "google/gemini-2.5-flash-lite",
|
||||||
env_file_encoding="utf-8",
|
}
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
def resolve_vision_model(model: str) -> str:
|
||||||
host: str = "0.0.0.0"
|
stripped = model.strip()
|
||||||
port: int = 8080
|
return DEPRECATED_VISION_MODELS.get(stripped, stripped)
|
||||||
|
|
||||||
openrouter_api_key: str = ""
|
|
||||||
openrouter_model: str = "deepseek/deepseek-chat"
|
class Settings(BaseSettings):
|
||||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=(".env", "../.env"),
|
||||||
database_url: str = "sqlite:///./data/assistant.db"
|
env_file_encoding="utf-8",
|
||||||
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
|
extra="ignore",
|
||||||
system_prompt_path: str = "./prompts/assistant.md"
|
)
|
||||||
|
|
||||||
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
|
host: str = "0.0.0.0"
|
||||||
taiga_base_url: str = "http://host.docker.internal:9000"
|
port: int = 8080
|
||||||
taiga_username: str = ""
|
|
||||||
taiga_password: str = ""
|
openrouter_api_key: str = ""
|
||||||
taiga_public_url: str = "https://taiga.grigowashere.ru"
|
openrouter_model: str = "deepseek/deepseek-chat"
|
||||||
|
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||||
gitea_base_url: str = "http://host.docker.internal:3000"
|
# Отдельная модель для JSON-задач (память, фитнес). Пусто = та же, что OPENROUTER_MODEL.
|
||||||
gitea_token: str = ""
|
memory_extract_model: str = ""
|
||||||
gitea_public_url: str = "https://git.grigowashere.ru"
|
# Некоторые модели (reasoning / без function calling) — выключить tools.
|
||||||
gitea_webhook_secret: str = ""
|
openrouter_tools_enabled: bool = True
|
||||||
|
# DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking.
|
||||||
repos_dir: str = "/data/repos"
|
openrouter_reasoning_effort: str = "none"
|
||||||
|
openrouter_vision_model: str = "google/gemini-2.5-flash-lite"
|
||||||
@property
|
vision_max_edge_px: int = 1280
|
||||||
def cors_origins_list(self) -> list[str]:
|
vision_jpeg_quality: int = 85
|
||||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
vision_debug_enabled: bool = True
|
||||||
|
vision_max_images: int = 8
|
||||||
@property
|
uploads_dir: str = "./data/uploads"
|
||||||
def taiga_configured(self) -> bool:
|
|
||||||
return bool(self.taiga_username and self.taiga_password)
|
@field_validator("openrouter_vision_model")
|
||||||
|
@classmethod
|
||||||
@property
|
def migrate_vision_model(cls, value: str) -> str:
|
||||||
def gitea_configured(self) -> bool:
|
return resolve_vision_model(value)
|
||||||
return bool(self.gitea_token)
|
|
||||||
|
database_url: str = "sqlite:///./data/assistant.db"
|
||||||
def load_system_prompt(self) -> str:
|
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
|
||||||
path = Path(self.system_prompt_path)
|
system_prompt_path: str = "./prompts/assistant.md"
|
||||||
if path.is_file():
|
memory_auto_extract: bool = True
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
return "Ты домашний ИИ-ассистент. Общайся на русском."
|
default_user_username: str = "owner"
|
||||||
|
default_user_display_name: str = ""
|
||||||
|
default_api_token: str = ""
|
||||||
@lru_cache
|
auth_required: bool = True
|
||||||
def get_settings() -> Settings:
|
|
||||||
return Settings()
|
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
|
||||||
|
weather_forecast_days: int = 7
|
||||||
|
openmeteo_fallback_url: str = "https://api.open-meteo.com"
|
||||||
|
openmeteo_fallback_on_partial: bool = True
|
||||||
|
|
||||||
|
news_rss_urls: str = (
|
||||||
|
"https://habr.com/ru/rss/all/all/,"
|
||||||
|
"https://www.reddit.com/r/programming/.rss"
|
||||||
|
)
|
||||||
|
news_cache_sec: int = 1800
|
||||||
|
news_max_items: int = 7
|
||||||
|
|
||||||
|
morning_digest_enabled: bool = True
|
||||||
|
morning_digest_hour: int = 8
|
||||||
|
morning_digest_minute: int = 0
|
||||||
|
|
||||||
|
comfyui_base_url: str = "http://192.168.1.109:8188"
|
||||||
|
comfyui_enabled: bool = True
|
||||||
|
# Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty
|
||||||
|
comfyui_checkpoint: str = ""
|
||||||
|
comfyui_unet: str = "anima-preview3-base.safetensors"
|
||||||
|
comfyui_clip: str = "qwen_3_06b_base.safetensors"
|
||||||
|
comfyui_vae: str = "qwen_image_vae.safetensors"
|
||||||
|
comfyui_style_lora: str = "anima-preview-3-masterpieces-v5.safetensors"
|
||||||
|
comfyui_style_lora_weight: float = 0.7
|
||||||
|
comfyui_steps: int = 30
|
||||||
|
comfyui_cfg: float = 4.0
|
||||||
|
comfyui_sampler: str = "er_sde"
|
||||||
|
comfyui_scheduler: str = "simple"
|
||||||
|
comfyui_width: int = 1024
|
||||||
|
comfyui_height: int = 720
|
||||||
|
comfyui_negative_prompt: str = (
|
||||||
|
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
|
||||||
|
)
|
||||||
|
comfyui_poll_interval_sec: float = 2.0
|
||||||
|
comfyui_timeout_sec: float = 180.0
|
||||||
|
comfyui_rofl_enabled: bool = True
|
||||||
|
comfyui_rofl_max_per_day: int = 1
|
||||||
|
comfyui_rofl_probability: float = 0.15
|
||||||
|
comfyui_rofl_min_interval_hours: int = 12
|
||||||
|
generated_media_dir: str = "./data/generated"
|
||||||
|
|
||||||
|
netdata_base_url: str = "http://host.docker.internal:19999"
|
||||||
|
netdata_public_url: str = ""
|
||||||
|
netdata_alerts_enabled: bool = True
|
||||||
|
netdata_poll_interval_sec: int = 120
|
||||||
|
|
||||||
|
rp_chat_base_url: str = "http://host.docker.internal:8201"
|
||||||
|
rp_chat_enabled: bool = True
|
||||||
|
rp_chat_timeout_sec: float = 300.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins_list(self) -> list[str]:
|
||||||
|
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def taiga_configured(self) -> bool:
|
||||||
|
return bool(self.taiga_username and self.taiga_password)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gitea_configured(self) -> bool:
|
||||||
|
return bool(self.gitea_token)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def news_rss_urls_list(self) -> list[str]:
|
||||||
|
return [u.strip() for u in self.news_rss_urls.split(",") if u.strip()]
|
||||||
|
|
||||||
|
def load_system_prompt(self) -> str:
|
||||||
|
path = Path(self.system_prompt_path)
|
||||||
|
if path.is_file():
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
return "Ты домашний ИИ-ассистент. Общайся на русском."
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=(".env", "../.env"),
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8080
|
||||||
|
|
||||||
|
openrouter_api_key: str = ""
|
||||||
|
openrouter_model: str = "deepseek/deepseek-chat"
|
||||||
|
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||||
|
# Отдельная модель для JSON-задач (память, фитнес). Пусто = та же, что OPENROUTER_MODEL.
|
||||||
|
memory_extract_model: str = ""
|
||||||
|
# Некоторые модели (reasoning / без function calling) — выключить tools.
|
||||||
|
openrouter_tools_enabled: bool = True
|
||||||
|
# DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking.
|
||||||
|
openrouter_reasoning_effort: str = "none"
|
||||||
|
|
||||||
|
database_url: str = "sqlite:///./data/assistant.db"
|
||||||
|
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
|
||||||
|
system_prompt_path: str = "./prompts/assistant.md"
|
||||||
|
memory_auto_extract: bool = True
|
||||||
|
|
||||||
|
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
|
||||||
|
taiga_base_url: str = "http://host.docker.internal:9000"
|
||||||
|
taiga_username: str = ""
|
||||||
|
taiga_password: str = ""
|
||||||
|
taiga_public_url: str = "https://taiga.grigowashere.ru"
|
||||||
|
|
||||||
|
gitea_base_url: str = "http://host.docker.internal:3000"
|
||||||
|
gitea_token: str = ""
|
||||||
|
gitea_public_url: str = "https://git.grigowashere.ru"
|
||||||
|
gitea_webhook_secret: str = ""
|
||||||
|
|
||||||
|
repos_dir: str = "/data/repos"
|
||||||
|
|
||||||
|
wger_base_url: str = "https://wger.de/api/v2"
|
||||||
|
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
|
||||||
|
fitness_reminders_enabled: bool = True
|
||||||
|
reminders_enabled: bool = True
|
||||||
|
|
||||||
|
openmeteo_base_url: str = "http://192.168.1.109:8085"
|
||||||
|
weather_lat: float = 59.9343
|
||||||
|
weather_lon: float = 30.3351
|
||||||
|
weather_location_name: str = "Санкт-Петербург"
|
||||||
|
weather_cache_sec: int = 300
|
||||||
|
|
||||||
|
news_rss_urls: str = (
|
||||||
|
"https://habr.com/ru/rss/all/all/,"
|
||||||
|
"https://www.reddit.com/r/programming/.rss"
|
||||||
|
)
|
||||||
|
news_cache_sec: int = 1800
|
||||||
|
news_max_items: int = 7
|
||||||
|
|
||||||
|
morning_digest_enabled: bool = True
|
||||||
|
morning_digest_hour: int = 8
|
||||||
|
morning_digest_minute: int = 0
|
||||||
|
|
||||||
|
comfyui_base_url: str = "http://192.168.1.109:8188"
|
||||||
|
comfyui_enabled: bool = True
|
||||||
|
# Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty
|
||||||
|
comfyui_checkpoint: str = ""
|
||||||
|
comfyui_unet: str = "anima-preview3-base.safetensors"
|
||||||
|
comfyui_clip: str = "qwen_3_06b_base.safetensors"
|
||||||
|
comfyui_vae: str = "qwen_image_vae.safetensors"
|
||||||
|
comfyui_style_lora: str = "anima-preview-3-masterpieces-v5.safetensors"
|
||||||
|
comfyui_style_lora_weight: float = 0.7
|
||||||
|
comfyui_steps: int = 30
|
||||||
|
comfyui_cfg: float = 4.0
|
||||||
|
comfyui_sampler: str = "er_sde"
|
||||||
|
comfyui_scheduler: str = "simple"
|
||||||
|
comfyui_width: int = 1024
|
||||||
|
comfyui_height: int = 720
|
||||||
|
comfyui_negative_prompt: str = (
|
||||||
|
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
|
||||||
|
)
|
||||||
|
comfyui_poll_interval_sec: float = 2.0
|
||||||
|
comfyui_timeout_sec: float = 180.0
|
||||||
|
comfyui_rofl_enabled: bool = True
|
||||||
|
comfyui_rofl_max_per_day: int = 1
|
||||||
|
comfyui_rofl_probability: float = 0.15
|
||||||
|
comfyui_rofl_min_interval_hours: int = 12
|
||||||
|
generated_media_dir: str = "./data/generated"
|
||||||
|
|
||||||
|
netdata_base_url: str = "http://host.docker.internal:19999"
|
||||||
|
netdata_public_url: str = ""
|
||||||
|
netdata_alerts_enabled: bool = True
|
||||||
|
netdata_poll_interval_sec: int = 120
|
||||||
|
|
||||||
|
rp_chat_base_url: str = "http://host.docker.internal:8201"
|
||||||
|
rp_chat_enabled: bool = True
|
||||||
|
rp_chat_timeout_sec: float = 300.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins_list(self) -> list[str]:
|
||||||
|
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def taiga_configured(self) -> bool:
|
||||||
|
return bool(self.taiga_username and self.taiga_password)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gitea_configured(self) -> bool:
|
||||||
|
return bool(self.gitea_token)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def news_rss_urls_list(self) -> list[str]:
|
||||||
|
return [u.strip() for u in self.news_rss_urls.split(",") if u.strip()]
|
||||||
|
|
||||||
|
def load_system_prompt(self) -> str:
|
||||||
|
path = Path(self.system_prompt_path)
|
||||||
|
if path.is_file():
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
return "Ты домашний ИИ-ассистент. Общайся на русском."
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -21,6 +21,12 @@ def run_migrations() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "messages" in inspector.get_table_names():
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("messages")}
|
||||||
|
with engine.begin() as conn:
|
||||||
|
if "reasoning_json" not in columns:
|
||||||
|
conn.execute(text("ALTER TABLE messages ADD COLUMN reasoning_json TEXT"))
|
||||||
|
|
||||||
if "pomodoro_cycles" not in inspector.get_table_names():
|
if "pomodoro_cycles" not in inspector.get_table_names():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, select, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.base import engine
|
||||||
|
from app.db.models import FitnessProfile
|
||||||
|
from app.fitness.calculators import DEFAULT_NEAT_KCAL, compute_targets, macro_targets
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TDEE_V2_BACKFILL = "fitness_tdee_v2_backfill"
|
||||||
|
MACROS_GKG_BACKFILL = "fitness_macros_gkg_v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table: str) -> bool:
|
||||||
|
return table in inspect(engine).get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
|
||||||
|
inspector = inspect(engine)
|
||||||
|
if table not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
columns = {col["name"] for col in inspector.get_columns(table)}
|
||||||
|
if column in columns:
|
||||||
|
return
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text(ddl))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_schema_migrations_table() -> None:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE IF NOT EXISTS _schema_migrations ("
|
||||||
|
"name TEXT PRIMARY KEY, "
|
||||||
|
"applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_applied(name: str) -> bool:
|
||||||
|
_ensure_schema_migrations_table()
|
||||||
|
with engine.begin() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
text("SELECT 1 FROM _schema_migrations WHERE name = :name"),
|
||||||
|
{"name": name},
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_migration_applied(name: str) -> None:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text("INSERT INTO _schema_migrations (name) VALUES (:name)"),
|
||||||
|
{"name": name},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_targets(row: FitnessProfile) -> dict[str, float]:
|
||||||
|
neat = row.neat_base_kcal if row.neat_base_kcal is not None else DEFAULT_NEAT_KCAL
|
||||||
|
return compute_targets(
|
||||||
|
{
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"goal": row.goal,
|
||||||
|
"neat_base_kcal": neat,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_tdee_targets(*, force: bool = False) -> int:
|
||||||
|
"""Recalculate stored calorie/macro targets for all profiles (PAL → BMR+NEAT)."""
|
||||||
|
if not _table_exists("fitness_profiles"):
|
||||||
|
return 0
|
||||||
|
_ensure_schema_migrations_table()
|
||||||
|
if not force and _migration_applied(TDEE_V2_BACKFILL):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE fitness_profiles "
|
||||||
|
"SET neat_base_kcal = :neat "
|
||||||
|
"WHERE neat_base_kcal IS NULL"
|
||||||
|
),
|
||||||
|
{"neat": DEFAULT_NEAT_KCAL},
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
with Session(engine) as db:
|
||||||
|
rows = db.scalars(select(FitnessProfile)).all()
|
||||||
|
for row in rows:
|
||||||
|
if row.neat_base_kcal is None:
|
||||||
|
row.neat_base_kcal = DEFAULT_NEAT_KCAL
|
||||||
|
targets = _profile_targets(row)
|
||||||
|
row.calorie_target = targets["calorie_target"]
|
||||||
|
row.protein_g = targets["protein_g"]
|
||||||
|
row.fat_g = targets["fat_g"]
|
||||||
|
row.carbs_g = targets["carbs_g"]
|
||||||
|
row.water_l = targets["water_l"]
|
||||||
|
updated += 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if not force or not _migration_applied(TDEE_V2_BACKFILL):
|
||||||
|
_mark_migration_applied(TDEE_V2_BACKFILL)
|
||||||
|
|
||||||
|
logger.info("TDEE v2 backfill: recalculated %s fitness profile(s)", updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_macros_gkg(*, force: bool = False) -> int:
|
||||||
|
"""Recalculate stored BJU from weight (protein/fat g/kg, carbs = remainder)."""
|
||||||
|
if not _table_exists("fitness_profiles"):
|
||||||
|
return 0
|
||||||
|
_ensure_schema_migrations_table()
|
||||||
|
if not force and _migration_applied(MACROS_GKG_BACKFILL):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
with Session(engine) as db:
|
||||||
|
rows = db.scalars(select(FitnessProfile)).all()
|
||||||
|
for row in rows:
|
||||||
|
macros = macro_targets(row.calorie_target, row.weight_kg, row.goal)
|
||||||
|
row.protein_g = macros["protein_g"]
|
||||||
|
row.fat_g = macros["fat_g"]
|
||||||
|
row.carbs_g = macros["carbs_g"]
|
||||||
|
updated += 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
_mark_migration_applied(MACROS_GKG_BACKFILL)
|
||||||
|
logger.info("Macros g/kg backfill: updated %s fitness profile(s)", updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def run_fitness_migrations() -> None:
|
||||||
|
inspector = inspect(engine)
|
||||||
|
|
||||||
|
if "fitness_profiles" in inspector.get_table_names():
|
||||||
|
_add_column_if_missing(
|
||||||
|
"fitness_profiles",
|
||||||
|
"baseline_steps",
|
||||||
|
"ALTER TABLE fitness_profiles ADD COLUMN baseline_steps INTEGER",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"fitness_profiles",
|
||||||
|
"baseline_workout_kcal",
|
||||||
|
"ALTER TABLE fitness_profiles ADD COLUMN baseline_workout_kcal FLOAT",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"fitness_profiles",
|
||||||
|
"neat_base_kcal",
|
||||||
|
"ALTER TABLE fitness_profiles ADD COLUMN neat_base_kcal FLOAT DEFAULT 200.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
if "workout_logs" in inspector.get_table_names():
|
||||||
|
_add_column_if_missing(
|
||||||
|
"workout_logs",
|
||||||
|
"active_calories",
|
||||||
|
"ALTER TABLE workout_logs ADD COLUMN active_calories FLOAT",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"workout_logs",
|
||||||
|
"total_calories",
|
||||||
|
"ALTER TABLE workout_logs ADD COLUMN total_calories FLOAT",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"workout_logs",
|
||||||
|
"steps",
|
||||||
|
"ALTER TABLE workout_logs ADD COLUMN steps INTEGER",
|
||||||
|
)
|
||||||
|
|
||||||
|
if "step_logs" not in inspector.get_table_names():
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE step_logs ("
|
||||||
|
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||||
|
"logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
||||||
|
"steps INTEGER DEFAULT 0, "
|
||||||
|
"active_calories FLOAT, "
|
||||||
|
"source VARCHAR(32) DEFAULT 'manual', "
|
||||||
|
"notes TEXT DEFAULT ''"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if "body_metrics" in inspector.get_table_names():
|
||||||
|
_add_column_if_missing(
|
||||||
|
"body_metrics",
|
||||||
|
"neck_cm",
|
||||||
|
"ALTER TABLE body_metrics ADD COLUMN neck_cm FLOAT",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"body_metrics",
|
||||||
|
"hip_cm",
|
||||||
|
"ALTER TABLE body_metrics ADD COLUMN hip_cm FLOAT",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"body_metrics",
|
||||||
|
"body_fat_method",
|
||||||
|
"ALTER TABLE body_metrics ADD COLUMN body_fat_method VARCHAR(16)",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"body_metrics",
|
||||||
|
"whr",
|
||||||
|
"ALTER TABLE body_metrics ADD COLUMN whr FLOAT",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"body_metrics",
|
||||||
|
"lbm_kg",
|
||||||
|
"ALTER TABLE body_metrics ADD COLUMN lbm_kg FLOAT",
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
"body_metrics",
|
||||||
|
"ffmi",
|
||||||
|
"ALTER TABLE body_metrics ADD COLUMN ffmi FLOAT",
|
||||||
|
)
|
||||||
|
|
||||||
|
backfill_tdee_targets()
|
||||||
|
backfill_macros_gkg()
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
from app.auth.tokens import hash_token
|
||||||
|
from app.character.card import DEFAULT_CARD, normalize_card
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.base import engine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TENANT_TABLES = (
|
||||||
|
"chat_sessions",
|
||||||
|
"user_profile",
|
||||||
|
"memory_facts",
|
||||||
|
"fitness_profiles",
|
||||||
|
"body_metrics",
|
||||||
|
"food_logs",
|
||||||
|
"water_logs",
|
||||||
|
"workout_logs",
|
||||||
|
"step_logs",
|
||||||
|
"fitness_reminders",
|
||||||
|
"shopping_lists",
|
||||||
|
"reminders",
|
||||||
|
"documents",
|
||||||
|
"pomodoro_cycles",
|
||||||
|
"pomodoro_sessions",
|
||||||
|
"project_bindings",
|
||||||
|
"work_items",
|
||||||
|
)
|
||||||
|
|
||||||
|
LEGACY_CARD_PATH = Path("./data/character.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(name: str) -> bool:
|
||||||
|
return name in inspect(engine).get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(table: str) -> set[str]:
|
||||||
|
if not _table_exists(table):
|
||||||
|
return set()
|
||||||
|
return {col["name"] for col in inspect(engine).get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
|
||||||
|
if column in _columns(table):
|
||||||
|
return
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text(ddl))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_users_table() -> None:
|
||||||
|
if _table_exists("users"):
|
||||||
|
return
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE users ("
|
||||||
|
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||||
|
"username VARCHAR(64) NOT NULL UNIQUE, "
|
||||||
|
"display_name VARCHAR(255) DEFAULT '', "
|
||||||
|
"api_token_hash VARCHAR(64) NOT NULL, "
|
||||||
|
"is_active BOOLEAN DEFAULT 1, "
|
||||||
|
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_api_token_hash ON users (api_token_hash)"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_character_cards_table() -> None:
|
||||||
|
if _table_exists("character_cards"):
|
||||||
|
return
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE character_cards ("
|
||||||
|
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||||
|
"user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, "
|
||||||
|
"card_json TEXT DEFAULT '{}', "
|
||||||
|
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_character_cards_user_id ON character_cards (user_id)"))
|
||||||
|
|
||||||
|
|
||||||
|
def _add_user_id_columns() -> None:
|
||||||
|
for table in TENANT_TABLES:
|
||||||
|
if not _table_exists(table):
|
||||||
|
continue
|
||||||
|
_add_column_if_missing(
|
||||||
|
table,
|
||||||
|
"user_id",
|
||||||
|
f"ALTER TABLE {table} ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE",
|
||||||
|
)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text(f"CREATE INDEX IF NOT EXISTS ix_{table}_user_id ON {table} (user_id)"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_default_user() -> tuple[int, str | None]:
|
||||||
|
settings = get_settings()
|
||||||
|
with engine.begin() as conn:
|
||||||
|
row = conn.execute(text("SELECT id FROM users ORDER BY id LIMIT 1")).fetchone()
|
||||||
|
if row:
|
||||||
|
return int(row[0]), None
|
||||||
|
|
||||||
|
username = settings.default_user_username or "owner"
|
||||||
|
display_name = settings.default_user_display_name or username
|
||||||
|
plain_token = (settings.default_api_token or "").strip()
|
||||||
|
generated = False
|
||||||
|
if not plain_token:
|
||||||
|
plain_token = secrets.token_urlsafe(32)
|
||||||
|
generated = True
|
||||||
|
|
||||||
|
token_hash = hash_token(plain_token)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO users (id, username, display_name, api_token_hash, is_active) "
|
||||||
|
"VALUES (1, :username, :display_name, :token_hash, 1)"
|
||||||
|
),
|
||||||
|
{"username": username, "display_name": display_name, "token_hash": token_hash},
|
||||||
|
)
|
||||||
|
if generated:
|
||||||
|
logger.warning(
|
||||||
|
"DEFAULT_API_TOKEN not set — generated token for user '%s': %s",
|
||||||
|
username,
|
||||||
|
plain_token,
|
||||||
|
)
|
||||||
|
return 1, plain_token
|
||||||
|
return 1, None
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_user_id(default_user_id: int = 1) -> None:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for table in TENANT_TABLES:
|
||||||
|
if not _table_exists(table):
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
text(f"UPDATE {table} SET user_id = :uid WHERE user_id IS NULL"),
|
||||||
|
{"uid": default_user_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_shopping_unique() -> None:
|
||||||
|
if not _table_exists("shopping_lists"):
|
||||||
|
return
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_shopping_lists_user_name ON shopping_lists (user_id, name)"))
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_project_bindings_unique() -> None:
|
||||||
|
if not _table_exists("project_bindings"):
|
||||||
|
return
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_project_bindings_user_slug "
|
||||||
|
"ON project_bindings (user_id, taiga_slug)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _import_character_card(user_id: int) -> None:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
text("SELECT id FROM character_cards WHERE user_id = :uid"),
|
||||||
|
{"uid": user_id},
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
|
||||||
|
card = normalize_card(DEFAULT_CARD)
|
||||||
|
if LEGACY_CARD_PATH.is_file():
|
||||||
|
try:
|
||||||
|
raw = json.loads(LEGACY_CARD_PATH.read_text(encoding="utf-8"))
|
||||||
|
card = normalize_card(raw)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
text("INSERT INTO character_cards (user_id, card_json) VALUES (:uid, :json)"),
|
||||||
|
{"uid": user_id, "json": json.dumps(card, ensure_ascii=False)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_qdrant_user_id(default_user_id: int = 1) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from app.rag.store import COLLECTION_DOC_CHUNKS, COLLECTION_FACTS, COLLECTION_SUMMARIES, _client
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Qdrant backfill skipped")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = _client()
|
||||||
|
except Exception:
|
||||||
|
logger.warning('Qdrant unavailable, skipping user_id backfill')
|
||||||
|
return
|
||||||
|
|
||||||
|
for collection in (COLLECTION_FACTS, COLLECTION_SUMMARIES, COLLECTION_DOC_CHUNKS):
|
||||||
|
try:
|
||||||
|
if not client.collection_exists(collection):
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
logger.warning('Qdrant unavailable for collection %s', collection)
|
||||||
|
continue
|
||||||
|
offset = None
|
||||||
|
while True:
|
||||||
|
points, offset = client.scroll(
|
||||||
|
collection_name=collection,
|
||||||
|
limit=100,
|
||||||
|
offset=offset,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
if not points:
|
||||||
|
break
|
||||||
|
missing = [point.id for point in points if (point.payload or {}).get("user_id") is None]
|
||||||
|
if missing:
|
||||||
|
client.set_payload(
|
||||||
|
collection_name=collection,
|
||||||
|
payload={"user_id": default_user_id},
|
||||||
|
points=missing,
|
||||||
|
)
|
||||||
|
if offset is None:
|
||||||
|
break
|
||||||
|
logger.info("Qdrant user_id backfill completed for user_id=%s", default_user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def run_multi_user_migrations() -> str | None:
|
||||||
|
"""Returns newly generated API token if any."""
|
||||||
|
_ensure_users_table()
|
||||||
|
_ensure_character_cards_table()
|
||||||
|
_add_user_id_columns()
|
||||||
|
user_id, new_token = _ensure_default_user()
|
||||||
|
_backfill_user_id(user_id)
|
||||||
|
_rebuild_shopping_unique()
|
||||||
|
_rebuild_project_bindings_unique()
|
||||||
|
_import_character_card(user_id)
|
||||||
|
_backfill_qdrant_user_id(user_id)
|
||||||
|
return new_token
|
||||||
+397
-112
@@ -1,112 +1,397 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
class ChatSession(Base):
|
class User(Base):
|
||||||
__tablename__ = "chat_sessions"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
display_name: Mapped[str] = mapped_column(String(255), default="")
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
api_token_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
messages: Mapped[list["Message"]] = relationship(
|
|
||||||
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
|
class CharacterCard(Base):
|
||||||
)
|
__tablename__ = "character_cards"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
class Message(Base):
|
user_id: Mapped[int] = mapped_column(
|
||||||
__tablename__ = "messages"
|
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
|
||||||
|
)
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
card_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
role: Mapped[str] = mapped_column(String(32))
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
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())
|
class ChatSession(Base):
|
||||||
|
__tablename__ = "chat_sessions"
|
||||||
session: Mapped["ChatSession"] = relationship(back_populates="messages")
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
class PomodoroCycle(Base):
|
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
|
||||||
__tablename__ = "pomodoro_cycles"
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
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)
|
messages: Mapped[list["Message"]] = relationship(
|
||||||
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
|
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
|
||||||
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)
|
class Message(Base):
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
__tablename__ = "messages"
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
||||||
)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(32))
|
||||||
class PomodoroSession(Base):
|
content: Mapped[str] = mapped_column(Text, default="")
|
||||||
__tablename__ = "pomodoro_sessions"
|
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="idle")
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
phase: Mapped[str] = mapped_column(String(32), default="work")
|
|
||||||
duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
session: Mapped["ChatSession"] = relationship(back_populates="messages")
|
||||||
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)
|
class PomodoroCycle(Base):
|
||||||
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
__tablename__ = "pomodoro_cycles"
|
||||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
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)
|
||||||
class TaigaProject(Base):
|
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
__tablename__ = "taiga_projects"
|
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
name: Mapped[str] = mapped_column(String(255))
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
)
|
||||||
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
|
class PomodoroSession(Base):
|
||||||
class ProjectBinding(Base):
|
__tablename__ = "pomodoro_sessions"
|
||||||
__tablename__ = "project_bindings"
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
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), unique=True, index=True)
|
status: Mapped[str] = mapped_column(String(32), default="idle")
|
||||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
phase: Mapped[str] = mapped_column(String(32), default="work")
|
||||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||||
default_branch: Mapped[str] = mapped_column(String(64), default="main")
|
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
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)
|
||||||
class WorkItem(Base):
|
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
__tablename__ = "work_items"
|
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())
|
||||||
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)
|
class TaigaProject(Base):
|
||||||
taiga_story_id: Mapped[int] = mapped_column(Integer)
|
__tablename__ = "taiga_projects"
|
||||||
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
|
|
||||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||||
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
|
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
raw_text: Mapped[str] = mapped_column(Text, default="")
|
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
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())
|
class ProjectBinding(Base):
|
||||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
__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)
|
||||||
|
neat_base_kcal: Mapped[float] = mapped_column(Float, default=200.0)
|
||||||
|
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
baseline_steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
baseline_workout_kcal: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
|
||||||
|
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
|
||||||
|
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
|
||||||
|
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
|
||||||
|
water_l: Mapped[float] = mapped_column(Float, default=2.5)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BodyMetric(Base):
|
||||||
|
__tablename__ = "body_metrics"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
weight_kg: Mapped[float] = mapped_column(Float)
|
||||||
|
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
body_fat_method: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
neck_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
hip_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
whr: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
lbm_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
ffmi: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class FoodLog(Base):
|
||||||
|
__tablename__ = "food_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
|
||||||
|
description: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
calories: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
protein_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
fat_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
carbs_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), default="llm")
|
||||||
|
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class StepLog(Base):
|
||||||
|
__tablename__ = "step_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
steps: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), default="manual")
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class WaterLog(Base):
|
||||||
|
__tablename__ = "water_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
amount_ml: Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkoutLog(Base):
|
||||||
|
__tablename__ = "workout_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
total_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||||
|
|
||||||
|
|
||||||
|
class FitnessReminder(Base):
|
||||||
|
__tablename__ = "fitness_reminders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(32))
|
||||||
|
hour: Mapped[int] = mapped_column(Integer, default=12)
|
||||||
|
minute: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingList(Base):
|
||||||
|
__tablename__ = "shopping_lists"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_shopping_lists_user_name"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
items: Mapped[list["ShoppingListItem"]] = relationship(
|
||||||
|
back_populates="shopping_list",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingListItem(Base):
|
||||||
|
__tablename__ = "shopping_list_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
list_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
text: Mapped[str] = mapped_column(String(500))
|
||||||
|
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
unit: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
|
class Reminder(Base):
|
||||||
|
__tablename__ = "reminders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
recurrence: Mapped[str] = mapped_column(String(16), default="none")
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantState(Base):
|
||||||
|
__tablename__ = "assistant_state"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkItem(Base):
|
||||||
|
__tablename__ = "work_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
taiga_project_id: Mapped[int] = mapped_column(Integer)
|
||||||
|
taiga_story_id: Mapped[int] = mapped_column(Integer)
|
||||||
|
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
|
||||||
|
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
raw_text: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
title: Mapped[str] = mapped_column(String(500), default="")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Document(Base):
|
||||||
|
__tablename__ = "documents"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
filename: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
|
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks: Mapped[list["DocumentChunk"]] = relationship(
|
||||||
|
back_populates="document",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="DocumentChunk.chunk_index",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentChunk(Base):
|
||||||
|
__tablename__ = "document_chunks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
document_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("documents.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
chunk_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
content: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
document: Mapped["Document"] = relationship(back_populates="chunks")
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSession(Base):
|
||||||
|
__tablename__ = "chat_sessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
messages: Mapped[list["Message"]] = relationship(
|
||||||
|
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(32))
|
||||||
|
content: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
session: Mapped["ChatSession"] = relationship(back_populates="messages")
|
||||||
|
|
||||||
|
|
||||||
|
class PomodoroCycle(Base):
|
||||||
|
__tablename__ = "pomodoro_cycles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||||
|
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
|
||||||
|
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
|
||||||
|
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
|
||||||
|
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PomodoroSession(Base):
|
||||||
|
__tablename__ = "pomodoro_sessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="idle")
|
||||||
|
phase: Mapped[str] = mapped_column(String(32), default="work")
|
||||||
|
duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||||
|
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class TaigaProject(Base):
|
||||||
|
__tablename__ = "taiga_projects"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectBinding(Base):
|
||||||
|
__tablename__ = "project_bindings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
default_branch: Mapped[str] = mapped_column(String(64), default="main")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(Base):
|
||||||
|
__tablename__ = "user_profile"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
data_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryFact(Base):
|
||||||
|
__tablename__ = "memory_facts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), default="user")
|
||||||
|
session_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
importance: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSummary(Base):
|
||||||
|
__tablename__ = "session_summaries"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
|
||||||
|
)
|
||||||
|
summary: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FitnessProfile(Base):
|
||||||
|
__tablename__ = "fitness_profiles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
sex: Mapped[str] = mapped_column(String(16), default="male")
|
||||||
|
age: Mapped[int] = mapped_column(Integer, default=30)
|
||||||
|
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
|
||||||
|
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
|
||||||
|
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
|
||||||
|
goal: Mapped[str] = mapped_column(String(32), default="maintain")
|
||||||
|
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
|
||||||
|
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
|
||||||
|
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
|
||||||
|
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
|
||||||
|
water_l: Mapped[float] = mapped_column(Float, default=2.5)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BodyMetric(Base):
|
||||||
|
__tablename__ = "body_metrics"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
weight_kg: Mapped[float] = mapped_column(Float)
|
||||||
|
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class FoodLog(Base):
|
||||||
|
__tablename__ = "food_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
|
||||||
|
description: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
calories: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
protein_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
fat_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
carbs_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), default="llm")
|
||||||
|
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class WaterLog(Base):
|
||||||
|
__tablename__ = "water_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
amount_ml: Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkoutLog(Base):
|
||||||
|
__tablename__ = "workout_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||||
|
|
||||||
|
|
||||||
|
class FitnessReminder(Base):
|
||||||
|
__tablename__ = "fitness_reminders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(32))
|
||||||
|
hour: Mapped[int] = mapped_column(Integer, default=12)
|
||||||
|
minute: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingList(Base):
|
||||||
|
__tablename__ = "shopping_lists"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
items: Mapped[list["ShoppingListItem"]] = relationship(
|
||||||
|
back_populates="shopping_list",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingListItem(Base):
|
||||||
|
__tablename__ = "shopping_list_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
list_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
text: Mapped[str] = mapped_column(String(500))
|
||||||
|
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
unit: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
|
class Reminder(Base):
|
||||||
|
__tablename__ = "reminders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
recurrence: Mapped[str] = mapped_column(String(16), default="none")
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantState(Base):
|
||||||
|
__tablename__ = "assistant_state"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkItem(Base):
|
||||||
|
__tablename__ = "work_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
taiga_project_id: Mapped[int] = mapped_column(Integer)
|
||||||
|
taiga_story_id: Mapped[int] = mapped_column(Integer)
|
||||||
|
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
|
||||||
|
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
raw_text: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
title: Mapped[str] = mapped_column(String(500), default="")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
DEFAULT_MET = 5.0
|
||||||
|
|
||||||
|
MET_BY_KEYWORD: list[tuple[str, float]] = [
|
||||||
|
("триатлон", 10.0),
|
||||||
|
("марафон", 9.8),
|
||||||
|
("бег", 9.8),
|
||||||
|
("running", 9.8),
|
||||||
|
("run", 9.0),
|
||||||
|
("плаван", 8.0),
|
||||||
|
("swim", 8.0),
|
||||||
|
("велосипед", 7.5),
|
||||||
|
("cycling", 7.5),
|
||||||
|
("вел", 7.5),
|
||||||
|
("hiit", 8.0),
|
||||||
|
("кроссфит", 8.0),
|
||||||
|
("силов", 6.0),
|
||||||
|
("strength", 6.0),
|
||||||
|
("зал", 5.5),
|
||||||
|
("gym", 5.5),
|
||||||
|
("йога", 3.0),
|
||||||
|
("yoga", 3.0),
|
||||||
|
("ходьб", 3.5),
|
||||||
|
("walk", 3.5),
|
||||||
|
("прогул", 3.5),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def infer_met(workout: dict[str, Any]) -> float | None:
|
||||||
|
explicit = workout.get("met")
|
||||||
|
if explicit is not None:
|
||||||
|
return float(explicit)
|
||||||
|
|
||||||
|
activity_type = str(workout.get("activity_type") or "").lower()
|
||||||
|
title = str(workout.get("title") or "").lower()
|
||||||
|
notes = str(workout.get("notes") or "").lower()
|
||||||
|
haystack = f"{activity_type} {title} {notes}"
|
||||||
|
|
||||||
|
for keyword, met in MET_BY_KEYWORD:
|
||||||
|
if keyword in haystack:
|
||||||
|
return met
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_workout_active_kcal(workout: dict[str, Any], *, weight_kg: float) -> float:
|
||||||
|
active = workout.get("active_calories")
|
||||||
|
if active is not None:
|
||||||
|
return round(float(active), 1)
|
||||||
|
|
||||||
|
duration = workout.get("duration_min")
|
||||||
|
if not duration:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
met = infer_met(workout)
|
||||||
|
if met is None:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
hours = float(duration) / 60.0
|
||||||
|
return round(met * weight_kg * hours, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def workouts_kcal_total(workouts: list[dict[str, Any]], *, weight_kg: float) -> float:
|
||||||
|
if not workouts:
|
||||||
|
return 0.0
|
||||||
|
return round(sum(estimate_workout_active_kcal(w, weight_kg=weight_kg) for w in workouts), 1)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _is_female(sex: str) -> bool:
|
||||||
|
return sex.lower() in ("f", "female", "ж", "женский", "woman")
|
||||||
|
|
||||||
|
|
||||||
|
def _cm_to_inches(cm: float) -> float:
|
||||||
|
return cm / 2.54
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_bf(value: float) -> float:
|
||||||
|
return round(max(3.0, min(50.0, value)), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def navy_body_fat_pct(
|
||||||
|
*,
|
||||||
|
sex: str,
|
||||||
|
height_cm: float,
|
||||||
|
neck_cm: float,
|
||||||
|
waist_cm: float,
|
||||||
|
hip_cm: float | None = None,
|
||||||
|
) -> float | None:
|
||||||
|
if height_cm <= 0 or neck_cm <= 0 or waist_cm <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
height_in = _cm_to_inches(height_cm)
|
||||||
|
neck_in = _cm_to_inches(neck_cm)
|
||||||
|
waist_in = _cm_to_inches(waist_cm)
|
||||||
|
|
||||||
|
if _is_female(sex):
|
||||||
|
if hip_cm is None or hip_cm <= 0:
|
||||||
|
return None
|
||||||
|
hip_in = _cm_to_inches(hip_cm)
|
||||||
|
sum_in = waist_in + hip_in - neck_in
|
||||||
|
if sum_in <= 0:
|
||||||
|
return None
|
||||||
|
denom = (
|
||||||
|
1.29579
|
||||||
|
- 0.35004 * math.log10(sum_in)
|
||||||
|
+ 0.22100 * math.log10(height_in)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
diff_in = waist_in - neck_in
|
||||||
|
if diff_in <= 0:
|
||||||
|
return None
|
||||||
|
denom = (
|
||||||
|
1.0324
|
||||||
|
- 0.19077 * math.log10(diff_in)
|
||||||
|
+ 0.15456 * math.log10(height_in)
|
||||||
|
)
|
||||||
|
|
||||||
|
if denom <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _clamp_bf(495.0 / denom - 450.0)
|
||||||
|
|
||||||
|
|
||||||
|
def whr(waist_cm: float, hip_cm: float) -> float | None:
|
||||||
|
if waist_cm <= 0 or hip_cm <= 0:
|
||||||
|
return None
|
||||||
|
return round(waist_cm / hip_cm, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def lean_body_mass(weight_kg: float, body_fat_pct: float) -> float:
|
||||||
|
return round(weight_kg * (1.0 - body_fat_pct / 100.0), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def ffmi(weight_kg: float, height_cm: float, body_fat_pct: float) -> float | None:
|
||||||
|
if height_cm <= 0:
|
||||||
|
return None
|
||||||
|
height_m = height_cm / 100.0
|
||||||
|
lbm = weight_kg * (1.0 - body_fat_pct / 100.0)
|
||||||
|
raw = lbm / (height_m * height_m)
|
||||||
|
normalized = raw + 6.1 * (1.8 - height_m)
|
||||||
|
return round(normalized, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_body_composition(
|
||||||
|
*,
|
||||||
|
sex: str,
|
||||||
|
height_cm: float,
|
||||||
|
weight_kg: float,
|
||||||
|
neck_cm: float | None = None,
|
||||||
|
waist_cm: float | None = None,
|
||||||
|
hip_cm: float | None = None,
|
||||||
|
body_fat_pct: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
warnings: list[str] = []
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"body_fat_pct": None,
|
||||||
|
"body_fat_method": None,
|
||||||
|
"whr": None,
|
||||||
|
"lbm_kg": None,
|
||||||
|
"ffmi": None,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
bf = body_fat_pct
|
||||||
|
method: str | None = "manual" if bf is not None else None
|
||||||
|
|
||||||
|
if bf is None and neck_cm and waist_cm:
|
||||||
|
navy_bf = navy_body_fat_pct(
|
||||||
|
sex=sex,
|
||||||
|
height_cm=height_cm,
|
||||||
|
neck_cm=neck_cm,
|
||||||
|
waist_cm=waist_cm,
|
||||||
|
hip_cm=hip_cm,
|
||||||
|
)
|
||||||
|
if navy_bf is not None:
|
||||||
|
bf = navy_bf
|
||||||
|
method = "navy"
|
||||||
|
elif _is_female(sex) and not hip_cm:
|
||||||
|
warnings.append("Для Navy у женщин нужен обхват бёдер (hip_cm).")
|
||||||
|
elif neck_cm and waist_cm and waist_cm <= neck_cm:
|
||||||
|
warnings.append("Обхват талии должен быть больше шеи для Navy.")
|
||||||
|
|
||||||
|
if bf is not None:
|
||||||
|
result["body_fat_pct"] = round(float(bf), 1)
|
||||||
|
result["body_fat_method"] = method
|
||||||
|
result["lbm_kg"] = lean_body_mass(weight_kg, float(bf))
|
||||||
|
result["ffmi"] = ffmi(weight_kg, height_cm, float(bf))
|
||||||
|
|
||||||
|
if waist_cm and hip_cm:
|
||||||
|
result["whr"] = whr(waist_cm, hip_cm)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.fitness.activity_budget import workouts_kcal_total
|
||||||
|
|
||||||
|
DEFAULT_NEAT_KCAL = 200.0
|
||||||
|
NEAT_KCAL_MIN = 200.0
|
||||||
|
NEAT_KCAL_MAX = 300.0
|
||||||
|
KCAL_PER_STEP_REF = 0.04 / 86 # ~0.04 kcal/step at 86 kg
|
||||||
|
WATER_ML_PER_KG = 33 # middle of 30–35 ml/kg range
|
||||||
|
|
||||||
|
GOAL_CALORIE_ADJUST = {
|
||||||
|
"lose": -500,
|
||||||
|
"maintain": 0,
|
||||||
|
"gain": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
PROTEIN_G_PER_KG = {
|
||||||
|
"lose": 2.2,
|
||||||
|
"maintain": 1.8,
|
||||||
|
"gain": 1.8,
|
||||||
|
}
|
||||||
|
FAT_G_PER_KG = 1.0
|
||||||
|
|
||||||
|
EXPECTED_LOOKBACK_DAYS = 7
|
||||||
|
EXPECTED_MIN_DAYS_WITH_DATA = 3
|
||||||
|
DEFAULT_SESSION_KCAL = 350.0
|
||||||
|
|
||||||
|
ACTIVITY_LEVEL_STEPS: dict[str, int] = {
|
||||||
|
"sedentary": 5000,
|
||||||
|
"moderate": 8000,
|
||||||
|
"active": 10000,
|
||||||
|
"very_active": 12000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> float:
|
||||||
|
base = 10 * weight_kg + 6.25 * height_cm - 5 * age
|
||||||
|
if sex.lower() in ("m", "male", "м", "мужской"):
|
||||||
|
return base + 5
|
||||||
|
return base - 161
|
||||||
|
|
||||||
|
|
||||||
|
def neat_base_kcal(profile: dict[str, Any]) -> float:
|
||||||
|
raw = profile.get("neat_base_kcal")
|
||||||
|
if raw is not None:
|
||||||
|
return max(NEAT_KCAL_MIN, min(NEAT_KCAL_MAX, float(raw)))
|
||||||
|
return DEFAULT_NEAT_KCAL
|
||||||
|
|
||||||
|
|
||||||
|
def steps_kcal(*, steps: int, weight_kg: float) -> float:
|
||||||
|
if steps <= 0:
|
||||||
|
return 0.0
|
||||||
|
return round(steps * weight_kg * KCAL_PER_STEP_REF, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def bmi(weight_kg: float, height_cm: float) -> float:
|
||||||
|
if height_cm <= 0:
|
||||||
|
return 0.0
|
||||||
|
h = height_cm / 100
|
||||||
|
return weight_kg / (h * h)
|
||||||
|
|
||||||
|
|
||||||
|
def water_target_l(weight_kg: float) -> float:
|
||||||
|
return round(weight_kg * WATER_ML_PER_KG / 1000, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def macro_targets(
|
||||||
|
calorie_target: float,
|
||||||
|
weight_kg: float,
|
||||||
|
goal: str = "maintain",
|
||||||
|
) -> dict[str, float]:
|
||||||
|
protein_g = round(weight_kg * PROTEIN_G_PER_KG.get(goal, 1.8), 0)
|
||||||
|
fat_g = round(weight_kg * FAT_G_PER_KG, 0)
|
||||||
|
protein_cal = protein_g * 4
|
||||||
|
fat_cal = fat_g * 9
|
||||||
|
carbs_g = max(0, round((calorie_target - protein_cal - fat_cal) / 4, 0))
|
||||||
|
return {"protein_g": protein_g, "fat_g": fat_g, "carbs_g": carbs_g}
|
||||||
|
|
||||||
|
|
||||||
|
def one_rep_max(weight_kg: float, reps: int) -> float:
|
||||||
|
if reps <= 0:
|
||||||
|
return weight_kg
|
||||||
|
if reps == 1:
|
||||||
|
return weight_kg
|
||||||
|
return round(weight_kg * (1 + reps / 30), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_fields(profile: dict[str, Any]) -> tuple[float, float, int, str, str]:
|
||||||
|
weight = float(profile.get("weight_kg") or 70)
|
||||||
|
height = float(profile.get("height_cm") or 170)
|
||||||
|
age = int(profile.get("age") or 30)
|
||||||
|
sex = str(profile.get("sex") or "male")
|
||||||
|
goal = str(profile.get("goal") or "maintain")
|
||||||
|
return weight, height, age, sex, goal
|
||||||
|
|
||||||
|
|
||||||
|
def compute_tdee(
|
||||||
|
profile: dict[str, Any],
|
||||||
|
*,
|
||||||
|
steps_total: int = 0,
|
||||||
|
workouts: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
weight, height, age, sex, _ = _profile_fields(profile)
|
||||||
|
bmr = bmr_mifflin(sex=sex, weight_kg=weight, height_cm=height, age=age)
|
||||||
|
neat = neat_base_kcal(profile)
|
||||||
|
s_kcal = steps_kcal(steps=steps_total, weight_kg=weight)
|
||||||
|
w_kcal = workouts_kcal_total(workouts or [], weight_kg=weight)
|
||||||
|
tdee_val = bmr + neat + s_kcal + w_kcal
|
||||||
|
return {
|
||||||
|
"bmr": round(bmr, 0),
|
||||||
|
"neat_kcal": round(neat, 0),
|
||||||
|
"steps_kcal": s_kcal,
|
||||||
|
"workout_kcal": w_kcal,
|
||||||
|
"tdee": round(tdee_val, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_daily_targets(
|
||||||
|
profile: dict[str, Any],
|
||||||
|
*,
|
||||||
|
steps_total: int = 0,
|
||||||
|
workouts: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
weight, height, age, sex, goal = _profile_fields(profile)
|
||||||
|
breakdown = compute_tdee(profile, steps_total=steps_total, workouts=workouts)
|
||||||
|
calorie_target = round(breakdown["tdee"] + GOAL_CALORIE_ADJUST.get(goal, 0), 0)
|
||||||
|
macros = macro_targets(calorie_target, weight, goal)
|
||||||
|
water = water_target_l(weight)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**breakdown,
|
||||||
|
"calorie_target": calorie_target,
|
||||||
|
"protein_g": macros["protein_g"],
|
||||||
|
"fat_g": macros["fat_g"],
|
||||||
|
"carbs_g": macros["carbs_g"],
|
||||||
|
"water_l": water,
|
||||||
|
"bmi": round(bmi(weight, height), 1),
|
||||||
|
"steps": steps_total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def targets_to_api(daily: dict[str, Any]) -> dict[str, float]:
|
||||||
|
return {
|
||||||
|
"calories": daily["calorie_target"],
|
||||||
|
"protein_g": daily["protein_g"],
|
||||||
|
"fat_g": daily["fat_g"],
|
||||||
|
"carbs_g": daily["carbs_g"],
|
||||||
|
"water_ml": round(daily["water_l"] * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tdee_breakdown_to_api(daily: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"bmr": daily["bmr"],
|
||||||
|
"neat_kcal": daily["neat_kcal"],
|
||||||
|
"steps_kcal": daily["steps_kcal"],
|
||||||
|
"workout_kcal": daily["workout_kcal"],
|
||||||
|
"tdee": daily["tdee"],
|
||||||
|
"calorie_target": daily["calorie_target"],
|
||||||
|
"steps": daily.get("steps", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_targets(profile: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Rest-day targets (BMR + NEAT, no steps/workouts) for profile storage."""
|
||||||
|
daily = compute_daily_targets(profile, steps_total=0, workouts=[])
|
||||||
|
return {
|
||||||
|
"bmr": daily["bmr"],
|
||||||
|
"tdee": daily["tdee"],
|
||||||
|
"bmi": daily["bmi"],
|
||||||
|
"neat_kcal": daily["neat_kcal"],
|
||||||
|
"steps_kcal": 0,
|
||||||
|
"workout_kcal": 0,
|
||||||
|
"calorie_target": daily["calorie_target"],
|
||||||
|
"protein_g": daily["protein_g"],
|
||||||
|
"fat_g": daily["fat_g"],
|
||||||
|
"carbs_g": daily["carbs_g"],
|
||||||
|
"water_l": daily["water_l"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _activity_level_steps(activity_level: str | None) -> int:
|
||||||
|
key = (activity_level or "moderate").lower().replace("-", "_")
|
||||||
|
return ACTIVITY_LEVEL_STEPS.get(key, ACTIVITY_LEVEL_STEPS["moderate"])
|
||||||
|
|
||||||
|
|
||||||
|
def _history_days_with_data(history: list[dict[str, Any]]) -> int:
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for row in history
|
||||||
|
if int(row.get("steps") or 0) > 0 or float(row.get("workout_kcal") or 0) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_expected_activity(
|
||||||
|
profile: dict[str, Any],
|
||||||
|
*,
|
||||||
|
history: list[dict[str, Any]],
|
||||||
|
lookback_days: int = EXPECTED_LOOKBACK_DAYS,
|
||||||
|
) -> tuple[int, float, str, int]:
|
||||||
|
"""Return expected daily steps, workout kcal, source, and days_with_data."""
|
||||||
|
days_with_data = _history_days_with_data(history)
|
||||||
|
|
||||||
|
if days_with_data >= EXPECTED_MIN_DAYS_WITH_DATA:
|
||||||
|
steps_vals = [int(row.get("steps") or 0) for row in history]
|
||||||
|
workout_vals = [float(row.get("workout_kcal") or 0) for row in history]
|
||||||
|
expected_steps = round(sum(steps_vals) / len(steps_vals))
|
||||||
|
expected_workout_kcal = round(sum(workout_vals) / len(workout_vals), 1)
|
||||||
|
return expected_steps, expected_workout_kcal, "weekly_avg", days_with_data
|
||||||
|
|
||||||
|
baseline_steps = profile.get("baseline_steps")
|
||||||
|
baseline_workout_kcal = profile.get("baseline_workout_kcal")
|
||||||
|
if baseline_steps is not None or baseline_workout_kcal is not None:
|
||||||
|
steps = int(baseline_steps) if baseline_steps is not None else _activity_level_steps(
|
||||||
|
profile.get("activity_level")
|
||||||
|
)
|
||||||
|
workout_daily = (
|
||||||
|
round(float(baseline_workout_kcal) / 7, 1)
|
||||||
|
if baseline_workout_kcal is not None
|
||||||
|
else round(
|
||||||
|
int(profile.get("weekly_workouts") or 3) * DEFAULT_SESSION_KCAL / 7,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return steps, workout_daily, "baseline", days_with_data
|
||||||
|
|
||||||
|
weekly_workouts = int(profile.get("weekly_workouts") or 3)
|
||||||
|
return (
|
||||||
|
_activity_level_steps(profile.get("activity_level")),
|
||||||
|
round(weekly_workouts * DEFAULT_SESSION_KCAL / 7, 1),
|
||||||
|
"defaults",
|
||||||
|
days_with_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_expected_targets(
|
||||||
|
profile: dict[str, Any],
|
||||||
|
*,
|
||||||
|
history: list[dict[str, Any]],
|
||||||
|
lookback_days: int = EXPECTED_LOOKBACK_DAYS,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
expected_steps, expected_workout_kcal, source, days_with_data = resolve_expected_activity(
|
||||||
|
profile,
|
||||||
|
history=history,
|
||||||
|
lookback_days=lookback_days,
|
||||||
|
)
|
||||||
|
workouts = [{"active_calories": expected_workout_kcal}] if expected_workout_kcal > 0 else []
|
||||||
|
daily = compute_daily_targets(
|
||||||
|
profile,
|
||||||
|
steps_total=expected_steps,
|
||||||
|
workouts=workouts,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**daily,
|
||||||
|
"source": source,
|
||||||
|
"lookback_days": lookback_days,
|
||||||
|
"days_with_data": days_with_data,
|
||||||
|
"expected_steps": expected_steps,
|
||||||
|
"expected_workout_kcal": expected_workout_kcal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tdee_expected_to_api(daily: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"bmr": daily["bmr"],
|
||||||
|
"neat_kcal": daily["neat_kcal"],
|
||||||
|
"steps_kcal": daily["steps_kcal"],
|
||||||
|
"workout_kcal": daily["workout_kcal"],
|
||||||
|
"tdee": daily["tdee"],
|
||||||
|
"calorie_target": daily["calorie_target"],
|
||||||
|
"steps": daily.get("expected_steps", daily.get("steps", 0)),
|
||||||
|
"source": daily.get("source", "defaults"),
|
||||||
|
"lookback_days": daily.get("lookback_days", EXPECTED_LOOKBACK_DAYS),
|
||||||
|
"days_with_data": daily.get("days_with_data", 0),
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
"""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, FitnessProfile, FoodLog, StepLog, WaterLog, WorkoutLog
|
||||||
|
from app.fitness.activity_budget import estimate_workout_active_kcal
|
||||||
|
from app.fitness.calculators import (
|
||||||
|
EXPECTED_LOOKBACK_DAYS,
|
||||||
|
compute_daily_targets,
|
||||||
|
compute_expected_targets,
|
||||||
|
)
|
||||||
|
|
||||||
|
METRIC_DEFS: dict[str, dict[str, str]] = {
|
||||||
|
"weight_kg": {"label": "Вес", "unit": "кг"},
|
||||||
|
"body_fat_pct": {"label": "Жир", "unit": "%"},
|
||||||
|
"calories": {"label": "Калории", "unit": "ккал/день"},
|
||||||
|
"tdee": {"label": "TDEE факт", "unit": "ккал/день"},
|
||||||
|
"tdee_expected": {"label": "TDEE план", "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 _profile_for_charts(row: FitnessProfile | None) -> dict[str, float | int | str | None] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"goal": row.goal,
|
||||||
|
"neat_base_kcal": row.neat_base_kcal,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"weekly_workouts": row.weekly_workouts,
|
||||||
|
"baseline_steps": row.baseline_steps,
|
||||||
|
"baseline_workout_kcal": row.baseline_workout_kcal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_activity_maps(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
range_start: datetime,
|
||||||
|
range_end: datetime,
|
||||||
|
weight_kg: float,
|
||||||
|
) -> tuple[dict[date, int], dict[date, float]]:
|
||||||
|
steps_by_day: dict[date, int] = defaultdict(int)
|
||||||
|
workout_kcal_by_day: dict[date, float] = defaultdict(float)
|
||||||
|
|
||||||
|
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:
|
||||||
|
steps_by_day[row.logged_at.date()] += row.steps
|
||||||
|
|
||||||
|
workouts_rows = db.scalars(
|
||||||
|
select(WorkoutLog).where(
|
||||||
|
WorkoutLog.user_id == user_id,
|
||||||
|
WorkoutLog.logged_at >= range_start,
|
||||||
|
WorkoutLog.logged_at <= range_end,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for row in workouts_rows:
|
||||||
|
d = row.logged_at.date()
|
||||||
|
workout_kcal_by_day[d] += estimate_workout_active_kcal(
|
||||||
|
{
|
||||||
|
"title": row.title,
|
||||||
|
"duration_min": row.duration_min,
|
||||||
|
"active_calories": row.active_calories,
|
||||||
|
},
|
||||||
|
weight_kg=weight_kg,
|
||||||
|
)
|
||||||
|
return steps_by_day, workout_kcal_by_day
|
||||||
|
|
||||||
|
|
||||||
|
def _activity_history_before(
|
||||||
|
day: date,
|
||||||
|
steps_by_day: dict[date, int],
|
||||||
|
workout_kcal_by_day: dict[date, float],
|
||||||
|
*,
|
||||||
|
days: int = EXPECTED_LOOKBACK_DAYS,
|
||||||
|
) -> list[dict[str, float | int]]:
|
||||||
|
history: list[dict[str, float | int]] = []
|
||||||
|
start = day - timedelta(days=days)
|
||||||
|
cursor = start
|
||||||
|
while cursor < day:
|
||||||
|
history.append(
|
||||||
|
{
|
||||||
|
"steps": steps_by_day.get(cursor, 0),
|
||||||
|
"workout_kcal": workout_kcal_by_day.get(cursor, 0.0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cursor += timedelta(days=1)
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
|
def _tdee_actual_for_day(
|
||||||
|
profile: dict[str, float | int | str | None],
|
||||||
|
steps_by_day: dict[date, int],
|
||||||
|
workout_kcal_by_day: dict[date, float],
|
||||||
|
day: date,
|
||||||
|
) -> float:
|
||||||
|
steps = steps_by_day.get(day, 0)
|
||||||
|
workout_kcal = workout_kcal_by_day.get(day, 0.0)
|
||||||
|
workouts = [{"active_calories": workout_kcal}] if workout_kcal > 0 else []
|
||||||
|
return float(compute_daily_targets(profile, steps_total=steps, workouts=workouts)["tdee"])
|
||||||
|
|
||||||
|
|
||||||
|
def _tdee_expected_for_day(
|
||||||
|
profile: dict[str, float | int | str | None],
|
||||||
|
steps_by_day: dict[date, int],
|
||||||
|
workout_kcal_by_day: dict[date, float],
|
||||||
|
day: date,
|
||||||
|
) -> float:
|
||||||
|
history = _activity_history_before(day, steps_by_day, workout_kcal_by_day)
|
||||||
|
return float(compute_expected_targets(profile, history=history)["tdee"])
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
profile_row = db.scalar(
|
||||||
|
select(FitnessProfile).where(FitnessProfile.user_id == user_id).limit(1)
|
||||||
|
)
|
||||||
|
profile = _profile_for_charts(profile_row)
|
||||||
|
weight_kg = float(profile["weight_kg"]) if profile else 70.0
|
||||||
|
|
||||||
|
activity_start = datetime.combine(
|
||||||
|
first_week_start - timedelta(days=EXPECTED_LOOKBACK_DAYS),
|
||||||
|
datetime.min.time(),
|
||||||
|
tzinfo=timezone.utc,
|
||||||
|
)
|
||||||
|
steps_by_day, workout_kcal_by_day = _load_activity_maps(
|
||||||
|
db,
|
||||||
|
user_id,
|
||||||
|
activity_start,
|
||||||
|
range_end,
|
||||||
|
weight_kg,
|
||||||
|
)
|
||||||
|
|
||||||
|
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"])
|
||||||
|
elif metric == "tdee" and profile is not None and day_cursor <= end:
|
||||||
|
week_daily_values.append(
|
||||||
|
_tdee_actual_for_day(profile, steps_by_day, workout_kcal_by_day, day_cursor)
|
||||||
|
)
|
||||||
|
elif metric == "tdee_expected" and profile is not None and day_cursor <= end:
|
||||||
|
week_daily_values.append(
|
||||||
|
_tdee_expected_for_day(profile, steps_by_day, workout_kcal_by_day, day_cursor)
|
||||||
|
)
|
||||||
|
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)),
|
||||||
|
profile=profile,
|
||||||
|
steps_by_day=steps_by_day,
|
||||||
|
workout_kcal_by_day=workout_kcal_by_day,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
profile: dict[str, float | int | str | None] | None = None,
|
||||||
|
steps_by_day: dict[date, int] | None = None,
|
||||||
|
workout_kcal_by_day: dict[date, float] | None = None,
|
||||||
|
) -> 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
|
||||||
|
elif key == "tdee" and profile is not None and steps_by_day is not None and workout_kcal_by_day is not None:
|
||||||
|
value = _tdee_actual_for_day(profile, steps_by_day, workout_kcal_by_day, d)
|
||||||
|
has_data = True
|
||||||
|
elif (
|
||||||
|
key == "tdee_expected"
|
||||||
|
and profile is not None
|
||||||
|
and steps_by_day is not None
|
||||||
|
and workout_kcal_by_day is not None
|
||||||
|
):
|
||||||
|
value = _tdee_expected_for_day(profile, steps_by_day, workout_kcal_by_day, d)
|
||||||
|
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
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.fitness.service import FitnessService
|
||||||
|
|
||||||
|
|
||||||
|
def get_fitness_snapshot(db: Session, user_id: int) -> dict[str, Any]:
|
||||||
|
return FitnessService(db, user_id).snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
def format_fitness_context(snapshot: dict[str, Any]) -> str:
|
||||||
|
lines = ["[Фитнес — сводка на сегодня]"]
|
||||||
|
|
||||||
|
profile = snapshot.get("profile")
|
||||||
|
if not profile:
|
||||||
|
lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.")
|
||||||
|
else:
|
||||||
|
computed = profile.get("computed") or {}
|
||||||
|
lines.append(
|
||||||
|
f"Цели (база, без шагов/тренировок): {profile.get('calorie_target')} ккал, "
|
||||||
|
f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, "
|
||||||
|
f"вода {profile.get('water_l')} л"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"BMR {computed.get('bmr', '?')} + NEAT {computed.get('neat_kcal', 200)} = "
|
||||||
|
f"TDEE база {computed.get('tdee', '?')} ккал"
|
||||||
|
)
|
||||||
|
if profile.get("goal"):
|
||||||
|
lines.append(
|
||||||
|
f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, "
|
||||||
|
f"рост {profile.get('height_cm')} см"
|
||||||
|
)
|
||||||
|
|
||||||
|
today = snapshot.get("today") or {}
|
||||||
|
totals = today.get("totals") or {}
|
||||||
|
targets = today.get("targets") or {}
|
||||||
|
breakdown = today.get("tdee_breakdown") or {}
|
||||||
|
expected = today.get("tdee_expected") or {}
|
||||||
|
targets_expected = today.get("targets_expected") or {}
|
||||||
|
steps_total = today.get("steps_total") or 0
|
||||||
|
water_l = totals.get("water_ml", 0) / 1000
|
||||||
|
water_target = targets.get("water_ml", 2500) / 1000
|
||||||
|
|
||||||
|
if breakdown:
|
||||||
|
lines.append(
|
||||||
|
f"TDEE факт: BMR {breakdown.get('bmr')} + NEAT {breakdown.get('neat_kcal')} + "
|
||||||
|
f"шаги {breakdown.get('steps_kcal')} ({steps_total} шаг.) + "
|
||||||
|
f"тренировки {breakdown.get('workout_kcal')} = {breakdown.get('tdee')} ккал → "
|
||||||
|
f"цель {breakdown.get('calorie_target')} ккал"
|
||||||
|
)
|
||||||
|
elif steps_total == 0:
|
||||||
|
lines.append(
|
||||||
|
"Шаги/тренировки не внесены — TDEE факт = BMR + NEAT. "
|
||||||
|
"log_steps / log_workout для точной дневной цели."
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected:
|
||||||
|
source = expected.get("source", "?")
|
||||||
|
source_labels = {
|
||||||
|
"weekly_avg": "среднее за неделю",
|
||||||
|
"baseline": "baseline профиля",
|
||||||
|
"defaults": "по activity_level",
|
||||||
|
}
|
||||||
|
source_label = source_labels.get(str(source), str(source))
|
||||||
|
days_data = expected.get("days_with_data", 0)
|
||||||
|
lookback = expected.get("lookback_days", 7)
|
||||||
|
extra = f", {days_data} дн. с данными за {lookback} дн." if source == "weekly_avg" else ""
|
||||||
|
lines.append(
|
||||||
|
f"TDEE план ({source_label}{extra}): BMR {expected.get('bmr')} + NEAT {expected.get('neat_kcal')} + "
|
||||||
|
f"шаги {expected.get('steps_kcal')} (~{expected.get('steps', 0)} шаг.) + "
|
||||||
|
f"тренировки {expected.get('workout_kcal')} = {expected.get('tdee')} ккал → "
|
||||||
|
f"цель {expected.get('calorie_target')} ккал"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
if targets_expected and targets_expected.get("carbs_g") != targets.get("carbs_g"):
|
||||||
|
lines.append(
|
||||||
|
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал "
|
||||||
|
f"(план {targets_expected.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} "
|
||||||
|
f"(план {targets_expected.get('carbs_g', 0):.0f}) г"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
|
||||||
|
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
|
||||||
|
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
|
||||||
|
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г"
|
||||||
|
)
|
||||||
|
lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л")
|
||||||
|
|
||||||
|
workouts = today.get("workouts") or []
|
||||||
|
if workouts:
|
||||||
|
lines.append(f"Тренировок сегодня: {len(workouts)}")
|
||||||
|
|
||||||
|
stats = snapshot.get("workout_stats") or {}
|
||||||
|
if stats.get("count"):
|
||||||
|
lines.append(
|
||||||
|
f"Тренировки за {stats.get('days', 7)} дн.: {stats.get('count')} "
|
||||||
|
f"(серия {stats.get('streak')} дн., {stats.get('active_kcal')} ккал активных)"
|
||||||
|
)
|
||||||
|
|
||||||
|
latest = (snapshot.get("body_metrics") or [None])[0]
|
||||||
|
if latest:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Антропометрия (последняя):")
|
||||||
|
parts = [f"{latest.get('weight_kg')} кг"]
|
||||||
|
if latest.get("body_fat_pct") is not None:
|
||||||
|
method = latest.get("body_fat_method") or "?"
|
||||||
|
parts.append(f"жир {latest.get('body_fat_pct')}% ({method})")
|
||||||
|
if latest.get("neck_cm"):
|
||||||
|
parts.append(f"шея {latest.get('neck_cm')}")
|
||||||
|
if latest.get("waist_cm"):
|
||||||
|
parts.append(f"талия {latest.get('waist_cm')}")
|
||||||
|
if latest.get("hip_cm"):
|
||||||
|
parts.append(f"бёдра {latest.get('hip_cm')}")
|
||||||
|
if latest.get("whr"):
|
||||||
|
parts.append(f"WHR {latest.get('whr')}")
|
||||||
|
if latest.get("ffmi"):
|
||||||
|
parts.append(f"FFMI {latest.get('ffmi')}")
|
||||||
|
lines.append(" · ".join(parts))
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Правила: log_meal, log_water, log_weight (обхваты → Navy), log_steps, log_workout (date/days_ago), "
|
||||||
|
"calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, "
|
||||||
|
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
|
||||||
|
"TDEE = BMR + NEAT (200 ккал) + шаги + тренировки. "
|
||||||
|
"TDEE факт — по залогированной активности; TDEE план — среднее за неделю (или baseline) для утреннего бюджета углеводов. "
|
||||||
|
"БЖУ: белок 2.2 г/кг (сушка) / 1.8 г/кг (поддержание/набор), жир 1.0 г/кг, угли — остаток от целевых ккал. "
|
||||||
|
"Скриншоты Mi Fitness: vision уже извлекла данные в блок [Скриншот] с fitness_hints — используй их, не говори что не видишь картинку. "
|
||||||
|
"Еда — оценка LLM (≈)."
|
||||||
|
)
|
||||||
|
return chr(10).join(lines)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import (
|
||||||
|
BodyMetric,
|
||||||
|
FitnessProfile,
|
||||||
|
FitnessReminder,
|
||||||
|
FoodLog,
|
||||||
|
StepLog,
|
||||||
|
WaterLog,
|
||||||
|
WorkoutLog,
|
||||||
|
)
|
||||||
|
from app.fitness.activity_budget import estimate_workout_active_kcal
|
||||||
|
from app.fitness.calculators import (
|
||||||
|
compute_daily_targets,
|
||||||
|
compute_expected_targets,
|
||||||
|
compute_targets,
|
||||||
|
one_rep_max,
|
||||||
|
targets_to_api,
|
||||||
|
tdee_breakdown_to_api,
|
||||||
|
tdee_expected_to_api,
|
||||||
|
EXPECTED_LOOKBACK_DAYS,
|
||||||
|
)
|
||||||
|
from app.fitness.body_composition import compute_body_composition
|
||||||
|
|
||||||
|
DEFAULT_REMINDERS = [
|
||||||
|
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
|
||||||
|
{"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None},
|
||||||
|
{"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None},
|
||||||
|
{"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FitnessService:
|
||||||
|
def __init__(self, db: Session, user_id: int):
|
||||||
|
self.db = db
|
||||||
|
self.user_id = user_id
|
||||||
|
|
||||||
|
def _get_profile_row(self) -> FitnessProfile | None:
|
||||||
|
return self.db.scalar(select(FitnessProfile).where(FitnessProfile.user_id == self.user_id).limit(1))
|
||||||
|
|
||||||
|
def get_profile(self) -> dict[str, Any] | None:
|
||||||
|
row = self._get_profile_row()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return self._profile_to_dict(row)
|
||||||
|
|
||||||
|
def _profile_params(self, row: FitnessProfile) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"goal": row.goal,
|
||||||
|
"neat_base_kcal": row.neat_base_kcal,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"weekly_workouts": row.weekly_workouts,
|
||||||
|
"baseline_steps": row.baseline_steps,
|
||||||
|
"baseline_workout_kcal": row.baseline_workout_kcal,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
|
||||||
|
targets = compute_targets(self._profile_params(row))
|
||||||
|
return {
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"goal": row.goal,
|
||||||
|
"target_weight_kg": row.target_weight_kg,
|
||||||
|
"neat_base_kcal": row.neat_base_kcal,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"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",
|
||||||
|
"goal", "target_weight_kg", "neat_base_kcal",
|
||||||
|
"activity_level", "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(self._profile_params(row))
|
||||||
|
row.calorie_target = targets["calorie_target"]
|
||||||
|
row.protein_g = targets["protein_g"]
|
||||||
|
row.fat_g = targets["fat_g"]
|
||||||
|
row.carbs_g = targets["carbs_g"]
|
||||||
|
row.water_l = targets["water_l"]
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
self._ensure_default_reminders()
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "profile": self._profile_to_dict(row)}
|
||||||
|
|
||||||
|
def _ensure_default_reminders(self) -> None:
|
||||||
|
existing = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id)).all()
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
for item in DEFAULT_REMINDERS:
|
||||||
|
self.db.add(FitnessReminder(user_id=self.user_id, **item))
|
||||||
|
|
||||||
|
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return compute_targets(params)
|
||||||
|
|
||||||
|
def calc_body_composition(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = self.get_profile() or {}
|
||||||
|
sex = params.get("sex") or profile.get("sex") or "male"
|
||||||
|
height_cm = float(params.get("height_cm") or profile.get("height_cm") or 170)
|
||||||
|
weight_kg = float(params.get("weight_kg") or profile.get("weight_kg") or 70)
|
||||||
|
return compute_body_composition(
|
||||||
|
sex=str(sex),
|
||||||
|
height_cm=height_cm,
|
||||||
|
weight_kg=weight_kg,
|
||||||
|
neck_cm=params.get("neck_cm"),
|
||||||
|
waist_cm=params.get("waist_cm"),
|
||||||
|
hip_cm=params.get("hip_cm"),
|
||||||
|
body_fat_pct=params.get("body_fat_pct"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_latest_body_composition(self) -> dict[str, Any] | None:
|
||||||
|
rows = self.list_body_metrics(limit=1)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _body_metric_to_dict(row: BodyMetric) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"body_fat_pct": row.body_fat_pct,
|
||||||
|
"body_fat_method": row.body_fat_method,
|
||||||
|
"chest_cm": row.chest_cm,
|
||||||
|
"waist_cm": row.waist_cm,
|
||||||
|
"neck_cm": row.neck_cm,
|
||||||
|
"hip_cm": row.hip_cm,
|
||||||
|
"whr": row.whr,
|
||||||
|
"lbm_kg": row.lbm_kg,
|
||||||
|
"ffmi": row.ffmi,
|
||||||
|
"notes": row.notes,
|
||||||
|
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_logged_at(
|
||||||
|
*,
|
||||||
|
logged_at: datetime | str | None = None,
|
||||||
|
day: date | None = None,
|
||||||
|
days_ago: int | None = None,
|
||||||
|
) -> datetime:
|
||||||
|
if logged_at is not None:
|
||||||
|
if isinstance(logged_at, str):
|
||||||
|
dt = datetime.fromisoformat(logged_at.replace("Z", "+00:00"))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
if logged_at.tzinfo is None:
|
||||||
|
return logged_at.replace(tzinfo=timezone.utc)
|
||||||
|
return logged_at
|
||||||
|
|
||||||
|
target_day = day
|
||||||
|
if target_day is None and days_ago is not None:
|
||||||
|
target_day = datetime.now(timezone.utc).date() - timedelta(days=int(days_ago))
|
||||||
|
|
||||||
|
if target_day is None:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
return datetime.combine(target_day, time(12, 0), tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
def _profile_for_budget(self, profile: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if profile:
|
||||||
|
return profile
|
||||||
|
return {
|
||||||
|
"weight_kg": 70,
|
||||||
|
"height_cm": 170,
|
||||||
|
"age": 30,
|
||||||
|
"sex": "male",
|
||||||
|
"goal": "maintain",
|
||||||
|
"neat_base_kcal": 200,
|
||||||
|
"activity_level": "moderate",
|
||||||
|
"weekly_workouts": 3,
|
||||||
|
"baseline_steps": None,
|
||||||
|
"baseline_workout_kcal": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _activity_history(
|
||||||
|
self,
|
||||||
|
end_day: date,
|
||||||
|
*,
|
||||||
|
days: int = EXPECTED_LOOKBACK_DAYS,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Daily steps and workout kcal for `days` calendar days before end_day (exclusive)."""
|
||||||
|
if days <= 0:
|
||||||
|
return []
|
||||||
|
start = end_day - timedelta(days=days)
|
||||||
|
range_start = datetime.combine(start, time.min, tzinfo=timezone.utc)
|
||||||
|
range_end = datetime.combine(end_day - timedelta(days=1), time.max, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
profile_row = self._get_profile_row()
|
||||||
|
weight_kg = float(profile_row.weight_kg) if profile_row else 70.0
|
||||||
|
|
||||||
|
steps_by_day: dict[date, int] = defaultdict(int)
|
||||||
|
workout_kcal_by_day: dict[date, float] = defaultdict(float)
|
||||||
|
|
||||||
|
steps_rows = self.db.scalars(
|
||||||
|
select(StepLog).where(
|
||||||
|
StepLog.user_id == self.user_id,
|
||||||
|
StepLog.logged_at >= range_start,
|
||||||
|
StepLog.logged_at <= range_end,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for row in steps_rows:
|
||||||
|
steps_by_day[row.logged_at.date()] += row.steps
|
||||||
|
|
||||||
|
workouts_rows = self.db.scalars(
|
||||||
|
select(WorkoutLog).where(
|
||||||
|
WorkoutLog.user_id == self.user_id,
|
||||||
|
WorkoutLog.logged_at >= range_start,
|
||||||
|
WorkoutLog.logged_at <= range_end,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for row in workouts_rows:
|
||||||
|
d = row.logged_at.date()
|
||||||
|
workout_kcal_by_day[d] += estimate_workout_active_kcal(
|
||||||
|
self._workout_to_dict(row),
|
||||||
|
weight_kg=weight_kg,
|
||||||
|
)
|
||||||
|
|
||||||
|
history: list[dict[str, Any]] = []
|
||||||
|
cursor = start
|
||||||
|
while cursor < end_day:
|
||||||
|
history.append(
|
||||||
|
{
|
||||||
|
"date": cursor.isoformat(),
|
||||||
|
"steps": steps_by_day.get(cursor, 0),
|
||||||
|
"workout_kcal": round(workout_kcal_by_day.get(cursor, 0.0), 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cursor += timedelta(days=1)
|
||||||
|
return history
|
||||||
|
|
||||||
|
def _maybe_update_baseline(self, profile_row: FitnessProfile | None, expected: dict[str, Any]) -> None:
|
||||||
|
if profile_row is None:
|
||||||
|
return
|
||||||
|
if expected.get("source") != "weekly_avg":
|
||||||
|
return
|
||||||
|
if int(expected.get("days_with_data") or 0) < 5:
|
||||||
|
return
|
||||||
|
profile_row.baseline_steps = int(expected.get("expected_steps") or 0)
|
||||||
|
profile_row.baseline_workout_kcal = round(
|
||||||
|
float(expected.get("expected_workout_kcal") or 0) * 7,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _expected_payload(
|
||||||
|
self,
|
||||||
|
profile: dict[str, Any],
|
||||||
|
day: date,
|
||||||
|
*,
|
||||||
|
profile_row: FitnessProfile | None = None,
|
||||||
|
update_baseline: bool = False,
|
||||||
|
) -> tuple[dict[str, Any], dict[str, float]]:
|
||||||
|
history = self._activity_history(day, days=EXPECTED_LOOKBACK_DAYS)
|
||||||
|
expected_daily = compute_expected_targets(profile, history=history)
|
||||||
|
if update_baseline:
|
||||||
|
self._maybe_update_baseline(profile_row, expected_daily)
|
||||||
|
return tdee_expected_to_api(expected_daily), targets_to_api(expected_daily)
|
||||||
|
|
||||||
|
|
||||||
|
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_row()
|
||||||
|
profile_dict = self.get_profile()
|
||||||
|
profile = self._profile_for_budget(profile_dict)
|
||||||
|
|
||||||
|
foods = self.db.scalars(
|
||||||
|
select(FoodLog)
|
||||||
|
.where(FoodLog.user_id == self.user_id, FoodLog.logged_at >= start, FoodLog.logged_at <= end)
|
||||||
|
.order_by(FoodLog.logged_at)
|
||||||
|
).all()
|
||||||
|
waters = self.db.scalars(
|
||||||
|
select(WaterLog)
|
||||||
|
.where(WaterLog.user_id == self.user_id, WaterLog.logged_at >= start, WaterLog.logged_at <= end)
|
||||||
|
.order_by(WaterLog.logged_at)
|
||||||
|
).all()
|
||||||
|
workouts_rows = self.db.scalars(
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
|
||||||
|
.order_by(WorkoutLog.logged_at)
|
||||||
|
).all()
|
||||||
|
steps_rows = self.db.scalars(
|
||||||
|
select(StepLog)
|
||||||
|
.where(StepLog.user_id == self.user_id, StepLog.logged_at >= start, StepLog.logged_at <= end)
|
||||||
|
.order_by(StepLog.logged_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
workouts = [self._workout_to_dict(w) for w in workouts_rows]
|
||||||
|
steps_total = sum(s.steps for s in steps_rows)
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"calories": sum(f.calories for f in foods),
|
||||||
|
"protein_g": sum(f.protein_g for f in foods),
|
||||||
|
"fat_g": sum(f.fat_g for f in foods),
|
||||||
|
"carbs_g": sum(f.carbs_g for f in foods),
|
||||||
|
"water_ml": sum(w.amount_ml for w in waters),
|
||||||
|
"steps": steps_total,
|
||||||
|
}
|
||||||
|
|
||||||
|
daily = compute_daily_targets(
|
||||||
|
profile,
|
||||||
|
steps_total=steps_total,
|
||||||
|
workouts=workouts,
|
||||||
|
)
|
||||||
|
targets = targets_to_api(daily)
|
||||||
|
target_day = day or datetime.now(timezone.utc).date()
|
||||||
|
tdee_expected, targets_expected = self._expected_payload(
|
||||||
|
profile,
|
||||||
|
target_day,
|
||||||
|
profile_row=profile_row,
|
||||||
|
update_baseline=target_day == datetime.now(timezone.utc).date(),
|
||||||
|
)
|
||||||
|
if profile_row is not None and target_day == datetime.now(timezone.utc).date():
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": target_day.isoformat(),
|
||||||
|
"profile_configured": profile_row is not None,
|
||||||
|
"totals": totals,
|
||||||
|
"targets": targets,
|
||||||
|
"targets_expected": targets_expected,
|
||||||
|
"tdee_breakdown": tdee_breakdown_to_api(daily),
|
||||||
|
"tdee_expected": tdee_expected,
|
||||||
|
"meals": [self._food_to_dict(f) for f in foods],
|
||||||
|
"water": [self._water_to_dict(w) for w in waters],
|
||||||
|
"workouts": workouts,
|
||||||
|
"steps": [self._step_to_dict(s) for s in steps_rows],
|
||||||
|
"steps_total": steps_total,
|
||||||
|
}
|
||||||
|
|
||||||
|
def log_meal(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
description: str,
|
||||||
|
meal_type: str = "snack",
|
||||||
|
calories: float = 0,
|
||||||
|
protein_g: float = 0,
|
||||||
|
fat_g: float = 0,
|
||||||
|
carbs_g: float = 0,
|
||||||
|
source: str = "llm",
|
||||||
|
estimated: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = FoodLog(
|
||||||
|
user_id=self.user_id,
|
||||||
|
meal_type=meal_type[:32],
|
||||||
|
description=description[:2000],
|
||||||
|
calories=calories,
|
||||||
|
protein_g=protein_g,
|
||||||
|
fat_g=fat_g,
|
||||||
|
carbs_g=carbs_g,
|
||||||
|
source=source[:32],
|
||||||
|
estimated=estimated,
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "meal": self._food_to_dict(row)}
|
||||||
|
|
||||||
|
def log_water(self, amount_ml: int) -> dict[str, Any]:
|
||||||
|
row = WaterLog(user_id=self.user_id, amount_ml=max(0, amount_ml))
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "water": self._water_to_dict(row)}
|
||||||
|
|
||||||
|
def log_steps(
|
||||||
|
self,
|
||||||
|
steps: int,
|
||||||
|
*,
|
||||||
|
active_calories: float | None = None,
|
||||||
|
logged_at: datetime | str | None = None,
|
||||||
|
day: date | None = None,
|
||||||
|
days_ago: int | None = None,
|
||||||
|
notes: str = "",
|
||||||
|
source: str = "manual",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = StepLog(
|
||||||
|
user_id=self.user_id,
|
||||||
|
steps=max(0, int(steps)),
|
||||||
|
active_calories=active_calories,
|
||||||
|
notes=notes[:2000],
|
||||||
|
source=source[:32],
|
||||||
|
logged_at=self._resolve_logged_at(
|
||||||
|
logged_at=logged_at,
|
||||||
|
day=day,
|
||||||
|
days_ago=days_ago,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "step_log": self._step_to_dict(row)}
|
||||||
|
|
||||||
|
def log_weight(
|
||||||
|
self,
|
||||||
|
weight_kg: float,
|
||||||
|
*,
|
||||||
|
body_fat_pct: float | None = None,
|
||||||
|
chest_cm: float | None = None,
|
||||||
|
waist_cm: float | None = None,
|
||||||
|
neck_cm: float | None = None,
|
||||||
|
hip_cm: float | None = None,
|
||||||
|
notes: str = "",
|
||||||
|
recorded_at: datetime | str | None = None,
|
||||||
|
day: date | None = None,
|
||||||
|
days_ago: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
profile = self.get_profile() or {}
|
||||||
|
sex = profile.get("sex") or "male"
|
||||||
|
height_cm = float(profile.get("height_cm") or 170)
|
||||||
|
|
||||||
|
computed = compute_body_composition(
|
||||||
|
sex=str(sex),
|
||||||
|
height_cm=height_cm,
|
||||||
|
weight_kg=weight_kg,
|
||||||
|
neck_cm=neck_cm,
|
||||||
|
waist_cm=waist_cm,
|
||||||
|
hip_cm=hip_cm,
|
||||||
|
body_fat_pct=body_fat_pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = BodyMetric(
|
||||||
|
user_id=self.user_id,
|
||||||
|
weight_kg=weight_kg,
|
||||||
|
body_fat_pct=computed.get("body_fat_pct"),
|
||||||
|
body_fat_method=computed.get("body_fat_method"),
|
||||||
|
chest_cm=chest_cm,
|
||||||
|
waist_cm=waist_cm,
|
||||||
|
neck_cm=neck_cm,
|
||||||
|
hip_cm=hip_cm,
|
||||||
|
whr=computed.get("whr"),
|
||||||
|
lbm_kg=computed.get("lbm_kg"),
|
||||||
|
ffmi=computed.get("ffmi"),
|
||||||
|
notes=notes[:1000],
|
||||||
|
recorded_at=self._resolve_logged_at(
|
||||||
|
logged_at=recorded_at,
|
||||||
|
day=day,
|
||||||
|
days_ago=days_ago,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
profile_row = self._get_profile_row()
|
||||||
|
if profile_row:
|
||||||
|
profile_row.weight_kg = weight_kg
|
||||||
|
targets = compute_targets(
|
||||||
|
{
|
||||||
|
"sex": profile_row.sex,
|
||||||
|
"age": profile_row.age,
|
||||||
|
"height_cm": profile_row.height_cm,
|
||||||
|
"weight_kg": weight_kg,
|
||||||
|
"goal": profile_row.goal,
|
||||||
|
"neat_base_kcal": profile_row.neat_base_kcal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
profile_row.calorie_target = targets["calorie_target"]
|
||||||
|
profile_row.protein_g = targets["protein_g"]
|
||||||
|
profile_row.fat_g = targets["fat_g"]
|
||||||
|
profile_row.carbs_g = targets["carbs_g"]
|
||||||
|
profile_row.water_l = targets["water_l"]
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
metric = self._body_metric_to_dict(row)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"metric": metric,
|
||||||
|
"computed": {
|
||||||
|
"body_fat_pct": computed.get("body_fat_pct"),
|
||||||
|
"body_fat_method": computed.get("body_fat_method"),
|
||||||
|
"whr": computed.get("whr"),
|
||||||
|
"lbm_kg": computed.get("lbm_kg"),
|
||||||
|
"ffmi": computed.get("ffmi"),
|
||||||
|
"warnings": computed.get("warnings") or [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def log_workout(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
notes: str = "",
|
||||||
|
duration_min: int | None = None,
|
||||||
|
exercises: list[dict[str, Any]] | None = None,
|
||||||
|
active_calories: float | None = None,
|
||||||
|
total_calories: float | None = None,
|
||||||
|
steps: int | None = None,
|
||||||
|
activity_type: str | None = None,
|
||||||
|
met: float | None = None,
|
||||||
|
logged_at: datetime | str | None = None,
|
||||||
|
day: date | None = None,
|
||||||
|
days_ago: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
profile = self.get_profile() or {}
|
||||||
|
weight_kg = float(profile.get("weight_kg") or 70)
|
||||||
|
|
||||||
|
if active_calories is None and duration_min and met is not None:
|
||||||
|
active_calories = round(met * weight_kg * (float(duration_min) / 60.0), 1)
|
||||||
|
elif active_calories is None and duration_min:
|
||||||
|
draft = {
|
||||||
|
"title": title,
|
||||||
|
"notes": notes,
|
||||||
|
"activity_type": activity_type,
|
||||||
|
"met": met,
|
||||||
|
"duration_min": duration_min,
|
||||||
|
}
|
||||||
|
active_calories = estimate_workout_active_kcal(draft, weight_kg=weight_kg) or None
|
||||||
|
|
||||||
|
row = WorkoutLog(
|
||||||
|
user_id=self.user_id,
|
||||||
|
title=title[:255],
|
||||||
|
notes=notes[:2000],
|
||||||
|
duration_min=duration_min,
|
||||||
|
active_calories=active_calories,
|
||||||
|
total_calories=total_calories,
|
||||||
|
steps=steps,
|
||||||
|
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
|
||||||
|
logged_at=self._resolve_logged_at(
|
||||||
|
logged_at=logged_at,
|
||||||
|
day=day,
|
||||||
|
days_ago=days_ago,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "workout": self._workout_to_dict(row)}
|
||||||
|
|
||||||
|
def get_workout_stats(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
days: int = 7,
|
||||||
|
end_day: date | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
days = max(1, min(days, 90))
|
||||||
|
end = end_day or datetime.now(timezone.utc).date()
|
||||||
|
start = end - timedelta(days=days - 1)
|
||||||
|
start_dt, _ = self._day_bounds(start)
|
||||||
|
_, end_dt = self._day_bounds(end)
|
||||||
|
|
||||||
|
rows = self.db.scalars(
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start_dt, WorkoutLog.logged_at <= end_dt)
|
||||||
|
.order_by(WorkoutLog.logged_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
profile = self.get_profile() or {}
|
||||||
|
weight_kg = float(profile.get("weight_kg") or 70)
|
||||||
|
weekly_target = 3
|
||||||
|
|
||||||
|
count = len(rows)
|
||||||
|
duration_min = sum(r.duration_min or 0 for r in rows)
|
||||||
|
active_kcal = round(
|
||||||
|
sum(
|
||||||
|
estimate_workout_active_kcal(self._workout_to_dict(r), weight_kg=weight_kg)
|
||||||
|
for r in rows
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
days_with_workout: set[date] = set()
|
||||||
|
for row in rows:
|
||||||
|
if row.logged_at:
|
||||||
|
days_with_workout.add(row.logged_at.astimezone(timezone.utc).date())
|
||||||
|
|
||||||
|
streak = 0
|
||||||
|
cursor = end
|
||||||
|
while cursor >= start:
|
||||||
|
if cursor in days_with_workout:
|
||||||
|
streak += 1
|
||||||
|
cursor -= timedelta(days=1)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"days": days,
|
||||||
|
"start_date": start.isoformat(),
|
||||||
|
"end_date": end.isoformat(),
|
||||||
|
"count": count,
|
||||||
|
"duration_min": duration_min,
|
||||||
|
"active_kcal": active_kcal,
|
||||||
|
"weekly_target": weekly_target,
|
||||||
|
"streak": streak,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
|
||||||
|
rows = self.db.scalars(
|
||||||
|
select(BodyMetric).where(BodyMetric.user_id == self.user_id).order_by(BodyMetric.recorded_at.desc()).limit(limit)
|
||||||
|
).all()
|
||||||
|
return [self._body_metric_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
def delete_food_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(FoodLog, log_id)
|
||||||
|
if not row or row.user_id != self.user_id:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_water_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(WaterLog, log_id)
|
||||||
|
if not row or row.user_id != self.user_id:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_workout_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(WorkoutLog, log_id)
|
||||||
|
if not row or row.user_id != self.user_id:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_step_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(StepLog, log_id)
|
||||||
|
if not row or row.user_id != self.user_id:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_reminders(self) -> list[dict[str, Any]]:
|
||||||
|
rows = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id).order_by(FitnessReminder.kind)).all()
|
||||||
|
return [self._reminder_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
def set_reminder(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
hour: int | None = None,
|
||||||
|
minute: int | None = None,
|
||||||
|
interval_hours: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = self.db.scalar(
|
||||||
|
select(FitnessReminder).where(FitnessReminder.user_id == self.user_id, FitnessReminder.kind == kind)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
row = FitnessReminder(user_id=self.user_id, kind=kind)
|
||||||
|
self.db.add(row)
|
||||||
|
if enabled is not None:
|
||||||
|
row.enabled = enabled
|
||||||
|
if hour is not None:
|
||||||
|
row.hour = hour
|
||||||
|
if minute is not None:
|
||||||
|
row.minute = minute
|
||||||
|
if interval_hours is not None:
|
||||||
|
row.interval_hours = interval_hours
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "reminder": self._reminder_to_dict(row)}
|
||||||
|
|
||||||
|
def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]:
|
||||||
|
return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)}
|
||||||
|
|
||||||
|
def get_history(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
days: int = 7,
|
||||||
|
end_day: date | None = None,
|
||||||
|
include_tdee_breakdown: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
days = max(1, min(days, 90))
|
||||||
|
end = end_day or datetime.now(timezone.utc).date()
|
||||||
|
start = end - timedelta(days=days - 1)
|
||||||
|
summaries: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for offset in range(days):
|
||||||
|
d = start + timedelta(days=offset)
|
||||||
|
full = self.get_daily_summary(d)
|
||||||
|
totals = full["totals"]
|
||||||
|
has_data = bool(full["meals"] or full["water"] or full["workouts"] or full["steps"])
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"date": full["date"],
|
||||||
|
"has_data": has_data,
|
||||||
|
"totals": totals,
|
||||||
|
"targets": full["targets"],
|
||||||
|
"targets_expected": full.get("targets_expected"),
|
||||||
|
"meal_count": len(full["meals"]),
|
||||||
|
"workout_count": len(full["workouts"]),
|
||||||
|
}
|
||||||
|
if include_tdee_breakdown:
|
||||||
|
item["tdee_breakdown"] = full.get("tdee_breakdown")
|
||||||
|
item["tdee_expected"] = full.get("tdee_expected")
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import json
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import (
|
||||||
|
BodyMetric,
|
||||||
|
FitnessProfile,
|
||||||
|
FitnessReminder,
|
||||||
|
FoodLog,
|
||||||
|
WaterLog,
|
||||||
|
WorkoutLog,
|
||||||
|
)
|
||||||
|
from app.fitness.calculators import compute_targets, one_rep_max
|
||||||
|
|
||||||
|
DEFAULT_REMINDERS = [
|
||||||
|
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
|
||||||
|
{"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None},
|
||||||
|
{"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None},
|
||||||
|
{"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FitnessService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def _get_profile_row(self) -> FitnessProfile | None:
|
||||||
|
return self.db.scalar(select(FitnessProfile).limit(1))
|
||||||
|
|
||||||
|
def get_profile(self) -> dict[str, Any] | None:
|
||||||
|
row = self._get_profile_row()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return self._profile_to_dict(row)
|
||||||
|
|
||||||
|
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
|
||||||
|
targets = compute_targets(
|
||||||
|
{
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"goal": row.goal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"goal": row.goal,
|
||||||
|
"target_weight_kg": row.target_weight_kg,
|
||||||
|
"weekly_workouts": row.weekly_workouts,
|
||||||
|
"calorie_target": row.calorie_target,
|
||||||
|
"protein_g": row.protein_g,
|
||||||
|
"fat_g": row.fat_g,
|
||||||
|
"carbs_g": row.carbs_g,
|
||||||
|
"water_l": row.water_l,
|
||||||
|
"computed": targets,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = self._get_profile_row()
|
||||||
|
is_new = row is None
|
||||||
|
if is_new:
|
||||||
|
row = FitnessProfile()
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"sex", "age", "height_cm", "weight_kg", "activity_level",
|
||||||
|
"goal", "target_weight_kg", "weekly_workouts",
|
||||||
|
):
|
||||||
|
if key in updates and updates[key] is not None:
|
||||||
|
setattr(row, key, updates[key])
|
||||||
|
|
||||||
|
targets = compute_targets(
|
||||||
|
{
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"goal": row.goal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
row.calorie_target = targets["calorie_target"]
|
||||||
|
row.protein_g = targets["protein_g"]
|
||||||
|
row.fat_g = targets["fat_g"]
|
||||||
|
row.carbs_g = targets["carbs_g"]
|
||||||
|
row.water_l = targets["water_l"]
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
self._ensure_default_reminders()
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "profile": self._profile_to_dict(row)}
|
||||||
|
|
||||||
|
def _ensure_default_reminders(self) -> None:
|
||||||
|
existing = self.db.scalars(select(FitnessReminder)).all()
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
for item in DEFAULT_REMINDERS:
|
||||||
|
self.db.add(FitnessReminder(**item))
|
||||||
|
|
||||||
|
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return compute_targets(params)
|
||||||
|
|
||||||
|
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
|
||||||
|
d = day or datetime.now(timezone.utc).date()
|
||||||
|
start = datetime.combine(d, time.min, tzinfo=timezone.utc)
|
||||||
|
end = datetime.combine(d, time.max, tzinfo=timezone.utc)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
|
||||||
|
start, end = self._day_bounds(day)
|
||||||
|
profile = self.get_profile()
|
||||||
|
|
||||||
|
foods = self.db.scalars(
|
||||||
|
select(FoodLog)
|
||||||
|
.where(FoodLog.logged_at >= start, FoodLog.logged_at <= end)
|
||||||
|
.order_by(FoodLog.logged_at)
|
||||||
|
).all()
|
||||||
|
waters = self.db.scalars(
|
||||||
|
select(WaterLog)
|
||||||
|
.where(WaterLog.logged_at >= start, WaterLog.logged_at <= end)
|
||||||
|
.order_by(WaterLog.logged_at)
|
||||||
|
).all()
|
||||||
|
workouts = self.db.scalars(
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
|
||||||
|
.order_by(WorkoutLog.logged_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"calories": sum(f.calories for f in foods),
|
||||||
|
"protein_g": sum(f.protein_g for f in foods),
|
||||||
|
"fat_g": sum(f.fat_g for f in foods),
|
||||||
|
"carbs_g": sum(f.carbs_g for f in foods),
|
||||||
|
"water_ml": sum(w.amount_ml for w in waters),
|
||||||
|
}
|
||||||
|
|
||||||
|
targets = profile or {
|
||||||
|
"calorie_target": 2000,
|
||||||
|
"protein_g": 140,
|
||||||
|
"fat_g": 65,
|
||||||
|
"carbs_g": 200,
|
||||||
|
"water_l": 2.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
|
||||||
|
"profile_configured": profile is not None,
|
||||||
|
"totals": totals,
|
||||||
|
"targets": {
|
||||||
|
"calories": targets.get("calorie_target", 2000),
|
||||||
|
"protein_g": targets.get("protein_g", 140),
|
||||||
|
"fat_g": targets.get("fat_g", 65),
|
||||||
|
"carbs_g": targets.get("carbs_g", 200),
|
||||||
|
"water_ml": targets.get("water_l", 2.5) * 1000,
|
||||||
|
},
|
||||||
|
"meals": [self._food_to_dict(f) for f in foods],
|
||||||
|
"water": [self._water_to_dict(w) for w in waters],
|
||||||
|
"workouts": [self._workout_to_dict(w) for w in workouts],
|
||||||
|
}
|
||||||
|
|
||||||
|
def log_meal(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
description: str,
|
||||||
|
meal_type: str = "snack",
|
||||||
|
calories: float = 0,
|
||||||
|
protein_g: float = 0,
|
||||||
|
fat_g: float = 0,
|
||||||
|
carbs_g: float = 0,
|
||||||
|
source: str = "llm",
|
||||||
|
estimated: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = FoodLog(
|
||||||
|
meal_type=meal_type[:32],
|
||||||
|
description=description[:2000],
|
||||||
|
calories=calories,
|
||||||
|
protein_g=protein_g,
|
||||||
|
fat_g=fat_g,
|
||||||
|
carbs_g=carbs_g,
|
||||||
|
source=source[:32],
|
||||||
|
estimated=estimated,
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "meal": self._food_to_dict(row)}
|
||||||
|
|
||||||
|
def log_water(self, amount_ml: int) -> dict[str, Any]:
|
||||||
|
row = WaterLog(amount_ml=max(0, amount_ml))
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "water": self._water_to_dict(row)}
|
||||||
|
|
||||||
|
def log_weight(
|
||||||
|
self,
|
||||||
|
weight_kg: float,
|
||||||
|
*,
|
||||||
|
body_fat_pct: float | None = None,
|
||||||
|
chest_cm: float | None = None,
|
||||||
|
waist_cm: float | None = None,
|
||||||
|
notes: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = BodyMetric(
|
||||||
|
weight_kg=weight_kg,
|
||||||
|
body_fat_pct=body_fat_pct,
|
||||||
|
chest_cm=chest_cm,
|
||||||
|
waist_cm=waist_cm,
|
||||||
|
notes=notes[:1000],
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
profile = self._get_profile_row()
|
||||||
|
if profile:
|
||||||
|
profile.weight_kg = weight_kg
|
||||||
|
targets = compute_targets(
|
||||||
|
{
|
||||||
|
"sex": profile.sex,
|
||||||
|
"age": profile.age,
|
||||||
|
"height_cm": profile.height_cm,
|
||||||
|
"weight_kg": weight_kg,
|
||||||
|
"activity_level": profile.activity_level,
|
||||||
|
"goal": profile.goal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
profile.calorie_target = targets["calorie_target"]
|
||||||
|
profile.protein_g = targets["protein_g"]
|
||||||
|
profile.fat_g = targets["fat_g"]
|
||||||
|
profile.carbs_g = targets["carbs_g"]
|
||||||
|
profile.water_l = targets["water_l"]
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"metric": {
|
||||||
|
"id": row.id,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def log_workout(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
notes: str = "",
|
||||||
|
duration_min: int | None = None,
|
||||||
|
exercises: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = WorkoutLog(
|
||||||
|
title=title[:255],
|
||||||
|
notes=notes[:2000],
|
||||||
|
duration_min=duration_min,
|
||||||
|
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "workout": self._workout_to_dict(row)}
|
||||||
|
|
||||||
|
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
|
||||||
|
rows = self.db.scalars(
|
||||||
|
select(BodyMetric).order_by(BodyMetric.recorded_at.desc()).limit(limit)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"weight_kg": r.weight_kg,
|
||||||
|
"body_fat_pct": r.body_fat_pct,
|
||||||
|
"chest_cm": r.chest_cm,
|
||||||
|
"waist_cm": r.waist_cm,
|
||||||
|
"notes": r.notes,
|
||||||
|
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def delete_food_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(FoodLog, log_id)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_water_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(WaterLog, log_id)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_workout_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(WorkoutLog, log_id)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_reminders(self) -> list[dict[str, Any]]:
|
||||||
|
rows = self.db.scalars(select(FitnessReminder).order_by(FitnessReminder.kind)).all()
|
||||||
|
return [self._reminder_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
def set_reminder(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
hour: int | None = None,
|
||||||
|
minute: int | None = None,
|
||||||
|
interval_hours: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = self.db.scalar(
|
||||||
|
select(FitnessReminder).where(FitnessReminder.kind == kind)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
row = FitnessReminder(kind=kind)
|
||||||
|
self.db.add(row)
|
||||||
|
if enabled is not None:
|
||||||
|
row.enabled = enabled
|
||||||
|
if hour is not None:
|
||||||
|
row.hour = hour
|
||||||
|
if minute is not None:
|
||||||
|
row.minute = minute
|
||||||
|
if interval_hours is not None:
|
||||||
|
row.interval_hours = interval_hours
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "reminder": self._reminder_to_dict(row)}
|
||||||
|
|
||||||
|
def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]:
|
||||||
|
return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)}
|
||||||
|
|
||||||
|
def get_history(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
days: int = 7,
|
||||||
|
end_day: date | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
days = max(1, min(days, 90))
|
||||||
|
end = end_day or datetime.now(timezone.utc).date()
|
||||||
|
start = end - timedelta(days=days - 1)
|
||||||
|
summaries: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for offset in range(days):
|
||||||
|
d = start + timedelta(days=offset)
|
||||||
|
full = self.get_daily_summary(d)
|
||||||
|
totals = full["totals"]
|
||||||
|
has_data = bool(full["meals"] or full["water"] or full["workouts"])
|
||||||
|
summaries.append(
|
||||||
|
{
|
||||||
|
"date": full["date"],
|
||||||
|
"has_data": has_data,
|
||||||
|
"totals": totals,
|
||||||
|
"targets": full["targets"],
|
||||||
|
"meal_count": len(full["meals"]),
|
||||||
|
"workout_count": len(full["workouts"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"start_date": start.isoformat(),
|
||||||
|
"end_date": end.isoformat(),
|
||||||
|
"days": days,
|
||||||
|
"summaries": summaries,
|
||||||
|
}
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
return {
|
||||||
|
"profile": self.get_profile(),
|
||||||
|
"today": self.get_daily_summary(today),
|
||||||
|
"history": self.get_history(days=7, end_day=today),
|
||||||
|
"body_metrics": self.list_body_metrics(limit=10),
|
||||||
|
"reminders": self.list_reminders(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _food_to_dict(row: FoodLog) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"meal_type": row.meal_type,
|
||||||
|
"description": row.description,
|
||||||
|
"calories": row.calories,
|
||||||
|
"protein_g": row.protein_g,
|
||||||
|
"fat_g": row.fat_g,
|
||||||
|
"carbs_g": row.carbs_g,
|
||||||
|
"source": row.source,
|
||||||
|
"estimated": row.estimated,
|
||||||
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _water_to_dict(row: WaterLog) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"amount_ml": row.amount_ml,
|
||||||
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
exercises = json.loads(row.exercises_json or "[]")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
exercises = []
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"title": row.title,
|
||||||
|
"notes": row.notes,
|
||||||
|
"duration_min": row.duration_min,
|
||||||
|
"exercises": exercises,
|
||||||
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"kind": row.kind,
|
||||||
|
"hour": row.hour,
|
||||||
|
"minute": row.minute,
|
||||||
|
"interval_hours": row.interval_hours,
|
||||||
|
"enabled": row.enabled,
|
||||||
|
"last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.llm.client import LLMClient
|
||||||
|
from app.projects.structuring import strip_markdown_json
|
||||||
|
|
||||||
|
MEAL_PROMPT = """
|
||||||
|
Преобразуй описание еды в JSON. Только JSON, без markdown.
|
||||||
|
Схема:
|
||||||
|
{
|
||||||
|
"meal_type": "breakfast|lunch|dinner|snack",
|
||||||
|
"description": "краткое описание",
|
||||||
|
"calories": 0,
|
||||||
|
"protein_g": 0,
|
||||||
|
"fat_g": 0,
|
||||||
|
"carbs_g": 0,
|
||||||
|
"estimated": true
|
||||||
|
}
|
||||||
|
Правила:
|
||||||
|
- Оцени ккал и БЖУ по типичным значениям для России/СНГ.
|
||||||
|
- Все числа — float/int, метрическая система (г, ккал).
|
||||||
|
- meal_type угадай из контекста или snack.
|
||||||
|
- estimated всегда true для LLM-оценки.
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
WORKOUT_PROMPT = """
|
||||||
|
Преобразуй описание тренировки в JSON. Только JSON.
|
||||||
|
Формат:
|
||||||
|
{
|
||||||
|
"title": "название",
|
||||||
|
"activity_type": "ходьба|бег|силовая|велосипед|плавание|йога|hiit|другое",
|
||||||
|
"duration_min": null,
|
||||||
|
"active_calories": null,
|
||||||
|
"met": null,
|
||||||
|
"total_calories": null,
|
||||||
|
"steps": null,
|
||||||
|
"notes": "",
|
||||||
|
"exercises": [
|
||||||
|
{"name": "имя упраж", "sets": 3, "reps": 8, "weight_kg": 80}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Правила:
|
||||||
|
- weight_kg в кг, округляй разумно.
|
||||||
|
- active_calories — только если явно указаны в тексте, иначе null.
|
||||||
|
- duration_min — длительность в минутах, если можно оценить из текста.
|
||||||
|
- met — MET по Compendium of Physical Activities, если ккал не указаны (ходьба ~3.5, бег ~9.8, силовая ~6, велосипед ~7.5, плавание ~8, йога ~3, hiit ~8).
|
||||||
|
- activity_type — тип активности для расчёта MET.
|
||||||
|
- total_calories / steps — если упомянуты в тексте, иначе null.
|
||||||
|
- Если данных нет — null или пустой массив.
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
STEPS_PROMPT = """
|
||||||
|
Преобразуй запись о шагах в JSON. Только JSON.
|
||||||
|
Формат:
|
||||||
|
{
|
||||||
|
"steps": 0,
|
||||||
|
"active_calories": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
Правила:
|
||||||
|
- steps — целое число шагов за день.
|
||||||
|
- active_calories — только если явно указаны.
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def structure_meal(raw_text: str) -> dict[str, Any]:
|
||||||
|
llm = LLMClient()
|
||||||
|
result = await llm.complete(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": MEAL_PROMPT},
|
||||||
|
{"role": "user", "content": raw_text},
|
||||||
|
],
|
||||||
|
temperature=0.2,
|
||||||
|
)
|
||||||
|
raw = strip_markdown_json(result.get("content") or "")
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def structure_workout(raw_text: str) -> dict[str, Any]:
|
||||||
|
llm = LLMClient()
|
||||||
|
result = await llm.complete(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": WORKOUT_PROMPT},
|
||||||
|
{"role": "user", "content": raw_text},
|
||||||
|
],
|
||||||
|
temperature=0.2,
|
||||||
|
)
|
||||||
|
raw = strip_markdown_json(result.get("content") or "")
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def structure_steps(raw_text: str) -> dict[str, Any]:
|
||||||
|
llm = LLMClient()
|
||||||
|
result = await llm.complete(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": STEPS_PROMPT},
|
||||||
|
{"role": "user", "content": raw_text},
|
||||||
|
],
|
||||||
|
temperature=0.2,
|
||||||
|
)
|
||||||
|
raw = strip_markdown_json(result.get("content") or "")
|
||||||
|
return json.loads(raw)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Сборка 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.memory.service import MemoryService
|
||||||
|
|
||||||
|
WEEKDAY_RU = (
|
||||||
|
"понедельник",
|
||||||
|
"вторник",
|
||||||
|
"среда",
|
||||||
|
"четверг",
|
||||||
|
"пятница",
|
||||||
|
"суббота",
|
||||||
|
"воскресенье",
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_TIMEZONE = "Europe/Moscow"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_timezone(db: Session, 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)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.homelab.openmeteo import OpenMeteoClient, format_weather_snapshot
|
||||||
|
from app.homelab.rss import RssClient
|
||||||
|
|
||||||
|
|
||||||
|
def build_morning_digest(db: Session, *, include_news: bool = True) -> str:
|
||||||
|
del db # timezone resolved via weather client / profile in future extensions
|
||||||
|
weather_client = OpenMeteoClient()
|
||||||
|
weather = weather_client.fetch_forecast(hours_ahead=12, days_ahead=7)
|
||||||
|
|
||||||
|
lines = ["🌤 **Утренний дайджест**", ""]
|
||||||
|
|
||||||
|
if weather.get("ok"):
|
||||||
|
cur = weather.get("current") or {}
|
||||||
|
lines.append(
|
||||||
|
f"**Погода ({weather.get('location')})**: "
|
||||||
|
f"{cur.get('temperature_c')}°C, {cur.get('conditions')}, "
|
||||||
|
f"ветер {cur.get('wind_speed_kmh')} км/ч."
|
||||||
|
)
|
||||||
|
lines.append(weather_client.rain_summary(hours_ahead=12, daily=weather.get("daily")))
|
||||||
|
daily = weather_client.daily_summary(days_ahead=7)
|
||||||
|
if daily:
|
||||||
|
lines.append(f"**На неделю**: {daily}")
|
||||||
|
else:
|
||||||
|
lines.append(f"**Погода**: недоступна ({weather.get('error', 'ошибка')}).")
|
||||||
|
|
||||||
|
if include_news:
|
||||||
|
headlines = RssClient().fetch_headlines(limit=7)
|
||||||
|
lines.append("")
|
||||||
|
if headlines:
|
||||||
|
lines.append("**Новости:**")
|
||||||
|
for item in headlines:
|
||||||
|
title = item.get("title", "")
|
||||||
|
link = item.get("link", "")
|
||||||
|
source = item.get("source", "")
|
||||||
|
if link:
|
||||||
|
lines.append(f"- [{title}]({link}) — {source}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- {title} — {source}")
|
||||||
|
else:
|
||||||
|
lines.append("**Новости**: ленты временно недоступны.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_weather_briefing(hours_ahead: int = 12, days_ahead: int = 7, include_news: bool = False) -> dict:
|
||||||
|
client = OpenMeteoClient()
|
||||||
|
weather = client.fetch_forecast(hours_ahead=hours_ahead, days_ahead=days_ahead)
|
||||||
|
result = {
|
||||||
|
"weather": weather,
|
||||||
|
"rain_summary": client.rain_summary(hours_ahead=hours_ahead, daily=weather.get("daily")) if weather.get("ok") else "",
|
||||||
|
"daily_summary": client.daily_summary(days_ahead=days_ahead) if weather.get("ok") else "",
|
||||||
|
"context": format_weather_snapshot(weather),
|
||||||
|
}
|
||||||
|
if include_news:
|
||||||
|
result["news"] = RssClient().fetch_headlines(limit=7)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
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"}
|
||||||
|
|
||||||
|
appearance = (card.get("appearance_tags") or "").strip()
|
||||||
|
lora_name = (card.get("lora_name") or "").strip()
|
||||||
|
lora_weight = float(card.get("lora_weight") or 0.8)
|
||||||
|
persona_id = (card.get("rp_persona_id") or "").strip() or "default"
|
||||||
|
backend = "rp_chat" if settings.rp_chat_enabled else "comfyui_local"
|
||||||
|
messages = _session_messages(db, session_id)
|
||||||
|
|
||||||
|
if draw_self:
|
||||||
|
if not appearance:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Заполни appearance_tags в настройках персонажа для «нарисуй себя»",
|
||||||
|
}
|
||||||
|
request = scene_description.strip() or _last_user_message(messages) or "portrait"
|
||||||
|
bundle, tag_source = await _build_contextual_bundle(
|
||||||
|
appearance,
|
||||||
|
request=request,
|
||||||
|
messages=messages,
|
||||||
|
lora_name=lora_name,
|
||||||
|
lora_weight=lora_weight,
|
||||||
|
)
|
||||||
|
return await _generate_from_bundle(
|
||||||
|
bundle,
|
||||||
|
backend=backend,
|
||||||
|
persona_id=persona_id,
|
||||||
|
prompt_mode="context_tags",
|
||||||
|
tag_source=tag_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
scene = scene_description.strip()
|
||||||
|
if looks_like_booru_tags(scene):
|
||||||
|
bundle = build_scene_tags_prompt(
|
||||||
|
scene,
|
||||||
|
appearance,
|
||||||
|
lora_name=lora_name,
|
||||||
|
lora_weight=lora_weight,
|
||||||
|
)
|
||||||
|
return await _generate_from_bundle(
|
||||||
|
bundle,
|
||||||
|
backend=backend,
|
||||||
|
persona_id=persona_id,
|
||||||
|
prompt_mode="booru_literal",
|
||||||
|
tag_source="booru_literal",
|
||||||
|
)
|
||||||
|
|
||||||
|
request = scene or _last_user_message(messages)
|
||||||
|
if appearance and request:
|
||||||
|
bundle, tag_source = await _build_contextual_bundle(
|
||||||
|
appearance,
|
||||||
|
request=request,
|
||||||
|
messages=messages,
|
||||||
|
lora_name=lora_name,
|
||||||
|
lora_weight=lora_weight,
|
||||||
|
)
|
||||||
|
return await _generate_from_bundle(
|
||||||
|
bundle,
|
||||||
|
backend=backend,
|
||||||
|
persona_id=persona_id,
|
||||||
|
prompt_mode="context_tags",
|
||||||
|
tag_source=tag_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = messages + [{"role": "user", "content": scene}]
|
||||||
|
|
||||||
|
if settings.rp_chat_enabled:
|
||||||
|
return await _generate_via_rp_chat(
|
||||||
|
card,
|
||||||
|
messages,
|
||||||
|
appearance_override=appearance or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
fallback = f"{appearance}, {scene}" if appearance else scene
|
||||||
|
return await ComfyUIClient().generate_image(fallback)
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_via_rp_chat(
|
||||||
|
card: dict[str, Any],
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
appearance_override: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
client = RpChatClient()
|
||||||
|
persona_id = (card.get("rp_persona_id") or "").strip() or "default"
|
||||||
|
override = appearance_override or (card.get("appearance_tags") or "").strip() or None
|
||||||
|
|
||||||
|
prompt_result = await client.sd_prompt(
|
||||||
|
persona_id,
|
||||||
|
messages,
|
||||||
|
appearance_override=override,
|
||||||
|
)
|
||||||
|
if not prompt_result.get("ok"):
|
||||||
|
return prompt_result
|
||||||
|
|
||||||
|
positive = (
|
||||||
|
prompt_result.get("hybrid_positive")
|
||||||
|
or prompt_result.get("tag_positive")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
negative = (prompt_result.get("negative") or "").strip()
|
||||||
|
if not positive:
|
||||||
|
return {"ok": False, "error": "RP-чат не вернул промпт", "raw": prompt_result}
|
||||||
|
|
||||||
|
lora = (card.get("lora_name") or "").strip()
|
||||||
|
if lora:
|
||||||
|
weight = float(card.get("lora_weight") or 0.8)
|
||||||
|
positive = _append_lora(positive, lora, weight)
|
||||||
|
|
||||||
|
gen_result = await client.generate(positive, negative)
|
||||||
|
if not gen_result.get("ok"):
|
||||||
|
return gen_result
|
||||||
|
|
||||||
|
saved = await client.save_image_locally(gen_result["image_path"])
|
||||||
|
if not saved.get("ok"):
|
||||||
|
return saved
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"url": saved["url"],
|
||||||
|
"filename": saved["filename"],
|
||||||
|
"prompt": positive,
|
||||||
|
"negative_prompt": negative,
|
||||||
|
"backend": "rp_chat",
|
||||||
|
"persona_id": persona_id,
|
||||||
|
"prompt_mode": "llm",
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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)}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from app.chat.notice_inbox import post_notice_to_latest_chat
|
||||||
|
|
||||||
|
|
||||||
|
def post_chat_notice(content: str) -> None:
|
||||||
|
post_notice_to_latest_chat(content)
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
WEATHER_CODES: dict[int, str] = {
|
||||||
|
0: "ясно",
|
||||||
|
1: "преимущественно ясно",
|
||||||
|
2: "переменная облачность",
|
||||||
|
3: "пасмурно",
|
||||||
|
45: "туман",
|
||||||
|
48: "изморозь",
|
||||||
|
51: "морось",
|
||||||
|
53: "морось",
|
||||||
|
55: "морось",
|
||||||
|
61: "дождь",
|
||||||
|
63: "дождь",
|
||||||
|
65: "сильный дождь",
|
||||||
|
71: "снег",
|
||||||
|
73: "снег",
|
||||||
|
75: "сильный снег",
|
||||||
|
80: "ливень",
|
||||||
|
81: "ливень",
|
||||||
|
82: "сильный ливень",
|
||||||
|
95: "гроза",
|
||||||
|
96: "гроза с градом",
|
||||||
|
99: "гроза с градом",
|
||||||
|
}
|
||||||
|
|
||||||
|
WEATHER_QUERY_KEYWORDS = (
|
||||||
|
"погод", "дожд", "снег", "ветер", "температур", "градус", "мороз", "жар",
|
||||||
|
"на улице", "одеть", "зонт", "прогноз", "завтра", "послезавтра", "выходн",
|
||||||
|
"weather", "rain", "forecast", "umbrella", "outside",
|
||||||
|
)
|
||||||
|
|
||||||
|
_cache: dict[str, Any] = {
|
||||||
|
"data": None,
|
||||||
|
"fetched_at": 0.0,
|
||||||
|
"expires_at": 0.0,
|
||||||
|
"source": "local",
|
||||||
|
"local_coverage": {"current": [], "hourly": [], "daily": []},
|
||||||
|
"merged_fields": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
CURRENT_FIELDS = (
|
||||||
|
"temperature_2m",
|
||||||
|
"apparent_temperature",
|
||||||
|
"relative_humidity_2m",
|
||||||
|
"precipitation",
|
||||||
|
"weather_code",
|
||||||
|
"wind_speed_10m",
|
||||||
|
)
|
||||||
|
HOURLY_FIELDS = (
|
||||||
|
"temperature_2m",
|
||||||
|
"precipitation_probability",
|
||||||
|
"precipitation",
|
||||||
|
"weather_code",
|
||||||
|
)
|
||||||
|
DAILY_FIELDS = (
|
||||||
|
"weather_code",
|
||||||
|
"temperature_2m_max",
|
||||||
|
"temperature_2m_min",
|
||||||
|
"precipitation_sum",
|
||||||
|
"precipitation_probability_max",
|
||||||
|
"wind_speed_10m_max",
|
||||||
|
)
|
||||||
|
|
||||||
|
RECOMMENDED_SYNC_DOMAINS = "dwd_icon,ncep_gfs013,ncep_gefs025"
|
||||||
|
RECOMMENDED_SYNC_VARIABLES = (
|
||||||
|
"temperature_2m,dew_point_2m,relative_humidity_2m,precipitation_probability,"
|
||||||
|
"precipitation,rain,cloud_cover,weather_code,wind_u_component_10m,wind_v_component_10m"
|
||||||
|
)
|
||||||
|
SYNC_HINT = (
|
||||||
|
"Локальный open-meteo-sync отдаёт неполные данные. "
|
||||||
|
f"SYNC_DOMAINS={RECOMMENDED_SYNC_DOMAINS} "
|
||||||
|
f"SYNC_VARIABLES={RECOMMENDED_SYNC_VARIABLES} (~12 GB). "
|
||||||
|
"Документация: github.com/open-meteo/open-data/tree/main/tutorial_weather_api"
|
||||||
|
)
|
||||||
|
PRECIP_PROB_HINT = (
|
||||||
|
"Для вероятности дождя добавь ncep_gefs025 в SYNC_DOMAINS "
|
||||||
|
"и precipitation_probability в SYNC_VARIABLES."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def weather_query_relevant(query: str) -> bool:
|
||||||
|
q = (query or "").lower()
|
||||||
|
return any(kw in q for kw in WEATHER_QUERY_KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _hourly_series(hourly: dict[str, Any], key: str) -> list[Any]:
|
||||||
|
values = hourly.get(key)
|
||||||
|
return values if isinstance(values, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_series(daily: dict[str, Any], key: str) -> list[Any]:
|
||||||
|
values = daily.get(key)
|
||||||
|
return values if isinstance(values, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _hourly_start_index(times: list[str], anchor_time: str | None) -> int:
|
||||||
|
if not times:
|
||||||
|
return 0
|
||||||
|
if not anchor_time:
|
||||||
|
return 0
|
||||||
|
best = 0
|
||||||
|
for i, t in enumerate(times):
|
||||||
|
if t <= anchor_time:
|
||||||
|
best = i
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _field_coverage(raw: dict[str, Any]) -> dict[str, list[str]]:
|
||||||
|
current = raw.get("current") or {}
|
||||||
|
hourly = raw.get("hourly") or {}
|
||||||
|
daily = raw.get("daily") or {}
|
||||||
|
current_present = [key for key in CURRENT_FIELDS if current.get(key) is not None]
|
||||||
|
hourly_present = []
|
||||||
|
for key in HOURLY_FIELDS:
|
||||||
|
series = _hourly_series(hourly, key)
|
||||||
|
if any(v is not None for v in series):
|
||||||
|
hourly_present.append(key)
|
||||||
|
daily_present = []
|
||||||
|
for key in DAILY_FIELDS:
|
||||||
|
series = _daily_series(daily, key)
|
||||||
|
if any(v is not None for v in series):
|
||||||
|
daily_present.append(key)
|
||||||
|
return {"current": current_present, "hourly": hourly_present, "daily": daily_present}
|
||||||
|
|
||||||
|
|
||||||
|
def _coverage_sufficient(coverage: dict[str, list[str]]) -> bool:
|
||||||
|
current = set(coverage.get("current") or [])
|
||||||
|
hourly = set(coverage.get("hourly") or [])
|
||||||
|
if "weather_code" not in current:
|
||||||
|
return False
|
||||||
|
if len(current) < 3:
|
||||||
|
return False
|
||||||
|
if "weather_code" not in hourly and "temperature_2m" not in hourly:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _local_needs_sync_hint(local_coverage: dict[str, list[str]]) -> bool:
|
||||||
|
current = set(local_coverage.get("current") or [])
|
||||||
|
hourly = set(local_coverage.get("hourly") or [])
|
||||||
|
if "temperature_2m" not in current:
|
||||||
|
return True
|
||||||
|
if "weather_code" not in current:
|
||||||
|
return True
|
||||||
|
if "temperature_2m" not in hourly:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_precip_probability(coverage: dict[str, list[str]]) -> bool:
|
||||||
|
return "precipitation_probability" not in set(coverage.get("hourly") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_num(value: Any, *, suffix: str = "") -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
if isinstance(value, float):
|
||||||
|
text = f"{value:.1f}".rstrip("0").rstrip(".")
|
||||||
|
else:
|
||||||
|
text = str(value)
|
||||||
|
return f"{text}{suffix}" if suffix else text
|
||||||
|
|
||||||
|
|
||||||
|
def _conditions(code: Any) -> str:
|
||||||
|
if code is None:
|
||||||
|
return "неизвестно"
|
||||||
|
return WEATHER_CODES.get(int(code), "неизвестно")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_day_label(date_str: str, index: int) -> str:
|
||||||
|
if index == 0:
|
||||||
|
return "Сегодня"
|
||||||
|
if index == 1:
|
||||||
|
return "Завтра"
|
||||||
|
if not date_str:
|
||||||
|
return f"День {index + 1}"
|
||||||
|
parts = date_str.split("-")
|
||||||
|
if len(parts) == 3:
|
||||||
|
return f"{parts[2]}.{parts[1]}"
|
||||||
|
return date_str
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_hourly_field(target: dict[str, Any], source: dict[str, Any], field: str) -> bool:
|
||||||
|
hourly_t = target.setdefault("hourly", {})
|
||||||
|
hourly_s = source.get("hourly") or {}
|
||||||
|
src = hourly_s.get(field)
|
||||||
|
if not isinstance(src, list) or not any(v is not None for v in src):
|
||||||
|
return False
|
||||||
|
dst = hourly_t.get(field)
|
||||||
|
if isinstance(dst, list) and len(dst) == len(src):
|
||||||
|
hourly_t[field] = [
|
||||||
|
dst[i] if dst[i] is not None else src[i]
|
||||||
|
for i in range(len(src))
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
hourly_t[field] = src
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class OpenMeteoClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.base_url = settings.openmeteo_base_url.rstrip("/")
|
||||||
|
self.fallback_url = (settings.openmeteo_fallback_url or "").strip().rstrip("/")
|
||||||
|
self.fallback_on_partial = settings.openmeteo_fallback_on_partial
|
||||||
|
self.lat = settings.weather_lat
|
||||||
|
self.lon = settings.weather_lon
|
||||||
|
self.location_name = settings.weather_location_name
|
||||||
|
self.cache_ttl = settings.weather_cache_sec
|
||||||
|
self.forecast_days = max(2, int(settings.weather_forecast_days or 7))
|
||||||
|
|
||||||
|
def _request_params(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"latitude": self.lat,
|
||||||
|
"longitude": self.lon,
|
||||||
|
"current": ",".join(CURRENT_FIELDS),
|
||||||
|
"hourly": ",".join(HOURLY_FIELDS),
|
||||||
|
"daily": ",".join(DAILY_FIELDS),
|
||||||
|
"timezone": "auto",
|
||||||
|
"forecast_days": self.forecast_days,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fetch_from_url(self, base_url: str) -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=20.0) as client:
|
||||||
|
response = client.get(f"{base_url.rstrip('/')}/v1/forecast", params=self._request_params())
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def _fetch_raw(self) -> dict[str, Any]:
|
||||||
|
now = time.time()
|
||||||
|
if _cache["data"] and now < _cache["expires_at"]:
|
||||||
|
return _cache["data"]
|
||||||
|
|
||||||
|
local_raw = self._fetch_from_url(self.base_url)
|
||||||
|
local_coverage = _field_coverage(local_raw)
|
||||||
|
source = "local"
|
||||||
|
raw = local_raw
|
||||||
|
merged_fields: list[str] = []
|
||||||
|
|
||||||
|
need_fallback = (
|
||||||
|
self.fallback_on_partial
|
||||||
|
and self.fallback_url
|
||||||
|
and self.fallback_url.rstrip("/") != self.base_url
|
||||||
|
)
|
||||||
|
|
||||||
|
if need_fallback:
|
||||||
|
try:
|
||||||
|
fallback_raw = self._fetch_from_url(self.fallback_url)
|
||||||
|
fallback_coverage = _field_coverage(fallback_raw)
|
||||||
|
|
||||||
|
if not _coverage_sufficient(local_coverage) and _coverage_sufficient(fallback_coverage):
|
||||||
|
raw = fallback_raw
|
||||||
|
source = "fallback"
|
||||||
|
elif _missing_precip_probability(local_coverage) and not _missing_precip_probability(fallback_coverage):
|
||||||
|
if _merge_hourly_field(raw, fallback_raw, "precipitation_probability"):
|
||||||
|
merged_fields.append("precipitation_probability")
|
||||||
|
source = "merged"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_cache["data"] = raw
|
||||||
|
_cache["fetched_at"] = now
|
||||||
|
_cache["expires_at"] = now + self.cache_ttl
|
||||||
|
_cache["source"] = source
|
||||||
|
_cache["local_coverage"] = local_coverage
|
||||||
|
_cache["merged_fields"] = merged_fields
|
||||||
|
return raw
|
||||||
|
|
||||||
|
def cache_status(self) -> dict[str, Any]:
|
||||||
|
now = time.time()
|
||||||
|
fetched_at = float(_cache.get("fetched_at") or 0)
|
||||||
|
expires_at = float(_cache.get("expires_at") or 0)
|
||||||
|
has_data = _cache.get("data") is not None
|
||||||
|
age_sec = int(now - fetched_at) if fetched_at else None
|
||||||
|
expires_in_sec = max(0, int(expires_at - now)) if expires_at else None
|
||||||
|
return {
|
||||||
|
"has_data": has_data,
|
||||||
|
"cached": bool(has_data and expires_at and now < expires_at),
|
||||||
|
"fetched_at": fetched_at or None,
|
||||||
|
"age_sec": age_sec,
|
||||||
|
"ttl_sec": self.cache_ttl,
|
||||||
|
"expires_in_sec": expires_in_sec,
|
||||||
|
"source": _cache.get("source") or "local",
|
||||||
|
"merged_fields": list(_cache.get("merged_fields") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_hourly_slice(self, raw: dict[str, Any], hours_ahead: int) -> list[dict[str, Any]]:
|
||||||
|
current = raw.get("current") or {}
|
||||||
|
hourly = raw.get("hourly") or {}
|
||||||
|
times = hourly.get("time") or []
|
||||||
|
start = _hourly_start_index(times, current.get("time"))
|
||||||
|
end = min(start + hours_ahead, len(times))
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for i in range(start, end):
|
||||||
|
code = _hourly_series(hourly, "weather_code")[i] if i < len(_hourly_series(hourly, "weather_code")) else None
|
||||||
|
temp_series = _hourly_series(hourly, "temperature_2m")
|
||||||
|
precip_series = _hourly_series(hourly, "precipitation")
|
||||||
|
prob_series = _hourly_series(hourly, "precipitation_probability")
|
||||||
|
rows.append({
|
||||||
|
"time": times[i],
|
||||||
|
"temperature_c": temp_series[i] if i < len(temp_series) else None,
|
||||||
|
"precipitation_mm": precip_series[i] if i < len(precip_series) else None,
|
||||||
|
"precipitation_probability": prob_series[i] if i < len(prob_series) else None,
|
||||||
|
"weather_code": code,
|
||||||
|
"conditions": _conditions(code),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _build_daily_slice(self, raw: dict[str, Any], days_ahead: int) -> list[dict[str, Any]]:
|
||||||
|
daily = raw.get("daily") or {}
|
||||||
|
times = daily.get("time") or []
|
||||||
|
limit = min(days_ahead, len(times))
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for i in range(limit):
|
||||||
|
code = _daily_series(daily, "weather_code")[i] if i < len(_daily_series(daily, "weather_code")) else None
|
||||||
|
rows.append({
|
||||||
|
"date": times[i],
|
||||||
|
"label": _format_day_label(times[i], i),
|
||||||
|
"temperature_max_c": _daily_series(daily, "temperature_2m_max")[i] if i < len(_daily_series(daily, "temperature_2m_max")) else None,
|
||||||
|
"temperature_min_c": _daily_series(daily, "temperature_2m_min")[i] if i < len(_daily_series(daily, "temperature_2m_min")) else None,
|
||||||
|
"precipitation_sum_mm": _daily_series(daily, "precipitation_sum")[i] if i < len(_daily_series(daily, "precipitation_sum")) else None,
|
||||||
|
"precipitation_probability_max": _daily_series(daily, "precipitation_probability_max")[i] if i < len(_daily_series(daily, "precipitation_probability_max")) else None,
|
||||||
|
"wind_speed_max_kmh": _daily_series(daily, "wind_speed_10m_max")[i] if i < len(_daily_series(daily, "wind_speed_10m_max")) else None,
|
||||||
|
"weather_code": code,
|
||||||
|
"conditions": _conditions(code),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def fetch_forecast(self, hours_ahead: int = 12, days_ahead: int = 7) -> dict[str, Any]:
|
||||||
|
hours_ahead = max(1, min(int(hours_ahead), 168))
|
||||||
|
days_ahead = max(1, min(int(days_ahead), self.forecast_days))
|
||||||
|
try:
|
||||||
|
raw = self._fetch_raw()
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": str(exc), "location": self.location_name}
|
||||||
|
|
||||||
|
current = raw.get("current") or {}
|
||||||
|
code = current.get("weather_code")
|
||||||
|
coverage = _field_coverage(raw)
|
||||||
|
local_coverage = _cache.get("local_coverage") or coverage
|
||||||
|
|
||||||
|
sync_hint = ""
|
||||||
|
if _local_needs_sync_hint(local_coverage):
|
||||||
|
sync_hint = SYNC_HINT
|
||||||
|
elif _missing_precip_probability(local_coverage):
|
||||||
|
sync_hint = PRECIP_PROB_HINT
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"location": self.location_name,
|
||||||
|
"data_source": _cache.get("source") or "local",
|
||||||
|
"merged_fields": list(_cache.get("merged_fields") or []),
|
||||||
|
"local_field_coverage": local_coverage,
|
||||||
|
"field_coverage": coverage,
|
||||||
|
"sync_hint": sync_hint,
|
||||||
|
"current": {
|
||||||
|
"time": current.get("time"),
|
||||||
|
"temperature_c": current.get("temperature_2m"),
|
||||||
|
"apparent_temperature_c": current.get("apparent_temperature"),
|
||||||
|
"humidity_pct": current.get("relative_humidity_2m"),
|
||||||
|
"precipitation_mm": current.get("precipitation"),
|
||||||
|
"wind_speed_kmh": current.get("wind_speed_10m"),
|
||||||
|
"weather_code": code,
|
||||||
|
"conditions": _conditions(code),
|
||||||
|
},
|
||||||
|
"hourly": self._build_hourly_slice(raw, hours_ahead),
|
||||||
|
"daily": self._build_daily_slice(raw, days_ahead),
|
||||||
|
}
|
||||||
|
|
||||||
|
def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]:
|
||||||
|
return self.fetch_forecast(hours_ahead=hours_ahead, days_ahead=min(7, self.forecast_days))
|
||||||
|
|
||||||
|
def rain_summary(self, hours_ahead: int = 12, daily: list[dict[str, Any]] | None = None) -> str:
|
||||||
|
data = self.fetch_forecast(hours_ahead=hours_ahead, days_ahead=2)
|
||||||
|
if not data.get("ok"):
|
||||||
|
return f"Погода недоступна: {data.get('error', 'ошибка')}"
|
||||||
|
|
||||||
|
rainy_hours = []
|
||||||
|
for hour in data.get("hourly") or []:
|
||||||
|
prob = hour.get("precipitation_probability")
|
||||||
|
precip = hour.get("precipitation_mm") or 0
|
||||||
|
if (prob is not None and prob >= 40) or precip > 0:
|
||||||
|
time_str = (hour.get("time") or "")[11:16]
|
||||||
|
prob_text = f"{prob}%" if prob is not None else "—"
|
||||||
|
rainy_hours.append(f"{time_str} ({prob_text}, {precip} мм)")
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
if rainy_hours:
|
||||||
|
lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6]))
|
||||||
|
else:
|
||||||
|
lines.append("Существенных осадков в ближайшие часы не ожидается.")
|
||||||
|
|
||||||
|
days = daily if daily is not None else data.get("daily") or []
|
||||||
|
if len(days) > 1:
|
||||||
|
tomorrow = days[1]
|
||||||
|
tmax = tomorrow.get("temperature_max_c")
|
||||||
|
tmin = tomorrow.get("temperature_min_c")
|
||||||
|
prob = tomorrow.get("precipitation_probability_max")
|
||||||
|
precip = tomorrow.get("precipitation_sum_mm") or 0
|
||||||
|
cond = tomorrow.get("conditions") or "неизвестно"
|
||||||
|
prob_part = f", дождь до {prob}%" if prob is not None and prob >= 30 else ""
|
||||||
|
precip_part = f", {precip} мм" if precip > 0 else ""
|
||||||
|
lines.append(
|
||||||
|
f"Завтра: {_fmt_num(tmin)}–{_fmt_num(tmax, suffix='°C')}, {cond}{prob_part}{precip_part}."
|
||||||
|
)
|
||||||
|
return " ".join(lines)
|
||||||
|
|
||||||
|
def daily_summary(self, days_ahead: int = 7) -> str:
|
||||||
|
data = self.fetch_forecast(hours_ahead=1, days_ahead=days_ahead)
|
||||||
|
if not data.get("ok"):
|
||||||
|
return ""
|
||||||
|
parts = []
|
||||||
|
for day in data.get("daily") or []:
|
||||||
|
label = day.get("label") or day.get("date")
|
||||||
|
tmax = day.get("temperature_max_c")
|
||||||
|
tmin = day.get("temperature_min_c")
|
||||||
|
cond = day.get("conditions") or "неизвестно"
|
||||||
|
prob = day.get("precipitation_probability_max")
|
||||||
|
prob_part = f", дождь до {prob}%" if prob is not None and prob >= 30 else ""
|
||||||
|
parts.append(f"{label}: {_fmt_num(tmin)}–{_fmt_num(tmax, suffix='°C')}, {cond}{prob_part}")
|
||||||
|
return "; ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def format_weather_snapshot(data: dict[str, Any] | None = None, *, include_daily: bool = True) -> str:
|
||||||
|
client = OpenMeteoClient()
|
||||||
|
snapshot = data if data is not None else client.fetch_forecast(hours_ahead=6, days_ahead=3)
|
||||||
|
|
||||||
|
lines = ["[Погода]"]
|
||||||
|
if not snapshot.get("ok"):
|
||||||
|
lines.append(f"Данные недоступны ({snapshot.get('error', 'ошибка')}).")
|
||||||
|
lines.append("Для точного ответа вызови get_weather.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
cur = snapshot.get("current") or {}
|
||||||
|
apparent = cur.get("apparent_temperature_c")
|
||||||
|
wind = cur.get("wind_speed_kmh")
|
||||||
|
apparent_part = f", ощущается {_fmt_num(apparent, suffix='°C')}" if apparent is not None else ""
|
||||||
|
wind_part = f", ветер {_fmt_num(wind, suffix=' км/ч')}" if wind is not None else ""
|
||||||
|
lines.append(
|
||||||
|
f"{snapshot.get('location')}: {_fmt_num(cur.get('temperature_c'), suffix='°C')}"
|
||||||
|
f"{apparent_part}, {cur.get('conditions') or 'неизвестно'}{wind_part}."
|
||||||
|
)
|
||||||
|
|
||||||
|
rainy_hours = []
|
||||||
|
for hour in snapshot.get("hourly") or []:
|
||||||
|
prob = hour.get("precipitation_probability")
|
||||||
|
precip = hour.get("precipitation_mm") or 0
|
||||||
|
if (prob is not None and prob >= 40) or precip > 0:
|
||||||
|
time_str = (hour.get("time") or "")[11:16]
|
||||||
|
prob_text = f"{prob}%" if prob is not None else "—"
|
||||||
|
rainy_hours.append(f"{time_str} ({prob_text}, {precip} мм)")
|
||||||
|
if rainy_hours:
|
||||||
|
lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6]))
|
||||||
|
else:
|
||||||
|
lines.append("Существенных осадков в ближайшие часы не ожидается.")
|
||||||
|
|
||||||
|
if include_daily:
|
||||||
|
days = snapshot.get("daily") or []
|
||||||
|
if len(days) > 1:
|
||||||
|
tomorrow = days[1]
|
||||||
|
lines.append(
|
||||||
|
f"Завтра: {_fmt_num(tomorrow.get('temperature_min_c'))}–"
|
||||||
|
f"{_fmt_num(tomorrow.get('temperature_max_c'), suffix='°C')}, "
|
||||||
|
f"{tomorrow.get('conditions') or 'неизвестно'}."
|
||||||
|
)
|
||||||
|
if len(days) > 2:
|
||||||
|
week_bits = []
|
||||||
|
for day in days[2:7]:
|
||||||
|
week_bits.append(
|
||||||
|
f"{day.get('label')}: {_fmt_num(day.get('temperature_min_c'))}–"
|
||||||
|
f"{_fmt_num(day.get('temperature_max_c'), suffix='°C')}"
|
||||||
|
)
|
||||||
|
if week_bits:
|
||||||
|
lines.append("Далее: " + "; ".join(week_bits) + ".")
|
||||||
|
|
||||||
|
lines.append("Подробнее — get_weather (hours_ahead, days_ahead).")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_weather_dashboard(hours_ahead: int = 12, days_ahead: int = 7) -> dict[str, Any]:
|
||||||
|
client = OpenMeteoClient()
|
||||||
|
weather = client.fetch_forecast(hours_ahead=hours_ahead, days_ahead=days_ahead)
|
||||||
|
return {
|
||||||
|
"weather": weather,
|
||||||
|
"rain_summary": client.rain_summary(hours_ahead=hours_ahead, daily=weather.get("daily")) if weather.get("ok") else "",
|
||||||
|
"daily_summary": client.daily_summary(days_ahead=days_ahead) if weather.get("ok") else "",
|
||||||
|
"assistant_context": format_weather_snapshot(weather),
|
||||||
|
"cache": client.cache_status(),
|
||||||
|
"config": {
|
||||||
|
"location": client.location_name,
|
||||||
|
"latitude": client.lat,
|
||||||
|
"longitude": client.lon,
|
||||||
|
"openmeteo_base_url": client.base_url,
|
||||||
|
"cache_ttl_sec": client.cache_ttl,
|
||||||
|
"forecast_days": client.forecast_days,
|
||||||
|
"timezone": "auto",
|
||||||
|
},
|
||||||
|
"available_fields": {
|
||||||
|
"current": list(CURRENT_FIELDS),
|
||||||
|
"hourly": list(HOURLY_FIELDS),
|
||||||
|
"daily": list(DAILY_FIELDS),
|
||||||
|
},
|
||||||
|
"field_coverage": weather.get("field_coverage") if weather.get("ok") else {"current": [], "hourly": [], "daily": []},
|
||||||
|
"local_field_coverage": weather.get("local_field_coverage") if weather.get("ok") else {"current": [], "hourly": [], "daily": []},
|
||||||
|
"data_source": weather.get("data_source", "local") if weather.get("ok") else "local",
|
||||||
|
"merged_fields": weather.get("merged_fields", []) if weather.get("ok") else [],
|
||||||
|
"sync_hint": weather.get("sync_hint", "") if weather.get("ok") else SYNC_HINT,
|
||||||
|
"recommended_sync": {
|
||||||
|
"domains": RECOMMENDED_SYNC_DOMAINS,
|
||||||
|
"variables": RECOMMENDED_SYNC_VARIABLES,
|
||||||
|
},
|
||||||
|
"assistant_tools": {
|
||||||
|
"get_weather": "Сейчас + почасово (hours_ahead до 168) + по дням (days_ahead до 16)",
|
||||||
|
"get_morning_briefing": "Погода + заголовки RSS-новостей",
|
||||||
|
},
|
||||||
|
"system_prompt": "Блок [Погода] в system prompt — только если запрос про погоду/одежду/прогноз.",
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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]
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Извлечение 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))
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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\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()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from app.chat.notice_inbox import post_notice_to_latest_chat
|
||||||
|
|
||||||
|
|
||||||
|
def post_chat_notice(content: str, user_id: int) -> None:
|
||||||
|
post_notice_to_latest_chat(content, user_id)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
from app.db.models import User
|
||||||
|
from app.homelab.comfyui import ComfyUIClient
|
||||||
|
from app.homelab.context import resolve_timezone
|
||||||
|
from app.homelab.digest import build_morning_digest
|
||||||
|
from app.homelab.monitoring import check_netdata_alerts
|
||||||
|
from app.homelab_scoped.notices import post_chat_notice
|
||||||
|
from app.homelab.state import get_state, set_state
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WATCH_INTERVAL_SEC = 60
|
||||||
|
_netdata_tick = 0
|
||||||
|
|
||||||
|
|
||||||
|
async def homelab_watcher_loop() -> None:
|
||||||
|
global _netdata_tick
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(WATCH_INTERVAL_SEC)
|
||||||
|
await _tick_morning_digest()
|
||||||
|
await _tick_rofl()
|
||||||
|
settings = get_settings()
|
||||||
|
_netdata_tick += WATCH_INTERVAL_SEC
|
||||||
|
if _netdata_tick >= settings.netdata_poll_interval_sec:
|
||||||
|
_netdata_tick = 0
|
||||||
|
await _tick_netdata()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Homelab watcher error")
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick_morning_digest() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.morning_digest_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||||
|
digest = build_morning_digest(db, include_news=True)
|
||||||
|
for user in users:
|
||||||
|
tz_name = resolve_timezone(db, user.id)
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
except Exception:
|
||||||
|
tz = ZoneInfo("Europe/Moscow")
|
||||||
|
|
||||||
|
now = datetime.now(tz)
|
||||||
|
target_min = settings.morning_digest_hour * 60 + settings.morning_digest_minute
|
||||||
|
current_min = now.hour * 60 + now.minute
|
||||||
|
if current_min < target_min or current_min >= target_min + 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
today = now.date().isoformat()
|
||||||
|
state_key = f"last_morning_digest_date:{user.id}"
|
||||||
|
if get_state(db, state_key) == today:
|
||||||
|
continue
|
||||||
|
|
||||||
|
post_chat_notice(digest, user.id)
|
||||||
|
set_state(db, state_key, today)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick_netdata() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
notices = check_netdata_alerts(db)
|
||||||
|
if not notices:
|
||||||
|
return
|
||||||
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||||
|
for user in users:
|
||||||
|
for notice in notices:
|
||||||
|
post_chat_notice(notice, user.id)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _comfyui_reachable(base_url: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(3.0, connect=2.0)) as client:
|
||||||
|
response = await client.get(f"{base_url.rstrip('/')}/system_stats")
|
||||||
|
return response.status_code < 500
|
||||||
|
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick_rofl() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||||
|
for user in users:
|
||||||
|
tz_name = resolve_timezone(db, user.id)
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
except Exception:
|
||||||
|
tz = ZoneInfo("Europe/Moscow")
|
||||||
|
now = datetime.now(tz)
|
||||||
|
last_raw = get_state(db, f"last_comfy_rofl_at:{user.id}")
|
||||||
|
if last_raw:
|
||||||
|
try:
|
||||||
|
last_at = datetime.fromisoformat(last_raw)
|
||||||
|
if last_at.tzinfo is None:
|
||||||
|
last_at = last_at.replace(tzinfo=tz)
|
||||||
|
if (now - last_at).total_seconds() < settings.comfyui_rofl_min_interval_hours * 3600:
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if random.random() > settings.comfyui_rofl_probability:
|
||||||
|
continue
|
||||||
|
|
||||||
|
today = now.date().isoformat()
|
||||||
|
count_key = f"comfy_rofl_count_{today}:{user.id}"
|
||||||
|
count_raw = get_state(db, count_key) or "0"
|
||||||
|
try:
|
||||||
|
count = int(count_raw)
|
||||||
|
except ValueError:
|
||||||
|
count = 0
|
||||||
|
if count >= settings.comfyui_rofl_max_per_day:
|
||||||
|
continue
|
||||||
|
|
||||||
|
client = ComfyUIClient()
|
||||||
|
if not await _comfyui_reachable(client.base_url):
|
||||||
|
continue
|
||||||
|
|
||||||
|
prompt = client.random_rofl_prompt()
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
client.generate_image(prompt),
|
||||||
|
timeout=settings.comfyui_timeout_sec + 15,
|
||||||
|
)
|
||||||
|
except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc:
|
||||||
|
logger.warning("Rofl image skipped (ComfyUI): %s", exc)
|
||||||
|
continue
|
||||||
|
if not result.get("ok"):
|
||||||
|
logger.warning("Rofl image failed: %s", result.get("error"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = result.get("url", "")
|
||||||
|
post_chat_notice(
|
||||||
|
f"🎨 **Рофл дня**\n\n\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()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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
|
||||||
+374
-112
@@ -1,112 +1,374 @@
|
|||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator
|
import logging
|
||||||
from typing import Any
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any
|
||||||
from openai import AsyncOpenAI
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
from app.config import get_settings
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
class LLMClient:
|
logger = logging.getLogger(__name__)
|
||||||
def __init__(self) -> None:
|
|
||||||
settings = get_settings()
|
|
||||||
self.model = settings.openrouter_model
|
class LLMClient:
|
||||||
self.client = AsyncOpenAI(
|
def __init__(self) -> None:
|
||||||
api_key=settings.openrouter_api_key,
|
settings = get_settings()
|
||||||
base_url=settings.openrouter_base_url,
|
self.tools_enabled = settings.openrouter_tools_enabled
|
||||||
)
|
self.client = AsyncOpenAI(
|
||||||
|
api_key=settings.openrouter_api_key,
|
||||||
async def stream_chat(
|
base_url=settings.openrouter_base_url,
|
||||||
self,
|
)
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
tools: list[dict[str, Any]] | None = None,
|
def _runtime(self) -> tuple[str, str, str]:
|
||||||
) -> AsyncIterator[dict[str, Any]]:
|
from app.db.base import SessionLocal
|
||||||
kwargs: dict[str, Any] = {
|
from app.settings.service import SettingsService
|
||||||
"model": self.model,
|
|
||||||
"messages": messages,
|
settings = get_settings()
|
||||||
"stream": True,
|
db = SessionLocal()
|
||||||
"temperature": 0.7,
|
try:
|
||||||
}
|
svc = SettingsService(db)
|
||||||
if tools:
|
model = str(svc.get_effective("openrouter_model"))
|
||||||
kwargs["tools"] = tools
|
extract = str(svc.get_effective("memory_extract_model"))
|
||||||
|
effort = str(svc.get_effective("openrouter_reasoning_effort")).strip().lower()
|
||||||
stream = await self.client.chat.completions.create(**kwargs)
|
return model, extract, effort
|
||||||
|
finally:
|
||||||
tool_calls: dict[int, dict[str, Any]] = {}
|
db.close()
|
||||||
|
|
||||||
async for chunk in stream:
|
def _vision_model_runtime(self) -> str:
|
||||||
if not chunk.choices:
|
from app.db.base import SessionLocal
|
||||||
continue
|
from app.settings.service import SettingsService
|
||||||
|
|
||||||
choice = chunk.choices[0]
|
db = SessionLocal()
|
||||||
delta = choice.delta
|
try:
|
||||||
|
return str(SettingsService(db).get_effective("openrouter_vision_model"))
|
||||||
if delta.content:
|
finally:
|
||||||
yield {"type": "content", "content": delta.content}
|
db.close()
|
||||||
|
|
||||||
if delta.tool_calls:
|
@property
|
||||||
for tool_call in delta.tool_calls:
|
def model(self) -> str:
|
||||||
idx = tool_call.index
|
return self._runtime()[0]
|
||||||
if idx not in tool_calls:
|
|
||||||
tool_calls[idx] = {
|
@property
|
||||||
"id": tool_call.id or "",
|
def memory_extract_model(self) -> str:
|
||||||
"type": "function",
|
return self._runtime()[1]
|
||||||
"function": {"name": "", "arguments": ""},
|
|
||||||
}
|
@property
|
||||||
if tool_call.id:
|
def reasoning_effort(self) -> str:
|
||||||
tool_calls[idx]["id"] = tool_call.id
|
return self._runtime()[2]
|
||||||
if tool_call.function:
|
|
||||||
if tool_call.function.name:
|
@property
|
||||||
tool_calls[idx]["function"]["name"] = tool_call.function.name
|
def vision_model(self) -> str:
|
||||||
if tool_call.function.arguments:
|
return self._vision_model_runtime()
|
||||||
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
|
|
||||||
|
def _reasoning_extra_body(self) -> dict[str, Any] | None:
|
||||||
if choice.finish_reason:
|
if not self.reasoning_effort:
|
||||||
if tool_calls:
|
return None
|
||||||
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
|
if self.reasoning_effort == "none":
|
||||||
yield {"type": "done", "finish_reason": choice.finish_reason}
|
return {"reasoning": {"effort": "none", "exclude": True}}
|
||||||
|
return {"reasoning": {"effort": self.reasoning_effort}}
|
||||||
async def complete(
|
|
||||||
self,
|
@staticmethod
|
||||||
messages: list[dict[str, Any]],
|
def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]:
|
||||||
tools: list[dict[str, Any]] | None = None,
|
parts: list[str] = []
|
||||||
) -> dict[str, Any]:
|
for attr in ("reasoning", "reasoning_content"):
|
||||||
kwargs: dict[str, Any] = {
|
value = getattr(delta, attr, None)
|
||||||
"model": self.model,
|
if value:
|
||||||
"messages": messages,
|
parts.append(str(value))
|
||||||
"temperature": 0.7,
|
|
||||||
}
|
details: list[Any] = []
|
||||||
if tools:
|
raw_details = getattr(delta, "reasoning_details", None)
|
||||||
kwargs["tools"] = tools
|
if raw_details:
|
||||||
|
if isinstance(raw_details, list):
|
||||||
response = await self.client.chat.completions.create(**kwargs)
|
details.extend(raw_details)
|
||||||
message = response.choices[0].message
|
else:
|
||||||
|
details.append(raw_details)
|
||||||
result: dict[str, Any] = {
|
|
||||||
"content": message.content or "",
|
return "".join(parts), details
|
||||||
"tool_calls": [],
|
|
||||||
}
|
@staticmethod
|
||||||
|
def _normalize_reasoning_details(details: Any) -> list[Any] | None:
|
||||||
if message.tool_calls:
|
if not details:
|
||||||
result["tool_calls"] = [
|
return None
|
||||||
{
|
items = details if isinstance(details, list) else [details]
|
||||||
"id": tc.id,
|
normalized: list[Any] = []
|
||||||
"type": "function",
|
for item in items:
|
||||||
"function": {
|
if hasattr(item, "model_dump"):
|
||||||
"name": tc.function.name,
|
normalized.append(item.model_dump())
|
||||||
"arguments": tc.function.arguments,
|
elif isinstance(item, dict):
|
||||||
},
|
normalized.append(item)
|
||||||
}
|
else:
|
||||||
for tc in message.tool_calls
|
normalized.append(item)
|
||||||
]
|
return normalized or None
|
||||||
|
|
||||||
return result
|
@staticmethod
|
||||||
|
def attach_reasoning_to_message(
|
||||||
@staticmethod
|
message: dict[str, Any],
|
||||||
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
|
*,
|
||||||
if not arguments:
|
reasoning: str = "",
|
||||||
return {}
|
reasoning_details: list[Any] | None = None,
|
||||||
try:
|
) -> dict[str, Any]:
|
||||||
return json.loads(arguments)
|
if reasoning:
|
||||||
except json.JSONDecodeError:
|
message["reasoning"] = reasoning
|
||||||
return {}
|
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
|
||||||
|
|
||||||
|
async def complete_vision(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
temperature: float = 0.1,
|
||||||
|
model: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
use_model = model or self.vision_model
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"model": use_model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"extra_body": {"reasoning": {"effort": "none", "exclude": True}},
|
||||||
|
}
|
||||||
|
response = await self.client.chat.completions.create(**kwargs)
|
||||||
|
usage = getattr(response, "usage", None)
|
||||||
|
usage_dict: dict[str, Any] = {}
|
||||||
|
if usage is not None:
|
||||||
|
usage_dict = {
|
||||||
|
"prompt_tokens": getattr(usage, "prompt_tokens", None),
|
||||||
|
"completion_tokens": getattr(usage, "completion_tokens", None),
|
||||||
|
"total_tokens": getattr(usage, "total_tokens", None),
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"LLM vision usage: prompt=%s completion=%s total=%s model=%s",
|
||||||
|
usage_dict.get("prompt_tokens"),
|
||||||
|
usage_dict.get("completion_tokens"),
|
||||||
|
usage_dict.get("total_tokens"),
|
||||||
|
use_model,
|
||||||
|
)
|
||||||
|
message = response.choices[0].message
|
||||||
|
return {
|
||||||
|
"content": message.content or "",
|
||||||
|
"model": use_model,
|
||||||
|
"usage": usage_dict,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
|
||||||
|
if not arguments:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(arguments)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def serialize_reasoning(
|
||||||
|
*,
|
||||||
|
reasoning: str = "",
|
||||||
|
reasoning_details: list[Any] | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if reasoning:
|
||||||
|
payload["reasoning"] = reasoning
|
||||||
|
payload["reasoning_content"] = reasoning
|
||||||
|
if reasoning_details:
|
||||||
|
payload["reasoning_details"] = reasoning_details
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deserialize_reasoning(raw: str | None) -> dict[str, Any]:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"reasoning": raw}
|
||||||
|
if isinstance(data, str):
|
||||||
|
return {"reasoning": data, "reasoning_content": data}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
settings = get_settings()
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
response = await self.client.embeddings.create(
|
||||||
|
model=settings.embedding_model,
|
||||||
|
input=texts,
|
||||||
|
)
|
||||||
|
return [item.embedding for item in response.data]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.model = settings.openrouter_model
|
||||||
|
self.tools_enabled = settings.openrouter_tools_enabled
|
||||||
|
self.reasoning_effort = settings.openrouter_reasoning_effort.strip().lower()
|
||||||
|
self.client = AsyncOpenAI(
|
||||||
|
api_key=settings.openrouter_api_key,
|
||||||
|
base_url=settings.openrouter_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reasoning_extra_body(self) -> dict[str, Any] | None:
|
||||||
|
if not self.reasoning_effort:
|
||||||
|
return None
|
||||||
|
if self.reasoning_effort == "none":
|
||||||
|
return {"reasoning": {"effort": "none", "exclude": True}}
|
||||||
|
return {"reasoning": {"effort": self.reasoning_effort}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]:
|
||||||
|
parts: list[str] = []
|
||||||
|
for attr in ("reasoning", "reasoning_content"):
|
||||||
|
value = getattr(delta, attr, None)
|
||||||
|
if value:
|
||||||
|
parts.append(str(value))
|
||||||
|
|
||||||
|
details: list[Any] = []
|
||||||
|
raw_details = getattr(delta, "reasoning_details", None)
|
||||||
|
if raw_details:
|
||||||
|
if isinstance(raw_details, list):
|
||||||
|
details.extend(raw_details)
|
||||||
|
else:
|
||||||
|
details.append(raw_details)
|
||||||
|
|
||||||
|
return "".join(parts), details
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_reasoning_details(details: Any) -> list[Any] | None:
|
||||||
|
if not details:
|
||||||
|
return None
|
||||||
|
items = details if isinstance(details, list) else [details]
|
||||||
|
normalized: list[Any] = []
|
||||||
|
for item in items:
|
||||||
|
if hasattr(item, "model_dump"):
|
||||||
|
normalized.append(item.model_dump())
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
normalized.append(item)
|
||||||
|
else:
|
||||||
|
normalized.append(item)
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def attach_reasoning_to_message(
|
||||||
|
message: dict[str, Any],
|
||||||
|
*,
|
||||||
|
reasoning: str = "",
|
||||||
|
reasoning_details: list[Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if reasoning:
|
||||||
|
message["reasoning"] = reasoning
|
||||||
|
message["reasoning_content"] = reasoning
|
||||||
|
normalized = LLMClient._normalize_reasoning_details(reasoning_details)
|
||||||
|
if normalized:
|
||||||
|
message["reasoning_details"] = normalized
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def stream_chat(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
*,
|
||||||
|
model: str | None = None,
|
||||||
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
use_tools = bool(tools) and self.tools_enabled
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"model": model or self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": True,
|
||||||
|
"temperature": 0.7,
|
||||||
|
}
|
||||||
|
if use_tools:
|
||||||
|
kwargs["tools"] = tools
|
||||||
|
extra_body = self._reasoning_extra_body()
|
||||||
|
if extra_body:
|
||||||
|
kwargs["extra_body"] = extra_body
|
||||||
|
|
||||||
|
try:
|
||||||
|
stream = await self.client.chat.completions.create(**kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("LLM stream failed: %s", exc)
|
||||||
|
yield {"type": "error", "content": str(exc)}
|
||||||
|
yield {"type": "done", "finish_reason": "error"}
|
||||||
|
return
|
||||||
|
|
||||||
|
tool_calls: dict[int, dict[str, Any]] = {}
|
||||||
|
reasoning_parts: list[str] = []
|
||||||
|
reasoning_details: list[Any] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for chunk in stream:
|
||||||
|
if not chunk.choices:
|
||||||
|
continue
|
||||||
|
|
||||||
|
choice = chunk.choices[0]
|
||||||
|
delta = choice.delta
|
||||||
|
|
||||||
|
if delta.content:
|
||||||
|
yield {"type": "content", "content": delta.content}
|
||||||
|
|
||||||
|
reasoning_text, details = self._delta_reasoning(delta)
|
||||||
|
if reasoning_text:
|
||||||
|
reasoning_parts.append(reasoning_text)
|
||||||
|
if details:
|
||||||
|
reasoning_details.extend(details)
|
||||||
|
|
||||||
|
if delta.tool_calls:
|
||||||
|
for tool_call in delta.tool_calls:
|
||||||
|
idx = tool_call.index
|
||||||
|
if idx not in tool_calls:
|
||||||
|
tool_calls[idx] = {
|
||||||
|
"id": tool_call.id or "",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "", "arguments": ""},
|
||||||
|
}
|
||||||
|
if tool_call.id:
|
||||||
|
tool_calls[idx]["id"] = tool_call.id
|
||||||
|
if tool_call.function:
|
||||||
|
if tool_call.function.name:
|
||||||
|
tool_calls[idx]["function"]["name"] = tool_call.function.name
|
||||||
|
if tool_call.function.arguments:
|
||||||
|
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
|
||||||
|
|
||||||
|
if choice.finish_reason:
|
||||||
|
reasoning = "".join(reasoning_parts)
|
||||||
|
normalized_details = self._normalize_reasoning_details(reasoning_details)
|
||||||
|
if reasoning or normalized_details:
|
||||||
|
yield {
|
||||||
|
"type": "reasoning",
|
||||||
|
"reasoning": reasoning,
|
||||||
|
"reasoning_details": normalized_details,
|
||||||
|
}
|
||||||
|
if tool_calls:
|
||||||
|
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
|
||||||
|
logger.info(
|
||||||
|
"LLM stream done: model=%s finish_reason=%s tool_calls=%d "
|
||||||
|
"content_in_stream=%d reasoning_len=%d",
|
||||||
|
model or self.model,
|
||||||
|
choice.finish_reason,
|
||||||
|
len(tool_calls),
|
||||||
|
len(reasoning_parts),
|
||||||
|
len(reasoning),
|
||||||
|
)
|
||||||
|
yield {"type": "done", "finish_reason": choice.finish_reason}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("LLM stream read failed: %s", exc)
|
||||||
|
yield {"type": "error", "content": str(exc)}
|
||||||
|
yield {"type": "done", "finish_reason": "error"}
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
*,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
model: str | None = None,
|
||||||
|
for_extraction: bool = False,
|
||||||
|
visible_reply: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
use_tools = bool(tools) and self.tools_enabled and not for_extraction
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"model": model or self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
}
|
||||||
|
if use_tools:
|
||||||
|
kwargs["tools"] = tools
|
||||||
|
if for_extraction:
|
||||||
|
kwargs["extra_body"] = {"reasoning": {"effort": "none"}}
|
||||||
|
else:
|
||||||
|
extra_body = self._reasoning_extra_body()
|
||||||
|
if extra_body:
|
||||||
|
kwargs["extra_body"] = extra_body
|
||||||
|
|
||||||
|
response = await self.client.chat.completions.create(**kwargs)
|
||||||
|
message = response.choices[0].message
|
||||||
|
|
||||||
|
content = message.content or ""
|
||||||
|
reasoning = ""
|
||||||
|
for attr in ("reasoning", "reasoning_content"):
|
||||||
|
value = getattr(message, attr, None)
|
||||||
|
if value:
|
||||||
|
reasoning = str(value)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not content and reasoning and not visible_reply:
|
||||||
|
content = reasoning
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"content": content,
|
||||||
|
"tool_calls": [],
|
||||||
|
"reasoning": reasoning,
|
||||||
|
"reasoning_details": getattr(message, "reasoning_details", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.tool_calls:
|
||||||
|
result["tool_calls"] = [
|
||||||
|
{
|
||||||
|
"id": tc.id,
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tc.function.name,
|
||||||
|
"arguments": tc.function.arguments,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for tc in message.tool_calls
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
|
||||||
|
if not arguments:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(arguments)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def serialize_reasoning(
|
||||||
|
*,
|
||||||
|
reasoning: str = "",
|
||||||
|
reasoning_details: list[Any] | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if reasoning:
|
||||||
|
payload["reasoning"] = reasoning
|
||||||
|
payload["reasoning_content"] = reasoning
|
||||||
|
if reasoning_details:
|
||||||
|
payload["reasoning_details"] = reasoning_details
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deserialize_reasoning(raw: str | None) -> dict[str, Any]:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"reasoning": raw}
|
||||||
|
if isinstance(data, str):
|
||||||
|
return {"reasoning": data, "reasoning_content": data}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
return {}
|
||||||
+65
-39
@@ -1,39 +1,65 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import api_router
|
from app.api.routes import api_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db.base import init_db
|
from app.db.base import init_db
|
||||||
from app.pomodoro.watcher import pomodoro_watcher_loop
|
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
|
||||||
@asynccontextmanager
|
from app.reminders_scoped.watcher import reminders_watcher_loop
|
||||||
async def lifespan(_: FastAPI):
|
|
||||||
init_db()
|
|
||||||
watcher_task = asyncio.create_task(pomodoro_watcher_loop())
|
@asynccontextmanager
|
||||||
yield
|
async def lifespan(_: FastAPI):
|
||||||
watcher_task.cancel()
|
init_db()
|
||||||
with suppress(asyncio.CancelledError):
|
from app.db.migrate_fitness import run_fitness_migrations
|
||||||
await watcher_task
|
|
||||||
|
run_fitness_migrations()
|
||||||
|
from app.db.migrate_multi_user import run_multi_user_migrations
|
||||||
def create_app() -> FastAPI:
|
|
||||||
settings = get_settings()
|
run_multi_user_migrations()
|
||||||
app = FastAPI(title="Home AI Assistant", lifespan=lifespan)
|
settings = get_settings()
|
||||||
|
if settings.rag_enabled:
|
||||||
app.add_middleware(
|
from app.rag.store import ensure_collections
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=settings.cors_origins_list,
|
ensure_collections()
|
||||||
allow_credentials=True,
|
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
|
||||||
allow_methods=["*"],
|
fitness_task = asyncio.create_task(fitness_watcher_loop())
|
||||||
allow_headers=["*"],
|
homelab_task = asyncio.create_task(homelab_watcher_loop())
|
||||||
)
|
reminders_task = asyncio.create_task(reminders_watcher_loop())
|
||||||
|
yield
|
||||||
app.include_router(api_router)
|
pomodoro_task.cancel()
|
||||||
return app
|
fitness_task.cancel()
|
||||||
|
homelab_task.cancel()
|
||||||
|
reminders_task.cancel()
|
||||||
app = create_app()
|
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()
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager, suppress
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.routes import api_router
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.base import init_db
|
||||||
|
from app.fitness.watcher import fitness_watcher_loop
|
||||||
|
from app.homelab.watcher import homelab_watcher_loop
|
||||||
|
from app.pomodoro.watcher import pomodoro_watcher_loop
|
||||||
|
from app.reminders.watcher import reminders_watcher_loop
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
init_db()
|
||||||
|
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
|
||||||
|
fitness_task = asyncio.create_task(fitness_watcher_loop())
|
||||||
|
homelab_task = asyncio.create_task(homelab_watcher_loop())
|
||||||
|
reminders_task = asyncio.create_task(reminders_watcher_loop())
|
||||||
|
yield
|
||||||
|
pomodoro_task.cancel()
|
||||||
|
fitness_task.cancel()
|
||||||
|
homelab_task.cancel()
|
||||||
|
reminders_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await pomodoro_task
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await fitness_task
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await homelab_task
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await reminders_task
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
settings = get_settings()
|
||||||
|
app = FastAPI(title="Home AI Assistant", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(api_router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.memory.service import MemoryService
|
||||||
|
|
||||||
|
from app.memory.parse import is_identity_question
|
||||||
|
|
||||||
|
MAX_FACTS_IN_CONTEXT = 25
|
||||||
|
PROFILE_KEYS = ("name", "age", "timezone", "language", "notes")
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_snapshot(db: Session, session_id: int | None = None) -> dict[str, Any]:
|
||||||
|
return MemoryService(db).snapshot(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def format_memory_context(snapshot: dict[str, Any]) -> str:
|
||||||
|
lines = ["[Память и профиль — долгосрочный контекст]"]
|
||||||
|
|
||||||
|
profile = snapshot.get("profile") or {}
|
||||||
|
profile_lines = []
|
||||||
|
for key in PROFILE_KEYS:
|
||||||
|
value = (profile.get(key) or "").strip()
|
||||||
|
if value:
|
||||||
|
profile_lines.append(f"- {key}: {value}")
|
||||||
|
if profile_lines:
|
||||||
|
lines.append("Профиль пользователя:")
|
||||||
|
lines.extend(profile_lines)
|
||||||
|
else:
|
||||||
|
lines.append("Профиль: не заполнен (можно уточнить имя, часовой пояс).")
|
||||||
|
|
||||||
|
summary = (snapshot.get("session_summary") or "").strip()
|
||||||
|
if summary:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Сводка текущего чата (ранние сообщения):")
|
||||||
|
lines.append(summary)
|
||||||
|
|
||||||
|
facts = snapshot.get("facts") or []
|
||||||
|
if facts:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):")
|
||||||
|
for fact in facts[:MAX_FACTS_IN_CONTEXT]:
|
||||||
|
lines.append(
|
||||||
|
f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Запомненные факты: пока нет.")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Правила памяти: "
|
||||||
|
"«запомни» → remember_fact (имя/возраст также пишутся в профиль). "
|
||||||
|
"«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. "
|
||||||
|
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
|
||||||
|
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. "
|
||||||
|
"«забудь #N» → forget_memory. "
|
||||||
|
"Длинный чат — update_session_summary."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_identity_hint(snapshot: dict[str, Any], user_text: str) -> str:
|
||||||
|
if not is_identity_question(user_text):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
profile = snapshot.get("profile") or {}
|
||||||
|
facts = snapshot.get("facts") or []
|
||||||
|
lines = [
|
||||||
|
"[Вопрос об идентичности пользователя]",
|
||||||
|
"Ответь ТОЛЬКО из данных ниже. Не придумывай роли из сценария персонажа.",
|
||||||
|
]
|
||||||
|
name = (profile.get("name") or "").strip()
|
||||||
|
age = (profile.get("age") or "").strip()
|
||||||
|
if name:
|
||||||
|
lines.append(f"Имя: {name}")
|
||||||
|
if age:
|
||||||
|
lines.append(f"Возраст: {age} лет")
|
||||||
|
for fact in facts:
|
||||||
|
lines.append(f"Факт: {fact.get('content')}")
|
||||||
|
if not name and not age and not facts:
|
||||||
|
lines.append("Данных нет — скажи, что не помнишь.")
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.llm.client import LLMClient
|
||||||
|
from app.memory.service import MemoryService
|
||||||
|
from app.projects.structuring import strip_markdown_json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SKIP_USER_PATTERN = re.compile(
|
||||||
|
r"^(ок|ok|да|нет|спасибо|thanks|\.{1,3}|👍|\+1)$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
EXTRACTION_PROMPT = """
|
||||||
|
Ты извлекаешь долгосрочные факты о пользователе из фрагмента диалога.
|
||||||
|
Ответь ТОЛЬКО JSON без markdown.
|
||||||
|
|
||||||
|
Схема:
|
||||||
|
{
|
||||||
|
"facts": [
|
||||||
|
{"content": "текст факта", "category": "preference|person|habit|project|fact", "importance": 1}
|
||||||
|
],
|
||||||
|
"profile": {"name": "", "age": "", "timezone": "", "notes": ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- Сохраняй устойчивое: имя, возраст, предпочтения, привычки, проекты, семья, работа.
|
||||||
|
- НЕ сохраняй: статус помидоро, погоду, разовые команды, ролевую игру, выдумки ассистента.
|
||||||
|
- profile — только поля с новыми значениями (пустые строки не включай).
|
||||||
|
- facts — короткие утверждения от первого лица пользователя («люблю кофе», «меня зовут …»).
|
||||||
|
- Если нечего сохранять — {"facts": [], "profile": {}}.
|
||||||
|
- Не дублируй уже известное (см. текущий профиль и факты ниже).
|
||||||
|
- importance: 5 критично (имя), 4 важно, 3 обычно, 2 мелочь.
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _should_skip_extraction(user_text: str) -> bool:
|
||||||
|
text = user_text.strip()
|
||||||
|
if len(text) < 4:
|
||||||
|
return True
|
||||||
|
if SKIP_USER_PATTERN.match(text):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_extractor(
|
||||||
|
user_text: str,
|
||||||
|
assistant_text: str,
|
||||||
|
snapshot: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
profile = snapshot.get("profile") or {}
|
||||||
|
facts = snapshot.get("facts") or []
|
||||||
|
known = [
|
||||||
|
f"Профиль: {json.dumps(profile, ensure_ascii=False)}",
|
||||||
|
"Факты:",
|
||||||
|
*[f"- {f.get('content')}" for f in facts[:30]],
|
||||||
|
]
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
extract_model = settings.memory_extract_model.strip() or None
|
||||||
|
|
||||||
|
llm = LLMClient()
|
||||||
|
result = await llm.complete(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": EXTRACTION_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"\n".join(known)
|
||||||
|
+ "\n\n---\nДиалог:\nПользователь: "
|
||||||
|
+ user_text
|
||||||
|
+ "\nАссистент: "
|
||||||
|
+ assistant_text[:1500]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature=0.2,
|
||||||
|
model=extract_model,
|
||||||
|
for_extraction=True,
|
||||||
|
)
|
||||||
|
raw = strip_markdown_json(result.get("content") or "")
|
||||||
|
if not raw:
|
||||||
|
return {"facts": [], "profile": {}}
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return {"facts": [], "profile": {}}
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_after_turn(
|
||||||
|
db: Session,
|
||||||
|
session_id: int,
|
||||||
|
user_text: str,
|
||||||
|
assistant_text: str,
|
||||||
|
*,
|
||||||
|
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)}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import MemoryFact, SessionSummary, UserProfile
|
||||||
|
from app.memory.parse import normalize_text, parse_identity, texts_are_similar
|
||||||
|
|
||||||
|
DEFAULT_PROFILE: dict[str, Any] = {
|
||||||
|
"name": "",
|
||||||
|
"age": "",
|
||||||
|
"timezone": "",
|
||||||
|
"language": "ru",
|
||||||
|
"notes": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_profile(self) -> dict[str, Any]:
|
||||||
|
row = self.db.scalar(select(UserProfile).limit(1))
|
||||||
|
if not row:
|
||||||
|
return dict(DEFAULT_PROFILE)
|
||||||
|
try:
|
||||||
|
data = json.loads(row.data_json or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = {}
|
||||||
|
merged = dict(DEFAULT_PROFILE)
|
||||||
|
merged.update(data)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = self.db.scalar(select(UserProfile).limit(1))
|
||||||
|
if not row:
|
||||||
|
row = UserProfile(data_json="{}")
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
current = self.get_profile()
|
||||||
|
for key, value in updates.items():
|
||||||
|
if value is None:
|
||||||
|
current.pop(key, None)
|
||||||
|
else:
|
||||||
|
current[key] = value
|
||||||
|
|
||||||
|
row.data_json = json.dumps(current, ensure_ascii=False)
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "profile": current}
|
||||||
|
|
||||||
|
def _find_similar_fact(self, text: str) -> MemoryFact | None:
|
||||||
|
for fact in self.db.scalars(
|
||||||
|
select(MemoryFact).where(MemoryFact.active.is_(True))
|
||||||
|
):
|
||||||
|
if texts_are_similar(fact.content, text):
|
||||||
|
return fact
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _sync_identity_to_profile(self, text: str) -> dict[str, Any] | None:
|
||||||
|
parsed = parse_identity(text)
|
||||||
|
if not parsed:
|
||||||
|
return None
|
||||||
|
return self.update_profile(parsed)
|
||||||
|
|
||||||
|
def remember_fact(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
category: str = "fact",
|
||||||
|
source: str = "user",
|
||||||
|
session_id: int | None = None,
|
||||||
|
importance: int = 3,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
text = content.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Пустой факт")
|
||||||
|
|
||||||
|
profile_sync = self._sync_identity_to_profile(text)
|
||||||
|
|
||||||
|
existing = self._find_similar_fact(text)
|
||||||
|
if existing:
|
||||||
|
if len(text) > len(existing.content):
|
||||||
|
existing.content = text[:2000]
|
||||||
|
existing.category = category or existing.category
|
||||||
|
existing.importance = max(existing.importance, min(5, max(1, importance)))
|
||||||
|
existing.updated_at = datetime.now(timezone.utc)
|
||||||
|
if session_id:
|
||||||
|
existing.session_id = session_id
|
||||||
|
self.db.commit()
|
||||||
|
result = {
|
||||||
|
"ok": True,
|
||||||
|
"action": "updated",
|
||||||
|
"memory_id": existing.id,
|
||||||
|
"content": existing.content,
|
||||||
|
"category": existing.category,
|
||||||
|
}
|
||||||
|
if profile_sync:
|
||||||
|
result["profile"] = profile_sync.get("profile")
|
||||||
|
return result
|
||||||
|
|
||||||
|
fact = MemoryFact(
|
||||||
|
category=(category or "fact")[:64],
|
||||||
|
content=text[:2000],
|
||||||
|
source=source[:32],
|
||||||
|
session_id=session_id,
|
||||||
|
importance=min(5, max(1, importance)),
|
||||||
|
)
|
||||||
|
self.db.add(fact)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(fact)
|
||||||
|
result = {
|
||||||
|
"ok": True,
|
||||||
|
"action": "created",
|
||||||
|
"memory_id": fact.id,
|
||||||
|
"content": fact.content,
|
||||||
|
"category": fact.category,
|
||||||
|
}
|
||||||
|
if profile_sync:
|
||||||
|
result["profile"] = profile_sync.get("profile")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def recall_memories(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
query: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
active_only: bool = True,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(MemoryFact).order_by(
|
||||||
|
MemoryFact.importance.desc(),
|
||||||
|
MemoryFact.updated_at.desc(),
|
||||||
|
)
|
||||||
|
if active_only:
|
||||||
|
stmt = stmt.where(MemoryFact.active.is_(True))
|
||||||
|
if category:
|
||||||
|
stmt = stmt.where(MemoryFact.category == category)
|
||||||
|
facts = self.db.scalars(stmt.limit(100)).all()
|
||||||
|
if query:
|
||||||
|
qnorm = normalize_text(query)
|
||||||
|
facts = [
|
||||||
|
f
|
||||||
|
for f in facts
|
||||||
|
if qnorm in normalize_text(f.content)
|
||||||
|
or qnorm in normalize_text(f.category)
|
||||||
|
]
|
||||||
|
facts = facts[: min(limit, 50)]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"category": f.category,
|
||||||
|
"content": f.content,
|
||||||
|
"importance": f.importance,
|
||||||
|
"source": f.source,
|
||||||
|
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||||
|
}
|
||||||
|
for f in facts
|
||||||
|
]
|
||||||
|
|
||||||
|
def forget_memory(self, memory_id: int) -> dict[str, Any]:
|
||||||
|
fact = self.db.get(MemoryFact, memory_id)
|
||||||
|
if not fact:
|
||||||
|
raise ValueError(f"Память #{memory_id} не найдена")
|
||||||
|
fact.active = False
|
||||||
|
fact.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "memory_id": memory_id, "forgotten": fact.content}
|
||||||
|
|
||||||
|
def get_active_facts(self, limit: int = 25) -> list[MemoryFact]:
|
||||||
|
return list(
|
||||||
|
self.db.scalars(
|
||||||
|
select(MemoryFact)
|
||||||
|
.where(MemoryFact.active.is_(True))
|
||||||
|
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_session_summary(self, session_id: int) -> SessionSummary | None:
|
||||||
|
return self.db.scalar(
|
||||||
|
select(SessionSummary).where(SessionSummary.session_id == session_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_session_summary(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
summary: str,
|
||||||
|
*,
|
||||||
|
message_count: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
text = summary.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Пустая сводка")
|
||||||
|
|
||||||
|
row = self.get_session_summary(session_id)
|
||||||
|
if not row:
|
||||||
|
row = SessionSummary(session_id=session_id)
|
||||||
|
self.db.add(row)
|
||||||
|
|
||||||
|
row.summary = text[:4000]
|
||||||
|
row.message_count = message_count
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "session_id": session_id, "summary": row.summary}
|
||||||
|
|
||||||
|
def snapshot(self, session_id: int | None = None) -> dict[str, Any]:
|
||||||
|
facts = self.get_active_facts()
|
||||||
|
summary_row = self.get_session_summary(session_id) if session_id else None
|
||||||
|
return {
|
||||||
|
"profile": self.get_profile(),
|
||||||
|
"facts": [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"category": f.category,
|
||||||
|
"content": f.content,
|
||||||
|
"importance": f.importance,
|
||||||
|
"source": f.source,
|
||||||
|
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||||
|
}
|
||||||
|
for f in facts
|
||||||
|
],
|
||||||
|
"session_summary": summary_row.summary if summary_row else "",
|
||||||
|
"total_facts": len(facts),
|
||||||
|
}
|
||||||
@@ -1,101 +1,92 @@
|
|||||||
from sqlalchemy import select
|
import logging
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
from app.character.service import CharacterService
|
|
||||||
from app.chat.notices import format_phase_completed_notice
|
from app.character.service import CharacterService
|
||||||
from app.db.models import ChatSession, Message, PomodoroSession
|
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
|
||||||
from app.llm.client import LLMClient
|
from app.chat.notices import format_phase_completed_notice
|
||||||
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
|
from app.db.models import PomodoroSession
|
||||||
from app.pomodoro.service import PomodoroService
|
from app.llm.client import LLMClient
|
||||||
|
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
|
||||||
PHASE_LABELS = {
|
from app.pomodoro.service import PomodoroService
|
||||||
PHASE_WORK: "работа",
|
|
||||||
PHASE_SHORT_BREAK: "короткий перерыв",
|
logger = logging.getLogger(__name__)
|
||||||
PHASE_LONG_BREAK: "длинный перерыв",
|
|
||||||
}
|
PHASE_LABELS = {
|
||||||
|
PHASE_WORK: "работа",
|
||||||
|
PHASE_SHORT_BREAK: "короткий перерыв",
|
||||||
class PomodoroCompletionHandler:
|
PHASE_LONG_BREAK: "длинный перерыв",
|
||||||
def __init__(self, db: Session):
|
}
|
||||||
self.db = db
|
|
||||||
self.pomodoro = PomodoroService(db)
|
|
||||||
self.cycle = CycleManager(db)
|
class PomodoroCompletionHandler:
|
||||||
self.llm = LLMClient()
|
def __init__(self, db: Session, user_id: int):
|
||||||
self.character = CharacterService()
|
self.db = db
|
||||||
|
self.user_id = user_id
|
||||||
def _latest_chat_session_id(self) -> int | None:
|
self.pomodoro = PomodoroService(db, user_id)
|
||||||
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
|
self.cycle = CycleManager(db, user_id)
|
||||||
session = self.db.scalar(stmt)
|
self.llm = LLMClient()
|
||||||
return session.id if session else None
|
self.character = CharacterService(db, user_id)
|
||||||
|
|
||||||
def _save_chat_message(self, session_id: int, role: str, content: str) -> None:
|
async def _generate_llm_comment(
|
||||||
self.db.add(Message(session_id=session_id, role=role, content=content))
|
self,
|
||||||
chat = self.db.get(ChatSession, session_id)
|
session: PomodoroSession,
|
||||||
if chat:
|
next_phase: str | None,
|
||||||
chat.updated_at = chat.updated_at # trigger onupdate
|
) -> str:
|
||||||
self.db.commit()
|
cycle = self.cycle.to_dict()
|
||||||
|
phase_label = PHASE_LABELS.get(session.phase, session.phase)
|
||||||
async def _generate_llm_comment(
|
next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен"
|
||||||
self,
|
work_done = cycle["completed_work_sessions"]
|
||||||
session: PomodoroSession,
|
if session.phase == PHASE_WORK:
|
||||||
next_phase: str | None,
|
work_done += 1
|
||||||
) -> str:
|
|
||||||
cycle = self.cycle.to_dict()
|
system = self.character.get_system_prompt()
|
||||||
phase_label = PHASE_LABELS.get(session.phase, session.phase)
|
user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась.
|
||||||
next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен"
|
Задача: {session.task_note or 'без описания'}
|
||||||
work_done = cycle["completed_work_sessions"]
|
Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ.
|
||||||
if session.phase == PHASE_WORK:
|
Следующая фаза: {next_label}.
|
||||||
work_done += 1
|
|
||||||
|
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown и без эмодзи."""
|
||||||
system = self.character.get_system_prompt()
|
|
||||||
user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась.
|
result = await self.llm.complete(
|
||||||
Задача: {session.task_note or 'без описания'}
|
[
|
||||||
Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ.
|
{"role": "system", "content": system},
|
||||||
Следующая фаза: {next_label}.
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown."""
|
temperature=0.8,
|
||||||
|
visible_reply=True,
|
||||||
result = await self.llm.complete(
|
)
|
||||||
[
|
return (result.get("content") or "").strip() or "Фаза завершена. Хорошая работа."
|
||||||
{"role": "system", "content": system},
|
|
||||||
{"role": "user", "content": user_prompt},
|
def _resolve_next_phase(self, session: PomodoroSession) -> str | None:
|
||||||
]
|
phase = session.phase
|
||||||
)
|
cycle = self.cycle.get()
|
||||||
return (result.get("content") or "").strip() or "Фаза завершена! Отличная работа."
|
if phase == PHASE_WORK:
|
||||||
|
if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break:
|
||||||
def _resolve_next_phase(self, session: PomodoroSession) -> str | None:
|
return PHASE_LONG_BREAK
|
||||||
phase = session.phase
|
return PHASE_SHORT_BREAK
|
||||||
cycle = self.cycle.get()
|
if phase == PHASE_SHORT_BREAK:
|
||||||
if phase == PHASE_WORK:
|
return PHASE_WORK
|
||||||
if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break:
|
if phase == PHASE_LONG_BREAK:
|
||||||
return PHASE_LONG_BREAK
|
return None
|
||||||
return PHASE_SHORT_BREAK
|
return None
|
||||||
if phase == PHASE_SHORT_BREAK:
|
|
||||||
return PHASE_WORK
|
async def process(self, session: PomodoroSession) -> None:
|
||||||
if phase == PHASE_LONG_BREAK:
|
if session.completion_notified:
|
||||||
return None
|
return
|
||||||
return None
|
|
||||||
|
next_phase = self._resolve_next_phase(session)
|
||||||
async def process(self, session: PomodoroSession) -> None:
|
notice = format_phase_completed_notice(session, next_phase)
|
||||||
if session.completion_notified:
|
post_notice_to_latest_chat(notice, self.user_id)
|
||||||
return
|
|
||||||
|
try:
|
||||||
next_phase = self._resolve_next_phase(session)
|
comment = await self._generate_llm_comment(session, next_phase)
|
||||||
notice = format_phase_completed_notice(session, next_phase)
|
if comment:
|
||||||
|
post_character_comment_to_latest_chat(comment, self.user_id)
|
||||||
chat_id = self._latest_chat_session_id()
|
except Exception:
|
||||||
if not chat_id:
|
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase)
|
||||||
chat = ChatSession(title="Помидоро")
|
|
||||||
self.db.add(chat)
|
self.cycle.bump_notify_seq()
|
||||||
self.db.commit()
|
self.pomodoro.mark_notified(session)
|
||||||
self.db.refresh(chat)
|
self.pomodoro.advance_after_completion(session)
|
||||||
chat_id = chat.id
|
logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -1,89 +1,90 @@
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.models import PomodoroCycle
|
from app.db.models import PomodoroCycle
|
||||||
|
|
||||||
PHASE_WORK = "work"
|
PHASE_WORK = "work"
|
||||||
PHASE_SHORT_BREAK = "short_break"
|
PHASE_SHORT_BREAK = "short_break"
|
||||||
PHASE_LONG_BREAK = "long_break"
|
PHASE_LONG_BREAK = "long_break"
|
||||||
|
|
||||||
|
|
||||||
class CycleManager:
|
class CycleManager:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session, user_id: int):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
self.user_id = user_id
|
||||||
def get(self) -> PomodoroCycle:
|
|
||||||
cycle = self.db.scalar(select(PomodoroCycle).limit(1))
|
def get(self) -> PomodoroCycle:
|
||||||
if not cycle:
|
cycle = self.db.scalar(select(PomodoroCycle).where(PomodoroCycle.user_id == self.user_id).limit(1))
|
||||||
cycle = PomodoroCycle()
|
if not cycle:
|
||||||
self.db.add(cycle)
|
cycle = PomodoroCycle(user_id=self.user_id)
|
||||||
self.db.commit()
|
self.db.add(cycle)
|
||||||
self.db.refresh(cycle)
|
self.db.commit()
|
||||||
return cycle
|
self.db.refresh(cycle)
|
||||||
|
return cycle
|
||||||
def to_dict(self, cycle: PomodoroCycle | None = None) -> dict:
|
|
||||||
c = cycle or self.get()
|
def to_dict(self, cycle: PomodoroCycle | None = None) -> dict:
|
||||||
return {
|
c = cycle or self.get()
|
||||||
"completed_work_sessions": c.completed_work_sessions,
|
return {
|
||||||
"sessions_until_long_break": c.sessions_until_long_break,
|
"completed_work_sessions": c.completed_work_sessions,
|
||||||
"task_note": c.task_note,
|
"sessions_until_long_break": c.sessions_until_long_break,
|
||||||
"work_duration_min": c.work_duration_min,
|
"task_note": c.task_note,
|
||||||
"short_break_min": c.short_break_min,
|
"work_duration_min": c.work_duration_min,
|
||||||
"long_break_min": c.long_break_min,
|
"short_break_min": c.short_break_min,
|
||||||
"auto_advance": c.auto_advance,
|
"long_break_min": c.long_break_min,
|
||||||
"chat_notify_seq": c.chat_notify_seq,
|
"auto_advance": c.auto_advance,
|
||||||
}
|
"chat_notify_seq": c.chat_notify_seq,
|
||||||
|
}
|
||||||
def reset(self, clear_task: bool = False) -> dict:
|
|
||||||
cycle = self.get()
|
def reset(self, clear_task: bool = False) -> dict:
|
||||||
cycle.completed_work_sessions = 0
|
cycle = self.get()
|
||||||
if clear_task:
|
cycle.completed_work_sessions = 0
|
||||||
cycle.task_note = ""
|
if clear_task:
|
||||||
self.db.commit()
|
cycle.task_note = ""
|
||||||
self.db.refresh(cycle)
|
self.db.commit()
|
||||||
return self.to_dict(cycle)
|
self.db.refresh(cycle)
|
||||||
|
return self.to_dict(cycle)
|
||||||
def bump_notify_seq(self) -> int:
|
|
||||||
cycle = self.get()
|
def bump_notify_seq(self) -> int:
|
||||||
cycle.chat_notify_seq += 1
|
cycle = self.get()
|
||||||
self.db.commit()
|
cycle.chat_notify_seq += 1
|
||||||
self.db.refresh(cycle)
|
self.db.commit()
|
||||||
return cycle.chat_notify_seq
|
self.db.refresh(cycle)
|
||||||
|
return cycle.chat_notify_seq
|
||||||
def on_work_completed(self) -> str:
|
|
||||||
"""Returns next phase: short_break or long_break."""
|
def on_work_completed(self) -> str:
|
||||||
cycle = self.get()
|
"""Returns next phase: short_break or long_break."""
|
||||||
cycle.completed_work_sessions += 1
|
cycle = self.get()
|
||||||
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
|
cycle.completed_work_sessions += 1
|
||||||
next_phase = PHASE_LONG_BREAK
|
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
|
||||||
else:
|
next_phase = PHASE_LONG_BREAK
|
||||||
next_phase = PHASE_SHORT_BREAK
|
else:
|
||||||
self.db.commit()
|
next_phase = PHASE_SHORT_BREAK
|
||||||
return next_phase
|
self.db.commit()
|
||||||
|
return next_phase
|
||||||
def on_long_break_completed(self) -> None:
|
|
||||||
cycle = self.get()
|
def on_long_break_completed(self) -> None:
|
||||||
cycle.completed_work_sessions = 0
|
cycle = self.get()
|
||||||
self.db.commit()
|
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()
|
def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int:
|
||||||
if phase == PHASE_WORK:
|
c = cycle or self.get()
|
||||||
return c.work_duration_min
|
if phase == PHASE_WORK:
|
||||||
if phase == PHASE_SHORT_BREAK:
|
return c.work_duration_min
|
||||||
return c.short_break_min
|
if phase == PHASE_SHORT_BREAK:
|
||||||
if phase == PHASE_LONG_BREAK:
|
return c.short_break_min
|
||||||
return c.long_break_min
|
if phase == PHASE_LONG_BREAK:
|
||||||
return c.work_duration_min
|
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:
|
def next_phase_after(self, completed_phase: str) -> str | None:
|
||||||
cycle = self.get()
|
if completed_phase == PHASE_WORK:
|
||||||
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
|
cycle = self.get()
|
||||||
return PHASE_LONG_BREAK
|
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
|
||||||
return PHASE_SHORT_BREAK
|
return PHASE_LONG_BREAK
|
||||||
if completed_phase == PHASE_SHORT_BREAK:
|
return PHASE_SHORT_BREAK
|
||||||
return PHASE_WORK
|
if completed_phase == PHASE_SHORT_BREAK:
|
||||||
if completed_phase == PHASE_LONG_BREAK:
|
return PHASE_WORK
|
||||||
return None
|
if completed_phase == PHASE_LONG_BREAK:
|
||||||
return None
|
return None
|
||||||
|
return None
|
||||||
|
|||||||
+296
-287
@@ -1,287 +1,296 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.models import PomodoroSession
|
from app.db.models import PomodoroSession
|
||||||
from app.pomodoro.cycle import (
|
from app.pomodoro.cycle import (
|
||||||
PHASE_LONG_BREAK,
|
PHASE_LONG_BREAK,
|
||||||
PHASE_SHORT_BREAK,
|
PHASE_SHORT_BREAK,
|
||||||
PHASE_WORK,
|
PHASE_WORK,
|
||||||
CycleManager,
|
CycleManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> datetime:
|
def _utcnow() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class PomodoroService:
|
class PomodoroService:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session, user_id: int):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.cycle = CycleManager(db)
|
self.user_id = user_id
|
||||||
|
self.cycle = CycleManager(db, user_id)
|
||||||
def _get_active(self) -> PomodoroSession | None:
|
|
||||||
stmt = (
|
def _get_active(self) -> PomodoroSession | None:
|
||||||
select(PomodoroSession)
|
stmt = (
|
||||||
.where(PomodoroSession.status.in_(("running", "paused")))
|
select(PomodoroSession)
|
||||||
.order_by(PomodoroSession.id.desc())
|
.where(
|
||||||
.limit(1)
|
PomodoroSession.user_id == self.user_id,
|
||||||
)
|
PomodoroSession.status.in_(("running", "paused")),
|
||||||
return self.db.scalar(stmt)
|
)
|
||||||
|
.order_by(PomodoroSession.id.desc())
|
||||||
def _elapsed(self, session: PomodoroSession) -> int:
|
.limit(1)
|
||||||
elapsed = session.elapsed_seconds
|
)
|
||||||
if session.status == "running" and session.started_at:
|
return self.db.scalar(stmt)
|
||||||
started = session.started_at
|
|
||||||
if started.tzinfo is None:
|
def _elapsed(self, session: PomodoroSession) -> int:
|
||||||
started = started.replace(tzinfo=timezone.utc)
|
elapsed = session.elapsed_seconds
|
||||||
delta = _utcnow() - started
|
if session.status == "running" and session.started_at:
|
||||||
elapsed += int(delta.total_seconds())
|
started = session.started_at
|
||||||
return elapsed
|
if started.tzinfo is None:
|
||||||
|
started = started.replace(tzinfo=timezone.utc)
|
||||||
def _remaining(self, session: PomodoroSession) -> int:
|
delta = _utcnow() - started
|
||||||
total = session.duration_min * 60
|
elapsed += int(delta.total_seconds())
|
||||||
return max(0, total - self._elapsed(session))
|
return elapsed
|
||||||
|
|
||||||
def _try_auto_complete(self, session: PomodoroSession) -> bool:
|
def _remaining(self, session: PomodoroSession) -> int:
|
||||||
if session.status != "running":
|
total = session.duration_min * 60
|
||||||
return False
|
return max(0, total - self._elapsed(session))
|
||||||
if self._remaining(session) > 0:
|
|
||||||
return False
|
def _try_auto_complete(self, session: PomodoroSession) -> bool:
|
||||||
self._finalize_session(session, auto=True)
|
if session.status != "running":
|
||||||
return True
|
return False
|
||||||
|
if self._remaining(session) > 0:
|
||||||
def _finalize_session(
|
return False
|
||||||
self,
|
self._finalize_session(session, auto=True)
|
||||||
session: PomodoroSession,
|
return True
|
||||||
*,
|
|
||||||
auto: bool,
|
def _finalize_session(
|
||||||
result: str = "",
|
self,
|
||||||
completed: bool | None = None,
|
session: PomodoroSession,
|
||||||
cancelled: bool = False,
|
*,
|
||||||
) -> None:
|
auto: bool,
|
||||||
session.elapsed_seconds = self._elapsed(session)
|
result: str = "",
|
||||||
session.started_at = None
|
completed: bool | None = None,
|
||||||
session.finished_at = _utcnow()
|
cancelled: bool = False,
|
||||||
session.completion_notified = False
|
) -> None:
|
||||||
session.result = result or None
|
session.elapsed_seconds = self._elapsed(session)
|
||||||
|
session.started_at = None
|
||||||
if cancelled:
|
session.finished_at = _utcnow()
|
||||||
session.status = "cancelled"
|
session.completion_notified = False
|
||||||
session.completed = False
|
session.result = result or None
|
||||||
elif completed is not None:
|
|
||||||
session.status = "completed"
|
if cancelled:
|
||||||
session.completed = completed
|
session.status = "cancelled"
|
||||||
else:
|
session.completed = False
|
||||||
session.status = "completed"
|
elif completed is not None:
|
||||||
session.completed = True
|
session.status = "completed"
|
||||||
|
session.completed = completed
|
||||||
self.db.commit()
|
else:
|
||||||
self.db.refresh(session)
|
session.status = "completed"
|
||||||
|
session.completed = True
|
||||||
def _start_phase(
|
|
||||||
self,
|
self.db.commit()
|
||||||
phase: str,
|
self.db.refresh(session)
|
||||||
*,
|
|
||||||
duration_min: int | None = None,
|
def _start_phase(
|
||||||
task_note: str | None = None,
|
self,
|
||||||
) -> PomodoroSession:
|
phase: str,
|
||||||
active = self._get_active()
|
*,
|
||||||
if active:
|
duration_min: int | None = None,
|
||||||
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
|
task_note: str | None = None,
|
||||||
|
) -> PomodoroSession:
|
||||||
cycle = self.cycle.get()
|
active = self._get_active()
|
||||||
if task_note is not None:
|
if active:
|
||||||
cycle.task_note = task_note
|
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
|
||||||
elif phase == PHASE_WORK and not cycle.task_note:
|
|
||||||
cycle.task_note = ""
|
cycle = self.cycle.get()
|
||||||
|
if task_note is not None:
|
||||||
duration = duration_min or self.cycle.duration_for_phase(phase, cycle)
|
cycle.task_note = task_note
|
||||||
note = task_note if task_note is not None else cycle.task_note
|
elif phase == PHASE_WORK and not cycle.task_note:
|
||||||
|
cycle.task_note = ""
|
||||||
session = PomodoroSession(
|
|
||||||
status="running",
|
duration = duration_min or self.cycle.duration_for_phase(phase, cycle)
|
||||||
phase=phase,
|
note = task_note if task_note is not None else cycle.task_note
|
||||||
duration_min=duration,
|
|
||||||
task_note=note,
|
session = PomodoroSession(
|
||||||
started_at=_utcnow(),
|
user_id=self.user_id,
|
||||||
)
|
status="running",
|
||||||
self.db.add(session)
|
phase=phase,
|
||||||
self.db.commit()
|
duration_min=duration,
|
||||||
self.db.refresh(session)
|
task_note=note,
|
||||||
return session
|
started_at=_utcnow(),
|
||||||
|
)
|
||||||
def _to_status_dict(self, session: PomodoroSession | None) -> dict:
|
self.db.add(session)
|
||||||
cycle_dict = self.cycle.to_dict()
|
self.db.commit()
|
||||||
if not session:
|
self.db.refresh(session)
|
||||||
return {
|
return session
|
||||||
"status": "idle",
|
|
||||||
"phase": PHASE_WORK,
|
def _to_status_dict(self, session: PomodoroSession | None) -> dict:
|
||||||
"duration_min": cycle_dict["work_duration_min"],
|
cycle_dict = self.cycle.to_dict()
|
||||||
"task_note": cycle_dict["task_note"],
|
if not session:
|
||||||
"elapsed_seconds": 0,
|
return {
|
||||||
"remaining_seconds": 0,
|
"status": "idle",
|
||||||
"session_id": None,
|
"phase": PHASE_WORK,
|
||||||
"cycle": cycle_dict,
|
"duration_min": cycle_dict["work_duration_min"],
|
||||||
}
|
"task_note": cycle_dict["task_note"],
|
||||||
|
"elapsed_seconds": 0,
|
||||||
elapsed = self._elapsed(session)
|
"remaining_seconds": 0,
|
||||||
total = session.duration_min * 60
|
"session_id": None,
|
||||||
remaining = max(0, total - elapsed)
|
"cycle": cycle_dict,
|
||||||
|
}
|
||||||
return {
|
|
||||||
"status": session.status,
|
elapsed = self._elapsed(session)
|
||||||
"phase": session.phase,
|
total = session.duration_min * 60
|
||||||
"duration_min": session.duration_min,
|
remaining = max(0, total - elapsed)
|
||||||
"task_note": session.task_note,
|
|
||||||
"elapsed_seconds": elapsed,
|
return {
|
||||||
"remaining_seconds": remaining,
|
"status": session.status,
|
||||||
"session_id": session.id,
|
"phase": session.phase,
|
||||||
"started_at": session.started_at.isoformat() if session.started_at else None,
|
"duration_min": session.duration_min,
|
||||||
"finished_at": session.finished_at.isoformat() if session.finished_at else None,
|
"task_note": session.task_note,
|
||||||
"cycle": cycle_dict,
|
"elapsed_seconds": elapsed,
|
||||||
}
|
"remaining_seconds": remaining,
|
||||||
|
"session_id": session.id,
|
||||||
def get_status(self) -> dict:
|
"started_at": session.started_at.isoformat() if session.started_at else None,
|
||||||
active = self._get_active()
|
"finished_at": session.finished_at.isoformat() if session.finished_at else None,
|
||||||
if active:
|
"cycle": cycle_dict,
|
||||||
self._try_auto_complete(active)
|
}
|
||||||
active = self._get_active()
|
|
||||||
return self._to_status_dict(active)
|
def get_status(self) -> dict:
|
||||||
|
active = self._get_active()
|
||||||
def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict:
|
if active:
|
||||||
session = self._start_phase(
|
self._try_auto_complete(active)
|
||||||
PHASE_WORK,
|
active = self._get_active()
|
||||||
duration_min=duration_min,
|
return self._to_status_dict(active)
|
||||||
task_note=task_note,
|
|
||||||
)
|
def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict:
|
||||||
return self._to_status_dict(session)
|
session = self._start_phase(
|
||||||
|
PHASE_WORK,
|
||||||
def start_short_break(self, duration_min: int | None = None) -> dict:
|
duration_min=duration_min,
|
||||||
session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min)
|
task_note=task_note,
|
||||||
return self._to_status_dict(session)
|
)
|
||||||
|
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)
|
def start_short_break(self, duration_min: int | None = None) -> dict:
|
||||||
return self._to_status_dict(session)
|
session = self._start_phase(PHASE_SHORT_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 start_long_break(self, duration_min: int | None = None) -> dict:
|
||||||
|
session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min)
|
||||||
def pause(self) -> dict:
|
return self._to_status_dict(session)
|
||||||
session = self._get_active()
|
|
||||||
if not session or session.status != "running":
|
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
|
||||||
raise ValueError("Нет активного запущенного таймера.")
|
return self.start_work(duration_min=duration_min, task_note=task_note)
|
||||||
|
|
||||||
session.elapsed_seconds = self._elapsed(session)
|
def pause(self) -> dict:
|
||||||
session.status = "paused"
|
session = self._get_active()
|
||||||
session.paused_at = _utcnow()
|
if not session or session.status != "running":
|
||||||
session.started_at = None
|
raise ValueError("Нет активного запущенного таймера.")
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(session)
|
session.elapsed_seconds = self._elapsed(session)
|
||||||
return self._to_status_dict(session)
|
session.status = "paused"
|
||||||
|
session.paused_at = _utcnow()
|
||||||
def resume(self) -> dict:
|
session.started_at = None
|
||||||
session = self._get_active()
|
self.db.commit()
|
||||||
if not session or session.status != "paused":
|
self.db.refresh(session)
|
||||||
raise ValueError("Нет таймера на паузе.")
|
return self._to_status_dict(session)
|
||||||
|
|
||||||
session.status = "running"
|
def resume(self) -> dict:
|
||||||
session.started_at = _utcnow()
|
session = self._get_active()
|
||||||
session.paused_at = None
|
if not session or session.status != "paused":
|
||||||
self.db.commit()
|
raise ValueError("Нет таймера на паузе.")
|
||||||
self.db.refresh(session)
|
|
||||||
return self._to_status_dict(session)
|
session.status = "running"
|
||||||
|
session.started_at = _utcnow()
|
||||||
def stop(self, result: str = "", completed: bool = False) -> dict:
|
session.paused_at = None
|
||||||
session = self._get_active()
|
self.db.commit()
|
||||||
if not session:
|
self.db.refresh(session)
|
||||||
raise ValueError("Нет активного таймера.")
|
return self._to_status_dict(session)
|
||||||
|
|
||||||
if completed:
|
def stop(self, result: str = "", completed: bool = False) -> dict:
|
||||||
self._finalize_session(session, auto=False, result=result, completed=True)
|
session = self._get_active()
|
||||||
else:
|
if not session:
|
||||||
self._finalize_session(session, auto=False, result=result, cancelled=True)
|
raise ValueError("Нет активного таймера.")
|
||||||
session.completion_notified = True
|
|
||||||
self.db.commit()
|
if completed:
|
||||||
return self._to_status_dict(None)
|
self._finalize_session(session, auto=False, result=result, completed=True)
|
||||||
|
else:
|
||||||
def reset_cycle(self, clear_task: bool = False) -> dict:
|
self._finalize_session(session, auto=False, result=result, cancelled=True)
|
||||||
active = self._get_active()
|
session.completion_notified = True
|
||||||
if active:
|
self.db.commit()
|
||||||
self._finalize_session(active, auto=False, cancelled=True)
|
return self._to_status_dict(None)
|
||||||
active.completion_notified = True
|
|
||||||
self.db.commit()
|
def reset_cycle(self, clear_task: bool = False) -> dict:
|
||||||
cycle = self.cycle.reset(clear_task=clear_task)
|
active = self._get_active()
|
||||||
status = self._to_status_dict(None)
|
if active:
|
||||||
status["cycle"] = cycle
|
self._finalize_session(active, auto=False, cancelled=True)
|
||||||
return status
|
active.completion_notified = True
|
||||||
|
self.db.commit()
|
||||||
def skip_phase(self) -> dict:
|
cycle = self.cycle.reset(clear_task=clear_task)
|
||||||
session = self._get_active()
|
status = self._to_status_dict(None)
|
||||||
if not session:
|
status["cycle"] = cycle
|
||||||
raise ValueError("Нет активного таймера.")
|
return status
|
||||||
|
|
||||||
self._finalize_session(session, auto=True)
|
def skip_phase(self) -> dict:
|
||||||
return self._to_status_dict(None)
|
session = self._get_active()
|
||||||
|
if not session:
|
||||||
def get_pending_completions(self) -> list[PomodoroSession]:
|
raise ValueError("Нет активного таймера.")
|
||||||
stmt = (
|
|
||||||
select(PomodoroSession)
|
self._finalize_session(session, auto=True)
|
||||||
.where(
|
return self._to_status_dict(None)
|
||||||
PomodoroSession.status == "completed",
|
|
||||||
PomodoroSession.completed.is_(True),
|
def get_pending_completions(self) -> list[PomodoroSession]:
|
||||||
PomodoroSession.completion_notified.is_(False),
|
stmt = (
|
||||||
)
|
select(PomodoroSession)
|
||||||
.order_by(PomodoroSession.id.asc())
|
.where(
|
||||||
)
|
PomodoroSession.user_id == self.user_id,
|
||||||
return list(self.db.scalars(stmt))
|
PomodoroSession.status == "completed",
|
||||||
|
PomodoroSession.completed.is_(True),
|
||||||
def mark_notified(self, session: PomodoroSession) -> None:
|
PomodoroSession.completion_notified.is_(False),
|
||||||
session.completion_notified = True
|
)
|
||||||
self.db.commit()
|
.order_by(PomodoroSession.id.asc())
|
||||||
|
)
|
||||||
def advance_after_completion(self, session: PomodoroSession) -> dict | None:
|
return list(self.db.scalars(stmt))
|
||||||
"""Update cycle counters and auto-start next phase. Returns new status or None."""
|
|
||||||
phase = session.phase
|
def mark_notified(self, session: PomodoroSession) -> None:
|
||||||
cycle = self.cycle.get()
|
session.completion_notified = True
|
||||||
|
self.db.commit()
|
||||||
if phase == PHASE_WORK:
|
|
||||||
next_phase = self.cycle.on_work_completed()
|
def advance_after_completion(self, session: PomodoroSession) -> dict | None:
|
||||||
elif phase == PHASE_SHORT_BREAK:
|
"""Update cycle counters and auto-start next phase. Returns new status or None."""
|
||||||
next_phase = PHASE_WORK
|
phase = session.phase
|
||||||
elif phase == PHASE_LONG_BREAK:
|
cycle = self.cycle.get()
|
||||||
self.cycle.on_long_break_completed()
|
|
||||||
next_phase = None
|
if phase == PHASE_WORK:
|
||||||
else:
|
next_phase = self.cycle.on_work_completed()
|
||||||
next_phase = None
|
elif phase == PHASE_SHORT_BREAK:
|
||||||
|
next_phase = PHASE_WORK
|
||||||
if not cycle.auto_advance or next_phase is None:
|
elif phase == PHASE_LONG_BREAK:
|
||||||
return None
|
self.cycle.on_long_break_completed()
|
||||||
|
next_phase = None
|
||||||
new_session = self._start_phase(next_phase)
|
else:
|
||||||
return self._to_status_dict(new_session)
|
next_phase = None
|
||||||
|
|
||||||
def history(self, limit: int = 20) -> list[dict]:
|
if not cycle.auto_advance or next_phase is None:
|
||||||
stmt = (
|
return None
|
||||||
select(PomodoroSession)
|
|
||||||
.where(PomodoroSession.status.in_(("completed", "cancelled")))
|
new_session = self._start_phase(next_phase)
|
||||||
.order_by(PomodoroSession.finished_at.desc())
|
return self._to_status_dict(new_session)
|
||||||
.limit(limit)
|
|
||||||
)
|
def history(self, limit: int = 20) -> list[dict]:
|
||||||
sessions = self.db.scalars(stmt).all()
|
stmt = (
|
||||||
return [
|
select(PomodoroSession)
|
||||||
{
|
.where(
|
||||||
"id": s.id,
|
PomodoroSession.user_id == self.user_id,
|
||||||
"status": s.status,
|
PomodoroSession.status.in_(("completed", "cancelled")),
|
||||||
"phase": s.phase,
|
)
|
||||||
"duration_min": s.duration_min,
|
.order_by(PomodoroSession.finished_at.desc())
|
||||||
"task_note": s.task_note,
|
.limit(limit)
|
||||||
"result": s.result,
|
)
|
||||||
"completed": s.completed,
|
sessions = self.db.scalars(stmt).all()
|
||||||
"elapsed_seconds": s.elapsed_seconds,
|
return [
|
||||||
"finished_at": s.finished_at.isoformat() if s.finished_at else None,
|
{
|
||||||
}
|
"id": s.id,
|
||||||
for s in sessions
|
"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
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,38 +1,41 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.db.base import SessionLocal
|
from sqlalchemy import select
|
||||||
from app.pomodoro.completion import PomodoroCompletionHandler
|
|
||||||
from app.pomodoro.service import PomodoroService
|
from app.db.base import SessionLocal
|
||||||
|
from app.db.models import User
|
||||||
logger = logging.getLogger(__name__)
|
from app.pomodoro.completion import PomodoroCompletionHandler
|
||||||
|
from app.pomodoro.service import PomodoroService
|
||||||
WATCH_INTERVAL_SEC = 2
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def pomodoro_watcher_loop() -> None:
|
WATCH_INTERVAL_SEC = 2
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(WATCH_INTERVAL_SEC)
|
async def pomodoro_watcher_loop() -> None:
|
||||||
await _tick()
|
while True:
|
||||||
except asyncio.CancelledError:
|
try:
|
||||||
raise
|
await asyncio.sleep(WATCH_INTERVAL_SEC)
|
||||||
except Exception:
|
await _tick()
|
||||||
logger.exception("Pomodoro watcher error")
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
async def _tick() -> None:
|
logger.exception("Pomodoro watcher error")
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
service = PomodoroService(db)
|
async def _tick() -> None:
|
||||||
service.get_status()
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
pending = service.get_pending_completions()
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||||
if not pending:
|
for user in users:
|
||||||
return
|
service = PomodoroService(db, user.id)
|
||||||
|
service.get_status()
|
||||||
handler = PomodoroCompletionHandler(db)
|
pending = service.get_pending_completions()
|
||||||
for session in pending:
|
if not pending:
|
||||||
await handler.process(session)
|
continue
|
||||||
finally:
|
handler = PomodoroCompletionHandler(db, user.id)
|
||||||
db.close()
|
for session in pending:
|
||||||
|
await handler.process(session)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
+155
-133
@@ -1,133 +1,155 @@
|
|||||||
from typing import Any
|
import time
|
||||||
|
from typing import Any
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
from app.config import get_settings
|
|
||||||
from app.integrations.taiga import TaigaClient
|
from app.config import get_settings
|
||||||
from app.projects.service import ProjectService
|
from app.integrations.taiga import TaigaClient
|
||||||
|
from app.projects.service import ProjectService
|
||||||
MAX_PROJECTS_IN_CONTEXT = 20
|
|
||||||
MAX_OPEN_PER_PROJECT = 8
|
MAX_PROJECTS_IN_CONTEXT = 20
|
||||||
|
MAX_OPEN_PER_PROJECT = 8
|
||||||
|
PROJECTS_CACHE_SEC = 120
|
||||||
def get_projects_snapshot(db: Session) -> dict[str, Any]:
|
|
||||||
settings = get_settings()
|
_cache: dict[int, dict[str, Any]] = {}
|
||||||
service = ProjectService(db)
|
|
||||||
|
|
||||||
if not settings.taiga_configured:
|
def invalidate_projects_snapshot_cache(user_id: int | None = None) -> None:
|
||||||
return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
|
if user_id is None:
|
||||||
|
_cache.clear()
|
||||||
projects = service.list_projects()
|
else:
|
||||||
if not projects:
|
_cache.pop(user_id, None)
|
||||||
try:
|
|
||||||
projects = service.sync_taiga_projects()
|
|
||||||
except Exception as exc:
|
def get_projects_snapshot(db: Session, user_id: int, *, force: bool = False) -> dict[str, Any]:
|
||||||
return {
|
now = time.time()
|
||||||
"configured": True,
|
entry = _cache.get(user_id)
|
||||||
"projects": [],
|
if not force and entry and now < entry.get("expires_at", 0):
|
||||||
"open_items": [],
|
return entry["data"]
|
||||||
"taiga_open": [],
|
|
||||||
"error": str(exc),
|
snapshot = _fetch_projects_snapshot(db, user_id)
|
||||||
}
|
_cache[user_id] = {"data": snapshot, "expires_at": now + PROJECTS_CACHE_SEC}
|
||||||
|
return snapshot
|
||||||
open_items = service.list_work_items(limit=15, status="open")
|
|
||||||
taiga_open: list[dict[str, Any]] = []
|
|
||||||
fetch_error: str | None = None
|
def _fetch_projects_snapshot(db: Session, user_id: int) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
try:
|
service = ProjectService(db, user_id)
|
||||||
client = TaigaClient()
|
|
||||||
for proj in projects[:MAX_PROJECTS_IN_CONTEXT]:
|
if not settings.taiga_configured:
|
||||||
stories = client.list_open_userstories(
|
return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
|
||||||
proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT
|
|
||||||
)
|
projects = service.list_projects()
|
||||||
tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT)
|
if not projects:
|
||||||
taiga_open.append(
|
try:
|
||||||
{
|
projects = service.sync_taiga_projects()
|
||||||
"slug": proj["slug"],
|
except Exception as exc:
|
||||||
"name": proj["name"],
|
return {
|
||||||
"stories": [
|
"configured": True,
|
||||||
{
|
"projects": [],
|
||||||
"ref": s.get("ref"),
|
"open_items": [],
|
||||||
"subject": s.get("subject", "")[:120],
|
"taiga_open": [],
|
||||||
}
|
"error": str(exc),
|
||||||
for s in stories
|
}
|
||||||
],
|
|
||||||
"tasks": [
|
open_items = service.list_work_items(limit=15, status="open")
|
||||||
{
|
taiga_open: list[dict[str, Any]] = []
|
||||||
"ref": t.get("ref"),
|
fetch_error: str | None = None
|
||||||
"subject": t.get("subject", "")[:120],
|
|
||||||
}
|
try:
|
||||||
for t in tasks
|
client = TaigaClient()
|
||||||
],
|
for proj in projects[:MAX_PROJECTS_IN_CONTEXT]:
|
||||||
}
|
stories = client.list_open_userstories(
|
||||||
)
|
proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT
|
||||||
except Exception as exc:
|
)
|
||||||
fetch_error = str(exc)
|
tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT)
|
||||||
|
taiga_open.append(
|
||||||
return {
|
{
|
||||||
"configured": True,
|
"slug": proj["slug"],
|
||||||
"projects": projects,
|
"name": proj["name"],
|
||||||
"open_items": open_items,
|
"stories": [
|
||||||
"taiga_open": taiga_open,
|
{
|
||||||
"error": fetch_error,
|
"ref": s.get("ref"),
|
||||||
}
|
"subject": s.get("subject", "")[:120],
|
||||||
|
}
|
||||||
|
for s in stories
|
||||||
def format_projects_context(snapshot: dict[str, Any]) -> str:
|
],
|
||||||
if not snapshot.get("configured"):
|
"tasks": [
|
||||||
return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)."
|
{
|
||||||
|
"ref": t.get("ref"),
|
||||||
lines = ["[Проекты и задачи — снимок на начало ответа]"]
|
"subject": t.get("subject", "")[:120],
|
||||||
|
}
|
||||||
if snapshot.get("error"):
|
for t in tasks
|
||||||
lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}")
|
],
|
||||||
|
}
|
||||||
projects = snapshot.get("projects") or []
|
)
|
||||||
if not projects:
|
except Exception as exc:
|
||||||
lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.")
|
fetch_error = str(exc)
|
||||||
else:
|
|
||||||
lines.append(f"Проекты Taiga ({len(projects)}):")
|
return {
|
||||||
for p in projects[:MAX_PROJECTS_IN_CONTEXT]:
|
"configured": True,
|
||||||
gitea = (
|
"projects": projects,
|
||||||
f"{p.get('gitea_owner')}/{p.get('gitea_repo')}"
|
"open_items": open_items,
|
||||||
if p.get("gitea_configured")
|
"taiga_open": taiga_open,
|
||||||
else "Gitea не привязан"
|
"error": fetch_error,
|
||||||
)
|
}
|
||||||
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}")
|
|
||||||
|
|
||||||
taiga_open = snapshot.get("taiga_open") or []
|
def format_projects_context(snapshot: dict[str, Any]) -> str:
|
||||||
if taiga_open:
|
if not snapshot.get("configured"):
|
||||||
lines.append("")
|
return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)."
|
||||||
lines.append("Открытые задачи в Taiga (live):")
|
|
||||||
for block in taiga_open:
|
lines = ["[Проекты и задачи — снимок на начало ответа]"]
|
||||||
stories = block.get("stories") or []
|
|
||||||
tasks = block.get("tasks") or []
|
if snapshot.get("error"):
|
||||||
if not stories and not tasks:
|
lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}")
|
||||||
lines.append(f" `{block.get('slug')}`: нет открытых")
|
|
||||||
continue
|
projects = snapshot.get("projects") or []
|
||||||
lines.append(f" `{block.get('slug')}`:")
|
if not projects:
|
||||||
for story in stories:
|
lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.")
|
||||||
lines.append(f" story #{story.get('ref')} {story.get('subject')}")
|
else:
|
||||||
for task in tasks:
|
lines.append(f"Проекты Taiga ({len(projects)}):")
|
||||||
lines.append(f" task #{task.get('ref')} {task.get('subject')}")
|
for p in projects[:MAX_PROJECTS_IN_CONTEXT]:
|
||||||
|
gitea = (
|
||||||
open_items = snapshot.get("open_items") or []
|
f"{p.get('gitea_owner')}/{p.get('gitea_repo')}"
|
||||||
if open_items:
|
if p.get("gitea_configured")
|
||||||
lines.append("")
|
else "Gitea не привязан"
|
||||||
lines.append("Work items созданные ассистентом (локальная БД):")
|
)
|
||||||
for item in open_items[:10]:
|
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}")
|
||||||
gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else ""
|
|
||||||
lines.append(
|
taiga_open = snapshot.get("taiga_open") or []
|
||||||
f"- #{item.get('taiga_ref')} {item.get('title')} "
|
if taiga_open:
|
||||||
f"({item.get('taiga_slug')}{gitea_part})"
|
lines.append("")
|
||||||
)
|
lines.append("Открытые задачи в Taiga (live):")
|
||||||
|
for block in taiga_open:
|
||||||
lines.append("")
|
stories = block.get("stories") or []
|
||||||
lines.append(
|
tasks = block.get("tasks") or []
|
||||||
"Правила: "
|
if not stories and not tasks:
|
||||||
"«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. "
|
lines.append(f" `{block.get('slug')}`: нет открытых")
|
||||||
"list_work_items — только созданные через ассистента. "
|
continue
|
||||||
"Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. "
|
lines.append(f" `{block.get('slug')}`:")
|
||||||
"create_work_item — для новых фич/багов из вольного текста."
|
for story in stories:
|
||||||
)
|
lines.append(f" story #{story.get('ref')} {story.get('subject')}")
|
||||||
return "\n".join(lines)
|
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)
|
||||||
|
|||||||
+476
-450
@@ -1,450 +1,476 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db.models import ProjectBinding, TaigaProject, WorkItem
|
from app.db.models import ProjectBinding, TaigaProject, WorkItem
|
||||||
from app.integrations.gitea import GiteaClient
|
from app.integrations.gitea import GiteaClient
|
||||||
from app.integrations.taiga import TaigaClient
|
from app.integrations.taiga import TaigaClient
|
||||||
from app.projects.commit_parser import parse_commit_message
|
from app.projects.commit_parser import parse_commit_message
|
||||||
from app.projects.structuring import (
|
from app.projects.structuring import (
|
||||||
format_gitea_body,
|
format_gitea_body,
|
||||||
format_story_description,
|
format_story_description,
|
||||||
slugify_branch,
|
slugify_branch,
|
||||||
structure_work_item,
|
structure_work_item,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProjectService:
|
class ProjectService:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session, user_id: int):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.settings = get_settings()
|
self.user_id = user_id
|
||||||
|
self.settings = get_settings()
|
||||||
def sync_taiga_projects(self) -> list[dict[str, Any]]:
|
|
||||||
if not self.settings.taiga_configured:
|
def sync_taiga_projects(self) -> list[dict[str, Any]]:
|
||||||
raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD")
|
if not self.settings.taiga_configured:
|
||||||
|
raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD")
|
||||||
client = TaigaClient()
|
|
||||||
remote = client.list_projects()
|
client = TaigaClient()
|
||||||
now = datetime.now(timezone.utc)
|
remote = client.list_projects()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
for item in remote:
|
|
||||||
slug = item.get("slug") or ""
|
for item in remote:
|
||||||
if not slug:
|
slug = item.get("slug") or ""
|
||||||
continue
|
if not slug:
|
||||||
existing = self.db.scalar(
|
continue
|
||||||
select(TaigaProject).where(TaigaProject.slug == slug)
|
existing = self.db.scalar(
|
||||||
)
|
select(TaigaProject).where(TaigaProject.slug == slug)
|
||||||
if existing:
|
)
|
||||||
existing.name = item.get("name", slug)
|
if existing:
|
||||||
existing.taiga_id = item["id"]
|
existing.name = item.get("name", slug)
|
||||||
existing.synced_at = now
|
existing.taiga_id = item["id"]
|
||||||
else:
|
existing.synced_at = now
|
||||||
self.db.add(
|
else:
|
||||||
TaigaProject(
|
self.db.add(
|
||||||
taiga_id=item["id"],
|
TaigaProject(
|
||||||
name=item.get("name", slug),
|
taiga_id=item["id"],
|
||||||
slug=slug,
|
name=item.get("name", slug),
|
||||||
synced_at=now,
|
slug=slug,
|
||||||
)
|
synced_at=now,
|
||||||
)
|
)
|
||||||
self.db.commit()
|
)
|
||||||
return self.list_projects()
|
self.db.commit()
|
||||||
|
return self.list_projects()
|
||||||
def list_projects(self) -> list[dict[str, Any]]:
|
|
||||||
stmt = (
|
def list_projects(self) -> list[dict[str, Any]]:
|
||||||
select(TaigaProject, ProjectBinding)
|
stmt = (
|
||||||
.outerjoin(ProjectBinding, ProjectBinding.taiga_slug == TaigaProject.slug)
|
select(TaigaProject, ProjectBinding)
|
||||||
.order_by(TaigaProject.name)
|
.outerjoin(
|
||||||
)
|
ProjectBinding,
|
||||||
rows = self.db.execute(stmt).all()
|
(ProjectBinding.taiga_slug == TaigaProject.slug)
|
||||||
result = []
|
& (ProjectBinding.user_id == self.user_id),
|
||||||
for taiga_proj, binding in rows:
|
)
|
||||||
result.append(
|
.order_by(TaigaProject.name)
|
||||||
{
|
)
|
||||||
"taiga_id": taiga_proj.taiga_id,
|
rows = self.db.execute(stmt).all()
|
||||||
"name": taiga_proj.name,
|
result = []
|
||||||
"slug": taiga_proj.slug,
|
for taiga_proj, binding in rows:
|
||||||
"gitea_owner": binding.gitea_owner if binding else "",
|
result.append(
|
||||||
"gitea_repo": binding.gitea_repo if binding else "",
|
{
|
||||||
"default_branch": binding.default_branch if binding else "main",
|
"taiga_id": taiga_proj.taiga_id,
|
||||||
"gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo),
|
"name": taiga_proj.name,
|
||||||
}
|
"slug": taiga_proj.slug,
|
||||||
)
|
"gitea_owner": binding.gitea_owner if binding else "",
|
||||||
return result
|
"gitea_repo": binding.gitea_repo if binding else "",
|
||||||
|
"default_branch": binding.default_branch if binding else "main",
|
||||||
def bind_gitea(
|
"gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo),
|
||||||
self,
|
}
|
||||||
taiga_slug: str,
|
)
|
||||||
gitea_owner: str,
|
return result
|
||||||
gitea_repo: str,
|
|
||||||
default_branch: str = "main",
|
def bind_gitea(
|
||||||
) -> dict[str, Any]:
|
self,
|
||||||
if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)):
|
taiga_slug: str,
|
||||||
raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.")
|
gitea_owner: str,
|
||||||
|
gitea_repo: str,
|
||||||
binding = self.db.scalar(
|
default_branch: str = "main",
|
||||||
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_slug)
|
) -> dict[str, Any]:
|
||||||
)
|
if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)):
|
||||||
if binding:
|
raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.")
|
||||||
binding.gitea_owner = gitea_owner
|
|
||||||
binding.gitea_repo = gitea_repo
|
binding = self.db.scalar(
|
||||||
binding.default_branch = default_branch
|
select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_slug)
|
||||||
else:
|
)
|
||||||
binding = ProjectBinding(
|
if binding:
|
||||||
taiga_slug=taiga_slug,
|
binding.gitea_owner = gitea_owner
|
||||||
gitea_owner=gitea_owner,
|
binding.gitea_repo = gitea_repo
|
||||||
gitea_repo=gitea_repo,
|
binding.default_branch = default_branch
|
||||||
default_branch=default_branch,
|
else:
|
||||||
)
|
binding = ProjectBinding(
|
||||||
self.db.add(binding)
|
user_id=self.user_id,
|
||||||
self.db.commit()
|
taiga_slug=taiga_slug,
|
||||||
|
gitea_owner=gitea_owner,
|
||||||
for proj in self.list_projects():
|
gitea_repo=gitea_repo,
|
||||||
if proj["slug"] == taiga_slug:
|
default_branch=default_branch,
|
||||||
return proj
|
)
|
||||||
raise ValueError("Binding failed")
|
self.db.add(binding)
|
||||||
|
self.db.commit()
|
||||||
def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]:
|
|
||||||
projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all()
|
for proj in self.list_projects():
|
||||||
if not projects:
|
if proj["slug"] == taiga_slug:
|
||||||
raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.")
|
return proj
|
||||||
|
raise ValueError("Binding failed")
|
||||||
taiga_proj: TaigaProject | None = None
|
|
||||||
if slug:
|
def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]:
|
||||||
taiga_proj = self.db.scalar(
|
projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all()
|
||||||
select(TaigaProject).where(TaigaProject.slug == slug)
|
if not projects:
|
||||||
)
|
raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.")
|
||||||
if not taiga_proj:
|
|
||||||
raise ValueError(f"Проект '{slug}' не найден")
|
taiga_proj: TaigaProject | None = None
|
||||||
else:
|
if slug:
|
||||||
taiga_proj = projects[0]
|
taiga_proj = self.db.scalar(
|
||||||
|
select(TaigaProject).where(TaigaProject.slug == slug)
|
||||||
binding = self.db.scalar(
|
)
|
||||||
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_proj.slug)
|
if not taiga_proj:
|
||||||
)
|
raise ValueError(f"Проект '{slug}' не найден")
|
||||||
return taiga_proj, binding
|
else:
|
||||||
|
taiga_proj = projects[0]
|
||||||
async def create_work_item(
|
|
||||||
self, raw_text: str, project_slug: str | None = None
|
binding = self.db.scalar(
|
||||||
) -> dict[str, Any]:
|
select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_proj.slug)
|
||||||
if not self.settings.taiga_configured:
|
)
|
||||||
raise ValueError("Taiga не настроена")
|
return taiga_proj, binding
|
||||||
|
|
||||||
project_list = self.list_projects()
|
async def create_work_item(
|
||||||
if not project_list:
|
self, raw_text: str, project_slug: str | None = None
|
||||||
self.sync_taiga_projects()
|
) -> dict[str, Any]:
|
||||||
project_list = self.list_projects()
|
if not self.settings.taiga_configured:
|
||||||
|
raise ValueError("Taiga не настроена")
|
||||||
structured = await structure_work_item(raw_text, project_list)
|
|
||||||
slug = project_slug or structured.get("project_slug")
|
project_list = self.list_projects()
|
||||||
taiga_proj, binding = self._resolve_project(slug)
|
if not project_list:
|
||||||
|
self.sync_taiga_projects()
|
||||||
if binding and not (binding.gitea_owner and binding.gitea_repo):
|
project_list = self.list_projects()
|
||||||
binding = None
|
|
||||||
|
structured = await structure_work_item(raw_text, project_list)
|
||||||
taiga = TaigaClient()
|
slug = project_slug or structured.get("project_slug")
|
||||||
title = (structured.get("title") or raw_text).strip()[:500]
|
taiga_proj, binding = self._resolve_project(slug)
|
||||||
description = format_story_description(structured, raw_text)
|
|
||||||
tags = structured.get("tags") or []
|
if binding and not (binding.gitea_owner and binding.gitea_repo):
|
||||||
issue_type = structured.get("issue_type", "feature")
|
binding = None
|
||||||
if issue_type == "bug" and "bug" not in [t.lower() for t in tags]:
|
|
||||||
tags.append("bug")
|
taiga = TaigaClient()
|
||||||
|
title = (structured.get("title") or raw_text).strip()[:500]
|
||||||
story = taiga.create_userstory(
|
description = format_story_description(structured, raw_text)
|
||||||
taiga_proj.taiga_id,
|
tags = structured.get("tags") or []
|
||||||
title,
|
issue_type = structured.get("issue_type", "feature")
|
||||||
description,
|
if issue_type == "bug" and "bug" not in [t.lower() for t in tags]:
|
||||||
tags=tags,
|
tags.append("bug")
|
||||||
)
|
|
||||||
|
story = taiga.create_userstory(
|
||||||
subtasks = []
|
taiga_proj.taiga_id,
|
||||||
for child in structured.get("children") or []:
|
title,
|
||||||
if isinstance(child, dict):
|
description,
|
||||||
subtasks.append(
|
tags=tags,
|
||||||
taiga.create_task(
|
)
|
||||||
taiga_proj.taiga_id,
|
|
||||||
story["id"],
|
subtasks = []
|
||||||
child.get("title", "Подзадача"),
|
for child in structured.get("children") or []:
|
||||||
child.get("description", ""),
|
if isinstance(child, dict):
|
||||||
)
|
subtasks.append(
|
||||||
)
|
taiga.create_task(
|
||||||
|
taiga_proj.taiga_id,
|
||||||
branch = f"feature/{story['ref']}-{slugify_branch(title)}"
|
story["id"],
|
||||||
gitea_issue_number = None
|
child.get("title", "Подзадача"),
|
||||||
gitea_url = ""
|
child.get("description", ""),
|
||||||
|
)
|
||||||
if binding and self.settings.gitea_configured:
|
)
|
||||||
gitea = GiteaClient()
|
|
||||||
gitea_body = format_gitea_body(
|
branch = f"feature/{story['ref']}-{slugify_branch(title)}"
|
||||||
structured,
|
gitea_issue_number = None
|
||||||
raw_text,
|
gitea_url = ""
|
||||||
story["ref"],
|
|
||||||
taiga.story_url(taiga_proj.taiga_id, story["ref"]),
|
if binding and self.settings.gitea_configured:
|
||||||
branch,
|
gitea = GiteaClient()
|
||||||
)
|
gitea_body = format_gitea_body(
|
||||||
if issue_type:
|
structured,
|
||||||
gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}"
|
raw_text,
|
||||||
issue = gitea.create_issue(
|
story["ref"],
|
||||||
binding.gitea_owner,
|
taiga.story_url(taiga_proj.taiga_id, story["ref"]),
|
||||||
binding.gitea_repo,
|
branch,
|
||||||
title,
|
)
|
||||||
gitea_body,
|
if issue_type:
|
||||||
)
|
gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}"
|
||||||
gitea_issue_number = issue["number"]
|
issue = gitea.create_issue(
|
||||||
gitea_url = gitea.issue_url(
|
binding.gitea_owner,
|
||||||
binding.gitea_owner, binding.gitea_repo, gitea_issue_number
|
binding.gitea_repo,
|
||||||
)
|
title,
|
||||||
|
gitea_body,
|
||||||
work_item = WorkItem(
|
)
|
||||||
taiga_slug=taiga_proj.slug,
|
gitea_issue_number = issue["number"]
|
||||||
taiga_project_id=taiga_proj.taiga_id,
|
gitea_url = gitea.issue_url(
|
||||||
taiga_story_id=story["id"],
|
binding.gitea_owner, binding.gitea_repo, gitea_issue_number
|
||||||
taiga_story_ref=story["ref"],
|
)
|
||||||
gitea_owner=binding.gitea_owner if binding else "",
|
|
||||||
gitea_repo=binding.gitea_repo if binding else "",
|
work_item = WorkItem(
|
||||||
gitea_issue_number=gitea_issue_number,
|
user_id=self.user_id,
|
||||||
suggested_branch=branch,
|
taiga_slug=taiga_proj.slug,
|
||||||
raw_text=raw_text,
|
taiga_project_id=taiga_proj.taiga_id,
|
||||||
title=title,
|
taiga_story_id=story["id"],
|
||||||
status="open",
|
taiga_story_ref=story["ref"],
|
||||||
)
|
gitea_owner=binding.gitea_owner if binding else "",
|
||||||
self.db.add(work_item)
|
gitea_repo=binding.gitea_repo if binding else "",
|
||||||
self.db.commit()
|
gitea_issue_number=gitea_issue_number,
|
||||||
self.db.refresh(work_item)
|
suggested_branch=branch,
|
||||||
|
raw_text=raw_text,
|
||||||
return {
|
title=title,
|
||||||
"ok": True,
|
status="open",
|
||||||
"work_item_id": work_item.id,
|
)
|
||||||
"taiga": {
|
self.db.add(work_item)
|
||||||
"ref": story["ref"],
|
self.db.commit()
|
||||||
"id": story["id"],
|
self.db.refresh(work_item)
|
||||||
"subject": story["subject"],
|
|
||||||
"url": taiga.story_url(taiga_proj.taiga_id, story["ref"]),
|
return {
|
||||||
},
|
"ok": True,
|
||||||
"gitea": {
|
"work_item_id": work_item.id,
|
||||||
"number": gitea_issue_number,
|
"taiga": {
|
||||||
"url": gitea_url,
|
"ref": story["ref"],
|
||||||
},
|
"id": story["id"],
|
||||||
"branch": branch,
|
"subject": story["subject"],
|
||||||
"issue_type": issue_type,
|
"url": taiga.story_url(taiga_proj.taiga_id, story["ref"]),
|
||||||
"subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks],
|
},
|
||||||
"questions": structured.get("questions") or [],
|
"gitea": {
|
||||||
"project_slug": taiga_proj.slug,
|
"number": gitea_issue_number,
|
||||||
}
|
"url": gitea_url,
|
||||||
|
},
|
||||||
def process_push(
|
"branch": branch,
|
||||||
self, owner: str, repo: str, commits: list[dict[str, Any]]
|
"issue_type": issue_type,
|
||||||
) -> list[dict[str, Any]]:
|
"subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks],
|
||||||
if not self.settings.taiga_configured:
|
"questions": structured.get("questions") or [],
|
||||||
return []
|
"project_slug": taiga_proj.slug,
|
||||||
|
}
|
||||||
taiga = TaigaClient()
|
|
||||||
gitea = GiteaClient() if self.settings.gitea_configured else None
|
def process_push(
|
||||||
results: list[dict[str, Any]] = []
|
self, owner: str, repo: str, commits: list[dict[str, Any]]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
for commit in commits:
|
if not self.settings.taiga_configured:
|
||||||
message = commit.get("message", "")
|
return []
|
||||||
parsed = parse_commit_message(message)
|
|
||||||
sha = commit.get("id", "")[:8]
|
taiga = TaigaClient()
|
||||||
|
gitea = GiteaClient() if self.settings.gitea_configured else None
|
||||||
gitea_refs = set(parsed["gitea"])
|
results: list[dict[str, Any]] = []
|
||||||
taiga_story_refs = set(parsed["taiga_story"])
|
|
||||||
taiga_task_refs = set(parsed["taiga_task"])
|
for commit in commits:
|
||||||
|
message = commit.get("message", "")
|
||||||
linked_items = self.db.scalars(
|
parsed = parse_commit_message(message)
|
||||||
select(WorkItem).where(
|
sha = commit.get("id", "")[:8]
|
||||||
WorkItem.gitea_owner == owner,
|
|
||||||
WorkItem.gitea_repo == repo,
|
gitea_refs = set(parsed["gitea"])
|
||||||
WorkItem.status == "open",
|
taiga_story_refs = set(parsed["taiga_story"])
|
||||||
)
|
taiga_task_refs = set(parsed["taiga_task"])
|
||||||
).all()
|
|
||||||
|
linked_items = self.db.scalars(
|
||||||
for item in linked_items:
|
select(WorkItem).where(
|
||||||
if item.gitea_issue_number and item.gitea_issue_number in gitea_refs:
|
WorkItem.user_id == self.user_id,
|
||||||
taiga_story_refs.add(item.taiga_story_ref)
|
WorkItem.gitea_owner == owner,
|
||||||
if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number:
|
WorkItem.gitea_repo == repo,
|
||||||
gitea_refs.add(item.gitea_issue_number)
|
WorkItem.status == "open",
|
||||||
|
)
|
||||||
for gitea_num in gitea_refs:
|
).all()
|
||||||
if gitea:
|
|
||||||
try:
|
for item in linked_items:
|
||||||
gitea.close_issue(owner, repo, gitea_num)
|
if item.gitea_issue_number and item.gitea_issue_number in gitea_refs:
|
||||||
except Exception as exc:
|
taiga_story_refs.add(item.taiga_story_ref)
|
||||||
results.append({"error": f"gitea #{gitea_num}: {exc}"})
|
if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number:
|
||||||
continue
|
gitea_refs.add(item.gitea_issue_number)
|
||||||
|
|
||||||
for item in linked_items:
|
for gitea_num in gitea_refs:
|
||||||
if item.gitea_issue_number == gitea_num:
|
if gitea:
|
||||||
self._close_work_item(item, taiga)
|
try:
|
||||||
results.append(
|
gitea.close_issue(owner, repo, gitea_num)
|
||||||
{
|
except Exception as exc:
|
||||||
"commit": sha,
|
results.append({"error": f"gitea #{gitea_num}: {exc}"})
|
||||||
"closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}",
|
continue
|
||||||
}
|
|
||||||
)
|
for item in linked_items:
|
||||||
|
if item.gitea_issue_number == gitea_num:
|
||||||
for ref in taiga_story_refs:
|
try:
|
||||||
project_id = self._project_id_for_ref(owner, repo, ref, linked_items)
|
self._close_work_item(item, taiga)
|
||||||
if not project_id:
|
results.append(
|
||||||
continue
|
{
|
||||||
story = taiga.get_by_ref(project_id, ref, kind="userstory")
|
"commit": sha,
|
||||||
if story:
|
"closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}",
|
||||||
taiga.close_userstory(story["id"], project_id)
|
}
|
||||||
for item in linked_items:
|
)
|
||||||
if item.taiga_story_ref == ref:
|
except Exception as exc:
|
||||||
self._close_work_item(item, taiga, close_gitea=bool(gitea))
|
results.append(
|
||||||
results.append({"commit": sha, "closed": f"taiga #{ref}"})
|
{"error": f"work item {item.id} (gitea #{gitea_num}): {exc}"}
|
||||||
|
)
|
||||||
for ref in taiga_task_refs:
|
|
||||||
binding = self.db.scalar(
|
for ref in taiga_story_refs:
|
||||||
select(ProjectBinding).where(
|
project_id = self._project_id_for_ref(owner, repo, ref, linked_items)
|
||||||
ProjectBinding.gitea_owner == owner,
|
if not project_id:
|
||||||
ProjectBinding.gitea_repo == repo,
|
continue
|
||||||
)
|
story = taiga.get_by_ref(project_id, ref, kind="userstory")
|
||||||
)
|
if story and not story.get("is_closed"):
|
||||||
if not binding:
|
try:
|
||||||
continue
|
taiga.close_userstory(story["id"], project_id)
|
||||||
taiga_proj = self.db.scalar(
|
results.append({"commit": sha, "closed": f"taiga #{ref}"})
|
||||||
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
|
except Exception as exc:
|
||||||
)
|
results.append({"error": f"taiga #{ref}: {exc}"})
|
||||||
if not taiga_proj:
|
for item in linked_items:
|
||||||
continue
|
if item.taiga_story_ref == ref and item.status != "closed":
|
||||||
task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task")
|
try:
|
||||||
if task:
|
self._close_work_item(item, taiga, close_gitea=bool(gitea))
|
||||||
taiga.close_task(task["id"], taiga_proj.taiga_id)
|
except Exception as exc:
|
||||||
results.append({"commit": sha, "closed": f"taiga task #{ref}"})
|
results.append(
|
||||||
|
{"error": f"work item {item.id} (taiga #{ref}): {exc}"}
|
||||||
self.db.commit()
|
)
|
||||||
return results
|
|
||||||
|
for ref in taiga_task_refs:
|
||||||
def _project_id_for_ref(
|
binding = self.db.scalar(
|
||||||
self,
|
select(ProjectBinding).where(
|
||||||
owner: str,
|
ProjectBinding.user_id == self.user_id,
|
||||||
repo: str,
|
ProjectBinding.gitea_owner == owner,
|
||||||
ref: int,
|
ProjectBinding.gitea_repo == repo,
|
||||||
items: list[WorkItem],
|
)
|
||||||
) -> int | None:
|
)
|
||||||
for item in items:
|
if not binding:
|
||||||
if item.taiga_story_ref == ref:
|
continue
|
||||||
return item.taiga_project_id
|
taiga_proj = self.db.scalar(
|
||||||
binding = self.db.scalar(
|
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
|
||||||
select(ProjectBinding).where(
|
)
|
||||||
ProjectBinding.gitea_owner == owner,
|
if not taiga_proj:
|
||||||
ProjectBinding.gitea_repo == repo,
|
continue
|
||||||
)
|
task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task")
|
||||||
)
|
if task and not task.get("is_closed"):
|
||||||
if binding:
|
try:
|
||||||
taiga_proj = self.db.scalar(
|
taiga.close_task(task["id"], taiga_proj.taiga_id)
|
||||||
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
|
results.append({"commit": sha, "closed": f"taiga task #{ref}"})
|
||||||
)
|
except Exception as exc:
|
||||||
return taiga_proj.taiga_id if taiga_proj else None
|
results.append({"error": f"taiga task #{ref}: {exc}"})
|
||||||
return None
|
|
||||||
|
self.db.commit()
|
||||||
def _close_work_item(
|
return results
|
||||||
self,
|
|
||||||
item: WorkItem,
|
def _project_id_for_ref(
|
||||||
taiga: TaigaClient,
|
self,
|
||||||
*,
|
owner: str,
|
||||||
close_gitea: bool = True,
|
repo: str,
|
||||||
) -> None:
|
ref: int,
|
||||||
if item.status == "closed":
|
items: list[WorkItem],
|
||||||
return
|
) -> int | None:
|
||||||
story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory")
|
for item in items:
|
||||||
if story:
|
if item.taiga_story_ref == ref:
|
||||||
taiga.close_userstory(story["id"], item.taiga_project_id)
|
return item.taiga_project_id
|
||||||
if (
|
binding = self.db.scalar(
|
||||||
close_gitea
|
select(ProjectBinding).where(
|
||||||
and item.gitea_issue_number
|
ProjectBinding.user_id == self.user_id,
|
||||||
and self.settings.gitea_configured
|
ProjectBinding.gitea_owner == owner,
|
||||||
):
|
ProjectBinding.gitea_repo == repo,
|
||||||
GiteaClient().close_issue(
|
)
|
||||||
item.gitea_owner, item.gitea_repo, item.gitea_issue_number
|
)
|
||||||
)
|
if binding:
|
||||||
item.status = "closed"
|
taiga_proj = self.db.scalar(
|
||||||
item.closed_at = datetime.now(timezone.utc)
|
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
|
||||||
|
)
|
||||||
def list_taiga_open_tasks(
|
return taiga_proj.taiga_id if taiga_proj else None
|
||||||
self,
|
return None
|
||||||
project_slug: str | None = None,
|
|
||||||
limit: int = 20,
|
def _close_work_item(
|
||||||
) -> dict[str, Any]:
|
self,
|
||||||
if not self.settings.taiga_configured:
|
item: WorkItem,
|
||||||
raise ValueError("Taiga не настроена")
|
taiga: TaigaClient,
|
||||||
|
*,
|
||||||
projects = self.list_projects()
|
close_gitea: bool = True,
|
||||||
if not projects:
|
) -> None:
|
||||||
projects = self.sync_taiga_projects()
|
if item.status == "closed":
|
||||||
|
return
|
||||||
if project_slug:
|
story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory")
|
||||||
projects = [p for p in projects if p["slug"] == project_slug]
|
if story:
|
||||||
if not projects:
|
taiga.close_userstory(story["id"], item.taiga_project_id)
|
||||||
raise ValueError(
|
if (
|
||||||
f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects."
|
close_gitea
|
||||||
)
|
and item.gitea_issue_number
|
||||||
|
and self.settings.gitea_configured
|
||||||
client = TaigaClient()
|
):
|
||||||
blocks: list[dict[str, Any]] = []
|
GiteaClient().close_issue(
|
||||||
|
item.gitea_owner, item.gitea_repo, item.gitea_issue_number
|
||||||
for proj in projects:
|
)
|
||||||
stories = client.list_open_userstories(proj["taiga_id"], limit=limit)
|
item.status = "closed"
|
||||||
tasks = client.list_open_tasks(proj["taiga_id"], limit=limit)
|
item.closed_at = datetime.now(timezone.utc)
|
||||||
blocks.append(
|
|
||||||
{
|
def list_taiga_open_tasks(
|
||||||
"slug": proj["slug"],
|
self,
|
||||||
"name": proj["name"],
|
project_slug: str | None = None,
|
||||||
"taiga_id": proj["taiga_id"],
|
limit: int = 20,
|
||||||
"stories": [
|
) -> dict[str, Any]:
|
||||||
{
|
if not self.settings.taiga_configured:
|
||||||
"ref": s.get("ref"),
|
raise ValueError("Taiga не настроена")
|
||||||
"subject": s.get("subject", ""),
|
|
||||||
"url": client.story_url(proj["taiga_id"], s.get("ref", 0)),
|
projects = self.list_projects()
|
||||||
}
|
if not projects:
|
||||||
for s in stories
|
projects = self.sync_taiga_projects()
|
||||||
],
|
|
||||||
"tasks": [
|
if project_slug:
|
||||||
{
|
projects = [p for p in projects if p["slug"] == project_slug]
|
||||||
"ref": t.get("ref"),
|
if not projects:
|
||||||
"subject": t.get("subject", ""),
|
raise ValueError(
|
||||||
"user_story": t.get("user_story"),
|
f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects."
|
||||||
}
|
)
|
||||||
for t in tasks
|
|
||||||
],
|
client = TaigaClient()
|
||||||
}
|
blocks: list[dict[str, Any]] = []
|
||||||
)
|
|
||||||
|
for proj in projects:
|
||||||
total_stories = sum(len(b["stories"]) for b in blocks)
|
stories = client.list_open_userstories(proj["taiga_id"], limit=limit)
|
||||||
total_tasks = sum(len(b["tasks"]) for b in blocks)
|
tasks = client.list_open_tasks(proj["taiga_id"], limit=limit)
|
||||||
return {
|
blocks.append(
|
||||||
"projects": blocks,
|
{
|
||||||
"total_stories": total_stories,
|
"slug": proj["slug"],
|
||||||
"total_tasks": total_tasks,
|
"name": proj["name"],
|
||||||
}
|
"taiga_id": proj["taiga_id"],
|
||||||
|
"stories": [
|
||||||
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)
|
"ref": s.get("ref"),
|
||||||
if status:
|
"subject": s.get("subject", ""),
|
||||||
stmt = stmt.where(WorkItem.status == status)
|
"url": client.story_url(proj["taiga_id"], s.get("ref", 0)),
|
||||||
items = self.db.scalars(stmt).all()
|
}
|
||||||
settings = get_settings()
|
for s in stories
|
||||||
return [
|
],
|
||||||
{
|
"tasks": [
|
||||||
"id": i.id,
|
{
|
||||||
"title": i.title,
|
"ref": t.get("ref"),
|
||||||
"status": i.status,
|
"subject": t.get("subject", ""),
|
||||||
"taiga_slug": i.taiga_slug,
|
"user_story": t.get("user_story"),
|
||||||
"taiga_ref": i.taiga_story_ref,
|
}
|
||||||
"gitea_issue": i.gitea_issue_number,
|
for t in tasks
|
||||||
"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
|
total_stories = sum(len(b["stories"]) for b in blocks)
|
||||||
else ""
|
total_tasks = sum(len(b["tasks"]) for b in blocks)
|
||||||
),
|
return {
|
||||||
"created_at": i.created_at.isoformat() if i.created_at else None,
|
"projects": blocks,
|
||||||
}
|
"total_stories": total_stories,
|
||||||
for i in items
|
"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
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""RAG: embeddings, Qdrant store, retrieval, ingest."""
|
||||||
|
|
||||||
|
from app.rag import chunker, embeddings, ingest, retriever, store
|
||||||
|
|
||||||
|
__all__ = ["chunker", "embeddings", "ingest", "retriever", "store"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(text: str, *, chunk_size: int = 800, overlap: int = 120) -> list[str]:
|
||||||
|
cleaned = (text or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return []
|
||||||
|
if len(cleaned) <= chunk_size:
|
||||||
|
return [cleaned]
|
||||||
|
chunks: list[str] = []
|
||||||
|
start = 0
|
||||||
|
while start < len(cleaned):
|
||||||
|
end = min(len(cleaned), start + chunk_size)
|
||||||
|
piece = cleaned[start:end].strip()
|
||||||
|
if piece:
|
||||||
|
chunks.append(piece)
|
||||||
|
if end >= len(cleaned):
|
||||||
|
break
|
||||||
|
start = max(0, end - overlap)
|
||||||
|
return chunks
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.llm.client import LLMClient
|
||||||
|
|
||||||
|
|
||||||
|
async def embed_texts(texts: list[str]) -> list[list[float]]:
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
client = LLMClient()
|
||||||
|
return await client.embed(texts)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.models import ChatSession, Document, DocumentChunk, MemoryFact
|
||||||
|
from app.rag import embeddings
|
||||||
|
from app.rag.chunker import chunk_text
|
||||||
|
from app.rag.store import (
|
||||||
|
COLLECTION_DOC_CHUNKS,
|
||||||
|
COLLECTION_FACTS,
|
||||||
|
COLLECTION_SUMMARIES,
|
||||||
|
delete_by_filter,
|
||||||
|
upsert_points,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def index_memory_fact(fact: MemoryFact) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled or not fact.active:
|
||||||
|
return
|
||||||
|
vectors = await embeddings.embed_texts([fact.content])
|
||||||
|
if not vectors:
|
||||||
|
return
|
||||||
|
upsert_points(
|
||||||
|
COLLECTION_FACTS,
|
||||||
|
[
|
||||||
|
qm.PointStruct(
|
||||||
|
id=int(fact.id),
|
||||||
|
vector=vectors[0],
|
||||||
|
payload={
|
||||||
|
"user_id": fact.user_id,
|
||||||
|
"fact_id": fact.id,
|
||||||
|
"category": fact.category,
|
||||||
|
"content": fact.content,
|
||||||
|
"importance": fact.importance,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def deactivate_memory_fact(fact_id: int) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled:
|
||||||
|
return
|
||||||
|
delete_by_filter(
|
||||||
|
COLLECTION_FACTS,
|
||||||
|
[qm.FieldCondition(key="fact_id", match=qm.MatchValue(value=fact_id))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def index_session_summary(session_id: int, summary: str) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled or not summary.strip():
|
||||||
|
return
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
|
||||||
|
user_id = 1
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
session = db.get(ChatSession, session_id)
|
||||||
|
if session:
|
||||||
|
user_id = session.user_id
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
vectors = await embeddings.embed_texts([summary])
|
||||||
|
if not vectors:
|
||||||
|
return
|
||||||
|
upsert_points(
|
||||||
|
COLLECTION_SUMMARIES,
|
||||||
|
[
|
||||||
|
qm.PointStruct(
|
||||||
|
id=int(session_id),
|
||||||
|
vector=vectors[0],
|
||||||
|
payload={"user_id": user_id, "session_id": session_id, "summary": summary[:4000]},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_document_file(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
title: str,
|
||||||
|
filename: str,
|
||||||
|
raw_bytes: bytes,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
text = raw_bytes.decode("utf-8", errors="replace").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Пустой документ")
|
||||||
|
|
||||||
|
digest = hashlib.sha256(raw_bytes).hexdigest()
|
||||||
|
doc = Document(
|
||||||
|
user_id=user_id,
|
||||||
|
title=title or filename,
|
||||||
|
filename=filename,
|
||||||
|
content_hash=digest,
|
||||||
|
size_bytes=len(raw_bytes),
|
||||||
|
)
|
||||||
|
db.add(doc)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
chunks = chunk_text(text)
|
||||||
|
chunk_rows: list[DocumentChunk] = []
|
||||||
|
for idx, piece in enumerate(chunks):
|
||||||
|
row = DocumentChunk(document_id=doc.id, chunk_index=idx, content=piece)
|
||||||
|
db.add(row)
|
||||||
|
chunk_rows.append(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(doc)
|
||||||
|
|
||||||
|
if settings.rag_enabled and chunks:
|
||||||
|
vectors = await embeddings.embed_texts(chunks)
|
||||||
|
points: list[qm.PointStruct] = []
|
||||||
|
for row, vector in zip(chunk_rows, vectors, strict=False):
|
||||||
|
db.refresh(row)
|
||||||
|
point_id = int(row.id)
|
||||||
|
points.append(
|
||||||
|
qm.PointStruct(
|
||||||
|
id=point_id,
|
||||||
|
vector=vector,
|
||||||
|
payload={
|
||||||
|
"user_id": user_id,
|
||||||
|
"document_id": doc.id,
|
||||||
|
"chunk_id": row.id,
|
||||||
|
"chunk_index": row.chunk_index,
|
||||||
|
"title": doc.title,
|
||||||
|
"content": row.content,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upsert_points(COLLECTION_DOC_CHUNKS, points)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": doc.id,
|
||||||
|
"title": doc.title,
|
||||||
|
"filename": doc.filename,
|
||||||
|
"chunk_count": len(chunks),
|
||||||
|
"size_bytes": doc.size_bytes,
|
||||||
|
"created_at": doc.created_at.isoformat() if doc.created_at else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Migrate active memory facts into Qdrant
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.base import SessionLocal, init_db
|
||||||
|
from app.db.models import MemoryFact, SessionSummary
|
||||||
|
from app.rag.ingest import index_memory_fact, index_session_summary
|
||||||
|
from app.rag.store import ensure_collections
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled:
|
||||||
|
print("RAG disabled; set RAG_ENABLED=true")
|
||||||
|
return
|
||||||
|
init_db()
|
||||||
|
ensure_collections()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
facts = db.scalars(select(MemoryFact).where(MemoryFact.active.is_(True))).all()
|
||||||
|
for fact in facts:
|
||||||
|
await index_memory_fact(fact)
|
||||||
|
summaries = db.scalars(select(SessionSummary)).all()
|
||||||
|
for row in summaries:
|
||||||
|
if row.summary:
|
||||||
|
await index_session_summary(row.session_id, row.summary)
|
||||||
|
print(f"Indexed {len(facts)} facts and {len(summaries)} summaries")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.rag import embeddings
|
||||||
|
from app.rag.store import COLLECTION_DOC_CHUNKS, COLLECTION_FACTS, search
|
||||||
|
|
||||||
|
|
||||||
|
def _user_filter(user_id: int) -> qm.Filter:
|
||||||
|
return qm.Filter(
|
||||||
|
must=[qm.FieldCondition(key="user_id", match=qm.MatchValue(value=user_id))]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def retrieve_memory_facts(
|
||||||
|
query: str, *, user_id: int, top_k: int | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled or not query.strip():
|
||||||
|
return []
|
||||||
|
k = top_k or settings.rag_top_k
|
||||||
|
vectors = await embeddings.embed_texts([query])
|
||||||
|
if not vectors:
|
||||||
|
return []
|
||||||
|
hits = search(COLLECTION_FACTS, vectors[0], limit=k, query_filter=_user_filter(user_id))
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for hit in hits:
|
||||||
|
payload = hit.payload or {}
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"id": payload.get("fact_id") or hit.id,
|
||||||
|
"category": payload.get("category", "fact"),
|
||||||
|
"content": payload.get("content", ""),
|
||||||
|
"score": hit.score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def retrieve_document_chunks(
|
||||||
|
query: str, *, user_id: int, top_k: int = 6
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled or not query.strip():
|
||||||
|
return []
|
||||||
|
vectors = await embeddings.embed_texts([query])
|
||||||
|
if not vectors:
|
||||||
|
return []
|
||||||
|
hits = search(
|
||||||
|
COLLECTION_DOC_CHUNKS, vectors[0], limit=top_k, query_filter=_user_filter(user_id)
|
||||||
|
)
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for hit in hits:
|
||||||
|
payload = hit.payload or {}
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"document_id": payload.get("document_id"),
|
||||||
|
"chunk_index": payload.get("chunk_index"),
|
||||||
|
"title": payload.get("title", ""),
|
||||||
|
"content": payload.get("content", ""),
|
||||||
|
"score": hit.score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
COLLECTION_FACTS = "memory_facts"
|
||||||
|
COLLECTION_SUMMARIES = "session_summaries"
|
||||||
|
COLLECTION_DOC_CHUNKS = "document_chunks"
|
||||||
|
VECTOR_SIZE = 1536
|
||||||
|
|
||||||
|
|
||||||
|
def _client() -> QdrantClient:
|
||||||
|
settings = get_settings()
|
||||||
|
return QdrantClient(url=settings.qdrant_url)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_collections() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled:
|
||||||
|
return
|
||||||
|
client = _client()
|
||||||
|
for name in (COLLECTION_FACTS, COLLECTION_SUMMARIES, COLLECTION_DOC_CHUNKS):
|
||||||
|
if client.collection_exists(name):
|
||||||
|
continue
|
||||||
|
client.create_collection(
|
||||||
|
collection_name=name,
|
||||||
|
vectors_config=qm.VectorParams(size=VECTOR_SIZE, distance=qm.Distance.COSINE),
|
||||||
|
)
|
||||||
|
logger.info("Created Qdrant collection %s", name)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_points(collection: str, points: list[qm.PointStruct]) -> None:
|
||||||
|
if not points:
|
||||||
|
return
|
||||||
|
_client().upsert(collection_name=collection, points=points)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_by_filter(collection: str, must: list[qm.FieldCondition]) -> None:
|
||||||
|
_client().delete(
|
||||||
|
collection_name=collection,
|
||||||
|
points_selector=qm.FilterSelector(filter=qm.Filter(must=must)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search(
|
||||||
|
collection: str,
|
||||||
|
vector: list[float],
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
query_filter: qm.Filter | None = None,
|
||||||
|
) -> list[qm.ScoredPoint]:
|
||||||
|
return _client().search(
|
||||||
|
collection_name=collection,
|
||||||
|
query_vector=vector,
|
||||||
|
limit=limit,
|
||||||
|
query_filter=query_filter,
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.reminders.service import RemindersService
|
||||||
|
|
||||||
|
__all__ = ["RemindersService"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user