41 Commits

Author SHA1 Message Date
Grigo 2f892bef19 fix migration 2026-06-16 09:45:59 +03:00
Grigo 9c09152bbf fix migration 2026-06-16 09:40:14 +03:00
Grigo 81c8117520 fix migration 2026-06-16 09:26:31 +03:00
Grigo 8f3ac70b20 refactor 2026-06-16 09:19:32 +03:00
Grigo 7f1516c9c9 added Table support 2026-06-16 08:35:35 +03:00
Grigo 0f2827030b fixed dynamic TDEE 2026-06-16 08:04:15 +03:00
Grigo a3f01cd850 smart tdee 2026-06-16 04:38:23 +00:00
Grigo f2e98942ff daily 2026-06-15 03:15:08 +00:00
Grigo 0c8ab6018a added RAG, Multiuser, TG bot 2026-06-14 06:26:16 +00:00
Grigo c8a9429bed added RAG, Multiuser, TG bot 2026-06-13 20:20:56 +00:00
Grigo 66e1b0e29e fixed reminder 2026-06-11 12:34:35 +03:00
Grigo 41cbef61a9 fixed reminder 2026-06-11 12:22:37 +03:00
Grigo 4108d737e3 fixed jenkins 2026-06-11 11:55:10 +03:00
Grigo 533f047e45 fixed jenkins 2026-06-11 11:42:09 +03:00
Grigo 1aa39fc4b2 added jenkins 2026-06-11 11:35:46 +03:00
Grigo b70ac1899c added jenkins 2026-06-11 11:28:35 +03:00
Grigo 603fcc58e3 added reminder 2026-06-11 11:22:41 +03:00
Grigo f7cc238308 added reminder 2026-06-11 11:04:22 +03:00
Grigo 363aca293a fixed chat 2026-06-11 10:20:49 +03:00
Grigo 52ab7e1ac4 fixed timer 2026-06-11 09:09:51 +03:00
Grigo 54ed9ba791 fixed timer 2026-06-11 08:48:21 +03:00
Grigo 0ccf19a1cc fixed injection watcher 2026-06-11 08:23:45 +03:00
Grigo b5a1831b8e fixed injection watcher 2026-06-11 08:18:30 +03:00
Grigo 481b93e84a fixed injection watcher 2026-06-11 08:11:51 +03:00
Grigo 06e09cd728 fixed injection watcher 2026-06-11 07:18:19 +03:00
Grigo 827f9016cd fixed reasoning 2026-06-10 15:09:36 +03:00
Grigo e9762d7921 fixed reasoning 2026-06-10 14:56:18 +03:00
Grigo 89158930ee fixed reasoning 2026-06-10 14:37:27 +03:00
Grigo 905d756a25 fixed reasoning 2026-06-10 13:40:14 +03:00
Grigo 320f7c7195 fixed reasoning 2026-06-10 13:11:15 +03:00
Grigo 07e9ef6e04 fixed reasoning 2026-06-10 13:06:44 +03:00
Grigo 8eb6505724 fixed rp api 2026-06-10 12:03:05 +03:00
Grigo 5844551038 fixed rp api 2026-06-10 11:52:22 +03:00
Grigo f407e41b6d added rp api 2026-06-10 11:49:01 +03:00
Grigo 73baf4dbe1 added api 2026-06-10 10:29:21 +03:00
Grigo d0bdd1e95c added fitness 2026-06-10 09:12:50 +03:00
Grigo 0b39692300 fixed memmory 2026-06-10 08:48:57 +03:00
Grigo c56471050c fixed memmory 2026-06-10 08:32:20 +03:00
Grigo 5a9d26fbf4 added memmory 2026-06-10 08:23:45 +03:00
Grigo 2c86a634bb Fixed Git integration 2026-06-09 15:57:59 +03:00
Grigo 19d8e50505 Fixed Git integration 2026-06-09 15:31:31 +03:00
218 changed files with 25221 additions and 3273 deletions
+105 -8
View File
@@ -11,30 +11,127 @@ VITE_DEV_PORT=5173
# OpenRouter # OpenRouter
OPENROUTER_API_KEY=sk-or-v1-your-key-here OPENROUTER_API_KEY=sk-or-v1-your-key-here
OPENROUTER_MODEL=deepseek/deepseek-chat OPENROUTER_MODEL=deepseek/deepseek-chat
# deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются:
# OPENROUTER_MODEL=deepseek/deepseek-v4-pro
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_TOOLS_ENABLED=true
# none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning.
OPENROUTER_REASONING_EFFORT=none
# Vision (скриншоты Mi Fitness и др.)
OPENROUTER_VISION_MODEL=google/gemini-2.5-flash-lite
VISION_MAX_EDGE_PX=1280
VISION_JPEG_QUALITY=85
VISION_DEBUG_ENABLED=true
VISION_MAX_IMAGES=8
# JSON-экстракция памяти отдельной моделью (если основная капризничает):
# MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat
# App # PostgreSQL (docker compose default)
DATABASE_URL=sqlite:///./data/assistant.db POSTGRES_USER=assistant
POSTGRES_PASSWORD=change-me-postgres-password
POSTGRES_DB=assistant
# App — docker: postgresql+psycopg2://assistant:PASSWORD@postgres:5432/assistant
# local dev without docker: sqlite:///./data/assistant.db
DATABASE_URL=postgresql+psycopg2://assistant:change-me-postgres-password@postgres:5432/assistant
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080 CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
SYSTEM_PROMPT_PATH=./prompts/assistant.md SYSTEM_PROMPT_PATH=./prompts/assistant.md
MEMORY_AUTO_EXTRACT=true
# Taiga (on host :9000, nginx → taiga.grigowashere.ru) # Multi-user (API token auth)
DEFAULT_USER_USERNAME=owner
DEFAULT_USER_DISPLAY_NAME=
DEFAULT_API_TOKEN=change-me-to-long-random-string
AUTH_REQUIRED=true
# Опционально для dev (автовход без /login). В prod оставьте пустым.
VITE_API_TOKEN=
# Fitness (wger + Open Food Facts — public HTTPS, no proxy)
WGER_BASE_URL=https://wger.de/api/v2
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
FITNESS_REMINDERS_ENABLED=true
REMINDERS_ENABLED=true
# Taiga (on host :9000 — replace with your public URL)
TAIGA_BASE_URL=http://host.docker.internal:9000 TAIGA_BASE_URL=http://host.docker.internal:9000
TAIGA_USERNAME=your_taiga_user TAIGA_USERNAME=your_taiga_user
TAIGA_PASSWORD=your_taiga_password TAIGA_PASSWORD=your_taiga_password
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru TAIGA_PUBLIC_URL=https://taiga.example.com
# Gitea (on host :3000, nginx → git.grigowashere.ru) # Gitea (on host :3000 — replace with your public URL)
GITEA_BASE_URL=http://host.docker.internal:3000 GITEA_BASE_URL=http://host.docker.internal:3000
GITEA_TOKEN=your_gitea_api_token GITEA_TOKEN=your_gitea_api_token
GITEA_PUBLIC_URL=https://git.grigowashere.ru GITEA_PUBLIC_URL=https://git.example.com
GITEA_WEBHOOK_SECRET=generate_a_random_secret GITEA_WEBHOOK_SECRET=generate_a_random_secret
# Gitea webhook URL (configure in repo settings): # Gitea webhook URL (repo Settings → Webhooks):
# http://127.0.0.1:8080/api/v1/webhooks/gitea # 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 REPOS_DIR=/data/repos
# Homelab — replace host with your weather/ComfyUI server
OPENMETEO_BASE_URL=http://host.docker.internal: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 (Anima split-model)
COMFYUI_BASE_URL=http://host.docker.internal: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) # Vector DB (phase 3)
QDRANT_PORT=6333 QDRANT_PORT=6333
QDRANT_GRPC_PORT=6334 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
Vendored
+141
View File
@@ -0,0 +1,141 @@
// 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/**'
docker compose build backend
docker compose run --rm --no-deps backend \
sh -c 'pip install -q -r requirements-dev.txt && pytest tests/ -q --tb=short'
if [ "${DOCKER_PULL}" = "true" ]; then
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
'''
}
}
}
+214 -39
View File
@@ -2,12 +2,18 @@
Домашний ИИ-ассистент с REST API, веб-интерфейсом и помидоро-таймером. LLM — OpenRouter (по умолчанию DeepSeek). Домашний ИИ-ассистент с REST API, веб-интерфейсом и помидоро-таймером. LLM — OpenRouter (по умолчанию DeepSeek).
## Возможности (MVP) ## Возможности
- Чат с потоковыми ответами (SSE) - Чат с потоковыми ответами (SSE), скриншоты и vision-разбор изображений
- Управление помидоро из чата через tool calling - Помидоро-таймер с циклом работа/перерывы, управление из чата (tool calling)
- REST API для внешних клиентов (Telegram-бот, мобильное приложение) - Долгосрочная память, профиль пользователя, опциональный RAG (Qdrant)
- Веб-морда: вкладки «Чат» и «Помидоро» - Фитнес: дневник, TDEE/Navy, графики веса и состава тела
- Списки покупок, календарь напоминаний
- Персонаж и генерация картинок (ComfyUI / RP Chat)
- Интеграции: Taiga, Gitea, погода, утренний дайджест, Netdata
- Мультипользовательская авторизация по API-токену (`/login`)
- Веб-интерфейс: Чат, Помидоро, Персонаж, Память, Фитнес, Покупки, Календарь, Настройки
- REST API для внешних клиентов — см. [Telegram-бот](telegram-bot/README.md)
## Быстрый старт ## Быстрый старт
@@ -37,6 +43,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`:
| Переменная | По умолчанию | Назначение | | Переменная | По умолчанию | Назначение |
@@ -47,7 +55,8 @@ docker compose up --build
| `TAIGA_PORT` | 9000 | Taiga (фаза 2) | | `TAIGA_PORT` | 9000 | Taiga (фаза 2) |
| `GITEA_PORT` | 3000 | Gitea HTTP (фаза 2) | | `GITEA_PORT` | 3000 | Gitea HTTP (фаза 2) |
| `GITEA_SSH_PORT` | 222 | Gitea SSH (фаза 2) | | `GITEA_SSH_PORT` | 222 | Gitea SSH (фаза 2) |
| `QDRANT_PORT` | 6333 | Qdrant HTTP (фаза 3) | | `QDRANT_PORT` | 6333 | Qdrant HTTP |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | assistant | PostgreSQL в docker compose |
### 3. Локальная разработка ### 3. Локальная разработка
@@ -57,10 +66,16 @@ docker compose up --build
cd backend cd backend
python -m venv .venv python -m venv .venv
.venv\Scripts\activate # Windows .venv\Scripts\activate # Windows
pip install -r requirements.txt pip install -r requirements-dev.txt
uvicorn app.main:app --reload --port 8080 uvicorn app.main:app --reload --port 8080
``` ```
Для локального backend без Docker задайте в `.env`:
```env
DATABASE_URL=sqlite:///./data/assistant.db
```
**Frontend:** **Frontend:**
```bash ```bash
@@ -73,34 +88,30 @@ Vite dev-server: http://localhost:5173 (проксирует `/api` на backend
## REST API ## REST API
Полная схема — Swagger UI: `http://localhost:${BACKEND_PORT:-8080}/docs`
Основные эндпоинты (префикс `/api/v1`, авторизация `Authorization: Bearer <token>` если `AUTH_REQUIRED=true`):
| Method | Path | Описание | | Method | Path | Описание |
|--------|------|----------| |--------|------|----------|
| GET | `/api/v1/health` | Healthcheck | | POST | `/login` | Получить сессию по API-токену |
| POST | `/api/v1/chat/sessions` | Создать чат-сессию | | GET | `/health` | Healthcheck |
| GET | `/api/v1/chat/sessions` | Список сессий | | POST/GET | `/chat/sessions` | Чат-сессии и история |
| GET | `/api/v1/chat/sessions/{id}` | История сообщений | | POST | `/chat/sessions/{id}/messages` | Сообщение (SSE) |
| POST | `/api/v1/chat/sessions/{id}/messages` | Отправить сообщение (SSE) | | GET/POST | `/pomodoro/*` | Таймер |
| DELETE | `/api/v1/chat/sessions/{id}` | Удалить сессию | | GET/PUT | `/memory`, `/profile` | Память и профиль |
| GET | `/api/v1/pomodoro/status` | Статус таймера | | GET/POST | `/fitness/*` | Фитнес, графики `/fitness/charts` |
| POST | `/api/v1/pomodoro/start` | Старт `{duration_min, task_note}` | | GET/POST | `/shopping/*` | Списки покупок |
| POST | `/api/v1/pomodoro/pause` | Пауза | | GET/POST | `/reminders/*` | Напоминания и календарь |
| POST | `/api/v1/pomodoro/resume` | Продолжить | | GET/POST | `/documents/*` | Загрузка документов (RAG) |
| POST | `/api/v1/pomodoro/stop` | Стоп `{result, completed}` | | GET | `/homelab/status`, `/homelab/weather` | Homelab |
| GET | `/api/v1/pomodoro/history` | История сессий | | GET/PUT | `/settings` | RAG toggle, пользователи |
| GET | `/api/v1/projects` | Проекты Taiga + привязка Gitea | | GET/POST | `/projects`, `/work-items` | Taiga + Gitea |
| POST | `/api/v1/projects/sync-taiga` | Синхронизировать проекты из Taiga | | POST | `/webhooks/gitea` | Webhook автозакрытия |
| PUT | `/api/v1/projects/{slug}/gitea` | Привязать Gitea repo |
| POST | `/api/v1/work-items` | Создать фичу/баг → Taiga + Gitea |
| GET | `/api/v1/work-items` | Список work items |
| POST | `/api/v1/webhooks/gitea` | Webhook для автозакрытия по push |
## Taiga + Gitea (фаза 2) ## Taiga + Gitea (фаза 2)
Taiga и Gitea работают **на хосте** (не в Docker): Taiga и Gitea обычно работают **на хосте** (не в Docker compose). Контейнер backend достучится через `host.docker.internal` (настроено в `docker-compose.yml`). Публичные URL — в `.env` (`TAIGA_PUBLIC_URL`, `GITEA_PUBLIC_URL`).
- Taiga: `127.0.0.1:9000``taiga.grigowashere.ru`
- Gitea: `127.0.0.1:3000``git.grigowashere.ru`
Контейнер backend достучится через `host.docker.internal` (настроено в `docker-compose.yml`).
### Настройка `.env` ### Настройка `.env`
@@ -108,11 +119,11 @@ Taiga и Gitea работают **на хосте** (не в Docker):
TAIGA_BASE_URL=http://host.docker.internal:9000 TAIGA_BASE_URL=http://host.docker.internal:9000
TAIGA_USERNAME=... TAIGA_USERNAME=...
TAIGA_PASSWORD=... TAIGA_PASSWORD=...
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru TAIGA_PUBLIC_URL=https://taiga.example.com
GITEA_BASE_URL=http://host.docker.internal:3000 GITEA_BASE_URL=http://host.docker.internal:3000
GITEA_TOKEN=... # Settings → Applications → Generate Token GITEA_TOKEN=... # Settings → Applications → Generate Token
GITEA_PUBLIC_URL=https://git.grigowashere.ru GITEA_PUBLIC_URL=https://git.example.com
GITEA_WEBHOOK_SECRET=... # произвольная строка GITEA_WEBHOOK_SECRET=... # произвольная строка
``` ```
@@ -132,11 +143,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**.
### Автозакрытие по коммиту ### Автозакрытие по коммиту
В сообщении коммита: В сообщении коммита:
@@ -155,16 +171,175 @@ Closes gitea #12, taiga #45
## Структура проекта ## Структура проекта
``` ```
backend/ FastAPI, OpenRouter, SQLite, помидоро backend/ FastAPI, OpenRouter, PostgreSQL (docker) / SQLite (local dev)
frontend/ React + Vite, чат и таймер frontend/ React + Vite
data/ SQLite БД (создаётся автоматически) telegram-bot/ Telegram-клиент (отдельный VPS)
data/ uploads, generated media, SQLite-бэкап при миграции
deploy/ примеры nginx
``` ```
## PostgreSQL
В `docker compose` по умолчанию поднимается **PostgreSQL 16**. Переменные — `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` в `.env`.
### Миграция с SQLite
Если уже есть `./data/assistant.db`:
```bash
# 1. Бэкап
cp -a data data.bak.$(date +%Y%m%d)
# 2. Поднять postgres и пересобрать backend (скрипт миграции в образе)
docker compose up -d postgres
docker compose build backend
# 3. Dry-run (подсчёт строк)
docker compose run --rm backend python scripts/migrate_sqlite_to_postgres.py --dry-run
# 4. Импорт (DATABASE_URL уже указывает на postgres в compose)
docker compose run --rm backend python scripts/migrate_sqlite_to_postgres.py
# 5. Перезапуск
docker compose up -d
```
Флаги: `--force` — очистить Postgres перед импортом; `--sqlite-path` — путь к файлу.
SQLite-файл **не удаляется** — остаётся бэкапом.
## Память и контекст
Долгосрочная память в БД (PostgreSQL или SQLite). При включённом RAG — семантический поиск фактов через Qdrant.
| Слой | Что хранит |
|------|------------|
| **Профиль** | имя, 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` | сводка чата |
## RAG (Qdrant)
Векторный поиск по фактам памяти и загруженным документам. **Два условия одновременно:**
1. `RAG_ENABLED=true` в `.env` (Qdrant должен быть доступен)
2. Toggle «RAG включён» в **Настройки** (веб-UI)
Загрузка документов: Settings → документы, или `POST /api/v1/documents/upload` (текст: `.txt`, `.md`, `.json`, `.csv`).
Tool в чате: `search_documents`.
Backfill существующих фактов:
```bash
docker compose exec backend python -m app.rag.migrate_memory_to_qdrant
```
Ограничения: нет API удаления документов; session summaries индексируются, но в чате читаются из SQLite; при ошибке embedding — fallback на топ фактов из БД.
## Фитнес-трекер
Профиль, дневник (еда/вода/вес/шаги/тренировки), калькуляторы TDEE и Navy (WHR/LBM/FFMI),
графики веса и состава тела (`/fitness`, API `/fitness/charts`), LLM-оценка ккал/БЖУ,
lookup wger + Open Food Facts, vision-импорт скриншотов Mi Fitness, напоминания в чат.
Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3».
## Списки покупок
Несколько списков, позиции с количеством, отметка «куплено». Вкладка `/shopping`, tools в чате (`add_shopping_items`, `list_shopping_lists`, …).
Чат: «добавь молоко и хлеб в продукты», «что в списке покупок», «отметь молоко купленным».
## Homelab API (фаза 4)
Интеграции с домашней инфраструктурой:
| Сервис | URL по умолчанию | Назначение |
|--------|------------------|------------|
| Open-Meteo | `$OPENMETEO_BASE_URL` | Погода в контексте и tool `get_weather` |
| ComfyUI | `$COMFYUI_BASE_URL` | 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 os, httpx
for url in [
os.environ.get('OPENMETEO_BASE_URL', 'http://host.docker.internal:8085').rstrip('/') + '/v1/forecast?latitude=59.93&longitude=30.33&current=temperature_2m',
os.environ.get('COMFYUI_BASE_URL', 'http://host.docker.internal:8188').rstrip('/') + '/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`.
## Telegram-бот
Отдельный сервис в [`telegram-bot/`](telegram-bot/README.md): диалог с ассистентом с VPS, привязка API-токена, дублирование notice из чата.
Создание пользователя: Settings → Пользователи или `docker compose exec backend python -m scripts.create_user`.
## Разработка и тесты
```bash
cd backend
pip install -r requirements-dev.txt
pytest tests/ -q
```
Перед деплоем Jenkins запускает pytest (см. `Jenkinsfile`). CI на GitHub Actions нет — только Jenkins на linux-ноде.
## Следующие фазы ## Следующие фазы
- RAG с Qdrant для документов - Taiga/fitness в утреннем дайджесте
- Проактивные чаты по расписанию - LLM-мотивация в фитнес-напоминаниях (сейчас шаблонные строки)
- Фитнес-трекер - API удаления документов и re-index при включении RAG задним числом
## Модель ## Модель
+34
View File
@@ -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")
+12
View File
@@ -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
+10 -1
View File
@@ -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(auth.router)
api_router.include_router(homelab.router, tags=["homelab"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"]) api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(character.router, tags=["character"]) api_router.include_router(character.router, tags=["character"])
api_router.include_router(projects.router, tags=["projects"]) 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(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"])
+73
View File
@@ -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,
}
+33 -9
View File
@@ -1,9 +1,13 @@
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.auth.deps import get_current_user
from app.character.service import CharacterService from app.character.service import CharacterService
from app.db.base import get_db
from app.db.models import User
router = APIRouter() router = APIRouter()
@@ -22,6 +26,12 @@ class CharacterCardData(BaseModel):
creator_notes: str = "" creator_notes: str = ""
alternate_greetings: list[str] = Field(default_factory=list) alternate_greetings: list[str] = Field(default_factory=list)
character_version: str = "1.0" character_version: str = "1.0"
appearance_tags: str = ""
appearance_prose: str = ""
lora_name: str = ""
lora_weight: float = 0.8
rp_persona_id: str = ""
sd_enabled: bool = True
class CharacterCardV2(BaseModel): class CharacterCardV2(BaseModel):
@@ -31,18 +41,28 @@ class CharacterCardV2(BaseModel):
@router.get("/character") @router.get("/character")
def get_character() -> dict[str, Any]: def get_character(
return CharacterService().get_card() db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
return CharacterService(db, user.id).get_card()
@router.put("/character") @router.put("/character")
def update_character(payload: CharacterCardV2) -> dict[str, Any]: def update_character(
return CharacterService().save_card(payload.model_dump()) payload: CharacterCardV2,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
return CharacterService(db, user.id).save_card(payload.model_dump())
@router.get("/character/prompt") @router.get("/character/prompt")
def get_character_prompt() -> dict[str, str]: def get_character_prompt(
service = CharacterService() db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, str]:
service = CharacterService(db, user.id)
return { return {
"system_prompt": service.get_system_prompt(), "system_prompt": service.get_system_prompt(),
"first_mes": service.get_card().get("data", {}).get("first_mes", ""), "first_mes": service.get_card().get("data", {}).get("first_mes", ""),
@@ -50,7 +70,11 @@ def get_character_prompt() -> dict[str, str]:
@router.post("/character/import") @router.post("/character/import")
def import_character(payload: dict[str, Any]) -> dict[str, Any]: def import_character(
payload: dict[str, Any],
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
if not payload: if not payload:
raise HTTPException(status_code=400, detail="Empty card") raise HTTPException(status_code=400, detail="Empty card")
return CharacterService().save_card(payload) return CharacterService(db, user.id).save_card(payload)
+213 -16
View File
@@ -1,55 +1,252 @@
from fastapi import APIRouter, Depends, HTTPException import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut from app.api.chat_schemas import GenerationStatusOut, MessagesPageOut
from app.api.schemas import (
MessageCreate,
SessionCreate,
SessionDetailOut,
SessionOut,
)
from app.auth.deps import get_current_user
from app.chat.generation import (
GenerationBusyError,
get_active_handle,
is_generation_active,
start_generation,
subscribe_generation,
)
from app.chat.service import ChatService from app.chat.service import ChatService
from app.config import get_settings
from app.db.base import get_db from app.db.base import get_db
from app.db.models import User
from app.vision import VisionService, format_user_messages, vision_debug_payloads
from app.vision.analyze import VisionUnavailableError
from app.vision.preprocess import prepare_image
from app.vision.storage import format_upload_images_markdown, save_upload
router = APIRouter() router = APIRouter()
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
@router.post("/sessions", response_model=SessionOut) @router.post("/sessions", response_model=SessionOut)
def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut: def create_session(payload: SessionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionOut:
service = ChatService(db) service = ChatService(db, user.id)
return service.create_session(title=payload.title) return service.create_session(title=payload.title)
@router.get("/sessions", response_model=list[SessionOut]) @router.get("/sessions", response_model=list[SessionOut])
def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]: def list_sessions(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[SessionOut]:
service = ChatService(db) service = ChatService(db, user.id)
return service.list_sessions() return service.list_sessions()
@router.get("/sessions/{session_id}", response_model=SessionDetailOut) @router.get("/sessions/{session_id}", response_model=SessionDetailOut)
def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut: def get_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionDetailOut:
service = ChatService(db) service = ChatService(db, user.id)
session = service.get_session(session_id) session = service.get_session(session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
return session 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}") @router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]: def delete_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
service = ChatService(db) service = ChatService(db, user.id)
if not service.delete_session(session_id): if not service.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True} 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") @router.post("/sessions/{session_id}/messages")
async def send_message( async def send_message(
session_id: int, session_id: int,
payload: MessageCreate, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StreamingResponse: ) -> StreamingResponse:
service = ChatService(db) service = ChatService(db, user.id)
if not service.get_session(session_id): if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
async def event_stream(): if is_generation_active(session_id):
async for chunk in service.stream_response(session_id, payload.content): raise HTTPException(status_code=409, detail="Generation already in progress")
yield chunk
return StreamingResponse(event_stream(), media_type="text/event-stream") 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)
+51
View File
@@ -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}
+322
View File
@@ -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)
+56
View File
@@ -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&current=temperature_2m"),
"comfyui": _probe(f"{settings.comfyui_base_url.rstrip('/')}/system_stats"),
"netdata": _probe(f"{settings.netdata_base_url.rstrip('/')}/api/v1/info"),
"rp_chat": _probe(f"{settings.rp_chat_base_url.rstrip('/')}/health"),
"config": {
"openmeteo_base_url": settings.openmeteo_base_url,
"comfyui_base_url": settings.comfyui_base_url,
"comfyui_backend": comfy_backend,
"comfyui_unet": settings.comfyui_unet,
"netdata_base_url": settings.netdata_base_url,
"rp_chat_base_url": settings.rp_chat_base_url,
"rp_chat_enabled": settings.rp_chat_enabled,
},
}
@router.get("/weather")
def weather_dashboard(
hours_ahead: int = 12,
days_ahead: int = 7,
_: User = Depends(get_current_user),
) -> dict:
hours = max(1, min(int(hours_ahead), 168))
days = max(1, min(int(days_ahead), 16))
return build_weather_dashboard(hours_ahead=hours, days_ahead=days)
+42
View File
@@ -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")
+130
View File
@@ -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
+24 -22
View File
@@ -2,7 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.schemas import PomodoroStart, PomodoroStop from app.api.schemas import PomodoroStart, PomodoroStop
from app.auth.deps import get_current_user
from app.db.base import get_db from app.db.base import get_db
from app.db.models import User
from app.pomodoro.service import PomodoroService from app.pomodoro.service import PomodoroService
router = APIRouter() router = APIRouter()
@@ -13,14 +15,14 @@ def _handle_value_error(exc: ValueError) -> HTTPException:
@router.get("/status") @router.get("/status")
def get_status(db: Session = Depends(get_db)) -> dict: def get_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db).get_status() return PomodoroService(db, user.id).get_status()
@router.post("/start") @router.post("/start")
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict: def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try: try:
return PomodoroService(db).start( return PomodoroService(db, user.id).start(
duration_min=payload.duration_min, duration_min=payload.duration_min,
task_note=payload.task_note, task_note=payload.task_note,
) )
@@ -29,25 +31,25 @@ def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db)) -> dic
@router.post("/pause") @router.post("/pause")
def pause_pomodoro(db: Session = Depends(get_db)) -> dict: def pause_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try: try:
return PomodoroService(db).pause() return PomodoroService(db, user.id).pause()
except ValueError as exc: except ValueError as exc:
raise _handle_value_error(exc) from exc raise _handle_value_error(exc) from exc
@router.post("/resume") @router.post("/resume")
def resume_pomodoro(db: Session = Depends(get_db)) -> dict: def resume_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try: try:
return PomodoroService(db).resume() return PomodoroService(db, user.id).resume()
except ValueError as exc: except ValueError as exc:
raise _handle_value_error(exc) from exc raise _handle_value_error(exc) from exc
@router.post("/stop") @router.post("/stop")
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict: def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try: try:
return PomodoroService(db).stop( return PomodoroService(db, user.id).stop(
result=payload.result, result=payload.result,
completed=payload.completed, completed=payload.completed,
) )
@@ -56,14 +58,14 @@ def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict:
@router.get("/history") @router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]: def get_history(limit: int = 20, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
return PomodoroService(db).history(limit=limit) return PomodoroService(db, user.id).history(limit=limit)
@router.post("/work/start") @router.post("/work/start")
def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict: def start_work(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try: try:
return PomodoroService(db).start_work( return PomodoroService(db, user.id).start_work(
duration_min=payload.duration_min, duration_min=payload.duration_min,
task_note=payload.task_note, task_note=payload.task_note,
) )
@@ -72,29 +74,29 @@ def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
@router.post("/break/short/start") @router.post("/break/short/start")
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict: def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try: try:
return PomodoroService(db).start_short_break(duration_min=duration_min) return PomodoroService(db, user.id).start_short_break(duration_min=duration_min)
except ValueError as exc: except ValueError as exc:
raise _handle_value_error(exc) from exc raise _handle_value_error(exc) from exc
@router.post("/break/long/start") @router.post("/break/long/start")
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict: def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try: try:
return PomodoroService(db).start_long_break(duration_min=duration_min) return PomodoroService(db, user.id).start_long_break(duration_min=duration_min)
except ValueError as exc: except ValueError as exc:
raise _handle_value_error(exc) from exc raise _handle_value_error(exc) from exc
@router.post("/cycle/reset") @router.post("/cycle/reset")
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db)) -> dict: def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db).reset_cycle(clear_task=clear_task) return PomodoroService(db, user.id).reset_cycle(clear_task=clear_task)
@router.post("/skip") @router.post("/skip")
def skip_phase(db: Session = Depends(get_db)) -> dict: def skip_phase(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try: try:
return PomodoroService(db).skip_phase() return PomodoroService(db, user.id).skip_phase()
except ValueError as exc: except ValueError as exc:
raise _handle_value_error(exc) from exc raise _handle_value_error(exc) from exc
+12 -10
View File
@@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db from app.db.base import get_db
from app.db.models import User
from app.projects.service import ProjectService from app.projects.service import ProjectService
router = APIRouter() router = APIRouter()
@@ -22,14 +24,14 @@ class WorkItemCreate(BaseModel):
@router.get("/projects") @router.get("/projects")
def list_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]: def list_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return ProjectService(db).list_projects() return ProjectService(db, user.id).list_projects()
@router.post("/projects/sync-taiga") @router.post("/projects/sync-taiga")
def sync_taiga_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]: def sync_taiga_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
try: try:
return ProjectService(db).sync_taiga_projects() return ProjectService(db, user.id).sync_taiga_projects()
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -38,10 +40,10 @@ def sync_taiga_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
def bind_gitea( def bind_gitea(
taiga_slug: str, taiga_slug: str,
payload: GiteaBinding, payload: GiteaBinding,
db: Session = Depends(get_db), db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
try: try:
return ProjectService(db).bind_gitea( return ProjectService(db, user.id).bind_gitea(
taiga_slug, taiga_slug,
payload.gitea_owner, payload.gitea_owner,
payload.gitea_repo, payload.gitea_repo,
@@ -54,10 +56,10 @@ def bind_gitea(
@router.post("/work-items") @router.post("/work-items")
async def create_work_item( async def create_work_item(
payload: WorkItemCreate, payload: WorkItemCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
try: try:
return await ProjectService(db).create_work_item( return await ProjectService(db, user.id).create_work_item(
payload.text, payload.text,
project_slug=payload.project_slug, project_slug=payload.project_slug,
) )
@@ -71,6 +73,6 @@ async def create_work_item(
def list_work_items( def list_work_items(
limit: int = 30, limit: int = 30,
status: str | None = None, status: str | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
return ProjectService(db).list_work_items(limit=limit, status=status) return ProjectService(db, user.id).list_work_items(limit=limit, status=status)
+128
View File
@@ -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
+35
View File
@@ -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)
+118
View File
@@ -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
+37 -25
View File
@@ -1,18 +1,21 @@
import hashlib import hashlib
import hmac import hmac
import json import json
import logging
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.chat.notice_inbox import post_notice_to_latest_chat
from app.config import get_settings from app.config import get_settings
from app.db.base import SessionLocal, get_db from app.db.base import get_db
from app.db.models import ChatSession, Message, ProjectBinding from app.db.models import ProjectBinding
from app.projects.service import ProjectService from app.projects.service import ProjectService
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool: def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
@@ -26,38 +29,29 @@ def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) ->
return hmac.compare_digest(expected, signature) return hmac.compare_digest(expected, signature)
def _post_close_notice(results: list[dict[str, Any]], owner: str, repo: str) -> None: def _post_close_notice(
results: list[dict[str, Any]], owner: str, repo: str, user_id: int
) -> None:
if not results: if not results:
return return
db = SessionLocal()
try:
session = db.scalar(
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
)
if not session:
session = ChatSession(title="Git")
db.add(session)
db.commit()
db.refresh(session)
lines = [f"🔀 **Push** `{owner}/{repo}`"] lines = [f"🔀 **Push** `{owner}/{repo}`"]
for item in results: for item in results:
if "closed" in item: if "closed" in item:
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}") lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
elif "error" in item: elif "error" in item:
lines.append(f"- ошибка: {item['error']}") lines.append(f"- ошибка: {item['error']}")
post_notice_to_latest_chat("\n".join(lines), user_id)
db.add(Message(session_id=session.id, role="notice", content="\n".join(lines)))
db.commit()
finally:
db.close()
@router.post("/webhooks/gitea") @router.post("/webhooks/gitea")
async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]: async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
body = await request.body() body = await request.body()
settings = get_settings() settings = get_settings()
signature = request.headers.get("X-Gitea-Signature") signature = (
request.headers.get("X-Gitea-Signature")
or request.headers.get("X-Gogs-Signature")
or request.headers.get("X-Hub-Signature-256")
)
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret): if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature") raise HTTPException(status_code=401, detail="Invalid webhook signature")
@@ -86,9 +80,27 @@ async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict
if not binding: if not binding:
return {"ok": True, "skipped": "unknown repo"} return {"ok": True, "skipped": "unknown repo"}
commits = payload.get("commits") or [] commits = list(payload.get("commits") or [])
service = ProjectService(db) if not commits:
results = service.process_push(owner, repo_name, commits) head = payload.get("head_commit")
_post_close_notice(results, owner, repo_name) if head:
commits = [head]
return {"ok": True, "results": results} logger.info(
"Gitea push %s/%s ref=%s commits=%d",
owner,
repo_name,
payload.get("ref", ""),
len(commits),
)
service = ProjectService(db, binding.user_id)
results = service.process_push(owner, repo_name, commits)
if results:
logger.info("Gitea push results: %s", results)
else:
logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name)
_post_close_notice(results, owner, repo_name, binding.user_id)
return {"ok": True, "results": results, "commits_processed": len(commits)}
+1
View File
@@ -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}
+5
View File
@@ -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"]
+37
View File
@@ -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
+61
View File
@@ -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
+9
View File
@@ -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
+28 -1
View File
@@ -12,7 +12,28 @@ TOOLS_INSTRUCTIONS = """
- «Какие задачи» / «покажи задачи проекта» → 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,
- «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history.
- Скриншоты и фото: vision-модель уже разобрала каждую картинку ДО твоего ответа. В сообщении один или несколько блоков [Скриншот] / [Скриншот N/M] — это содержимое изображений; отвечай так, будто ты их видишь.
- НЕ говори, что у тебя нет глаз / ты не видишь картинку / нужен Gemini, OpenRouter или curl — распознавание уже выполнено.
- fitness_workout / fitness_steps + fitness_hints: log_workout, log_steps и т.д.; при confidence=low уточни детали.
- document_type=other: опиши и прокомментируй по блоку [Скриншот], без советов про настройку vision API.
calc_fitness_targets, calc_body_composition (расчёт Navy/WHR/LBM/FFMI без записи), lookup_food, lookup_exercise, set_fitness_reminder.
- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary.
- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
- Никогда не пиши «ожидаю ответа от системы».
- В текстовых ответах пользователю не используй эмодзи.
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
- Утренний брифинг (погода + новости) → get_morning_briefing.
- Картинки: generate_image — draw_self=true + scene_description (full_body, outfit…); appearance только из карточки. Не злоупотребляй.
- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list.
- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки.
- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.
- «Напомни через 15 минут», «завтра утром», «12 мая в 9:00» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]).
- День рождения, Новый год и другие праздники → recurrence yearly.
- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе.
""".strip() """.strip()
DEFAULT_CARD: dict[str, Any] = { DEFAULT_CARD: dict[str, Any] = {
@@ -32,6 +53,12 @@ DEFAULT_CARD: dict[str, Any] = {
"creator": "", "creator": "",
"creator_notes": "", "creator_notes": "",
"character_version": "1.0", "character_version": "1.0",
"appearance_tags": "",
"appearance_prose": "",
"lora_name": "",
"lora_weight": 0.8,
"rp_persona_id": "",
"sd_enabled": True,
}, },
} }
+26 -10
View File
@@ -1,26 +1,42 @@
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: class CharacterService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
def get_card(self) -> dict[str, Any]: def get_card(self) -> dict[str, Any]:
if CARD_PATH.is_file(): row = self.db.scalar(
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
)
if not row:
return normalize_card(DEFAULT_CARD)
try: try:
raw = json.loads(CARD_PATH.read_text(encoding="utf-8")) return normalize_card(json.loads(row.card_json or "{}"))
return normalize_card(raw) except json.JSONDecodeError:
except (json.JSONDecodeError, OSError):
pass
return normalize_card(DEFAULT_CARD) return normalize_card(DEFAULT_CARD)
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]: def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
card = normalize_card(raw) card = normalize_card(raw)
CARD_PATH.parent.mkdir(parents=True, exist_ok=True) row = self.db.scalar(
CARD_PATH.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8") 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 return card
def get_system_prompt(self) -> str: def get_system_prompt(self) -> str:
+95
View File
@@ -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)
+71
View File
@@ -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
+47
View File
@@ -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()
+170 -12
View File
@@ -3,6 +3,13 @@ from typing import Any
from app.db.models import PomodoroSession from app.db.models import PomodoroSession
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
from app.tools.fitness import TOOL_NAMES as FITNESS_TOOL_NAMES
from app.tools.homelab import TOOL_NAMES as HOMELAB_TOOL_NAMES
from app.tools.memory import TOOL_NAMES as MEMORY_TOOL_NAMES
from app.tools.pomodoro import TOOL_NAMES as POMODORO_TOOL_NAMES
from app.tools.projects import TOOL_NAMES as PROJECT_TOOL_NAMES
from app.tools.reminders import TOOL_NAMES as REMINDER_TOOL_NAMES
from app.tools.shopping import TOOL_NAMES as SHOPPING_TOOL_NAMES
PHASE_LABELS = { PHASE_LABELS = {
PHASE_WORK: "Работа", PHASE_WORK: "Работа",
@@ -16,6 +23,18 @@ def _format_time(seconds: int) -> str:
return f"{minutes:02d}:{secs:02d}" return f"{minutes:02d}:{secs:02d}"
def _format_image_generation_notice(data: dict[str, Any]) -> str:
url = data.get("url", "")
positive = (data.get("prompt") or "").strip()
negative = (data.get("negative_prompt") or "").strip()
lines = ["🎨 **Картинка готова**", "", f"![image]({url})"]
if positive:
lines.extend(["", "**Comfy (+):**", f"```\n{positive}\n```"])
if negative:
lines.extend(["", "**Comfy ():**", f"```\n{negative}\n```"])
return "\n".join(lines)
def format_phase_completed_notice( def format_phase_completed_notice(
session: PomodoroSession, session: PomodoroSession,
next_phase: str | None, next_phase: str | None,
@@ -36,23 +55,43 @@ def format_phase_completed_notice(
return "\n".join(lines) return "\n".join(lines)
POMODORO_TOOL_NAMES = frozenset({
"get_pomodoro_status",
"start_pomodoro",
"start_short_break",
"start_long_break",
"stop_pomodoro",
"skip_pomodoro_phase",
"reset_pomodoro_cycle",
"get_pomodoro_history",
})
# Не засорять чат служебными ответами
TOOLS_SKIP_CHAT_NOTICE = frozenset({ TOOLS_SKIP_CHAT_NOTICE = frozenset({
"get_pomodoro_status", "get_pomodoro_status",
"recall_memories",
"get_fitness_summary",
"get_fitness_history",
"lookup_food",
"lookup_exercise",
"calc_fitness_targets",
"calc_body_composition",
"get_weather",
"get_morning_briefing",
"list_shopping_lists",
"list_reminders",
}) })
def _format_body_composition_notice(computed: dict[str, Any], *, headline: str) -> str:
parts: list[str] = []
bf = computed.get("body_fat_pct")
if bf is not None:
method = computed.get("body_fat_method")
if method == "navy":
parts.append(f"жир ≈{bf}% (Navy)")
elif method == "manual":
parts.append(f"жир {bf}%")
else:
parts.append(f"жир ≈{bf}%")
if computed.get("whr") is not None:
parts.append(f"WHR {computed.get('whr')}")
if computed.get("ffmi") is not None:
parts.append(f"FFMI {computed.get('ffmi')}")
if parts:
return f"{headline}{', '.join(parts)}"
return headline
def format_tool_notice(tool_name: str, raw_result: str) -> str | None: def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
if tool_name in TOOLS_SKIP_CHAT_NOTICE: if tool_name in TOOLS_SKIP_CHAT_NOTICE:
return None return None
@@ -63,7 +102,22 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
return None return None
if isinstance(data, dict) and "error" in data: if isinstance(data, dict) and "error" in data:
prefix = "" if tool_name in POMODORO_TOOL_NAMES else "📋" if tool_name in POMODORO_TOOL_NAMES:
prefix = ""
elif tool_name in MEMORY_TOOL_NAMES:
prefix = "🧠"
elif tool_name in FITNESS_TOOL_NAMES:
prefix = "💪"
elif tool_name in SHOPPING_TOOL_NAMES:
prefix = "🛒"
elif tool_name in REMINDER_TOOL_NAMES:
prefix = "📅"
elif tool_name in PROJECT_TOOL_NAMES:
prefix = "📋"
elif tool_name in HOMELAB_TOOL_NAMES:
prefix = "🏠"
else:
prefix = "📋"
return f"{prefix} {data['error']}" return f"{prefix} {data['error']}"
if tool_name == "reset_pomodoro_cycle": if tool_name == "reset_pomodoro_cycle":
@@ -109,6 +163,110 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}") lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
return "\n".join(lines) return "\n".join(lines)
if tool_name == "remember_fact" and data.get("ok"):
action = "обновлено" if data.get("action") == "updated" else "сохранено"
return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}"
if tool_name == "forget_memory" and data.get("ok"):
return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}"
if tool_name == "update_profile" and data.get("ok"):
profile = data.get("profile") or {}
parts = [f"{k}={v}" for k, v in profile.items() if v]
return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}"
if tool_name == "update_session_summary" and data.get("ok"):
return "🧠 **Сводка чата сохранена**"
if tool_name == "log_meal" and data.get("ok"):
meal = data.get("meal", {})
est = "" if meal.get("estimated") else ""
return (
f"💪 **Приём пищи** · {meal.get('description')} · "
f"{est}{meal.get('calories', 0):.0f} ккал "
f"{meal.get('protein_g', 0):.0f}{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})"
)
if tool_name == "log_water" and data.get("ok"):
w = data.get("water", {})
return f"💪 **Вода** +{w.get('amount_ml')} мл"
if tool_name == "log_weight" and data.get("ok"):
m = data.get("metric", {})
computed = data.get("computed") or {}
headline = f"💪 **Вес** {m.get('weight_kg')} кг"
return _format_body_composition_notice(computed, headline=headline)
if tool_name == "calc_body_composition" and isinstance(data, dict) and "error" not in data:
w = data.get("weight_kg")
headline = "💪 **Состав тела** (расчёт)"
if w is not None:
headline += f" · {w} кг"
msg = _format_body_composition_notice(data, headline=headline)
warnings = data.get("warnings") or []
if warnings:
msg += f" · {'; '.join(warnings[:2])}"
return msg
if tool_name == "log_workout" and data.get("ok"):
wo = data.get("workout", {})
return f"💪 **Тренировка** · {wo.get('title')}"
if tool_name == "set_fitness_profile" and data.get("ok"):
p = data.get("profile", {})
return (
f"💪 **Профиль** · {p.get('calorie_target')} ккал, "
f"вода {p.get('water_l')} л"
)
if tool_name == "set_fitness_reminder" and data.get("ok"):
r = data.get("reminder", {})
state = "вкл" if r.get("enabled") else "выкл"
return f"💪 **Напоминание {r.get('kind')}** · {state}"
if tool_name == "generate_image" and data.get("ok"):
return _format_image_generation_notice(data)
if tool_name == "create_shopping_list" and data.get("ok"):
lst = data.get("list") or {}
action = "создан" if data.get("created") else "уже был"
return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})"
if tool_name == "add_shopping_items" and data.get("ok"):
added = data.get("added") or []
names = ", ".join(i.get("text", "") for i in added[:5])
extra = f" +{len(added) - 5}" if len(added) > 5 else ""
return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}"
if tool_name == "check_shopping_item" and data.get("ok"):
item = data.get("item") or {}
state = "куплено" if item.get("checked") else "снята отметка"
return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}"
if tool_name == "remove_shopping_item" and data.get("ok"):
removed = data.get("removed") or {}
return f"🛒 **Удалено** · {removed.get('text')}"
if tool_name == "delete_shopping_list" and data.get("ok"):
return f"🛒 **Список удалён** · «{data.get('name')}»"
if tool_name == "create_reminder" and data.get("ok"):
r = data.get("reminder") or {}
rec = r.get("recurrence", "none")
rec_label = f" · повтор {rec}" if rec and rec != "none" else ""
return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}"
if tool_name == "update_reminder" and data.get("ok"):
r = data.get("reminder") or {}
return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}"
if tool_name == "delete_reminder" and data.get("ok"):
return f"📅 **Напоминание удалено** · «{data.get('title')}»"
if tool_name == "complete_reminder" and data.get("ok"):
r = data.get("reminder") or {}
return f"📅 **Готово** · {r.get('title')}"
return None return None
+445 -39
View File
@@ -1,40 +1,143 @@
import asyncio
import json import json
import logging
import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
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.db.base import SessionLocal
from app.character.service import CharacterService 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 ( from app.chat.notices import (
POMODORO_TOOL_NAMES, POMODORO_TOOL_NAMES,
format_pomodoro_context, format_pomodoro_context,
format_tool_notice, 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 OpenMeteoClient, 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.projects.context import format_projects_context, get_projects_snapshot
from app.reminders_scoped.context import format_reminders_context, get_reminders_snapshot
from app.shopping.context import format_shopping_context, get_shopping_snapshot
from app.db.models import ChatSession, Message from app.db.models import ChatSession, Message
from app.llm.client import LLMClient from app.llm.client import LLMClient
from app.pomodoro.service import PomodoroService from app.pomodoro.service import PomodoroService
from app.tools.registry import TOOL_DEFINITIONS, execute_tool from app.tools.registry import TOOL_DEFINITIONS, execute_tool
from app.vision.analyze import format_vision_turn_hint
MAX_TOOL_ROUNDS = 5 MAX_TOOL_ROUNDS = 5
MAX_HISTORY_MESSAGES = 40
_DOMAIN_CACHE: dict[str, tuple[float, str]] = {}
_DOMAIN_TTL_SEC = 60.0
_DOMAIN_KEYWORDS: dict[str, tuple[str, ...]] = {
"fitness": ("фитнес", "тренир", "калори", "еда", "вода", "вес", "workout", "meal", "белок", "жир"),
"shopping": ("покуп", "магазин", "список", "shopping", "корзин"),
"reminders": ("напомин", "календар", "событи", "дедлайн", "встреч", "план"),
"projects": ("taiga", "gitea", "задач", "проект", "git", "issue", "коммит", "ветк"),
"weather": (
"погод", "дожд", "снег", "ветер", "температур", "градус", "мороз", "жар",
"на улице", "одеть", "зонт", "прогноз", "завтра", "послезавтра", "выходн",
"weather", "rain", "forecast", "umbrella", "outside",
),
}
logger = logging.getLogger(__name__)
def _build_messages_for_session(session_id: int, user_id: int) -> list[dict[str, Any]]:
db = SessionLocal()
try:
service = ChatService(db, user_id)
session = service.get_session(session_id)
if not session:
return []
return service._build_messages(session)
finally:
db.close()
async def _extract_memory_background(
session_id: int,
user_id: int,
user_text: str,
assistant_text: str,
) -> None:
db = SessionLocal()
try:
await extract_after_turn(db, session_id, user_text, assistant_text, user_id=user_id)
except Exception as exc:
logger.warning("Background memory extraction failed: %s", exc)
finally:
db.close()
class ChatService: class ChatService:
def __init__(self, db: Session): def __init__(self, db: Session, user_id: int):
self.db = db self.db = db
self.user_id = user_id
self.llm = LLMClient() self.llm = LLMClient()
self.character = CharacterService() self.character = CharacterService(db, user_id)
def list_sessions(self) -> list[ChatSession]: def list_sessions(self) -> list[ChatSession]:
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()) stmt = select(ChatSession).where(ChatSession.user_id == self.user_id).order_by(ChatSession.updated_at.desc())
return list(self.db.scalars(stmt).all()) return list(self.db.scalars(stmt).all())
def get_session(self, session_id: int) -> ChatSession | None: def get_session(self, session_id: int) -> ChatSession | None:
return self.db.get(ChatSession, session_id) session = self.db.get(ChatSession, session_id)
if session and session.user_id != self.user_id:
return None
return session
def list_messages(
self,
session_id: int,
limit: int = 30,
before_id: int | None = None,
after_id: int | None = None,
) -> tuple[list[Message], bool]:
if not self.get_session(session_id):
return [], False
if after_id is not None:
stmt = (
select(Message)
.where(Message.session_id == session_id, Message.id > after_id)
.order_by(Message.created_at.asc())
.limit(limit + 1)
)
rows = list(self.db.scalars(stmt).all())
has_more = len(rows) > limit
return rows[:limit], has_more
stmt = select(Message).where(Message.session_id == session_id)
if before_id is not None:
anchor = self.db.get(Message, before_id)
if anchor is None or anchor.session_id != session_id:
return [], False
stmt = stmt.where(Message.created_at < anchor.created_at)
stmt = stmt.order_by(Message.created_at.desc()).limit(limit + 1)
rows = list(self.db.scalars(stmt).all())
has_more = len(rows) > limit
page = rows[:limit]
page.reverse()
return page, has_more
def create_session(self, title: str = "Новый чат") -> ChatSession: def create_session(self, title: str = "Новый чат") -> ChatSession:
session = ChatSession(title=title) session = ChatSession(user_id=self.user_id, title=title)
self.db.add(session) self.db.add(session)
self.db.commit() self.db.commit()
self.db.refresh(session) self.db.refresh(session)
@@ -48,32 +151,99 @@ class ChatService:
self.db.commit() self.db.commit()
return True return True
def _build_system_prompt(self) -> str: def _cached_domain(self, key: str, loader, formatter) -> str:
status = PomodoroService(self.db).get_status() now = time.monotonic()
projects_snapshot = get_projects_snapshot(self.db) hit = _DOMAIN_CACHE.get(f"{self.user_id}:{key}")
return ( if hit and now < hit[0]:
f"{self.character.get_system_prompt()}\n\n" return hit[1]
f"{format_pomodoro_context(status)}\n\n" rendered = formatter(loader())
f"{format_projects_context(projects_snapshot)}" _DOMAIN_CACHE[f"{self.user_id}:{key}"] = (now + _DOMAIN_TTL_SEC, rendered)
) return rendered
def _domain_relevant(self, key: str, user_query: str) -> bool:
query = user_query.strip().lower()
if not query:
return False
keywords = _DOMAIN_KEYWORDS.get(key, ())
return any(kw in query for kw in keywords)
def _optional_domain(
self,
key: str,
user_query: str,
loader,
formatter,
) -> str:
if not self._domain_relevant(key, user_query):
return ""
return self._cached_domain(key, loader, formatter)
def _build_system_prompt(self, session_id: int | None = None, user_query: str = "") -> str:
status = PomodoroService(self.db, self.user_id).get_status()
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session_id, query=user_query)
fitness_snapshot = get_fitness_snapshot(self.db, self.user_id)
shopping_snapshot = get_shopping_snapshot(self.db, self.user_id)
reminders_snapshot = get_reminders_snapshot(self.db, self.user_id)
projects_snapshot = get_projects_snapshot(self.db, self.user_id)
parts = [
self.character.get_system_prompt(),
format_datetime_context(self.db, self.user_id),
format_memory_context(memory_snapshot),
self._optional_domain("fitness", user_query, lambda: fitness_snapshot, format_fitness_context),
self._optional_domain("shopping", user_query, lambda: shopping_snapshot, format_shopping_context),
self._optional_domain("reminders", user_query, lambda: reminders_snapshot, format_reminders_context),
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]]: 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]] = [ messages: list[dict[str, Any]] = [
{"role": "system", "content": self._build_system_prompt()} {"role": "system", "content": system_prompt}
] ]
for msg in session.messages: chat_messages = all_chat[-MAX_HISTORY_MESSAGES:] if len(all_chat) > MAX_HISTORY_MESSAGES else all_chat
if msg.role == "notice":
continue
for msg in chat_messages:
content = msg.content or None content = msg.content or None
entry: dict[str, Any] = {"role": msg.role, "content": content} entry: dict[str, Any] = {"role": msg.role, "content": content}
if msg.tool_calls_json: if msg.tool_calls_json:
entry["tool_calls"] = json.loads(msg.tool_calls_json) entry["tool_calls"] = json.loads(msg.tool_calls_json)
if not content: if not content:
entry["content"] = None 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: if msg.role == "tool" and msg.tool_call_id:
entry["tool_call_id"] = msg.tool_call_id entry["tool_call_id"] = msg.tool_call_id
messages.append(entry) messages.append(entry)
messages = sanitize_openai_messages(messages)
messages = strip_historical_reasoning(messages)
return messages return messages
def _save_message( def _save_message(
@@ -83,12 +253,14 @@ class ChatService:
content: str = "", content: str = "",
tool_calls: list[dict[str, Any]] | None = None, tool_calls: list[dict[str, Any]] | None = None,
tool_call_id: str | None = None, tool_call_id: str | None = None,
reasoning_json: str | None = None,
) -> Message: ) -> Message:
message = Message( message = Message(
session_id=session_id, session_id=session_id,
role=role, role=role,
content=content, content=content,
tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None, 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, tool_call_id=tool_call_id,
) )
self.db.add(message) self.db.add(message)
@@ -99,44 +271,213 @@ class ChatService:
self.db.refresh(message) self.db.refresh(message)
return message return message
async def stream_response(self, session_id: int, user_text: str) -> AsyncIterator[str]: def save_user_message(self, session_id: int, user_text: str) -> None:
session = self.get_session(session_id)
if not session:
yield self._sse("error", {"message": "Session not found"})
return
self._save_message(session_id, "user", user_text) self._save_message(session_id, "user", user_text)
messages = self._build_messages(session)
for _ in range(MAX_TOOL_ROUNDS): async def _fallback_complete(
content_parts: list[str] = [] self,
tool_calls: list[dict[str, Any]] = [] 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
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS): tool_calls = result.get("tool_calls") or []
if event["type"] == "content": content = (result.get("content") or "").strip()
content_parts.append(event["content"]) notices: list[str] = []
yield self._sse("token", {"content": event["content"]}) pomodoro_events: list[dict[str, Any]] = []
elif event["type"] == "tool_calls":
tool_calls = event["tool_calls"]
if tool_calls: if tool_calls:
assistant_msg: dict[str, Any] = { assistant_msg: dict[str, Any] = {
"role": "assistant", "role": "assistant",
"content": "".join(content_parts) or None, "content": content or None,
"tool_calls": tool_calls, "tool_calls": tool_calls,
} }
messages.append(assistant_msg) messages.append(assistant_msg)
self._save_message( self._save_message(
session_id, session_id,
"assistant", "assistant",
"".join(content_parts), content,
tool_calls=tool_calls, tool_calls=tool_calls,
) )
for tool_call in tool_calls: for tool_call in tool_calls:
fn = tool_call["function"] fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", "")) args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
result = await execute_tool(self.db, fn["name"], args) 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 = { tool_message = {
"role": "tool", "role": "tool",
"tool_call_id": tool_call["id"], "tool_call_id": tool_call["id"],
@@ -148,7 +489,8 @@ class ChatService:
notice = format_tool_notice(fn["name"], result) notice = format_tool_notice(fn["name"], result)
if notice: if notice:
self._save_message(session_id, "notice", notice) self._save_message(session_id, "notice", notice)
yield self._sse("notice", {"content": notice}) round_notices.append(notice)
all_tool_notices.append(notice)
if fn["name"] in POMODORO_TOOL_NAMES: if fn["name"] in POMODORO_TOOL_NAMES:
yield self._sse( yield self._sse(
@@ -156,13 +498,77 @@ class ChatService:
{"name": fn["name"], "result": json.loads(result)}, {"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 continue
final_content = "".join(content_parts) 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: 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) 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", {}) 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 return
yield self._sse("error", {"message": "Too many tool call rounds"}) yield self._sse("error", {"message": "Too many tool call rounds"})
+106 -2
View File
@@ -1,8 +1,19 @@
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
DEPRECATED_VISION_MODELS: dict[str, str] = {
"google/gemini-2.0-flash-lite-001": "google/gemini-2.5-flash-lite",
"google/gemini-2.0-flash-lite": "google/gemini-2.5-flash-lite",
}
def resolve_vision_model(model: str) -> str:
stripped = model.strip()
return DEPRECATED_VISION_MODELS.get(stripped, stripped)
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
@@ -17,24 +28,113 @@ class Settings(BaseSettings):
openrouter_api_key: str = "" openrouter_api_key: str = ""
openrouter_model: str = "deepseek/deepseek-chat" openrouter_model: str = "deepseek/deepseek-chat"
openrouter_base_url: str = "https://openrouter.ai/api/v1" 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"
openrouter_vision_model: str = "google/gemini-2.5-flash-lite"
vision_max_edge_px: int = 1280
vision_jpeg_quality: int = 85
vision_debug_enabled: bool = True
vision_max_images: int = 8
uploads_dir: str = "./data/uploads"
@field_validator("openrouter_vision_model")
@classmethod
def migrate_vision_model(cls, value: str) -> str:
return resolve_vision_model(value)
database_url: str = "sqlite:///./data/assistant.db" database_url: str = "sqlite:///./data/assistant.db"
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000" cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
system_prompt_path: str = "./prompts/assistant.md" system_prompt_path: str = "./prompts/assistant.md"
memory_auto_extract: bool = True
default_user_username: str = "owner"
default_user_display_name: str = ""
default_api_token: str = ""
auth_required: bool = True
qdrant_url: str = "http://qdrant:6333"
embedding_model: str = "openai/text-embedding-3-small"
rag_enabled: bool = False
rag_top_k: int = 8
memory_facts_in_context: int = 8
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container # Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
taiga_base_url: str = "http://host.docker.internal:9000" taiga_base_url: str = "http://host.docker.internal:9000"
taiga_username: str = "" taiga_username: str = ""
taiga_password: str = "" taiga_password: str = ""
taiga_public_url: str = "https://taiga.grigowashere.ru" taiga_public_url: str = "https://taiga.example.com"
gitea_base_url: str = "http://host.docker.internal:3000" gitea_base_url: str = "http://host.docker.internal:3000"
gitea_token: str = "" gitea_token: str = ""
gitea_public_url: str = "https://git.grigowashere.ru" gitea_public_url: str = "https://git.example.com"
gitea_webhook_secret: str = "" gitea_webhook_secret: str = ""
repos_dir: str = "/data/repos" repos_dir: str = "/data/repos"
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://host.docker.internal: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://host.docker.internal: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 @property
def cors_origins_list(self) -> list[str]: def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@@ -47,6 +147,10 @@ class Settings(BaseSettings):
def gitea_configured(self) -> bool: def gitea_configured(self) -> bool:
return bool(self.gitea_token) 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: def load_system_prompt(self) -> str:
path = Path(self.system_prompt_path) path = Path(self.system_prompt_path)
if path.is_file(): if path.is_file():
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy.engine import Engine
def dialect_name(engine: Engine) -> str:
return engine.dialect.name
def is_sqlite(engine: Engine) -> bool:
return dialect_name(engine) == "sqlite"
def is_postgresql(engine: Engine) -> bool:
return dialect_name(engine) == "postgresql"
def bool_literal(engine: Engine, value: bool = False) -> str:
if is_sqlite(engine):
return "1" if value else "0"
return "true" if value else "false"
+8 -1
View File
@@ -1,6 +1,7 @@
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from app.db.base import engine from app.db.base import engine
from app.db.dialect import bool_literal
def run_migrations() -> None: def run_migrations() -> None:
@@ -17,10 +18,16 @@ def run_migrations() -> None:
conn.execute( conn.execute(
text( text(
"ALTER TABLE pomodoro_sessions " "ALTER TABLE pomodoro_sessions "
"ADD COLUMN completion_notified BOOLEAN DEFAULT 0" f"ADD COLUMN completion_notified BOOLEAN DEFAULT {bool_literal(engine, False)}"
) )
) )
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
+218
View File
@@ -0,0 +1,218 @@
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, StepLog
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:
from app.db.dialect import is_postgresql
applied_type = (
"TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
if is_postgresql(engine)
else "DATETIME DEFAULT CURRENT_TIMESTAMP"
)
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE IF NOT EXISTS _schema_migrations ("
"name TEXT PRIMARY KEY, "
f"applied_at {applied_type})"
)
)
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():
StepLog.__table__.create(engine, checkfirst=True)
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()
+222
View File
@@ -0,0 +1,222 @@
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
from app.db.models import CharacterCard, User
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:
User.__table__.create(engine, checkfirst=True)
def _ensure_character_cards_table() -> None:
CharacterCard.__table__.create(engine, checkfirst=True)
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
+287 -2
View File
@@ -1,15 +1,40 @@
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 User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(255), default="")
api_token_hash: Mapped[str] = mapped_column(String(64), index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class CharacterCard(Base):
__tablename__ = "character_cards"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
)
card_json: Mapped[str] = mapped_column(Text, default="{}")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class ChatSession(Base): class ChatSession(Base):
__tablename__ = "chat_sessions" __tablename__ = "chat_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255), default="Новый чат") title: Mapped[str] = mapped_column(String(255), default="Новый чат")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
@@ -29,6 +54,7 @@ class Message(Base):
role: Mapped[str] = mapped_column(String(32)) role: Mapped[str] = mapped_column(String(32))
content: Mapped[str] = mapped_column(Text, default="") content: Mapped[str] = mapped_column(Text, default="")
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True) 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) 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()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -39,6 +65,7 @@ class PomodoroCycle(Base):
__tablename__ = "pomodoro_cycles" __tablename__ = "pomodoro_cycles"
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)
work_duration_min: Mapped[int] = mapped_column(Integer, default=25) work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
short_break_min: Mapped[int] = mapped_column(Integer, default=5) short_break_min: Mapped[int] = mapped_column(Integer, default=5)
long_break_min: Mapped[int] = mapped_column(Integer, default=15) long_break_min: Mapped[int] = mapped_column(Integer, default=15)
@@ -56,6 +83,7 @@ class PomodoroSession(Base):
__tablename__ = "pomodoro_sessions" __tablename__ = "pomodoro_sessions"
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)
status: Mapped[str] = mapped_column(String(32), default="idle") status: Mapped[str] = mapped_column(String(32), default="idle")
phase: Mapped[str] = mapped_column(String(32), default="work") phase: Mapped[str] = mapped_column(String(32), default="work")
duration_min: Mapped[int] = mapped_column(Integer, default=25) duration_min: Mapped[int] = mapped_column(Integer, default=25)
@@ -82,9 +110,11 @@ class TaigaProject(Base):
class ProjectBinding(Base): class ProjectBinding(Base):
__tablename__ = "project_bindings" __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) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=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_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: 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") default_branch: Mapped[str] = mapped_column(String(64), default="main")
@@ -93,10 +123,232 @@ class ProjectBinding(Base):
) )
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): class WorkItem(Base):
__tablename__ = "work_items" __tablename__ = "work_items"
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), index=True) taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
taiga_project_id: Mapped[int] = mapped_column(Integer) taiga_project_id: Mapped[int] = mapped_column(Integer)
taiga_story_id: Mapped[int] = mapped_column(Integer) taiga_story_id: Mapped[int] = mapped_column(Integer)
@@ -110,3 +362,36 @@ class WorkItem(Base):
status: Mapped[str] = mapped_column(String(32), default="open") status: Mapped[str] = mapped_column(String(32), default="open")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) 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) 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")
View File
+68
View File
@@ -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)
+128
View File
@@ -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
+275
View File
@@ -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 3035 ml/kg range
GOAL_CALORIE_ADJUST = {
"lose": -500,
"maintain": 0,
"gain": 300,
}
PROTEIN_G_PER_KG = {
"lose": 2.2,
"maintain": 1.8,
"gain": 1.8,
}
FAT_G_PER_KG = 1.0
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),
}
+507
View File
@@ -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
+137
View File
@@ -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)
+111
View File
@@ -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
+825
View File
@@ -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,
}
+441
View File
@@ -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,
}
+102
View File
@@ -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)
+28
View File
@@ -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()
View File
+120
View File
@@ -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,
)
+277
View File
@@ -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)
+42
View File
@@ -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)
+58
View File
@@ -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
+251
View File
@@ -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",
}
+70
View File
@@ -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
+53
View File
@@ -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)}
+5
View File
@@ -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)
+525
View File
@@ -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 — только если запрос про погоду/одежду/прогноз.",
}
+64
View File
@@ -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"}
with httpx.Client(timeout=20.0, headers=headers, follow_redirects=True) as client:
response = client.get(url)
response.raise_for_status()
parsed = feedparser.parse(response.content)
source = urlparse(url).netloc or url
items: list[dict[str, str]] = []
for entry in parsed.entries[: self.max_items]:
link = (entry.get("link") or "").strip()
title = (entry.get("title") or "").strip()
if not title:
continue
items.append({
"title": title,
"link": link,
"source": source,
"published": (entry.get("published") or entry.get("updated") or "").strip(),
})
return items
def fetch_headlines(self, limit: int | None = None) -> list[dict[str, str]]:
now = time.time()
if _cache["items"] and now < _cache["expires_at"]:
items = _cache["items"]
else:
merged: list[dict[str, str]] = []
seen_links: set[str] = set()
for url in self.urls:
try:
for item in self._fetch_feed(url):
link = item.get("link") or item["title"]
if link in seen_links:
continue
seen_links.add(link)
merged.append(item)
except Exception:
continue
_cache["items"] = merged
_cache["expires_at"] = now + self.cache_ttl
items = merged
cap = limit or self.max_items
return items[:cap]
+189
View File
@@ -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))
+22
View File
@@ -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()
+151
View File
@@ -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![rofl]({url})\n\n_{prompt}_"
)
set_state(db, f"comfy_rofl_count_{today}", str(count + 1))
set_state(db, "last_comfy_rofl_at", now.isoformat())
finally:
db.close()
+5
View File
@@ -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)
+164
View File
@@ -0,0 +1,164 @@
import asyncio
import logging
import random
from datetime import datetime
from zoneinfo import ZoneInfo
import httpx
from sqlalchemy import select
from app.config import get_settings
from app.db.base import SessionLocal
from app.db.models import User
from app.homelab.comfyui import ComfyUIClient
from app.homelab.context import resolve_timezone
from app.homelab.digest import build_morning_digest
from app.homelab.monitoring import check_netdata_alerts
from app.homelab_scoped.notices import post_chat_notice
from app.homelab.state import get_state, set_state
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 60
_netdata_tick = 0
async def homelab_watcher_loop() -> None:
global _netdata_tick
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick_morning_digest()
await _tick_rofl()
settings = get_settings()
_netdata_tick += WATCH_INTERVAL_SEC
if _netdata_tick >= settings.netdata_poll_interval_sec:
_netdata_tick = 0
await _tick_netdata()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Homelab watcher error")
async def _tick_morning_digest() -> None:
settings = get_settings()
if not settings.morning_digest_enabled:
return
db = SessionLocal()
try:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
digest = build_morning_digest(db, include_news=True)
for user in users:
tz_name = resolve_timezone(db, user.id)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
now = datetime.now(tz)
target_min = settings.morning_digest_hour * 60 + settings.morning_digest_minute
current_min = now.hour * 60 + now.minute
if current_min < target_min or current_min >= target_min + 3:
continue
today = now.date().isoformat()
state_key = f"last_morning_digest_date:{user.id}"
if get_state(db, state_key) == today:
continue
post_chat_notice(digest, user.id)
set_state(db, state_key, today)
finally:
db.close()
async def _tick_netdata() -> None:
db = SessionLocal()
try:
notices = check_netdata_alerts(db)
if not notices:
return
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
for user in users:
for notice in notices:
post_chat_notice(notice, user.id)
finally:
db.close()
async def _comfyui_reachable(base_url: str) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(3.0, connect=2.0)) as client:
response = await client.get(f"{base_url.rstrip('/')}/system_stats")
return response.status_code < 500
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError):
return False
async def _tick_rofl() -> None:
settings = get_settings()
if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled:
return
db = SessionLocal()
try:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
for user in users:
tz_name = resolve_timezone(db, user.id)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
now = datetime.now(tz)
last_raw = get_state(db, f"last_comfy_rofl_at:{user.id}")
if last_raw:
try:
last_at = datetime.fromisoformat(last_raw)
if last_at.tzinfo is None:
last_at = last_at.replace(tzinfo=tz)
if (now - last_at).total_seconds() < settings.comfyui_rofl_min_interval_hours * 3600:
continue
except ValueError:
pass
if random.random() > settings.comfyui_rofl_probability:
continue
today = now.date().isoformat()
count_key = f"comfy_rofl_count_{today}:{user.id}"
count_raw = get_state(db, count_key) or "0"
try:
count = int(count_raw)
except ValueError:
count = 0
if count >= settings.comfyui_rofl_max_per_day:
continue
client = ComfyUIClient()
if not await _comfyui_reachable(client.base_url):
continue
prompt = client.random_rofl_prompt()
try:
result = await asyncio.wait_for(
client.generate_image(prompt),
timeout=settings.comfyui_timeout_sec + 15,
)
except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc:
logger.warning("Rofl image skipped (ComfyUI): %s", exc)
continue
if not result.get("ok"):
logger.warning("Rofl image failed: %s", result.get("error"))
continue
url = result.get("url", "")
post_chat_notice(
f"🎨 **Рофл дня**\n\n![rofl]({url})\n\n_{prompt}_",
user.id,
)
set_state(db, count_key, str(count + 1))
set_state(db, f"last_comfy_rofl_at:{user.id}", now.isoformat())
finally:
db.close()
+62
View File
@@ -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"),
}
+89
View File
@@ -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,
}
+72
View File
@@ -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
+269 -7
View File
@@ -1,4 +1,5 @@
import json import json
import logging
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Any from typing import Any
@@ -6,34 +7,147 @@ from openai import AsyncOpenAI
from app.config import get_settings from app.config import get_settings
logger = logging.getLogger(__name__)
class LLMClient: class LLMClient:
def __init__(self) -> None: def __init__(self) -> None:
settings = get_settings() settings = get_settings()
self.model = settings.openrouter_model self.tools_enabled = settings.openrouter_tools_enabled
self.client = AsyncOpenAI( self.client = AsyncOpenAI(
api_key=settings.openrouter_api_key, api_key=settings.openrouter_api_key,
base_url=settings.openrouter_base_url, base_url=settings.openrouter_base_url,
) )
def _runtime(self) -> tuple[str, str, str]:
from app.db.base import SessionLocal
from app.settings.service import SettingsService
settings = get_settings()
db = SessionLocal()
try:
svc = SettingsService(db)
model = str(svc.get_effective("openrouter_model"))
extract = str(svc.get_effective("memory_extract_model"))
effort = str(svc.get_effective("openrouter_reasoning_effort")).strip().lower()
return model, extract, effort
finally:
db.close()
def _vision_model_runtime(self) -> str:
from app.db.base import SessionLocal
from app.settings.service import SettingsService
db = SessionLocal()
try:
return str(SettingsService(db).get_effective("openrouter_vision_model"))
finally:
db.close()
@property
def model(self) -> str:
return self._runtime()[0]
@property
def memory_extract_model(self) -> str:
return self._runtime()[1]
@property
def reasoning_effort(self) -> str:
return self._runtime()[2]
@property
def vision_model(self) -> str:
return self._vision_model_runtime()
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( async def stream_chat(
self, self,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None, tools: list[dict[str, Any]] | None = None,
*,
model: str | None = None,
) -> AsyncIterator[dict[str, Any]]: ) -> AsyncIterator[dict[str, Any]]:
use_tools = bool(tools) and self.tools_enabled
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"model": self.model, "model": model or self.model,
"messages": messages, "messages": messages,
"stream": True, "stream": True,
"temperature": 0.7, "temperature": 0.7,
} }
if tools: if use_tools:
kwargs["tools"] = 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) 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]] = {} tool_calls: dict[int, dict[str, Any]] = {}
reasoning_parts: list[str] = []
reasoning_details: list[Any] = []
try:
async for chunk in stream: async for chunk in stream:
if not chunk.choices: if not chunk.choices:
continue continue
@@ -44,6 +158,12 @@ class LLMClient:
if delta.content: if delta.content:
yield {"type": "content", "content": 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: if delta.tool_calls:
for tool_call in delta.tool_calls: for tool_call in delta.tool_calls:
idx = tool_call.index idx = tool_call.index
@@ -61,30 +181,94 @@ class LLMClient:
if tool_call.function.arguments: if tool_call.function.arguments:
tool_calls[idx]["function"]["arguments"] += 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: 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: if tool_calls:
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())} 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} 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( async def complete(
self, self,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None, 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]: ) -> dict[str, Any]:
use_tools = bool(tools) and self.tools_enabled and not for_extraction
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"model": self.model, "model": model or self.model,
"messages": messages, "messages": messages,
"temperature": 0.7, "temperature": temperature,
} }
if tools: if use_tools:
kwargs["tools"] = 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) 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 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] = { result: dict[str, Any] = {
"content": message.content or "", "content": content,
"tool_calls": [], "tool_calls": [],
"reasoning": reasoning,
"reasoning_details": getattr(message, "reasoning_details", None),
} }
if message.tool_calls: if message.tool_calls:
@@ -102,6 +286,43 @@ class LLMClient:
return result 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 @staticmethod
def parse_tool_arguments(arguments: str) -> dict[str, Any]: def parse_tool_arguments(arguments: str) -> dict[str, Any]:
if not arguments: if not arguments:
@@ -110,3 +331,44 @@ class LLMClient:
return json.loads(arguments) return json.loads(arguments)
except json.JSONDecodeError: except json.JSONDecodeError:
return {} 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]
+29 -3
View File
@@ -7,17 +7,43 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router from app.api.routes import api_router
from app.config import get_settings from app.config import get_settings
from app.db.base import init_db from app.db.base import init_db
from app.fitness.watcher import fitness_watcher_loop
from app.homelab_scoped.watcher import homelab_watcher_loop
from app.pomodoro.watcher import pomodoro_watcher_loop from app.pomodoro.watcher import pomodoro_watcher_loop
from app.reminders_scoped.watcher import reminders_watcher_loop
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
init_db() init_db()
watcher_task = asyncio.create_task(pomodoro_watcher_loop()) from app.db.migrate_fitness import run_fitness_migrations
run_fitness_migrations()
from app.db.migrate_multi_user import run_multi_user_migrations
run_multi_user_migrations()
settings = get_settings()
if settings.rag_enabled:
from app.rag.store import ensure_collections
ensure_collections()
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
fitness_task = asyncio.create_task(fitness_watcher_loop())
homelab_task = asyncio.create_task(homelab_watcher_loop())
reminders_task = asyncio.create_task(reminders_watcher_loop())
yield yield
watcher_task.cancel() pomodoro_task.cancel()
fitness_task.cancel()
homelab_task.cancel()
reminders_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await watcher_task 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: def create_app() -> FastAPI:
View File
+89
View File
@@ -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)
+153
View File
@@ -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)}
+40
View File
@@ -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
+311
View File
@@ -0,0 +1,311 @@
import asyncio
import json
import threading
from concurrent.futures import ThreadPoolExecutor
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 _run_async(coro):
"""Run coroutine from sync code; safe inside FastAPI's running event loop."""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
with ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
@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 = self._run_async(_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,
}
+23 -32
View File
@@ -1,13 +1,17 @@
from sqlalchemy import select import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.character.service import CharacterService from app.character.service import CharacterService
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
from app.chat.notices import format_phase_completed_notice from app.chat.notices import format_phase_completed_notice
from app.db.models import ChatSession, Message, PomodoroSession from app.db.models import PomodoroSession
from app.llm.client import LLMClient from app.llm.client import LLMClient
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
from app.pomodoro.service import PomodoroService from app.pomodoro.service import PomodoroService
logger = logging.getLogger(__name__)
PHASE_LABELS = { PHASE_LABELS = {
PHASE_WORK: "работа", PHASE_WORK: "работа",
PHASE_SHORT_BREAK: "короткий перерыв", PHASE_SHORT_BREAK: "короткий перерыв",
@@ -16,24 +20,13 @@ PHASE_LABELS = {
class PomodoroCompletionHandler: class PomodoroCompletionHandler:
def __init__(self, db: Session): def __init__(self, db: Session, user_id: int):
self.db = db self.db = db
self.pomodoro = PomodoroService(db) self.user_id = user_id
self.cycle = CycleManager(db) self.pomodoro = PomodoroService(db, user_id)
self.cycle = CycleManager(db, user_id)
self.llm = LLMClient() self.llm = LLMClient()
self.character = CharacterService() self.character = CharacterService(db, user_id)
def _latest_chat_session_id(self) -> int | None:
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
session = self.db.scalar(stmt)
return session.id if session else None
def _save_chat_message(self, session_id: int, role: str, content: str) -> None:
self.db.add(Message(session_id=session_id, role=role, content=content))
chat = self.db.get(ChatSession, session_id)
if chat:
chat.updated_at = chat.updated_at # trigger onupdate
self.db.commit()
async def _generate_llm_comment( async def _generate_llm_comment(
self, self,
@@ -53,15 +46,17 @@ class PomodoroCompletionHandler:
Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ. Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ.
Следующая фаза: {next_label}. Следующая фаза: {next_label}.
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown.""" Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown и без эмодзи."""
result = await self.llm.complete( result = await self.llm.complete(
[ [
{"role": "system", "content": system}, {"role": "system", "content": system},
{"role": "user", "content": user_prompt}, {"role": "user", "content": user_prompt},
] ],
temperature=0.8,
visible_reply=True,
) )
return (result.get("content") or "").strip() or "Фаза завершена! Отличная работа." return (result.get("content") or "").strip() or "Фаза завершена. Хорошая работа."
def _resolve_next_phase(self, session: PomodoroSession) -> str | None: def _resolve_next_phase(self, session: PomodoroSession) -> str | None:
phase = session.phase phase = session.phase
@@ -82,20 +77,16 @@ class PomodoroCompletionHandler:
next_phase = self._resolve_next_phase(session) next_phase = self._resolve_next_phase(session)
notice = format_phase_completed_notice(session, next_phase) notice = format_phase_completed_notice(session, next_phase)
post_notice_to_latest_chat(notice, self.user_id)
chat_id = self._latest_chat_session_id() try:
if not chat_id:
chat = ChatSession(title="Помидоро")
self.db.add(chat)
self.db.commit()
self.db.refresh(chat)
chat_id = chat.id
self._save_chat_message(chat_id, "notice", notice)
comment = await self._generate_llm_comment(session, next_phase) comment = await self._generate_llm_comment(session, next_phase)
self._save_chat_message(chat_id, "assistant", comment) if comment:
post_character_comment_to_latest_chat(comment, self.user_id)
except Exception:
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase)
self.cycle.bump_notify_seq() self.cycle.bump_notify_seq()
self.pomodoro.mark_notified(session) self.pomodoro.mark_notified(session)
self.pomodoro.advance_after_completion(session) self.pomodoro.advance_after_completion(session)
logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase)
+4 -3
View File
@@ -9,13 +9,14 @@ 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: def get(self) -> PomodoroCycle:
cycle = self.db.scalar(select(PomodoroCycle).limit(1)) cycle = self.db.scalar(select(PomodoroCycle).where(PomodoroCycle.user_id == self.user_id).limit(1))
if not cycle: if not cycle:
cycle = PomodoroCycle() cycle = PomodoroCycle(user_id=self.user_id)
self.db.add(cycle) self.db.add(cycle)
self.db.commit() self.db.commit()
self.db.refresh(cycle) self.db.refresh(cycle)
+13 -4
View File
@@ -17,14 +17,18 @@ def _utcnow() -> datetime:
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: def _get_active(self) -> PomodoroSession | None:
stmt = ( stmt = (
select(PomodoroSession) select(PomodoroSession)
.where(PomodoroSession.status.in_(("running", "paused"))) .where(
PomodoroSession.user_id == self.user_id,
PomodoroSession.status.in_(("running", "paused")),
)
.order_by(PomodoroSession.id.desc()) .order_by(PomodoroSession.id.desc())
.limit(1) .limit(1)
) )
@@ -101,6 +105,7 @@ class PomodoroService:
note = task_note if task_note is not None else cycle.task_note note = task_note if task_note is not None else cycle.task_note
session = PomodoroSession( session = PomodoroSession(
user_id=self.user_id,
status="running", status="running",
phase=phase, phase=phase,
duration_min=duration, duration_min=duration,
@@ -230,6 +235,7 @@ class PomodoroService:
stmt = ( stmt = (
select(PomodoroSession) select(PomodoroSession)
.where( .where(
PomodoroSession.user_id == self.user_id,
PomodoroSession.status == "completed", PomodoroSession.status == "completed",
PomodoroSession.completed.is_(True), PomodoroSession.completed.is_(True),
PomodoroSession.completion_notified.is_(False), PomodoroSession.completion_notified.is_(False),
@@ -266,7 +272,10 @@ class PomodoroService:
def history(self, limit: int = 20) -> list[dict]: def history(self, limit: int = 20) -> list[dict]:
stmt = ( stmt = (
select(PomodoroSession) select(PomodoroSession)
.where(PomodoroSession.status.in_(("completed", "cancelled"))) .where(
PomodoroSession.user_id == self.user_id,
PomodoroSession.status.in_(("completed", "cancelled")),
)
.order_by(PomodoroSession.finished_at.desc()) .order_by(PomodoroSession.finished_at.desc())
.limit(limit) .limit(limit)
) )
+8 -5
View File
@@ -1,7 +1,10 @@
import asyncio import asyncio
import logging import logging
from sqlalchemy import select
from app.db.base import SessionLocal from app.db.base import SessionLocal
from app.db.models import User
from app.pomodoro.completion import PomodoroCompletionHandler from app.pomodoro.completion import PomodoroCompletionHandler
from app.pomodoro.service import PomodoroService from app.pomodoro.service import PomodoroService
@@ -24,14 +27,14 @@ async def pomodoro_watcher_loop() -> None:
async def _tick() -> None: async def _tick() -> None:
db = SessionLocal() db = SessionLocal()
try: try:
service = PomodoroService(db) users = db.scalars(select(User).where(User.is_active.is_(True))).all()
for user in users:
service = PomodoroService(db, user.id)
service.get_status() service.get_status()
pending = service.get_pending_completions() pending = service.get_pending_completions()
if not pending: if not pending:
return continue
handler = PomodoroCompletionHandler(db, user.id)
handler = PomodoroCompletionHandler(db)
for session in pending: for session in pending:
await handler.process(session) await handler.process(session)
finally: finally:
+24 -2
View File
@@ -1,3 +1,4 @@
import time
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -8,11 +9,32 @@ from app.projects.service import ProjectService
MAX_PROJECTS_IN_CONTEXT = 20 MAX_PROJECTS_IN_CONTEXT = 20
MAX_OPEN_PER_PROJECT = 8 MAX_OPEN_PER_PROJECT = 8
PROJECTS_CACHE_SEC = 120
_cache: dict[int, dict[str, Any]] = {}
def get_projects_snapshot(db: Session) -> dict[str, Any]: def invalidate_projects_snapshot_cache(user_id: int | None = None) -> None:
if user_id is None:
_cache.clear()
else:
_cache.pop(user_id, None)
def get_projects_snapshot(db: Session, user_id: int, *, force: bool = False) -> dict[str, Any]:
now = time.time()
entry = _cache.get(user_id)
if not force and entry and now < entry.get("expires_at", 0):
return entry["data"]
snapshot = _fetch_projects_snapshot(db, user_id)
_cache[user_id] = {"data": snapshot, "expires_at": now + PROJECTS_CACHE_SEC}
return snapshot
def _fetch_projects_snapshot(db: Session, user_id: int) -> dict[str, Any]:
settings = get_settings() settings = get_settings()
service = ProjectService(db) service = ProjectService(db, user_id)
if not settings.taiga_configured: if not settings.taiga_configured:
return {"configured": False, "projects": [], "open_items": [], "taiga_open": []} return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
+36 -10
View File
@@ -18,8 +18,9 @@ from app.projects.structuring import (
class ProjectService: class ProjectService:
def __init__(self, db: Session): def __init__(self, db: Session, user_id: int):
self.db = db self.db = db
self.user_id = user_id
self.settings = get_settings() self.settings = get_settings()
def sync_taiga_projects(self) -> list[dict[str, Any]]: def sync_taiga_projects(self) -> list[dict[str, Any]]:
@@ -56,7 +57,11 @@ class ProjectService:
def list_projects(self) -> list[dict[str, Any]]: def list_projects(self) -> list[dict[str, Any]]:
stmt = ( stmt = (
select(TaigaProject, ProjectBinding) select(TaigaProject, ProjectBinding)
.outerjoin(ProjectBinding, ProjectBinding.taiga_slug == TaigaProject.slug) .outerjoin(
ProjectBinding,
(ProjectBinding.taiga_slug == TaigaProject.slug)
& (ProjectBinding.user_id == self.user_id),
)
.order_by(TaigaProject.name) .order_by(TaigaProject.name)
) )
rows = self.db.execute(stmt).all() rows = self.db.execute(stmt).all()
@@ -86,7 +91,7 @@ class ProjectService:
raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.") raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.")
binding = self.db.scalar( binding = self.db.scalar(
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_slug) select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_slug)
) )
if binding: if binding:
binding.gitea_owner = gitea_owner binding.gitea_owner = gitea_owner
@@ -94,6 +99,7 @@ class ProjectService:
binding.default_branch = default_branch binding.default_branch = default_branch
else: else:
binding = ProjectBinding( binding = ProjectBinding(
user_id=self.user_id,
taiga_slug=taiga_slug, taiga_slug=taiga_slug,
gitea_owner=gitea_owner, gitea_owner=gitea_owner,
gitea_repo=gitea_repo, gitea_repo=gitea_repo,
@@ -123,7 +129,7 @@ class ProjectService:
taiga_proj = projects[0] taiga_proj = projects[0]
binding = self.db.scalar( binding = self.db.scalar(
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_proj.slug) select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_proj.slug)
) )
return taiga_proj, binding return taiga_proj, binding
@@ -199,6 +205,7 @@ class ProjectService:
) )
work_item = WorkItem( work_item = WorkItem(
user_id=self.user_id,
taiga_slug=taiga_proj.slug, taiga_slug=taiga_proj.slug,
taiga_project_id=taiga_proj.taiga_id, taiga_project_id=taiga_proj.taiga_id,
taiga_story_id=story["id"], taiga_story_id=story["id"],
@@ -256,6 +263,7 @@ class ProjectService:
linked_items = self.db.scalars( linked_items = self.db.scalars(
select(WorkItem).where( select(WorkItem).where(
WorkItem.user_id == self.user_id,
WorkItem.gitea_owner == owner, WorkItem.gitea_owner == owner,
WorkItem.gitea_repo == repo, WorkItem.gitea_repo == repo,
WorkItem.status == "open", WorkItem.status == "open",
@@ -278,6 +286,7 @@ class ProjectService:
for item in linked_items: for item in linked_items:
if item.gitea_issue_number == gitea_num: if item.gitea_issue_number == gitea_num:
try:
self._close_work_item(item, taiga) self._close_work_item(item, taiga)
results.append( results.append(
{ {
@@ -285,22 +294,35 @@ class ProjectService:
"closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}", "closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}",
} }
) )
except Exception as exc:
results.append(
{"error": f"work item {item.id} (gitea #{gitea_num}): {exc}"}
)
for ref in taiga_story_refs: for ref in taiga_story_refs:
project_id = self._project_id_for_ref(owner, repo, ref, linked_items) project_id = self._project_id_for_ref(owner, repo, ref, linked_items)
if not project_id: if not project_id:
continue continue
story = taiga.get_by_ref(project_id, ref, kind="userstory") story = taiga.get_by_ref(project_id, ref, kind="userstory")
if story: if story and not story.get("is_closed"):
try:
taiga.close_userstory(story["id"], project_id) taiga.close_userstory(story["id"], project_id)
for item in linked_items:
if item.taiga_story_ref == ref:
self._close_work_item(item, taiga, close_gitea=bool(gitea))
results.append({"commit": sha, "closed": f"taiga #{ref}"}) results.append({"commit": sha, "closed": f"taiga #{ref}"})
except Exception as exc:
results.append({"error": f"taiga #{ref}: {exc}"})
for item in linked_items:
if item.taiga_story_ref == ref and item.status != "closed":
try:
self._close_work_item(item, taiga, close_gitea=bool(gitea))
except Exception as exc:
results.append(
{"error": f"work item {item.id} (taiga #{ref}): {exc}"}
)
for ref in taiga_task_refs: for ref in taiga_task_refs:
binding = self.db.scalar( binding = self.db.scalar(
select(ProjectBinding).where( select(ProjectBinding).where(
ProjectBinding.user_id == self.user_id,
ProjectBinding.gitea_owner == owner, ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo, ProjectBinding.gitea_repo == repo,
) )
@@ -313,9 +335,12 @@ class ProjectService:
if not taiga_proj: if not taiga_proj:
continue continue
task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task") task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task")
if task: if task and not task.get("is_closed"):
try:
taiga.close_task(task["id"], taiga_proj.taiga_id) taiga.close_task(task["id"], taiga_proj.taiga_id)
results.append({"commit": sha, "closed": f"taiga task #{ref}"}) results.append({"commit": sha, "closed": f"taiga task #{ref}"})
except Exception as exc:
results.append({"error": f"taiga task #{ref}: {exc}"})
self.db.commit() self.db.commit()
return results return results
@@ -332,6 +357,7 @@ class ProjectService:
return item.taiga_project_id return item.taiga_project_id
binding = self.db.scalar( binding = self.db.scalar(
select(ProjectBinding).where( select(ProjectBinding).where(
ProjectBinding.user_id == self.user_id,
ProjectBinding.gitea_owner == owner, ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo, ProjectBinding.gitea_repo == repo,
) )
@@ -424,7 +450,7 @@ class ProjectService:
} }
def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]: 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) stmt = select(WorkItem).where(WorkItem.user_id == self.user_id).order_by(WorkItem.created_at.desc()).limit(limit)
if status: if status:
stmt = stmt.where(WorkItem.status == status) stmt = stmt.where(WorkItem.status == status)
items = self.db.scalars(stmt).all() items = self.db.scalars(stmt).all()
+5
View File
@@ -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"]
+20
View File
@@ -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
+10
View File
@@ -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)
+152
View File
@@ -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())
+67
View File
@@ -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
+64
View File
@@ -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,
)
+3
View File
@@ -0,0 +1,3 @@
from app.reminders.service import RemindersService
__all__ = ["RemindersService"]
+74
View File
@@ -0,0 +1,74 @@
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
from app.db.models import Reminder
from app.llm.client import LLMClient
from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local
logger = logging.getLogger(__name__)
def format_reminder_notice(row: Reminder) -> str:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_"
if row.notes:
notice += f"\n{row.notes}"
return notice
class ReminderCompletionHandler:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self.llm = LLMClient()
self.character = CharacterService(db, user_id)
async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str:
notes_part = f"\nЗаметки: {row.notes}" if row.notes else ""
rec_part = ""
if row.recurrence and row.recurrence != RECURRENCE_NONE:
rec_part = f"\nПовтор: {row.recurrence}"
system = self.character.get_system_prompt()
user_prompt = f"""Сработало напоминание.
Заголовок: {row.title}
Время: {local_when}{notes_part}{rec_part}
Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи."""
result = await self.llm.complete(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
],
temperature=0.8,
visible_reply=True,
)
return (result.get("content") or "").strip() or f"Напоминание: {row.title}"
def _mark_fired(self, row: Reminder, now: datetime) -> None:
row.last_fired_at = now
if row.recurrence == RECURRENCE_NONE:
row.completed_at = now
row.enabled = False
else:
row.due_at = _advance_due(row.due_at, row.recurrence)
row.last_fired_at = None
row.updated_at = now
async def process(self, row: Reminder) -> None:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
post_notice_to_latest_chat(format_reminder_notice(row), self.user_id)
try:
comment = await self._generate_llm_comment(row, local_when)
if comment:
post_character_comment_to_latest_chat(comment, self.user_id)
except Exception:
logger.exception("Reminder LLM comment failed (id=%s)", row.id)
self._mark_fired(row, datetime.now(timezone.utc))
+33
View File
@@ -0,0 +1,33 @@
from typing import Any
from sqlalchemy.orm import Session
from app.reminders.service import RemindersService
MAX_IN_CONTEXT = 10
def get_reminders_snapshot(db: Session, user_id: int) -> dict[str, Any]:
return RemindersService(db, user_id).snapshot()
def format_reminders_context(snapshot: dict[str, Any]) -> str:
lines = ["[Напоминания]"]
upcoming = snapshot.get("upcoming") or []
tz = snapshot.get("timezone", "Europe/Moscow")
if not upcoming:
lines.append(
"Ближайших напоминаний нет. "
"create_reminder для «напомни через 15 минут», «завтра утром», точной даты."
)
return "\n".join(lines)
lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.")
for item in upcoming[:MAX_IN_CONTEXT]:
rec = item.get("recurrence", "none")
rec_label = f" · повтор: {rec}" if rec and rec != "none" else ""
lines.append(
f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}"
)
return "\n".join(lines)
+50
View File
@@ -0,0 +1,50 @@
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import Reminder, User
from app.reminders.completion import ReminderCompletionHandler
from app.reminders.notify import bump_notify_seq
logger = logging.getLogger(__name__)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def get_due_reminders(db: Session, user_id: int) -> list[Reminder]:
now = _utcnow()
stmt = (
select(Reminder)
.where(
Reminder.user_id == user_id,
Reminder.enabled.is_(True),
Reminder.completed_at.is_(None),
Reminder.due_at <= now,
)
.order_by(Reminder.due_at.asc())
)
rows = list(db.scalars(stmt).all())
return [row for row in rows if not (row.last_fired_at and row.last_fired_at >= row.due_at)]
async def process_due_reminders(db: Session) -> int:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
total = 0
for user in users:
due = get_due_reminders(db, user.id)
if not due:
continue
handler = ReminderCompletionHandler(db, user.id)
for row in due:
await handler.process(row)
total += len(due)
if total:
db.commit()
bump_notify_seq(db)
logger.info("Reminders fired: %d", total)
return total
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy.orm import Session
from app.homelab.state import get_state, set_state
NOTIFY_SEQ_KEY = "reminders_notify_seq"
def get_notify_seq(db: Session) -> int:
raw = get_state(db, NOTIFY_SEQ_KEY)
try:
return int(raw or 0)
except ValueError:
return 0
def bump_notify_seq(db: Session) -> int:
seq = get_notify_seq(db) + 1
set_state(db, NOTIFY_SEQ_KEY, str(seq))
return seq
+245
View File
@@ -0,0 +1,245 @@
import calendar
from datetime import datetime, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import Reminder
from app.memory.service import MemoryService
from app.reminders.notify import bump_notify_seq, get_notify_seq
RECURRENCE_NONE = "none"
RECURRENCE_DAILY = "daily"
RECURRENCE_WEEKLY = "weekly"
RECURRENCE_MONTHLY = "monthly"
RECURRENCE_YEARLY = "yearly"
VALID_RECURRENCE = frozenset({
RECURRENCE_NONE,
RECURRENCE_DAILY,
RECURRENCE_WEEKLY,
RECURRENCE_MONTHLY,
RECURRENCE_YEARLY,
})
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _parse_due_at(raw: str, tz_name: str) -> datetime:
clean = raw.strip()
if not clean:
raise ValueError("due_at не может быть пустым")
try:
dt = datetime.fromisoformat(clean.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"Неверный формат даты: {raw}") from exc
if dt.tzinfo is None:
try:
dt = dt.replace(tzinfo=ZoneInfo(tz_name))
except Exception:
dt = dt.replace(tzinfo=ZoneInfo("Europe/Moscow"))
return dt.astimezone(timezone.utc)
def _advance_due(due_at: datetime, recurrence: str) -> datetime:
if recurrence == RECURRENCE_DAILY:
return due_at + timedelta(days=1)
if recurrence == RECURRENCE_WEEKLY:
return due_at + timedelta(weeks=1)
if recurrence == RECURRENCE_MONTHLY:
month = due_at.month + 1
year = due_at.year
if month > 12:
month = 1
year += 1
day = min(due_at.day, calendar.monthrange(year, month)[1])
return due_at.replace(year=year, month=month, day=day)
if recurrence == RECURRENCE_YEARLY:
year = due_at.year + 1
day = min(due_at.day, calendar.monthrange(year, due_at.month)[1])
return due_at.replace(year=year, day=day)
return due_at
def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str:
try:
local = dt.astimezone(ZoneInfo(tz_name))
except Exception:
local = dt.astimezone(ZoneInfo("Europe/Moscow"))
if all_day:
return local.strftime("%Y-%m-%d")
return local.strftime("%Y-%m-%d %H:%M")
class RemindersService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
def _tz(self) -> str:
profile = MemoryService(self.db, self.user_id).get_profile()
tz = (profile.get("timezone") or "").strip()
return tz or "Europe/Moscow"
def _to_dict(self, row: Reminder) -> dict[str, Any]:
tz = row.timezone or self._tz()
return {
"id": row.id,
"title": row.title,
"notes": row.notes,
"due_at": row.due_at.isoformat(),
"due_at_local": _format_local(row.due_at, tz, all_day=row.all_day),
"all_day": row.all_day,
"recurrence": row.recurrence,
"enabled": row.enabled,
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
"timezone": tz,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def snapshot(self) -> dict[str, Any]:
upcoming = self.list_upcoming(limit=12)
return {
"notify_seq": get_notify_seq(self.db),
"upcoming": upcoming,
"upcoming_count": len(upcoming),
"timezone": self._tz(),
}
def list_upcoming(self, *, limit: int = 30) -> list[dict[str, Any]]:
stmt = (
select(Reminder)
.where(
Reminder.user_id == self.user_id,
Reminder.enabled.is_(True),
Reminder.completed_at.is_(None),
)
.order_by(Reminder.due_at.asc())
.limit(limit)
)
return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
def list_in_range(
self,
*,
date_from: datetime,
date_to: datetime,
) -> list[dict[str, Any]]:
stmt = (
select(Reminder)
.where(
Reminder.user_id == self.user_id,
Reminder.enabled.is_(True),
Reminder.completed_at.is_(None),
Reminder.due_at >= date_from,
Reminder.due_at < date_to,
)
.order_by(Reminder.due_at.asc())
)
return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
def get(self, reminder_id: int) -> dict[str, Any] | None:
row = self.db.get(Reminder, reminder_id)
if not row or row.user_id != self.user_id:
return None
return self._to_dict(row)
def create(
self,
*,
title: str,
due_at: str,
notes: str = "",
all_day: bool = False,
recurrence: str = RECURRENCE_NONE,
) -> dict[str, Any]:
clean_title = title.strip()
if not clean_title:
raise ValueError("Название напоминания не может быть пустым")
rec = (recurrence or RECURRENCE_NONE).strip().lower()
if rec not in VALID_RECURRENCE:
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
tz = self._tz()
due = _parse_due_at(due_at, tz)
row = Reminder(
user_id=self.user_id,
title=clean_title,
notes=notes.strip(),
due_at=due,
all_day=all_day,
recurrence=rec,
timezone=tz,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
bump_notify_seq(self.db)
return {"ok": True, "reminder": self._to_dict(row), "created": True}
def update(
self,
reminder_id: int,
*,
title: str | None = None,
due_at: str | None = None,
notes: str | None = None,
all_day: bool | None = None,
recurrence: str | None = None,
enabled: bool | None = None,
) -> dict[str, Any]:
row = self.db.get(Reminder, reminder_id)
if not row or row.user_id != self.user_id:
raise ValueError("Напоминание не найдено")
if title is not None:
clean = title.strip()
if not clean:
raise ValueError("Название не может быть пустым")
row.title = clean
if notes is not None:
row.notes = notes.strip()
if due_at is not None:
row.due_at = _parse_due_at(due_at, row.timezone or self._tz())
row.last_fired_at = None
if all_day is not None:
row.all_day = all_day
if recurrence is not None:
rec = recurrence.strip().lower()
if rec not in VALID_RECURRENCE:
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
row.recurrence = rec
if enabled is not None:
row.enabled = enabled
row.updated_at = _utcnow()
self.db.commit()
self.db.refresh(row)
bump_notify_seq(self.db)
return {"ok": True, "reminder": self._to_dict(row)}
def delete(self, reminder_id: int) -> dict[str, Any]:
row = self.db.get(Reminder, reminder_id)
if not row or row.user_id != self.user_id:
raise ValueError("Напоминание не найдено")
title = row.title
self.db.delete(row)
self.db.commit()
bump_notify_seq(self.db)
return {"ok": True, "deleted_id": reminder_id, "title": title}
def complete(self, reminder_id: int) -> dict[str, Any]:
row = self.db.get(Reminder, reminder_id)
if not row or row.user_id != self.user_id:
raise ValueError("Напоминание не найдено")
now = _utcnow()
row.completed_at = now
row.enabled = False
row.updated_at = now
self.db.commit()
self.db.refresh(row)
bump_notify_seq(self.db)
return {"ok": True, "reminder": self._to_dict(row)}
+31
View File
@@ -0,0 +1,31 @@
import asyncio
import logging
from app.config import get_settings
from app.db.base import SessionLocal
from app.reminders.fire import process_due_reminders
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 30
async def reminders_watcher_loop() -> None:
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
if not get_settings().reminders_enabled:
continue
await _tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Reminders watcher error")
async def _tick() -> None:
db = SessionLocal()
try:
await process_due_reminders(db)
finally:
db.close()
+3
View File
@@ -0,0 +1,3 @@
from app.reminders_scoped.service import RemindersService
__all__ = ["RemindersService"]
@@ -0,0 +1,74 @@
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
from app.db.models import Reminder
from app.llm.client import LLMClient
from app.reminders_scoped.service import RECURRENCE_NONE, _advance_due, _format_local
logger = logging.getLogger(__name__)
def format_reminder_notice(row: Reminder) -> str:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_"
if row.notes:
notice += f"\n{row.notes}"
return notice
class ReminderCompletionHandler:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self.llm = LLMClient()
self.character = CharacterService(db, user_id)
async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str:
notes_part = f"\nЗаметки: {row.notes}" if row.notes else ""
rec_part = ""
if row.recurrence and row.recurrence != RECURRENCE_NONE:
rec_part = f"\nПовтор: {row.recurrence}"
system = self.character.get_system_prompt()
user_prompt = f"""Сработало напоминание.
Заголовок: {row.title}
Время: {local_when}{notes_part}{rec_part}
Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи."""
result = await self.llm.complete(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
],
temperature=0.8,
visible_reply=True,
)
return (result.get("content") or "").strip() or f"Напоминание: {row.title}"
def _mark_fired(self, row: Reminder, now: datetime) -> None:
row.last_fired_at = now
if row.recurrence == RECURRENCE_NONE:
row.completed_at = now
row.enabled = False
else:
row.due_at = _advance_due(row.due_at, row.recurrence)
row.last_fired_at = None
row.updated_at = now
async def process(self, row: Reminder) -> None:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
post_notice_to_latest_chat(format_reminder_notice(row), self.user_id)
try:
comment = await self._generate_llm_comment(row, local_when)
if comment:
post_character_comment_to_latest_chat(comment, self.user_id)
except Exception:
logger.exception("Reminder LLM comment failed (id=%s)", row.id)
self._mark_fired(row, datetime.now(timezone.utc))
+33
View File
@@ -0,0 +1,33 @@
from typing import Any
from sqlalchemy.orm import Session
from app.reminders_scoped.service import RemindersService
MAX_IN_CONTEXT = 10
def get_reminders_snapshot(db: Session, user_id: int) -> dict[str, Any]:
return RemindersService(db, user_id).snapshot()
def format_reminders_context(snapshot: dict[str, Any]) -> str:
lines = ["[Напоминания]"]
upcoming = snapshot.get("upcoming") or []
tz = snapshot.get("timezone", "Europe/Moscow")
if not upcoming:
lines.append(
"Ближайших напоминаний нет. "
"create_reminder для «напомни через 15 минут», «завтра утром», точной даты."
)
return "\n".join(lines)
lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.")
for item in upcoming[:MAX_IN_CONTEXT]:
rec = item.get("recurrence", "none")
rec_label = f" · повтор: {rec}" if rec and rec != "none" else ""
lines.append(
f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}"
)
return "\n".join(lines)
+50
View File
@@ -0,0 +1,50 @@
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import Reminder, User
from app.reminders_scoped.completion import ReminderCompletionHandler
from app.reminders.notify import bump_notify_seq
logger = logging.getLogger(__name__)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def get_due_reminders(db: Session, user_id: int) -> list[Reminder]:
now = _utcnow()
stmt = (
select(Reminder)
.where(
Reminder.user_id == user_id,
Reminder.enabled.is_(True),
Reminder.completed_at.is_(None),
Reminder.due_at <= now,
)
.order_by(Reminder.due_at.asc())
)
rows = list(db.scalars(stmt).all())
return [row for row in rows if not (row.last_fired_at and row.last_fired_at >= row.due_at)]
async def process_due_reminders(db: Session) -> int:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
total = 0
for user in users:
due = get_due_reminders(db, user.id)
if not due:
continue
handler = ReminderCompletionHandler(db, user.id)
for row in due:
await handler.process(row)
total += len(due)
if total:
db.commit()
bump_notify_seq(db)
logger.info("Reminders fired: %d", total)
return total

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