added RAG, Multiuser, TG bot

This commit is contained in:
2026-06-13 20:20:56 +00:00
parent 66e1b0e29e
commit c8a9429bed
142 changed files with 19901 additions and 8790 deletions
+121 -106
View File
@@ -1,106 +1,121 @@
# Server (internal bind inside containers) # Server (internal bind inside containers)
HOST=0.0.0.0 HOST=0.0.0.0
BACKEND_INTERNAL_PORT=8080 BACKEND_INTERNAL_PORT=8080
FRONTEND_INTERNAL_PORT=80 FRONTEND_INTERNAL_PORT=80
# External ports on the host (docker compose publish) # External ports on the host (docker compose publish)
BACKEND_PORT=8080 BACKEND_PORT=8080
FRONTEND_PORT=3080 FRONTEND_PORT=3080
VITE_DEV_PORT=5173 VITE_DEV_PORT=5173
# OpenRouter # OpenRouter
OPENROUTER_API_KEY=sk-or-v1-your-key-here OPENROUTER_API_KEY=sk-or-v1-your-key-here
OPENROUTER_MODEL=deepseek/deepseek-chat OPENROUTER_MODEL=deepseek/deepseek-chat
# deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются: # deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются:
# OPENROUTER_MODEL=deepseek/deepseek-v4-pro # 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 OPENROUTER_TOOLS_ENABLED=true
# none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning. # none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning.
OPENROUTER_REASONING_EFFORT=none OPENROUTER_REASONING_EFFORT=none
# JSON-экстракция памяти отдельной моделью (если основная капризничает): # JSON-экстракция памяти отдельной моделью (если основная капризничает):
# MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat # MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat
# App # App
DATABASE_URL=sqlite:///./data/assistant.db DATABASE_URL=sqlite:///./data/assistant.db
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080 CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
SYSTEM_PROMPT_PATH=./prompts/assistant.md SYSTEM_PROMPT_PATH=./prompts/assistant.md
MEMORY_AUTO_EXTRACT=true MEMORY_AUTO_EXTRACT=true
# Fitness (wger + Open Food Facts — public HTTPS, no proxy) # Multi-user (API token auth)
WGER_BASE_URL=https://wger.de/api/v2 DEFAULT_USER_USERNAME=owner
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org DEFAULT_USER_DISPLAY_NAME=
FITNESS_REMINDERS_ENABLED=true DEFAULT_API_TOKEN=change-me-to-long-random-string
REMINDERS_ENABLED=true AUTH_REQUIRED=true
# Опционально для dev (автовход без /login). В prod оставьте пустым.
# Taiga (on host :9000, nginx → taiga.grigowashere.ru) VITE_API_TOKEN=
TAIGA_BASE_URL=http://host.docker.internal:9000
TAIGA_USERNAME=your_taiga_user # Fitness (wger + Open Food Facts — public HTTPS, no proxy)
TAIGA_PASSWORD=your_taiga_password WGER_BASE_URL=https://wger.de/api/v2
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
FITNESS_REMINDERS_ENABLED=true
# Gitea (on host :3000, nginx → git.grigowashere.ru) REMINDERS_ENABLED=true
GITEA_BASE_URL=http://host.docker.internal:3000
GITEA_TOKEN=your_gitea_api_token # Taiga (on host :9000, nginx → taiga.grigowashere.ru)
GITEA_PUBLIC_URL=https://git.grigowashere.ru TAIGA_BASE_URL=http://host.docker.internal:9000
GITEA_WEBHOOK_SECRET=generate_a_random_secret TAIGA_USERNAME=your_taiga_user
TAIGA_PASSWORD=your_taiga_password
# Gitea webhook URL (repo Settings → Webhooks): TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
# 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!) # Gitea (on host :3000, nginx → git.grigowashere.ru)
GITEA_BASE_URL=http://host.docker.internal:3000
REPOS_DIR=/data/repos GITEA_TOKEN=your_gitea_api_token
GITEA_PUBLIC_URL=https://git.grigowashere.ru
# Homelab — GPU PC 192.168.1.109 GITEA_WEBHOOK_SECRET=generate_a_random_secret
OPENMETEO_BASE_URL=http://192.168.1.109:8085
WEATHER_LAT=59.9343 # Gitea webhook URL (repo Settings → Webhooks):
WEATHER_LON=30.3351 # https://assistant.your-domain/api/v1/webhooks/gitea ← nginx → 127.0.0.1:BACKEND_PORT
WEATHER_LOCATION_NAME=Санкт-Петербург # http://172.17.0.1:8202/api/v1/webhooks/gitea ← если Gitea в Docker (не 127.0.0.1!)
WEATHER_CACHE_SEC=300
REPOS_DIR=/data/repos
# News RSS (comma-separated)
NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss # Homelab — GPU PC 192.168.1.109
NEWS_CACHE_SEC=1800 OPENMETEO_BASE_URL=http://192.168.1.109:8085
NEWS_MAX_ITEMS=7 WEATHER_LAT=59.9343
WEATHER_LON=30.3351
# Morning digest (Europe/Moscow or user profile timezone) WEATHER_LOCATION_NAME=Санкт-Петербург
MORNING_DIGEST_ENABLED=true WEATHER_CACHE_SEC=300
MORNING_DIGEST_HOUR=8
MORNING_DIGEST_MINUTE=0 # News RSS (comma-separated)
NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss
# ComfyUI on GPU PC (Anima split-model — как в aiChatBot) NEWS_CACHE_SEC=1800
COMFYUI_BASE_URL=http://192.168.1.109:8188 NEWS_MAX_ITEMS=7
COMFYUI_ENABLED=true
# Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET. # Morning digest (Europe/Moscow or user profile timezone)
COMFYUI_CHECKPOINT= MORNING_DIGEST_ENABLED=true
COMFYUI_UNET=anima-preview3-base.safetensors MORNING_DIGEST_HOUR=8
COMFYUI_CLIP=qwen_3_06b_base.safetensors MORNING_DIGEST_MINUTE=0
COMFYUI_VAE=qwen_image_vae.safetensors
COMFYUI_STYLE_LORA=anima-preview-3-masterpieces-v5.safetensors # ComfyUI on GPU PC (Anima split-model — как в aiChatBot)
COMFYUI_STYLE_LORA_WEIGHT=0.7 COMFYUI_BASE_URL=http://192.168.1.109:8188
COMFYUI_STEPS=30 COMFYUI_ENABLED=true
COMFYUI_CFG=4 # Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET.
COMFYUI_SAMPLER=er_sde COMFYUI_CHECKPOINT=
COMFYUI_SCHEDULER=simple COMFYUI_UNET=anima-preview3-base.safetensors
COMFYUI_WIDTH=1024 COMFYUI_CLIP=qwen_3_06b_base.safetensors
COMFYUI_HEIGHT=720 COMFYUI_VAE=qwen_image_vae.safetensors
COMFYUI_NEGATIVE_PROMPT=worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia COMFYUI_STYLE_LORA=anima-preview-3-masterpieces-v5.safetensors
COMFYUI_ROFL_ENABLED=true COMFYUI_STYLE_LORA_WEIGHT=0.7
COMFYUI_ROFL_MAX_PER_DAY=1 COMFYUI_STEPS=30
COMFYUI_ROFL_PROBABILITY=0.15 COMFYUI_CFG=4
COMFYUI_ROFL_MIN_INTERVAL_HOURS=12 COMFYUI_SAMPLER=er_sde
GENERATED_MEDIA_DIR=./data/generated COMFYUI_SCHEDULER=simple
COMFYUI_WIDTH=1024
# RP Chat (aiChatBot) — генерация картинок + sd-prompt; persona_id в карточке персонажа COMFYUI_HEIGHT=720
RP_CHAT_BASE_URL=http://host.docker.internal:8201 COMFYUI_NEGATIVE_PROMPT=worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia
RP_CHAT_ENABLED=true COMFYUI_ROFL_ENABLED=true
RP_CHAT_TIMEOUT_SEC=300 COMFYUI_ROFL_MAX_PER_DAY=1
COMFYUI_ROFL_PROBABILITY=0.15
# Netdata on server COMFYUI_ROFL_MIN_INTERVAL_HOURS=12
NETDATA_BASE_URL=http://host.docker.internal:19999 GENERATED_MEDIA_DIR=./data/generated
NETDATA_PUBLIC_URL=
NETDATA_ALERTS_ENABLED=true # RP Chat (aiChatBot) — генерация картинок + sd-prompt; persona_id в карточке персонажа
NETDATA_POLL_INTERVAL_SEC=120 RP_CHAT_BASE_URL=http://host.docker.internal:8201
RP_CHAT_ENABLED=true
# Vector DB (phase 3) RP_CHAT_TIMEOUT_SEC=300
QDRANT_PORT=6333
QDRANT_GRPC_PORT=6334 # Netdata on server
NETDATA_BASE_URL=http://host.docker.internal:19999
NETDATA_PUBLIC_URL=
NETDATA_ALERTS_ENABLED=true
NETDATA_POLL_INTERVAL_SEC=120
# Vector DB (phase 3)
QDRANT_PORT=6333
QDRANT_GRPC_PORT=6334
# RAG / embeddings
QDRANT_URL=http://qdrant:6333
EMBEDDING_MODEL=openai/text-embedding-3-small
RAG_ENABLED=true
RAG_TOP_K=8
MEMORY_FACTS_IN_CONTEXT=8
+106
View File
@@ -0,0 +1,106 @@
# Server (internal bind inside containers)
HOST=0.0.0.0
BACKEND_INTERNAL_PORT=8080
FRONTEND_INTERNAL_PORT=80
# External ports on the host (docker compose publish)
BACKEND_PORT=8080
FRONTEND_PORT=3080
VITE_DEV_PORT=5173
# OpenRouter
OPENROUTER_API_KEY=sk-or-v1-your-key-here
OPENROUTER_MODEL=deepseek/deepseek-chat
# deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются:
# OPENROUTER_MODEL=deepseek/deepseek-v4-pro
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_TOOLS_ENABLED=true
# none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning.
OPENROUTER_REASONING_EFFORT=none
# JSON-экстракция памяти отдельной моделью (если основная капризничает):
# MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat
# App
DATABASE_URL=sqlite:///./data/assistant.db
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
SYSTEM_PROMPT_PATH=./prompts/assistant.md
MEMORY_AUTO_EXTRACT=true
# Fitness (wger + Open Food Facts — public HTTPS, no proxy)
WGER_BASE_URL=https://wger.de/api/v2
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
FITNESS_REMINDERS_ENABLED=true
REMINDERS_ENABLED=true
# Taiga (on host :9000, nginx → taiga.grigowashere.ru)
TAIGA_BASE_URL=http://host.docker.internal:9000
TAIGA_USERNAME=your_taiga_user
TAIGA_PASSWORD=your_taiga_password
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
# Gitea (on host :3000, nginx → git.grigowashere.ru)
GITEA_BASE_URL=http://host.docker.internal:3000
GITEA_TOKEN=your_gitea_api_token
GITEA_PUBLIC_URL=https://git.grigowashere.ru
GITEA_WEBHOOK_SECRET=generate_a_random_secret
# Gitea webhook URL (repo Settings → Webhooks):
# https://assistant.your-domain/api/v1/webhooks/gitea ← nginx → 127.0.0.1:BACKEND_PORT
# http://172.17.0.1:8202/api/v1/webhooks/gitea ← если Gitea в Docker (не 127.0.0.1!)
REPOS_DIR=/data/repos
# Homelab — GPU PC 192.168.1.109
OPENMETEO_BASE_URL=http://192.168.1.109:8085
WEATHER_LAT=59.9343
WEATHER_LON=30.3351
WEATHER_LOCATION_NAME=Санкт-Петербург
WEATHER_CACHE_SEC=300
# News RSS (comma-separated)
NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss
NEWS_CACHE_SEC=1800
NEWS_MAX_ITEMS=7
# Morning digest (Europe/Moscow or user profile timezone)
MORNING_DIGEST_ENABLED=true
MORNING_DIGEST_HOUR=8
MORNING_DIGEST_MINUTE=0
# ComfyUI on GPU PC (Anima split-model — как в aiChatBot)
COMFYUI_BASE_URL=http://192.168.1.109:8188
COMFYUI_ENABLED=true
# Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET.
COMFYUI_CHECKPOINT=
COMFYUI_UNET=anima-preview3-base.safetensors
COMFYUI_CLIP=qwen_3_06b_base.safetensors
COMFYUI_VAE=qwen_image_vae.safetensors
COMFYUI_STYLE_LORA=anima-preview-3-masterpieces-v5.safetensors
COMFYUI_STYLE_LORA_WEIGHT=0.7
COMFYUI_STEPS=30
COMFYUI_CFG=4
COMFYUI_SAMPLER=er_sde
COMFYUI_SCHEDULER=simple
COMFYUI_WIDTH=1024
COMFYUI_HEIGHT=720
COMFYUI_NEGATIVE_PROMPT=worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia
COMFYUI_ROFL_ENABLED=true
COMFYUI_ROFL_MAX_PER_DAY=1
COMFYUI_ROFL_PROBABILITY=0.15
COMFYUI_ROFL_MIN_INTERVAL_HOURS=12
GENERATED_MEDIA_DIR=./data/generated
# RP Chat (aiChatBot) — генерация картинок + sd-prompt; persona_id в карточке персонажа
RP_CHAT_BASE_URL=http://host.docker.internal:8201
RP_CHAT_ENABLED=true
RP_CHAT_TIMEOUT_SEC=300
# Netdata on server
NETDATA_BASE_URL=http://host.docker.internal:19999
NETDATA_PUBLIC_URL=
NETDATA_ALERTS_ENABLED=true
NETDATA_POLL_INTERVAL_SEC=120
# Vector DB (phase 3)
QDRANT_PORT=6333
QDRANT_GRPC_PORT=6334
+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
+20 -17
View File
@@ -1,17 +1,20 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, reminders, shopping, 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(homelab.router, tags=["homelab"]) api_router.include_router(auth.router)
api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) api_router.include_router(homelab.router, tags=["homelab"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"]) api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(character.router, tags=["character"]) api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(projects.router, tags=["projects"]) api_router.include_router(character.router, tags=["character"])
api_router.include_router(memory.router, tags=["memory"]) api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(fitness.router, tags=["fitness"]) api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"]) api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"]) api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(webhooks.router, tags=["webhooks"]) api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
api_router.include_router(media.router, tags=["media"]) api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"])
api_router.include_router(settings.router, tags=["settings"])
api_router.include_router(documents.router, tags=["documents"])
@@ -0,0 +1,17 @@
from fastapi import APIRouter
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, reminders, shopping, webhooks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"])
api_router.include_router(homelab.router, tags=["homelab"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(character.router, tags=["character"])
api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"])
+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,
}
+80 -62
View File
@@ -1,62 +1,80 @@
from typing import Any from typing import Any
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.auth.deps import get_current_user
router = APIRouter() from app.character.service import CharacterService
from app.db.base import get_db
from app.db.models import User
class CharacterCardData(BaseModel):
name: str = "Ассистент" router = APIRouter()
description: str = ""
personality: str = ""
scenario: str = "" class CharacterCardData(BaseModel):
first_mes: str = "" name: str = "Ассистент"
mes_example: str = "" description: str = ""
system_prompt: str = "" personality: str = ""
post_history_instructions: str = "" scenario: str = ""
tags: list[str] = Field(default_factory=list) first_mes: str = ""
creator: str = "" mes_example: str = ""
creator_notes: str = "" system_prompt: str = ""
alternate_greetings: list[str] = Field(default_factory=list) post_history_instructions: str = ""
character_version: str = "1.0" tags: list[str] = Field(default_factory=list)
appearance_tags: str = "" creator: str = ""
appearance_prose: str = "" creator_notes: str = ""
lora_name: str = "" alternate_greetings: list[str] = Field(default_factory=list)
lora_weight: float = 0.8 character_version: str = "1.0"
rp_persona_id: str = "" appearance_tags: str = ""
sd_enabled: bool = True appearance_prose: str = ""
lora_name: str = ""
lora_weight: float = 0.8
class CharacterCardV2(BaseModel): rp_persona_id: str = ""
spec: str = "chara_card_v2" sd_enabled: bool = True
spec_version: str = "2.0"
data: CharacterCardData
class CharacterCardV2(BaseModel):
spec: str = "chara_card_v2"
@router.get("/character") spec_version: str = "2.0"
def get_character() -> dict[str, Any]: data: CharacterCardData
return CharacterService().get_card()
@router.get("/character")
@router.put("/character") def get_character(
def update_character(payload: CharacterCardV2) -> dict[str, Any]: db: Session = Depends(get_db),
return CharacterService().save_card(payload.model_dump()) user: User = Depends(get_current_user),
) -> dict[str, Any]:
return CharacterService(db, user.id).get_card()
@router.get("/character/prompt")
def get_character_prompt() -> dict[str, str]:
service = CharacterService() @router.put("/character")
return { def update_character(
"system_prompt": service.get_system_prompt(), payload: CharacterCardV2,
"first_mes": service.get_card().get("data", {}).get("first_mes", ""), 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.post("/character/import")
def import_character(payload: dict[str, Any]) -> dict[str, Any]:
if not payload: @router.get("/character/prompt")
raise HTTPException(status_code=400, detail="Empty card") def get_character_prompt(
return CharacterService().save_card(payload) db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, str]:
service = CharacterService(db, user.id)
return {
"system_prompt": service.get_system_prompt(),
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
}
@router.post("/character/import")
def import_character(
payload: dict[str, Any],
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
if not payload:
raise HTTPException(status_code=400, detail="Empty card")
return CharacterService(db, user.id).save_card(payload)
+158 -70
View File
@@ -1,70 +1,158 @@
from fastapi import APIRouter, Depends, HTTPException import asyncio
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut from sqlalchemy.orm import Session
from app.chat.service import ChatService
from app.db.base import get_db from app.api.chat_schemas import GenerationStatusOut, MessagesPageOut
from app.api.schemas import (
router = APIRouter() MessageCreate,
SessionCreate,
SessionDetailOut,
@router.post("/sessions", response_model=SessionOut) SessionOut,
def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut: )
service = ChatService(db) from app.chat.generation import (
return service.create_session(title=payload.title) GenerationBusyError,
get_active_handle,
is_generation_active,
@router.get("/sessions", response_model=list[SessionOut]) start_generation,
def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]: subscribe_generation,
service = ChatService(db) )
return service.list_sessions() from app.chat.service import ChatService
from app.auth.deps import get_current_user
from app.db.base import get_db
@router.get("/sessions/{session_id}", response_model=SessionDetailOut) from app.db.models import User
def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut:
service = ChatService(db) router = APIRouter()
session = service.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found") @router.post("/sessions", response_model=SessionOut)
return session def create_session(payload: SessionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionOut:
service = ChatService(db, user.id)
return service.create_session(title=payload.title)
@router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
service = ChatService(db) @router.get("/sessions", response_model=list[SessionOut])
if not service.delete_session(session_id): def list_sessions(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[SessionOut]:
raise HTTPException(status_code=404, detail="Session not found") service = ChatService(db, user.id)
return {"ok": True} return service.list_sessions()
@router.post("/sessions/{session_id}/messages") @router.get("/sessions/{session_id}", response_model=SessionDetailOut)
async def send_message( def get_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionDetailOut:
session_id: int, service = ChatService(db, user.id)
payload: MessageCreate, session = service.get_session(session_id)
db: Session = Depends(get_db), if not session:
) -> StreamingResponse: raise HTTPException(status_code=404, detail="Session not found")
service = ChatService(db) return session
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
@router.get("/sessions/{session_id}/messages", response_model=MessagesPageOut)
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД. def list_messages(
service.save_user_message(session_id, payload.content) session_id: int,
limit: int = 30,
async def event_stream(): before_id: int | None = None,
async for chunk in service.stream_response( after_id: int | None = None,
session_id, db: Session = Depends(get_db), user: User = Depends(get_current_user),
payload.content, ) -> MessagesPageOut:
user_message_saved=True, service = ChatService(db, user.id)
): if not service.get_session(session_id):
yield chunk raise HTTPException(status_code=404, detail="Session not found")
messages, has_more = service.list_messages(
return StreamingResponse( session_id,
event_stream(), limit=min(max(limit, 1), 100),
media_type="text/event-stream", before_id=before_id,
headers={ after_id=after_id,
"Cache-Control": "no-cache", )
"Connection": "keep-alive", return MessagesPageOut(messages=messages, has_more=has_more)
"X-Accel-Buffering": "no",
},
) @router.get("/sessions/{session_id}/generation", response_model=GenerationStatusOut)
def generation_status(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> GenerationStatusOut:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return GenerationStatusOut(active=is_generation_active(session_id))
@router.get("/sessions/{session_id}/generation/stream")
async def generation_stream(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> StreamingResponse:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
handle = get_active_handle(session_id)
if not handle:
raise HTTPException(status_code=404, detail="No active generation")
async def event_stream():
async for chunk in subscribe_generation(handle):
yield chunk
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
service = ChatService(db, user.id)
if not service.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@router.post("/sessions/{session_id}/messages")
async def send_message(
session_id: int,
payload: MessageCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> StreamingResponse:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
if is_generation_active(session_id):
raise HTTPException(status_code=409, detail="Generation already in progress")
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД.
service.save_user_message(session_id, payload.content)
try:
handle = await start_generation(session_id, user.id, payload.content)
except GenerationBusyError:
raise HTTPException(status_code=409, detail="Generation already in progress") from None
async def event_stream():
try:
async for chunk in subscribe_generation(handle):
yield chunk
except asyncio.CancelledError:
raise
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/sessions/{session_id}/context-preview")
def context_preview(
session_id: int,
query: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict:
service = ChatService(db, user.id)
return service.context_preview(session_id, query=query)
@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut
from app.chat.service import ChatService
from app.db.base import get_db
router = APIRouter()
@router.post("/sessions", response_model=SessionOut)
def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut:
service = ChatService(db)
return service.create_session(title=payload.title)
@router.get("/sessions", response_model=list[SessionOut])
def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]:
service = ChatService(db)
return service.list_sessions()
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut:
service = ChatService(db)
session = service.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
service = ChatService(db)
if not service.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@router.post("/sessions/{session_id}/messages")
async def send_message(
session_id: int,
payload: MessageCreate,
db: Session = Depends(get_db),
) -> StreamingResponse:
service = ChatService(db)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД.
service.save_user_message(session_id, payload.content)
async def event_stream():
async for chunk in service.stream_response(
session_id,
payload.content,
user_message_saved=True,
):
yield chunk
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+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}
+308 -223
View File
@@ -1,223 +1,308 @@
from datetime import date from datetime import date
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.base import get_db from app.auth.deps import get_current_user
from app.fitness.service import FitnessService from app.db.base import get_db
from app.fitness.structuring import structure_meal, structure_workout from app.db.models import User
from app.integrations.openfoodfacts import OpenFoodFactsClient from app.fitness.service import FitnessService
from app.integrations.wger import WgerClient from app.fitness.structuring import structure_meal, structure_workout
from app.integrations.openfoodfacts import OpenFoodFactsClient
router = APIRouter() from app.integrations.wger import WgerClient
router = APIRouter()
class ProfileUpdate(BaseModel):
sex: str | None = None
age: int | None = None class ProfileUpdate(BaseModel):
height_cm: float | None = None sex: str | None = None
weight_kg: float | None = None age: int | None = None
activity_level: str | None = None height_cm: float | None = None
goal: str | None = None weight_kg: float | None = None
target_weight_kg: float | None = None activity_level: str | None = None
weekly_workouts: int | None = None goal: str | None = None
target_weight_kg: float | None = None
weekly_workouts: int | None = None
class MealCreate(BaseModel): baseline_steps: int | None = None
text: str = Field(min_length=1) baseline_workout_kcal: float | None = None
meal_type: str | None = None
class MealCreate(BaseModel):
class WaterCreate(BaseModel): text: str = Field(min_length=1)
amount_ml: int = Field(gt=0) meal_type: str | None = None
class WeightCreate(BaseModel): class WaterCreate(BaseModel):
weight_kg: float = Field(gt=0) amount_ml: int = Field(gt=0)
body_fat_pct: float | None = None
chest_cm: float | None = None
waist_cm: float | None = None class WeightCreate(BaseModel):
notes: str = "" weight_kg: float = Field(gt=0)
body_fat_pct: float | None = None
chest_cm: float | None = None
class WorkoutCreate(BaseModel): waist_cm: float | None = None
text: str = Field(min_length=1) neck_cm: float | None = None
hip_cm: float | None = None
notes: str = ""
class ReminderUpdate(BaseModel): day: str | None = None
enabled: bool | None = None days_ago: int | None = Field(default=None, ge=0, le=90)
hour: int | None = Field(default=None, ge=0, le=23) recorded_at: str | None = None
minute: int | None = Field(default=None, ge=0, le=59)
interval_hours: int | None = Field(default=None, ge=1, le=12)
class BodyCompositionCalc(BaseModel):
weight_kg: float | None = None
@router.get("/fitness") height_cm: float | None = None
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]: sex: str | None = None
return FitnessService(db).snapshot() neck_cm: float | None = None
waist_cm: float | None = None
hip_cm: float | None = None
@router.get("/fitness/summary") body_fat_pct: float | None = None
def get_summary(
day: str | None = None,
db: Session = Depends(get_db), class StepsCreate(BaseModel):
) -> dict[str, Any]: steps: int = Field(ge=0)
d = date.fromisoformat(day) if day else None active_calories: float | None = None
return FitnessService(db).get_daily_summary(d) notes: str = ""
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
@router.get("/fitness/history") logged_at: str | None = None
def get_history(
days: int = 7,
end: str | None = None, class WorkoutCreate(BaseModel):
db: Session = Depends(get_db), text: str = Field(min_length=1)
) -> dict[str, Any]: day: str | None = None
end_day = date.fromisoformat(end) if end else None days_ago: int | None = Field(default=None, ge=0, le=90)
return FitnessService(db).get_history(days=days, end_day=end_day) logged_at: str | None = None
@router.get("/fitness/profile") class ReminderUpdate(BaseModel):
def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]: enabled: bool | None = None
profile = FitnessService(db).get_profile() hour: int | None = Field(default=None, ge=0, le=23)
return profile or {"configured": False} minute: int | None = Field(default=None, ge=0, le=59)
interval_hours: int | None = Field(default=None, ge=1, le=12)
@router.put("/fitness/profile")
def update_profile( @router.get("/fitness")
payload: ProfileUpdate, def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
db: Session = Depends(get_db), return FitnessService(db, user.id).snapshot()
) -> dict[str, Any]:
return FitnessService(db).set_profile(payload.model_dump(exclude_none=True))
@router.get("/fitness/summary")
def get_summary(
@router.post("/fitness/profile/calc") day: str | None = None,
def calc_targets( db: Session = Depends(get_db), user: User = Depends(get_current_user),
payload: ProfileUpdate, ) -> dict[str, Any]:
db: Session = Depends(get_db), d = date.fromisoformat(day) if day else None
) -> dict[str, Any]: return FitnessService(db, user.id).get_daily_summary(d)
params = payload.model_dump(exclude_none=True)
if not params:
raise HTTPException(status_code=400, detail="No parameters") @router.get("/fitness/workout-stats")
return FitnessService(db).calc_targets(params) def get_workout_stats(
days: int = 7,
end: str | None = None,
@router.post("/fitness/meals") db: Session = Depends(get_db), user: User = Depends(get_current_user),
async def create_meal( ) -> dict[str, Any]:
payload: MealCreate, end_day = date.fromisoformat(end) if end else None
db: Session = Depends(get_db), return FitnessService(db, user.id).get_workout_stats(days=days, end_day=end_day)
) -> dict[str, Any]:
service = FitnessService(db)
try: @router.get("/fitness/history")
structured = await structure_meal(payload.text) def get_history(
except Exception as exc: days: int = 7,
raise HTTPException(status_code=502, detail=str(exc)) from exc end: str | None = None,
return service.log_meal( db: Session = Depends(get_db), user: User = Depends(get_current_user),
description=structured.get("description") or payload.text, ) -> dict[str, Any]:
meal_type=payload.meal_type or structured.get("meal_type") or "snack", end_day = date.fromisoformat(end) if end else None
calories=float(structured.get("calories") or 0), return FitnessService(db, user.id).get_history(days=days, end_day=end_day)
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), @router.get("/fitness/profile")
source="llm", def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
estimated=bool(structured.get("estimated", True)), profile = FitnessService(db, user.id).get_profile()
) return profile or {"configured": False}
@router.post("/fitness/water") @router.put("/fitness/profile")
def create_water( def update_profile(
payload: WaterCreate, payload: ProfileUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
return FitnessService(db).log_water(payload.amount_ml) return FitnessService(db, user.id).set_profile(payload.model_dump(exclude_none=True))
@router.post("/fitness/weight") @router.post("/fitness/profile/calc")
def create_weight( def calc_targets(
payload: WeightCreate, payload: ProfileUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
return FitnessService(db).log_weight( params = payload.model_dump(exclude_none=True)
payload.weight_kg, if not params:
body_fat_pct=payload.body_fat_pct, raise HTTPException(status_code=400, detail="No parameters")
chest_cm=payload.chest_cm, return FitnessService(db, user.id).calc_targets(params)
waist_cm=payload.waist_cm,
notes=payload.notes,
) @router.post("/fitness/meals")
async def create_meal(
payload: MealCreate,
@router.post("/fitness/workouts") db: Session = Depends(get_db), user: User = Depends(get_current_user),
async def create_workout( ) -> dict[str, Any]:
payload: WorkoutCreate, service = FitnessService(db, user.id)
db: Session = Depends(get_db), try:
) -> dict[str, Any]: structured = await structure_meal(payload.text)
service = FitnessService(db) except Exception as exc:
try: raise HTTPException(status_code=502, detail=str(exc)) from exc
structured = await structure_workout(payload.text) return service.log_meal(
except Exception as exc: description=structured.get("description") or payload.text,
raise HTTPException(status_code=502, detail=str(exc)) from exc meal_type=payload.meal_type or structured.get("meal_type") or "snack",
return service.log_workout( calories=float(structured.get("calories") or 0),
title=structured.get("title") or "Тренировка", protein_g=float(structured.get("protein_g") or 0),
notes=structured.get("notes") or payload.text, fat_g=float(structured.get("fat_g") or 0),
duration_min=structured.get("duration_min"), carbs_g=float(structured.get("carbs_g") or 0),
exercises=structured.get("exercises"), source="llm",
) estimated=bool(structured.get("estimated", True)),
)
@router.get("/fitness/body-metrics")
def list_metrics( @router.post("/fitness/water")
limit: int = 30, def create_water(
db: Session = Depends(get_db), payload: WaterCreate,
) -> list[dict[str, Any]]: db: Session = Depends(get_db), user: User = Depends(get_current_user),
return FitnessService(db).list_body_metrics(limit=limit) ) -> dict[str, Any]:
return FitnessService(db, user.id).log_water(payload.amount_ml)
@router.delete("/fitness/meals/{log_id}")
def delete_meal(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]: @router.post("/fitness/weight")
if not FitnessService(db).delete_food_log(log_id): def create_weight(
raise HTTPException(status_code=404, detail="Not found") payload: WeightCreate,
return {"ok": True} db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
day = date.fromisoformat(payload.day) if payload.day else None
@router.delete("/fitness/water/{log_id}") return FitnessService(db, user.id).log_weight(
def delete_water(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]: payload.weight_kg,
if not FitnessService(db).delete_water_log(log_id): body_fat_pct=payload.body_fat_pct,
raise HTTPException(status_code=404, detail="Not found") chest_cm=payload.chest_cm,
return {"ok": True} waist_cm=payload.waist_cm,
neck_cm=payload.neck_cm,
hip_cm=payload.hip_cm,
@router.delete("/fitness/workouts/{log_id}") notes=payload.notes,
def delete_workout(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]: recorded_at=payload.recorded_at,
if not FitnessService(db).delete_workout_log(log_id): day=day,
raise HTTPException(status_code=404, detail="Not found") days_ago=payload.days_ago,
return {"ok": True} )
@router.get("/fitness/reminders") @router.post("/fitness/body-composition/calc")
def list_reminders(db: Session = Depends(get_db)) -> list[dict[str, Any]]: def calc_body_composition(
return FitnessService(db).list_reminders() payload: BodyCompositionCalc,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
@router.put("/fitness/reminders/{kind}") return FitnessService(db, user.id).calc_body_composition(payload.model_dump(exclude_none=True))
def update_reminder(
kind: str,
payload: ReminderUpdate, @router.post("/fitness/steps")
db: Session = Depends(get_db), def create_steps(
) -> dict[str, Any]: payload: StepsCreate,
return FitnessService(db).set_reminder( db: Session = Depends(get_db), user: User = Depends(get_current_user),
kind, ) -> dict[str, Any]:
enabled=payload.enabled, day = date.fromisoformat(payload.day) if payload.day else None
hour=payload.hour, return FitnessService(db, user.id).log_steps(
minute=payload.minute, payload.steps,
interval_hours=payload.interval_hours, active_calories=payload.active_calories,
) notes=payload.notes,
day=day,
days_ago=payload.days_ago,
@router.get("/fitness/lookup/food") logged_at=payload.logged_at,
def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]: )
return OpenFoodFactsClient().search(q, limit=limit)
@router.delete("/fitness/steps/{log_id}")
@router.get("/fitness/lookup/exercise") def delete_steps(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]: if not FitnessService(db, user.id).delete_step_log(log_id):
return WgerClient().search_exercises(q, limit=limit) raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.post("/fitness/workouts")
async def create_workout(
payload: WorkoutCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
service = FitnessService(db, user.id)
try:
structured = await structure_workout(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
day = date.fromisoformat(payload.day) if payload.day else None
return service.log_workout(
title=structured.get("title") or "Тренировка",
notes=structured.get("notes") or payload.text,
duration_min=structured.get("duration_min"),
exercises=structured.get("exercises"),
active_calories=structured.get("active_calories"),
total_calories=structured.get("total_calories"),
steps=structured.get("steps"),
day=day,
days_ago=payload.days_ago,
logged_at=payload.logged_at,
)
@router.get("/fitness/body-metrics")
def list_metrics(
limit: int = 30,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return FitnessService(db, user.id).list_body_metrics(limit=limit)
@router.delete("/fitness/meals/{log_id}")
def delete_meal(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_food_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/water/{log_id}")
def delete_water(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_water_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/workouts/{log_id}")
def delete_workout(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_workout_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.get("/fitness/reminders")
def list_reminders(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return FitnessService(db, user.id).list_reminders()
@router.put("/fitness/reminders/{kind}")
def update_reminder(
kind: str,
payload: ReminderUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).set_reminder(
kind,
enabled=payload.enabled,
hour=payload.hour,
minute=payload.minute,
interval_hours=payload.interval_hours,
)
@router.get("/fitness/lookup/food")
def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]:
return OpenFoodFactsClient().search(q, limit=limit)
@router.get("/fitness/lookup/exercise")
def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]:
return WgerClient().search_exercises(q, limit=limit)
+130 -127
View File
@@ -1,127 +1,130 @@
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.base import get_db from app.auth.deps import get_current_user
from app.db.models import ChatSession from app.db.base import get_db
from app.memory.extract import extract_after_turn from app.db.models import User
from app.memory.service import MemoryService from app.db.models import ChatSession
from app.memory.extract import extract_after_turn
router = APIRouter() from app.memory.service import MemoryService
router = APIRouter()
class ProfileUpdate(BaseModel):
updates: dict[str, Any] = Field(default_factory=dict)
class ProfileUpdate(BaseModel):
updates: dict[str, Any] = Field(default_factory=dict)
class FactCreate(BaseModel):
content: str = Field(min_length=1)
category: str = "fact" class FactCreate(BaseModel):
importance: int = Field(default=3, ge=1, le=5) content: str = Field(min_length=1)
session_id: int | None = None 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 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) class ExtractRequest(BaseModel):
assistant_text: str = "" session_id: int
force: bool = False 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, @router.get("/memory")
db: Session = Depends(get_db), def get_memory_snapshot(
) -> dict[str, Any]: session_id: int | None = None,
return MemoryService(db).snapshot(session_id) 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)) -> dict[str, Any]:
return MemoryService(db).get_profile() @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, @router.put("/profile")
db: Session = Depends(get_db), def update_profile(
) -> dict[str, Any]: payload: ProfileUpdate,
try: db: Session = Depends(get_db), user: User = Depends(get_current_user),
return MemoryService(db).update_profile(payload.updates) ) -> dict[str, Any]:
except ValueError as exc: try:
raise HTTPException(status_code=400, detail=str(exc)) from exc 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, @router.get("/memory/facts")
category: str | None = None, def list_facts(
limit: int = 30, query: str | None = None,
db: Session = Depends(get_db), category: str | None = None,
) -> list[dict[str, Any]]: limit: int = 30,
return MemoryService(db).recall_memories(query=query, category=category, limit=limit) 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, @router.post("/memory/facts")
db: Session = Depends(get_db), def create_fact(
) -> dict[str, Any]: payload: FactCreate,
try: db: Session = Depends(get_db), user: User = Depends(get_current_user),
return MemoryService(db).remember_fact( ) -> dict[str, Any]:
payload.content, try:
category=payload.category, return MemoryService(db, user.id).remember_fact(
session_id=payload.session_id, payload.content,
importance=payload.importance, category=payload.category,
source="api", session_id=payload.session_id,
) importance=payload.importance,
except ValueError as exc: source="api",
raise HTTPException(status_code=400, detail=str(exc)) from exc )
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/memory/facts/{memory_id}")
def forget_fact(memory_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try: @router.delete("/memory/facts/{memory_id}")
return MemoryService(db).forget_memory(memory_id) def forget_fact(memory_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
except ValueError as exc: try:
raise HTTPException(status_code=404, detail=str(exc)) from exc 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, @router.post("/memory/extract")
db: Session = Depends(get_db), async def extract_memories(
) -> dict: payload: ExtractRequest,
session = db.get(ChatSession, payload.session_id) db: Session = Depends(get_db), user: User = Depends(get_current_user),
if not session: ) -> dict:
raise HTTPException(status_code=404, detail="Session not found") session = db.get(ChatSession, payload.session_id)
return await extract_after_turn( if not session or session.user_id != user.id:
db, raise HTTPException(status_code=404, detail="Session not found")
payload.session_id, return await extract_after_turn(
payload.user_text, db,
payload.assistant_text, payload.session_id,
force=payload.force, 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, @router.put("/memory/sessions/{session_id}/summary")
db: Session = Depends(get_db), def update_session_summary(
) -> dict[str, Any]: session_id: int,
try: payload: SessionSummaryUpdate,
return MemoryService(db).update_session_summary( db: Session = Depends(get_db), user: User = Depends(get_current_user),
session_id, ) -> dict[str, Any]:
payload.summary, try:
message_count=payload.message_count, return MemoryService(db, user.id).update_session_summary(
) session_id,
except ValueError as exc: payload.summary,
raise HTTPException(status_code=400, detail=str(exc)) from exc message_count=payload.message_count,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+102 -100
View File
@@ -1,100 +1,102 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.schemas import PomodoroStart, PomodoroStop from app.api.schemas import PomodoroStart, PomodoroStop
from app.db.base import get_db from app.auth.deps import get_current_user
from app.pomodoro.service import PomodoroService from app.db.base import get_db
from app.db.models import User
router = APIRouter() from app.pomodoro.service import PomodoroService
router = APIRouter()
def _handle_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
def _handle_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
@router.get("/status")
def get_status(db: Session = Depends(get_db)) -> dict:
return PomodoroService(db).get_status() @router.get("/status")
def get_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db, user.id).get_status()
@router.post("/start")
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
try: @router.post("/start")
return PomodoroService(db).start( def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
duration_min=payload.duration_min, try:
task_note=payload.task_note, return PomodoroService(db, user.id).start(
) duration_min=payload.duration_min,
except ValueError as exc: task_note=payload.task_note,
raise _handle_value_error(exc) from exc )
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/pause")
def pause_pomodoro(db: Session = Depends(get_db)) -> dict:
try: @router.post("/pause")
return PomodoroService(db).pause() def pause_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
except ValueError as exc: try:
raise _handle_value_error(exc) from exc return PomodoroService(db, user.id).pause()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/resume")
def resume_pomodoro(db: Session = Depends(get_db)) -> dict:
try: @router.post("/resume")
return PomodoroService(db).resume() def resume_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
except ValueError as exc: try:
raise _handle_value_error(exc) from exc return PomodoroService(db, user.id).resume()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/stop")
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict:
try: @router.post("/stop")
return PomodoroService(db).stop( def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
result=payload.result, try:
completed=payload.completed, return PomodoroService(db, user.id).stop(
) result=payload.result,
except ValueError as exc: completed=payload.completed,
raise _handle_value_error(exc) from exc )
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]:
return PomodoroService(db).history(limit=limit) @router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
return PomodoroService(db, user.id).history(limit=limit)
@router.post("/work/start")
def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
try: @router.post("/work/start")
return PomodoroService(db).start_work( def start_work(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
duration_min=payload.duration_min, try:
task_note=payload.task_note, return PomodoroService(db, user.id).start_work(
) duration_min=payload.duration_min,
except ValueError as exc: task_note=payload.task_note,
raise _handle_value_error(exc) from exc )
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/short/start")
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
try: @router.post("/break/short/start")
return PomodoroService(db).start_short_break(duration_min=duration_min) def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
except ValueError as exc: try:
raise _handle_value_error(exc) from exc return PomodoroService(db, user.id).start_short_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/long/start")
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
try: @router.post("/break/long/start")
return PomodoroService(db).start_long_break(duration_min=duration_min) def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
except ValueError as exc: try:
raise _handle_value_error(exc) from exc return PomodoroService(db, user.id).start_long_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/cycle/reset")
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db)) -> dict:
return PomodoroService(db).reset_cycle(clear_task=clear_task) @router.post("/cycle/reset")
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db, user.id).reset_cycle(clear_task=clear_task)
@router.post("/skip")
def skip_phase(db: Session = Depends(get_db)) -> dict:
try: @router.post("/skip")
return PomodoroService(db).skip_phase() def skip_phase(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
except ValueError as exc: try:
raise _handle_value_error(exc) from exc return PomodoroService(db, user.id).skip_phase()
except ValueError as exc:
raise _handle_value_error(exc) from exc
+78 -76
View File
@@ -1,76 +1,78 @@
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.base import get_db from app.auth.deps import get_current_user
from app.projects.service import ProjectService from app.db.base import get_db
from app.db.models import User
router = APIRouter() from app.projects.service import ProjectService
router = APIRouter()
class GiteaBinding(BaseModel):
gitea_owner: str = Field(min_length=1)
gitea_repo: str = Field(min_length=1) class GiteaBinding(BaseModel):
default_branch: str = "main" gitea_owner: str = Field(min_length=1)
gitea_repo: str = Field(min_length=1)
default_branch: str = "main"
class WorkItemCreate(BaseModel):
text: str = Field(min_length=1)
project_slug: str | None = None class WorkItemCreate(BaseModel):
text: str = Field(min_length=1)
project_slug: str | None = None
@router.get("/projects")
def list_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
return ProjectService(db).list_projects() @router.get("/projects")
def list_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return ProjectService(db, user.id).list_projects()
@router.post("/projects/sync-taiga")
def sync_taiga_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
try: @router.post("/projects/sync-taiga")
return ProjectService(db).sync_taiga_projects() def sync_taiga_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
except ValueError as exc: try:
raise HTTPException(status_code=400, detail=str(exc)) from exc return ProjectService(db, user.id).sync_taiga_projects()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/projects/{taiga_slug}/gitea")
def bind_gitea(
taiga_slug: str, @router.put("/projects/{taiga_slug}/gitea")
payload: GiteaBinding, def bind_gitea(
db: Session = Depends(get_db), taiga_slug: str,
) -> dict[str, Any]: payload: GiteaBinding,
try: db: Session = Depends(get_db), user: User = Depends(get_current_user),
return ProjectService(db).bind_gitea( ) -> dict[str, Any]:
taiga_slug, try:
payload.gitea_owner, return ProjectService(db, user.id).bind_gitea(
payload.gitea_repo, taiga_slug,
payload.default_branch, payload.gitea_owner,
) payload.gitea_repo,
except ValueError as exc: payload.default_branch,
raise HTTPException(status_code=400, detail=str(exc)) from exc )
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/work-items")
async def create_work_item(
payload: WorkItemCreate, @router.post("/work-items")
db: Session = Depends(get_db), async def create_work_item(
) -> dict[str, Any]: payload: WorkItemCreate,
try: db: Session = Depends(get_db), user: User = Depends(get_current_user),
return await ProjectService(db).create_work_item( ) -> dict[str, Any]:
payload.text, try:
project_slug=payload.project_slug, return await ProjectService(db, user.id).create_work_item(
) payload.text,
except ValueError as exc: project_slug=payload.project_slug,
raise HTTPException(status_code=400, detail=str(exc)) from exc )
except Exception as exc: except ValueError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/work-items")
def list_work_items(
limit: int = 30, @router.get("/work-items")
status: str | None = None, def list_work_items(
db: Session = Depends(get_db), limit: int = 30,
) -> list[dict[str, Any]]: status: str | None = None,
return ProjectService(db).list_work_items(limit=limit, status=status) db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return ProjectService(db, user.id).list_work_items(limit=limit, status=status)
+128 -124
View File
@@ -1,124 +1,128 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.base import get_db from app.auth.deps import get_current_user
from app.homelab.context import resolve_timezone from app.db.base import get_db
from app.reminders.service import RemindersService from app.db.models import User
from app.homelab.context import resolve_timezone
router = APIRouter() 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") class ReminderCreate(BaseModel):
notes: str = "" title: str = Field(min_length=1, max_length=255)
all_day: bool = False due_at: str = Field(description="ISO datetime, например 2027-05-12T12:16:00")
recurrence: str = "none" 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 class ReminderUpdate(BaseModel):
notes: str | None = None title: str | None = Field(default=None, min_length=1, max_length=255)
all_day: bool | None = None due_at: str | None = None
recurrence: str | None = None notes: str | None = None
enabled: bool | 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)) -> dict[str, Any]:
return RemindersService(db).snapshot() @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), @router.get("/upcoming")
db: Session = Depends(get_db), def list_upcoming(
) -> list[dict[str, Any]]: limit: int = Query(30, ge=1, le=100),
return RemindersService(db).list_upcoming(limit=limit) db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
@router.get("/calendar") return RemindersService(db, user.id).list_upcoming(limit=limit)
def calendar(
year: int = Query(..., ge=2000, le=2100),
month: int = Query(..., ge=1, le=12), @router.get("/calendar")
db: Session = Depends(get_db), def calendar(
) -> dict[str, Any]: year: int = Query(..., ge=2000, le=2100),
tz_name = resolve_timezone(db) month: int = Query(..., ge=1, le=12),
try: db: Session = Depends(get_db),
tz = ZoneInfo(tz_name) user: User = Depends(get_current_user),
except Exception: ) -> dict[str, Any]:
tz = ZoneInfo("Europe/Moscow") tz_name = resolve_timezone(db, user.id)
try:
start = datetime(year, month, 1, tzinfo=tz) tz = ZoneInfo(tz_name)
if month == 12: except Exception:
end = datetime(year + 1, 1, 1, tzinfo=tz) tz = ZoneInfo("Europe/Moscow")
else:
end = datetime(year, month + 1, 1, tzinfo=tz) start = datetime(year, month, 1, tzinfo=tz)
if month == 12:
service = RemindersService(db) end = datetime(year + 1, 1, 1, tzinfo=tz)
items = service.list_in_range( else:
date_from=start.astimezone(timezone.utc), end = datetime(year, month + 1, 1, tzinfo=tz)
date_to=end.astimezone(timezone.utc),
) service = RemindersService(db, user.id)
return { items = service.list_in_range(
"year": year, date_from=start.astimezone(timezone.utc),
"month": month, date_to=end.astimezone(timezone.utc),
"timezone": tz_name, )
"reminders": items, return {
} "year": year,
"month": month,
"timezone": tz_name,
@router.post("") "reminders": items,
def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db)) -> dict[str, Any]: }
try:
return RemindersService(db).create(
title=payload.title, @router.post("")
due_at=payload.due_at, def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
notes=payload.notes, try:
all_day=payload.all_day, return RemindersService(db, user.id).create(
recurrence=payload.recurrence, title=payload.title,
) due_at=payload.due_at,
except ValueError as exc: notes=payload.notes,
raise HTTPException(status_code=400, detail=str(exc)) from exc all_day=payload.all_day,
recurrence=payload.recurrence,
)
@router.patch("/{reminder_id}") except ValueError as exc:
def update_reminder( raise HTTPException(status_code=400, detail=str(exc)) from exc
reminder_id: int,
payload: ReminderUpdate,
db: Session = Depends(get_db), @router.patch("/{reminder_id}")
) -> dict[str, Any]: def update_reminder(
try: reminder_id: int,
return RemindersService(db).update( payload: ReminderUpdate,
reminder_id, db: Session = Depends(get_db), user: User = Depends(get_current_user),
title=payload.title, ) -> dict[str, Any]:
due_at=payload.due_at, try:
notes=payload.notes, return RemindersService(db, user.id).update(
all_day=payload.all_day, reminder_id,
recurrence=payload.recurrence, title=payload.title,
enabled=payload.enabled, due_at=payload.due_at,
) notes=payload.notes,
except ValueError as exc: all_day=payload.all_day,
raise HTTPException(status_code=404, detail=str(exc)) from exc recurrence=payload.recurrence,
enabled=payload.enabled,
)
@router.delete("/{reminder_id}") except ValueError as exc:
def delete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: raise HTTPException(status_code=404, detail=str(exc)) from exc
try:
return RemindersService(db).delete(reminder_id)
except ValueError as exc: @router.delete("/{reminder_id}")
raise HTTPException(status_code=404, detail=str(exc)) from exc 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)
@router.post("/{reminder_id}/complete") except ValueError as exc:
def complete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: raise HTTPException(status_code=404, detail=str(exc)) from exc
try:
return RemindersService(db).complete(reminder_id)
except ValueError as exc: @router.post("/{reminder_id}/complete")
raise HTTPException(status_code=404, detail=str(exc)) from exc def complete_reminder(reminder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return RemindersService(db, user.id).complete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+34
View File
@@ -0,0 +1,34 @@
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.settings.service import SETTING_KEYS, SettingsService
router = APIRouter()
class SettingsPatch(BaseModel):
openrouter_model: str | None = None
memory_extract_model: str | None = None
openrouter_reasoning_effort: str | None = None
rag_enabled: bool | None = None
rag_top_k: int | None = Field(default=None, ge=1, le=50)
@router.get("/settings")
def get_settings_route(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return SettingsService(db).snapshot()
@router.patch("/settings")
def patch_settings_route(
payload: SettingsPatch,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
updates = payload.model_dump(exclude_unset=True)
return SettingsService(db).patch(updates)
+118 -116
View File
@@ -1,116 +1,118 @@
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.base import get_db from app.auth.deps import get_current_user
from app.shopping.service import ShoppingService from app.db.base import get_db
from app.db.models import User
router = APIRouter() from app.shopping.service import ShoppingService
router = APIRouter()
class ListCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
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 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 class ItemInput(BaseModel):
unit: str = "" 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 class ItemsAdd(BaseModel):
items: list[ItemInput] = Field(min_length=1) list_id: int | None = None
list_name: str | None = None
items: list[ItemInput] = Field(min_length=1)
class ItemChecked(BaseModel):
checked: bool
class ItemChecked(BaseModel):
checked: bool
@router.get("")
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
return ShoppingService(db).snapshot() @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)) -> list[dict[str, Any]]:
return ShoppingService(db).list_lists(include_items=True) @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)) -> dict[str, Any]:
try: @router.post("/lists")
return ShoppingService(db).create_list(payload.name) def create_list(payload: ListCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
except ValueError as exc: try:
raise HTTPException(status_code=400, detail=str(exc)) from exc 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)) -> dict[str, Any]:
data = ShoppingService(db).get_list(list_id=list_id) @router.get("/lists/{list_id}")
if not data: def get_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
raise HTTPException(status_code=404, detail="List not found") data = ShoppingService(db, user.id).get_list(list_id=list_id)
return data 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)) -> dict[str, Any]:
try: @router.patch("/lists/{list_id}")
return ShoppingService(db).rename_list(list_id, payload.name) def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
except ValueError as exc: try:
raise HTTPException(status_code=400, detail=str(exc)) from exc 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)) -> dict[str, Any]:
try: @router.delete("/lists/{list_id}")
return ShoppingService(db).delete_list(list_id) def delete_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
except ValueError as exc: try:
raise HTTPException(status_code=404, detail=str(exc)) from exc 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)) -> dict[str, Any]:
try: @router.post("/items")
return ShoppingService(db).add_items( def add_items(payload: ItemsAdd, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
[i.model_dump() for i in payload.items], try:
list_id=payload.list_id, return ShoppingService(db, user.id).add_items(
list_name=payload.list_name, [i.model_dump() for i in payload.items],
) list_id=payload.list_id,
except ValueError as exc: list_name=payload.list_name,
raise HTTPException(status_code=400, detail=str(exc)) from exc )
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/items/{item_id}")
def set_item_checked(
item_id: int, @router.patch("/items/{item_id}")
payload: ItemChecked, def set_item_checked(
db: Session = Depends(get_db), item_id: int,
) -> dict[str, Any]: payload: ItemChecked,
try: db: Session = Depends(get_db), user: User = Depends(get_current_user),
return ShoppingService(db).set_item_checked(item_id, payload.checked) ) -> dict[str, Any]:
except ValueError as exc: try:
raise HTTPException(status_code=404, detail=str(exc)) from exc 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)) -> dict[str, Any]:
try: @router.delete("/items/{item_id}")
return ShoppingService(db).remove_item(item_id) def remove_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
except ValueError as exc: try:
raise HTTPException(status_code=404, detail=str(exc)) from exc 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)) -> dict[str, Any]:
try: @router.post("/lists/{list_id}/clear-checked")
return ShoppingService(db).clear_checked(list_id) def clear_checked(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
except ValueError as exc: try:
raise HTTPException(status_code=404, detail=str(exc)) from exc return ShoppingService(db, user.id).clear_checked(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+106 -118
View File
@@ -1,118 +1,106 @@
import hashlib import hashlib
import hmac import hmac
import json import json
import logging import logging
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.chat.notice_inbox import post_notice_to_latest_chat
from app.db.base import SessionLocal, get_db from app.config import get_settings
from app.db.models import ChatSession, Message, ProjectBinding from app.db.base import get_db
from app.projects.service import ProjectService from app.db.models import ProjectBinding
from app.projects.service import ProjectService
router = APIRouter()
logger = logging.getLogger(__name__) router = APIRouter()
logger = logging.getLogger(__name__)
def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
if not secret: def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
return True if not secret:
if not signature: return True
return False if not signature:
if signature.startswith("sha256="): return False
signature = signature[7:] if signature.startswith("sha256="):
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() signature = signature[7:]
return hmac.compare_digest(expected, signature) expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def _post_close_notice(results: list[dict[str, Any]], owner: str, repo: str) -> None:
if not results: def _post_close_notice(
return results: list[dict[str, Any]], owner: str, repo: str, user_id: int
db = SessionLocal() ) -> None:
try: if not results:
session = db.scalar( return
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1) lines = [f"🔀 **Push** `{owner}/{repo}`"]
) for item in results:
if not session: if "closed" in item:
session = ChatSession(title="Git") lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
db.add(session) elif "error" in item:
db.commit() lines.append(f"- ошибка: {item['error']}")
db.refresh(session) post_notice_to_latest_chat("\n".join(lines), user_id)
lines = [f"🔀 **Push** `{owner}/{repo}`"]
for item in results: @router.post("/webhooks/gitea")
if "closed" in item: async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}") body = await request.body()
elif "error" in item: settings = get_settings()
lines.append(f"- ошибка: {item['error']}") signature = (
request.headers.get("X-Gitea-Signature")
db.add(Message(session_id=session.id, role="notice", content="\n".join(lines))) or request.headers.get("X-Gogs-Signature")
db.commit() or request.headers.get("X-Hub-Signature-256")
finally: )
db.close()
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
@router.post("/webhooks/gitea")
async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]: payload = json.loads(body)
body = await request.body() if payload.get("secret") and settings.gitea_webhook_secret:
settings = get_settings() if payload.get("secret") != settings.gitea_webhook_secret:
signature = ( raise HTTPException(status_code=401, detail="Invalid webhook secret")
request.headers.get("X-Gitea-Signature")
or request.headers.get("X-Gogs-Signature") event = request.headers.get("X-Gitea-Event", "")
or request.headers.get("X-Hub-Signature-256") if event != "push":
) return {"ok": True, "skipped": event}
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret): repo = payload.get("repository", {})
raise HTTPException(status_code=401, detail="Invalid webhook signature") owner = repo.get("owner", {}).get("login", "")
repo_name = repo.get("name", "")
payload = json.loads(body) if not owner or not repo_name:
if payload.get("secret") and settings.gitea_webhook_secret: raise HTTPException(status_code=400, detail="Missing repository info")
if payload.get("secret") != settings.gitea_webhook_secret:
raise HTTPException(status_code=401, detail="Invalid webhook secret") binding = db.scalar(
select(ProjectBinding).where(
event = request.headers.get("X-Gitea-Event", "") ProjectBinding.gitea_owner == owner,
if event != "push": ProjectBinding.gitea_repo == repo_name,
return {"ok": True, "skipped": event} )
)
repo = payload.get("repository", {}) if not binding:
owner = repo.get("owner", {}).get("login", "") return {"ok": True, "skipped": "unknown repo"}
repo_name = repo.get("name", "")
if not owner or not repo_name: commits = list(payload.get("commits") or [])
raise HTTPException(status_code=400, detail="Missing repository info") if not commits:
head = payload.get("head_commit")
binding = db.scalar( if head:
select(ProjectBinding).where( commits = [head]
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo_name, logger.info(
) "Gitea push %s/%s ref=%s commits=%d",
) owner,
if not binding: repo_name,
return {"ok": True, "skipped": "unknown repo"} payload.get("ref", ""),
len(commits),
commits = list(payload.get("commits") or []) )
if not commits:
head = payload.get("head_commit") service = ProjectService(db, binding.user_id)
if head: results = service.process_push(owner, repo_name, commits)
commits = [head] if results:
logger.info("Gitea push results: %s", results)
logger.info( else:
"Gitea push %s/%s ref=%s commits=%d", logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name)
owner,
repo_name, _post_close_notice(results, owner, repo_name, binding.user_id)
payload.get("ref", ""),
len(commits), return {"ok": True, "results": results, "commits_processed": len(commits)}
)
service = ProjectService(db)
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)
return {"ok": True, "results": results, "commits_processed": len(commits)}
+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"]
+34
View File
@@ -0,0 +1,34 @@
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.tokens import hash_token
from app.db.base import get_db
from app.db.models import User
def _extract_token(request: Request) -> str | None:
auth = request.headers.get("Authorization", "")
if auth.lower().startswith("bearer "):
token = auth[7:].strip()
if token:
return token
header = request.headers.get("X-API-Token", "").strip()
return header or None
def get_current_user(
request: Request,
db: Session = Depends(get_db),
) -> User:
token = _extract_token(request)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API token")
token_hash = hash_token(token)
user = db.scalar(
select(User).where(User.api_token_hash == token_hash, User.is_active.is_(True))
)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API token")
return user
+61
View File
@@ -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
+100 -100
View File
@@ -1,100 +1,100 @@
from typing import Any from typing import Any
TOOLS_INSTRUCTIONS = """ TOOLS_INSTRUCTIONS = """
Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс). Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс).
Обязательные правила: Обязательные правила:
- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент. - Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент.
- Никогда не выдумывай статус таймера или список задач. - Никогда не выдумывай статус таймера или список задач.
- После вызова инструмента кратко объясни результат пользователю по-человечески. - После вызова инструмента кратко объясни результат пользователю по-человечески.
- Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break, - Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break,
stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history. stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history.
- Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items. - Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items.
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga). - «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД). - list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. - create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
- Фитнес: get_fitness_summary (date/days_ago), get_fitness_history, set_fitness_profile, log_meal, log_water, log_weight, log_workout, - Фитнес: 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. - «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history.
calc_fitness_targets, lookup_food, lookup_exercise, set_fitness_reminder. 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, recall_memories, forget_memory, update_profile, update_session_summary.
- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай. - «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе. - Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools. - Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
- Никогда не пиши «ожидаю ответа от системы». - Никогда не пиши «ожидаю ответа от системы».
- В текстовых ответах пользователю не используй эмодзи. - В текстовых ответах пользователю не используй эмодзи.
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай. - Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
- Утренний брифинг (погода + новости) → get_morning_briefing. - Утренний брифинг (погода + новости) → get_morning_briefing.
- Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй. - Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй.
- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list. - Покупки: 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. Не выдумывай списки. - «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки.
- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder. - Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.
- «Напомни через 15 минут», «завтра утром», «12 мая в 9:00» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]). - «Напомни через 15 минут», «завтра утром», «12 мая в 9:00» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]).
- День рождения, Новый год и другие праздники → recurrence yearly. - День рождения, Новый год и другие праздники → recurrence yearly.
- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе. - Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе.
""".strip() """.strip()
DEFAULT_CARD: dict[str, Any] = { DEFAULT_CARD: dict[str, Any] = {
"spec": "chara_card_v2", "spec": "chara_card_v2",
"spec_version": "2.0", "spec_version": "2.0",
"data": { "data": {
"name": "Домашний ассистент", "name": "Домашний ассистент",
"description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.", "description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.",
"personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.", "personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.",
"scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.", "scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.",
"first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?", "first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?",
"mes_example": "", "mes_example": "",
"system_prompt": "", "system_prompt": "",
"post_history_instructions": "", "post_history_instructions": "",
"alternate_greetings": [], "alternate_greetings": [],
"tags": ["assistant", "home", "pomodoro"], "tags": ["assistant", "home", "pomodoro"],
"creator": "", "creator": "",
"creator_notes": "", "creator_notes": "",
"character_version": "1.0", "character_version": "1.0",
"appearance_tags": "", "appearance_tags": "",
"appearance_prose": "", "appearance_prose": "",
"lora_name": "", "lora_name": "",
"lora_weight": 0.8, "lora_weight": 0.8,
"rp_persona_id": "", "rp_persona_id": "",
"sd_enabled": True, "sd_enabled": True,
}, },
} }
def normalize_card(raw: dict[str, Any]) -> dict[str, Any]: def normalize_card(raw: dict[str, Any]) -> dict[str, Any]:
if "data" in raw and isinstance(raw["data"], dict): if "data" in raw and isinstance(raw["data"], dict):
card = { card = {
"spec": raw.get("spec", "chara_card_v2"), "spec": raw.get("spec", "chara_card_v2"),
"spec_version": raw.get("spec_version", "2.0"), "spec_version": raw.get("spec_version", "2.0"),
"data": {**DEFAULT_CARD["data"], **raw["data"]}, "data": {**DEFAULT_CARD["data"], **raw["data"]},
} }
return card return card
if "name" in raw or "description" in raw: if "name" in raw or "description" in raw:
return { return {
"spec": "chara_card_v2", "spec": "chara_card_v2",
"spec_version": "2.0", "spec_version": "2.0",
"data": {**DEFAULT_CARD["data"], **raw}, "data": {**DEFAULT_CARD["data"], **raw},
} }
return DEFAULT_CARD.copy() return DEFAULT_CARD.copy()
def build_system_prompt(card: dict[str, Any]) -> str: def build_system_prompt(card: dict[str, Any]) -> str:
data = card.get("data", {}) data = card.get("data", {})
parts: list[str] = [] parts: list[str] = []
name = data.get("name", "Ассистент") name = data.get("name", "Ассистент")
parts.append(f"Ты — {name}.") parts.append(f"Ты — {name}.")
if data.get("system_prompt"): if data.get("system_prompt"):
parts.append(data["system_prompt"]) parts.append(data["system_prompt"])
if data.get("description"): if data.get("description"):
parts.append(data["description"]) parts.append(data["description"])
if data.get("personality"): if data.get("personality"):
parts.append(f"Характер: {data['personality']}") parts.append(f"Характер: {data['personality']}")
if data.get("scenario"): if data.get("scenario"):
parts.append(f"Сценарий: {data['scenario']}") parts.append(f"Сценарий: {data['scenario']}")
if data.get("post_history_instructions"): if data.get("post_history_instructions"):
parts.append(data["post_history_instructions"]) parts.append(data["post_history_instructions"])
parts.append(TOOLS_INSTRUCTIONS) parts.append(TOOLS_INSTRUCTIONS)
return "\n\n".join(part for part in parts if part.strip()) return "\n\n".join(part for part in parts if part.strip())
+43 -27
View File
@@ -1,27 +1,43 @@
import json import json
from pathlib import Path from datetime import datetime, timezone
from typing import Any from typing import Any
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card from sqlalchemy import select
from sqlalchemy.orm import Session
CARD_PATH = Path("./data/character.json")
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
from app.db.models import CharacterCard
class CharacterService:
def get_card(self) -> dict[str, Any]:
if CARD_PATH.is_file(): class CharacterService:
try: def __init__(self, db: Session, user_id: int):
raw = json.loads(CARD_PATH.read_text(encoding="utf-8")) self.db = db
return normalize_card(raw) self.user_id = user_id
except (json.JSONDecodeError, OSError):
pass def get_card(self) -> dict[str, Any]:
return normalize_card(DEFAULT_CARD) row = self.db.scalar(
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]: )
card = normalize_card(raw) if not row:
CARD_PATH.parent.mkdir(parents=True, exist_ok=True) return normalize_card(DEFAULT_CARD)
CARD_PATH.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8") try:
return card return normalize_card(json.loads(row.card_json or "{}"))
except json.JSONDecodeError:
def get_system_prompt(self) -> str: return normalize_card(DEFAULT_CARD)
return build_system_prompt(self.get_card())
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
card = normalize_card(raw)
row = self.db.scalar(
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
)
if not row:
row = CharacterCard(user_id=self.user_id, card_json="{}")
self.db.add(row)
self.db.flush()
row.card_json = json.dumps(card, ensure_ascii=False)
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return card
def get_system_prompt(self) -> str:
return build_system_prompt(self.get_card())
+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)
+47 -44
View File
@@ -1,44 +1,47 @@
"""Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю).""" """Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю)."""
from sqlalchemy import select from sqlalchemy import select
from app.db.base import SessionLocal from app.db.base import SessionLocal
from app.db.models import ChatSession, Message from app.db.models import ChatSession, Message
DISPLAY_ONLY_ROLES = frozenset({"notice", "character"}) DISPLAY_ONLY_ROLES = frozenset({"notice", "character"})
def _latest_chat_session(db) -> ChatSession: def _latest_chat_session(db, user_id: int) -> ChatSession:
session = db.scalar( session = db.scalar(
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1) select(ChatSession)
) .where(ChatSession.user_id == user_id)
if not session: .order_by(ChatSession.updated_at.desc())
session = ChatSession(title="Уведомления") .limit(1)
db.add(session) )
db.commit() if not session:
db.refresh(session) session = ChatSession(user_id=user_id, title="Уведомления")
return session db.add(session)
db.commit()
db.refresh(session)
def post_notice_to_latest_chat(content: str) -> int | None: return session
"""Сохраняет notice в последний активный чат. Возвращает session_id."""
db = SessionLocal()
try: def post_notice_to_latest_chat(content: str, user_id: int) -> int | None:
session = _latest_chat_session(db) """Сохраняет notice в последний активный чат пользователя. Возвращает session_id."""
db.add(Message(session_id=session.id, role="notice", content=content)) db = SessionLocal()
db.commit() try:
return session.id session = _latest_chat_session(db, user_id)
finally: db.add(Message(session_id=session.id, role="notice", content=content))
db.close() db.commit()
return session.id
finally:
def post_character_comment_to_latest_chat(content: str) -> int | None: db.close()
"""Реплика персонажа в UI; не попадает в контекст LLM (в отличие от assistant)."""
db = SessionLocal()
try: def post_character_comment_to_latest_chat(content: str, user_id: int) -> int | None:
session = _latest_chat_session(db) """Реплика персонажа в UI; не попадает в контекст LLM (в отличие от assistant)."""
db.add(Message(session_id=session.id, role="character", content=content)) db = SessionLocal()
db.commit() try:
return session.id session = _latest_chat_session(db, user_id)
finally: db.add(Message(session_id=session.id, role="character", content=content))
db.close() db.commit()
return session.id
finally:
db.close()
+432 -397
View File
@@ -1,397 +1,432 @@
import json import json
from typing import Any from typing import Any
from app.db.models import PomodoroSession from app.db.models import PomodoroSession
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
PHASE_LABELS = { PHASE_LABELS = {
PHASE_WORK: "Работа", PHASE_WORK: "Работа",
PHASE_SHORT_BREAK: "Короткий перерыв", PHASE_SHORT_BREAK: "Короткий перерыв",
PHASE_LONG_BREAK: "Длинный перерыв", PHASE_LONG_BREAK: "Длинный перерыв",
} }
def _format_time(seconds: int) -> str: def _format_time(seconds: int) -> str:
minutes, secs = divmod(max(0, seconds), 60) minutes, secs = divmod(max(0, seconds), 60)
return f"{minutes:02d}:{secs:02d}" return f"{minutes:02d}:{secs:02d}"
def format_phase_completed_notice( def format_phase_completed_notice(
session: PomodoroSession, session: PomodoroSession,
next_phase: str | None, next_phase: str | None,
) -> str: ) -> str:
phase_label = PHASE_LABELS.get(session.phase, session.phase) phase_label = PHASE_LABELS.get(session.phase, session.phase)
task = session.task_note or "без описания" task = session.task_note or "без описания"
lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"] lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
if next_phase == PHASE_SHORT_BREAK: if next_phase == PHASE_SHORT_BREAK:
lines.append("Дальше: короткий перерыв ☕") lines.append("Дальше: короткий перерыв ☕")
elif next_phase == PHASE_LONG_BREAK: elif next_phase == PHASE_LONG_BREAK:
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён") lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён")
elif next_phase == PHASE_WORK: elif next_phase == PHASE_WORK:
lines.append("Дальше: снова работа 💪") lines.append("Дальше: снова работа 💪")
else: else:
lines.append("Цикл сброшен. Можно отдохнуть и начать заново.") lines.append("Цикл сброшен. Можно отдохнуть и начать заново.")
return "\n".join(lines) return "\n".join(lines)
POMODORO_TOOL_NAMES = frozenset({ POMODORO_TOOL_NAMES = frozenset({
"get_pomodoro_status", "get_pomodoro_status",
"start_pomodoro", "start_pomodoro",
"start_short_break", "start_short_break",
"start_long_break", "start_long_break",
"stop_pomodoro", "stop_pomodoro",
"skip_pomodoro_phase", "skip_pomodoro_phase",
"reset_pomodoro_cycle", "reset_pomodoro_cycle",
"get_pomodoro_history", "get_pomodoro_history",
}) })
MEMORY_TOOL_NAMES = frozenset({ MEMORY_TOOL_NAMES = frozenset({
"remember_fact", "remember_fact",
"recall_memories", "recall_memories",
"forget_memory", "forget_memory",
"update_profile", "update_profile",
"update_session_summary", "update_session_summary",
}) })
FITNESS_TOOL_NAMES = frozenset({ FITNESS_TOOL_NAMES = frozenset({
"get_fitness_summary", "get_fitness_summary",
"get_fitness_history", "get_fitness_history",
"set_fitness_profile", "set_fitness_profile",
"calc_fitness_targets", "calc_fitness_targets",
"log_meal", "calc_body_composition",
"log_water", "log_meal",
"log_weight", "log_water",
"log_workout", "log_weight",
"lookup_food", "log_workout",
"lookup_exercise", "lookup_food",
"set_fitness_reminder", "lookup_exercise",
}) "set_fitness_reminder",
})
# Не засорять чат служебными ответами
REMINDER_TOOL_NAMES = frozenset({ # Не засорять чат служебными ответами
"list_reminders", REMINDER_TOOL_NAMES = frozenset({
"create_reminder", "list_reminders",
"update_reminder", "create_reminder",
"delete_reminder", "update_reminder",
"complete_reminder", "delete_reminder",
}) "complete_reminder",
})
SHOPPING_TOOL_NAMES = frozenset({
"list_shopping_lists", SHOPPING_TOOL_NAMES = frozenset({
"create_shopping_list", "list_shopping_lists",
"add_shopping_items", "create_shopping_list",
"check_shopping_item", "add_shopping_items",
"remove_shopping_item", "check_shopping_item",
"delete_shopping_list", "remove_shopping_item",
}) "delete_shopping_list",
})
TOOLS_SKIP_CHAT_NOTICE = frozenset({
"get_pomodoro_status", TOOLS_SKIP_CHAT_NOTICE = frozenset({
"recall_memories", "get_pomodoro_status",
"get_fitness_summary", "recall_memories",
"get_fitness_history", "get_fitness_summary",
"lookup_food", "get_fitness_history",
"lookup_exercise", "lookup_food",
"calc_fitness_targets", "lookup_exercise",
"get_weather", "calc_fitness_targets",
"get_morning_briefing", "calc_body_composition",
"list_shopping_lists", "get_weather",
"list_reminders", "get_morning_briefing",
}) "list_shopping_lists",
"list_reminders",
})
def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
return None
def _format_body_composition_notice(computed: dict[str, Any], *, headline: str) -> str:
try: parts: list[str] = []
data = json.loads(raw_result) bf = computed.get("body_fat_pct")
except json.JSONDecodeError: if bf is not None:
return None method = computed.get("body_fat_method")
if method == "navy":
if isinstance(data, dict) and "error" in data: parts.append(f"жир ≈{bf}% (Navy)")
if tool_name in POMODORO_TOOL_NAMES: elif method == "manual":
prefix = "" parts.append(f"жир {bf}%")
elif tool_name in MEMORY_TOOL_NAMES: else:
prefix = "🧠" parts.append(f"жир ≈{bf}%")
elif tool_name in FITNESS_TOOL_NAMES: if computed.get("whr") is not None:
prefix = "💪" parts.append(f"WHR {computed.get('whr')}")
elif tool_name in SHOPPING_TOOL_NAMES: if computed.get("ffmi") is not None:
prefix = "🛒" parts.append(f"FFMI {computed.get('ffmi')}")
elif tool_name in REMINDER_TOOL_NAMES: if parts:
prefix = "📅" return f"{headline}{', '.join(parts)}"
else: return headline
prefix = "📋"
return f"{prefix} {data['error']}" def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
if tool_name == "reset_pomodoro_cycle": return None
cycle = data.get("cycle", data)
return ( try:
"⏱ **Цикл помидоро сброшен** · " data = json.loads(raw_result)
f"прогресс: {cycle.get('completed_work_sessions', 0)}/" except json.JSONDecodeError:
f"{cycle.get('sessions_until_long_break', 4)}" return None
)
if isinstance(data, dict) and "error" in data:
if tool_name in ( if tool_name in POMODORO_TOOL_NAMES:
"get_pomodoro_status", prefix = ""
"start_pomodoro", elif tool_name in MEMORY_TOOL_NAMES:
"start_work", prefix = "🧠"
"start_short_break", elif tool_name in FITNESS_TOOL_NAMES:
"start_long_break", prefix = "💪"
"stop_pomodoro", elif tool_name in SHOPPING_TOOL_NAMES:
"skip_pomodoro_phase", prefix = "🛒"
): elif tool_name in REMINDER_TOOL_NAMES:
return _format_status_notice(data) prefix = "📅"
else:
if tool_name == "get_pomodoro_history": prefix = "📋"
return _format_history_notice(data) return f"{prefix} {data['error']}"
if tool_name == "create_work_item": if tool_name == "reset_pomodoro_cycle":
return _format_work_item_notice(data) cycle = data.get("cycle", data)
return (
if tool_name == "list_work_items": "⏱ **Цикл помидоро сброшен** · "
return _format_work_items_list_notice(data) f"прогресс: {cycle.get('completed_work_sessions', 0)}/"
f"{cycle.get('sessions_until_long_break', 4)}"
if tool_name == "list_taiga_tasks": )
return _format_taiga_tasks_notice(data)
if tool_name in (
if tool_name == "sync_taiga_projects": "get_pomodoro_status",
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**" "start_pomodoro",
"start_work",
if tool_name == "list_taiga_projects": "start_short_break",
if not isinstance(data, list) or not data: "start_long_break",
return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects." "stop_pomodoro",
lines = ["📋 **Проекты:**"] "skip_pomodoro_phase",
for p in data: ):
gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else "" return _format_status_notice(data)
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
return "\n".join(lines) if tool_name == "get_pomodoro_history":
return _format_history_notice(data)
if tool_name == "remember_fact" and data.get("ok"):
action = "обновлено" if data.get("action") == "updated" else "сохранено" if tool_name == "create_work_item":
return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}" return _format_work_item_notice(data)
if tool_name == "forget_memory" and data.get("ok"): if tool_name == "list_work_items":
return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}" return _format_work_items_list_notice(data)
if tool_name == "update_profile" and data.get("ok"): if tool_name == "list_taiga_tasks":
profile = data.get("profile") or {} return _format_taiga_tasks_notice(data)
parts = [f"{k}={v}" for k, v in profile.items() if v]
return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}" if tool_name == "sync_taiga_projects":
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
if tool_name == "update_session_summary" and data.get("ok"):
return "🧠 **Сводка чата сохранена**" if tool_name == "list_taiga_projects":
if not isinstance(data, list) or not data:
if tool_name == "log_meal" and data.get("ok"): return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects."
meal = data.get("meal", {}) lines = ["📋 **Проекты:**"]
est = "" if meal.get("estimated") else "" for p in data:
return ( gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else ""
f"💪 **Приём пищи** · {meal.get('description')} · " lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
f"{est}{meal.get('calories', 0):.0f} ккал " return "\n".join(lines)
f"{meal.get('protein_g', 0):.0f}{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})"
) if tool_name == "remember_fact" and data.get("ok"):
action = "обновлено" if data.get("action") == "updated" else "сохранено"
if tool_name == "log_water" and data.get("ok"): return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}"
w = data.get("water", {})
return f"💪 **Вода** +{w.get('amount_ml')} мл" if tool_name == "forget_memory" and data.get("ok"):
return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}"
if tool_name == "log_weight" and data.get("ok"):
m = data.get("metric", {}) if tool_name == "update_profile" and data.get("ok"):
return f"💪 **Вес** {m.get('weight_kg')} кг" profile = data.get("profile") or {}
parts = [f"{k}={v}" for k, v in profile.items() if v]
if tool_name == "log_workout" and data.get("ok"): return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}"
wo = data.get("workout", {})
return f"💪 **Тренировка** · {wo.get('title')}" if tool_name == "update_session_summary" and data.get("ok"):
return "🧠 **Сводка чата сохранена**"
if tool_name == "set_fitness_profile" and data.get("ok"):
p = data.get("profile", {}) if tool_name == "log_meal" and data.get("ok"):
return ( meal = data.get("meal", {})
f"💪 **Профиль** · {p.get('calorie_target')} ккал, " est = "" if meal.get("estimated") else ""
f"вода {p.get('water_l')} л" return (
) f"💪 **Приём пищи** · {meal.get('description')} · "
f"{est}{meal.get('calories', 0):.0f} ккал "
if tool_name == "set_fitness_reminder" and data.get("ok"): f"{meal.get('protein_g', 0):.0f}{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})"
r = data.get("reminder", {}) )
state = "вкл" if r.get("enabled") else "выкл"
return f"💪 **Напоминание {r.get('kind')}** · {state}" if tool_name == "log_water" and data.get("ok"):
w = data.get("water", {})
if tool_name == "generate_image" and data.get("ok"): return f"💪 **Вода** +{w.get('amount_ml')} мл"
url = data.get("url", "")
return f"🎨 **Картинка готова**\n\n![image]({url})" if tool_name == "log_weight" and data.get("ok"):
m = data.get("metric", {})
if tool_name == "create_shopping_list" and data.get("ok"): computed = data.get("computed") or {}
lst = data.get("list") or {} headline = f"💪 **Вес** {m.get('weight_kg')} кг"
action = "создан" if data.get("created") else "уже был" return _format_body_composition_notice(computed, headline=headline)
return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})"
if tool_name == "calc_body_composition" and isinstance(data, dict) and "error" not in data:
if tool_name == "add_shopping_items" and data.get("ok"): w = data.get("weight_kg")
added = data.get("added") or [] headline = "💪 **Состав тела** (расчёт)"
names = ", ".join(i.get("text", "") for i in added[:5]) if w is not None:
extra = f" +{len(added) - 5}" if len(added) > 5 else "" headline += f" · {w} кг"
return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}" msg = _format_body_composition_notice(data, headline=headline)
warnings = data.get("warnings") or []
if tool_name == "check_shopping_item" and data.get("ok"): if warnings:
item = data.get("item") or {} msg += f" · {'; '.join(warnings[:2])}"
state = "куплено" if item.get("checked") else "снята отметка" return msg
return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}"
if tool_name == "log_workout" and data.get("ok"):
if tool_name == "remove_shopping_item" and data.get("ok"): wo = data.get("workout", {})
removed = data.get("removed") or {} return f"💪 **Тренировка** · {wo.get('title')}"
return f"🛒 **Удалено** · {removed.get('text')}"
if tool_name == "set_fitness_profile" and data.get("ok"):
if tool_name == "delete_shopping_list" and data.get("ok"): p = data.get("profile", {})
return f"🛒 **Список удалён** · «{data.get('name')}»" return (
f"💪 **Профиль** · {p.get('calorie_target')} ккал, "
if tool_name == "create_reminder" and data.get("ok"): f"вода {p.get('water_l')} л"
r = data.get("reminder") or {} )
rec = r.get("recurrence", "none")
rec_label = f" · повтор {rec}" if rec and rec != "none" else "" if tool_name == "set_fitness_reminder" and data.get("ok"):
return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}" r = data.get("reminder", {})
state = "вкл" if r.get("enabled") else "выкл"
if tool_name == "update_reminder" and data.get("ok"): return f"💪 **Напоминание {r.get('kind')}** · {state}"
r = data.get("reminder") or {}
return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}" if tool_name == "generate_image" and data.get("ok"):
url = data.get("url", "")
if tool_name == "delete_reminder" and data.get("ok"): return f"🎨 **Картинка готова**\n\n![image]({url})"
return f"📅 **Напоминание удалено** · «{data.get('title')}»"
if tool_name == "create_shopping_list" and data.get("ok"):
if tool_name == "complete_reminder" and data.get("ok"): lst = data.get("list") or {}
r = data.get("reminder") or {} action = "создан" if data.get("created") else "уже был"
return f"📅 **Готово** · {r.get('title')}" return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})"
return None 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])
def _format_work_item_notice(data: dict[str, Any]) -> str | None: extra = f" +{len(added) - 5}" if len(added) > 5 else ""
if data.get("error"): return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}"
return f"📋 {data['error']}"
if not data.get("ok"): if tool_name == "check_shopping_item" and data.get("ok"):
return None item = data.get("item") or {}
taiga = data.get("taiga", {}) state = "куплено" if item.get("checked") else "снята отметка"
gitea = data.get("gitea", {}) return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}"
lines = [
"📋 **Создано:**", if tool_name == "remove_shopping_item" and data.get("ok"):
f"- Taiga: #{taiga.get('ref')}{taiga.get('subject')}", removed = data.get("removed") or {}
f"- URL: {taiga.get('url')}", return f"🛒 **Удалено** · {removed.get('text')}"
]
if gitea.get("url"): if tool_name == "delete_shopping_list" and data.get("ok"):
lines.append(f"- Gitea: {gitea.get('url')}") return f"🛒 **Список удалён** · «{data.get('name')}»"
if data.get("branch"):
lines.append(f"- Ветка: `{data['branch']}`") if tool_name == "create_reminder" and data.get("ok"):
subtasks = data.get("subtasks") or [] r = data.get("reminder") or {}
if subtasks: rec = r.get("recurrence", "none")
lines.append("**Подзадачи:**") rec_label = f" · повтор {rec}" if rec and rec != "none" else ""
for t in subtasks: return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}"
lines.append(f"- #{t.get('ref')} {t.get('subject')}")
return "\n".join(lines) if tool_name == "update_reminder" and data.get("ok"):
r = data.get("reminder") or {}
return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}"
def _format_work_items_list_notice(data: Any) -> str | None:
if not isinstance(data, list) or not data: if tool_name == "delete_reminder" and data.get("ok"):
return "📋 Локальных work items (созданных ассистентом) нет." return f"📅 **Напоминание удалено** · «{data.get('title')}»"
lines = ["📋 **Work items ассистента:**"]
for item in data[:15]: if tool_name == "complete_reminder" and data.get("ok"):
lines.append( r = data.get("reminder") or {}
f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} " return f"📅 **Готово** · {r.get('title')}"
f"({item.get('taiga_slug')})"
) return None
return "\n".join(lines)
def _format_work_item_notice(data: dict[str, Any]) -> str | None:
def _format_taiga_tasks_notice(data: Any) -> str | None: if data.get("error"):
if not isinstance(data, dict): return f"📋 {data['error']}"
return None if not data.get("ok"):
if data.get("error"): return None
return f"📋 {data['error']}" taiga = data.get("taiga", {})
gitea = data.get("gitea", {})
blocks = data.get("projects") or [] lines = [
total_stories = data.get("total_stories", 0) "📋 **Создано:**",
total_tasks = data.get("total_tasks", 0) f"- Taiga: #{taiga.get('ref')}{taiga.get('subject')}",
f"- URL: {taiga.get('url')}",
if not blocks or (total_stories == 0 and total_tasks == 0): ]
slug = blocks[0].get("slug") if len(blocks) == 1 else None if gitea.get("url"):
if slug: lines.append(f"- Gitea: {gitea.get('url')}")
return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga." if data.get("branch"):
return "📋 Открытых задач в Taiga не найдено." lines.append(f"- Ветка: `{data['branch']}`")
subtasks = data.get("subtasks") or []
lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"] if subtasks:
for block in blocks: lines.append("**Подзадачи:**")
stories = block.get("stories") or [] for t in subtasks:
tasks = block.get("tasks") or [] lines.append(f"- #{t.get('ref')} {t.get('subject')}")
if not stories and not tasks: return "\n".join(lines)
continue
lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):")
for s in stories: def _format_work_items_list_notice(data: Any) -> str | None:
lines.append(f"- story #{s.get('ref')} {s.get('subject')}") if not isinstance(data, list) or not data:
for t in tasks: return "📋 Локальных work items (созданных ассистентом) нет."
lines.append(f"- task #{t.get('ref')} {t.get('subject')}") lines = ["📋 **Work items ассистента:**"]
return "\n".join(lines) for item in data[:15]:
lines.append(
f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} "
def _format_status_notice(data: dict[str, Any]) -> str: f"({item.get('taiga_slug')})"
status = data.get("status", "idle") )
phase = data.get("phase", PHASE_WORK) return "\n".join(lines)
phase_label = PHASE_LABELS.get(phase, phase)
task = data.get("task_note") or "без описания"
remaining = data.get("remaining_seconds", 0) def _format_taiga_tasks_notice(data: Any) -> str | None:
duration = data.get("duration_min", 25) if not isinstance(data, dict):
cycle = data.get("cycle", {}) return None
cycle_info = "" if data.get("error"):
if cycle: return f"📋 {data['error']}"
cycle_info = (
f" · цикл {cycle.get('completed_work_sessions', 0)}/" blocks = data.get("projects") or []
f"{cycle.get('sessions_until_long_break', 4)}" total_stories = data.get("total_stories", 0)
) total_tasks = data.get("total_tasks", 0)
if status == "idle": if not blocks or (total_stories == 0 and total_tasks == 0):
return f"⏱ **Помидоро:** таймер не запущен{cycle_info}." slug = blocks[0].get("slug") if len(blocks) == 1 else None
if slug:
if status == "running": return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga."
return ( return "📋 Открытых задач в Taiga не найдено."
f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** "
f"из {duration} мин · _{task}_{cycle_info}" lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"]
) for block in blocks:
stories = block.get("stories") or []
if status == "paused": tasks = block.get("tasks") or []
elapsed = data.get("elapsed_seconds", 0) if not stories and not tasks:
return ( continue
f"**{phase_label} на паузе** · прошло {_format_time(elapsed)} " lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):")
f"из {duration} мин · _{task}_{cycle_info}" for s in stories:
) lines.append(f"- story #{s.get('ref')} {s.get('subject')}")
for t in tasks:
if status == "completed": lines.append(f"- task #{t.get('ref')} {t.get('subject')}")
return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_" return "\n".join(lines)
if status == "cancelled":
return f"⏱ **{phase_label} отменена** · _{task}_" def _format_status_notice(data: dict[str, Any]) -> str:
status = data.get("status", "idle")
return f"⏱ Помидоро: {status}" phase = data.get("phase", PHASE_WORK)
phase_label = PHASE_LABELS.get(phase, phase)
task = data.get("task_note") or "без описания"
def _format_history_notice(data: Any) -> str: remaining = data.get("remaining_seconds", 0)
if not isinstance(data, list) or not data: duration = data.get("duration_min", 25)
return "⏱ **История помидоро** пуста." cycle = data.get("cycle", {})
cycle_info = ""
lines = ["⏱ **История помидоро:**"] if cycle:
for item in data[:10]: cycle_info = (
task = item.get("task_note") or "без описания" f" · цикл {cycle.get('completed_work_sessions', 0)}/"
phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?")) f"{cycle.get('sessions_until_long_break', 4)}"
duration = item.get("duration_min", "?") )
lines.append(f"- {phase}: {task} ({duration} мин)")
if status == "idle":
return "\n".join(lines) return f"⏱ **Помидоро:** таймер не запущен{cycle_info}."
if status == "running":
def format_pomodoro_context(status: dict[str, Any]) -> str: return (
notice = _format_status_notice(status) f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** "
cycle = status.get("cycle", {}) f"из {duration} мин · _{task}_{cycle_info}"
extra = "" )
if cycle:
extra = ( if status == "paused":
f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, " elapsed = data.get("elapsed_seconds", 0)
f"перерыв {cycle.get('short_break_min')} мин, " return (
f"длинный {cycle.get('long_break_min')} мин." f"⏱ **{phase_label} на паузе** · прошло {_format_time(elapsed)} "
) f"из {duration} мин · _{task}_{cycle_info}"
return f"[Актуальный статус помидоро]\n{notice}{extra}" )
if status == "completed":
return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_"
if status == "cancelled":
return f"⏱ **{phase_label} отменена** · _{task}_"
return f"⏱ Помидоро: {status}"
def _format_history_notice(data: Any) -> str:
if not isinstance(data, list) or not data:
return "⏱ **История помидоро** пуста."
lines = ["⏱ **История помидоро:**"]
for item in data[:10]:
task = item.get("task_note") or "без описания"
phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?"))
duration = item.get("duration_min", "?")
lines.append(f"- {phase}: {task} ({duration} мин)")
return "\n".join(lines)
def format_pomodoro_context(status: dict[str, Any]) -> str:
notice = _format_status_notice(status)
cycle = status.get("cycle", {})
extra = ""
if cycle:
extra = (
f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, "
f"перерыв {cycle.get('short_break_min')} мин, "
f"длинный {cycle.get('long_break_min')} мин."
)
return f"[Актуальный статус помидоро]\n{notice}{extra}"
File diff suppressed because it is too large Load Diff
+468
View File
@@ -0,0 +1,468 @@
import asyncio
import json
import logging
import time
from collections.abc import AsyncIterator
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.base import SessionLocal
from app.character.service import CharacterService
from app.chat.history import sanitize_openai_messages, strip_historical_reasoning
from app.chat.notice_inbox import DISPLAY_ONLY_ROLES
from app.chat.notices import (
POMODORO_TOOL_NAMES,
format_pomodoro_context,
format_tool_notice,
)
from app.fitness.context import format_fitness_context, get_fitness_snapshot
from app.homelab.context import format_datetime_context
from app.homelab.openmeteo import format_weather_snapshot
from app.memory.context import (
format_identity_hint,
format_memory_context,
get_memory_snapshot,
)
from app.memory.extract import extract_after_turn
from app.projects.context import format_projects_context, get_projects_snapshot
from app.reminders.context import format_reminders_context, get_reminders_snapshot
from app.shopping.context import format_shopping_context, get_shopping_snapshot
from app.db.models import ChatSession, Message
from app.llm.client import LLMClient
from app.pomodoro.service import PomodoroService
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
MAX_TOOL_ROUNDS = 5
MAX_HISTORY_MESSAGES = 40
logger = logging.getLogger(__name__)
def _build_messages_for_session(session_id: int) -> list[dict[str, Any]]:
db = SessionLocal()
try:
service = ChatService(db)
session = service.get_session(session_id)
if not session:
return []
return service._build_messages(session)
finally:
db.close()
async def _extract_memory_background(
session_id: int,
user_text: str,
assistant_text: str,
) -> None:
db = SessionLocal()
try:
await extract_after_turn(db, session_id, user_text, assistant_text)
except Exception as exc:
logger.warning("Background memory extraction failed: %s", exc)
finally:
db.close()
class ChatService:
def __init__(self, db: Session):
self.db = db
self.llm = LLMClient()
self.character = CharacterService()
def list_sessions(self) -> list[ChatSession]:
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
return list(self.db.scalars(stmt).all())
def get_session(self, session_id: int) -> ChatSession | None:
return self.db.get(ChatSession, session_id)
def create_session(self, title: str = "Новый чат") -> ChatSession:
session = ChatSession(title=title)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return session
def delete_session(self, session_id: int) -> bool:
session = self.get_session(session_id)
if not session:
return False
self.db.delete(session)
self.db.commit()
return True
def _build_system_prompt(self, session_id: int | None = None) -> str:
status = PomodoroService(self.db).get_status()
memory_snapshot = get_memory_snapshot(self.db, session_id)
fitness_snapshot = get_fitness_snapshot(self.db)
shopping_snapshot = get_shopping_snapshot(self.db)
reminders_snapshot = get_reminders_snapshot(self.db)
projects_snapshot = get_projects_snapshot(self.db)
return (
f"{self.character.get_system_prompt()}\n\n"
f"{format_datetime_context(self.db)}\n\n"
f"{format_memory_context(memory_snapshot)}\n\n"
f"{format_fitness_context(fitness_snapshot)}\n\n"
f"{format_shopping_context(shopping_snapshot)}\n\n"
f"{format_reminders_context(reminders_snapshot)}\n\n"
f"{format_weather_snapshot()}\n\n"
f"{format_pomodoro_context(status)}\n\n"
f"{format_projects_context(projects_snapshot)}"
)
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
system_prompt = self._build_system_prompt(session.id)
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
last_user = next((m.content for m in reversed(all_chat) if m.role == "user"), "")
if last_user:
memory_snapshot = get_memory_snapshot(self.db, session.id)
identity_hint = format_identity_hint(memory_snapshot, last_user)
if identity_hint:
system_prompt += f"\n\n{identity_hint}"
if len(all_chat) > MAX_HISTORY_MESSAGES:
system_prompt += (
f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} "
f"из {len(all_chat)} сообщений. Раннее — в сводке сессии, если сохранена.]"
)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt}
]
chat_messages = all_chat[-MAX_HISTORY_MESSAGES:] if len(all_chat) > MAX_HISTORY_MESSAGES else all_chat
for msg in chat_messages:
content = msg.content or None
entry: dict[str, Any] = {"role": msg.role, "content": content}
if msg.tool_calls_json:
entry["tool_calls"] = json.loads(msg.tool_calls_json)
if not content:
entry["content"] = None
reasoning_data = LLMClient.deserialize_reasoning(msg.reasoning_json)
if reasoning_data:
LLMClient.attach_reasoning_to_message(
entry,
reasoning=reasoning_data.get("reasoning", ""),
reasoning_details=reasoning_data.get("reasoning_details"),
)
if msg.role == "tool" and msg.tool_call_id:
entry["tool_call_id"] = msg.tool_call_id
messages.append(entry)
messages = sanitize_openai_messages(messages)
messages = strip_historical_reasoning(messages)
return messages
def _save_message(
self,
session_id: int,
role: str,
content: str = "",
tool_calls: list[dict[str, Any]] | None = None,
tool_call_id: str | None = None,
reasoning_json: str | None = None,
) -> Message:
message = Message(
session_id=session_id,
role=role,
content=content,
tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None,
reasoning_json=reasoning_json,
tool_call_id=tool_call_id,
)
self.db.add(message)
session = self.get_session(session_id)
if session and role == "user" and session.title == "Новый чат" and content:
session.title = content[:60] + ("..." if len(content) > 60 else "")
self.db.commit()
self.db.refresh(message)
return message
def save_user_message(self, session_id: int, user_text: str) -> None:
self._save_message(session_id, "user", user_text)
async def _fallback_complete(
self,
messages: list[dict[str, Any]],
session_id: int,
) -> tuple[str, list[str], list[dict[str, Any]]]:
"""Нестриминговый запасной путь, если stream вернул пустоту."""
logger.info("chat session=%s fallback complete", session_id)
result: dict[str, Any] = {"content": "", "tool_calls": []}
for with_tools in (True, False):
result = await self.llm.complete(
messages,
tools=TOOL_DEFINITIONS if with_tools else None,
temperature=0.5,
visible_reply=True,
)
if (result.get("content") or "").strip() or result.get("tool_calls"):
break
tool_calls = result.get("tool_calls") or []
content = (result.get("content") or "").strip()
notices: list[str] = []
pomodoro_events: list[dict[str, Any]] = []
if tool_calls:
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": content or None,
"tool_calls": tool_calls,
}
messages.append(assistant_msg)
self._save_message(
session_id,
"assistant",
content,
tool_calls=tool_calls,
)
for tool_call in tool_calls:
fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
tool_result = await execute_tool(
self.db, fn["name"], args, session_id=session_id
)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call["id"],
"content": tool_result,
}
)
self._save_message(
session_id,
"tool",
tool_result,
tool_call_id=tool_call["id"],
)
notice = format_tool_notice(fn["name"], tool_result)
if notice:
self._save_message(session_id, "notice", notice)
notices.append(notice)
if fn["name"] in POMODORO_TOOL_NAMES:
pomodoro_events.append(
{"name": fn["name"], "result": json.loads(tool_result)}
)
followup = await self.llm.complete(
messages,
tools=None,
temperature=0.4,
visible_reply=True,
)
return (followup.get("content") or "").strip(), notices, pomodoro_events
return content, notices, pomodoro_events
async def stream_response(
self,
session_id: int,
user_text: str,
*,
user_message_saved: bool = False,
) -> AsyncIterator[str]:
session = self.get_session(session_id)
if not session:
yield self._sse("error", {"message": "Session not found"})
return
if not user_message_saved:
self._save_message(session_id, "user", user_text)
yield self._sse("status", {"phase": "preparing"})
t0 = time.monotonic()
messages = await asyncio.to_thread(_build_messages_for_session, session_id)
prepare_sec = time.monotonic() - t0
if not messages:
yield self._sse("error", {"message": "Session not found"})
return
yield self._sse("status", {"phase": "generating"})
streamed_reply_parts: list[str] = []
all_tool_notices: list[str] = []
tools_executed = 0
tool_round = 0
for _ in range(MAX_TOOL_ROUNDS):
tool_round += 1
t_round = time.monotonic()
content_parts: list[str] = []
tool_calls: list[dict[str, Any]] = []
reasoning = ""
reasoning_details: list[Any] | None = None
finish_reason = ""
# После tool-раунда стримим вживую; до tools — буфер (иначе текст «переписывает» notice).
stream_live = tools_executed > 0
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
if event["type"] == "content":
content_parts.append(event["content"])
if stream_live:
yield self._sse("token", {"content": event["content"]})
elif event["type"] == "reasoning":
reasoning = event.get("reasoning", "") or reasoning
if event.get("reasoning_details"):
reasoning_details = event["reasoning_details"]
elif event["type"] == "error":
logger.warning(
"chat session=%s llm_error round=%d prepare=%.2fs: %s",
session_id,
tool_round,
prepare_sec,
event.get("content"),
)
yield self._sse("error", {"message": event.get("content", "LLM error")})
return
elif event["type"] == "tool_calls":
tool_calls = event["tool_calls"]
elif event["type"] == "done":
finish_reason = event.get("finish_reason", "")
logger.info(
"chat session=%s round=%d prepare=%.2fs llm=%.2fs "
"content_len=%d tool_calls=%d finish_reason=%s reasoning_len=%d",
session_id,
tool_round,
prepare_sec,
time.monotonic() - t_round,
len("".join(content_parts)),
len(tool_calls),
finish_reason,
len(reasoning),
)
if tool_calls:
round_text = "".join(content_parts)
if round_text.strip():
streamed_reply_parts.append(round_text)
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": round_text or None,
"tool_calls": tool_calls,
}
LLMClient.attach_reasoning_to_message(
assistant_msg,
reasoning=reasoning,
reasoning_details=reasoning_details,
)
reasoning_json = LLMClient.serialize_reasoning(
reasoning=reasoning,
reasoning_details=reasoning_details,
)
messages.append(assistant_msg)
self._save_message(
session_id,
"assistant",
round_text,
tool_calls=tool_calls,
reasoning_json=reasoning_json,
)
round_notices: list[str] = []
for tool_call in tool_calls:
fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
result = await execute_tool(
self.db, fn["name"], args, session_id=session_id
)
tools_executed += 1
tool_message = {
"role": "tool",
"tool_call_id": tool_call["id"],
"content": result,
}
messages.append(tool_message)
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
notice = format_tool_notice(fn["name"], result)
if notice:
self._save_message(session_id, "notice", notice)
round_notices.append(notice)
all_tool_notices.append(notice)
if fn["name"] in POMODORO_TOOL_NAMES:
yield self._sse(
"pomodoro",
{"name": fn["name"], "result": json.loads(result)},
)
yield self._sse("status", {"phase": "tools"})
for notice in round_notices:
yield self._sse("notice", {"content": notice})
continue
if content_parts and not stream_live:
for part in content_parts:
yield self._sse("token", {"content": part})
final_content = "".join(content_parts).strip()
if not final_content and streamed_reply_parts and tools_executed == 0:
final_content = "".join(streamed_reply_parts).strip()
if not final_content and reasoning:
final_content = reasoning.strip()
if not final_content and tools_executed:
retry = await self.llm.complete(
messages,
tools=None,
temperature=0.4,
visible_reply=True,
)
final_content = (retry.get("content") or "").strip()
if final_content:
yield self._sse("token", {"content": final_content})
# Notices уже в чате как role=notice — не дублируем в assistant.
if not final_content:
final_content, fb_notices, fb_pomodoro = await self._fallback_complete(
messages, session_id
)
if final_content:
yield self._sse("token", {"content": final_content})
for notice in fb_notices:
yield self._sse("notice", {"content": notice})
for event in fb_pomodoro:
yield self._sse("pomodoro", event)
if not final_content:
logger.warning(
"chat session=%s empty_reply tools=%d rounds=%d finish_reason=%s",
session_id,
tools_executed,
tool_round,
finish_reason,
)
yield self._sse(
"error",
{
"message": (
"Модель не вернула ответ (finish_reason="
f"{finish_reason or 'unknown'}). "
"Попробуй новый чат или проверь OPENROUTER_MODEL."
),
},
)
return
self._save_message(session_id, "assistant", final_content)
logger.info(
"chat session=%s done tools=%d reply_len=%d total=%.2fs",
session_id,
tools_executed,
len(final_content),
time.monotonic() - t0,
)
yield self._sse("done", {})
if get_settings().memory_auto_extract:
asyncio.create_task(
_extract_memory_background(session_id, user_text, final_content)
)
return
yield self._sse("error", {"message": "Too many tool call rounds"})
@staticmethod
def _sse(event: str, data: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+138 -127
View File
@@ -1,127 +1,138 @@
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=(".env", "../.env"), env_file=(".env", "../.env"),
env_file_encoding="utf-8", env_file_encoding="utf-8",
extra="ignore", extra="ignore",
) )
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 8080 port: int = 8080
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. # Отдельная модель для JSON-задач (память, фитнес). Пусто = та же, что OPENROUTER_MODEL.
memory_extract_model: str = "" memory_extract_model: str = ""
# Некоторые модели (reasoning / без function calling) — выключить tools. # Некоторые модели (reasoning / без function calling) — выключить tools.
openrouter_tools_enabled: bool = True openrouter_tools_enabled: bool = True
# DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking. # DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking.
openrouter_reasoning_effort: str = "none" openrouter_reasoning_effort: str = "none"
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 memory_auto_extract: bool = True
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container default_user_username: str = "owner"
taiga_base_url: str = "http://host.docker.internal:9000" default_user_display_name: str = ""
taiga_username: str = "" default_api_token: str = ""
taiga_password: str = "" auth_required: bool = True
taiga_public_url: str = "https://taiga.grigowashere.ru"
qdrant_url: str = "http://qdrant:6333"
gitea_base_url: str = "http://host.docker.internal:3000" embedding_model: str = "openai/text-embedding-3-small"
gitea_token: str = "" rag_enabled: bool = False
gitea_public_url: str = "https://git.grigowashere.ru" rag_top_k: int = 8
gitea_webhook_secret: str = "" memory_facts_in_context: int = 8
repos_dir: str = "/data/repos" # Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
taiga_base_url: str = "http://host.docker.internal:9000"
wger_base_url: str = "https://wger.de/api/v2" taiga_username: str = ""
openfoodfacts_base_url: str = "https://world.openfoodfacts.org" taiga_password: str = ""
fitness_reminders_enabled: bool = True taiga_public_url: str = "https://taiga.grigowashere.ru"
reminders_enabled: bool = True
gitea_base_url: str = "http://host.docker.internal:3000"
openmeteo_base_url: str = "http://192.168.1.109:8085" gitea_token: str = ""
weather_lat: float = 59.9343 gitea_public_url: str = "https://git.grigowashere.ru"
weather_lon: float = 30.3351 gitea_webhook_secret: str = ""
weather_location_name: str = "Санкт-Петербург"
weather_cache_sec: int = 300 repos_dir: str = "/data/repos"
news_rss_urls: str = ( wger_base_url: str = "https://wger.de/api/v2"
"https://habr.com/ru/rss/all/all/," openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
"https://www.reddit.com/r/programming/.rss" fitness_reminders_enabled: bool = True
) reminders_enabled: bool = True
news_cache_sec: int = 1800
news_max_items: int = 7 openmeteo_base_url: str = "http://192.168.1.109:8085"
weather_lat: float = 59.9343
morning_digest_enabled: bool = True weather_lon: float = 30.3351
morning_digest_hour: int = 8 weather_location_name: str = "Санкт-Петербург"
morning_digest_minute: int = 0 weather_cache_sec: int = 300
comfyui_base_url: str = "http://192.168.1.109:8188" news_rss_urls: str = (
comfyui_enabled: bool = True "https://habr.com/ru/rss/all/all/,"
# Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty "https://www.reddit.com/r/programming/.rss"
comfyui_checkpoint: str = "" )
comfyui_unet: str = "anima-preview3-base.safetensors" news_cache_sec: int = 1800
comfyui_clip: str = "qwen_3_06b_base.safetensors" news_max_items: int = 7
comfyui_vae: str = "qwen_image_vae.safetensors"
comfyui_style_lora: str = "anima-preview-3-masterpieces-v5.safetensors" morning_digest_enabled: bool = True
comfyui_style_lora_weight: float = 0.7 morning_digest_hour: int = 8
comfyui_steps: int = 30 morning_digest_minute: int = 0
comfyui_cfg: float = 4.0
comfyui_sampler: str = "er_sde" comfyui_base_url: str = "http://192.168.1.109:8188"
comfyui_scheduler: str = "simple" comfyui_enabled: bool = True
comfyui_width: int = 1024 # Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty
comfyui_height: int = 720 comfyui_checkpoint: str = ""
comfyui_negative_prompt: str = ( comfyui_unet: str = "anima-preview3-base.safetensors"
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia" comfyui_clip: str = "qwen_3_06b_base.safetensors"
) comfyui_vae: str = "qwen_image_vae.safetensors"
comfyui_poll_interval_sec: float = 2.0 comfyui_style_lora: str = "anima-preview-3-masterpieces-v5.safetensors"
comfyui_timeout_sec: float = 180.0 comfyui_style_lora_weight: float = 0.7
comfyui_rofl_enabled: bool = True comfyui_steps: int = 30
comfyui_rofl_max_per_day: int = 1 comfyui_cfg: float = 4.0
comfyui_rofl_probability: float = 0.15 comfyui_sampler: str = "er_sde"
comfyui_rofl_min_interval_hours: int = 12 comfyui_scheduler: str = "simple"
generated_media_dir: str = "./data/generated" comfyui_width: int = 1024
comfyui_height: int = 720
netdata_base_url: str = "http://host.docker.internal:19999" comfyui_negative_prompt: str = (
netdata_public_url: str = "" "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
netdata_alerts_enabled: bool = True )
netdata_poll_interval_sec: int = 120 comfyui_poll_interval_sec: float = 2.0
comfyui_timeout_sec: float = 180.0
rp_chat_base_url: str = "http://host.docker.internal:8201" comfyui_rofl_enabled: bool = True
rp_chat_enabled: bool = True comfyui_rofl_max_per_day: int = 1
rp_chat_timeout_sec: float = 300.0 comfyui_rofl_probability: float = 0.15
comfyui_rofl_min_interval_hours: int = 12
@property generated_media_dir: str = "./data/generated"
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] netdata_base_url: str = "http://host.docker.internal:19999"
netdata_public_url: str = ""
@property netdata_alerts_enabled: bool = True
def taiga_configured(self) -> bool: netdata_poll_interval_sec: int = 120
return bool(self.taiga_username and self.taiga_password)
rp_chat_base_url: str = "http://host.docker.internal:8201"
@property rp_chat_enabled: bool = True
def gitea_configured(self) -> bool: rp_chat_timeout_sec: float = 300.0
return bool(self.gitea_token)
@property
@property def cors_origins_list(self) -> list[str]:
def news_rss_urls_list(self) -> list[str]: return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
return [u.strip() for u in self.news_rss_urls.split(",") if u.strip()]
@property
def load_system_prompt(self) -> str: def taiga_configured(self) -> bool:
path = Path(self.system_prompt_path) return bool(self.taiga_username and self.taiga_password)
if path.is_file():
return path.read_text(encoding="utf-8") @property
return "Ты домашний ИИ-ассистент. Общайся на русском." def gitea_configured(self) -> bool:
return bool(self.gitea_token)
@lru_cache @property
def get_settings() -> Settings: def news_rss_urls_list(self) -> list[str]:
return Settings() return [u.strip() for u in self.news_rss_urls.split(",") if u.strip()]
def load_system_prompt(self) -> str:
path = Path(self.system_prompt_path)
if path.is_file():
return path.read_text(encoding="utf-8")
return "Ты домашний ИИ-ассистент. Общайся на русском."
@lru_cache
def get_settings() -> Settings:
return Settings()
+127
View File
@@ -0,0 +1,127 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file_encoding="utf-8",
extra="ignore",
)
host: str = "0.0.0.0"
port: int = 8080
openrouter_api_key: str = ""
openrouter_model: str = "deepseek/deepseek-chat"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
# Отдельная модель для JSON-задач (память, фитнес). Пусто = та же, что OPENROUTER_MODEL.
memory_extract_model: str = ""
# Некоторые модели (reasoning / без function calling) — выключить tools.
openrouter_tools_enabled: bool = True
# DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking.
openrouter_reasoning_effort: str = "none"
database_url: str = "sqlite:///./data/assistant.db"
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
system_prompt_path: str = "./prompts/assistant.md"
memory_auto_extract: bool = True
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
taiga_base_url: str = "http://host.docker.internal:9000"
taiga_username: str = ""
taiga_password: str = ""
taiga_public_url: str = "https://taiga.grigowashere.ru"
gitea_base_url: str = "http://host.docker.internal:3000"
gitea_token: str = ""
gitea_public_url: str = "https://git.grigowashere.ru"
gitea_webhook_secret: str = ""
repos_dir: str = "/data/repos"
wger_base_url: str = "https://wger.de/api/v2"
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
fitness_reminders_enabled: bool = True
reminders_enabled: bool = True
openmeteo_base_url: str = "http://192.168.1.109:8085"
weather_lat: float = 59.9343
weather_lon: float = 30.3351
weather_location_name: str = "Санкт-Петербург"
weather_cache_sec: int = 300
news_rss_urls: str = (
"https://habr.com/ru/rss/all/all/,"
"https://www.reddit.com/r/programming/.rss"
)
news_cache_sec: int = 1800
news_max_items: int = 7
morning_digest_enabled: bool = True
morning_digest_hour: int = 8
morning_digest_minute: int = 0
comfyui_base_url: str = "http://192.168.1.109:8188"
comfyui_enabled: bool = True
# Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty
comfyui_checkpoint: str = ""
comfyui_unet: str = "anima-preview3-base.safetensors"
comfyui_clip: str = "qwen_3_06b_base.safetensors"
comfyui_vae: str = "qwen_image_vae.safetensors"
comfyui_style_lora: str = "anima-preview-3-masterpieces-v5.safetensors"
comfyui_style_lora_weight: float = 0.7
comfyui_steps: int = 30
comfyui_cfg: float = 4.0
comfyui_sampler: str = "er_sde"
comfyui_scheduler: str = "simple"
comfyui_width: int = 1024
comfyui_height: int = 720
comfyui_negative_prompt: str = (
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
)
comfyui_poll_interval_sec: float = 2.0
comfyui_timeout_sec: float = 180.0
comfyui_rofl_enabled: bool = True
comfyui_rofl_max_per_day: int = 1
comfyui_rofl_probability: float = 0.15
comfyui_rofl_min_interval_hours: int = 12
generated_media_dir: str = "./data/generated"
netdata_base_url: str = "http://host.docker.internal:19999"
netdata_public_url: str = ""
netdata_alerts_enabled: bool = True
netdata_poll_interval_sec: int = 120
rp_chat_base_url: str = "http://host.docker.internal:8201"
rp_chat_enabled: bool = True
rp_chat_timeout_sec: float = 300.0
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@property
def taiga_configured(self) -> bool:
return bool(self.taiga_username and self.taiga_password)
@property
def gitea_configured(self) -> bool:
return bool(self.gitea_token)
@property
def news_rss_urls_list(self) -> list[str]:
return [u.strip() for u in self.news_rss_urls.split(",") if u.strip()]
def load_system_prompt(self) -> str:
path = Path(self.system_prompt_path)
if path.is_file():
return path.read_text(encoding="utf-8")
return "Ты домашний ИИ-ассистент. Общайся на русском."
@lru_cache
def get_settings() -> Settings:
return Settings()
+94
View File
@@ -0,0 +1,94 @@
from sqlalchemy import inspect, text
from app.db.base import engine
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
inspector = inspect(engine)
if table not in inspector.get_table_names():
return
columns = {col["name"] for col in inspector.get_columns(table)}
if column in columns:
return
with engine.begin() as conn:
conn.execute(text(ddl))
def run_fitness_migrations() -> None:
inspector = inspect(engine)
if "fitness_profiles" in inspector.get_table_names():
_add_column_if_missing(
"fitness_profiles",
"baseline_steps",
"ALTER TABLE fitness_profiles ADD COLUMN baseline_steps INTEGER",
)
_add_column_if_missing(
"fitness_profiles",
"baseline_workout_kcal",
"ALTER TABLE fitness_profiles ADD COLUMN baseline_workout_kcal FLOAT",
)
if "workout_logs" in inspector.get_table_names():
_add_column_if_missing(
"workout_logs",
"active_calories",
"ALTER TABLE workout_logs ADD COLUMN active_calories FLOAT",
)
_add_column_if_missing(
"workout_logs",
"total_calories",
"ALTER TABLE workout_logs ADD COLUMN total_calories FLOAT",
)
_add_column_if_missing(
"workout_logs",
"steps",
"ALTER TABLE workout_logs ADD COLUMN steps INTEGER",
)
if "step_logs" not in inspector.get_table_names():
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE step_logs ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"steps INTEGER DEFAULT 0, "
"active_calories FLOAT, "
"source VARCHAR(32) DEFAULT 'manual', "
"notes TEXT DEFAULT ''"
")"
)
)
if "body_metrics" in inspector.get_table_names():
_add_column_if_missing(
"body_metrics",
"neck_cm",
"ALTER TABLE body_metrics ADD COLUMN neck_cm FLOAT",
)
_add_column_if_missing(
"body_metrics",
"hip_cm",
"ALTER TABLE body_metrics ADD COLUMN hip_cm FLOAT",
)
_add_column_if_missing(
"body_metrics",
"body_fat_method",
"ALTER TABLE body_metrics ADD COLUMN body_fat_method VARCHAR(16)",
)
_add_column_if_missing(
"body_metrics",
"whr",
"ALTER TABLE body_metrics ADD COLUMN whr FLOAT",
)
_add_column_if_missing(
"body_metrics",
"lbm_kg",
"ALTER TABLE body_metrics ADD COLUMN lbm_kg FLOAT",
)
_add_column_if_missing(
"body_metrics",
"ffmi",
"ALTER TABLE body_metrics ADD COLUMN ffmi FLOAT",
)
+249
View File
@@ -0,0 +1,249 @@
from __future__ import annotations
import json
import logging
import secrets
from pathlib import Path
from sqlalchemy import inspect, text
from app.auth.tokens import hash_token
from app.character.card import DEFAULT_CARD, normalize_card
from app.config import get_settings
from app.db.base import engine
logger = logging.getLogger(__name__)
TENANT_TABLES = (
"chat_sessions",
"user_profile",
"memory_facts",
"fitness_profiles",
"body_metrics",
"food_logs",
"water_logs",
"workout_logs",
"step_logs",
"fitness_reminders",
"shopping_lists",
"reminders",
"documents",
"pomodoro_cycles",
"pomodoro_sessions",
"project_bindings",
"work_items",
)
LEGACY_CARD_PATH = Path("./data/character.json")
def _table_exists(name: str) -> bool:
return name in inspect(engine).get_table_names()
def _columns(table: str) -> set[str]:
if not _table_exists(table):
return set()
return {col["name"] for col in inspect(engine).get_columns(table)}
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
if column in _columns(table):
return
with engine.begin() as conn:
conn.execute(text(ddl))
def _ensure_users_table() -> None:
if _table_exists("users"):
return
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE users ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"username VARCHAR(64) NOT NULL UNIQUE, "
"display_name VARCHAR(255) DEFAULT '', "
"api_token_hash VARCHAR(64) NOT NULL, "
"is_active BOOLEAN DEFAULT 1, "
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP"
")"
)
)
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_api_token_hash ON users (api_token_hash)"))
def _ensure_character_cards_table() -> None:
if _table_exists("character_cards"):
return
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE character_cards ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, "
"card_json TEXT DEFAULT '{}', "
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP"
")"
)
)
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_character_cards_user_id ON character_cards (user_id)"))
def _add_user_id_columns() -> None:
for table in TENANT_TABLES:
if not _table_exists(table):
continue
_add_column_if_missing(
table,
"user_id",
f"ALTER TABLE {table} ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE",
)
with engine.begin() as conn:
conn.execute(text(f"CREATE INDEX IF NOT EXISTS ix_{table}_user_id ON {table} (user_id)"))
def _ensure_default_user() -> tuple[int, str | None]:
settings = get_settings()
with engine.begin() as conn:
row = conn.execute(text("SELECT id FROM users ORDER BY id LIMIT 1")).fetchone()
if row:
return int(row[0]), None
username = settings.default_user_username or "owner"
display_name = settings.default_user_display_name or username
plain_token = (settings.default_api_token or "").strip()
generated = False
if not plain_token:
plain_token = secrets.token_urlsafe(32)
generated = True
token_hash = hash_token(plain_token)
conn.execute(
text(
"INSERT INTO users (id, username, display_name, api_token_hash, is_active) "
"VALUES (1, :username, :display_name, :token_hash, 1)"
),
{"username": username, "display_name": display_name, "token_hash": token_hash},
)
if generated:
logger.warning(
"DEFAULT_API_TOKEN not set — generated token for user '%s': %s",
username,
plain_token,
)
return 1, plain_token
return 1, None
def _backfill_user_id(default_user_id: int = 1) -> None:
with engine.begin() as conn:
for table in TENANT_TABLES:
if not _table_exists(table):
continue
conn.execute(
text(f"UPDATE {table} SET user_id = :uid WHERE user_id IS NULL"),
{"uid": default_user_id},
)
def _rebuild_shopping_unique() -> None:
if not _table_exists("shopping_lists"):
return
with engine.begin() as conn:
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_shopping_lists_user_name ON shopping_lists (user_id, name)"))
def _rebuild_project_bindings_unique() -> None:
if not _table_exists("project_bindings"):
return
with engine.begin() as conn:
conn.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_project_bindings_user_slug "
"ON project_bindings (user_id, taiga_slug)"
)
)
def _import_character_card(user_id: int) -> None:
with engine.begin() as conn:
existing = conn.execute(
text("SELECT id FROM character_cards WHERE user_id = :uid"),
{"uid": user_id},
).fetchone()
if existing:
return
card = normalize_card(DEFAULT_CARD)
if LEGACY_CARD_PATH.is_file():
try:
raw = json.loads(LEGACY_CARD_PATH.read_text(encoding="utf-8"))
card = normalize_card(raw)
except (json.JSONDecodeError, OSError):
pass
conn.execute(
text("INSERT INTO character_cards (user_id, card_json) VALUES (:uid, :json)"),
{"uid": user_id, "json": json.dumps(card, ensure_ascii=False)},
)
def _backfill_qdrant_user_id(default_user_id: int = 1) -> None:
settings = get_settings()
if not settings.rag_enabled:
return
try:
from app.rag.store import COLLECTION_DOC_CHUNKS, COLLECTION_FACTS, COLLECTION_SUMMARIES, _client
except Exception:
logger.exception("Qdrant backfill skipped")
return
try:
client = _client()
except Exception:
logger.warning('Qdrant unavailable, skipping user_id backfill')
return
for collection in (COLLECTION_FACTS, COLLECTION_SUMMARIES, COLLECTION_DOC_CHUNKS):
try:
if not client.collection_exists(collection):
continue
except Exception:
logger.warning('Qdrant unavailable for collection %s', collection)
continue
offset = None
while True:
points, offset = client.scroll(
collection_name=collection,
limit=100,
offset=offset,
with_payload=True,
with_vectors=False,
)
if not points:
break
missing = [point.id for point in points if (point.payload or {}).get("user_id") is None]
if missing:
client.set_payload(
collection_name=collection,
payload={"user_id": default_user_id},
points=missing,
)
if offset is None:
break
logger.info("Qdrant user_id backfill completed for user_id=%s", default_user_id)
def run_multi_user_migrations() -> str | None:
"""Returns newly generated API token if any."""
_ensure_users_table()
_ensure_character_cards_table()
_add_user_id_columns()
user_id, new_token = _ensure_default_user()
_backfill_user_id(user_id)
_rebuild_shopping_unique()
_rebuild_project_bindings_unique()
_import_character_card(user_id)
_backfill_qdrant_user_id(user_id)
return new_token
+396 -299
View File
@@ -1,299 +1,396 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base from app.db.base import Base
class ChatSession(Base): class User(Base):
__tablename__ = "chat_sessions" __tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(255), default="Новый чат") username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) display_name: Mapped[str] = mapped_column(String(255), default="")
updated_at: Mapped[datetime] = mapped_column( api_token_hash: Mapped[str] = mapped_column(String(64), index=True)
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() is_active: Mapped[bool] = mapped_column(Boolean, default=True)
) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
messages: Mapped[list["Message"]] = relationship(
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at" class CharacterCard(Base):
) __tablename__ = "character_cards"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
class Message(Base): user_id: Mapped[int] = mapped_column(
__tablename__ = "messages" ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) card_json: Mapped[str] = mapped_column(Text, default="{}")
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True) updated_at: Mapped[datetime] = mapped_column(
role: Mapped[str] = mapped_column(String(32)) DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
content: Mapped[str] = mapped_column(Text, default="") )
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True) class ChatSession(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) __tablename__ = "chat_sessions"
session: Mapped["ChatSession"] = relationship(back_populates="messages") id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
class PomodoroCycle(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__tablename__ = "pomodoro_cycles" updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) )
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
short_break_min: Mapped[int] = mapped_column(Integer, default=5) messages: Mapped[list["Message"]] = relationship(
long_break_min: Mapped[int] = mapped_column(Integer, default=15) back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4) )
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
task_note: Mapped[str] = mapped_column(Text, default="")
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True) class Message(Base):
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0) __tablename__ = "messages"
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
) session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
role: Mapped[str] = mapped_column(String(32))
content: Mapped[str] = mapped_column(Text, default="")
class PomodoroSession(Base): tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
__tablename__ = "pomodoro_sessions" reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
status: Mapped[str] = mapped_column(String(32), default="idle")
phase: Mapped[str] = mapped_column(String(32), default="work") session: Mapped["ChatSession"] = relationship(back_populates="messages")
duration_min: Mapped[int] = mapped_column(Integer, default=25)
task_note: Mapped[str] = mapped_column(Text, default="")
result: Mapped[str | None] = mapped_column(Text, nullable=True) class PomodoroCycle(Base):
completed: Mapped[bool] = mapped_column(Boolean, default=False) __tablename__ = "pomodoro_cycles"
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0) work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) short_break_min: Mapped[int] = mapped_column(Integer, default=5)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) long_break_min: Mapped[int] = mapped_column(Integer, default=15)
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
class TaigaProject(Base): task_note: Mapped[str] = mapped_column(Text, default="")
__tablename__ = "taiga_projects" auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) updated_at: Mapped[datetime] = mapped_column(
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True) DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
name: Mapped[str] = mapped_column(String(255)) )
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class PomodoroSession(Base):
__tablename__ = "pomodoro_sessions"
class ProjectBinding(Base):
__tablename__ = "project_bindings" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) status: Mapped[str] = mapped_column(String(32), default="idle")
taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True) phase: Mapped[str] = mapped_column(String(32), default="work")
gitea_owner: Mapped[str] = mapped_column(String(255), default="") duration_min: Mapped[int] = mapped_column(Integer, default=25)
gitea_repo: Mapped[str] = mapped_column(String(255), default="") task_note: Mapped[str] = mapped_column(Text, default="")
default_branch: Mapped[str] = mapped_column(String(64), default="main") result: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column( completed: Mapped[bool] = mapped_column(Boolean, default=False)
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
class UserProfile(Base): finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__tablename__ = "user_profile" created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
data_json: Mapped[str] = mapped_column(Text, default="{}") class TaigaProject(Base):
updated_at: Mapped[datetime] = mapped_column( __tablename__ = "taiga_projects"
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
name: Mapped[str] = mapped_column(String(255))
class MemoryFact(Base): slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
__tablename__ = "memory_facts" synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
category: Mapped[str] = mapped_column(String(64), default="fact", index=True) class ProjectBinding(Base):
content: Mapped[str] = mapped_column(Text) __tablename__ = "project_bindings"
source: Mapped[str] = mapped_column(String(32), default="user") __table_args__ = (UniqueConstraint("user_id", "taiga_slug", name="uq_project_bindings_user_slug"),)
session_id: Mapped[int | None] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=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)
importance: Mapped[int] = mapped_column(Integer, default=3) taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) gitea_owner: Mapped[str] = mapped_column(String(255), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) gitea_repo: Mapped[str] = mapped_column(String(255), default="")
updated_at: Mapped[datetime] = mapped_column( default_branch: Mapped[str] = mapped_column(String(64), default="main")
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() updated_at: Mapped[datetime] = mapped_column(
) DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class SessionSummary(Base):
__tablename__ = "session_summaries" class UserProfile(Base):
__tablename__ = "user_profile"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
) data_json: Mapped[str] = mapped_column(Text, default="{}")
summary: Mapped[str] = mapped_column(Text, default="") updated_at: Mapped[datetime] = mapped_column(
message_count: Mapped[int] = mapped_column(Integer, default=0) DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
updated_at: Mapped[datetime] = mapped_column( )
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class MemoryFact(Base):
__tablename__ = "memory_facts"
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)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
sex: Mapped[str] = mapped_column(String(16), default="male") content: Mapped[str] = mapped_column(Text)
age: Mapped[int] = mapped_column(Integer, default=30) source: Mapped[str] = mapped_column(String(32), default="user")
height_cm: Mapped[float] = mapped_column(Float, default=170.0) session_id: Mapped[int | None] = mapped_column(
weight_kg: Mapped[float] = mapped_column(Float, default=70.0) ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
activity_level: Mapped[str] = mapped_column(String(32), default="moderate") )
goal: Mapped[str] = mapped_column(String(32), default="maintain") importance: Mapped[int] = mapped_column(Integer, default=3)
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True) active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0) updated_at: Mapped[datetime] = mapped_column(
protein_g: Mapped[float] = mapped_column(Float, default=140.0) DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
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( class SessionSummary(Base):
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() __tablename__ = "session_summaries"
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(
class BodyMetric(Base): ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
__tablename__ = "body_metrics" )
summary: Mapped[str] = mapped_column(Text, default="")
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) message_count: Mapped[int] = mapped_column(Integer, default=0)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(
weight_kg: Mapped[float] = mapped_column(Float) DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True) )
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
notes: Mapped[str] = mapped_column(Text, default="") class FitnessProfile(Base):
__tablename__ = "fitness_profiles"
class FoodLog(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
__tablename__ = "food_logs" user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
sex: Mapped[str] = mapped_column(String(16), default="male")
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) age: Mapped[int] = mapped_column(Integer, default=30)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) height_cm: Mapped[float] = mapped_column(Float, default=170.0)
meal_type: Mapped[str] = mapped_column(String(32), default="snack") weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
description: Mapped[str] = mapped_column(Text, default="") activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
calories: Mapped[float] = mapped_column(Float, default=0) goal: Mapped[str] = mapped_column(String(32), default="maintain")
protein_g: Mapped[float] = mapped_column(Float, default=0) target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
fat_g: Mapped[float] = mapped_column(Float, default=0) weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
carbs_g: Mapped[float] = mapped_column(Float, default=0) baseline_steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
source: Mapped[str] = mapped_column(String(32), default="llm") baseline_workout_kcal: Mapped[float | None] = mapped_column(Float, nullable=True)
estimated: Mapped[bool] = mapped_column(Boolean, default=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)
class WaterLog(Base): carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
__tablename__ = "water_logs" water_l: Mapped[float] = mapped_column(Float, default=2.5)
updated_at: Mapped[datetime] = mapped_column(
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) )
amount_ml: Mapped[int] = mapped_column(Integer)
class BodyMetric(Base):
class WorkoutLog(Base): __tablename__ = "body_metrics"
__tablename__ = "workout_logs"
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)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
title: Mapped[str] = mapped_column(String(255), default="Тренировка") weight_kg: Mapped[float] = mapped_column(Float)
notes: Mapped[str] = mapped_column(Text, default="") body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True) body_fat_method: Mapped[str | None] = mapped_column(String(16), nullable=True)
exercises_json: Mapped[str] = mapped_column(Text, default="[]") 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)
class FitnessReminder(Base): hip_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
__tablename__ = "fitness_reminders" whr: Mapped[float | None] = mapped_column(Float, nullable=True)
lbm_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) ffmi: Mapped[float | None] = mapped_column(Float, nullable=True)
kind: Mapped[str] = mapped_column(String(32)) notes: Mapped[str] = mapped_column(Text, default="")
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) class FoodLog(Base):
enabled: Mapped[bool] = mapped_column(Boolean, default=True) __tablename__ = "food_logs"
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=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)
class ShoppingList(Base): logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__tablename__ = "shopping_lists" meal_type: Mapped[str] = mapped_column(String(32), default="snack")
description: Mapped[str] = mapped_column(Text, default="")
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) calories: Mapped[float] = mapped_column(Float, default=0)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True) protein_g: Mapped[float] = mapped_column(Float, default=0)
sort_order: Mapped[int] = mapped_column(Integer, default=0) fat_g: Mapped[float] = mapped_column(Float, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) carbs_g: Mapped[float] = mapped_column(Float, default=0)
updated_at: Mapped[datetime] = mapped_column( source: Mapped[str] = mapped_column(String(32), default="llm")
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() estimated: Mapped[bool] = mapped_column(Boolean, default=True)
)
items: Mapped[list["ShoppingListItem"]] = relationship( class StepLog(Base):
back_populates="shopping_list", __tablename__ = "step_logs"
cascade="all, delete-orphan",
order_by="ShoppingListItem.sort_order, ShoppingListItem.id", 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)
class ShoppingListItem(Base): active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
__tablename__ = "shopping_list_items" source: Mapped[str] = mapped_column(String(32), default="manual")
notes: Mapped[str] = mapped_column(Text, default="")
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 class WaterLog(Base):
) __tablename__ = "water_logs"
text: Mapped[str] = mapped_column(String(500))
quantity: Mapped[float | None] = mapped_column(Float, nullable=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
unit: Mapped[str] = mapped_column(String(64), default="") user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True) logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
sort_order: Mapped[int] = mapped_column(Integer, default=0) amount_ml: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items") class WorkoutLog(Base):
__tablename__ = "workout_logs"
class Reminder(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
__tablename__ = "reminders" 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())
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) title: Mapped[str] = mapped_column(String(255), default="Тренировка")
title: Mapped[str] = mapped_column(String(255)) notes: Mapped[str] = mapped_column(Text, default="")
notes: Mapped[str] = mapped_column(Text, default="") duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False) total_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
recurrence: Mapped[str] = mapped_column(String(16), default="none") steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True) exercises_json: Mapped[str] = mapped_column(Text, default="[]")
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") class FitnessReminder(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) __tablename__ = "fitness_reminders"
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
) 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)
class AssistantState(Base): minute: Mapped[int] = mapped_column(Integer, default=0)
__tablename__ = "assistant_state" interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
key: Mapped[str] = mapped_column(String(128), primary_key=True) last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=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 ShoppingList(Base):
) __tablename__ = "shopping_lists"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_shopping_lists_user_name"),)
class WorkItem(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
__tablename__ = "work_items" user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(255), index=True)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) sort_order: Mapped[int] = mapped_column(Integer, default=0)
taiga_slug: Mapped[str] = mapped_column(String(255), index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
taiga_project_id: Mapped[int] = mapped_column(Integer) updated_at: Mapped[datetime] = mapped_column(
taiga_story_id: Mapped[int] = mapped_column(Integer) DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True) )
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="") items: Mapped[list["ShoppingListItem"]] = relationship(
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) back_populates="shopping_list",
suggested_branch: Mapped[str] = mapped_column(String(255), default="") cascade="all, delete-orphan",
raw_text: Mapped[str] = mapped_column(Text, default="") order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
title: Mapped[str] = mapped_column(String(500), default="") )
status: Mapped[str] = mapped_column(String(32), default="open")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) class ShoppingListItem(Base):
__tablename__ = "shopping_list_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
list_id: Mapped[int] = mapped_column(
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
)
text: Mapped[str] = mapped_column(String(500))
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
unit: Mapped[str] = mapped_column(String(64), default="")
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
class Reminder(Base):
__tablename__ = "reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255))
notes: Mapped[str] = mapped_column(Text, default="")
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
recurrence: Mapped[str] = mapped_column(String(16), default="none")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AssistantState(Base):
__tablename__ = "assistant_state"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[str] = mapped_column(Text, default="")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class WorkItem(Base):
__tablename__ = "work_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
taiga_project_id: Mapped[int] = mapped_column(Integer)
taiga_story_id: Mapped[int] = mapped_column(Integer)
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
raw_text: Mapped[str] = mapped_column(Text, default="")
title: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(32), default="open")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Document(Base):
__tablename__ = "documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255), default="")
filename: Mapped[str] = mapped_column(String(255), default="")
content_hash: Mapped[str] = mapped_column(String(64), default="", index=True)
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
chunks: Mapped[list["DocumentChunk"]] = relationship(
back_populates="document",
cascade="all, delete-orphan",
order_by="DocumentChunk.chunk_index",
)
class DocumentChunk(Base):
__tablename__ = "document_chunks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
document_id: Mapped[int] = mapped_column(
ForeignKey("documents.id", ondelete="CASCADE"), index=True
)
chunk_index: Mapped[int] = mapped_column(Integer, default=0)
content: Mapped[str] = mapped_column(Text, default="")
document: Mapped["Document"] = relationship(back_populates="chunks")
+299
View File
@@ -0,0 +1,299 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class ChatSession(Base):
__tablename__ = "chat_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
messages: Mapped[list["Message"]] = relationship(
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
)
class Message(Base):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
role: Mapped[str] = mapped_column(String(32))
content: Mapped[str] = mapped_column(Text, default="")
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
session: Mapped["ChatSession"] = relationship(back_populates="messages")
class PomodoroCycle(Base):
__tablename__ = "pomodoro_cycles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
task_note: Mapped[str] = mapped_column(Text, default="")
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class PomodoroSession(Base):
__tablename__ = "pomodoro_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
status: Mapped[str] = mapped_column(String(32), default="idle")
phase: Mapped[str] = mapped_column(String(32), default="work")
duration_min: Mapped[int] = mapped_column(Integer, default=25)
task_note: Mapped[str] = mapped_column(Text, default="")
result: Mapped[str | None] = mapped_column(Text, nullable=True)
completed: Mapped[bool] = mapped_column(Boolean, default=False)
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class TaigaProject(Base):
__tablename__ = "taiga_projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
name: Mapped[str] = mapped_column(String(255))
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ProjectBinding(Base):
__tablename__ = "project_bindings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
default_branch: Mapped[str] = mapped_column(String(64), default="main")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class UserProfile(Base):
__tablename__ = "user_profile"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
data_json: Mapped[str] = mapped_column(Text, default="{}")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class MemoryFact(Base):
__tablename__ = "memory_facts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
content: Mapped[str] = mapped_column(Text)
source: Mapped[str] = mapped_column(String(32), default="user")
session_id: Mapped[int | None] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
)
importance: Mapped[int] = mapped_column(Integer, default=3)
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class SessionSummary(Base):
__tablename__ = "session_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
)
summary: Mapped[str] = mapped_column(Text, default="")
message_count: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class FitnessProfile(Base):
__tablename__ = "fitness_profiles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sex: Mapped[str] = mapped_column(String(16), default="male")
age: Mapped[int] = mapped_column(Integer, default=30)
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
goal: Mapped[str] = mapped_column(String(32), default="maintain")
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
water_l: Mapped[float] = mapped_column(Float, default=2.5)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class BodyMetric(Base):
__tablename__ = "body_metrics"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
weight_kg: Mapped[float] = mapped_column(Float)
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
notes: Mapped[str] = mapped_column(Text, default="")
class FoodLog(Base):
__tablename__ = "food_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
description: Mapped[str] = mapped_column(Text, default="")
calories: Mapped[float] = mapped_column(Float, default=0)
protein_g: Mapped[float] = mapped_column(Float, default=0)
fat_g: Mapped[float] = mapped_column(Float, default=0)
carbs_g: Mapped[float] = mapped_column(Float, default=0)
source: Mapped[str] = mapped_column(String(32), default="llm")
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
class WaterLog(Base):
__tablename__ = "water_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
amount_ml: Mapped[int] = mapped_column(Integer)
class WorkoutLog(Base):
__tablename__ = "workout_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
notes: Mapped[str] = mapped_column(Text, default="")
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
class FitnessReminder(Base):
__tablename__ = "fitness_reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
kind: Mapped[str] = mapped_column(String(32))
hour: Mapped[int] = mapped_column(Integer, default=12)
minute: Mapped[int] = mapped_column(Integer, default=0)
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ShoppingList(Base):
__tablename__ = "shopping_lists"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
items: Mapped[list["ShoppingListItem"]] = relationship(
back_populates="shopping_list",
cascade="all, delete-orphan",
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
)
class ShoppingListItem(Base):
__tablename__ = "shopping_list_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
list_id: Mapped[int] = mapped_column(
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
)
text: Mapped[str] = mapped_column(String(500))
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
unit: Mapped[str] = mapped_column(String(64), default="")
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
class Reminder(Base):
__tablename__ = "reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(255))
notes: Mapped[str] = mapped_column(Text, default="")
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
recurrence: Mapped[str] = mapped_column(String(16), default="none")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AssistantState(Base):
__tablename__ = "assistant_state"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[str] = mapped_column(Text, default="")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class WorkItem(Base):
__tablename__ = "work_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
taiga_project_id: Mapped[int] = mapped_column(Integer)
taiga_story_id: Mapped[int] = mapped_column(Integer)
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
raw_text: Mapped[str] = mapped_column(Text, default="")
title: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(32), default="open")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
BASELINE_STEPS_BY_LEVEL: dict[str, int] = {
"sedentary": 5000,
"light": 7000,
"moderate": 9000,
"active": 11000,
"very_active": 13000,
}
WORKOUT_KCAL_PER_SESSION = 200
KCAL_PER_STEP_PER_KG = 0.0005
FALLBACK_KCAL_PER_MIN = 6
@dataclass
class ActivityBonus:
steps: int
steps_baseline: int
steps_bonus_kcal: float
workout_active_kcal: float
workout_baseline_kcal: float
workout_bonus_kcal: float
total_bonus_kcal: float
scale_factor: float
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def baseline_steps(profile: dict[str, Any]) -> int:
override = profile.get("baseline_steps")
if override is not None:
return int(override)
level = str(profile.get("activity_level") or "moderate")
return BASELINE_STEPS_BY_LEVEL.get(level, 9000)
def baseline_workout_kcal_day(profile: dict[str, Any]) -> float:
override = profile.get("baseline_workout_kcal")
if override is not None:
return float(override)
weekly = int(profile.get("weekly_workouts") or 3)
return round(weekly * WORKOUT_KCAL_PER_SESSION / 7, 1)
def estimate_workout_active_kcal(workout: dict[str, Any]) -> float:
active = workout.get("active_calories")
if active is not None:
return float(active)
duration = workout.get("duration_min")
if duration:
return float(duration) * FALLBACK_KCAL_PER_MIN
return 0.0
def steps_bonus_kcal(*, steps: int, baseline_steps: int, weight_kg: float) -> float:
extra_steps = max(0, steps - baseline_steps)
return round(extra_steps * weight_kg * KCAL_PER_STEP_PER_KG, 1)
def compute_activity_bonus(
profile: dict[str, Any],
*,
steps_total: int,
workouts: list[dict[str, Any]],
) -> ActivityBonus:
weight_kg = float(profile.get("weight_kg") or 70)
steps_base = baseline_steps(profile)
workout_base = baseline_workout_kcal_day(profile)
s_bonus = steps_bonus_kcal(steps=steps_total, baseline_steps=steps_base, weight_kg=weight_kg)
workout_active = round(sum(estimate_workout_active_kcal(w) for w in workouts), 1)
w_bonus = max(0.0, round(workout_active - workout_base, 1))
total_bonus = round(s_bonus + w_bonus, 1)
base_cal = float(profile.get("calorie_target") or 2000)
scale_factor = 1.0 if base_cal <= 0 else round((base_cal + total_bonus) / base_cal, 4)
return ActivityBonus(
steps=steps_total,
steps_baseline=steps_base,
steps_bonus_kcal=s_bonus,
workout_active_kcal=workout_active,
workout_baseline_kcal=workout_base,
workout_bonus_kcal=w_bonus,
total_bonus_kcal=total_bonus,
scale_factor=scale_factor,
)
def _targets_dict(
*,
calories: float,
protein_g: float,
fat_g: float,
carbs_g: float,
water_ml: float,
) -> dict[str, float]:
return {
"calories": round(calories),
"protein_g": round(protein_g),
"fat_g": round(fat_g),
"carbs_g": round(carbs_g),
"water_ml": round(water_ml),
}
def build_base_targets(profile: dict[str, Any]) -> dict[str, float]:
water_l = float(profile.get("water_l") or 2.5)
return _targets_dict(
calories=float(profile.get("calorie_target") or 2000),
protein_g=float(profile.get("protein_g") or 140),
fat_g=float(profile.get("fat_g") or 65),
carbs_g=float(profile.get("carbs_g") or 200),
water_ml=water_l * 1000,
)
def scale_targets(
base_targets: dict[str, float],
bonus_kcal: float,
) -> tuple[dict[str, float], dict[str, float]]:
"""Return (effective_targets, targets_base). Water is not scaled."""
targets_base = dict(base_targets)
base_cal = float(base_targets["calories"])
if bonus_kcal <= 0 or base_cal <= 0:
return dict(base_targets), targets_base
scale = (base_cal + bonus_kcal) / base_cal
effective = _targets_dict(
calories=base_cal + bonus_kcal,
protein_g=float(base_targets["protein_g"]) * scale,
fat_g=float(base_targets["fat_g"]) * scale,
carbs_g=float(base_targets["carbs_g"]) * scale,
water_ml=float(base_targets["water_ml"]),
)
return effective, targets_base
+128
View File
@@ -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
+94 -55
View File
@@ -1,55 +1,94 @@
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.fitness.service import FitnessService from app.fitness.service import FitnessService
def get_fitness_snapshot(db: Session) -> dict[str, Any]: def get_fitness_snapshot(db: Session, user_id: int) -> dict[str, Any]:
return FitnessService(db).snapshot() return FitnessService(db, user_id).snapshot()
def format_fitness_context(snapshot: dict[str, Any]) -> str: def format_fitness_context(snapshot: dict[str, Any]) -> str:
lines = ["[Фитнес — сводка на сегодня]"] lines = ["[Фитнес — сводка на сегодня]"]
profile = snapshot.get("profile") profile = snapshot.get("profile")
if not profile: if not profile:
lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.") lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.")
else: else:
lines.append( lines.append(
f"Цели: {profile.get('calorie_target')} ккал, " f"Цели (база): {profile.get('calorie_target')} ккал, "
f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, " f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, "
f"вода {profile.get('water_l')} л" f"вода {profile.get('water_l')} л"
) )
if profile.get("goal"): if profile.get("goal"):
lines.append( lines.append(
f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, " f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, "
f"рост {profile.get('height_cm')} см" f"рост {profile.get('height_cm')} см"
) )
today = snapshot.get("today") or {} today = snapshot.get("today") or {}
totals = today.get("totals") or {} totals = today.get("totals") or {}
targets = today.get("targets") or {} targets = today.get("targets") or {}
water_l = totals.get("water_ml", 0) / 1000 targets_base = today.get("targets_base") or {}
water_target = targets.get("water_ml", 2500) / 1000 activity = today.get("activity") or {}
steps_total = today.get("steps_total") or 0
lines.append("") water_l = totals.get("water_ml", 0) / 1000
lines.append( water_target = targets.get("water_ml", 2500) / 1000
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · " if profile and (activity.get("total_bonus_kcal") or steps_total):
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · " lines.append(
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г" f"Активность: шаги {steps_total} (база {activity.get('steps_baseline', 0)}), "
) f"бонус +{activity.get('total_bonus_kcal', 0)} ккал"
lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л") )
base_cal = targets_base.get("calories", profile.get("calorie_target"))
workouts = today.get("workouts") or [] lines.append(f"Эффективная цель ккал: {base_cal}{targets.get('calories', base_cal)}")
if workouts:
lines.append(f"Тренировок сегодня: {len(workouts)}") lines.append("")
lines.append(
lines.append("") f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
lines.append( f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
"Правила: log_meal, log_water, log_weight, log_workout, get_fitness_summary (date/days_ago), get_fitness_history, " f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. " f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г"
"Еда — оценка LLM (≈), пользователь может уточнить." )
) lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л")
return "\n".join(lines)
workouts = today.get("workouts") or []
if workouts:
lines.append(f"Тренировок сегодня: {len(workouts)}")
stats = snapshot.get("workout_stats") or {}
if stats.get("count"):
lines.append(
f"Тренировки за {stats.get('days', 7)} дн.: {stats.get('count')} "
f"(цель/нед {stats.get('weekly_target')}, серия {stats.get('streak')} дн.)"
)
latest = (snapshot.get("body_metrics") or [None])[0]
if latest:
lines.append("")
lines.append("Антропометрия (последняя):")
parts = [f"{latest.get('weight_kg')} кг"]
if latest.get("body_fat_pct") is not None:
method = latest.get("body_fat_method") or "?"
parts.append(f"жир {latest.get('body_fat_pct')}% ({method})")
if latest.get("neck_cm"):
parts.append(f"шея {latest.get('neck_cm')}")
if latest.get("waist_cm"):
parts.append(f"талия {latest.get('waist_cm')}")
if latest.get("hip_cm"):
parts.append(f"бёдра {latest.get('hip_cm')}")
if latest.get("whr"):
parts.append(f"WHR {latest.get('whr')}")
if latest.get("ffmi"):
parts.append(f"FFMI {latest.get('ffmi')}")
lines.append(" · ".join(parts))
lines.append("")
lines.append(
"Правила: log_meal, log_water, log_weight (обхваты → Navy), log_steps, log_workout (date/days_ago), "
"calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"Еда — оценка LLM (≈), пользователь может уточнить."
)
return chr(10).join(lines)
+111 -114
View File
@@ -1,114 +1,111 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.chat.notice_inbox import post_notice_to_latest_chat
from app.db.base import SessionLocal from app.config import get_settings
from app.db.models import ChatSession, FitnessReminder, Message from app.db.models import FitnessReminder, User
from app.fitness.service import FitnessService from app.fitness.service import FitnessService
KIND_LABELS = { KIND_LABELS = {
"water": "Вода", "water": "Вода",
"meal": "Еда", "meal": "Еда",
"workout": "Тренировка", "workout": "Тренировка",
"weigh_in": "Взвешивание", "weigh_in": "Взвешивание",
} }
def _post_fitness_notice(content: str) -> None: def _build_notice(kind: str, summary: dict) -> str:
db = SessionLocal() label = KIND_LABELS.get(kind, kind)
try: totals = summary.get("totals") or {}
session = db.scalar( targets = summary.get("targets") or {}
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1) water_l = totals.get("water_ml", 0) / 1000
) water_target = targets.get("water_ml", 2500) / 1000
if not session: cals = totals.get("calories", 0)
session = ChatSession(title="Фитнес") cal_target = targets.get("calories", 2000)
db.add(session)
db.commit() if kind == "water":
db.refresh(session) return (
db.add(Message(session_id=session.id, role="notice", content=content)) f"💪 **{label}** · выпито {water_l:.1f}/{water_target:.1f} л сегодня. "
db.commit() "Пора выпить стакан воды."
finally: )
db.close() if kind == "meal":
return (
f"💪 **{label}** · {cals:.0f}/{cal_target:.0f} ккал за день. "
def _build_notice(kind: str, summary: dict) -> str: "Не забудь залогировать приём пищи."
label = KIND_LABELS.get(kind, kind) )
totals = summary.get("totals") or {} if kind == "workout":
targets = summary.get("targets") or {} workouts = summary.get("workouts") or []
water_l = totals.get("water_ml", 0) / 1000 if workouts:
water_target = targets.get("water_ml", 2500) / 1000 return f"💪 **{label}** · сегодня уже была тренировка. Отдыхай или лёгкая активность."
cals = totals.get("calories", 0) return "💪 **Тренировка** · запланирована на сегодня. Время двигаться!"
cal_target = targets.get("calories", 2000) if kind == "weigh_in":
return "💪 **Взвешивание** · пора записать вес (log_weight)."
if kind == "water": return f"💪 **{label}** · напоминание"
return (
f"💪 **{label}** · выпито {water_l:.1f}/{water_target:.1f} л сегодня. "
"Пора выпить стакан воды." def _check_user_reminders(db: Session, user_id: int) -> list[str]:
) now = datetime.now(timezone.utc)
if kind == "meal": service = FitnessService(db, user_id)
return ( summary = service.get_daily_summary()
f"💪 **{label}** · {cals:.0f}/{cal_target:.0f} ккал за день. " fired: list[str] = []
"Не забудь залогировать приём пищи."
) reminders = db.scalars(
if kind == "workout": select(FitnessReminder).where(
workouts = summary.get("workouts") or [] FitnessReminder.user_id == user_id,
if workouts: FitnessReminder.enabled.is_(True),
return f"💪 **{label}** · сегодня уже была тренировка. Отдыхай или лёгкая активность." )
return "💪 **Тренировка** · запланирована на сегодня. Время двигаться!" ).all()
if kind == "weigh_in":
return "💪 **Взвешивание** · пора записать вес (log_weight)." for rem in reminders:
return f"💪 **{label}** · напоминание" should_fire = False
if rem.interval_hours:
def check_reminders(db: Session) -> list[str]: if rem.last_fired_at is None:
if not get_settings().fitness_reminders_enabled: should_fire = now.hour >= rem.hour
return [] else:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc) should_fire = delta >= timedelta(hours=rem.interval_hours)
service = FitnessService(db) else:
summary = service.get_daily_summary() if rem.kind == "weigh_in":
fired: list[str] = [] if rem.last_fired_at:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
reminders = db.scalars( should_fire = delta >= timedelta(days=7)
select(FitnessReminder).where(FitnessReminder.enabled.is_(True)) else:
).all() should_fire = now.hour == rem.hour and now.minute >= rem.minute
else:
for rem in reminders: if rem.last_fired_at:
should_fire = False last = rem.last_fired_at.replace(tzinfo=timezone.utc)
already_today = last.date() == now.date()
if rem.interval_hours: if already_today:
if rem.last_fired_at is None: continue
should_fire = now.hour >= rem.hour should_fire = now.hour == rem.hour and now.minute >= rem.minute
else:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc) if not should_fire:
should_fire = delta >= timedelta(hours=rem.interval_hours) continue
else:
if rem.kind == "weigh_in": notice = _build_notice(rem.kind, summary)
if rem.last_fired_at: rem.last_fired_at = now
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc) fired.append(notice)
should_fire = delta >= timedelta(days=7)
else: if fired:
should_fire = now.hour == rem.hour and now.minute >= rem.minute for notice in fired:
else: post_notice_to_latest_chat(notice, user_id)
if rem.last_fired_at:
last = rem.last_fired_at.replace(tzinfo=timezone.utc) return fired
already_today = last.date() == now.date()
if already_today:
continue def check_reminders(db: Session) -> list[str]:
should_fire = now.hour == rem.hour and now.minute >= rem.minute if not get_settings().fitness_reminders_enabled:
return []
if not should_fire:
continue users = db.scalars(select(User).where(User.is_active.is_(True))).all()
all_fired: list[str] = []
notice = _build_notice(rem.kind, summary) for user in users:
rem.last_fired_at = now all_fired.extend(_check_user_reminders(db, user.id))
fired.append(notice)
if all_fired:
if fired: db.commit()
db.commit()
for notice in fired: return all_fired
_post_fitness_notice(notice)
return fired
File diff suppressed because it is too large Load Diff
+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,
}
+96 -66
View File
@@ -1,66 +1,96 @@
import json import json
from typing import Any from typing import Any
from app.llm.client import LLMClient from app.llm.client import LLMClient
from app.projects.structuring import strip_markdown_json from app.projects.structuring import strip_markdown_json
MEAL_PROMPT = """ MEAL_PROMPT = """
Преобразуй описание еды в JSON. Только JSON, без markdown. Преобразуй описание еды в JSON. Только JSON, без markdown.
Схема: Схема:
{ {
"meal_type": "breakfast|lunch|dinner|snack", "meal_type": "breakfast|lunch|dinner|snack",
"description": "краткое описание", "description": "краткое описание",
"calories": 0, "calories": 0,
"protein_g": 0, "protein_g": 0,
"fat_g": 0, "fat_g": 0,
"carbs_g": 0, "carbs_g": 0,
"estimated": true "estimated": true
} }
Правила: Правила:
- Оцени ккал и БЖУ по типичным значениям для России/СНГ. - Оцени ккал и БЖУ по типичным значениям для России/СНГ.
- Все числа — float/int, метрическая система (г, ккал). - Все числа — float/int, метрическая система (г, ккал).
- meal_type угадай из контекста или snack. - meal_type угадай из контекста или snack.
- estimated всегда true для LLM-оценки. - estimated всегда true для LLM-оценки.
""".strip() """.strip()
WORKOUT_PROMPT = """ WORKOUT_PROMPT = """
Преобразуй описание тренировки в JSON. Только JSON. Преобразуй описание тренировки в JSON. Только JSON.
Схема: Формат:
{ {
"title": "название", "title": "название",
"duration_min": null, "duration_min": null,
"notes": "", "active_calories": null,
"exercises": [ "total_calories": null,
{"name": "жим лёжа", "sets": 3, "reps": 8, "weight_kg": 80} "steps": null,
] "notes": "",
} "exercises": [
Правила: {"name": "имя упраж", "sets": 3, "reps": 8, "weight_kg": 80}
- weight_kg в кг, метрическая система. ]
- Если данных нет — null или пустой массив. }
""".strip() Правила:
- weight_kg в кг, округляй разумно.
- active_calories / total_calories / steps — если упомянуты в тексте, иначе null.
async def structure_meal(raw_text: str) -> dict[str, Any]: - Если данных нет — null или пустой массив.
llm = LLMClient() """.strip()
result = await llm.complete(
[ STEPS_PROMPT = """
{"role": "system", "content": MEAL_PROMPT}, Преобразуй запись о шагах в JSON. Только JSON.
{"role": "user", "content": raw_text}, Формат:
], {
temperature=0.2, "steps": 0,
) "active_calories": null,
raw = strip_markdown_json(result.get("content") or "") "notes": ""
return json.loads(raw) }
Правила:
- steps — целое число шагов за день.
async def structure_workout(raw_text: str) -> dict[str, Any]: - active_calories — только если явно указаны.
llm = LLMClient() """.strip()
result = await llm.complete(
[
{"role": "system", "content": WORKOUT_PROMPT}, async def structure_meal(raw_text: str) -> dict[str, Any]:
{"role": "user", "content": raw_text}, llm = LLMClient()
], result = await llm.complete(
temperature=0.2, [
) {"role": "system", "content": MEAL_PROMPT},
raw = strip_markdown_json(result.get("content") or "") {"role": "user", "content": raw_text},
return json.loads(raw) ],
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)
+42 -42
View File
@@ -1,42 +1,42 @@
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.memory.service import MemoryService from app.memory.service import MemoryService
WEEKDAY_RU = ( WEEKDAY_RU = (
"понедельник", "понедельник",
"вторник", "вторник",
"среда", "среда",
"четверг", "четверг",
"пятница", "пятница",
"суббота", "суббота",
"воскресенье", "воскресенье",
) )
DEFAULT_TIMEZONE = "Europe/Moscow" DEFAULT_TIMEZONE = "Europe/Moscow"
def resolve_timezone(db: Session) -> str: def resolve_timezone(db: Session, user_id: int) -> str:
profile = MemoryService(db).get_profile() profile = MemoryService(db, user_id).get_profile()
tz = (profile.get("timezone") or "").strip() tz = (profile.get("timezone") or "").strip()
return tz or DEFAULT_TIMEZONE return tz or DEFAULT_TIMEZONE
def format_datetime_context(db: Session) -> str: def format_datetime_context(db: Session, user_id: int) -> str:
tz_name = resolve_timezone(db) tz_name = resolve_timezone(db, user_id)
try: try:
tz = ZoneInfo(tz_name) tz = ZoneInfo(tz_name)
except Exception: except Exception:
tz = ZoneInfo(DEFAULT_TIMEZONE) tz = ZoneInfo(DEFAULT_TIMEZONE)
tz_name = DEFAULT_TIMEZONE tz_name = DEFAULT_TIMEZONE
now = datetime.now(tz) now = datetime.now(tz)
weekday = WEEKDAY_RU[now.weekday()] weekday = WEEKDAY_RU[now.weekday()]
lines = [ lines = [
"[Текущее время]", "[Текущее время]",
f"Сейчас: {now.strftime('%Y-%m-%d %H:%M')} ({weekday}), часовой пояс {tz_name}.", f"Сейчас: {now.strftime('%Y-%m-%d %H:%M')} ({weekday}), часовой пояс {tz_name}.",
"Учитывай время при ответах о «сегодня», «утром», «вечером» и расписании.", "Учитывай время при ответах о «сегодня», «утром», «вечером» и расписании.",
] ]
return "\n".join(lines) return "\n".join(lines)
+131 -130
View File
@@ -1,130 +1,131 @@
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.character.service import CharacterService from app.character.service import CharacterService
from app.config import get_settings from app.config import get_settings
from app.db.models import Message from app.db.models import Message
from app.homelab.comfyui import ComfyUIClient from app.homelab.comfyui import ComfyUIClient
from app.integrations.rp_chat import RpChatClient from app.integrations.rp_chat import RpChatClient
def _card_image_settings() -> dict[str, Any]: def _card_image_settings(db: Session, user_id: int) -> dict[str, Any]:
return CharacterService().get_card().get("data", {}) 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]]: def _session_messages(db: Session, session_id: int | None, limit: int = 8) -> list[dict[str, str]]:
if not session_id: if not session_id:
return [] return []
rows = db.scalars( rows = db.scalars(
select(Message) select(Message)
.where( .where(
Message.session_id == session_id, Message.session_id == session_id,
Message.role.in_(("user", "assistant")), Message.role.in_(("user", "assistant")),
) )
.order_by(Message.created_at.desc()) .order_by(Message.created_at.desc())
.limit(limit) .limit(limit)
).all() ).all()
rows = list(reversed(rows)) rows = list(reversed(rows))
return [{"role": m.role, "content": (m.content or "").strip()} for m in rows if m.content.strip()] return [{"role": m.role, "content": (m.content or "").strip()} for m in rows if m.content.strip()]
def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str: def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str:
if not lora_name or f"<lora:{lora_name}" in positive: if not lora_name or f"<lora:{lora_name}" in positive:
return positive return positive
return f"{positive} <lora:{lora_name}:{lora_weight}>" return f"{positive} <lora:{lora_name}:{lora_weight}>"
async def generate_image( async def generate_image(
db: Session, db: Session,
*, *,
session_id: int | None = None, user_id: int,
draw_self: bool = False, session_id: int | None = None,
scene_description: str = "", draw_self: bool = False,
) -> dict[str, Any]: scene_description: str = "",
card = _card_image_settings() ) -> dict[str, Any]:
settings = get_settings() card = _card_image_settings(db, user_id)
settings = get_settings()
if not card.get("sd_enabled", True):
return {"ok": False, "error": "Генерация изображений отключена в настройках персонажа"} 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"} 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()
if draw_self and not appearance: appearance = (card.get("appearance_tags") or "").strip()
return { if draw_self and not appearance:
"ok": False, return {
"error": "Заполни appearance_tags в настройках персонажа для «нарисуй себя»", "ok": False,
} "error": "Заполни appearance_tags в настройках персонажа для «нарисуй себя»",
}
messages = _session_messages(db, session_id)
if scene_description.strip(): messages = _session_messages(db, session_id)
messages = messages + [{"role": "user", "content": scene_description.strip()}] if scene_description.strip():
elif draw_self and messages: messages = messages + [{"role": "user", "content": scene_description.strip()}]
messages = messages + [{"role": "user", "content": "Illustrate the current scene with the character."}] elif draw_self and messages:
elif draw_self: messages = messages + [{"role": "user", "content": "Illustrate the current scene with the character."}]
messages = [{"role": "user", "content": "Portrait of the character, looking at viewer, friendly expression."}] elif draw_self:
messages = [{"role": "user", "content": "Portrait of the character, looking at viewer, friendly expression."}]
if settings.rp_chat_enabled:
appearance_override = (card.get("appearance_tags") or "").strip() or None if settings.rp_chat_enabled:
return await _generate_via_rp_chat(card, messages, appearance_override) appearance_override = (card.get("appearance_tags") or "").strip() or None
return await _generate_via_rp_chat(card, messages, appearance_override)
return await _generate_via_local_comfy(scene_description or "anime character portrait")
return await _generate_via_local_comfy(scene_description or "anime character portrait")
async def _generate_via_rp_chat(
card: dict[str, Any], async def _generate_via_rp_chat(
messages: list[dict[str, str]], card: dict[str, Any],
appearance_override: str | None, messages: list[dict[str, str]],
) -> dict[str, Any]: appearance_override: str | None,
client = RpChatClient() ) -> dict[str, Any]:
persona_id = (card.get("rp_persona_id") or "").strip() or "default" client = RpChatClient()
override = appearance_override or (card.get("appearance_tags") or "").strip() or None 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, prompt_result = await client.sd_prompt(
messages, persona_id,
appearance_override=override, messages,
) appearance_override=override,
if not prompt_result.get("ok"): )
return prompt_result if not prompt_result.get("ok"):
return prompt_result
positive = (
prompt_result.get("hybrid_positive") positive = (
or prompt_result.get("tag_positive") prompt_result.get("hybrid_positive")
or "" or prompt_result.get("tag_positive")
).strip() or ""
negative = (prompt_result.get("negative") or "").strip() ).strip()
if not positive: negative = (prompt_result.get("negative") or "").strip()
return {"ok": False, "error": "RP-чат не вернул промпт", "raw": prompt_result} if not positive:
return {"ok": False, "error": "RP-чат не вернул промпт", "raw": prompt_result}
lora = (card.get("lora_name") or "").strip()
if lora: lora = (card.get("lora_name") or "").strip()
weight = float(card.get("lora_weight") or 0.8) if lora:
positive = _append_lora(positive, lora, weight) 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"): gen_result = await client.generate(positive, negative)
return gen_result if not gen_result.get("ok"):
return gen_result
saved = await client.save_image_locally(gen_result["image_path"])
if not saved.get("ok"): saved = await client.save_image_locally(gen_result["image_path"])
return saved if not saved.get("ok"):
return saved
return {
"ok": True, return {
"url": saved["url"], "ok": True,
"filename": saved["filename"], "url": saved["url"],
"prompt": positive, "filename": saved["filename"],
"backend": "rp_chat", "prompt": positive,
"persona_id": persona_id, "backend": "rp_chat",
} "persona_id": persona_id,
}
async def _generate_via_local_comfy(prompt: str) -> dict[str, Any]:
result = await ComfyUIClient().generate_image(prompt) async def _generate_via_local_comfy(prompt: str) -> dict[str, Any]:
if result.get("ok"): result = await ComfyUIClient().generate_image(prompt)
result["backend"] = "comfyui_local" if result.get("ok"):
return result result["backend"] = "comfyui_local"
return result
+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()
+323 -269
View File
@@ -1,269 +1,323 @@
import json import json
import logging import logging
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Any from typing import Any
from openai import AsyncOpenAI from openai import AsyncOpenAI
from app.config import get_settings from app.config import get_settings
logger = logging.getLogger(__name__) 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.tools_enabled = settings.openrouter_tools_enabled self.client = AsyncOpenAI(
self.reasoning_effort = settings.openrouter_reasoning_effort.strip().lower() api_key=settings.openrouter_api_key,
self.client = AsyncOpenAI( base_url=settings.openrouter_base_url,
api_key=settings.openrouter_api_key, )
base_url=settings.openrouter_base_url,
) def _runtime(self) -> tuple[str, str, str]:
from app.db.base import SessionLocal
def _reasoning_extra_body(self) -> dict[str, Any] | None: from app.settings.service import SettingsService
if not self.reasoning_effort:
return None settings = get_settings()
if self.reasoning_effort == "none": db = SessionLocal()
return {"reasoning": {"effort": "none", "exclude": True}} try:
return {"reasoning": {"effort": self.reasoning_effort}} svc = SettingsService(db)
model = str(svc.get_effective("openrouter_model"))
@staticmethod extract = str(svc.get_effective("memory_extract_model"))
def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]: effort = str(svc.get_effective("openrouter_reasoning_effort")).strip().lower()
parts: list[str] = [] return model, extract, effort
for attr in ("reasoning", "reasoning_content"): finally:
value = getattr(delta, attr, None) db.close()
if value:
parts.append(str(value)) @property
def model(self) -> str:
details: list[Any] = [] return self._runtime()[0]
raw_details = getattr(delta, "reasoning_details", None)
if raw_details: @property
if isinstance(raw_details, list): def memory_extract_model(self) -> str:
details.extend(raw_details) return self._runtime()[1]
else:
details.append(raw_details) @property
def reasoning_effort(self) -> str:
return "".join(parts), details return self._runtime()[2]
@staticmethod def _reasoning_extra_body(self) -> dict[str, Any] | None:
def _normalize_reasoning_details(details: Any) -> list[Any] | None: if not self.reasoning_effort:
if not details: return None
return None if self.reasoning_effort == "none":
items = details if isinstance(details, list) else [details] return {"reasoning": {"effort": "none", "exclude": True}}
normalized: list[Any] = [] return {"reasoning": {"effort": self.reasoning_effort}}
for item in items:
if hasattr(item, "model_dump"): @staticmethod
normalized.append(item.model_dump()) def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]:
elif isinstance(item, dict): parts: list[str] = []
normalized.append(item) for attr in ("reasoning", "reasoning_content"):
else: value = getattr(delta, attr, None)
normalized.append(item) if value:
return normalized or None parts.append(str(value))
@staticmethod details: list[Any] = []
def attach_reasoning_to_message( raw_details = getattr(delta, "reasoning_details", None)
message: dict[str, Any], if raw_details:
*, if isinstance(raw_details, list):
reasoning: str = "", details.extend(raw_details)
reasoning_details: list[Any] | None = None, else:
) -> dict[str, Any]: details.append(raw_details)
if reasoning:
message["reasoning"] = reasoning return "".join(parts), details
message["reasoning_content"] = reasoning
normalized = LLMClient._normalize_reasoning_details(reasoning_details) @staticmethod
if normalized: def _normalize_reasoning_details(details: Any) -> list[Any] | None:
message["reasoning_details"] = normalized if not details:
return message return None
items = details if isinstance(details, list) else [details]
async def stream_chat( normalized: list[Any] = []
self, for item in items:
messages: list[dict[str, Any]], if hasattr(item, "model_dump"):
tools: list[dict[str, Any]] | None = None, normalized.append(item.model_dump())
*, elif isinstance(item, dict):
model: str | None = None, normalized.append(item)
) -> AsyncIterator[dict[str, Any]]: else:
use_tools = bool(tools) and self.tools_enabled normalized.append(item)
kwargs: dict[str, Any] = { return normalized or None
"model": model or self.model,
"messages": messages, @staticmethod
"stream": True, def attach_reasoning_to_message(
"temperature": 0.7, message: dict[str, Any],
} *,
if use_tools: reasoning: str = "",
kwargs["tools"] = tools reasoning_details: list[Any] | None = None,
extra_body = self._reasoning_extra_body() ) -> dict[str, Any]:
if extra_body: if reasoning:
kwargs["extra_body"] = extra_body message["reasoning"] = reasoning
message["reasoning_content"] = reasoning
try: normalized = LLMClient._normalize_reasoning_details(reasoning_details)
stream = await self.client.chat.completions.create(**kwargs) if normalized:
except Exception as exc: message["reasoning_details"] = normalized
logger.exception("LLM stream failed: %s", exc) return message
yield {"type": "error", "content": str(exc)}
yield {"type": "done", "finish_reason": "error"} async def stream_chat(
return self,
messages: list[dict[str, Any]],
tool_calls: dict[int, dict[str, Any]] = {} tools: list[dict[str, Any]] | None = None,
reasoning_parts: list[str] = [] *,
reasoning_details: list[Any] = [] model: str | None = None,
) -> AsyncIterator[dict[str, Any]]:
try: use_tools = bool(tools) and self.tools_enabled
async for chunk in stream: kwargs: dict[str, Any] = {
if not chunk.choices: "model": model or self.model,
continue "messages": messages,
"stream": True,
choice = chunk.choices[0] "temperature": 0.7,
delta = choice.delta }
if use_tools:
if delta.content: kwargs["tools"] = tools
yield {"type": "content", "content": delta.content} extra_body = self._reasoning_extra_body()
if extra_body:
reasoning_text, details = self._delta_reasoning(delta) kwargs["extra_body"] = extra_body
if reasoning_text:
reasoning_parts.append(reasoning_text) try:
if details: stream = await self.client.chat.completions.create(**kwargs)
reasoning_details.extend(details) except Exception as exc:
logger.exception("LLM stream failed: %s", exc)
if delta.tool_calls: yield {"type": "error", "content": str(exc)}
for tool_call in delta.tool_calls: yield {"type": "done", "finish_reason": "error"}
idx = tool_call.index return
if idx not in tool_calls:
tool_calls[idx] = { tool_calls: dict[int, dict[str, Any]] = {}
"id": tool_call.id or "", reasoning_parts: list[str] = []
"type": "function", reasoning_details: list[Any] = []
"function": {"name": "", "arguments": ""},
} try:
if tool_call.id: async for chunk in stream:
tool_calls[idx]["id"] = tool_call.id if not chunk.choices:
if tool_call.function: continue
if tool_call.function.name:
tool_calls[idx]["function"]["name"] = tool_call.function.name choice = chunk.choices[0]
if tool_call.function.arguments: delta = choice.delta
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
if delta.content:
if choice.finish_reason: yield {"type": "content", "content": delta.content}
reasoning = "".join(reasoning_parts)
normalized_details = self._normalize_reasoning_details(reasoning_details) reasoning_text, details = self._delta_reasoning(delta)
if reasoning or normalized_details: if reasoning_text:
yield { reasoning_parts.append(reasoning_text)
"type": "reasoning", if details:
"reasoning": reasoning, reasoning_details.extend(details)
"reasoning_details": normalized_details,
} if delta.tool_calls:
if tool_calls: for tool_call in delta.tool_calls:
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())} idx = tool_call.index
logger.info( if idx not in tool_calls:
"LLM stream done: model=%s finish_reason=%s tool_calls=%d " tool_calls[idx] = {
"content_in_stream=%d reasoning_len=%d", "id": tool_call.id or "",
model or self.model, "type": "function",
choice.finish_reason, "function": {"name": "", "arguments": ""},
len(tool_calls), }
len(reasoning_parts), if tool_call.id:
len(reasoning), tool_calls[idx]["id"] = tool_call.id
) if tool_call.function:
yield {"type": "done", "finish_reason": choice.finish_reason} if tool_call.function.name:
except Exception as exc: tool_calls[idx]["function"]["name"] = tool_call.function.name
logger.exception("LLM stream read failed: %s", exc) if tool_call.function.arguments:
yield {"type": "error", "content": str(exc)} tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
yield {"type": "done", "finish_reason": "error"}
usage = getattr(chunk, "usage", None)
async def complete( if usage is not None:
self, logger.info(
messages: list[dict[str, Any]], "LLM stream usage: prompt=%s completion=%s total=%s",
tools: list[dict[str, Any]] | None = None, getattr(usage, "prompt_tokens", None),
*, getattr(usage, "completion_tokens", None),
temperature: float = 0.7, getattr(usage, "total_tokens", None),
model: str | None = None, )
for_extraction: bool = False,
visible_reply: bool = False, if choice.finish_reason:
) -> dict[str, Any]: reasoning = "".join(reasoning_parts)
use_tools = bool(tools) and self.tools_enabled and not for_extraction normalized_details = self._normalize_reasoning_details(reasoning_details)
kwargs: dict[str, Any] = { if reasoning or normalized_details:
"model": model or self.model, yield {
"messages": messages, "type": "reasoning",
"temperature": temperature, "reasoning": reasoning,
} "reasoning_details": normalized_details,
if use_tools: }
kwargs["tools"] = tools if tool_calls:
if for_extraction: yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
kwargs["extra_body"] = {"reasoning": {"effort": "none"}} logger.info(
else: "LLM stream done: model=%s finish_reason=%s tool_calls=%d "
extra_body = self._reasoning_extra_body() "content_in_stream=%d reasoning_len=%d",
if extra_body: model or self.model,
kwargs["extra_body"] = extra_body choice.finish_reason,
len(tool_calls),
response = await self.client.chat.completions.create(**kwargs) len(reasoning_parts),
message = response.choices[0].message len(reasoning),
)
content = message.content or "" yield {"type": "done", "finish_reason": choice.finish_reason}
reasoning = "" except Exception as exc:
for attr in ("reasoning", "reasoning_content"): logger.exception("LLM stream read failed: %s", exc)
value = getattr(message, attr, None) yield {"type": "error", "content": str(exc)}
if value: yield {"type": "done", "finish_reason": "error"}
reasoning = str(value)
break async def complete(
self,
if not content and reasoning and not visible_reply: messages: list[dict[str, Any]],
content = reasoning tools: list[dict[str, Any]] | None = None,
*,
result: dict[str, Any] = { temperature: float = 0.7,
"content": content, model: str | None = None,
"tool_calls": [], for_extraction: bool = False,
"reasoning": reasoning, visible_reply: bool = False,
"reasoning_details": getattr(message, "reasoning_details", None), ) -> dict[str, Any]:
} use_tools = bool(tools) and self.tools_enabled and not for_extraction
kwargs: dict[str, Any] = {
if message.tool_calls: "model": model or self.model,
result["tool_calls"] = [ "messages": messages,
{ "temperature": temperature,
"id": tc.id, }
"type": "function", if use_tools:
"function": { kwargs["tools"] = tools
"name": tc.function.name, if for_extraction:
"arguments": tc.function.arguments, kwargs["extra_body"] = {"reasoning": {"effort": "none"}}
}, else:
} extra_body = self._reasoning_extra_body()
for tc in message.tool_calls if extra_body:
] kwargs["extra_body"] = extra_body
return result response = await self.client.chat.completions.create(**kwargs)
usage = getattr(response, "usage", None)
@staticmethod if usage is not None:
def parse_tool_arguments(arguments: str) -> dict[str, Any]: logger.info(
if not arguments: "LLM complete usage: prompt=%s completion=%s total=%s model=%s",
return {} getattr(usage, "prompt_tokens", None),
try: getattr(usage, "completion_tokens", None),
return json.loads(arguments) getattr(usage, "total_tokens", None),
except json.JSONDecodeError: kwargs.get("model"),
return {} )
message = response.choices[0].message
@staticmethod
def serialize_reasoning( content = message.content or ""
*, reasoning = ""
reasoning: str = "", for attr in ("reasoning", "reasoning_content"):
reasoning_details: list[Any] | None = None, value = getattr(message, attr, None)
) -> str | None: if value:
payload: dict[str, Any] = {} reasoning = str(value)
if reasoning: break
payload["reasoning"] = reasoning
payload["reasoning_content"] = reasoning if not content and reasoning and not visible_reply:
if reasoning_details: content = reasoning
payload["reasoning_details"] = reasoning_details
if not payload: result: dict[str, Any] = {
return None "content": content,
return json.dumps(payload, ensure_ascii=False) "tool_calls": [],
"reasoning": reasoning,
@staticmethod "reasoning_details": getattr(message, "reasoning_details", None),
def deserialize_reasoning(raw: str | None) -> dict[str, Any]: }
if not raw:
return {} if message.tool_calls:
try: result["tool_calls"] = [
data = json.loads(raw) {
except json.JSONDecodeError: "id": tc.id,
return {"reasoning": raw} "type": "function",
if isinstance(data, str): "function": {
return {"reasoning": data, "reasoning_content": data} "name": tc.function.name,
if isinstance(data, dict): "arguments": tc.function.arguments,
return data },
return {} }
for tc in message.tool_calls
]
return result
@staticmethod
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
if not arguments:
return {}
try:
return json.loads(arguments)
except json.JSONDecodeError:
return {}
@staticmethod
def serialize_reasoning(
*,
reasoning: str = "",
reasoning_details: list[Any] | None = None,
) -> str | None:
payload: dict[str, Any] = {}
if reasoning:
payload["reasoning"] = reasoning
payload["reasoning_content"] = reasoning
if reasoning_details:
payload["reasoning_details"] = reasoning_details
if not payload:
return None
return json.dumps(payload, ensure_ascii=False)
@staticmethod
def deserialize_reasoning(raw: str | None) -> dict[str, Any]:
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {"reasoning": raw}
if isinstance(data, str):
return {"reasoning": data, "reasoning_content": data}
if isinstance(data, dict):
return data
return {}
async def embed(self, texts: list[str]) -> list[list[float]]:
settings = get_settings()
if not texts:
return []
response = await self.client.embeddings.create(
model=settings.embedding_model,
input=texts,
)
return [item.embedding for item in response.data]
+269
View File
@@ -0,0 +1,269 @@
import json
import logging
from collections.abc import AsyncIterator
from typing import Any
from openai import AsyncOpenAI
from app.config import get_settings
logger = logging.getLogger(__name__)
class LLMClient:
def __init__(self) -> None:
settings = get_settings()
self.model = settings.openrouter_model
self.tools_enabled = settings.openrouter_tools_enabled
self.reasoning_effort = settings.openrouter_reasoning_effort.strip().lower()
self.client = AsyncOpenAI(
api_key=settings.openrouter_api_key,
base_url=settings.openrouter_base_url,
)
def _reasoning_extra_body(self) -> dict[str, Any] | None:
if not self.reasoning_effort:
return None
if self.reasoning_effort == "none":
return {"reasoning": {"effort": "none", "exclude": True}}
return {"reasoning": {"effort": self.reasoning_effort}}
@staticmethod
def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]:
parts: list[str] = []
for attr in ("reasoning", "reasoning_content"):
value = getattr(delta, attr, None)
if value:
parts.append(str(value))
details: list[Any] = []
raw_details = getattr(delta, "reasoning_details", None)
if raw_details:
if isinstance(raw_details, list):
details.extend(raw_details)
else:
details.append(raw_details)
return "".join(parts), details
@staticmethod
def _normalize_reasoning_details(details: Any) -> list[Any] | None:
if not details:
return None
items = details if isinstance(details, list) else [details]
normalized: list[Any] = []
for item in items:
if hasattr(item, "model_dump"):
normalized.append(item.model_dump())
elif isinstance(item, dict):
normalized.append(item)
else:
normalized.append(item)
return normalized or None
@staticmethod
def attach_reasoning_to_message(
message: dict[str, Any],
*,
reasoning: str = "",
reasoning_details: list[Any] | None = None,
) -> dict[str, Any]:
if reasoning:
message["reasoning"] = reasoning
message["reasoning_content"] = reasoning
normalized = LLMClient._normalize_reasoning_details(reasoning_details)
if normalized:
message["reasoning_details"] = normalized
return message
async def stream_chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
*,
model: str | None = None,
) -> AsyncIterator[dict[str, Any]]:
use_tools = bool(tools) and self.tools_enabled
kwargs: dict[str, Any] = {
"model": model or self.model,
"messages": messages,
"stream": True,
"temperature": 0.7,
}
if use_tools:
kwargs["tools"] = tools
extra_body = self._reasoning_extra_body()
if extra_body:
kwargs["extra_body"] = extra_body
try:
stream = await self.client.chat.completions.create(**kwargs)
except Exception as exc:
logger.exception("LLM stream failed: %s", exc)
yield {"type": "error", "content": str(exc)}
yield {"type": "done", "finish_reason": "error"}
return
tool_calls: dict[int, dict[str, Any]] = {}
reasoning_parts: list[str] = []
reasoning_details: list[Any] = []
try:
async for chunk in stream:
if not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
if delta.content:
yield {"type": "content", "content": delta.content}
reasoning_text, details = self._delta_reasoning(delta)
if reasoning_text:
reasoning_parts.append(reasoning_text)
if details:
reasoning_details.extend(details)
if delta.tool_calls:
for tool_call in delta.tool_calls:
idx = tool_call.index
if idx not in tool_calls:
tool_calls[idx] = {
"id": tool_call.id or "",
"type": "function",
"function": {"name": "", "arguments": ""},
}
if tool_call.id:
tool_calls[idx]["id"] = tool_call.id
if tool_call.function:
if tool_call.function.name:
tool_calls[idx]["function"]["name"] = tool_call.function.name
if tool_call.function.arguments:
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
if choice.finish_reason:
reasoning = "".join(reasoning_parts)
normalized_details = self._normalize_reasoning_details(reasoning_details)
if reasoning or normalized_details:
yield {
"type": "reasoning",
"reasoning": reasoning,
"reasoning_details": normalized_details,
}
if tool_calls:
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
logger.info(
"LLM stream done: model=%s finish_reason=%s tool_calls=%d "
"content_in_stream=%d reasoning_len=%d",
model or self.model,
choice.finish_reason,
len(tool_calls),
len(reasoning_parts),
len(reasoning),
)
yield {"type": "done", "finish_reason": choice.finish_reason}
except Exception as exc:
logger.exception("LLM stream read failed: %s", exc)
yield {"type": "error", "content": str(exc)}
yield {"type": "done", "finish_reason": "error"}
async def complete(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
*,
temperature: float = 0.7,
model: str | None = None,
for_extraction: bool = False,
visible_reply: bool = False,
) -> dict[str, Any]:
use_tools = bool(tools) and self.tools_enabled and not for_extraction
kwargs: dict[str, Any] = {
"model": model or self.model,
"messages": messages,
"temperature": temperature,
}
if use_tools:
kwargs["tools"] = tools
if for_extraction:
kwargs["extra_body"] = {"reasoning": {"effort": "none"}}
else:
extra_body = self._reasoning_extra_body()
if extra_body:
kwargs["extra_body"] = extra_body
response = await self.client.chat.completions.create(**kwargs)
message = response.choices[0].message
content = message.content or ""
reasoning = ""
for attr in ("reasoning", "reasoning_content"):
value = getattr(message, attr, None)
if value:
reasoning = str(value)
break
if not content and reasoning and not visible_reply:
content = reasoning
result: dict[str, Any] = {
"content": content,
"tool_calls": [],
"reasoning": reasoning,
"reasoning_details": getattr(message, "reasoning_details", None),
}
if message.tool_calls:
result["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in message.tool_calls
]
return result
@staticmethod
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
if not arguments:
return {}
try:
return json.loads(arguments)
except json.JSONDecodeError:
return {}
@staticmethod
def serialize_reasoning(
*,
reasoning: str = "",
reasoning_details: list[Any] | None = None,
) -> str | None:
payload: dict[str, Any] = {}
if reasoning:
payload["reasoning"] = reasoning
payload["reasoning_content"] = reasoning
if reasoning_details:
payload["reasoning_details"] = reasoning_details
if not payload:
return None
return json.dumps(payload, ensure_ascii=False)
@staticmethod
def deserialize_reasoning(raw: str | None) -> dict[str, Any]:
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {"reasoning": raw}
if isinstance(data, str):
return {"reasoning": data, "reasoning_content": data}
if isinstance(data, dict):
return data
return {}
+65 -54
View File
@@ -1,54 +1,65 @@
import asyncio import asyncio
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router from app.api.routes import api_router
from app.config import get_settings from app.config import get_settings
from app.db.base import init_db from app.db.base import init_db
from app.fitness.watcher import fitness_watcher_loop from app.fitness.watcher import fitness_watcher_loop
from app.homelab.watcher import homelab_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.watcher import reminders_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()
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop()) from app.db.migrate_fitness import run_fitness_migrations
fitness_task = asyncio.create_task(fitness_watcher_loop())
homelab_task = asyncio.create_task(homelab_watcher_loop()) run_fitness_migrations()
reminders_task = asyncio.create_task(reminders_watcher_loop()) from app.db.migrate_multi_user import run_multi_user_migrations
yield
pomodoro_task.cancel() run_multi_user_migrations()
fitness_task.cancel() settings = get_settings()
homelab_task.cancel() if settings.rag_enabled:
reminders_task.cancel() from app.rag.store import ensure_collections
with suppress(asyncio.CancelledError):
await pomodoro_task ensure_collections()
with suppress(asyncio.CancelledError): pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
await fitness_task fitness_task = asyncio.create_task(fitness_watcher_loop())
with suppress(asyncio.CancelledError): homelab_task = asyncio.create_task(homelab_watcher_loop())
await homelab_task reminders_task = asyncio.create_task(reminders_watcher_loop())
with suppress(asyncio.CancelledError): yield
await reminders_task pomodoro_task.cancel()
fitness_task.cancel()
homelab_task.cancel()
def create_app() -> FastAPI: reminders_task.cancel()
settings = get_settings() with suppress(asyncio.CancelledError):
app = FastAPI(title="Home AI Assistant", lifespan=lifespan) await pomodoro_task
with suppress(asyncio.CancelledError):
app.add_middleware( await fitness_task
CORSMiddleware, with suppress(asyncio.CancelledError):
allow_origins=settings.cors_origins_list, await homelab_task
allow_credentials=True, with suppress(asyncio.CancelledError):
allow_methods=["*"], await reminders_task
allow_headers=["*"],
)
def create_app() -> FastAPI:
app.include_router(api_router) settings = get_settings()
return app app = FastAPI(title="Home AI Assistant", lifespan=lifespan)
app.add_middleware(
app = create_app() CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
return app
app = create_app()
+54
View File
@@ -0,0 +1,54 @@
import asyncio
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router
from app.config import get_settings
from app.db.base import init_db
from app.fitness.watcher import fitness_watcher_loop
from app.homelab.watcher import homelab_watcher_loop
from app.pomodoro.watcher import pomodoro_watcher_loop
from app.reminders.watcher import reminders_watcher_loop
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
fitness_task = asyncio.create_task(fitness_watcher_loop())
homelab_task = asyncio.create_task(homelab_watcher_loop())
reminders_task = asyncio.create_task(reminders_watcher_loop())
yield
pomodoro_task.cancel()
fitness_task.cancel()
homelab_task.cancel()
reminders_task.cancel()
with suppress(asyncio.CancelledError):
await pomodoro_task
with suppress(asyncio.CancelledError):
await fitness_task
with suppress(asyncio.CancelledError):
await homelab_task
with suppress(asyncio.CancelledError):
await reminders_task
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title="Home AI Assistant", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
return app
app = create_app()
+89 -83
View File
@@ -1,83 +1,89 @@
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.memory.service import MemoryService from app.config import get_settings
from app.memory.service import MemoryService
from app.memory.parse import is_identity_question
from app.memory.parse import is_identity_question
MAX_FACTS_IN_CONTEXT = 25
PROFILE_KEYS = ("name", "age", "timezone", "language", "notes") PROFILE_KEYS = ("name", "age", "timezone", "language", "notes")
def get_memory_snapshot(db: Session, session_id: int | None = None) -> dict[str, Any]: def get_memory_snapshot(
return MemoryService(db).snapshot(session_id) db: Session,
user_id: int,
session_id: int | None = None,
def format_memory_context(snapshot: dict[str, Any]) -> str: query: str | None = None,
lines = ["[Память и профиль — долгосрочный контекст]"] ) -> dict[str, Any]:
return MemoryService(db, user_id).snapshot(session_id, query=query)
profile = snapshot.get("profile") or {}
profile_lines = []
for key in PROFILE_KEYS: def format_memory_context(snapshot: dict[str, Any]) -> str:
value = (profile.get(key) or "").strip() lines = ["[Память и профиль — долгосрочный контекст]"]
if value:
profile_lines.append(f"- {key}: {value}") profile = snapshot.get("profile") or {}
if profile_lines: profile_lines = []
lines.append("Профиль пользователя:") for key in PROFILE_KEYS:
lines.extend(profile_lines) value = (profile.get(key) or "").strip()
else: if value:
lines.append("Профиль: не заполнен (можно уточнить имя, часовой пояс).") profile_lines.append(f"- {key}: {value}")
if profile_lines:
summary = (snapshot.get("session_summary") or "").strip() lines.append("Профиль пользователя:")
if summary: lines.extend(profile_lines)
lines.append("") else:
lines.append("Сводка текущего чата (ранние сообщения):") lines.append("Профиль: не заполнен (можно уточнить имя, часовой пояс).")
lines.append(summary)
summary = (snapshot.get("session_summary") or "").strip()
facts = snapshot.get("facts") or [] if summary:
if facts: lines.append("")
lines.append("") lines.append("Сводка текущего чата (ранние сообщения):")
lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):") lines.append(summary)
for fact in facts[:MAX_FACTS_IN_CONTEXT]:
lines.append( facts = snapshot.get("facts") or []
f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}" if facts:
) lines.append("")
else: lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):")
lines.append("") limit = get_settings().memory_facts_in_context
lines.append("Запомненные факты: пока нет.") for fact in facts[:limit]:
lines.append(
lines.append("") f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}"
lines.append( )
"Правила памяти: " else:
"«запомни» → remember_fact (имя/возраст также пишутся в профиль). " lines.append("")
"«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. " lines.append("Запомненные факты: пока нет.")
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. " lines.append("")
"«забудь #N» → forget_memory. " lines.append(
"Длинный чат — update_session_summary." "Правила памяти: "
) "«запомни» → remember_fact (имя/возраст также пишутся в профиль). "
return "\n".join(lines) "«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. "
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. "
def format_identity_hint(snapshot: dict[str, Any], user_text: str) -> str: "«забудь #N» → forget_memory. "
if not is_identity_question(user_text): "Длинный чат — update_session_summary."
return "" )
return "\n".join(lines)
profile = snapshot.get("profile") or {}
facts = snapshot.get("facts") or []
lines = [ def format_identity_hint(snapshot: dict[str, Any], user_text: str) -> str:
"[Вопрос об идентичности пользователя]", if not is_identity_question(user_text):
"Ответь ТОЛЬКО из данных ниже. Не придумывай роли из сценария персонажа.", return ""
]
name = (profile.get("name") or "").strip() profile = snapshot.get("profile") or {}
age = (profile.get("age") or "").strip() facts = snapshot.get("facts") or []
if name: lines = [
lines.append(f"Имя: {name}") "[Вопрос об идентичности пользователя]",
if age: "Ответь ТОЛЬКО из данных ниже. Не придумывай роли из сценария персонажа.",
lines.append(f"Возраст: {age} лет") ]
for fact in facts: name = (profile.get("name") or "").strip()
lines.append(f"Факт: {fact.get('content')}") age = (profile.get("age") or "").strip()
if not name and not age and not facts: if name:
lines.append("Данных нет — скажи, что не помнишь.") lines.append(f"Имя: {name}")
return "\n".join(lines) if age:
lines.append(f"Возраст: {age} лет")
for fact in facts:
lines.append(f"Факт: {fact.get('content')}")
if not name and not age and not facts:
lines.append("Данных нет — скажи, что не помнишь.")
return "\n".join(lines)
@@ -0,0 +1,83 @@
from typing import Any
from sqlalchemy.orm import Session
from app.memory.service import MemoryService
from app.memory.parse import is_identity_question
MAX_FACTS_IN_CONTEXT = 25
PROFILE_KEYS = ("name", "age", "timezone", "language", "notes")
def get_memory_snapshot(db: Session, session_id: int | None = None) -> dict[str, Any]:
return MemoryService(db).snapshot(session_id)
def format_memory_context(snapshot: dict[str, Any]) -> str:
lines = ["[Память и профиль — долгосрочный контекст]"]
profile = snapshot.get("profile") or {}
profile_lines = []
for key in PROFILE_KEYS:
value = (profile.get(key) or "").strip()
if value:
profile_lines.append(f"- {key}: {value}")
if profile_lines:
lines.append("Профиль пользователя:")
lines.extend(profile_lines)
else:
lines.append("Профиль: не заполнен (можно уточнить имя, часовой пояс).")
summary = (snapshot.get("session_summary") or "").strip()
if summary:
lines.append("")
lines.append("Сводка текущего чата (ранние сообщения):")
lines.append(summary)
facts = snapshot.get("facts") or []
if facts:
lines.append("")
lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):")
for fact in facts[:MAX_FACTS_IN_CONTEXT]:
lines.append(
f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}"
)
else:
lines.append("")
lines.append("Запомненные факты: пока нет.")
lines.append("")
lines.append(
"Правила памяти: "
"«запомни» → remember_fact (имя/возраст также пишутся в профиль). "
"«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. "
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. "
"«забудь #N» → forget_memory. "
"Длинный чат — update_session_summary."
)
return "\n".join(lines)
def format_identity_hint(snapshot: dict[str, Any], user_text: str) -> str:
if not is_identity_question(user_text):
return ""
profile = snapshot.get("profile") or {}
facts = snapshot.get("facts") or []
lines = [
"[Вопрос об идентичности пользователя]",
"Ответь ТОЛЬКО из данных ниже. Не придумывай роли из сценария персонажа.",
]
name = (profile.get("name") or "").strip()
age = (profile.get("age") or "").strip()
if name:
lines.append(f"Имя: {name}")
if age:
lines.append(f"Возраст: {age} лет")
for fact in facts:
lines.append(f"Факт: {fact.get('content')}")
if not name and not age and not facts:
lines.append("Данных нет — скажи, что не помнишь.")
return "\n".join(lines)
+153 -152
View File
@@ -1,152 +1,153 @@
import json import json
import logging import logging
import re import re
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.llm.client import LLMClient from app.llm.client import LLMClient
from app.memory.service import MemoryService from app.memory.service import MemoryService
from app.projects.structuring import strip_markdown_json from app.projects.structuring import strip_markdown_json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SKIP_USER_PATTERN = re.compile( SKIP_USER_PATTERN = re.compile(
r"^(ок|ok|да|нет|спасибо|thanks|\.{1,3}|👍|\+1)$", r"^(ок|ok|да|нет|спасибо|thanks|\.{1,3}|👍|\+1)$",
re.IGNORECASE, re.IGNORECASE,
) )
EXTRACTION_PROMPT = """ EXTRACTION_PROMPT = """
Ты извлекаешь долгосрочные факты о пользователе из фрагмента диалога. Ты извлекаешь долгосрочные факты о пользователе из фрагмента диалога.
Ответь ТОЛЬКО JSON без markdown. Ответь ТОЛЬКО JSON без markdown.
Схема: Схема:
{ {
"facts": [ "facts": [
{"content": "текст факта", "category": "preference|person|habit|project|fact", "importance": 1} {"content": "текст факта", "category": "preference|person|habit|project|fact", "importance": 1}
], ],
"profile": {"name": "", "age": "", "timezone": "", "notes": ""} "profile": {"name": "", "age": "", "timezone": "", "notes": ""}
} }
Правила: Правила:
- Сохраняй устойчивое: имя, возраст, предпочтения, привычки, проекты, семья, работа. - Сохраняй устойчивое: имя, возраст, предпочтения, привычки, проекты, семья, работа.
- НЕ сохраняй: статус помидоро, погоду, разовые команды, ролевую игру, выдумки ассистента. - НЕ сохраняй: статус помидоро, погоду, разовые команды, ролевую игру, выдумки ассистента.
- profile только поля с новыми значениями (пустые строки не включай). - profile только поля с новыми значениями (пустые строки не включай).
- facts короткие утверждения от первого лица пользователя («люблю кофе», «меня зовут »). - facts короткие утверждения от первого лица пользователя («люблю кофе», «меня зовут »).
- Если нечего сохранять {"facts": [], "profile": {}}. - Если нечего сохранять {"facts": [], "profile": {}}.
- Не дублируй уже известное (см. текущий профиль и факты ниже). - Не дублируй уже известное (см. текущий профиль и факты ниже).
- importance: 5 критично (имя), 4 важно, 3 обычно, 2 мелочь. - importance: 5 критично (имя), 4 важно, 3 обычно, 2 мелочь.
""".strip() """.strip()
def _should_skip_extraction(user_text: str) -> bool: def _should_skip_extraction(user_text: str) -> bool:
text = user_text.strip() text = user_text.strip()
if len(text) < 4: if len(text) < 4:
return True return True
if SKIP_USER_PATTERN.match(text): if SKIP_USER_PATTERN.match(text):
return True return True
return False return False
async def _call_extractor( async def _call_extractor(
user_text: str, user_text: str,
assistant_text: str, assistant_text: str,
snapshot: dict[str, Any], snapshot: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
profile = snapshot.get("profile") or {} profile = snapshot.get("profile") or {}
facts = snapshot.get("facts") or [] facts = snapshot.get("facts") or []
known = [ known = [
f"Профиль: {json.dumps(profile, ensure_ascii=False)}", f"Профиль: {json.dumps(profile, ensure_ascii=False)}",
"Факты:", "Факты:",
*[f"- {f.get('content')}" for f in facts[:30]], *[f"- {f.get('content')}" for f in facts[:30]],
] ]
settings = get_settings() settings = get_settings()
extract_model = settings.memory_extract_model.strip() or None extract_model = settings.memory_extract_model.strip() or None
llm = LLMClient() llm = LLMClient()
result = await llm.complete( result = await llm.complete(
[ [
{"role": "system", "content": EXTRACTION_PROMPT}, {"role": "system", "content": EXTRACTION_PROMPT},
{ {
"role": "user", "role": "user",
"content": ( "content": (
"\n".join(known) "\n".join(known)
+ "\n\n---\nДиалог:\nПользователь: " + "\n\n---\nДиалог:\nПользователь: "
+ user_text + user_text
+ "\nАссистент: " + "\nАссистент: "
+ assistant_text[:1500] + assistant_text[:1500]
), ),
}, },
], ],
temperature=0.2, temperature=0.2,
model=extract_model, model=extract_model,
for_extraction=True, for_extraction=True,
) )
raw = strip_markdown_json(result.get("content") or "") raw = strip_markdown_json(result.get("content") or "")
if not raw: if not raw:
return {"facts": [], "profile": {}} return {"facts": [], "profile": {}}
parsed = json.loads(raw) parsed = json.loads(raw)
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
return {"facts": [], "profile": {}} return {"facts": [], "profile": {}}
return parsed return parsed
async def extract_after_turn( async def extract_after_turn(
db: Session, db: Session,
session_id: int, session_id: int,
user_text: str, user_text: str,
assistant_text: str, assistant_text: str,
*, *,
force: bool = False, user_id: int,
) -> dict[str, Any]: force: bool = False,
if not force and _should_skip_extraction(user_text): ) -> dict[str, Any]:
return {"ok": True, "skipped": "short_message", "saved": []} 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": []} if not (assistant_text or "").strip():
return {"ok": True, "skipped": "no_assistant_reply", "saved": []}
memory = MemoryService(db)
snapshot = memory.snapshot(session_id) memory = MemoryService(db, user_id)
snapshot = memory.snapshot(session_id)
try:
parsed = await _call_extractor(user_text, assistant_text, snapshot) try:
except (json.JSONDecodeError, Exception) as exc: parsed = await _call_extractor(user_text, assistant_text, snapshot)
logger.warning("Memory extraction failed: %s", exc) except (json.JSONDecodeError, Exception) as exc:
return {"ok": False, "error": str(exc), "saved": []} logger.warning("Memory extraction failed: %s", exc)
return {"ok": False, "error": str(exc), "saved": []}
saved: list[dict[str, Any]] = []
saved: list[dict[str, Any]] = []
profile_updates = parsed.get("profile") or {}
if isinstance(profile_updates, dict): profile_updates = parsed.get("profile") or {}
filtered = { if isinstance(profile_updates, dict):
k: str(v).strip() filtered = {
for k, v in profile_updates.items() k: str(v).strip()
if v and str(v).strip() for k, v in profile_updates.items()
} if v and str(v).strip()
if filtered: }
memory.update_profile(filtered) if filtered:
saved.append({"type": "profile", "updates": filtered}) memory.update_profile(filtered)
saved.append({"type": "profile", "updates": filtered})
facts = parsed.get("facts") or []
if isinstance(facts, list): facts = parsed.get("facts") or []
for item in facts: if isinstance(facts, list):
if not isinstance(item, dict): for item in facts:
continue if not isinstance(item, dict):
content = (item.get("content") or "").strip() continue
if not content or len(content) < 3: content = (item.get("content") or "").strip()
continue if not content or len(content) < 3:
try: continue
result = memory.remember_fact( try:
content, result = memory.remember_fact(
category=str(item.get("category") or "fact")[:64], content,
importance=int(item.get("importance") or 3), category=str(item.get("category") or "fact")[:64],
session_id=session_id, importance=int(item.get("importance") or 3),
source="auto", session_id=session_id,
) source="auto",
saved.append({"type": "fact", **result}) )
except ValueError: saved.append({"type": "fact", **result})
continue except ValueError:
continue
return {"ok": True, "saved": saved, "count": len(saved)}
return {"ok": True, "saved": saved, "count": len(saved)}
+300 -228
View File
@@ -1,228 +1,300 @@
import json import asyncio
from datetime import datetime, timezone import json
from typing import Any import threading
from datetime import datetime, timezone
from sqlalchemy import select from typing import Any
from sqlalchemy.orm import Session
from sqlalchemy import select
from app.db.models import MemoryFact, SessionSummary, UserProfile from sqlalchemy.orm import Session
from app.memory.parse import normalize_text, parse_identity, texts_are_similar
from app.db.models import MemoryFact, SessionSummary, UserProfile
DEFAULT_PROFILE: dict[str, Any] = { from app.memory.parse import normalize_text, parse_identity, texts_are_similar
"name": "",
"age": "", DEFAULT_PROFILE: dict[str, Any] = {
"timezone": "", "name": "",
"language": "ru", "age": "",
"notes": "", "timezone": "",
} "language": "ru",
"notes": "",
}
class MemoryService:
def __init__(self, db: Session):
self.db = db class MemoryService:
def __init__(self, db: Session, user_id: int):
def get_profile(self) -> dict[str, Any]: self.db = db
row = self.db.scalar(select(UserProfile).limit(1)) self.user_id = user_id
if not row:
return dict(DEFAULT_PROFILE) @staticmethod
try: def _schedule_rag(coro) -> None:
data = json.loads(row.data_json or "{}") def runner() -> None:
except json.JSONDecodeError: asyncio.run(coro)
data = {}
merged = dict(DEFAULT_PROFILE) threading.Thread(target=runner, daemon=True).start()
merged.update(data)
return merged def get_profile(self) -> dict[str, Any]:
row = self.db.scalar(select(UserProfile).where(UserProfile.user_id == self.user_id).limit(1))
def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]: if not row:
row = self.db.scalar(select(UserProfile).limit(1)) return dict(DEFAULT_PROFILE)
if not row: try:
row = UserProfile(data_json="{}") data = json.loads(row.data_json or "{}")
self.db.add(row) except json.JSONDecodeError:
self.db.flush() data = {}
merged = dict(DEFAULT_PROFILE)
current = self.get_profile() merged.update(data)
for key, value in updates.items(): return merged
if value is None:
current.pop(key, None) def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
else: row = self.db.scalar(select(UserProfile).where(UserProfile.user_id == self.user_id).limit(1))
current[key] = value if not row:
row = UserProfile(user_id=self.user_id, data_json="{}")
row.data_json = json.dumps(current, ensure_ascii=False) self.db.add(row)
row.updated_at = datetime.now(timezone.utc) self.db.flush()
self.db.commit()
return {"ok": True, "profile": current} current = self.get_profile()
for key, value in updates.items():
def _find_similar_fact(self, text: str) -> MemoryFact | None: if value is None:
for fact in self.db.scalars( current.pop(key, None)
select(MemoryFact).where(MemoryFact.active.is_(True)) else:
): current[key] = value
if texts_are_similar(fact.content, text):
return fact row.data_json = json.dumps(current, ensure_ascii=False)
return None row.updated_at = datetime.now(timezone.utc)
self.db.commit()
def _sync_identity_to_profile(self, text: str) -> dict[str, Any] | None: return {"ok": True, "profile": current}
parsed = parse_identity(text)
if not parsed: def _find_similar_fact(self, text: str) -> MemoryFact | None:
return None for fact in self.db.scalars(
return self.update_profile(parsed) select(MemoryFact).where(MemoryFact.user_id == self.user_id, MemoryFact.active.is_(True))
):
def remember_fact( if texts_are_similar(fact.content, text):
self, return fact
content: str, return None
*,
category: str = "fact", def _sync_identity_to_profile(self, text: str) -> dict[str, Any] | None:
source: str = "user", parsed = parse_identity(text)
session_id: int | None = None, if not parsed:
importance: int = 3, return None
) -> dict[str, Any]: return self.update_profile(parsed)
text = content.strip()
if not text: def remember_fact(
raise ValueError("Пустой факт") self,
content: str,
profile_sync = self._sync_identity_to_profile(text) *,
category: str = "fact",
existing = self._find_similar_fact(text) source: str = "user",
if existing: session_id: int | None = None,
if len(text) > len(existing.content): importance: int = 3,
existing.content = text[:2000] ) -> dict[str, Any]:
existing.category = category or existing.category text = content.strip()
existing.importance = max(existing.importance, min(5, max(1, importance))) if not text:
existing.updated_at = datetime.now(timezone.utc) raise ValueError("Пустой факт")
if session_id:
existing.session_id = session_id profile_sync = self._sync_identity_to_profile(text)
self.db.commit()
result = { existing = self._find_similar_fact(text)
"ok": True, if existing:
"action": "updated", if len(text) > len(existing.content):
"memory_id": existing.id, existing.content = text[:2000]
"content": existing.content, existing.category = category or existing.category
"category": existing.category, existing.importance = max(existing.importance, min(5, max(1, importance)))
} existing.updated_at = datetime.now(timezone.utc)
if profile_sync: if session_id:
result["profile"] = profile_sync.get("profile") existing.session_id = session_id
return result self.db.commit()
from app.rag.ingest import index_memory_fact
fact = MemoryFact(
category=(category or "fact")[:64], self._schedule_rag(index_memory_fact(existing))
content=text[:2000], result = {
source=source[:32], "ok": True,
session_id=session_id, "action": "updated",
importance=min(5, max(1, importance)), "memory_id": existing.id,
) "content": existing.content,
self.db.add(fact) "category": existing.category,
self.db.commit() }
self.db.refresh(fact) if profile_sync:
result = { result["profile"] = profile_sync.get("profile")
"ok": True, return result
"action": "created",
"memory_id": fact.id, fact = MemoryFact(
"content": fact.content, user_id=self.user_id,
"category": fact.category, category=(category or "fact")[:64],
} content=text[:2000],
if profile_sync: source=source[:32],
result["profile"] = profile_sync.get("profile") session_id=session_id,
return result importance=min(5, max(1, importance)),
)
def recall_memories( self.db.add(fact)
self, self.db.commit()
*, self.db.refresh(fact)
query: str | None = None, from app.rag.ingest import index_memory_fact
category: str | None = None,
limit: int = 20, self._schedule_rag(index_memory_fact(fact))
active_only: bool = True, result = {
) -> list[dict[str, Any]]: "ok": True,
stmt = select(MemoryFact).order_by( "action": "created",
MemoryFact.importance.desc(), "memory_id": fact.id,
MemoryFact.updated_at.desc(), "content": fact.content,
) "category": fact.category,
if active_only: }
stmt = stmt.where(MemoryFact.active.is_(True)) if profile_sync:
if category: result["profile"] = profile_sync.get("profile")
stmt = stmt.where(MemoryFact.category == category) return result
facts = self.db.scalars(stmt.limit(100)).all()
if query: def recall_memories(
qnorm = normalize_text(query) self,
facts = [ *,
f query: str | None = None,
for f in facts category: str | None = None,
if qnorm in normalize_text(f.content) limit: int = 20,
or qnorm in normalize_text(f.category) active_only: bool = True,
] ) -> list[dict[str, Any]]:
facts = facts[: min(limit, 50)] stmt = select(MemoryFact).where(MemoryFact.user_id == self.user_id).order_by(
return [ MemoryFact.importance.desc(),
{ MemoryFact.updated_at.desc(),
"id": f.id, )
"category": f.category, if active_only:
"content": f.content, stmt = stmt.where(MemoryFact.active.is_(True))
"importance": f.importance, if category:
"source": f.source, stmt = stmt.where(MemoryFact.category == category)
"updated_at": f.updated_at.isoformat() if f.updated_at else None, facts = self.db.scalars(stmt.limit(100)).all()
} if query:
for f in facts qnorm = normalize_text(query)
] facts = [
f
def forget_memory(self, memory_id: int) -> dict[str, Any]: for f in facts
fact = self.db.get(MemoryFact, memory_id) if qnorm in normalize_text(f.content)
if not fact: or qnorm in normalize_text(f.category)
raise ValueError(f"Память #{memory_id} не найдена") ]
fact.active = False facts = facts[: min(limit, 50)]
fact.updated_at = datetime.now(timezone.utc) return [
self.db.commit() {
return {"ok": True, "memory_id": memory_id, "forgotten": fact.content} "id": f.id,
"category": f.category,
def get_active_facts(self, limit: int = 25) -> list[MemoryFact]: "content": f.content,
return list( "importance": f.importance,
self.db.scalars( "source": f.source,
select(MemoryFact) "updated_at": f.updated_at.isoformat() if f.updated_at else None,
.where(MemoryFact.active.is_(True)) }
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc()) for f in facts
.limit(limit) ]
).all()
) def forget_memory(self, memory_id: int) -> dict[str, Any]:
fact = self.db.get(MemoryFact, memory_id)
def get_session_summary(self, session_id: int) -> SessionSummary | None: if not fact or fact.user_id != self.user_id:
return self.db.scalar( raise ValueError(f"Память #{memory_id} не найдена")
select(SessionSummary).where(SessionSummary.session_id == session_id) fact.active = False
) fact.updated_at = datetime.now(timezone.utc)
self.db.commit()
def update_session_summary( from app.rag.ingest import deactivate_memory_fact
self,
session_id: int, self._schedule_rag(deactivate_memory_fact(memory_id))
summary: str, return {"ok": True, "memory_id": memory_id, "forgotten": fact.content}
*,
message_count: int = 0, def get_active_facts(self, limit: int = 25) -> list[MemoryFact]:
) -> dict[str, Any]: return list(
text = summary.strip() self.db.scalars(
if not text: select(MemoryFact)
raise ValueError("Пустая сводка") .where(MemoryFact.user_id == self.user_id, MemoryFact.active.is_(True))
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc())
row = self.get_session_summary(session_id) .limit(limit)
if not row: ).all()
row = SessionSummary(session_id=session_id) )
self.db.add(row)
def get_session_summary(self, session_id: int) -> SessionSummary | None:
row.summary = text[:4000] from app.db.models import ChatSession
row.message_count = message_count
row.updated_at = datetime.now(timezone.utc) session = self.db.get(ChatSession, session_id)
self.db.commit() if not session or session.user_id != self.user_id:
return {"ok": True, "session_id": session_id, "summary": row.summary} return None
return self.db.scalar(
def snapshot(self, session_id: int | None = None) -> dict[str, Any]: select(SessionSummary).where(SessionSummary.session_id == session_id)
facts = self.get_active_facts() )
summary_row = self.get_session_summary(session_id) if session_id else None
return { def update_session_summary(
"profile": self.get_profile(), self,
"facts": [ session_id: int,
{ summary: str,
"id": f.id, *,
"category": f.category, message_count: int = 0,
"content": f.content, ) -> dict[str, Any]:
"importance": f.importance, text = summary.strip()
"source": f.source, if not text:
"updated_at": f.updated_at.isoformat() if f.updated_at else None, raise ValueError("Пустая сводка")
}
for f in facts from app.db.models import ChatSession
],
"session_summary": summary_row.summary if summary_row else "", session = self.db.get(ChatSession, session_id)
"total_facts": len(facts), if not session or session.user_id != self.user_id:
} raise ValueError("Session not found")
row = self.db.scalar(
select(SessionSummary).where(SessionSummary.session_id == session_id)
)
if not row:
row = SessionSummary(session_id=session_id)
self.db.add(row)
row.summary = text[:4000]
row.message_count = message_count
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
from app.rag.ingest import index_session_summary
self._schedule_rag(index_session_summary(session_id, row.summary))
return {"ok": True, "session_id": session_id, "summary": row.summary}
def snapshot(self, session_id: int | None = None, query: str | None = None) -> dict[str, Any]:
from app.config import get_settings
from app.settings.service import SettingsService
settings = get_settings()
svc = SettingsService(self.db)
rag_on = bool(svc.get_effective("rag_enabled")) and settings.rag_enabled
facts_payload: list[dict[str, Any]]
total_facts = len(self.get_active_facts(limit=500))
if rag_on and (query or "").strip():
async def _load() -> list[dict[str, Any]]:
from app.rag.retriever import retrieve_memory_facts
top_k = int(svc.get_effective("rag_top_k"))
return await retrieve_memory_facts(query or "", user_id=self.user_id, top_k=top_k)
try:
rag_facts = asyncio.run(_load())
except Exception:
rag_facts = []
if rag_facts:
facts_payload = rag_facts
else:
facts = self.get_active_facts(limit=settings.memory_facts_in_context)
facts_payload = [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
]
else:
facts = self.get_active_facts(limit=settings.memory_facts_in_context)
facts_payload = [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
]
summary_row = self.get_session_summary(session_id) if session_id else None
return {
"profile": self.get_profile(),
"facts": facts_payload,
"session_summary": summary_row.summary if summary_row else "",
"total_facts": total_facts,
}
+228
View File
@@ -0,0 +1,228 @@
import json
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import MemoryFact, SessionSummary, UserProfile
from app.memory.parse import normalize_text, parse_identity, texts_are_similar
DEFAULT_PROFILE: dict[str, Any] = {
"name": "",
"age": "",
"timezone": "",
"language": "ru",
"notes": "",
}
class MemoryService:
def __init__(self, db: Session):
self.db = db
def get_profile(self) -> dict[str, Any]:
row = self.db.scalar(select(UserProfile).limit(1))
if not row:
return dict(DEFAULT_PROFILE)
try:
data = json.loads(row.data_json or "{}")
except json.JSONDecodeError:
data = {}
merged = dict(DEFAULT_PROFILE)
merged.update(data)
return merged
def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
row = self.db.scalar(select(UserProfile).limit(1))
if not row:
row = UserProfile(data_json="{}")
self.db.add(row)
self.db.flush()
current = self.get_profile()
for key, value in updates.items():
if value is None:
current.pop(key, None)
else:
current[key] = value
row.data_json = json.dumps(current, ensure_ascii=False)
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {"ok": True, "profile": current}
def _find_similar_fact(self, text: str) -> MemoryFact | None:
for fact in self.db.scalars(
select(MemoryFact).where(MemoryFact.active.is_(True))
):
if texts_are_similar(fact.content, text):
return fact
return None
def _sync_identity_to_profile(self, text: str) -> dict[str, Any] | None:
parsed = parse_identity(text)
if not parsed:
return None
return self.update_profile(parsed)
def remember_fact(
self,
content: str,
*,
category: str = "fact",
source: str = "user",
session_id: int | None = None,
importance: int = 3,
) -> dict[str, Any]:
text = content.strip()
if not text:
raise ValueError("Пустой факт")
profile_sync = self._sync_identity_to_profile(text)
existing = self._find_similar_fact(text)
if existing:
if len(text) > len(existing.content):
existing.content = text[:2000]
existing.category = category or existing.category
existing.importance = max(existing.importance, min(5, max(1, importance)))
existing.updated_at = datetime.now(timezone.utc)
if session_id:
existing.session_id = session_id
self.db.commit()
result = {
"ok": True,
"action": "updated",
"memory_id": existing.id,
"content": existing.content,
"category": existing.category,
}
if profile_sync:
result["profile"] = profile_sync.get("profile")
return result
fact = MemoryFact(
category=(category or "fact")[:64],
content=text[:2000],
source=source[:32],
session_id=session_id,
importance=min(5, max(1, importance)),
)
self.db.add(fact)
self.db.commit()
self.db.refresh(fact)
result = {
"ok": True,
"action": "created",
"memory_id": fact.id,
"content": fact.content,
"category": fact.category,
}
if profile_sync:
result["profile"] = profile_sync.get("profile")
return result
def recall_memories(
self,
*,
query: str | None = None,
category: str | None = None,
limit: int = 20,
active_only: bool = True,
) -> list[dict[str, Any]]:
stmt = select(MemoryFact).order_by(
MemoryFact.importance.desc(),
MemoryFact.updated_at.desc(),
)
if active_only:
stmt = stmt.where(MemoryFact.active.is_(True))
if category:
stmt = stmt.where(MemoryFact.category == category)
facts = self.db.scalars(stmt.limit(100)).all()
if query:
qnorm = normalize_text(query)
facts = [
f
for f in facts
if qnorm in normalize_text(f.content)
or qnorm in normalize_text(f.category)
]
facts = facts[: min(limit, 50)]
return [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
]
def forget_memory(self, memory_id: int) -> dict[str, Any]:
fact = self.db.get(MemoryFact, memory_id)
if not fact:
raise ValueError(f"Память #{memory_id} не найдена")
fact.active = False
fact.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {"ok": True, "memory_id": memory_id, "forgotten": fact.content}
def get_active_facts(self, limit: int = 25) -> list[MemoryFact]:
return list(
self.db.scalars(
select(MemoryFact)
.where(MemoryFact.active.is_(True))
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc())
.limit(limit)
).all()
)
def get_session_summary(self, session_id: int) -> SessionSummary | None:
return self.db.scalar(
select(SessionSummary).where(SessionSummary.session_id == session_id)
)
def update_session_summary(
self,
session_id: int,
summary: str,
*,
message_count: int = 0,
) -> dict[str, Any]:
text = summary.strip()
if not text:
raise ValueError("Пустая сводка")
row = self.get_session_summary(session_id)
if not row:
row = SessionSummary(session_id=session_id)
self.db.add(row)
row.summary = text[:4000]
row.message_count = message_count
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {"ok": True, "session_id": session_id, "summary": row.summary}
def snapshot(self, session_id: int | None = None) -> dict[str, Any]:
facts = self.get_active_facts()
summary_row = self.get_session_summary(session_id) if session_id else None
return {
"profile": self.get_profile(),
"facts": [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
],
"session_summary": summary_row.summary if summary_row else "",
"total_facts": len(facts),
}
+92 -91
View File
@@ -1,91 +1,92 @@
import logging 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.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 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__) logger = logging.getLogger(__name__)
PHASE_LABELS = { PHASE_LABELS = {
PHASE_WORK: "работа", PHASE_WORK: "работа",
PHASE_SHORT_BREAK: "короткий перерыв", PHASE_SHORT_BREAK: "короткий перерыв",
PHASE_LONG_BREAK: "длинный перерыв", PHASE_LONG_BREAK: "длинный перерыв",
} }
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.llm = LLMClient() self.cycle = CycleManager(db, user_id)
self.character = CharacterService() self.llm = LLMClient()
self.character = CharacterService(db, user_id)
async def _generate_llm_comment(
self, async def _generate_llm_comment(
session: PomodoroSession, self,
next_phase: str | None, session: PomodoroSession,
) -> str: next_phase: str | None,
cycle = self.cycle.to_dict() ) -> str:
phase_label = PHASE_LABELS.get(session.phase, session.phase) cycle = self.cycle.to_dict()
next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен" phase_label = PHASE_LABELS.get(session.phase, session.phase)
work_done = cycle["completed_work_sessions"] next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен"
if session.phase == PHASE_WORK: work_done = cycle["completed_work_sessions"]
work_done += 1 if session.phase == PHASE_WORK:
work_done += 1
system = self.character.get_system_prompt()
user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась. system = self.character.get_system_prompt()
Задача: {session.task_note or 'без описания'} user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась.
Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ. Задача: {session.task_note or 'без описания'}
Следующая фаза: {next_label}. Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ.
Следующая фаза: {next_label}.
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown и без эмодзи."""
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown и без эмодзи."""
result = await self.llm.complete(
[ result = await self.llm.complete(
{"role": "system", "content": system}, [
{"role": "user", "content": user_prompt}, {"role": "system", "content": system},
], {"role": "user", "content": user_prompt},
temperature=0.8, ],
visible_reply=True, 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:
phase = session.phase def _resolve_next_phase(self, session: PomodoroSession) -> str | None:
cycle = self.cycle.get() phase = session.phase
if phase == PHASE_WORK: cycle = self.cycle.get()
if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break: if phase == PHASE_WORK:
return PHASE_LONG_BREAK if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break:
return PHASE_SHORT_BREAK return PHASE_LONG_BREAK
if phase == PHASE_SHORT_BREAK: return PHASE_SHORT_BREAK
return PHASE_WORK if phase == PHASE_SHORT_BREAK:
if phase == PHASE_LONG_BREAK: return PHASE_WORK
return None if phase == PHASE_LONG_BREAK:
return None return None
return None
async def process(self, session: PomodoroSession) -> None:
if session.completion_notified: async def process(self, session: PomodoroSession) -> None:
return if session.completion_notified:
return
next_phase = self._resolve_next_phase(session)
notice = format_phase_completed_notice(session, next_phase) next_phase = self._resolve_next_phase(session)
post_notice_to_latest_chat(notice) notice = format_phase_completed_notice(session, next_phase)
post_notice_to_latest_chat(notice, self.user_id)
try:
comment = await self._generate_llm_comment(session, next_phase) try:
if comment: comment = await self._generate_llm_comment(session, next_phase)
post_character_comment_to_latest_chat(comment) if comment:
except Exception: post_character_comment_to_latest_chat(comment, self.user_id)
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase) except Exception:
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase)
self.cycle.bump_notify_seq()
self.pomodoro.mark_notified(session) self.cycle.bump_notify_seq()
self.pomodoro.advance_after_completion(session) self.pomodoro.mark_notified(session)
logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase) self.pomodoro.advance_after_completion(session)
logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase)
+90 -89
View File
@@ -1,89 +1,90 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import PomodoroCycle from app.db.models import PomodoroCycle
PHASE_WORK = "work" PHASE_WORK = "work"
PHASE_SHORT_BREAK = "short_break" PHASE_SHORT_BREAK = "short_break"
PHASE_LONG_BREAK = "long_break" PHASE_LONG_BREAK = "long_break"
class CycleManager: class CycleManager:
def __init__(self, db: Session): def __init__(self, db: Session, user_id: int):
self.db = db self.db = db
self.user_id = user_id
def get(self) -> PomodoroCycle:
cycle = self.db.scalar(select(PomodoroCycle).limit(1)) def get(self) -> PomodoroCycle:
if not cycle: cycle = self.db.scalar(select(PomodoroCycle).where(PomodoroCycle.user_id == self.user_id).limit(1))
cycle = PomodoroCycle() if not cycle:
self.db.add(cycle) cycle = PomodoroCycle(user_id=self.user_id)
self.db.commit() self.db.add(cycle)
self.db.refresh(cycle) self.db.commit()
return cycle self.db.refresh(cycle)
return cycle
def to_dict(self, cycle: PomodoroCycle | None = None) -> dict:
c = cycle or self.get() def to_dict(self, cycle: PomodoroCycle | None = None) -> dict:
return { c = cycle or self.get()
"completed_work_sessions": c.completed_work_sessions, return {
"sessions_until_long_break": c.sessions_until_long_break, "completed_work_sessions": c.completed_work_sessions,
"task_note": c.task_note, "sessions_until_long_break": c.sessions_until_long_break,
"work_duration_min": c.work_duration_min, "task_note": c.task_note,
"short_break_min": c.short_break_min, "work_duration_min": c.work_duration_min,
"long_break_min": c.long_break_min, "short_break_min": c.short_break_min,
"auto_advance": c.auto_advance, "long_break_min": c.long_break_min,
"chat_notify_seq": c.chat_notify_seq, "auto_advance": c.auto_advance,
} "chat_notify_seq": c.chat_notify_seq,
}
def reset(self, clear_task: bool = False) -> dict:
cycle = self.get() def reset(self, clear_task: bool = False) -> dict:
cycle.completed_work_sessions = 0 cycle = self.get()
if clear_task: cycle.completed_work_sessions = 0
cycle.task_note = "" if clear_task:
self.db.commit() cycle.task_note = ""
self.db.refresh(cycle) self.db.commit()
return self.to_dict(cycle) self.db.refresh(cycle)
return self.to_dict(cycle)
def bump_notify_seq(self) -> int:
cycle = self.get() def bump_notify_seq(self) -> int:
cycle.chat_notify_seq += 1 cycle = self.get()
self.db.commit() cycle.chat_notify_seq += 1
self.db.refresh(cycle) self.db.commit()
return cycle.chat_notify_seq self.db.refresh(cycle)
return cycle.chat_notify_seq
def on_work_completed(self) -> str:
"""Returns next phase: short_break or long_break.""" def on_work_completed(self) -> str:
cycle = self.get() """Returns next phase: short_break or long_break."""
cycle.completed_work_sessions += 1 cycle = self.get()
if cycle.completed_work_sessions >= cycle.sessions_until_long_break: cycle.completed_work_sessions += 1
next_phase = PHASE_LONG_BREAK if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
else: next_phase = PHASE_LONG_BREAK
next_phase = PHASE_SHORT_BREAK else:
self.db.commit() next_phase = PHASE_SHORT_BREAK
return next_phase self.db.commit()
return next_phase
def on_long_break_completed(self) -> None:
cycle = self.get() def on_long_break_completed(self) -> None:
cycle.completed_work_sessions = 0 cycle = self.get()
self.db.commit() cycle.completed_work_sessions = 0
self.db.commit()
def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int:
c = cycle or self.get() def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int:
if phase == PHASE_WORK: c = cycle or self.get()
return c.work_duration_min if phase == PHASE_WORK:
if phase == PHASE_SHORT_BREAK: return c.work_duration_min
return c.short_break_min if phase == PHASE_SHORT_BREAK:
if phase == PHASE_LONG_BREAK: return c.short_break_min
return c.long_break_min if phase == PHASE_LONG_BREAK:
return c.work_duration_min return c.long_break_min
return c.work_duration_min
def next_phase_after(self, completed_phase: str) -> str | None:
if completed_phase == PHASE_WORK: def next_phase_after(self, completed_phase: str) -> str | None:
cycle = self.get() if completed_phase == PHASE_WORK:
if cycle.completed_work_sessions >= cycle.sessions_until_long_break: cycle = self.get()
return PHASE_LONG_BREAK if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
return PHASE_SHORT_BREAK return PHASE_LONG_BREAK
if completed_phase == PHASE_SHORT_BREAK: return PHASE_SHORT_BREAK
return PHASE_WORK if completed_phase == PHASE_SHORT_BREAK:
if completed_phase == PHASE_LONG_BREAK: return PHASE_WORK
return None if completed_phase == PHASE_LONG_BREAK:
return None return None
return None
+296 -287
View File
@@ -1,287 +1,296 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import PomodoroSession from app.db.models import PomodoroSession
from app.pomodoro.cycle import ( from app.pomodoro.cycle import (
PHASE_LONG_BREAK, PHASE_LONG_BREAK,
PHASE_SHORT_BREAK, PHASE_SHORT_BREAK,
PHASE_WORK, PHASE_WORK,
CycleManager, CycleManager,
) )
def _utcnow() -> datetime: def _utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
class PomodoroService: class PomodoroService:
def __init__(self, db: Session): def __init__(self, db: Session, user_id: int):
self.db = db self.db = db
self.cycle = CycleManager(db) self.user_id = user_id
self.cycle = CycleManager(db, user_id)
def _get_active(self) -> PomodoroSession | None:
stmt = ( def _get_active(self) -> PomodoroSession | None:
select(PomodoroSession) stmt = (
.where(PomodoroSession.status.in_(("running", "paused"))) select(PomodoroSession)
.order_by(PomodoroSession.id.desc()) .where(
.limit(1) PomodoroSession.user_id == self.user_id,
) PomodoroSession.status.in_(("running", "paused")),
return self.db.scalar(stmt) )
.order_by(PomodoroSession.id.desc())
def _elapsed(self, session: PomodoroSession) -> int: .limit(1)
elapsed = session.elapsed_seconds )
if session.status == "running" and session.started_at: return self.db.scalar(stmt)
started = session.started_at
if started.tzinfo is None: def _elapsed(self, session: PomodoroSession) -> int:
started = started.replace(tzinfo=timezone.utc) elapsed = session.elapsed_seconds
delta = _utcnow() - started if session.status == "running" and session.started_at:
elapsed += int(delta.total_seconds()) started = session.started_at
return elapsed if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
def _remaining(self, session: PomodoroSession) -> int: delta = _utcnow() - started
total = session.duration_min * 60 elapsed += int(delta.total_seconds())
return max(0, total - self._elapsed(session)) return elapsed
def _try_auto_complete(self, session: PomodoroSession) -> bool: def _remaining(self, session: PomodoroSession) -> int:
if session.status != "running": total = session.duration_min * 60
return False return max(0, total - self._elapsed(session))
if self._remaining(session) > 0:
return False def _try_auto_complete(self, session: PomodoroSession) -> bool:
self._finalize_session(session, auto=True) if session.status != "running":
return True return False
if self._remaining(session) > 0:
def _finalize_session( return False
self, self._finalize_session(session, auto=True)
session: PomodoroSession, return True
*,
auto: bool, def _finalize_session(
result: str = "", self,
completed: bool | None = None, session: PomodoroSession,
cancelled: bool = False, *,
) -> None: auto: bool,
session.elapsed_seconds = self._elapsed(session) result: str = "",
session.started_at = None completed: bool | None = None,
session.finished_at = _utcnow() cancelled: bool = False,
session.completion_notified = False ) -> None:
session.result = result or None session.elapsed_seconds = self._elapsed(session)
session.started_at = None
if cancelled: session.finished_at = _utcnow()
session.status = "cancelled" session.completion_notified = False
session.completed = False session.result = result or None
elif completed is not None:
session.status = "completed" if cancelled:
session.completed = completed session.status = "cancelled"
else: session.completed = False
session.status = "completed" elif completed is not None:
session.completed = True session.status = "completed"
session.completed = completed
self.db.commit() else:
self.db.refresh(session) session.status = "completed"
session.completed = True
def _start_phase(
self, self.db.commit()
phase: str, self.db.refresh(session)
*,
duration_min: int | None = None, def _start_phase(
task_note: str | None = None, self,
) -> PomodoroSession: phase: str,
active = self._get_active() *,
if active: duration_min: int | None = None,
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.") task_note: str | None = None,
) -> PomodoroSession:
cycle = self.cycle.get() active = self._get_active()
if task_note is not None: if active:
cycle.task_note = task_note raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
elif phase == PHASE_WORK and not cycle.task_note:
cycle.task_note = "" cycle = self.cycle.get()
if task_note is not None:
duration = duration_min or self.cycle.duration_for_phase(phase, cycle) cycle.task_note = task_note
note = task_note if task_note is not None else cycle.task_note elif phase == PHASE_WORK and not cycle.task_note:
cycle.task_note = ""
session = PomodoroSession(
status="running", duration = duration_min or self.cycle.duration_for_phase(phase, cycle)
phase=phase, note = task_note if task_note is not None else cycle.task_note
duration_min=duration,
task_note=note, session = PomodoroSession(
started_at=_utcnow(), user_id=self.user_id,
) status="running",
self.db.add(session) phase=phase,
self.db.commit() duration_min=duration,
self.db.refresh(session) task_note=note,
return session started_at=_utcnow(),
)
def _to_status_dict(self, session: PomodoroSession | None) -> dict: self.db.add(session)
cycle_dict = self.cycle.to_dict() self.db.commit()
if not session: self.db.refresh(session)
return { return session
"status": "idle",
"phase": PHASE_WORK, def _to_status_dict(self, session: PomodoroSession | None) -> dict:
"duration_min": cycle_dict["work_duration_min"], cycle_dict = self.cycle.to_dict()
"task_note": cycle_dict["task_note"], if not session:
"elapsed_seconds": 0, return {
"remaining_seconds": 0, "status": "idle",
"session_id": None, "phase": PHASE_WORK,
"cycle": cycle_dict, "duration_min": cycle_dict["work_duration_min"],
} "task_note": cycle_dict["task_note"],
"elapsed_seconds": 0,
elapsed = self._elapsed(session) "remaining_seconds": 0,
total = session.duration_min * 60 "session_id": None,
remaining = max(0, total - elapsed) "cycle": cycle_dict,
}
return {
"status": session.status, elapsed = self._elapsed(session)
"phase": session.phase, total = session.duration_min * 60
"duration_min": session.duration_min, remaining = max(0, total - elapsed)
"task_note": session.task_note,
"elapsed_seconds": elapsed, return {
"remaining_seconds": remaining, "status": session.status,
"session_id": session.id, "phase": session.phase,
"started_at": session.started_at.isoformat() if session.started_at else None, "duration_min": session.duration_min,
"finished_at": session.finished_at.isoformat() if session.finished_at else None, "task_note": session.task_note,
"cycle": cycle_dict, "elapsed_seconds": elapsed,
} "remaining_seconds": remaining,
"session_id": session.id,
def get_status(self) -> dict: "started_at": session.started_at.isoformat() if session.started_at else None,
active = self._get_active() "finished_at": session.finished_at.isoformat() if session.finished_at else None,
if active: "cycle": cycle_dict,
self._try_auto_complete(active) }
active = self._get_active()
return self._to_status_dict(active) def get_status(self) -> dict:
active = self._get_active()
def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict: if active:
session = self._start_phase( self._try_auto_complete(active)
PHASE_WORK, active = self._get_active()
duration_min=duration_min, return self._to_status_dict(active)
task_note=task_note,
) def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict:
return self._to_status_dict(session) session = self._start_phase(
PHASE_WORK,
def start_short_break(self, duration_min: int | None = None) -> dict: duration_min=duration_min,
session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min) task_note=task_note,
return self._to_status_dict(session) )
return self._to_status_dict(session)
def start_long_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min) def start_short_break(self, duration_min: int | None = None) -> dict:
return self._to_status_dict(session) session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min)
return self._to_status_dict(session)
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
return self.start_work(duration_min=duration_min, task_note=task_note) def start_long_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min)
def pause(self) -> dict: return self._to_status_dict(session)
session = self._get_active()
if not session or session.status != "running": def start(self, duration_min: int = 25, task_note: str = "") -> dict:
raise ValueError("Нет активного запущенного таймера.") return self.start_work(duration_min=duration_min, task_note=task_note)
session.elapsed_seconds = self._elapsed(session) def pause(self) -> dict:
session.status = "paused" session = self._get_active()
session.paused_at = _utcnow() if not session or session.status != "running":
session.started_at = None raise ValueError("Нет активного запущенного таймера.")
self.db.commit()
self.db.refresh(session) session.elapsed_seconds = self._elapsed(session)
return self._to_status_dict(session) session.status = "paused"
session.paused_at = _utcnow()
def resume(self) -> dict: session.started_at = None
session = self._get_active() self.db.commit()
if not session or session.status != "paused": self.db.refresh(session)
raise ValueError("Нет таймера на паузе.") return self._to_status_dict(session)
session.status = "running" def resume(self) -> dict:
session.started_at = _utcnow() session = self._get_active()
session.paused_at = None if not session or session.status != "paused":
self.db.commit() raise ValueError("Нет таймера на паузе.")
self.db.refresh(session)
return self._to_status_dict(session) session.status = "running"
session.started_at = _utcnow()
def stop(self, result: str = "", completed: bool = False) -> dict: session.paused_at = None
session = self._get_active() self.db.commit()
if not session: self.db.refresh(session)
raise ValueError("Нет активного таймера.") return self._to_status_dict(session)
if completed: def stop(self, result: str = "", completed: bool = False) -> dict:
self._finalize_session(session, auto=False, result=result, completed=True) session = self._get_active()
else: if not session:
self._finalize_session(session, auto=False, result=result, cancelled=True) raise ValueError("Нет активного таймера.")
session.completion_notified = True
self.db.commit() if completed:
return self._to_status_dict(None) self._finalize_session(session, auto=False, result=result, completed=True)
else:
def reset_cycle(self, clear_task: bool = False) -> dict: self._finalize_session(session, auto=False, result=result, cancelled=True)
active = self._get_active() session.completion_notified = True
if active: self.db.commit()
self._finalize_session(active, auto=False, cancelled=True) return self._to_status_dict(None)
active.completion_notified = True
self.db.commit() def reset_cycle(self, clear_task: bool = False) -> dict:
cycle = self.cycle.reset(clear_task=clear_task) active = self._get_active()
status = self._to_status_dict(None) if active:
status["cycle"] = cycle self._finalize_session(active, auto=False, cancelled=True)
return status active.completion_notified = True
self.db.commit()
def skip_phase(self) -> dict: cycle = self.cycle.reset(clear_task=clear_task)
session = self._get_active() status = self._to_status_dict(None)
if not session: status["cycle"] = cycle
raise ValueError("Нет активного таймера.") return status
self._finalize_session(session, auto=True) def skip_phase(self) -> dict:
return self._to_status_dict(None) session = self._get_active()
if not session:
def get_pending_completions(self) -> list[PomodoroSession]: raise ValueError("Нет активного таймера.")
stmt = (
select(PomodoroSession) self._finalize_session(session, auto=True)
.where( return self._to_status_dict(None)
PomodoroSession.status == "completed",
PomodoroSession.completed.is_(True), def get_pending_completions(self) -> list[PomodoroSession]:
PomodoroSession.completion_notified.is_(False), stmt = (
) select(PomodoroSession)
.order_by(PomodoroSession.id.asc()) .where(
) PomodoroSession.user_id == self.user_id,
return list(self.db.scalars(stmt)) PomodoroSession.status == "completed",
PomodoroSession.completed.is_(True),
def mark_notified(self, session: PomodoroSession) -> None: PomodoroSession.completion_notified.is_(False),
session.completion_notified = True )
self.db.commit() .order_by(PomodoroSession.id.asc())
)
def advance_after_completion(self, session: PomodoroSession) -> dict | None: return list(self.db.scalars(stmt))
"""Update cycle counters and auto-start next phase. Returns new status or None."""
phase = session.phase def mark_notified(self, session: PomodoroSession) -> None:
cycle = self.cycle.get() session.completion_notified = True
self.db.commit()
if phase == PHASE_WORK:
next_phase = self.cycle.on_work_completed() def advance_after_completion(self, session: PomodoroSession) -> dict | None:
elif phase == PHASE_SHORT_BREAK: """Update cycle counters and auto-start next phase. Returns new status or None."""
next_phase = PHASE_WORK phase = session.phase
elif phase == PHASE_LONG_BREAK: cycle = self.cycle.get()
self.cycle.on_long_break_completed()
next_phase = None if phase == PHASE_WORK:
else: next_phase = self.cycle.on_work_completed()
next_phase = None elif phase == PHASE_SHORT_BREAK:
next_phase = PHASE_WORK
if not cycle.auto_advance or next_phase is None: elif phase == PHASE_LONG_BREAK:
return None self.cycle.on_long_break_completed()
next_phase = None
new_session = self._start_phase(next_phase) else:
return self._to_status_dict(new_session) next_phase = None
def history(self, limit: int = 20) -> list[dict]: if not cycle.auto_advance or next_phase is None:
stmt = ( return None
select(PomodoroSession)
.where(PomodoroSession.status.in_(("completed", "cancelled"))) new_session = self._start_phase(next_phase)
.order_by(PomodoroSession.finished_at.desc()) return self._to_status_dict(new_session)
.limit(limit)
) def history(self, limit: int = 20) -> list[dict]:
sessions = self.db.scalars(stmt).all() stmt = (
return [ select(PomodoroSession)
{ .where(
"id": s.id, PomodoroSession.user_id == self.user_id,
"status": s.status, PomodoroSession.status.in_(("completed", "cancelled")),
"phase": s.phase, )
"duration_min": s.duration_min, .order_by(PomodoroSession.finished_at.desc())
"task_note": s.task_note, .limit(limit)
"result": s.result, )
"completed": s.completed, sessions = self.db.scalars(stmt).all()
"elapsed_seconds": s.elapsed_seconds, return [
"finished_at": s.finished_at.isoformat() if s.finished_at else None, {
} "id": s.id,
for s in sessions "status": s.status,
] "phase": s.phase,
"duration_min": s.duration_min,
"task_note": s.task_note,
"result": s.result,
"completed": s.completed,
"elapsed_seconds": s.elapsed_seconds,
"finished_at": s.finished_at.isoformat() if s.finished_at else None,
}
for s in sessions
]
+41 -38
View File
@@ -1,38 +1,41 @@
import asyncio import asyncio
import logging import logging
from app.db.base import SessionLocal from sqlalchemy import select
from app.pomodoro.completion import PomodoroCompletionHandler
from app.pomodoro.service import PomodoroService from app.db.base import SessionLocal
from app.db.models import User
logger = logging.getLogger(__name__) from app.pomodoro.completion import PomodoroCompletionHandler
from app.pomodoro.service import PomodoroService
WATCH_INTERVAL_SEC = 2
logger = logging.getLogger(__name__)
async def pomodoro_watcher_loop() -> None: WATCH_INTERVAL_SEC = 2
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC) async def pomodoro_watcher_loop() -> None:
await _tick() while True:
except asyncio.CancelledError: try:
raise await asyncio.sleep(WATCH_INTERVAL_SEC)
except Exception: await _tick()
logger.exception("Pomodoro watcher error") except asyncio.CancelledError:
raise
except Exception:
async def _tick() -> None: logger.exception("Pomodoro watcher error")
db = SessionLocal()
try:
service = PomodoroService(db) async def _tick() -> None:
service.get_status() db = SessionLocal()
try:
pending = service.get_pending_completions() users = db.scalars(select(User).where(User.is_active.is_(True))).all()
if not pending: for user in users:
return service = PomodoroService(db, user.id)
service.get_status()
handler = PomodoroCompletionHandler(db) pending = service.get_pending_completions()
for session in pending: if not pending:
await handler.process(session) continue
finally: handler = PomodoroCompletionHandler(db, user.id)
db.close() for session in pending:
await handler.process(session)
finally:
db.close()
+155 -153
View File
@@ -1,153 +1,155 @@
import time import time
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.integrations.taiga import TaigaClient from app.integrations.taiga import TaigaClient
from app.projects.service import ProjectService from app.projects.service import ProjectService
MAX_PROJECTS_IN_CONTEXT = 20 MAX_PROJECTS_IN_CONTEXT = 20
MAX_OPEN_PER_PROJECT = 8 MAX_OPEN_PER_PROJECT = 8
PROJECTS_CACHE_SEC = 120 PROJECTS_CACHE_SEC = 120
_cache: dict[str, Any] = {"data": None, "expires_at": 0.0} _cache: dict[int, dict[str, Any]] = {}
def invalidate_projects_snapshot_cache() -> None: def invalidate_projects_snapshot_cache(user_id: int | None = None) -> None:
_cache["data"] = None if user_id is None:
_cache["expires_at"] = 0.0 _cache.clear()
else:
_cache.pop(user_id, None)
def get_projects_snapshot(db: Session, *, force: bool = False) -> dict[str, Any]:
now = time.time()
if not force and _cache["data"] is not None and now < _cache["expires_at"]: def get_projects_snapshot(db: Session, user_id: int, *, force: bool = False) -> dict[str, Any]:
return _cache["data"] now = time.time()
entry = _cache.get(user_id)
snapshot = _fetch_projects_snapshot(db) if not force and entry and now < entry.get("expires_at", 0):
_cache["data"] = snapshot return entry["data"]
_cache["expires_at"] = now + PROJECTS_CACHE_SEC
return snapshot 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) -> dict[str, Any]:
settings = get_settings()
service = ProjectService(db) def _fetch_projects_snapshot(db: Session, user_id: int) -> dict[str, Any]:
settings = get_settings()
if not settings.taiga_configured: service = ProjectService(db, user_id)
return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
if not settings.taiga_configured:
projects = service.list_projects() return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
if not projects:
try: projects = service.list_projects()
projects = service.sync_taiga_projects() if not projects:
except Exception as exc: try:
return { projects = service.sync_taiga_projects()
"configured": True, except Exception as exc:
"projects": [], return {
"open_items": [], "configured": True,
"taiga_open": [], "projects": [],
"error": str(exc), "open_items": [],
} "taiga_open": [],
"error": str(exc),
open_items = service.list_work_items(limit=15, status="open") }
taiga_open: list[dict[str, Any]] = []
fetch_error: str | None = None open_items = service.list_work_items(limit=15, status="open")
taiga_open: list[dict[str, Any]] = []
try: fetch_error: str | None = None
client = TaigaClient()
for proj in projects[:MAX_PROJECTS_IN_CONTEXT]: try:
stories = client.list_open_userstories( client = TaigaClient()
proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT for proj in projects[:MAX_PROJECTS_IN_CONTEXT]:
) stories = client.list_open_userstories(
tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT) proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT
taiga_open.append( )
{ tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT)
"slug": proj["slug"], taiga_open.append(
"name": proj["name"], {
"stories": [ "slug": proj["slug"],
{ "name": proj["name"],
"ref": s.get("ref"), "stories": [
"subject": s.get("subject", "")[:120], {
} "ref": s.get("ref"),
for s in stories "subject": s.get("subject", "")[:120],
], }
"tasks": [ for s in stories
{ ],
"ref": t.get("ref"), "tasks": [
"subject": t.get("subject", "")[:120], {
} "ref": t.get("ref"),
for t in tasks "subject": t.get("subject", "")[:120],
], }
} for t in tasks
) ],
except Exception as exc: }
fetch_error = str(exc) )
except Exception as exc:
return { fetch_error = str(exc)
"configured": True,
"projects": projects, return {
"open_items": open_items, "configured": True,
"taiga_open": taiga_open, "projects": projects,
"error": fetch_error, "open_items": open_items,
} "taiga_open": taiga_open,
"error": fetch_error,
}
def format_projects_context(snapshot: dict[str, Any]) -> str:
if not snapshot.get("configured"):
return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)." def format_projects_context(snapshot: dict[str, Any]) -> str:
if not snapshot.get("configured"):
lines = ["[Проекты и задачи — снимок на начало ответа]"] return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)."
if snapshot.get("error"): lines = ["[Проекты и задачи — снимок на начало ответа]"]
lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}")
if snapshot.get("error"):
projects = snapshot.get("projects") or [] lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}")
if not projects:
lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.") projects = snapshot.get("projects") or []
else: if not projects:
lines.append(f"Проекты Taiga ({len(projects)}):") lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.")
for p in projects[:MAX_PROJECTS_IN_CONTEXT]: else:
gitea = ( lines.append(f"Проекты Taiga ({len(projects)}):")
f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" for p in projects[:MAX_PROJECTS_IN_CONTEXT]:
if p.get("gitea_configured") gitea = (
else "Gitea не привязан" f"{p.get('gitea_owner')}/{p.get('gitea_repo')}"
) if p.get("gitea_configured")
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}") else "Gitea не привязан"
)
taiga_open = snapshot.get("taiga_open") or [] lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}")
if taiga_open:
lines.append("") taiga_open = snapshot.get("taiga_open") or []
lines.append("Открытые задачи в Taiga (live):") if taiga_open:
for block in taiga_open: lines.append("")
stories = block.get("stories") or [] lines.append("Открытые задачи в Taiga (live):")
tasks = block.get("tasks") or [] for block in taiga_open:
if not stories and not tasks: stories = block.get("stories") or []
lines.append(f" `{block.get('slug')}`: нет открытых") tasks = block.get("tasks") or []
continue if not stories and not tasks:
lines.append(f" `{block.get('slug')}`:") lines.append(f" `{block.get('slug')}`: нет открытых")
for story in stories: continue
lines.append(f" story #{story.get('ref')} {story.get('subject')}") lines.append(f" `{block.get('slug')}`:")
for task in tasks: for story in stories:
lines.append(f" task #{task.get('ref')} {task.get('subject')}") lines.append(f" story #{story.get('ref')} {story.get('subject')}")
for task in tasks:
open_items = snapshot.get("open_items") or [] lines.append(f" task #{task.get('ref')} {task.get('subject')}")
if open_items:
lines.append("") open_items = snapshot.get("open_items") or []
lines.append("Work items созданные ассистентом (локальная БД):") if open_items:
for item in open_items[:10]: lines.append("")
gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else "" lines.append("Work items созданные ассистентом (локальная БД):")
lines.append( for item in open_items[:10]:
f"- #{item.get('taiga_ref')} {item.get('title')} " gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else ""
f"({item.get('taiga_slug')}{gitea_part})" lines.append(
) f"- #{item.get('taiga_ref')} {item.get('title')} "
f"({item.get('taiga_slug')}{gitea_part})"
lines.append("") )
lines.append(
"Правила: " lines.append("")
"«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. " lines.append(
"list_work_items — только созданные через ассистента. " "Правила: "
"Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. " "«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. "
"create_work_item — для новых фич/багов из вольного текста." "list_work_itemsтолько созданные через ассистента. "
) "Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. "
return "\n".join(lines) "create_work_item — для новых фич/багов из вольного текста."
)
return "\n".join(lines)
+476 -466
View File
@@ -1,466 +1,476 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.db.models import ProjectBinding, TaigaProject, WorkItem from app.db.models import ProjectBinding, TaigaProject, WorkItem
from app.integrations.gitea import GiteaClient from app.integrations.gitea import GiteaClient
from app.integrations.taiga import TaigaClient from app.integrations.taiga import TaigaClient
from app.projects.commit_parser import parse_commit_message from app.projects.commit_parser import parse_commit_message
from app.projects.structuring import ( from app.projects.structuring import (
format_gitea_body, format_gitea_body,
format_story_description, format_story_description,
slugify_branch, slugify_branch,
structure_work_item, structure_work_item,
) )
class ProjectService: class ProjectService:
def __init__(self, db: Session): def __init__(self, db: Session, user_id: int):
self.db = db self.db = db
self.settings = get_settings() self.user_id = user_id
self.settings = get_settings()
def sync_taiga_projects(self) -> list[dict[str, Any]]:
if not self.settings.taiga_configured: def sync_taiga_projects(self) -> list[dict[str, Any]]:
raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD") if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD")
client = TaigaClient()
remote = client.list_projects() client = TaigaClient()
now = datetime.now(timezone.utc) remote = client.list_projects()
now = datetime.now(timezone.utc)
for item in remote:
slug = item.get("slug") or "" for item in remote:
if not slug: slug = item.get("slug") or ""
continue if not slug:
existing = self.db.scalar( continue
select(TaigaProject).where(TaigaProject.slug == slug) existing = self.db.scalar(
) select(TaigaProject).where(TaigaProject.slug == slug)
if existing: )
existing.name = item.get("name", slug) if existing:
existing.taiga_id = item["id"] existing.name = item.get("name", slug)
existing.synced_at = now existing.taiga_id = item["id"]
else: existing.synced_at = now
self.db.add( else:
TaigaProject( self.db.add(
taiga_id=item["id"], TaigaProject(
name=item.get("name", slug), taiga_id=item["id"],
slug=slug, name=item.get("name", slug),
synced_at=now, slug=slug,
) synced_at=now,
) )
self.db.commit() )
return self.list_projects() self.db.commit()
return self.list_projects()
def list_projects(self) -> list[dict[str, Any]]:
stmt = ( def list_projects(self) -> list[dict[str, Any]]:
select(TaigaProject, ProjectBinding) stmt = (
.outerjoin(ProjectBinding, ProjectBinding.taiga_slug == TaigaProject.slug) select(TaigaProject, ProjectBinding)
.order_by(TaigaProject.name) .outerjoin(
) ProjectBinding,
rows = self.db.execute(stmt).all() (ProjectBinding.taiga_slug == TaigaProject.slug)
result = [] & (ProjectBinding.user_id == self.user_id),
for taiga_proj, binding in rows: )
result.append( .order_by(TaigaProject.name)
{ )
"taiga_id": taiga_proj.taiga_id, rows = self.db.execute(stmt).all()
"name": taiga_proj.name, result = []
"slug": taiga_proj.slug, for taiga_proj, binding in rows:
"gitea_owner": binding.gitea_owner if binding else "", result.append(
"gitea_repo": binding.gitea_repo if binding else "", {
"default_branch": binding.default_branch if binding else "main", "taiga_id": taiga_proj.taiga_id,
"gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo), "name": taiga_proj.name,
} "slug": taiga_proj.slug,
) "gitea_owner": binding.gitea_owner if binding else "",
return result "gitea_repo": binding.gitea_repo if binding else "",
"default_branch": binding.default_branch if binding else "main",
def bind_gitea( "gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo),
self, }
taiga_slug: str, )
gitea_owner: str, return result
gitea_repo: str,
default_branch: str = "main", def bind_gitea(
) -> dict[str, Any]: self,
if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)): taiga_slug: str,
raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.") gitea_owner: str,
gitea_repo: str,
binding = self.db.scalar( default_branch: str = "main",
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_slug) ) -> dict[str, Any]:
) if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)):
if binding: raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.")
binding.gitea_owner = gitea_owner
binding.gitea_repo = gitea_repo binding = self.db.scalar(
binding.default_branch = default_branch select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_slug)
else: )
binding = ProjectBinding( if binding:
taiga_slug=taiga_slug, binding.gitea_owner = gitea_owner
gitea_owner=gitea_owner, binding.gitea_repo = gitea_repo
gitea_repo=gitea_repo, binding.default_branch = default_branch
default_branch=default_branch, else:
) binding = ProjectBinding(
self.db.add(binding) user_id=self.user_id,
self.db.commit() taiga_slug=taiga_slug,
gitea_owner=gitea_owner,
for proj in self.list_projects(): gitea_repo=gitea_repo,
if proj["slug"] == taiga_slug: default_branch=default_branch,
return proj )
raise ValueError("Binding failed") self.db.add(binding)
self.db.commit()
def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]:
projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all() for proj in self.list_projects():
if not projects: if proj["slug"] == taiga_slug:
raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.") return proj
raise ValueError("Binding failed")
taiga_proj: TaigaProject | None = None
if slug: def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]:
taiga_proj = self.db.scalar( projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all()
select(TaigaProject).where(TaigaProject.slug == slug) if not projects:
) raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.")
if not taiga_proj:
raise ValueError(f"Проект '{slug}' не найден") taiga_proj: TaigaProject | None = None
else: if slug:
taiga_proj = projects[0] taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == slug)
binding = self.db.scalar( )
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_proj.slug) if not taiga_proj:
) raise ValueError(f"Проект '{slug}' не найден")
return taiga_proj, binding else:
taiga_proj = projects[0]
async def create_work_item(
self, raw_text: str, project_slug: str | None = None binding = self.db.scalar(
) -> dict[str, Any]: select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_proj.slug)
if not self.settings.taiga_configured: )
raise ValueError("Taiga не настроена") return taiga_proj, binding
project_list = self.list_projects() async def create_work_item(
if not project_list: self, raw_text: str, project_slug: str | None = None
self.sync_taiga_projects() ) -> dict[str, Any]:
project_list = self.list_projects() if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена")
structured = await structure_work_item(raw_text, project_list)
slug = project_slug or structured.get("project_slug") project_list = self.list_projects()
taiga_proj, binding = self._resolve_project(slug) if not project_list:
self.sync_taiga_projects()
if binding and not (binding.gitea_owner and binding.gitea_repo): project_list = self.list_projects()
binding = None
structured = await structure_work_item(raw_text, project_list)
taiga = TaigaClient() slug = project_slug or structured.get("project_slug")
title = (structured.get("title") or raw_text).strip()[:500] taiga_proj, binding = self._resolve_project(slug)
description = format_story_description(structured, raw_text)
tags = structured.get("tags") or [] if binding and not (binding.gitea_owner and binding.gitea_repo):
issue_type = structured.get("issue_type", "feature") binding = None
if issue_type == "bug" and "bug" not in [t.lower() for t in tags]:
tags.append("bug") taiga = TaigaClient()
title = (structured.get("title") or raw_text).strip()[:500]
story = taiga.create_userstory( description = format_story_description(structured, raw_text)
taiga_proj.taiga_id, tags = structured.get("tags") or []
title, issue_type = structured.get("issue_type", "feature")
description, if issue_type == "bug" and "bug" not in [t.lower() for t in tags]:
tags=tags, tags.append("bug")
)
story = taiga.create_userstory(
subtasks = [] taiga_proj.taiga_id,
for child in structured.get("children") or []: title,
if isinstance(child, dict): description,
subtasks.append( tags=tags,
taiga.create_task( )
taiga_proj.taiga_id,
story["id"], subtasks = []
child.get("title", "Подзадача"), for child in structured.get("children") or []:
child.get("description", ""), if isinstance(child, dict):
) subtasks.append(
) taiga.create_task(
taiga_proj.taiga_id,
branch = f"feature/{story['ref']}-{slugify_branch(title)}" story["id"],
gitea_issue_number = None child.get("title", "Подзадача"),
gitea_url = "" child.get("description", ""),
)
if binding and self.settings.gitea_configured: )
gitea = GiteaClient()
gitea_body = format_gitea_body( branch = f"feature/{story['ref']}-{slugify_branch(title)}"
structured, gitea_issue_number = None
raw_text, gitea_url = ""
story["ref"],
taiga.story_url(taiga_proj.taiga_id, story["ref"]), if binding and self.settings.gitea_configured:
branch, gitea = GiteaClient()
) gitea_body = format_gitea_body(
if issue_type: structured,
gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}" raw_text,
issue = gitea.create_issue( story["ref"],
binding.gitea_owner, taiga.story_url(taiga_proj.taiga_id, story["ref"]),
binding.gitea_repo, branch,
title, )
gitea_body, if issue_type:
) gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}"
gitea_issue_number = issue["number"] issue = gitea.create_issue(
gitea_url = gitea.issue_url( binding.gitea_owner,
binding.gitea_owner, binding.gitea_repo, gitea_issue_number binding.gitea_repo,
) title,
gitea_body,
work_item = WorkItem( )
taiga_slug=taiga_proj.slug, gitea_issue_number = issue["number"]
taiga_project_id=taiga_proj.taiga_id, gitea_url = gitea.issue_url(
taiga_story_id=story["id"], binding.gitea_owner, binding.gitea_repo, gitea_issue_number
taiga_story_ref=story["ref"], )
gitea_owner=binding.gitea_owner if binding else "",
gitea_repo=binding.gitea_repo if binding else "", work_item = WorkItem(
gitea_issue_number=gitea_issue_number, user_id=self.user_id,
suggested_branch=branch, taiga_slug=taiga_proj.slug,
raw_text=raw_text, taiga_project_id=taiga_proj.taiga_id,
title=title, taiga_story_id=story["id"],
status="open", taiga_story_ref=story["ref"],
) gitea_owner=binding.gitea_owner if binding else "",
self.db.add(work_item) gitea_repo=binding.gitea_repo if binding else "",
self.db.commit() gitea_issue_number=gitea_issue_number,
self.db.refresh(work_item) suggested_branch=branch,
raw_text=raw_text,
return { title=title,
"ok": True, status="open",
"work_item_id": work_item.id, )
"taiga": { self.db.add(work_item)
"ref": story["ref"], self.db.commit()
"id": story["id"], self.db.refresh(work_item)
"subject": story["subject"],
"url": taiga.story_url(taiga_proj.taiga_id, story["ref"]), return {
}, "ok": True,
"gitea": { "work_item_id": work_item.id,
"number": gitea_issue_number, "taiga": {
"url": gitea_url, "ref": story["ref"],
}, "id": story["id"],
"branch": branch, "subject": story["subject"],
"issue_type": issue_type, "url": taiga.story_url(taiga_proj.taiga_id, story["ref"]),
"subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks], },
"questions": structured.get("questions") or [], "gitea": {
"project_slug": taiga_proj.slug, "number": gitea_issue_number,
} "url": gitea_url,
},
def process_push( "branch": branch,
self, owner: str, repo: str, commits: list[dict[str, Any]] "issue_type": issue_type,
) -> list[dict[str, Any]]: "subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks],
if not self.settings.taiga_configured: "questions": structured.get("questions") or [],
return [] "project_slug": taiga_proj.slug,
}
taiga = TaigaClient()
gitea = GiteaClient() if self.settings.gitea_configured else None def process_push(
results: list[dict[str, Any]] = [] self, owner: str, repo: str, commits: list[dict[str, Any]]
) -> list[dict[str, Any]]:
for commit in commits: if not self.settings.taiga_configured:
message = commit.get("message", "") return []
parsed = parse_commit_message(message)
sha = commit.get("id", "")[:8] taiga = TaigaClient()
gitea = GiteaClient() if self.settings.gitea_configured else None
gitea_refs = set(parsed["gitea"]) results: list[dict[str, Any]] = []
taiga_story_refs = set(parsed["taiga_story"])
taiga_task_refs = set(parsed["taiga_task"]) for commit in commits:
message = commit.get("message", "")
linked_items = self.db.scalars( parsed = parse_commit_message(message)
select(WorkItem).where( sha = commit.get("id", "")[:8]
WorkItem.gitea_owner == owner,
WorkItem.gitea_repo == repo, gitea_refs = set(parsed["gitea"])
WorkItem.status == "open", taiga_story_refs = set(parsed["taiga_story"])
) taiga_task_refs = set(parsed["taiga_task"])
).all()
linked_items = self.db.scalars(
for item in linked_items: select(WorkItem).where(
if item.gitea_issue_number and item.gitea_issue_number in gitea_refs: WorkItem.user_id == self.user_id,
taiga_story_refs.add(item.taiga_story_ref) WorkItem.gitea_owner == owner,
if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number: WorkItem.gitea_repo == repo,
gitea_refs.add(item.gitea_issue_number) WorkItem.status == "open",
)
for gitea_num in gitea_refs: ).all()
if gitea:
try: for item in linked_items:
gitea.close_issue(owner, repo, gitea_num) if item.gitea_issue_number and item.gitea_issue_number in gitea_refs:
except Exception as exc: taiga_story_refs.add(item.taiga_story_ref)
results.append({"error": f"gitea #{gitea_num}: {exc}"}) if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number:
continue gitea_refs.add(item.gitea_issue_number)
for item in linked_items: for gitea_num in gitea_refs:
if item.gitea_issue_number == gitea_num: if gitea:
try: try:
self._close_work_item(item, taiga) gitea.close_issue(owner, repo, gitea_num)
results.append( except Exception as exc:
{ results.append({"error": f"gitea #{gitea_num}: {exc}"})
"commit": sha, continue
"closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}",
} for item in linked_items:
) if item.gitea_issue_number == gitea_num:
except Exception as exc: try:
results.append( self._close_work_item(item, taiga)
{"error": f"work item {item.id} (gitea #{gitea_num}): {exc}"} results.append(
) {
"commit": sha,
for ref in taiga_story_refs: "closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}",
project_id = self._project_id_for_ref(owner, repo, ref, linked_items) }
if not project_id: )
continue except Exception as exc:
story = taiga.get_by_ref(project_id, ref, kind="userstory") results.append(
if story and not story.get("is_closed"): {"error": f"work item {item.id} (gitea #{gitea_num}): {exc}"}
try: )
taiga.close_userstory(story["id"], project_id)
results.append({"commit": sha, "closed": f"taiga #{ref}"}) for ref in taiga_story_refs:
except Exception as exc: project_id = self._project_id_for_ref(owner, repo, ref, linked_items)
results.append({"error": f"taiga #{ref}: {exc}"}) if not project_id:
for item in linked_items: continue
if item.taiga_story_ref == ref and item.status != "closed": story = taiga.get_by_ref(project_id, ref, kind="userstory")
try: if story and not story.get("is_closed"):
self._close_work_item(item, taiga, close_gitea=bool(gitea)) try:
except Exception as exc: taiga.close_userstory(story["id"], project_id)
results.append( results.append({"commit": sha, "closed": f"taiga #{ref}"})
{"error": f"work item {item.id} (taiga #{ref}): {exc}"} except Exception as exc:
) results.append({"error": f"taiga #{ref}: {exc}"})
for item in linked_items:
for ref in taiga_task_refs: if item.taiga_story_ref == ref and item.status != "closed":
binding = self.db.scalar( try:
select(ProjectBinding).where( self._close_work_item(item, taiga, close_gitea=bool(gitea))
ProjectBinding.gitea_owner == owner, except Exception as exc:
ProjectBinding.gitea_repo == repo, results.append(
) {"error": f"work item {item.id} (taiga #{ref}): {exc}"}
) )
if not binding:
continue for ref in taiga_task_refs:
taiga_proj = self.db.scalar( binding = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug) select(ProjectBinding).where(
) ProjectBinding.user_id == self.user_id,
if not taiga_proj: ProjectBinding.gitea_owner == owner,
continue ProjectBinding.gitea_repo == repo,
task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task") )
if task and not task.get("is_closed"): )
try: if not binding:
taiga.close_task(task["id"], taiga_proj.taiga_id) continue
results.append({"commit": sha, "closed": f"taiga task #{ref}"}) taiga_proj = self.db.scalar(
except Exception as exc: select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
results.append({"error": f"taiga task #{ref}: {exc}"}) )
if not taiga_proj:
self.db.commit() continue
return results task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task")
if task and not task.get("is_closed"):
def _project_id_for_ref( try:
self, taiga.close_task(task["id"], taiga_proj.taiga_id)
owner: str, results.append({"commit": sha, "closed": f"taiga task #{ref}"})
repo: str, except Exception as exc:
ref: int, results.append({"error": f"taiga task #{ref}: {exc}"})
items: list[WorkItem],
) -> int | None: self.db.commit()
for item in items: return results
if item.taiga_story_ref == ref:
return item.taiga_project_id def _project_id_for_ref(
binding = self.db.scalar( self,
select(ProjectBinding).where( owner: str,
ProjectBinding.gitea_owner == owner, repo: str,
ProjectBinding.gitea_repo == repo, ref: int,
) items: list[WorkItem],
) ) -> int | None:
if binding: for item in items:
taiga_proj = self.db.scalar( if item.taiga_story_ref == ref:
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug) return item.taiga_project_id
) binding = self.db.scalar(
return taiga_proj.taiga_id if taiga_proj else None select(ProjectBinding).where(
return None ProjectBinding.user_id == self.user_id,
ProjectBinding.gitea_owner == owner,
def _close_work_item( ProjectBinding.gitea_repo == repo,
self, )
item: WorkItem, )
taiga: TaigaClient, if binding:
*, taiga_proj = self.db.scalar(
close_gitea: bool = True, select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
) -> None: )
if item.status == "closed": return taiga_proj.taiga_id if taiga_proj else None
return return None
story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory")
if story: def _close_work_item(
taiga.close_userstory(story["id"], item.taiga_project_id) self,
if ( item: WorkItem,
close_gitea taiga: TaigaClient,
and item.gitea_issue_number *,
and self.settings.gitea_configured close_gitea: bool = True,
): ) -> None:
GiteaClient().close_issue( if item.status == "closed":
item.gitea_owner, item.gitea_repo, item.gitea_issue_number return
) story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory")
item.status = "closed" if story:
item.closed_at = datetime.now(timezone.utc) taiga.close_userstory(story["id"], item.taiga_project_id)
if (
def list_taiga_open_tasks( close_gitea
self, and item.gitea_issue_number
project_slug: str | None = None, and self.settings.gitea_configured
limit: int = 20, ):
) -> dict[str, Any]: GiteaClient().close_issue(
if not self.settings.taiga_configured: item.gitea_owner, item.gitea_repo, item.gitea_issue_number
raise ValueError("Taiga не настроена") )
item.status = "closed"
projects = self.list_projects() item.closed_at = datetime.now(timezone.utc)
if not projects:
projects = self.sync_taiga_projects() def list_taiga_open_tasks(
self,
if project_slug: project_slug: str | None = None,
projects = [p for p in projects if p["slug"] == project_slug] limit: int = 20,
if not projects: ) -> dict[str, Any]:
raise ValueError( if not self.settings.taiga_configured:
f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects." raise ValueError("Taiga не настроена")
)
projects = self.list_projects()
client = TaigaClient() if not projects:
blocks: list[dict[str, Any]] = [] projects = self.sync_taiga_projects()
for proj in projects: if project_slug:
stories = client.list_open_userstories(proj["taiga_id"], limit=limit) projects = [p for p in projects if p["slug"] == project_slug]
tasks = client.list_open_tasks(proj["taiga_id"], limit=limit) if not projects:
blocks.append( raise ValueError(
{ f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects."
"slug": proj["slug"], )
"name": proj["name"],
"taiga_id": proj["taiga_id"], client = TaigaClient()
"stories": [ blocks: list[dict[str, Any]] = []
{
"ref": s.get("ref"), for proj in projects:
"subject": s.get("subject", ""), stories = client.list_open_userstories(proj["taiga_id"], limit=limit)
"url": client.story_url(proj["taiga_id"], s.get("ref", 0)), tasks = client.list_open_tasks(proj["taiga_id"], limit=limit)
} blocks.append(
for s in stories {
], "slug": proj["slug"],
"tasks": [ "name": proj["name"],
{ "taiga_id": proj["taiga_id"],
"ref": t.get("ref"), "stories": [
"subject": t.get("subject", ""), {
"user_story": t.get("user_story"), "ref": s.get("ref"),
} "subject": s.get("subject", ""),
for t in tasks "url": client.story_url(proj["taiga_id"], s.get("ref", 0)),
], }
} for s in stories
) ],
"tasks": [
total_stories = sum(len(b["stories"]) for b in blocks) {
total_tasks = sum(len(b["tasks"]) for b in blocks) "ref": t.get("ref"),
return { "subject": t.get("subject", ""),
"projects": blocks, "user_story": t.get("user_story"),
"total_stories": total_stories, }
"total_tasks": total_tasks, for t in tasks
} ],
}
def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]: )
stmt = select(WorkItem).order_by(WorkItem.created_at.desc()).limit(limit)
if status: total_stories = sum(len(b["stories"]) for b in blocks)
stmt = stmt.where(WorkItem.status == status) total_tasks = sum(len(b["tasks"]) for b in blocks)
items = self.db.scalars(stmt).all() return {
settings = get_settings() "projects": blocks,
return [ "total_stories": total_stories,
{ "total_tasks": total_tasks,
"id": i.id, }
"title": i.title,
"status": i.status, def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]:
"taiga_slug": i.taiga_slug, stmt = select(WorkItem).where(WorkItem.user_id == self.user_id).order_by(WorkItem.created_at.desc()).limit(limit)
"taiga_ref": i.taiga_story_ref, if status:
"gitea_issue": i.gitea_issue_number, stmt = stmt.where(WorkItem.status == status)
"branch": i.suggested_branch, items = self.db.scalars(stmt).all()
"taiga_url": f"{settings.taiga_public_url}/project/0/{i.taiga_project_id}/us/{i.taiga_story_ref}", settings = get_settings()
"gitea_url": ( return [
f"{settings.gitea_public_url}/{i.gitea_owner}/{i.gitea_repo}/issues/{i.gitea_issue_number}" {
if i.gitea_issue_number "id": i.id,
else "" "title": i.title,
), "status": i.status,
"created_at": i.created_at.isoformat() if i.created_at else None, "taiga_slug": i.taiga_slug,
} "taiga_ref": i.taiga_story_ref,
for i in items "gitea_issue": i.gitea_issue_number,
] "branch": i.suggested_branch,
"taiga_url": f"{settings.taiga_public_url}/project/0/{i.taiga_project_id}/us/{i.taiga_story_ref}",
"gitea_url": (
f"{settings.gitea_public_url}/{i.gitea_owner}/{i.gitea_repo}/issues/{i.gitea_issue_number}"
if i.gitea_issue_number
else ""
),
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in items
]
+5
View File
@@ -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,
)
+74 -73
View File
@@ -1,73 +1,74 @@
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
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.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
from app.db.models import Reminder from app.db.models import Reminder
from app.llm.client import LLMClient from app.llm.client import LLMClient
from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def format_reminder_notice(row: Reminder) -> str: def format_reminder_notice(row: Reminder) -> str:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_" notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_"
if row.notes: if row.notes:
notice += f"\n{row.notes}" notice += f"\n{row.notes}"
return notice return notice
class ReminderCompletionHandler: class ReminderCompletionHandler:
def __init__(self, db: Session): def __init__(self, db: Session, user_id: int):
self.db = db self.db = db
self.llm = LLMClient() self.user_id = user_id
self.character = CharacterService() 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 "" async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str:
rec_part = "" notes_part = f"\nЗаметки: {row.notes}" if row.notes else ""
if row.recurrence and row.recurrence != RECURRENCE_NONE: rec_part = ""
rec_part = f"\nПовтор: {row.recurrence}" if row.recurrence and row.recurrence != RECURRENCE_NONE:
rec_part = f"\nПовтор: {row.recurrence}"
system = self.character.get_system_prompt()
user_prompt = f"""Сработало напоминание. system = self.character.get_system_prompt()
Заголовок: {row.title} user_prompt = f"""Сработало напоминание.
Время: {local_when}{notes_part}{rec_part} Заголовок: {row.title}
Время: {local_when}{notes_part}{rec_part}
Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи."""
Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи."""
result = await self.llm.complete(
[ result = await self.llm.complete(
{"role": "system", "content": system}, [
{"role": "user", "content": user_prompt}, {"role": "system", "content": system},
], {"role": "user", "content": user_prompt},
temperature=0.8, ],
visible_reply=True, temperature=0.8,
) visible_reply=True,
return (result.get("content") or "").strip() or f"Напоминание: {row.title}" )
return (result.get("content") or "").strip() or f"Напоминание: {row.title}"
def _mark_fired(self, row: Reminder, now: datetime) -> None:
row.last_fired_at = now def _mark_fired(self, row: Reminder, now: datetime) -> None:
if row.recurrence == RECURRENCE_NONE: row.last_fired_at = now
row.completed_at = now if row.recurrence == RECURRENCE_NONE:
row.enabled = False row.completed_at = now
else: row.enabled = False
row.due_at = _advance_due(row.due_at, row.recurrence) else:
row.last_fired_at = None row.due_at = _advance_due(row.due_at, row.recurrence)
row.updated_at = now 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) async def process(self, row: Reminder) -> None:
post_notice_to_latest_chat(format_reminder_notice(row)) 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) try:
if comment: comment = await self._generate_llm_comment(row, local_when)
post_character_comment_to_latest_chat(comment) if comment:
except Exception: post_character_comment_to_latest_chat(comment, self.user_id)
logger.exception("Reminder LLM comment failed (id=%s)", row.id) except Exception:
logger.exception("Reminder LLM comment failed (id=%s)", row.id)
self._mark_fired(row, datetime.now(timezone.utc))
self._mark_fired(row, datetime.now(timezone.utc))
+33 -33
View File
@@ -1,33 +1,33 @@
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.reminders.service import RemindersService from app.reminders.service import RemindersService
MAX_IN_CONTEXT = 10 MAX_IN_CONTEXT = 10
def get_reminders_snapshot(db: Session) -> dict[str, Any]: def get_reminders_snapshot(db: Session, user_id: int) -> dict[str, Any]:
return RemindersService(db).snapshot() return RemindersService(db, user_id).snapshot()
def format_reminders_context(snapshot: dict[str, Any]) -> str: def format_reminders_context(snapshot: dict[str, Any]) -> str:
lines = ["[Напоминания]"] lines = ["[Напоминания]"]
upcoming = snapshot.get("upcoming") or [] upcoming = snapshot.get("upcoming") or []
tz = snapshot.get("timezone", "Europe/Moscow") tz = snapshot.get("timezone", "Europe/Moscow")
if not upcoming: if not upcoming:
lines.append( lines.append(
"Ближайших напоминаний нет. " "Ближайших напоминаний нет. "
"create_reminder для «напомни через 15 минут», «завтра утром», точной даты." "create_reminder для «напомни через 15 минут», «завтра утром», точной даты."
) )
return "\n".join(lines) return "\n".join(lines)
lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.") lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.")
for item in upcoming[:MAX_IN_CONTEXT]: for item in upcoming[:MAX_IN_CONTEXT]:
rec = item.get("recurrence", "none") rec = item.get("recurrence", "none")
rec_label = f" · повтор: {rec}" if rec and rec != "none" else "" rec_label = f" · повтор: {rec}" if rec and rec != "none" else ""
lines.append( lines.append(
f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}" f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}"
) )
return "\n".join(lines) return "\n".join(lines)
+50 -45
View File
@@ -1,45 +1,50 @@
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import Reminder from app.db.models import Reminder, User
from app.reminders.completion import ReminderCompletionHandler from app.reminders.completion import ReminderCompletionHandler
from app.reminders.notify import bump_notify_seq from app.reminders.notify import bump_notify_seq
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _utcnow() -> datetime: def _utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def get_due_reminders(db: Session) -> list[Reminder]: def get_due_reminders(db: Session, user_id: int) -> list[Reminder]:
now = _utcnow() now = _utcnow()
stmt = ( stmt = (
select(Reminder) select(Reminder)
.where( .where(
Reminder.enabled.is_(True), Reminder.user_id == user_id,
Reminder.completed_at.is_(None), Reminder.enabled.is_(True),
Reminder.due_at <= now, Reminder.completed_at.is_(None),
) Reminder.due_at <= now,
.order_by(Reminder.due_at.asc()) )
) .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)] 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:
due = get_due_reminders(db) async def process_due_reminders(db: Session) -> int:
if not due: users = db.scalars(select(User).where(User.is_active.is_(True))).all()
return 0 total = 0
for user in users:
handler = ReminderCompletionHandler(db) due = get_due_reminders(db, user.id)
for row in due: if not due:
await handler.process(row) continue
handler = ReminderCompletionHandler(db, user.id)
db.commit() for row in due:
bump_notify_seq(db) await handler.process(row)
logger.info("Reminders fired: %d", len(due)) total += len(due)
return len(due)
if total:
db.commit()
bump_notify_seq(db)
logger.info("Reminders fired: %d", total)
return total
+245 -237
View File
@@ -1,237 +1,245 @@
import calendar import calendar
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import Reminder from app.db.models import Reminder
from app.homelab.context import resolve_timezone from app.memory.service import MemoryService
from app.reminders.notify import bump_notify_seq, get_notify_seq from app.reminders.notify import bump_notify_seq, get_notify_seq
RECURRENCE_NONE = "none" RECURRENCE_NONE = "none"
RECURRENCE_DAILY = "daily" RECURRENCE_DAILY = "daily"
RECURRENCE_WEEKLY = "weekly" RECURRENCE_WEEKLY = "weekly"
RECURRENCE_MONTHLY = "monthly" RECURRENCE_MONTHLY = "monthly"
RECURRENCE_YEARLY = "yearly" RECURRENCE_YEARLY = "yearly"
VALID_RECURRENCE = frozenset({ VALID_RECURRENCE = frozenset({
RECURRENCE_NONE, RECURRENCE_NONE,
RECURRENCE_DAILY, RECURRENCE_DAILY,
RECURRENCE_WEEKLY, RECURRENCE_WEEKLY,
RECURRENCE_MONTHLY, RECURRENCE_MONTHLY,
RECURRENCE_YEARLY, RECURRENCE_YEARLY,
}) })
def _utcnow() -> datetime: def _utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _parse_due_at(raw: str, tz_name: str) -> datetime: def _parse_due_at(raw: str, tz_name: str) -> datetime:
clean = raw.strip() clean = raw.strip()
if not clean: if not clean:
raise ValueError("due_at не может быть пустым") raise ValueError("due_at не может быть пустым")
try: try:
dt = datetime.fromisoformat(clean.replace("Z", "+00:00")) dt = datetime.fromisoformat(clean.replace("Z", "+00:00"))
except ValueError as exc: except ValueError as exc:
raise ValueError(f"Неверный формат даты: {raw}") from exc raise ValueError(f"Неверный формат даты: {raw}") from exc
if dt.tzinfo is None: if dt.tzinfo is None:
try: try:
dt = dt.replace(tzinfo=ZoneInfo(tz_name)) dt = dt.replace(tzinfo=ZoneInfo(tz_name))
except Exception: except Exception:
dt = dt.replace(tzinfo=ZoneInfo("Europe/Moscow")) dt = dt.replace(tzinfo=ZoneInfo("Europe/Moscow"))
return dt.astimezone(timezone.utc) return dt.astimezone(timezone.utc)
def _advance_due(due_at: datetime, recurrence: str) -> datetime: def _advance_due(due_at: datetime, recurrence: str) -> datetime:
if recurrence == RECURRENCE_DAILY: if recurrence == RECURRENCE_DAILY:
return due_at + timedelta(days=1) return due_at + timedelta(days=1)
if recurrence == RECURRENCE_WEEKLY: if recurrence == RECURRENCE_WEEKLY:
return due_at + timedelta(weeks=1) return due_at + timedelta(weeks=1)
if recurrence == RECURRENCE_MONTHLY: if recurrence == RECURRENCE_MONTHLY:
month = due_at.month + 1 month = due_at.month + 1
year = due_at.year year = due_at.year
if month > 12: if month > 12:
month = 1 month = 1
year += 1 year += 1
day = min(due_at.day, calendar.monthrange(year, month)[1]) day = min(due_at.day, calendar.monthrange(year, month)[1])
return due_at.replace(year=year, month=month, day=day) return due_at.replace(year=year, month=month, day=day)
if recurrence == RECURRENCE_YEARLY: if recurrence == RECURRENCE_YEARLY:
year = due_at.year + 1 year = due_at.year + 1
day = min(due_at.day, calendar.monthrange(year, due_at.month)[1]) day = min(due_at.day, calendar.monthrange(year, due_at.month)[1])
return due_at.replace(year=year, day=day) return due_at.replace(year=year, day=day)
return due_at return due_at
def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str: def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str:
try: try:
local = dt.astimezone(ZoneInfo(tz_name)) local = dt.astimezone(ZoneInfo(tz_name))
except Exception: except Exception:
local = dt.astimezone(ZoneInfo("Europe/Moscow")) local = dt.astimezone(ZoneInfo("Europe/Moscow"))
if all_day: if all_day:
return local.strftime("%Y-%m-%d") return local.strftime("%Y-%m-%d")
return local.strftime("%Y-%m-%d %H:%M") return local.strftime("%Y-%m-%d %H:%M")
class RemindersService: class RemindersService:
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 _tz(self) -> str:
return resolve_timezone(self.db) def _tz(self) -> str:
profile = MemoryService(self.db, self.user_id).get_profile()
def _to_dict(self, row: Reminder) -> dict[str, Any]: tz = (profile.get("timezone") or "").strip()
tz = row.timezone or self._tz() return tz or "Europe/Moscow"
return {
"id": row.id, def _to_dict(self, row: Reminder) -> dict[str, Any]:
"title": row.title, tz = row.timezone or self._tz()
"notes": row.notes, return {
"due_at": row.due_at.isoformat(), "id": row.id,
"due_at_local": _format_local(row.due_at, tz, all_day=row.all_day), "title": row.title,
"all_day": row.all_day, "notes": row.notes,
"recurrence": row.recurrence, "due_at": row.due_at.isoformat(),
"enabled": row.enabled, "due_at_local": _format_local(row.due_at, tz, all_day=row.all_day),
"completed_at": row.completed_at.isoformat() if row.completed_at else None, "all_day": row.all_day,
"timezone": tz, "recurrence": row.recurrence,
"created_at": row.created_at.isoformat() if row.created_at else None, "enabled": row.enabled,
} "completed_at": row.completed_at.isoformat() if row.completed_at else None,
"timezone": tz,
def snapshot(self) -> dict[str, Any]: "created_at": row.created_at.isoformat() if row.created_at else None,
upcoming = self.list_upcoming(limit=12) }
return {
"notify_seq": get_notify_seq(self.db), def snapshot(self) -> dict[str, Any]:
"upcoming": upcoming, upcoming = self.list_upcoming(limit=12)
"upcoming_count": len(upcoming), return {
"timezone": self._tz(), "notify_seq": get_notify_seq(self.db),
} "upcoming": upcoming,
"upcoming_count": len(upcoming),
def list_upcoming(self, *, limit: int = 30) -> list[dict[str, Any]]: "timezone": self._tz(),
stmt = ( }
select(Reminder)
.where( def list_upcoming(self, *, limit: int = 30) -> list[dict[str, Any]]:
Reminder.enabled.is_(True), stmt = (
Reminder.completed_at.is_(None), select(Reminder)
) .where(
.order_by(Reminder.due_at.asc()) Reminder.user_id == self.user_id,
.limit(limit) Reminder.enabled.is_(True),
) Reminder.completed_at.is_(None),
return [self._to_dict(row) for row in self.db.scalars(stmt).all()] )
.order_by(Reminder.due_at.asc())
def list_in_range( .limit(limit)
self, )
*, return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
date_from: datetime,
date_to: datetime, def list_in_range(
) -> list[dict[str, Any]]: self,
stmt = ( *,
select(Reminder) date_from: datetime,
.where( date_to: datetime,
Reminder.enabled.is_(True), ) -> list[dict[str, Any]]:
Reminder.completed_at.is_(None), stmt = (
Reminder.due_at >= date_from, select(Reminder)
Reminder.due_at < date_to, .where(
) Reminder.user_id == self.user_id,
.order_by(Reminder.due_at.asc()) Reminder.enabled.is_(True),
) Reminder.completed_at.is_(None),
return [self._to_dict(row) for row in self.db.scalars(stmt).all()] Reminder.due_at >= date_from,
Reminder.due_at < date_to,
def get(self, reminder_id: int) -> dict[str, Any] | None: )
row = self.db.get(Reminder, reminder_id) .order_by(Reminder.due_at.asc())
return self._to_dict(row) if row else None )
return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
def create(
self, def get(self, reminder_id: int) -> dict[str, Any] | None:
*, row = self.db.get(Reminder, reminder_id)
title: str, if not row or row.user_id != self.user_id:
due_at: str, return None
notes: str = "", return self._to_dict(row)
all_day: bool = False,
recurrence: str = RECURRENCE_NONE, def create(
) -> dict[str, Any]: self,
clean_title = title.strip() *,
if not clean_title: title: str,
raise ValueError("Название напоминания не может быть пустым") due_at: str,
rec = (recurrence or RECURRENCE_NONE).strip().lower() notes: str = "",
if rec not in VALID_RECURRENCE: all_day: bool = False,
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}") recurrence: str = RECURRENCE_NONE,
) -> dict[str, Any]:
tz = self._tz() clean_title = title.strip()
due = _parse_due_at(due_at, tz) if not clean_title:
row = Reminder( raise ValueError("Название напоминания не может быть пустым")
title=clean_title, rec = (recurrence or RECURRENCE_NONE).strip().lower()
notes=notes.strip(), if rec not in VALID_RECURRENCE:
due_at=due, raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
all_day=all_day,
recurrence=rec, tz = self._tz()
timezone=tz, due = _parse_due_at(due_at, tz)
) row = Reminder(
self.db.add(row) user_id=self.user_id,
self.db.commit() title=clean_title,
self.db.refresh(row) notes=notes.strip(),
bump_notify_seq(self.db) due_at=due,
return {"ok": True, "reminder": self._to_dict(row), "created": True} all_day=all_day,
recurrence=rec,
def update( timezone=tz,
self, )
reminder_id: int, self.db.add(row)
*, self.db.commit()
title: str | None = None, self.db.refresh(row)
due_at: str | None = None, bump_notify_seq(self.db)
notes: str | None = None, return {"ok": True, "reminder": self._to_dict(row), "created": True}
all_day: bool | None = None,
recurrence: str | None = None, def update(
enabled: bool | None = None, self,
) -> dict[str, Any]: reminder_id: int,
row = self.db.get(Reminder, reminder_id) *,
if not row: title: str | None = None,
raise ValueError("Напоминание не найдено") due_at: str | None = None,
notes: str | None = None,
if title is not None: all_day: bool | None = None,
clean = title.strip() recurrence: str | None = None,
if not clean: enabled: bool | None = None,
raise ValueError("Название не может быть пустым") ) -> dict[str, Any]:
row.title = clean row = self.db.get(Reminder, reminder_id)
if notes is not None: if not row or row.user_id != self.user_id:
row.notes = notes.strip() raise ValueError("Напоминание не найдено")
if due_at is not None:
row.due_at = _parse_due_at(due_at, row.timezone or self._tz()) if title is not None:
row.last_fired_at = None clean = title.strip()
if all_day is not None: if not clean:
row.all_day = all_day raise ValueError("Название не может быть пустым")
if recurrence is not None: row.title = clean
rec = recurrence.strip().lower() if notes is not None:
if rec not in VALID_RECURRENCE: row.notes = notes.strip()
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}") if due_at is not None:
row.recurrence = rec row.due_at = _parse_due_at(due_at, row.timezone or self._tz())
if enabled is not None: row.last_fired_at = None
row.enabled = enabled if all_day is not None:
row.all_day = all_day
row.updated_at = _utcnow() if recurrence is not None:
self.db.commit() rec = recurrence.strip().lower()
self.db.refresh(row) if rec not in VALID_RECURRENCE:
bump_notify_seq(self.db) raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
return {"ok": True, "reminder": self._to_dict(row)} row.recurrence = rec
if enabled is not None:
def delete(self, reminder_id: int) -> dict[str, Any]: row.enabled = enabled
row = self.db.get(Reminder, reminder_id)
if not row: row.updated_at = _utcnow()
raise ValueError("Напоминание не найдено") self.db.commit()
title = row.title self.db.refresh(row)
self.db.delete(row) bump_notify_seq(self.db)
self.db.commit() return {"ok": True, "reminder": self._to_dict(row)}
bump_notify_seq(self.db)
return {"ok": True, "deleted_id": reminder_id, "title": title} def delete(self, reminder_id: int) -> dict[str, Any]:
row = self.db.get(Reminder, reminder_id)
def complete(self, reminder_id: int) -> dict[str, Any]: if not row or row.user_id != self.user_id:
row = self.db.get(Reminder, reminder_id) raise ValueError("Напоминание не найдено")
if not row: title = row.title
raise ValueError("Напоминание не найдено") self.db.delete(row)
now = _utcnow() self.db.commit()
row.completed_at = now bump_notify_seq(self.db)
row.enabled = False return {"ok": True, "deleted_id": reminder_id, "title": title}
row.updated_at = now
self.db.commit() def complete(self, reminder_id: int) -> dict[str, Any]:
self.db.refresh(row) row = self.db.get(Reminder, reminder_id)
bump_notify_seq(self.db) if not row or row.user_id != self.user_id:
return {"ok": True, "reminder": self._to_dict(row)} 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)}
+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
+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_scoped.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()
+1
View File
@@ -0,0 +1 @@
"""Runtime settings stored in assistant_state."""
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db.models import AssistantState
SETTING_KEYS = (
"openrouter_model",
"memory_extract_model",
"openrouter_reasoning_effort",
"rag_enabled",
"rag_top_k",
)
class SettingsService:
def __init__(self, db: Session):
self.db = db
self._defaults = get_settings()
def _get_row(self, key: str) -> AssistantState | None:
return self.db.get(AssistantState, key)
def get_raw(self, key: str) -> str | None:
row = self._get_row(key)
if not row or not (row.value or "").strip():
return None
return row.value.strip()
def set_raw(self, key: str, value: str) -> None:
row = self._get_row(key)
if not row:
row = AssistantState(key=key, value=value)
self.db.add(row)
else:
row.value = value
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
def _default_for(self, key: str) -> Any:
defaults: Settings = self._defaults
mapping = {
"openrouter_model": defaults.openrouter_model,
"memory_extract_model": defaults.memory_extract_model or defaults.openrouter_model,
"openrouter_reasoning_effort": defaults.openrouter_reasoning_effort,
"rag_enabled": defaults.rag_enabled,
"rag_top_k": defaults.rag_top_k,
}
return mapping[key]
def get_effective(self, key: str) -> Any:
raw = self.get_raw(key)
if raw is None:
return self._default_for(key)
if key == "rag_enabled":
return raw.lower() in ("1", "true", "yes", "on")
if key == "rag_top_k":
try:
return max(1, min(50, int(raw)))
except ValueError:
return self._default_for(key)
return raw
def snapshot(self) -> dict[str, Any]:
data: dict[str, Any] = {}
for key in SETTING_KEYS:
data[key] = self.get_effective(key)
data["embedding_model"] = self._defaults.embedding_model
data["memory_facts_in_context"] = self._defaults.memory_facts_in_context
data["qdrant_url"] = self._defaults.qdrant_url
return data
def patch(self, updates: dict[str, Any]) -> dict[str, Any]:
for key, value in updates.items():
if key not in SETTING_KEYS:
continue
if value is None:
row = self._get_row(key)
if row:
self.db.delete(row)
self.db.commit()
continue
if key == "rag_enabled":
stored = "true" if bool(value) else "false"
elif key == "rag_top_k":
stored = str(int(value))
else:
stored = str(value).strip()
if not stored and key != "rag_enabled":
continue
self.set_raw(key, stored)
return self.snapshot()
+47 -47
View File
@@ -1,47 +1,47 @@
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.shopping.service import ShoppingService from app.shopping.service import ShoppingService
MAX_LISTS_IN_CONTEXT = 8 MAX_LISTS_IN_CONTEXT = 8
MAX_ITEMS_PER_LIST = 12 MAX_ITEMS_PER_LIST = 12
def get_shopping_snapshot(db: Session) -> dict[str, Any]: def get_shopping_snapshot(db: Session, user_id: int) -> dict[str, Any]:
return ShoppingService(db).snapshot() return ShoppingService(db, user_id).snapshot()
def format_shopping_context(snapshot: dict[str, Any]) -> str: def format_shopping_context(snapshot: dict[str, Any]) -> str:
lines = ["[Списки покупок]"] lines = ["[Списки покупок]"]
lists = snapshot.get("lists") or [] lists = snapshot.get("lists") or []
if not lists: if not lists:
lines.append("Списков пока нет. create_shopping_list или add_shopping_items.") lines.append("Списков пока нет. create_shopping_list или add_shopping_items.")
return "\n".join(lines) return "\n".join(lines)
lines.append( lines.append(
f"Всего списков: {snapshot.get('list_count', len(lists))}, " f"Всего списков: {snapshot.get('list_count', len(lists))}, "
f"неотмеченных позиций: {snapshot.get('unchecked_items', 0)}." f"неотмеченных позиций: {snapshot.get('unchecked_items', 0)}."
) )
lines.append("Для изменений вызывай tools: list_shopping_lists, add_shopping_items, check_shopping_item.") lines.append("Для изменений вызывай tools: list_shopping_lists, add_shopping_items, check_shopping_item.")
for lst in lists[:MAX_LISTS_IN_CONTEXT]: for lst in lists[:MAX_LISTS_IN_CONTEXT]:
items = lst.get("items") or [] items = lst.get("items") or []
unchecked = [i for i in items if not i.get("checked")] unchecked = [i for i in items if not i.get("checked")]
preview = unchecked[:MAX_ITEMS_PER_LIST] preview = unchecked[:MAX_ITEMS_PER_LIST]
parts = [] parts = []
for item in preview: for item in preview:
qty = item.get("quantity") qty = item.get("quantity")
unit = (item.get("unit") or "").strip() unit = (item.get("unit") or "").strip()
label = item["text"] label = item["text"]
if qty is not None: if qty is not None:
label = f"{label} ({qty}{' ' + unit if unit else ''})" label = f"{label} ({qty}{' ' + unit if unit else ''})"
parts.append(f"#{item['id']} {label}") parts.append(f"#{item['id']} {label}")
tail = f" +{len(unchecked) - len(preview)} ещё" if len(unchecked) > len(preview) else "" tail = f" +{len(unchecked) - len(preview)} ещё" if len(unchecked) > len(preview) else ""
if parts: if parts:
lines.append(f"- «{lst['name']}» (#{lst['id']}): {', '.join(parts)}{tail}") lines.append(f"- «{lst['name']}» (#{lst['id']}): {', '.join(parts)}{tail}")
else: else:
lines.append(f"- «{lst['name']}» (#{lst['id']}): всё отмечено или пусто") lines.append(f"- «{lst['name']}» (#{lst['id']}): всё отмечено или пусто")
return "\n".join(lines) return "\n".join(lines)
+224 -223
View File
@@ -1,223 +1,224 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from app.db.models import ShoppingList, ShoppingListItem from app.db.models import ShoppingList, ShoppingListItem
class ShoppingService: class ShoppingService:
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 snapshot(self) -> dict[str, Any]:
lists = self.list_lists(include_items=True) def snapshot(self) -> dict[str, Any]:
total_items = sum(len(lst.get("items") or []) for lst in lists) lists = self.list_lists(include_items=True)
unchecked = sum( total_items = sum(len(lst.get("items") or []) for lst in lists)
1 unchecked = sum(
for lst in lists 1
for item in (lst.get("items") or []) for lst in lists
if not item.get("checked") for item in (lst.get("items") or [])
) if not item.get("checked")
return { )
"lists": lists, return {
"list_count": len(lists), "lists": lists,
"total_items": total_items, "list_count": len(lists),
"unchecked_items": unchecked, "total_items": total_items,
} "unchecked_items": unchecked,
}
def list_lists(self, *, include_items: bool = False) -> list[dict[str, Any]]:
stmt = select(ShoppingList).order_by(ShoppingList.sort_order, ShoppingList.name) def list_lists(self, *, include_items: bool = False) -> list[dict[str, Any]]:
if include_items: stmt = select(ShoppingList).where(ShoppingList.user_id == self.user_id).order_by(ShoppingList.sort_order, ShoppingList.name)
stmt = stmt.options(selectinload(ShoppingList.items)) if include_items:
rows = list(self.db.scalars(stmt).all()) stmt = stmt.options(selectinload(ShoppingList.items))
return [self._list_to_dict(row, include_items=include_items) for row in rows] rows = list(self.db.scalars(stmt).all())
return [self._list_to_dict(row, include_items=include_items) for row in rows]
def get_list(
self, def get_list(
list_id: int | None = None, self,
*, list_id: int | None = None,
name: str | None = None, *,
) -> dict[str, Any] | None: name: str | None = None,
row = self._resolve_list(list_id=list_id, name=name) ) -> dict[str, Any] | None:
if not row: row = self._resolve_list(list_id=list_id, name=name)
return None if not row:
return self._list_to_dict(row, include_items=True) return None
return self._list_to_dict(row, include_items=True)
def create_list(self, name: str) -> dict[str, Any]:
clean = name.strip() def create_list(self, name: str) -> dict[str, Any]:
if not clean: clean = name.strip()
raise ValueError("Название списка не может быть пустым") if not clean:
existing = self.db.scalar(select(ShoppingList).where(ShoppingList.name == clean)) raise ValueError("Название списка не может быть пустым")
if existing: existing = self.db.scalar(select(ShoppingList).where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean))
return {"ok": True, "list": self._list_to_dict(existing, include_items=True), "created": False} if existing:
return {"ok": True, "list": self._list_to_dict(existing, include_items=True), "created": False}
max_order = self.db.scalar(select(func.max(ShoppingList.sort_order))) or 0
row = ShoppingList(name=clean, sort_order=max_order + 1) max_order = self.db.scalar(select(func.max(ShoppingList.sort_order)).where(ShoppingList.user_id == self.user_id)) or 0
self.db.add(row) row = ShoppingList(user_id=self.user_id, name=clean, sort_order=max_order + 1)
self.db.commit() self.db.add(row)
self.db.refresh(row) self.db.commit()
return {"ok": True, "list": self._list_to_dict(row, include_items=True), "created": True} self.db.refresh(row)
return {"ok": True, "list": self._list_to_dict(row, include_items=True), "created": True}
def rename_list(self, list_id: int, name: str) -> dict[str, Any]:
row = self._require_list(list_id) def rename_list(self, list_id: int, name: str) -> dict[str, Any]:
clean = name.strip() row = self._require_list(list_id)
if not clean: clean = name.strip()
raise ValueError("Название списка не может быть пустым") if not clean:
conflict = self.db.scalar( raise ValueError("Название списка не может быть пустым")
select(ShoppingList).where(ShoppingList.name == clean, ShoppingList.id != list_id) conflict = self.db.scalar(
) select(ShoppingList).where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean, ShoppingList.id != list_id)
if conflict: )
raise ValueError(f"Список «{clean}» уже существует") if conflict:
row.name = clean raise ValueError(f"Список «{clean}» уже существует")
row.updated_at = datetime.now(timezone.utc) row.name = clean
self.db.commit() row.updated_at = datetime.now(timezone.utc)
self.db.refresh(row) self.db.commit()
return {"ok": True, "list": self._list_to_dict(row, include_items=True)} self.db.refresh(row)
return {"ok": True, "list": self._list_to_dict(row, include_items=True)}
def delete_list(self, list_id: int) -> dict[str, Any]:
row = self._require_list(list_id) def delete_list(self, list_id: int) -> dict[str, Any]:
name = row.name row = self._require_list(list_id)
self.db.delete(row) name = row.name
self.db.commit() self.db.delete(row)
return {"ok": True, "deleted_list_id": list_id, "name": name} self.db.commit()
return {"ok": True, "deleted_list_id": list_id, "name": name}
def add_items(
self, def add_items(
items: list[dict[str, Any]], self,
*, items: list[dict[str, Any]],
list_id: int | None = None, *,
list_name: str | None = None, list_id: int | None = None,
create_list: bool = True, list_name: str | None = None,
) -> dict[str, Any]: create_list: bool = True,
if not items: ) -> dict[str, Any]:
raise ValueError("Нужен хотя бы один товар") if not items:
raise ValueError("Нужен хотя бы один товар")
row = self._resolve_list(list_id=list_id, name=list_name)
if not row and list_name and create_list: row = self._resolve_list(list_id=list_id, name=list_name)
created = self.create_list(list_name) if not row and list_name and create_list:
row = self._require_list(created["list"]["id"]) created = self.create_list(list_name)
if not row: row = self._require_list(created["list"]["id"])
raise ValueError("Укажи list_id или list_name") if not row:
raise ValueError("Укажи list_id или list_name")
max_order = self.db.scalar(
select(func.max(ShoppingListItem.sort_order)).where(ShoppingListItem.list_id == row.id) max_order = self.db.scalar(
) or 0 select(func.max(ShoppingListItem.sort_order)).where(ShoppingListItem.list_id == row.id)
added: list[dict[str, Any]] = [] ) or 0
for idx, raw in enumerate(items, start=1): added: list[dict[str, Any]] = []
text = (raw.get("text") or "").strip() for idx, raw in enumerate(items, start=1):
if not text: text = (raw.get("text") or "").strip()
continue if not text:
item = ShoppingListItem( continue
list_id=row.id, item = ShoppingListItem(
text=text, list_id=row.id,
quantity=raw.get("quantity"), text=text,
unit=(raw.get("unit") or "").strip(), quantity=raw.get("quantity"),
sort_order=max_order + idx, unit=(raw.get("unit") or "").strip(),
) sort_order=max_order + idx,
self.db.add(item) )
added.append(item) self.db.add(item)
added.append(item)
if not added:
raise ValueError("Нет валидных товаров для добавления") if not added:
raise ValueError("Нет валидных товаров для добавления")
row.updated_at = datetime.now(timezone.utc)
self.db.commit() row.updated_at = datetime.now(timezone.utc)
for item in added: self.db.commit()
self.db.refresh(item) for item in added:
self.db.refresh(item)
return {
"ok": True, return {
"list_id": row.id, "ok": True,
"list_name": row.name, "list_id": row.id,
"added": [self._item_to_dict(i) for i in added], "list_name": row.name,
"list": self._list_to_dict(self._require_list(row.id), include_items=True), "added": [self._item_to_dict(i) for i in added],
} "list": self._list_to_dict(self._require_list(row.id), include_items=True),
}
def set_item_checked(self, item_id: int, checked: bool) -> dict[str, Any]:
item = self._require_item(item_id) def set_item_checked(self, item_id: int, checked: bool) -> dict[str, Any]:
item.checked = checked item = self._require_item(item_id)
item.shopping_list.updated_at = datetime.now(timezone.utc) item.checked = checked
self.db.commit() item.shopping_list.updated_at = datetime.now(timezone.utc)
return {"ok": True, "item": self._item_to_dict(item)} self.db.commit()
return {"ok": True, "item": self._item_to_dict(item)}
def remove_item(self, item_id: int) -> dict[str, Any]:
item = self._require_item(item_id) def remove_item(self, item_id: int) -> dict[str, Any]:
data = self._item_to_dict(item) item = self._require_item(item_id)
list_id = item.list_id data = self._item_to_dict(item)
self.db.delete(item) list_id = item.list_id
self.db.commit() self.db.delete(item)
return {"ok": True, "removed": data, "list_id": list_id} self.db.commit()
return {"ok": True, "removed": data, "list_id": list_id}
def clear_checked(self, list_id: int) -> dict[str, Any]:
row = self._require_list(list_id) def clear_checked(self, list_id: int) -> dict[str, Any]:
removed = 0 row = self._require_list(list_id)
for item in list(row.items): removed = 0
if item.checked: for item in list(row.items):
self.db.delete(item) if item.checked:
removed += 1 self.db.delete(item)
row.updated_at = datetime.now(timezone.utc) removed += 1
self.db.commit() row.updated_at = datetime.now(timezone.utc)
return { self.db.commit()
"ok": True, return {
"list_id": list_id, "ok": True,
"removed_count": removed, "list_id": list_id,
"list": self._list_to_dict(self._require_list(list_id), include_items=True), "removed_count": removed,
} "list": self._list_to_dict(self._require_list(list_id), include_items=True),
}
def _resolve_list(
self, def _resolve_list(
*, self,
list_id: int | None = None, *,
name: str | None = None, list_id: int | None = None,
) -> ShoppingList | None: name: str | None = None,
if list_id is not None: ) -> ShoppingList | None:
return self.db.scalar( if list_id is not None:
select(ShoppingList) return self.db.scalar(
.where(ShoppingList.id == list_id) select(ShoppingList)
.options(selectinload(ShoppingList.items)) .where(ShoppingList.user_id == self.user_id, ShoppingList.id == list_id)
) .options(selectinload(ShoppingList.items))
if name: )
clean = name.strip() if name:
return self.db.scalar( clean = name.strip()
select(ShoppingList) return self.db.scalar(
.where(ShoppingList.name == clean) select(ShoppingList)
.options(selectinload(ShoppingList.items)) .where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean)
) .options(selectinload(ShoppingList.items))
return None )
return None
def _require_list(self, list_id: int) -> ShoppingList:
row = self._resolve_list(list_id=list_id) def _require_list(self, list_id: int) -> ShoppingList:
if not row: row = self._resolve_list(list_id=list_id)
raise ValueError(f"Список #{list_id} не найден") if not row:
return row raise ValueError(f"Список #{list_id} не найден")
return row
def _require_item(self, item_id: int) -> ShoppingListItem:
item = self.db.get(ShoppingListItem, item_id) def _require_item(self, item_id: int) -> ShoppingListItem:
if not item: item = self.db.get(ShoppingListItem, item_id)
raise ValueError(f"Позиция #{item_id} не найдена") if not item or item.shopping_list.user_id != self.user_id:
return item raise ValueError(f"Позиция #{item_id} не найдена")
return item
def _item_to_dict(self, item: ShoppingListItem) -> dict[str, Any]:
return { def _item_to_dict(self, item: ShoppingListItem) -> dict[str, Any]:
"id": item.id, return {
"list_id": item.list_id, "id": item.id,
"text": item.text, "list_id": item.list_id,
"quantity": item.quantity, "text": item.text,
"unit": item.unit, "quantity": item.quantity,
"checked": item.checked, "unit": item.unit,
"sort_order": item.sort_order, "checked": item.checked,
} "sort_order": item.sort_order,
}
def _list_to_dict(self, row: ShoppingList, *, include_items: bool) -> dict[str, Any]:
data: dict[str, Any] = { def _list_to_dict(self, row: ShoppingList, *, include_items: bool) -> dict[str, Any]:
"id": row.id, data: dict[str, Any] = {
"name": row.name, "id": row.id,
"sort_order": row.sort_order, "name": row.name,
"item_count": len(row.items) if row.items is not None else 0, "sort_order": row.sort_order,
"unchecked_count": sum(1 for i in (row.items or []) if not i.checked), "item_count": len(row.items) if row.items is not None else 0,
} "unchecked_count": sum(1 for i in (row.items or []) if not i.checked),
if include_items: }
data["items"] = [self._item_to_dict(i) for i in (row.items or [])] if include_items:
return data data["items"] = [self._item_to_dict(i) for i in (row.items or [])]
return data
File diff suppressed because it is too large Load Diff
+961
View File
@@ -0,0 +1,961 @@
import json
from datetime import date, datetime, timedelta, timezone
from typing import Any
from sqlalchemy.orm import Session
from app.fitness.service import FitnessService
from app.fitness.structuring import structure_meal, structure_workout
from app.homelab.digest import build_weather_briefing
from app.homelab.image_gen import generate_image as run_generate_image
from app.homelab.openmeteo import OpenMeteoClient
from app.integrations.openfoodfacts import OpenFoodFactsClient
from app.integrations.wger import WgerClient
from app.memory.service import MemoryService
from app.pomodoro.service import PomodoroService
from app.projects.service import ProjectService
from app.reminders.service import RemindersService
from app.shopping.service import ShoppingService
TOOL_DEFINITIONS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "get_pomodoro_status",
"description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Статус, фаза и прогресс цикла.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "start_pomodoro",
"description": "Запустить фазу работы в цикле помидоро (25 мин по умолчанию).",
"parameters": {
"type": "object",
"properties": {
"duration_min": {"type": "integer", "description": "Минуты работы"},
"task_note": {"type": "string", "description": "Над чем работаем"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "start_short_break",
"description": "Запустить короткий перерыв между работами.",
"parameters": {
"type": "object",
"properties": {
"duration_min": {"type": "integer", "description": "Минуты перерыва"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "start_long_break",
"description": "Запустить длинный перерыв после завершения цикла работ.",
"parameters": {
"type": "object",
"properties": {
"duration_min": {"type": "integer", "description": "Минуты перерыва"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "stop_pomodoro",
"description": "Остановить текущую фазу таймера.",
"parameters": {
"type": "object",
"properties": {
"result": {"type": "string", "description": "Отчёт о сделанном"},
"completed": {
"type": "boolean",
"description": "True если фаза полностью завершена",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "skip_pomodoro_phase",
"description": "Досрочно завершить текущую фазу и перейти к следующей в цикле.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "reset_pomodoro_cycle",
"description": "Сбросить цикл помидоро: обнулить счётчик работ и остановить таймер.",
"parameters": {
"type": "object",
"properties": {
"clear_task": {
"type": "boolean",
"description": "Также очистить текущую задачу",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_pomodoro_history",
"description": "История помидоро-сессий (таймер), не Taiga-задачи.",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Сколько сессий вернуть"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "sync_taiga_projects",
"description": "Синхронизировать список проектов из Taiga API. Вызывай если проекты неизвестны.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "list_taiga_projects",
"description": "Список проектов Taiga с привязкой Gitea.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "list_taiga_tasks",
"description": (
"ОБЯЗАТЕЛЬНО при вопросах «какие задачи», «покажи задачи проекта», «что открыто в Taiga». "
"Живые user stories и tasks из Taiga API. НЕ путать с list_work_items."
),
"parameters": {
"type": "object",
"properties": {
"project_slug": {
"type": "string",
"description": "Slug проекта, например home_assistant. Пусто = все проекты.",
},
"limit": {"type": "integer", "description": "Макс. на проект, по умолчанию 20"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "create_work_item",
"description": (
"Создать фичу/баг из вольного текста: структурировать через LLM, "
"создать Taiga story + Gitea issue. Вызывай при «заведи баг», «оформи фичу», «добавь в таигу»."
),
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Полное описание от пользователя"},
"project_slug": {
"type": "string",
"description": "Slug проекта Taiga, если известен",
},
},
"required": ["text"],
},
},
},
{
"type": "function",
"function": {
"name": "remember_fact",
"description": (
"Сохранить факт в долгосрочную память. "
"Когда пользователь просит «запомни», или сообщает устойчивое предпочтение/факт."
),
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "Что запомнить"},
"category": {
"type": "string",
"description": "preference, person, habit, project, fact",
},
"importance": {"type": "integer", "description": "1-5, по умолчанию 3"},
},
"required": ["content"],
},
},
},
{
"type": "function",
"function": {
"name": "recall_memories",
"description": (
"Поиск в долгосрочной памяти. "
"Когда спрашивают «что ты помнишь», «что я говорил про X»."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Подстрока для поиска"},
"category": {"type": "string"},
"limit": {"type": "integer"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "forget_memory",
"description": "Удалить (деактивировать) факт по id из recall_memories или снимка памяти.",
"parameters": {
"type": "object",
"properties": {
"memory_id": {"type": "integer"},
},
"required": ["memory_id"],
},
},
},
{
"type": "function",
"function": {
"name": "update_profile",
"description": (
"Обновить профиль пользователя: name, timezone, language, notes. "
"Передавай только изменившиеся поля."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "string", "description": "Возраст пользователя"},
"timezone": {"type": "string"},
"language": {"type": "string"},
"notes": {"type": "string"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "update_session_summary",
"description": (
"Сохранить краткую сводку темы текущего чата "
"(когда диалог длинный или пользователь просит «сожми контекст»)."
),
"parameters": {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "2-5 предложений о теме чата"},
"session_id": {"type": "integer"},
},
"required": ["summary", "session_id"],
},
},
},
{
"type": "function",
"function": {
"name": "get_fitness_summary",
"description": (
"Сводка фитнеса за день: ккал, БЖУ, вода, еда, тренировки. "
"Без даты — сегодня; date=YYYY-MM-DD или days_ago=1 (вчера)."
),
"parameters": {
"type": "object",
"properties": {
"date": {"type": "string", "description": "Дата YYYY-MM-DD"},
"days_ago": {
"type": "integer",
"description": "0 сегодня, 1 вчера, 2 позавчера…",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_fitness_history",
"description": (
"Краткая история за несколько дней (ккал, вода, тренировки по дням). "
"«На прошлой неделе», «за 7 дней»."
),
"parameters": {
"type": "object",
"properties": {
"days": {"type": "integer", "description": "Сколько дней, по умолчанию 7"},
"end_date": {"type": "string", "description": "Конец периода YYYY-MM-DD, по умолчанию сегодня"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "set_fitness_profile",
"description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды.",
"parameters": {
"type": "object",
"properties": {
"sex": {"type": "string", "description": "male/female"},
"age": {"type": "integer"},
"height_cm": {"type": "number"},
"weight_kg": {"type": "number"},
"activity_level": {
"type": "string",
"description": "sedentary/light/moderate/active/very_active",
},
"goal": {"type": "string", "description": "lose/maintain/gain"},
"target_weight_kg": {"type": "number"},
"weekly_workouts": {"type": "integer"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "calc_fitness_targets",
"description": "Калькулятор BMR/TDEE/макросов без сохранения.",
"parameters": {
"type": "object",
"properties": {
"sex": {"type": "string"},
"age": {"type": "integer"},
"height_cm": {"type": "number"},
"weight_kg": {"type": "number"},
"activity_level": {"type": "string"},
"goal": {"type": "string"},
},
"required": ["weight_kg", "height_cm", "age"],
},
},
},
{
"type": "function",
"function": {
"name": "log_meal",
"description": "Записать приём пищи. LLM оценит ккал и БЖУ из текста.",
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Что съел"},
"meal_type": {"type": "string"},
},
"required": ["text"],
},
},
},
{
"type": "function",
"function": {
"name": "log_water",
"description": "Записать воду в мл.",
"parameters": {
"type": "object",
"properties": {
"amount_ml": {"type": "integer"},
},
"required": ["amount_ml"],
},
},
},
{
"type": "function",
"function": {
"name": "log_weight",
"description": "Записать вес в кг.",
"parameters": {
"type": "object",
"properties": {
"weight_kg": {"type": "number"},
"body_fat_pct": {"type": "number"},
"notes": {"type": "string"},
},
"required": ["weight_kg"],
},
},
},
{
"type": "function",
"function": {
"name": "log_workout",
"description": "Записать тренировку из текста.",
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string"},
},
"required": ["text"],
},
},
},
{
"type": "function",
"function": {
"name": "lookup_food",
"description": "Поиск продукта в Open Food Facts (ккал на 100г).",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "lookup_exercise",
"description": "Поиск упражнения в базе wger.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "set_fitness_reminder",
"description": "Вкл/выкл или настроить напоминание: water, meal, workout, weigh_in.",
"parameters": {
"type": "object",
"properties": {
"kind": {"type": "string"},
"enabled": {"type": "boolean"},
"hour": {"type": "integer"},
"minute": {"type": "integer"},
"interval_hours": {"type": "integer"},
},
"required": ["kind"],
},
},
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": (
"ОБЯЗАТЕЛЬНО для вопросов о погоде, «что на улице», «будет ли дождь». "
"Текущая погода и прогноз по часам."
),
"parameters": {
"type": "object",
"properties": {
"hours_ahead": {
"type": "integer",
"description": "Сколько часов прогноза (по умолчанию 12)",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_morning_briefing",
"description": "Утренний брифинг: погода и заголовки новостей.",
"parameters": {
"type": "object",
"properties": {
"include_news": {
"type": "boolean",
"description": "Включить новости (по умолчанию true)",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "generate_image",
"description": (
"Аниме-картинка (Anima через RP-чат). "
"«Нарисуй себя» / портрет персонажа → draw_self=true. "
"Другая сцена → scene_description на английском (booru-теги). "
"Внешность берётся из карточки персонажа. Только по запросу или когда уместно."
),
"parameters": {
"type": "object",
"properties": {
"draw_self": {
"type": "boolean",
"description": "Нарисовать персонажа из карточки в контексте текущего чата",
},
"scene_description": {
"type": "string",
"description": "Описание сцены на английском (booru-теги), если не draw_self",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "list_shopping_lists",
"description": "Все списки покупок с позициями. «Что купить», «покажи списки».",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "create_shopping_list",
"description": "Создать новый список покупок.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Название списка, например «Продукты»"},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "add_shopping_items",
"description": "Добавить товары в список. Список создаётся, если не существует.",
"parameters": {
"type": "object",
"properties": {
"list_name": {"type": "string", "description": "Название списка"},
"list_id": {"type": "integer"},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": {"type": "string"},
"quantity": {"type": "number"},
"unit": {"type": "string"},
},
"required": ["text"],
},
},
},
"required": ["items"],
},
},
},
{
"type": "function",
"function": {
"name": "check_shopping_item",
"description": "Отметить позицию как купленную (checked=true) или снять отметку (false).",
"parameters": {
"type": "object",
"properties": {
"item_id": {"type": "integer"},
"checked": {"type": "boolean"},
},
"required": ["item_id", "checked"],
},
},
},
{
"type": "function",
"function": {
"name": "remove_shopping_item",
"description": "Удалить позицию из списка по item_id.",
"parameters": {
"type": "object",
"properties": {"item_id": {"type": "integer"}},
"required": ["item_id"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_shopping_list",
"description": "Удалить весь список покупок.",
"parameters": {
"type": "object",
"properties": {"list_id": {"type": "integer"}},
"required": ["list_id"],
},
},
},
{
"type": "function",
"function": {
"name": "list_reminders",
"description": "Список активных напоминаний. «Что напомнил», «мои напоминания».",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Макс. записей, по умолчанию 20"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "create_reminder",
"description": (
"Создать напоминание. due_at — ISO datetime в часовом поясе пользователя "
"(см. [Текущее время]). Примеры: через 15 мин, завтра 09:00, 2027-05-12T12:16:00."
),
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "О чём напомнить"},
"due_at": {"type": "string", "description": "ISO datetime"},
"notes": {"type": "string"},
"all_day": {"type": "boolean"},
"recurrence": {
"type": "string",
"enum": ["none", "daily", "weekly", "monthly", "yearly"],
"description": "Повтор (yearly — день рождения, Новый год)",
},
},
"required": ["title", "due_at"],
},
},
},
{
"type": "function",
"function": {
"name": "update_reminder",
"description": "Изменить напоминание по id.",
"parameters": {
"type": "object",
"properties": {
"reminder_id": {"type": "integer"},
"title": {"type": "string"},
"due_at": {"type": "string"},
"notes": {"type": "string"},
"all_day": {"type": "boolean"},
"recurrence": {
"type": "string",
"enum": ["none", "daily", "weekly", "monthly", "yearly"],
},
"enabled": {"type": "boolean"},
},
"required": ["reminder_id"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_reminder",
"description": "Удалить напоминание по id.",
"parameters": {
"type": "object",
"properties": {"reminder_id": {"type": "integer"}},
"required": ["reminder_id"],
},
},
},
{
"type": "function",
"function": {
"name": "complete_reminder",
"description": "Отметить напоминание выполненным (снять с календаря).",
"parameters": {
"type": "object",
"properties": {"reminder_id": {"type": "integer"}},
"required": ["reminder_id"],
},
},
},
{
"type": "function",
"function": {
"name": "list_work_items",
"description": (
"Только задачи, созданные ЭТИМ ассистентом через create_work_item (локальная БД). "
"НЕ использовать для общего вопроса «какие задачи в Taiga» — для того list_taiga_tasks."
),
"parameters": {
"type": "object",
"properties": {
"status": {"type": "string", "description": "open или closed"},
"limit": {"type": "integer"},
},
"required": [],
},
},
},
]
async def execute_tool(
db: Session,
name: str,
arguments: dict[str, Any],
*,
session_id: int | None = None,
) -> str:
pomodoro = PomodoroService(db)
projects = ProjectService(db)
memory = MemoryService(db)
fitness = FitnessService(db)
shopping = ShoppingService(db)
reminders = RemindersService(db)
try:
if name == "get_pomodoro_status":
result = pomodoro.get_status()
elif name == "start_pomodoro":
result = pomodoro.start_work(
duration_min=arguments.get("duration_min"),
task_note=arguments.get("task_note", ""),
)
elif name == "start_short_break":
result = pomodoro.start_short_break(duration_min=arguments.get("duration_min"))
elif name == "start_long_break":
result = pomodoro.start_long_break(duration_min=arguments.get("duration_min"))
elif name == "stop_pomodoro":
result = pomodoro.stop(
result=arguments.get("result", ""),
completed=arguments.get("completed", False),
)
elif name == "skip_pomodoro_phase":
result = pomodoro.skip_phase()
elif name == "reset_pomodoro_cycle":
result = pomodoro.reset_cycle(clear_task=arguments.get("clear_task", False))
elif name == "get_pomodoro_history":
result = pomodoro.history(limit=arguments.get("limit", 10))
elif name == "sync_taiga_projects":
from app.projects.context import invalidate_projects_snapshot_cache
result = projects.sync_taiga_projects()
invalidate_projects_snapshot_cache()
elif name == "list_taiga_projects":
result = projects.list_projects()
elif name == "list_taiga_tasks":
result = projects.list_taiga_open_tasks(
project_slug=arguments.get("project_slug"),
limit=arguments.get("limit", 20),
)
elif name == "create_work_item":
result = await projects.create_work_item(
arguments.get("text", ""),
project_slug=arguments.get("project_slug"),
)
elif name == "list_work_items":
result = projects.list_work_items(
limit=arguments.get("limit", 20),
status=arguments.get("status"),
)
elif name == "remember_fact":
result = memory.remember_fact(
arguments.get("content", ""),
category=arguments.get("category", "fact"),
importance=arguments.get("importance", 3),
session_id=session_id,
source="tool",
)
elif name == "recall_memories":
result = memory.recall_memories(
query=arguments.get("query"),
category=arguments.get("category"),
limit=arguments.get("limit", 20),
)
elif name == "forget_memory":
result = memory.forget_memory(int(arguments["memory_id"]))
elif name == "update_profile":
updates = {
k: arguments[k]
for k in ("name", "age", "timezone", "language", "notes")
if k in arguments and arguments[k] is not None
}
result = memory.update_profile(updates)
elif name == "update_session_summary":
result = memory.update_session_summary(
int(arguments["session_id"]),
arguments.get("summary", ""),
)
elif name == "get_fitness_summary":
day: date | None = None
if arguments.get("date"):
day = date.fromisoformat(str(arguments["date"]))
elif arguments.get("days_ago") is not None:
day = datetime.now(timezone.utc).date() - timedelta(
days=int(arguments["days_ago"])
)
result = fitness.get_daily_summary(day)
elif name == "get_fitness_history":
end_day = None
if arguments.get("end_date"):
end_day = date.fromisoformat(str(arguments["end_date"]))
result = fitness.get_history(
days=int(arguments.get("days") or 7),
end_day=end_day,
)
elif name == "set_fitness_profile":
updates = {
k: arguments[k]
for k in (
"sex", "age", "height_cm", "weight_kg", "activity_level",
"goal", "target_weight_kg", "weekly_workouts",
)
if k in arguments and arguments[k] is not None
}
result = fitness.set_profile(updates)
elif name == "calc_fitness_targets":
result = fitness.calc_targets(arguments)
elif name == "log_meal":
structured = await structure_meal(arguments.get("text", ""))
result = fitness.log_meal(
description=structured.get("description") or arguments.get("text", ""),
meal_type=arguments.get("meal_type") or structured.get("meal_type") or "snack",
calories=float(structured.get("calories") or 0),
protein_g=float(structured.get("protein_g") or 0),
fat_g=float(structured.get("fat_g") or 0),
carbs_g=float(structured.get("carbs_g") or 0),
source="llm",
estimated=True,
)
elif name == "log_water":
result = fitness.log_water(int(arguments.get("amount_ml", 250)))
elif name == "log_weight":
result = fitness.log_weight(
float(arguments["weight_kg"]),
body_fat_pct=arguments.get("body_fat_pct"),
notes=arguments.get("notes", ""),
)
elif name == "log_workout":
structured = await structure_workout(arguments.get("text", ""))
result = fitness.log_workout(
title=structured.get("title") or "Тренировка",
notes=structured.get("notes") or arguments.get("text", ""),
duration_min=structured.get("duration_min"),
exercises=structured.get("exercises"),
)
elif name == "lookup_food":
result = OpenFoodFactsClient().search(
arguments.get("query", ""),
limit=arguments.get("limit", 5),
)
elif name == "lookup_exercise":
result = WgerClient().search_exercises(
arguments.get("query", ""),
limit=arguments.get("limit", 8),
)
elif name == "set_fitness_reminder":
result = fitness.set_reminder(
arguments.get("kind", "water"),
enabled=arguments.get("enabled"),
hour=arguments.get("hour"),
minute=arguments.get("minute"),
interval_hours=arguments.get("interval_hours"),
)
elif name == "get_weather":
hours = int(arguments.get("hours_ahead") or 12)
client = OpenMeteoClient()
weather = client.fetch_current_and_hourly(hours_ahead=hours)
result = {
"weather": weather,
"rain_summary": client.rain_summary(hours_ahead=hours) if weather.get("ok") else "",
}
elif name == "get_morning_briefing":
include_news = arguments.get("include_news", True)
result = build_weather_briefing(
hours_ahead=12,
include_news=bool(include_news),
)
elif name == "generate_image":
result = await run_generate_image(
db,
session_id=session_id,
draw_self=bool(arguments.get("draw_self")),
scene_description=arguments.get("scene_description", ""),
)
elif name == "list_shopping_lists":
result = shopping.list_lists(include_items=True)
elif name == "create_shopping_list":
result = shopping.create_list(arguments.get("name", ""))
elif name == "add_shopping_items":
result = shopping.add_items(
arguments.get("items") or [],
list_id=arguments.get("list_id"),
list_name=arguments.get("list_name"),
)
elif name == "check_shopping_item":
result = shopping.set_item_checked(
int(arguments["item_id"]),
bool(arguments.get("checked", True)),
)
elif name == "remove_shopping_item":
result = shopping.remove_item(int(arguments["item_id"]))
elif name == "delete_shopping_list":
result = shopping.delete_list(int(arguments["list_id"]))
elif name == "list_reminders":
result = reminders.list_upcoming(limit=int(arguments.get("limit") or 20))
elif name == "create_reminder":
result = reminders.create(
title=arguments.get("title", ""),
due_at=arguments.get("due_at", ""),
notes=arguments.get("notes", ""),
all_day=bool(arguments.get("all_day", False)),
recurrence=arguments.get("recurrence", "none"),
)
elif name == "update_reminder":
result = reminders.update(
int(arguments["reminder_id"]),
title=arguments.get("title"),
due_at=arguments.get("due_at"),
notes=arguments.get("notes"),
all_day=arguments.get("all_day"),
recurrence=arguments.get("recurrence"),
enabled=arguments.get("enabled"),
)
elif name == "delete_reminder":
result = reminders.delete(int(arguments["reminder_id"]))
elif name == "complete_reminder":
result = reminders.complete(int(arguments["reminder_id"]))
else:
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
return json.dumps(result, ensure_ascii=False)
except ValueError as exc:
return json.dumps({"error": str(exc)}, ensure_ascii=False)
except Exception as exc:
return json.dumps({"error": str(exc)}, ensure_ascii=False)
+72
View File
@@ -0,0 +1,72 @@
"""Backfill workout active_calories / steps from notes via regex."""
from __future__ import annotations
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import WorkoutLog
ACTIVE_PATTERNS = [
re.compile(r"(?:active|активн(?:ые|ых)?)\s*(?:калор|kcal|ккал)[^\d]*(\d+(?:[.,]\d+)?)", re.I),
re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:ккал|kcal)\s*(?:active|актив)", re.I),
re.compile(r"актив[^\d]{0,20}(\d+(?:[.,]\d+)?)", re.I),
]
TOTAL_PATTERNS = [
re.compile(r"(?:total|всего|сожжено|burned)[^\d]*(\d+(?:[.,]\d+)?)\s*(?:ккал|kcal)", re.I),
re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:ккал|kcal)\s*(?:total|всего)", re.I),
]
STEPS_PATTERNS = [
re.compile(r"(?:шаг|step)s?\s*[:\-]?\s*(\d+)", re.I),
re.compile(r"(\d+)\s*(?:шаг|steps)", re.I),
]
def _first(patterns: list[re.Pattern[str]], text: str, *, as_int: bool = False):
for pat in patterns:
m = pat.search(text)
if not m:
continue
raw = m.group(1).replace(",", ".")
return int(float(raw)) if as_int else float(raw)
return None
def main() -> None:
db = SessionLocal()
updated = 0
try:
rows = db.scalars(select(WorkoutLog)).all()
for row in rows:
text = f"{row.title or ''}\n{row.notes or ''}"
changed = False
if row.active_calories is None:
val = _first(ACTIVE_PATTERNS, text)
if val is not None:
row.active_calories = float(val)
changed = True
if row.total_calories is None:
val = _first(TOTAL_PATTERNS, text)
if val is not None:
row.total_calories = float(val)
changed = True
if row.steps is None:
val = _first(STEPS_PATTERNS, text, as_int=True)
if val is not None:
row.steps = int(val)
changed = True
if changed:
updated += 1
db.commit()
print(f"updated {updated} workout rows")
finally:
db.close()
if __name__ == "__main__":
main()
+11 -9
View File
@@ -1,9 +1,11 @@
fastapi>=0.115.0 fastapi>=0.115.0
uvicorn[standard]>=0.32.0 uvicorn[standard]>=0.32.0
sqlalchemy>=2.0.36 python-multipart>=0.0.9
pydantic-settings>=2.6.0 sqlalchemy>=2.0.36
openai>=1.55.0 pydantic-settings>=2.6.0
python-dotenv>=1.0.1 openai>=1.55.0
aiosqlite>=0.20.0 python-dotenv>=1.0.1
httpx>=0.28.0 aiosqlite>=0.20.0
feedparser>=6.0.11 httpx>=0.28.0
feedparser>=6.0.11
qdrant-client>=1.12.0,<1.13.0
+9
View File
@@ -0,0 +1,9 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
sqlalchemy>=2.0.36
pydantic-settings>=2.6.0
openai>=1.55.0
python-dotenv>=1.0.1
aiosqlite>=0.20.0
httpx>=0.28.0
feedparser>=6.0.11
+42
View File
@@ -0,0 +1,42 @@
"""Create a user with API token. Usage:
python -m scripts.create_user testuser --display-name "Test User"
python -m scripts.create_user guest --token my-custom-token-32chars-min
"""
from __future__ import annotations
import argparse
import sys
from app.db.base import SessionLocal, init_db
from app.auth.service import create_user
def main() -> int:
parser = argparse.ArgumentParser(description="Create Home Assistant user")
parser.add_argument("username", help="Unique username (lowercase)")
parser.add_argument("--display-name", default="", help="Display name")
parser.add_argument("--token", default="", help="Custom API token (auto-generated if empty)")
args = parser.parse_args()
init_db()
db = SessionLocal()
try:
user, plain_token = create_user(
db,
username=args.username,
display_name=args.display_name or args.username,
api_token=args.token or None,
)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
finally:
db.close()
print(f"Created user id={user.id} username={user.username}")
print(f"API token (save it, shown once):\n{plain_token}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+84
View File
@@ -0,0 +1,84 @@
import unittest
from app.fitness.activity_budget import (
compute_activity_bonus,
scale_targets,
steps_bonus_kcal,
)
PROFILE = {
"weight_kg": 70,
"activity_level": "moderate",
"weekly_workouts": 3,
"calorie_target": 2000,
"protein_g": 126,
"fat_g": 56,
"carbs_g": 250,
"water_l": 2.5,
}
class ActivityBudgetTests(unittest.TestCase):
def test_no_bonus_at_baseline(self) -> None:
bonus = compute_activity_bonus(
PROFILE,
steps_total=9000,
workouts=[{"active_calories": 85}],
)
self.assertEqual(bonus.steps_bonus_kcal, 0.0)
self.assertEqual(bonus.workout_bonus_kcal, 0.0)
self.assertEqual(bonus.total_bonus_kcal, 0.0)
self.assertEqual(bonus.scale_factor, 1.0)
def test_steps_and_workout_bonus(self) -> None:
bonus = compute_activity_bonus(
PROFILE,
steps_total=21800,
workouts=[{"active_calories": 155}],
)
self.assertGreater(bonus.steps_bonus_kcal, 0)
self.assertGreater(bonus.workout_bonus_kcal, 0)
self.assertEqual(
bonus.total_bonus_kcal,
round(bonus.steps_bonus_kcal + bonus.workout_bonus_kcal, 1),
)
self.assertGreater(bonus.scale_factor, 1.0)
def test_steps_bonus_formula(self) -> None:
kcal = steps_bonus_kcal(steps=21800, baseline_steps=9000, weight_kg=70)
self.assertEqual(kcal, round(12800 * 70 * 0.0005, 1))
def test_proportional_macro_scale(self) -> None:
base = {
"calories": 2000,
"protein_g": 100,
"fat_g": 50,
"carbs_g": 200,
"water_ml": 2500,
}
effective, targets_base = scale_targets(base, 500)
self.assertEqual(effective["calories"], 2500)
self.assertEqual(targets_base, base)
self.assertEqual(effective["water_ml"], 2500)
ratio = effective["calories"] / base["calories"]
self.assertAlmostEqual(effective["protein_g"] / base["protein_g"], ratio, places=1)
self.assertAlmostEqual(effective["fat_g"] / base["fat_g"], ratio, places=1)
self.assertAlmostEqual(effective["carbs_g"] / base["carbs_g"], ratio, places=1)
def test_floor_at_base_when_no_activity(self) -> None:
effective, _ = scale_targets(
{
"calories": 2045,
"protein_g": 156,
"fat_g": 57,
"carbs_g": 227,
"water_ml": 2900,
},
0,
)
self.assertEqual(effective["calories"], 2045)
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
import pytest
from app.fitness.body_composition import (
compute_body_composition,
ffmi,
lean_body_mass,
navy_body_fat_pct,
whr,
)
def test_navy_male_reasonable_range():
bf = navy_body_fat_pct(
sex="male",
height_cm=180,
neck_cm=38,
waist_cm=84,
)
assert bf is not None
assert 5 <= bf <= 35
def test_navy_female_requires_hip():
assert navy_body_fat_pct(
sex="female",
height_cm=165,
neck_cm=34,
waist_cm=72,
hip_cm=None,
) is None
bf = navy_body_fat_pct(
sex="female",
height_cm=165,
neck_cm=34,
waist_cm=72,
hip_cm=98,
)
assert bf is not None
assert 10 <= bf <= 45
def test_navy_invalid_waist_neck():
assert navy_body_fat_pct(
sex="male",
height_cm=180,
neck_cm=40,
waist_cm=39,
) is None
def test_whr():
assert whr(80, 100) == 0.8
def test_lean_body_mass_and_ffmi():
lbm = lean_body_mass(80, 20)
assert lbm == 64.0
score = ffmi(80, 180, 20)
assert score is not None
assert 15 <= score <= 25
def test_compute_manual_body_fat():
result = compute_body_composition(
sex="male",
height_cm=180,
weight_kg=80,
body_fat_pct=18,
waist_cm=84,
hip_cm=100,
)
assert result["body_fat_pct"] == 18.0
assert result["body_fat_method"] == "manual"
assert result["whr"] == 0.84
assert result["lbm_kg"] == 65.6
assert result["ffmi"] is not None
def test_compute_navy_auto():
result = compute_body_composition(
sex="male",
height_cm=180,
weight_kg=82,
neck_cm=38,
waist_cm=84,
)
assert result["body_fat_pct"] is not None
assert result["body_fat_method"] == "navy"
assert result["lbm_kg"] is not None
def test_compute_female_warning_without_hip():
result = compute_body_composition(
sex="female",
height_cm=165,
weight_kg=60,
neck_cm=34,
waist_cm=72,
)
assert result["body_fat_pct"] is None
assert any("бёдер" in w for w in result["warnings"])
+154
View File
@@ -0,0 +1,154 @@
import os
import tempfile
import uuid
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.auth.tokens import hash_token
from app.db.base import Base, get_db
from app.db.models import CharacterCard, ChatSession, MemoryFact, ShoppingList, User
@pytest.fixture()
def client():
db_path = Path(tempfile.gettempdir()) / f"test_multi_{uuid.uuid4().hex}.db"
os.environ["DATABASE_URL"] = f"sqlite:///{db_path.as_posix()}"
os.environ["DEFAULT_API_TOKEN"] = "unused-in-tests"
os.environ["AUTH_REQUIRED"] = "true"
os.environ["RAG_ENABLED"] = "false"
from app.config import get_settings
get_settings.cache_clear()
from app.main import create_app
engine = create_engine(
f"sqlite:///{db_path.as_posix()}",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
token_a = "token-user-a"
token_b = "token-user-b"
db = TestingSessionLocal()
user_a = User(
username="alice",
display_name="Alice",
api_token_hash=hash_token(token_a),
is_active=True,
)
user_b = User(
username="bob",
display_name="Bob",
api_token_hash=hash_token(token_b),
is_active=True,
)
db.add_all([user_a, user_b])
db.commit()
db.refresh(user_a)
db.refresh(user_b)
db.add(ChatSession(user_id=user_a.id, title="Alice chat"))
db.add(ChatSession(user_id=user_b.id, title="Bob chat"))
db.add(ShoppingList(user_id=user_a.id, name="groceries"))
db.add(ShoppingList(user_id=user_b.id, name="groceries"))
db.add(
CharacterCard(
user_id=user_a.id,
card_json='{"spec":"chara_card_v2","spec_version":"2.0","data":{"name":"A","rp_persona_id":"persona-a"}}',
)
)
db.add(
CharacterCard(
user_id=user_b.id,
card_json='{"spec":"chara_card_v2","spec_version":"2.0","data":{"name":"B","rp_persona_id":"persona-b"}}',
)
)
db.add(
MemoryFact(
user_id=user_a.id,
category="person",
content="Секрет только для owner",
source="test",
)
)
db.commit()
db.close()
app = create_app()
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
test_client.tokens = {"a": token_a, "b": token_b}
yield test_client
app.dependency_overrides.clear()
get_settings.cache_clear()
try:
db_path.unlink(missing_ok=True)
except OSError:
pass
def _headers(client: TestClient, who: str) -> dict[str, str]:
return {"Authorization": f"Bearer {client.tokens[who]}"}
def test_chat_sessions_isolated(client: TestClient):
res_a = client.get("/api/v1/chat/sessions", headers=_headers(client, "a"))
res_b = client.get("/api/v1/chat/sessions", headers=_headers(client, "b"))
assert res_a.status_code == 200
assert res_b.status_code == 200
titles_a = {s["title"] for s in res_a.json()}
titles_b = {s["title"] for s in res_b.json()}
assert titles_a == {"Alice chat"}
assert titles_b == {"Bob chat"}
def test_character_cards_isolated(client: TestClient):
res_a = client.get("/api/v1/character", headers=_headers(client, "a"))
res_b = client.get("/api/v1/character", headers=_headers(client, "b"))
assert res_a.json()["data"]["rp_persona_id"] == "persona-a"
assert res_b.json()["data"]["rp_persona_id"] == "persona-b"
def test_shopping_same_name_different_users(client: TestClient):
res_a = client.get("/api/v1/shopping", headers=_headers(client, "a"))
res_b = client.get("/api/v1/shopping", headers=_headers(client, "b"))
assert res_a.status_code == 200
assert res_b.status_code == 200
assert len(res_a.json()["lists"]) == 1
assert len(res_b.json()["lists"]) == 1
def test_missing_token_unauthorized(client: TestClient):
res = client.get("/api/v1/chat/sessions")
assert res.status_code == 401
def test_memory_facts_isolated(client: TestClient):
res_a = client.get("/api/v1/memory", headers=_headers(client, "a"))
res_b = client.get("/api/v1/memory", headers=_headers(client, "b"))
assert res_a.status_code == 200
assert res_b.status_code == 200
facts_a = res_a.json().get("facts") or []
facts_b = res_b.json().get("facts") or []
assert any("Секрет только для owner" in f.get("content", "") for f in facts_a)
assert not any("Секрет только для owner" in f.get("content", "") for f in facts_b)
assert res_b.json().get("total_facts", 0) == 0
+39 -22
View File
@@ -1,22 +1,39 @@
services: services:
backend: qdrant:
build: ./backend image: qdrant/qdrant:v1.12.5
ports: ports:
- "${BACKEND_PORT:-8080}:${BACKEND_INTERNAL_PORT:-8080}" - "${QDRANT_PORT:-6333}:6333"
env_file: .env - "${QDRANT_GRPC_PORT:-6334}:6334"
volumes: volumes:
- ./data:/app/data - qdrant_data:/qdrant/storage
extra_hosts: restart: unless-stopped
- "host.docker.internal:host-gateway"
restart: unless-stopped backend:
build: ./backend
frontend: ports:
build: - "${BACKEND_PORT:-8080}:${BACKEND_INTERNAL_PORT:-8080}"
context: ./frontend env_file: .env
args: environment:
VITE_API_URL: "" QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333}
ports: depends_on:
- "${FRONTEND_PORT:-3080}:${FRONTEND_INTERNAL_PORT:-80}" - qdrant
depends_on: volumes:
- backend - ./data:/app/data
restart: unless-stopped extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_API_URL: ""
VITE_API_TOKEN: ${VITE_API_TOKEN:-}
ports:
- "${FRONTEND_PORT:-3080}:${FRONTEND_INTERNAL_PORT:-80}"
depends_on:
- backend
restart: unless-stopped
volumes:
qdrant_data:
+22
View File
@@ -0,0 +1,22 @@
services:
backend:
build: ./backend
ports:
- "${BACKEND_PORT:-8080}:${BACKEND_INTERNAL_PORT:-8080}"
env_file: .env
volumes:
- ./data:/app/data
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_API_URL: ""
ports:
- "${FRONTEND_PORT:-3080}:${FRONTEND_INTERNAL_PORT:-80}"
depends_on:
- backend
restart: unless-stopped
+3
View File
@@ -5,6 +5,9 @@ WORKDIR /app
ARG VITE_API_URL= ARG VITE_API_URL=
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
ARG VITE_API_TOKEN=
ENV VITE_API_TOKEN=$VITE_API_TOKEN
COPY package.json ./ COPY package.json ./
RUN npm install RUN npm install
+25 -24
View File
@@ -1,24 +1,25 @@
{ {
"name": "home-assistant-frontend", "name": "home-assistant-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "node -e \"try{require('fs').unlinkSync('src/pages/Chat.old.tsx')}catch(e){}\" && tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "@tanstack/react-virtual": "^3.14.2",
"react-dom": "^18.3.1", "react": "^18.3.1",
"react-markdown": "^9.0.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0" "react-markdown": "^9.0.1",
}, "react-router-dom": "^6.28.0"
"devDependencies": { },
"@types/react": "^18.3.12", "devDependencies": {
"@types/react-dom": "^18.3.1", "@types/react": "^18.3.12",
"@vitejs/plugin-react": "^4.3.3", "@types/react-dom": "^18.3.1",
"typescript": "^5.6.3", "@vitejs/plugin-react": "^4.3.3",
"vite": "^5.4.11" "typescript": "^5.6.3",
} "vite": "^5.4.11"
} }
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "home-assistant-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+134 -81
View File
@@ -1,81 +1,134 @@
.app { .app {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.app-header { .app-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-bottom: 1px solid #2a2f3a; border-bottom: 1px solid #2a2f3a;
background: #151922; background: #151922;
} }
.app-header h1 { .app-header h1 {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
} }
.app-header nav { .app-header nav {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
.app-header nav a { .app-header nav a {
padding: 0.45rem 0.9rem; padding: 0.45rem 0.9rem;
border-radius: 8px; border-radius: 8px;
color: #a8b0bd; color: #a8b0bd;
} }
.app-header nav a.active { .app-header nav a.active {
background: #2b3445; background: #2b3445;
color: #fff; color: #fff;
} }
.app-main { .app-user {
flex: 1; display: inline-flex;
min-height: 0; align-items: center;
overflow-x: hidden; gap: 0.5rem;
overflow-y: auto; margin-left: 0.25rem;
-webkit-overflow-scrolling: touch; padding-left: 0.5rem;
overscroll-behavior: contain; border-left: 1px solid #2a2f3a;
} color: #8b939f;
font-size: 0.8rem;
@media (max-width: 768px) { white-space: nowrap;
.app-header { }
padding: 0.55rem 0.75rem;
gap: 0.5rem; .app-logout {
flex-shrink: 0; padding: 0.35rem 0.6rem;
} border: 1px solid #3a4254;
border-radius: 6px;
.app-header h1 { background: transparent;
display: none; color: #c5ccd6;
} cursor: pointer;
font-size: 0.75rem;
.app-header nav { }
flex: 1;
overflow-x: auto; .app-logout:hover {
flex-wrap: nowrap; background: #2b3445;
gap: 0.35rem; }
padding-bottom: 0.1rem;
-webkit-overflow-scrolling: touch; .app-main {
scrollbar-width: none; position: relative;
} flex: 1;
min-height: 0;
.app-header nav::-webkit-scrollbar { overflow-x: hidden;
display: none; overflow-y: auto;
} -webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
.app-header nav a { }
padding: 0.4rem 0.65rem;
font-size: 0.85rem; @media (max-width: 768px) {
white-space: nowrap; .app-header {
flex-shrink: 0; padding: 0.55rem 0.75rem;
} gap: 0.5rem;
} flex-shrink: 0;
}
.app-header h1 {
display: none;
}
.app-header nav {
flex: 1;
overflow-x: auto;
flex-wrap: nowrap;
gap: 0.35rem;
padding-bottom: 0.1rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.app-header nav::-webkit-scrollbar {
display: none;
}
.app-header nav a {
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
white-space: nowrap;
flex-shrink: 0;
}
}
.app-main-chat {
overflow: hidden;
}
.route-panel {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
.route-panel-active {
visibility: visible;
pointer-events: auto;
}
.app-main:not(.app-main-chat) > .route-panel-active {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
+81
View File
@@ -0,0 +1,81 @@
.app {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #2a2f3a;
background: #151922;
}
.app-header h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.app-header nav {
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header nav a {
padding: 0.45rem 0.9rem;
border-radius: 8px;
color: #a8b0bd;
}
.app-header nav a.active {
background: #2b3445;
color: #fff;
}
.app-main {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
@media (max-width: 768px) {
.app-header {
padding: 0.55rem 0.75rem;
gap: 0.5rem;
flex-shrink: 0;
}
.app-header h1 {
display: none;
}
.app-header nav {
flex: 1;
overflow-x: auto;
flex-wrap: nowrap;
gap: 0.35rem;
padding-bottom: 0.1rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.app-header nav::-webkit-scrollbar {
display: none;
}
.app-header nav a {
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
white-space: nowrap;
flex-shrink: 0;
}
}

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