added RAG, Multiuser, TG bot
This commit is contained in:
@@ -26,6 +26,14 @@ 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
|
||||||
|
|
||||||
|
# Multi-user (API token auth)
|
||||||
|
DEFAULT_USER_USERNAME=owner
|
||||||
|
DEFAULT_USER_DISPLAY_NAME=
|
||||||
|
DEFAULT_API_TOKEN=change-me-to-long-random-string
|
||||||
|
AUTH_REQUIRED=true
|
||||||
|
# Опционально для dev (автовход без /login). В prod оставьте пустым.
|
||||||
|
VITE_API_TOKEN=
|
||||||
|
|
||||||
# Fitness (wger + Open Food Facts — public HTTPS, no proxy)
|
# Fitness (wger + Open Food Facts — public HTTPS, no proxy)
|
||||||
WGER_BASE_URL=https://wger.de/api/v2
|
WGER_BASE_URL=https://wger.de/api/v2
|
||||||
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
|
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
|
||||||
@@ -104,3 +112,10 @@ NETDATA_POLL_INTERVAL_SEC=120
|
|||||||
# Vector DB (phase 3)
|
# Vector DB (phase 3)
|
||||||
QDRANT_PORT=6333
|
QDRANT_PORT=6333
|
||||||
QDRANT_GRPC_PORT=6334
|
QDRANT_GRPC_PORT=6334
|
||||||
|
|
||||||
|
# RAG / embeddings
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||||
|
RAG_ENABLED=true
|
||||||
|
RAG_TOP_K=8
|
||||||
|
MEMORY_FACTS_IN_CONTEXT=8
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import pathlib
|
||||||
|
ROOT = pathlib.Path(".").resolve()
|
||||||
|
svc = ROOT / "app/fitness/service.py"
|
||||||
|
text = svc.read_text(encoding="utf-8")
|
||||||
|
old = """from app.db.models import (
|
||||||
|
BodyMetric,
|
||||||
|
FitnessProfile,
|
||||||
|
FitnessReminder,
|
||||||
|
FoodLog,
|
||||||
|
WaterLog,
|
||||||
|
WorkoutLog,
|
||||||
|
)
|
||||||
|
from app.fitness.calculators import compute_targets, one_rep_max"""
|
||||||
|
new = """from app.db.models import (
|
||||||
|
BodyMetric,
|
||||||
|
FitnessProfile,
|
||||||
|
FitnessReminder,
|
||||||
|
FoodLog,
|
||||||
|
StepLog,
|
||||||
|
WaterLog,
|
||||||
|
WorkoutLog,
|
||||||
|
)
|
||||||
|
from app.fitness.activity_budget import (
|
||||||
|
build_base_targets,
|
||||||
|
compute_activity_bonus,
|
||||||
|
estimate_workout_active_kcal,
|
||||||
|
scale_targets,
|
||||||
|
)
|
||||||
|
from app.fitness.calculators import compute_targets, one_rep_max"""
|
||||||
|
if old not in text:
|
||||||
|
raise SystemExit("import block missing")
|
||||||
|
text = text.replace(old, new, 1)
|
||||||
|
svc.write_text(text, encoding="utf-8")
|
||||||
|
print("ok imports")
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.api.schemas import MessageOut
|
||||||
|
|
||||||
|
|
||||||
|
class MessagesPageOut(BaseModel):
|
||||||
|
messages: list[MessageOut]
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationStatusOut(BaseModel):
|
||||||
|
active: bool
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
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(auth.router)
|
||||||
api_router.include_router(homelab.router, tags=["homelab"])
|
api_router.include_router(homelab.router, tags=["homelab"])
|
||||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||||
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
||||||
@@ -15,3 +16,5 @@ api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"]
|
|||||||
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
|
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
|
||||||
api_router.include_router(webhooks.router, tags=["webhooks"])
|
api_router.include_router(webhooks.router, tags=["webhooks"])
|
||||||
api_router.include_router(media.router, tags=["media"])
|
api_router.include_router(media.router, tags=["media"])
|
||||||
|
api_router.include_router(settings.router, tags=["settings"])
|
||||||
|
api_router.include_router(documents.router, tags=["documents"])
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, reminders, shopping, webhooks
|
||||||
|
|
||||||
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
|
api_router.include_router(health.router, tags=["health"])
|
||||||
|
api_router.include_router(homelab.router, tags=["homelab"])
|
||||||
|
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||||
|
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
||||||
|
api_router.include_router(character.router, tags=["character"])
|
||||||
|
api_router.include_router(projects.router, tags=["projects"])
|
||||||
|
api_router.include_router(memory.router, tags=["memory"])
|
||||||
|
api_router.include_router(fitness.router, tags=["fitness"])
|
||||||
|
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
|
||||||
|
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
|
||||||
|
api_router.include_router(webhooks.router, tags=["webhooks"])
|
||||||
|
api_router.include_router(media.router, tags=["media"])
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.auth.service import create_user, find_user_by_token, user_to_dict
|
||||||
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
token: str = Field(min_length=8, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=2, max_length=64)
|
||||||
|
display_name: str = ""
|
||||||
|
token: str | None = Field(default=None, min_length=8, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
user = find_user_by_token(db, payload.token)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Неверный токен")
|
||||||
|
return {"ok": True, "user": user_to_dict(user), "token": payload.token.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
|
return {"ok": True, "user": user_to_dict(user)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
def list_users(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
rows = db.scalars(select(User).where(User.is_active.is_(True)).order_by(User.id)).all()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"users": [user_to_dict(row) for row in rows],
|
||||||
|
"current_user_id": user.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
def register_user(
|
||||||
|
payload: CreateUserRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
new_user, plain_token = create_user(
|
||||||
|
db,
|
||||||
|
username=payload.username,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
api_token=payload.token,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"user": user_to_dict(new_user),
|
||||||
|
"token": plain_token,
|
||||||
|
"created_by": user.username,
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
from app.character.service import CharacterService
|
from app.character.service import CharacterService
|
||||||
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -37,18 +41,28 @@ class CharacterCardV2(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/character")
|
@router.get("/character")
|
||||||
def get_character() -> dict[str, Any]:
|
def get_character(
|
||||||
return CharacterService().get_card()
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return CharacterService(db, user.id).get_card()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/character")
|
@router.put("/character")
|
||||||
def update_character(payload: CharacterCardV2) -> dict[str, Any]:
|
def update_character(
|
||||||
return CharacterService().save_card(payload.model_dump())
|
payload: CharacterCardV2,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return CharacterService(db, user.id).save_card(payload.model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/character/prompt")
|
@router.get("/character/prompt")
|
||||||
def get_character_prompt() -> dict[str, str]:
|
def get_character_prompt(
|
||||||
service = CharacterService()
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
service = CharacterService(db, user.id)
|
||||||
return {
|
return {
|
||||||
"system_prompt": service.get_system_prompt(),
|
"system_prompt": service.get_system_prompt(),
|
||||||
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
|
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
|
||||||
@@ -56,7 +70,11 @@ def get_character_prompt() -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/character/import")
|
@router.post("/character/import")
|
||||||
def import_character(payload: dict[str, Any]) -> dict[str, Any]:
|
def import_character(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
if not payload:
|
if not payload:
|
||||||
raise HTTPException(status_code=400, detail="Empty card")
|
raise HTTPException(status_code=400, detail="Empty card")
|
||||||
return CharacterService().save_card(payload)
|
return CharacterService(db, user.id).save_card(payload)
|
||||||
|
|||||||
+116
-28
@@ -1,62 +1,92 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut
|
from app.api.chat_schemas import GenerationStatusOut, MessagesPageOut
|
||||||
|
from app.api.schemas import (
|
||||||
|
MessageCreate,
|
||||||
|
SessionCreate,
|
||||||
|
SessionDetailOut,
|
||||||
|
SessionOut,
|
||||||
|
)
|
||||||
|
from app.chat.generation import (
|
||||||
|
GenerationBusyError,
|
||||||
|
get_active_handle,
|
||||||
|
is_generation_active,
|
||||||
|
start_generation,
|
||||||
|
subscribe_generation,
|
||||||
|
)
|
||||||
from app.chat.service import ChatService
|
from app.chat.service import ChatService
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
from app.db.base import get_db
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sessions", response_model=SessionOut)
|
@router.post("/sessions", response_model=SessionOut)
|
||||||
def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut:
|
def create_session(payload: SessionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionOut:
|
||||||
service = ChatService(db)
|
service = ChatService(db, user.id)
|
||||||
return service.create_session(title=payload.title)
|
return service.create_session(title=payload.title)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions", response_model=list[SessionOut])
|
@router.get("/sessions", response_model=list[SessionOut])
|
||||||
def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]:
|
def list_sessions(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[SessionOut]:
|
||||||
service = ChatService(db)
|
service = ChatService(db, user.id)
|
||||||
return service.list_sessions()
|
return service.list_sessions()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
|
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
|
||||||
def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut:
|
def get_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionDetailOut:
|
||||||
service = ChatService(db)
|
service = ChatService(db, user.id)
|
||||||
session = service.get_session(session_id)
|
session = service.get_session(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/sessions/{session_id}")
|
@router.get("/sessions/{session_id}/messages", response_model=MessagesPageOut)
|
||||||
def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
|
def list_messages(
|
||||||
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,
|
session_id: int,
|
||||||
payload: MessageCreate,
|
limit: int = 30,
|
||||||
db: Session = Depends(get_db),
|
before_id: int | None = None,
|
||||||
) -> StreamingResponse:
|
after_id: int | None = None,
|
||||||
service = ChatService(db)
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> MessagesPageOut:
|
||||||
|
service = ChatService(db, user.id)
|
||||||
|
if not service.get_session(session_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
messages, has_more = service.list_messages(
|
||||||
|
session_id,
|
||||||
|
limit=min(max(limit, 1), 100),
|
||||||
|
before_id=before_id,
|
||||||
|
after_id=after_id,
|
||||||
|
)
|
||||||
|
return MessagesPageOut(messages=messages, has_more=has_more)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/generation", response_model=GenerationStatusOut)
|
||||||
|
def generation_status(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> GenerationStatusOut:
|
||||||
|
service = ChatService(db, user.id)
|
||||||
|
if not service.get_session(session_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
return GenerationStatusOut(active=is_generation_active(session_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/generation/stream")
|
||||||
|
async def generation_stream(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> StreamingResponse:
|
||||||
|
service = ChatService(db, user.id)
|
||||||
if not service.get_session(session_id):
|
if not service.get_session(session_id):
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД.
|
handle = get_active_handle(session_id)
|
||||||
service.save_user_message(session_id, payload.content)
|
if not handle:
|
||||||
|
raise HTTPException(status_code=404, detail="No active generation")
|
||||||
|
|
||||||
async def event_stream():
|
async def event_stream():
|
||||||
async for chunk in service.stream_response(
|
async for chunk in subscribe_generation(handle):
|
||||||
session_id,
|
|
||||||
payload.content,
|
|
||||||
user_message_saved=True,
|
|
||||||
):
|
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
@@ -68,3 +98,61 @@ async def send_message(
|
|||||||
"X-Accel-Buffering": "no",
|
"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",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
@@ -5,7 +5,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
from app.db.base import get_db
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
from app.fitness.service import FitnessService
|
from app.fitness.service import FitnessService
|
||||||
from app.fitness.structuring import structure_meal, structure_workout
|
from app.fitness.structuring import structure_meal, structure_workout
|
||||||
from app.integrations.openfoodfacts import OpenFoodFactsClient
|
from app.integrations.openfoodfacts import OpenFoodFactsClient
|
||||||
@@ -23,6 +25,8 @@ class ProfileUpdate(BaseModel):
|
|||||||
goal: str | None = None
|
goal: str | None = None
|
||||||
target_weight_kg: float | None = None
|
target_weight_kg: float | None = None
|
||||||
weekly_workouts: int | None = None
|
weekly_workouts: int | None = None
|
||||||
|
baseline_steps: int | None = None
|
||||||
|
baseline_workout_kcal: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class MealCreate(BaseModel):
|
class MealCreate(BaseModel):
|
||||||
@@ -39,11 +43,38 @@ class WeightCreate(BaseModel):
|
|||||||
body_fat_pct: float | None = None
|
body_fat_pct: float | None = None
|
||||||
chest_cm: float | None = None
|
chest_cm: float | None = None
|
||||||
waist_cm: float | None = None
|
waist_cm: float | None = None
|
||||||
|
neck_cm: float | None = None
|
||||||
|
hip_cm: float | None = None
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
|
day: str | None = None
|
||||||
|
days_ago: int | None = Field(default=None, ge=0, le=90)
|
||||||
|
recorded_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BodyCompositionCalc(BaseModel):
|
||||||
|
weight_kg: float | None = None
|
||||||
|
height_cm: float | None = None
|
||||||
|
sex: str | None = None
|
||||||
|
neck_cm: float | None = None
|
||||||
|
waist_cm: float | None = None
|
||||||
|
hip_cm: float | None = None
|
||||||
|
body_fat_pct: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StepsCreate(BaseModel):
|
||||||
|
steps: int = Field(ge=0)
|
||||||
|
active_calories: float | None = None
|
||||||
|
notes: str = ""
|
||||||
|
day: str | None = None
|
||||||
|
days_ago: int | None = Field(default=None, ge=0, le=90)
|
||||||
|
logged_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class WorkoutCreate(BaseModel):
|
class WorkoutCreate(BaseModel):
|
||||||
text: str = Field(min_length=1)
|
text: str = Field(min_length=1)
|
||||||
|
day: str | None = None
|
||||||
|
days_ago: int | None = Field(default=None, ge=0, le=90)
|
||||||
|
logged_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ReminderUpdate(BaseModel):
|
class ReminderUpdate(BaseModel):
|
||||||
@@ -54,60 +85,70 @@ class ReminderUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/fitness")
|
@router.get("/fitness")
|
||||||
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
|
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
return FitnessService(db).snapshot()
|
return FitnessService(db, user.id).snapshot()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/fitness/summary")
|
@router.get("/fitness/summary")
|
||||||
def get_summary(
|
def get_summary(
|
||||||
day: str | None = None,
|
day: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
d = date.fromisoformat(day) if day else None
|
d = date.fromisoformat(day) if day else None
|
||||||
return FitnessService(db).get_daily_summary(d)
|
return FitnessService(db, user.id).get_daily_summary(d)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/workout-stats")
|
||||||
|
def get_workout_stats(
|
||||||
|
days: int = 7,
|
||||||
|
end: str | None = None,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
end_day = date.fromisoformat(end) if end else None
|
||||||
|
return FitnessService(db, user.id).get_workout_stats(days=days, end_day=end_day)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/fitness/history")
|
@router.get("/fitness/history")
|
||||||
def get_history(
|
def get_history(
|
||||||
days: int = 7,
|
days: int = 7,
|
||||||
end: str | None = None,
|
end: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
end_day = date.fromisoformat(end) if end else None
|
end_day = date.fromisoformat(end) if end else None
|
||||||
return FitnessService(db).get_history(days=days, end_day=end_day)
|
return FitnessService(db, user.id).get_history(days=days, end_day=end_day)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/fitness/profile")
|
@router.get("/fitness/profile")
|
||||||
def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]:
|
def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
profile = FitnessService(db).get_profile()
|
profile = FitnessService(db, user.id).get_profile()
|
||||||
return profile or {"configured": False}
|
return profile or {"configured": False}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/fitness/profile")
|
@router.put("/fitness/profile")
|
||||||
def update_profile(
|
def update_profile(
|
||||||
payload: ProfileUpdate,
|
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).set_profile(payload.model_dump(exclude_none=True))
|
return FitnessService(db, user.id).set_profile(payload.model_dump(exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/fitness/profile/calc")
|
@router.post("/fitness/profile/calc")
|
||||||
def calc_targets(
|
def calc_targets(
|
||||||
payload: ProfileUpdate,
|
payload: ProfileUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
params = payload.model_dump(exclude_none=True)
|
params = payload.model_dump(exclude_none=True)
|
||||||
if not params:
|
if not params:
|
||||||
raise HTTPException(status_code=400, detail="No parameters")
|
raise HTTPException(status_code=400, detail="No parameters")
|
||||||
return FitnessService(db).calc_targets(params)
|
return FitnessService(db, user.id).calc_targets(params)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/fitness/meals")
|
@router.post("/fitness/meals")
|
||||||
async def create_meal(
|
async def create_meal(
|
||||||
payload: MealCreate,
|
payload: MealCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
service = FitnessService(db)
|
service = FitnessService(db, user.id)
|
||||||
try:
|
try:
|
||||||
structured = await structure_meal(payload.text)
|
structured = await structure_meal(payload.text)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -127,84 +168,128 @@ async def create_meal(
|
|||||||
@router.post("/fitness/water")
|
@router.post("/fitness/water")
|
||||||
def create_water(
|
def create_water(
|
||||||
payload: WaterCreate,
|
payload: WaterCreate,
|
||||||
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).log_water(payload.amount_ml)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/fitness/weight")
|
@router.post("/fitness/weight")
|
||||||
def create_weight(
|
def create_weight(
|
||||||
payload: WeightCreate,
|
payload: WeightCreate,
|
||||||
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(
|
day = date.fromisoformat(payload.day) if payload.day else None
|
||||||
|
return FitnessService(db, user.id).log_weight(
|
||||||
payload.weight_kg,
|
payload.weight_kg,
|
||||||
body_fat_pct=payload.body_fat_pct,
|
body_fat_pct=payload.body_fat_pct,
|
||||||
chest_cm=payload.chest_cm,
|
chest_cm=payload.chest_cm,
|
||||||
waist_cm=payload.waist_cm,
|
waist_cm=payload.waist_cm,
|
||||||
|
neck_cm=payload.neck_cm,
|
||||||
|
hip_cm=payload.hip_cm,
|
||||||
notes=payload.notes,
|
notes=payload.notes,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
day=day,
|
||||||
|
days_ago=payload.days_ago,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/body-composition/calc")
|
||||||
|
def calc_body_composition(
|
||||||
|
payload: BodyCompositionCalc,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return FitnessService(db, user.id).calc_body_composition(payload.model_dump(exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/steps")
|
||||||
|
def create_steps(
|
||||||
|
payload: StepsCreate,
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
day = date.fromisoformat(payload.day) if payload.day else None
|
||||||
|
return FitnessService(db, user.id).log_steps(
|
||||||
|
payload.steps,
|
||||||
|
active_calories=payload.active_calories,
|
||||||
|
notes=payload.notes,
|
||||||
|
day=day,
|
||||||
|
days_ago=payload.days_ago,
|
||||||
|
logged_at=payload.logged_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/fitness/steps/{log_id}")
|
||||||
|
def delete_steps(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
|
||||||
|
if not FitnessService(db, user.id).delete_step_log(log_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/fitness/workouts")
|
@router.post("/fitness/workouts")
|
||||||
async def create_workout(
|
async def create_workout(
|
||||||
payload: WorkoutCreate,
|
payload: WorkoutCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
service = FitnessService(db)
|
service = FitnessService(db, user.id)
|
||||||
try:
|
try:
|
||||||
structured = await structure_workout(payload.text)
|
structured = await structure_workout(payload.text)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from 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(
|
return service.log_workout(
|
||||||
title=structured.get("title") or "Тренировка",
|
title=structured.get("title") or "Тренировка",
|
||||||
notes=structured.get("notes") or payload.text,
|
notes=structured.get("notes") or payload.text,
|
||||||
duration_min=structured.get("duration_min"),
|
duration_min=structured.get("duration_min"),
|
||||||
exercises=structured.get("exercises"),
|
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")
|
@router.get("/fitness/body-metrics")
|
||||||
def list_metrics(
|
def list_metrics(
|
||||||
limit: int = 30,
|
limit: int = 30,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
return FitnessService(db).list_body_metrics(limit=limit)
|
return FitnessService(db, user.id).list_body_metrics(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/fitness/meals/{log_id}")
|
@router.delete("/fitness/meals/{log_id}")
|
||||||
def delete_meal(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
|
def delete_meal(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
|
||||||
if not FitnessService(db).delete_food_log(log_id):
|
if not FitnessService(db, user.id).delete_food_log(log_id):
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/fitness/water/{log_id}")
|
@router.delete("/fitness/water/{log_id}")
|
||||||
def delete_water(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
|
def delete_water(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
|
||||||
if not FitnessService(db).delete_water_log(log_id):
|
if not FitnessService(db, user.id).delete_water_log(log_id):
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/fitness/workouts/{log_id}")
|
@router.delete("/fitness/workouts/{log_id}")
|
||||||
def delete_workout(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
|
def delete_workout(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
|
||||||
if not FitnessService(db).delete_workout_log(log_id):
|
if not FitnessService(db, user.id).delete_workout_log(log_id):
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/fitness/reminders")
|
@router.get("/fitness/reminders")
|
||||||
def list_reminders(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
|
def list_reminders(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
|
||||||
return FitnessService(db).list_reminders()
|
return FitnessService(db, user.id).list_reminders()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/fitness/reminders/{kind}")
|
@router.put("/fitness/reminders/{kind}")
|
||||||
def update_reminder(
|
def update_reminder(
|
||||||
kind: str,
|
kind: str,
|
||||||
payload: ReminderUpdate,
|
payload: ReminderUpdate,
|
||||||
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).set_reminder(
|
return FitnessService(db, user.id).set_reminder(
|
||||||
kind,
|
kind,
|
||||||
enabled=payload.enabled,
|
enabled=payload.enabled,
|
||||||
hour=payload.hour,
|
hour=payload.hour,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
from app.db.base import get_db
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
from app.db.models import ChatSession
|
from app.db.models import ChatSession
|
||||||
from app.memory.extract import extract_after_turn
|
from app.memory.extract import extract_after_turn
|
||||||
from app.memory.service import MemoryService
|
from app.memory.service import MemoryService
|
||||||
@@ -38,23 +40,23 @@ class ExtractRequest(BaseModel):
|
|||||||
@router.get("/memory")
|
@router.get("/memory")
|
||||||
def get_memory_snapshot(
|
def get_memory_snapshot(
|
||||||
session_id: int | None = None,
|
session_id: int | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return MemoryService(db).snapshot(session_id)
|
return MemoryService(db, user.id).snapshot(session_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]:
|
def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
return MemoryService(db).get_profile()
|
return MemoryService(db, user.id).get_profile()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/profile")
|
@router.put("/profile")
|
||||||
def update_profile(
|
def update_profile(
|
||||||
payload: ProfileUpdate,
|
payload: ProfileUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return MemoryService(db).update_profile(payload.updates)
|
return MemoryService(db, user.id).update_profile(payload.updates)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
@@ -64,18 +66,18 @@ def list_facts(
|
|||||||
query: str | None = None,
|
query: str | None = None,
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
limit: int = 30,
|
limit: int = 30,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
return MemoryService(db).recall_memories(query=query, category=category, limit=limit)
|
return MemoryService(db, user.id).recall_memories(query=query, category=category, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/memory/facts")
|
@router.post("/memory/facts")
|
||||||
def create_fact(
|
def create_fact(
|
||||||
payload: FactCreate,
|
payload: FactCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return MemoryService(db).remember_fact(
|
return MemoryService(db, user.id).remember_fact(
|
||||||
payload.content,
|
payload.content,
|
||||||
category=payload.category,
|
category=payload.category,
|
||||||
session_id=payload.session_id,
|
session_id=payload.session_id,
|
||||||
@@ -87,9 +89,9 @@ def create_fact(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/memory/facts/{memory_id}")
|
@router.delete("/memory/facts/{memory_id}")
|
||||||
def forget_fact(memory_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def forget_fact(memory_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return MemoryService(db).forget_memory(memory_id)
|
return MemoryService(db, user.id).forget_memory(memory_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
@@ -97,16 +99,17 @@ def forget_fact(memory_id: int, db: Session = Depends(get_db)) -> dict[str, Any]
|
|||||||
@router.post("/memory/extract")
|
@router.post("/memory/extract")
|
||||||
async def extract_memories(
|
async def extract_memories(
|
||||||
payload: ExtractRequest,
|
payload: ExtractRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
session = db.get(ChatSession, payload.session_id)
|
session = db.get(ChatSession, payload.session_id)
|
||||||
if not session:
|
if not session or session.user_id != user.id:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
return await extract_after_turn(
|
return await extract_after_turn(
|
||||||
db,
|
db,
|
||||||
payload.session_id,
|
payload.session_id,
|
||||||
payload.user_text,
|
payload.user_text,
|
||||||
payload.assistant_text,
|
payload.assistant_text,
|
||||||
|
user_id=user.id,
|
||||||
force=payload.force,
|
force=payload.force,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,10 +118,10 @@ async def extract_memories(
|
|||||||
def update_session_summary(
|
def update_session_summary(
|
||||||
session_id: int,
|
session_id: int,
|
||||||
payload: SessionSummaryUpdate,
|
payload: SessionSummaryUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return MemoryService(db).update_session_summary(
|
return MemoryService(db, user.id).update_session_summary(
|
||||||
session_id,
|
session_id,
|
||||||
payload.summary,
|
payload.summary,
|
||||||
message_count=payload.message_count,
|
message_count=payload.message_count,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.schemas import PomodoroStart, PomodoroStop
|
from app.api.schemas import PomodoroStart, PomodoroStop
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
from app.db.base import get_db
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
from app.pomodoro.service import PomodoroService
|
from app.pomodoro.service import PomodoroService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -13,14 +15,14 @@ def _handle_value_error(exc: ValueError) -> HTTPException:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
def get_status(db: Session = Depends(get_db)) -> dict:
|
def get_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
return PomodoroService(db).get_status()
|
return PomodoroService(db, user.id).get_status()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/start")
|
@router.post("/start")
|
||||||
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
|
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
try:
|
try:
|
||||||
return PomodoroService(db).start(
|
return PomodoroService(db, user.id).start(
|
||||||
duration_min=payload.duration_min,
|
duration_min=payload.duration_min,
|
||||||
task_note=payload.task_note,
|
task_note=payload.task_note,
|
||||||
)
|
)
|
||||||
@@ -29,25 +31,25 @@ def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db)) -> dic
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/pause")
|
@router.post("/pause")
|
||||||
def pause_pomodoro(db: Session = Depends(get_db)) -> dict:
|
def pause_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
try:
|
try:
|
||||||
return PomodoroService(db).pause()
|
return PomodoroService(db, user.id).pause()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise _handle_value_error(exc) from exc
|
raise _handle_value_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resume")
|
@router.post("/resume")
|
||||||
def resume_pomodoro(db: Session = Depends(get_db)) -> dict:
|
def resume_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
try:
|
try:
|
||||||
return PomodoroService(db).resume()
|
return PomodoroService(db, user.id).resume()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise _handle_value_error(exc) from exc
|
raise _handle_value_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stop")
|
@router.post("/stop")
|
||||||
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict:
|
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
try:
|
try:
|
||||||
return PomodoroService(db).stop(
|
return PomodoroService(db, user.id).stop(
|
||||||
result=payload.result,
|
result=payload.result,
|
||||||
completed=payload.completed,
|
completed=payload.completed,
|
||||||
)
|
)
|
||||||
@@ -56,14 +58,14 @@ def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/history")
|
@router.get("/history")
|
||||||
def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]:
|
def get_history(limit: int = 20, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
|
||||||
return PomodoroService(db).history(limit=limit)
|
return PomodoroService(db, user.id).history(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/work/start")
|
@router.post("/work/start")
|
||||||
def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
|
def start_work(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
try:
|
try:
|
||||||
return PomodoroService(db).start_work(
|
return PomodoroService(db, user.id).start_work(
|
||||||
duration_min=payload.duration_min,
|
duration_min=payload.duration_min,
|
||||||
task_note=payload.task_note,
|
task_note=payload.task_note,
|
||||||
)
|
)
|
||||||
@@ -72,29 +74,29 @@ def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/break/short/start")
|
@router.post("/break/short/start")
|
||||||
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
|
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
try:
|
try:
|
||||||
return PomodoroService(db).start_short_break(duration_min=duration_min)
|
return PomodoroService(db, user.id).start_short_break(duration_min=duration_min)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise _handle_value_error(exc) from exc
|
raise _handle_value_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/break/long/start")
|
@router.post("/break/long/start")
|
||||||
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
|
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
try:
|
try:
|
||||||
return PomodoroService(db).start_long_break(duration_min=duration_min)
|
return PomodoroService(db, user.id).start_long_break(duration_min=duration_min)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise _handle_value_error(exc) from exc
|
raise _handle_value_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cycle/reset")
|
@router.post("/cycle/reset")
|
||||||
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db)) -> dict:
|
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
return PomodoroService(db).reset_cycle(clear_task=clear_task)
|
return PomodoroService(db, user.id).reset_cycle(clear_task=clear_task)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/skip")
|
@router.post("/skip")
|
||||||
def skip_phase(db: Session = Depends(get_db)) -> dict:
|
def skip_phase(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
try:
|
try:
|
||||||
return PomodoroService(db).skip_phase()
|
return PomodoroService(db, user.id).skip_phase()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise _handle_value_error(exc) from exc
|
raise _handle_value_error(exc) from exc
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
from app.db.base import get_db
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
from app.projects.service import ProjectService
|
from app.projects.service import ProjectService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -22,14 +24,14 @@ class WorkItemCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/projects")
|
@router.get("/projects")
|
||||||
def list_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
|
def list_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
|
||||||
return ProjectService(db).list_projects()
|
return ProjectService(db, user.id).list_projects()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/projects/sync-taiga")
|
@router.post("/projects/sync-taiga")
|
||||||
def sync_taiga_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
|
def sync_taiga_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
return ProjectService(db).sync_taiga_projects()
|
return ProjectService(db, user.id).sync_taiga_projects()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
@@ -38,10 +40,10 @@ def sync_taiga_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
|
|||||||
def bind_gitea(
|
def bind_gitea(
|
||||||
taiga_slug: str,
|
taiga_slug: str,
|
||||||
payload: GiteaBinding,
|
payload: GiteaBinding,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ProjectService(db).bind_gitea(
|
return ProjectService(db, user.id).bind_gitea(
|
||||||
taiga_slug,
|
taiga_slug,
|
||||||
payload.gitea_owner,
|
payload.gitea_owner,
|
||||||
payload.gitea_repo,
|
payload.gitea_repo,
|
||||||
@@ -54,10 +56,10 @@ def bind_gitea(
|
|||||||
@router.post("/work-items")
|
@router.post("/work-items")
|
||||||
async def create_work_item(
|
async def create_work_item(
|
||||||
payload: WorkItemCreate,
|
payload: WorkItemCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return await ProjectService(db).create_work_item(
|
return await ProjectService(db, user.id).create_work_item(
|
||||||
payload.text,
|
payload.text,
|
||||||
project_slug=payload.project_slug,
|
project_slug=payload.project_slug,
|
||||||
)
|
)
|
||||||
@@ -71,6 +73,6 @@ async def create_work_item(
|
|||||||
def list_work_items(
|
def list_work_items(
|
||||||
limit: int = 30,
|
limit: int = 30,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
return ProjectService(db).list_work_items(limit=limit, status=status)
|
return ProjectService(db, user.id).list_work_items(limit=limit, status=status)
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ 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.auth.deps import get_current_user
|
||||||
from app.db.base import get_db
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
from app.homelab.context import resolve_timezone
|
from app.homelab.context import resolve_timezone
|
||||||
from app.reminders.service import RemindersService
|
from app.reminders_scoped.service import RemindersService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -31,16 +33,17 @@ class ReminderUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
|
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
return RemindersService(db).snapshot()
|
return RemindersService(db, user.id).snapshot()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/upcoming")
|
@router.get("/upcoming")
|
||||||
def list_upcoming(
|
def list_upcoming(
|
||||||
limit: int = Query(30, ge=1, le=100),
|
limit: int = Query(30, ge=1, le=100),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
return RemindersService(db).list_upcoming(limit=limit)
|
return RemindersService(db, user.id).list_upcoming(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/calendar")
|
@router.get("/calendar")
|
||||||
@@ -48,8 +51,9 @@ def calendar(
|
|||||||
year: int = Query(..., ge=2000, le=2100),
|
year: int = Query(..., ge=2000, le=2100),
|
||||||
month: int = Query(..., ge=1, le=12),
|
month: int = Query(..., ge=1, le=12),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
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:
|
||||||
@@ -61,7 +65,7 @@ def calendar(
|
|||||||
else:
|
else:
|
||||||
end = datetime(year, month + 1, 1, tzinfo=tz)
|
end = datetime(year, month + 1, 1, tzinfo=tz)
|
||||||
|
|
||||||
service = RemindersService(db)
|
service = RemindersService(db, user.id)
|
||||||
items = service.list_in_range(
|
items = service.list_in_range(
|
||||||
date_from=start.astimezone(timezone.utc),
|
date_from=start.astimezone(timezone.utc),
|
||||||
date_to=end.astimezone(timezone.utc),
|
date_to=end.astimezone(timezone.utc),
|
||||||
@@ -75,9 +79,9 @@ def calendar(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return RemindersService(db).create(
|
return RemindersService(db, user.id).create(
|
||||||
title=payload.title,
|
title=payload.title,
|
||||||
due_at=payload.due_at,
|
due_at=payload.due_at,
|
||||||
notes=payload.notes,
|
notes=payload.notes,
|
||||||
@@ -92,10 +96,10 @@ def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db)) -> d
|
|||||||
def update_reminder(
|
def update_reminder(
|
||||||
reminder_id: int,
|
reminder_id: int,
|
||||||
payload: ReminderUpdate,
|
payload: ReminderUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return RemindersService(db).update(
|
return RemindersService(db, user.id).update(
|
||||||
reminder_id,
|
reminder_id,
|
||||||
title=payload.title,
|
title=payload.title,
|
||||||
due_at=payload.due_at,
|
due_at=payload.due_at,
|
||||||
@@ -109,16 +113,16 @@ def update_reminder(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{reminder_id}")
|
@router.delete("/{reminder_id}")
|
||||||
def delete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def delete_reminder(reminder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return RemindersService(db).delete(reminder_id)
|
return RemindersService(db, user.id).delete(reminder_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{reminder_id}/complete")
|
@router.post("/{reminder_id}/complete")
|
||||||
def complete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def complete_reminder(reminder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return RemindersService(db).complete(reminder_id)
|
return RemindersService(db, user.id).complete(reminder_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
from app.db.base import get_db
|
from app.db.base import get_db
|
||||||
|
from app.db.models import User
|
||||||
from app.shopping.service import ShoppingService
|
from app.shopping.service import ShoppingService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -35,51 +37,51 @@ class ItemChecked(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
|
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
return ShoppingService(db).snapshot()
|
return ShoppingService(db, user.id).snapshot()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lists")
|
@router.get("/lists")
|
||||||
def list_lists(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
|
def list_lists(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
|
||||||
return ShoppingService(db).list_lists(include_items=True)
|
return ShoppingService(db, user.id).list_lists(include_items=True)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lists")
|
@router.post("/lists")
|
||||||
def create_list(payload: ListCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def create_list(payload: ListCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ShoppingService(db).create_list(payload.name)
|
return ShoppingService(db, user.id).create_list(payload.name)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lists/{list_id}")
|
@router.get("/lists/{list_id}")
|
||||||
def get_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def get_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
data = ShoppingService(db).get_list(list_id=list_id)
|
data = ShoppingService(db, user.id).get_list(list_id=list_id)
|
||||||
if not data:
|
if not data:
|
||||||
raise HTTPException(status_code=404, detail="List not found")
|
raise HTTPException(status_code=404, detail="List not found")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/lists/{list_id}")
|
@router.patch("/lists/{list_id}")
|
||||||
def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ShoppingService(db).rename_list(list_id, payload.name)
|
return ShoppingService(db, user.id).rename_list(list_id, payload.name)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/lists/{list_id}")
|
@router.delete("/lists/{list_id}")
|
||||||
def delete_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def delete_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ShoppingService(db).delete_list(list_id)
|
return ShoppingService(db, user.id).delete_list(list_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items")
|
@router.post("/items")
|
||||||
def add_items(payload: ItemsAdd, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def add_items(payload: ItemsAdd, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ShoppingService(db).add_items(
|
return ShoppingService(db, user.id).add_items(
|
||||||
[i.model_dump() for i in payload.items],
|
[i.model_dump() for i in payload.items],
|
||||||
list_id=payload.list_id,
|
list_id=payload.list_id,
|
||||||
list_name=payload.list_name,
|
list_name=payload.list_name,
|
||||||
@@ -92,25 +94,25 @@ def add_items(payload: ItemsAdd, db: Session = Depends(get_db)) -> dict[str, Any
|
|||||||
def set_item_checked(
|
def set_item_checked(
|
||||||
item_id: int,
|
item_id: int,
|
||||||
payload: ItemChecked,
|
payload: ItemChecked,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db), user: User = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ShoppingService(db).set_item_checked(item_id, payload.checked)
|
return ShoppingService(db, user.id).set_item_checked(item_id, payload.checked)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/items/{item_id}")
|
@router.delete("/items/{item_id}")
|
||||||
def remove_item(item_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def remove_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ShoppingService(db).remove_item(item_id)
|
return ShoppingService(db, user.id).remove_item(item_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lists/{list_id}/clear-checked")
|
@router.post("/lists/{list_id}/clear-checked")
|
||||||
def clear_checked(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def clear_checked(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return ShoppingService(db).clear_checked(list_id)
|
return ShoppingService(db, user.id).clear_checked(list_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.chat.notice_inbox import post_notice_to_latest_chat
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db.base import SessionLocal, get_db
|
from app.db.base import get_db
|
||||||
from app.db.models import ChatSession, Message, ProjectBinding
|
from app.db.models import ProjectBinding
|
||||||
from app.projects.service import ProjectService
|
from app.projects.service import ProjectService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -28,31 +29,18 @@ def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) ->
|
|||||||
return hmac.compare_digest(expected, signature)
|
return hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
|
||||||
def _post_close_notice(results: list[dict[str, Any]], owner: str, repo: str) -> None:
|
def _post_close_notice(
|
||||||
|
results: list[dict[str, Any]], owner: str, repo: str, user_id: int
|
||||||
|
) -> None:
|
||||||
if not results:
|
if not results:
|
||||||
return
|
return
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
session = db.scalar(
|
|
||||||
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
|
|
||||||
)
|
|
||||||
if not session:
|
|
||||||
session = ChatSession(title="Git")
|
|
||||||
db.add(session)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(session)
|
|
||||||
|
|
||||||
lines = [f"🔀 **Push** `{owner}/{repo}`"]
|
lines = [f"🔀 **Push** `{owner}/{repo}`"]
|
||||||
for item in results:
|
for item in results:
|
||||||
if "closed" in item:
|
if "closed" in item:
|
||||||
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
|
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
|
||||||
elif "error" in item:
|
elif "error" in item:
|
||||||
lines.append(f"- ошибка: {item['error']}")
|
lines.append(f"- ошибка: {item['error']}")
|
||||||
|
post_notice_to_latest_chat("\n".join(lines), user_id)
|
||||||
db.add(Message(session_id=session.id, role="notice", content="\n".join(lines)))
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhooks/gitea")
|
@router.post("/webhooks/gitea")
|
||||||
@@ -106,13 +94,13 @@ async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict
|
|||||||
len(commits),
|
len(commits),
|
||||||
)
|
)
|
||||||
|
|
||||||
service = ProjectService(db)
|
service = ProjectService(db, binding.user_id)
|
||||||
results = service.process_push(owner, repo_name, commits)
|
results = service.process_push(owner, repo_name, commits)
|
||||||
if results:
|
if results:
|
||||||
logger.info("Gitea push results: %s", results)
|
logger.info("Gitea push results: %s", results)
|
||||||
else:
|
else:
|
||||||
logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name)
|
logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name)
|
||||||
|
|
||||||
_post_close_notice(results, owner, repo_name)
|
_post_close_notice(results, owner, repo_name, binding.user_id)
|
||||||
|
|
||||||
return {"ok": True, "results": results, "commits_processed": len(commits)}
|
return {"ok": True, "results": results, "commits_processed": len(commits)}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.auth.service import create_user, find_user_by_token
|
||||||
|
from app.auth.tokens import hash_token, verify_token
|
||||||
|
|
||||||
|
__all__ = ["get_current_user", "hash_token", "verify_token"]
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.tokens import hash_token
|
||||||
|
from app.db.models import CharacterCard, User
|
||||||
|
from app.character.card import DEFAULT_CARD, normalize_card
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def find_user_by_token(db: Session, token: str) -> User | None:
|
||||||
|
token_hash = hash_token(token.strip())
|
||||||
|
return db.scalar(
|
||||||
|
select(User).where(User.api_token_hash == token_hash, User.is_active.is_(True))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_to_dict(user: User) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"display_name": user.display_name or user.username,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
display_name: str = "",
|
||||||
|
api_token: str | None = None,
|
||||||
|
) -> tuple[User, str]:
|
||||||
|
clean = username.strip().lower()
|
||||||
|
if not clean:
|
||||||
|
raise ValueError("username не может быть пустым")
|
||||||
|
existing = db.scalar(select(User).where(User.username == clean))
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"Пользователь «{clean}» уже существует")
|
||||||
|
|
||||||
|
plain_token = (api_token or "").strip() or secrets.token_urlsafe(32)
|
||||||
|
user = User(
|
||||||
|
username=clean,
|
||||||
|
display_name=(display_name or clean).strip(),
|
||||||
|
api_token_hash=hash_token(plain_token),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
card = normalize_card(DEFAULT_CARD)
|
||||||
|
db.add(
|
||||||
|
CharacterCard(
|
||||||
|
user_id=user.id,
|
||||||
|
card_json=json.dumps(card, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user, plain_token
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(plain: str, token_hash: str) -> bool:
|
||||||
|
return hash_token(plain) == token_hash
|
||||||
@@ -12,9 +12,9 @@ TOOLS_INSTRUCTIONS = """
|
|||||||
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
|
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
|
||||||
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
|
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
|
||||||
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
|
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
|
||||||
- Фитнес: 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. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
|
||||||
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
|
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
|
||||||
|
|||||||
@@ -1,26 +1,42 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
CARD_PATH = Path("./data/character.json")
|
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
|
||||||
|
from app.db.models import CharacterCard
|
||||||
|
|
||||||
|
|
||||||
class CharacterService:
|
class CharacterService:
|
||||||
|
def __init__(self, db: Session, user_id: int):
|
||||||
|
self.db = db
|
||||||
|
self.user_id = user_id
|
||||||
|
|
||||||
def get_card(self) -> dict[str, Any]:
|
def get_card(self) -> dict[str, Any]:
|
||||||
if CARD_PATH.is_file():
|
row = self.db.scalar(
|
||||||
|
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return normalize_card(DEFAULT_CARD)
|
||||||
try:
|
try:
|
||||||
raw = json.loads(CARD_PATH.read_text(encoding="utf-8"))
|
return normalize_card(json.loads(row.card_json or "{}"))
|
||||||
return normalize_card(raw)
|
except json.JSONDecodeError:
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
pass
|
|
||||||
return normalize_card(DEFAULT_CARD)
|
return normalize_card(DEFAULT_CARD)
|
||||||
|
|
||||||
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
|
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
card = normalize_card(raw)
|
card = normalize_card(raw)
|
||||||
CARD_PATH.parent.mkdir(parents=True, exist_ok=True)
|
row = self.db.scalar(
|
||||||
CARD_PATH.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8")
|
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
row = CharacterCard(user_id=self.user_id, card_json="{}")
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.flush()
|
||||||
|
row.card_json = json.dumps(card, ensure_ascii=False)
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
return card
|
return card
|
||||||
|
|
||||||
def get_system_prompt(self) -> str:
|
def get_system_prompt(self) -> str:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -8,23 +8,26 @@ 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)
|
||||||
|
.order_by(ChatSession.updated_at.desc())
|
||||||
|
.limit(1)
|
||||||
)
|
)
|
||||||
if not session:
|
if not session:
|
||||||
session = ChatSession(title="Уведомления")
|
session = ChatSession(user_id=user_id, title="Уведомления")
|
||||||
db.add(session)
|
db.add(session)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(session)
|
db.refresh(session)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
def post_notice_to_latest_chat(content: str) -> int | None:
|
def post_notice_to_latest_chat(content: str, user_id: int) -> int | None:
|
||||||
"""Сохраняет notice в последний активный чат. Возвращает session_id."""
|
"""Сохраняет notice в последний активный чат пользователя. Возвращает session_id."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
session = _latest_chat_session(db)
|
session = _latest_chat_session(db, user_id)
|
||||||
db.add(Message(session_id=session.id, role="notice", content=content))
|
db.add(Message(session_id=session.id, role="notice", content=content))
|
||||||
db.commit()
|
db.commit()
|
||||||
return session.id
|
return session.id
|
||||||
@@ -32,11 +35,11 @@ def post_notice_to_latest_chat(content: str) -> int | None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def post_character_comment_to_latest_chat(content: str) -> int | None:
|
def post_character_comment_to_latest_chat(content: str, user_id: int) -> int | None:
|
||||||
"""Реплика персонажа в UI; не попадает в контекст LLM (в отличие от assistant)."""
|
"""Реплика персонажа в UI; не попадает в контекст LLM (в отличие от assistant)."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
session = _latest_chat_session(db)
|
session = _latest_chat_session(db, user_id)
|
||||||
db.add(Message(session_id=session.id, role="character", content=content))
|
db.add(Message(session_id=session.id, role="character", content=content))
|
||||||
db.commit()
|
db.commit()
|
||||||
return session.id
|
return session.id
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ FITNESS_TOOL_NAMES = frozenset({
|
|||||||
"get_fitness_history",
|
"get_fitness_history",
|
||||||
"set_fitness_profile",
|
"set_fitness_profile",
|
||||||
"calc_fitness_targets",
|
"calc_fitness_targets",
|
||||||
|
"calc_body_composition",
|
||||||
"log_meal",
|
"log_meal",
|
||||||
"log_water",
|
"log_water",
|
||||||
"log_weight",
|
"log_weight",
|
||||||
@@ -95,6 +96,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
|||||||
"lookup_food",
|
"lookup_food",
|
||||||
"lookup_exercise",
|
"lookup_exercise",
|
||||||
"calc_fitness_targets",
|
"calc_fitness_targets",
|
||||||
|
"calc_body_composition",
|
||||||
"get_weather",
|
"get_weather",
|
||||||
"get_morning_briefing",
|
"get_morning_briefing",
|
||||||
"list_shopping_lists",
|
"list_shopping_lists",
|
||||||
@@ -102,6 +104,26 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _format_body_composition_notice(computed: dict[str, Any], *, headline: str) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
bf = computed.get("body_fat_pct")
|
||||||
|
if bf is not None:
|
||||||
|
method = computed.get("body_fat_method")
|
||||||
|
if method == "navy":
|
||||||
|
parts.append(f"жир ≈{bf}% (Navy)")
|
||||||
|
elif method == "manual":
|
||||||
|
parts.append(f"жир {bf}%")
|
||||||
|
else:
|
||||||
|
parts.append(f"жир ≈{bf}%")
|
||||||
|
if computed.get("whr") is not None:
|
||||||
|
parts.append(f"WHR {computed.get('whr')}")
|
||||||
|
if computed.get("ffmi") is not None:
|
||||||
|
parts.append(f"FFMI {computed.get('ffmi')}")
|
||||||
|
if parts:
|
||||||
|
return f"{headline} — {', '.join(parts)}"
|
||||||
|
return headline
|
||||||
|
|
||||||
def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
||||||
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
|
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
|
||||||
return None
|
return None
|
||||||
@@ -199,7 +221,20 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
|||||||
|
|
||||||
if tool_name == "log_weight" and data.get("ok"):
|
if tool_name == "log_weight" and data.get("ok"):
|
||||||
m = data.get("metric", {})
|
m = data.get("metric", {})
|
||||||
return f"💪 **Вес** {m.get('weight_kg')} кг"
|
computed = data.get("computed") or {}
|
||||||
|
headline = f"💪 **Вес** {m.get('weight_kg')} кг"
|
||||||
|
return _format_body_composition_notice(computed, headline=headline)
|
||||||
|
|
||||||
|
if tool_name == "calc_body_composition" and isinstance(data, dict) and "error" not in data:
|
||||||
|
w = data.get("weight_kg")
|
||||||
|
headline = "💪 **Состав тела** (расчёт)"
|
||||||
|
if w is not None:
|
||||||
|
headline += f" · {w} кг"
|
||||||
|
msg = _format_body_composition_notice(data, headline=headline)
|
||||||
|
warnings = data.get("warnings") or []
|
||||||
|
if warnings:
|
||||||
|
msg += f" · {'; '.join(warnings[:2])}"
|
||||||
|
return msg
|
||||||
|
|
||||||
if tool_name == "log_workout" and data.get("ok"):
|
if tool_name == "log_workout" and data.get("ok"):
|
||||||
wo = data.get("workout", {})
|
wo = data.get("workout", {})
|
||||||
|
|||||||
+129
-33
@@ -28,7 +28,7 @@ from app.memory.context import (
|
|||||||
)
|
)
|
||||||
from app.memory.extract import extract_after_turn
|
from app.memory.extract import extract_after_turn
|
||||||
from app.projects.context import format_projects_context, get_projects_snapshot
|
from app.projects.context import format_projects_context, get_projects_snapshot
|
||||||
from app.reminders.context import format_reminders_context, get_reminders_snapshot
|
from app.reminders_scoped.context import format_reminders_context, get_reminders_snapshot
|
||||||
from app.shopping.context import format_shopping_context, get_shopping_snapshot
|
from app.shopping.context import format_shopping_context, get_shopping_snapshot
|
||||||
from app.db.models import ChatSession, Message
|
from app.db.models import ChatSession, Message
|
||||||
from app.llm.client import LLMClient
|
from app.llm.client import LLMClient
|
||||||
@@ -37,14 +37,23 @@ from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
|||||||
|
|
||||||
MAX_TOOL_ROUNDS = 5
|
MAX_TOOL_ROUNDS = 5
|
||||||
MAX_HISTORY_MESSAGES = 40
|
MAX_HISTORY_MESSAGES = 40
|
||||||
|
_DOMAIN_CACHE: dict[str, tuple[float, str]] = {}
|
||||||
|
_DOMAIN_TTL_SEC = 60.0
|
||||||
|
|
||||||
|
_DOMAIN_KEYWORDS: dict[str, tuple[str, ...]] = {
|
||||||
|
"fitness": ("фитнес", "тренир", "калори", "еда", "вода", "вес", "workout", "meal", "белок", "жир"),
|
||||||
|
"shopping": ("покуп", "магазин", "список", "shopping", "корзин"),
|
||||||
|
"reminders": ("напомин", "календар", "событи", "дедлайн", "встреч", "план"),
|
||||||
|
"projects": ("taiga", "gitea", "задач", "проект", "git", "issue", "коммит", "ветк"),
|
||||||
|
}
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _build_messages_for_session(session_id: int) -> list[dict[str, Any]]:
|
def _build_messages_for_session(session_id: int, user_id: int) -> list[dict[str, Any]]:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
service = ChatService(db)
|
service = ChatService(db, user_id)
|
||||||
session = service.get_session(session_id)
|
session = service.get_session(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
return []
|
return []
|
||||||
@@ -55,12 +64,13 @@ def _build_messages_for_session(session_id: int) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
async def _extract_memory_background(
|
async def _extract_memory_background(
|
||||||
session_id: int,
|
session_id: int,
|
||||||
|
user_id: int,
|
||||||
user_text: str,
|
user_text: str,
|
||||||
assistant_text: str,
|
assistant_text: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
await extract_after_turn(db, session_id, user_text, assistant_text)
|
await extract_after_turn(db, session_id, user_text, assistant_text, user_id=user_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Background memory extraction failed: %s", exc)
|
logger.warning("Background memory extraction failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
@@ -68,20 +78,60 @@ async def _extract_memory_background(
|
|||||||
|
|
||||||
|
|
||||||
class ChatService:
|
class ChatService:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session, user_id: int):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
self.user_id = user_id
|
||||||
self.llm = LLMClient()
|
self.llm = LLMClient()
|
||||||
self.character = CharacterService()
|
self.character = CharacterService(db, user_id)
|
||||||
|
|
||||||
def list_sessions(self) -> list[ChatSession]:
|
def list_sessions(self) -> list[ChatSession]:
|
||||||
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
|
stmt = select(ChatSession).where(ChatSession.user_id == self.user_id).order_by(ChatSession.updated_at.desc())
|
||||||
return list(self.db.scalars(stmt).all())
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
def get_session(self, session_id: int) -> ChatSession | None:
|
def get_session(self, session_id: int) -> ChatSession | None:
|
||||||
return self.db.get(ChatSession, session_id)
|
session = self.db.get(ChatSession, session_id)
|
||||||
|
if session and session.user_id != self.user_id:
|
||||||
|
return None
|
||||||
|
return session
|
||||||
|
|
||||||
|
def list_messages(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
limit: int = 30,
|
||||||
|
before_id: int | None = None,
|
||||||
|
after_id: int | None = None,
|
||||||
|
) -> tuple[list[Message], bool]:
|
||||||
|
if not self.get_session(session_id):
|
||||||
|
return [], False
|
||||||
|
|
||||||
|
if after_id is not None:
|
||||||
|
stmt = (
|
||||||
|
select(Message)
|
||||||
|
.where(Message.session_id == session_id, Message.id > after_id)
|
||||||
|
.order_by(Message.created_at.asc())
|
||||||
|
.limit(limit + 1)
|
||||||
|
)
|
||||||
|
rows = list(self.db.scalars(stmt).all())
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
return rows[:limit], has_more
|
||||||
|
|
||||||
|
stmt = select(Message).where(Message.session_id == session_id)
|
||||||
|
|
||||||
|
if before_id is not None:
|
||||||
|
anchor = self.db.get(Message, before_id)
|
||||||
|
if anchor is None or anchor.session_id != session_id:
|
||||||
|
return [], False
|
||||||
|
stmt = stmt.where(Message.created_at < anchor.created_at)
|
||||||
|
|
||||||
|
stmt = stmt.order_by(Message.created_at.desc()).limit(limit + 1)
|
||||||
|
rows = list(self.db.scalars(stmt).all())
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
page = rows[:limit]
|
||||||
|
page.reverse()
|
||||||
|
return page, has_more
|
||||||
|
|
||||||
def create_session(self, title: str = "Новый чат") -> ChatSession:
|
def create_session(self, title: str = "Новый чат") -> ChatSession:
|
||||||
session = ChatSession(title=title)
|
session = ChatSession(user_id=self.user_id, title=title)
|
||||||
self.db.add(session)
|
self.db.add(session)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(session)
|
self.db.refresh(session)
|
||||||
@@ -95,31 +145,59 @@ class ChatService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _build_system_prompt(self, session_id: int | None = None) -> str:
|
def _cached_domain(self, key: str, loader, formatter) -> str:
|
||||||
status = PomodoroService(self.db).get_status()
|
now = time.monotonic()
|
||||||
memory_snapshot = get_memory_snapshot(self.db, session_id)
|
hit = _DOMAIN_CACHE.get(f"{self.user_id}:{key}")
|
||||||
fitness_snapshot = get_fitness_snapshot(self.db)
|
if hit and now < hit[0]:
|
||||||
shopping_snapshot = get_shopping_snapshot(self.db)
|
return hit[1]
|
||||||
reminders_snapshot = get_reminders_snapshot(self.db)
|
rendered = formatter(loader())
|
||||||
projects_snapshot = get_projects_snapshot(self.db)
|
_DOMAIN_CACHE[f"{self.user_id}:{key}"] = (now + _DOMAIN_TTL_SEC, rendered)
|
||||||
return (
|
return rendered
|
||||||
f"{self.character.get_system_prompt()}\n\n"
|
|
||||||
f"{format_datetime_context(self.db)}\n\n"
|
def _domain_relevant(self, key: str, user_query: str) -> bool:
|
||||||
f"{format_memory_context(memory_snapshot)}\n\n"
|
query = user_query.strip().lower()
|
||||||
f"{format_fitness_context(fitness_snapshot)}\n\n"
|
if not query:
|
||||||
f"{format_shopping_context(shopping_snapshot)}\n\n"
|
return False
|
||||||
f"{format_reminders_context(reminders_snapshot)}\n\n"
|
keywords = _DOMAIN_KEYWORDS.get(key, ())
|
||||||
f"{format_weather_snapshot()}\n\n"
|
return any(kw in query for kw in keywords)
|
||||||
f"{format_pomodoro_context(status)}\n\n"
|
|
||||||
f"{format_projects_context(projects_snapshot)}"
|
def _optional_domain(
|
||||||
)
|
self,
|
||||||
|
key: str,
|
||||||
|
user_query: str,
|
||||||
|
loader,
|
||||||
|
formatter,
|
||||||
|
) -> str:
|
||||||
|
if not self._domain_relevant(key, user_query):
|
||||||
|
return ""
|
||||||
|
return self._cached_domain(key, loader, formatter)
|
||||||
|
|
||||||
|
def _build_system_prompt(self, session_id: int | None = None, user_query: str = "") -> str:
|
||||||
|
status = PomodoroService(self.db, self.user_id).get_status()
|
||||||
|
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session_id, query=user_query)
|
||||||
|
fitness_snapshot = get_fitness_snapshot(self.db, self.user_id)
|
||||||
|
shopping_snapshot = get_shopping_snapshot(self.db, self.user_id)
|
||||||
|
reminders_snapshot = get_reminders_snapshot(self.db, self.user_id)
|
||||||
|
projects_snapshot = get_projects_snapshot(self.db, self.user_id)
|
||||||
|
parts = [
|
||||||
|
self.character.get_system_prompt(),
|
||||||
|
format_datetime_context(self.db, self.user_id),
|
||||||
|
format_memory_context(memory_snapshot),
|
||||||
|
self._optional_domain("fitness", user_query, lambda: fitness_snapshot, format_fitness_context),
|
||||||
|
self._optional_domain("shopping", user_query, lambda: shopping_snapshot, format_shopping_context),
|
||||||
|
self._optional_domain("reminders", user_query, lambda: reminders_snapshot, format_reminders_context),
|
||||||
|
format_weather_snapshot(),
|
||||||
|
format_pomodoro_context(status),
|
||||||
|
self._optional_domain("projects", user_query, lambda: projects_snapshot, format_projects_context),
|
||||||
|
]
|
||||||
|
return "\n\n".join(part for part in parts if part.strip())
|
||||||
|
|
||||||
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
|
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]
|
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"), "")
|
last_user = next((m.content for m in reversed(all_chat) if m.role == "user"), "")
|
||||||
|
system_prompt = self._build_system_prompt(session.id, user_query=last_user)
|
||||||
if last_user:
|
if last_user:
|
||||||
memory_snapshot = get_memory_snapshot(self.db, session.id)
|
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session.id, query=last_user)
|
||||||
identity_hint = format_identity_hint(memory_snapshot, last_user)
|
identity_hint = format_identity_hint(memory_snapshot, last_user)
|
||||||
if identity_hint:
|
if identity_hint:
|
||||||
system_prompt += f"\n\n{identity_hint}"
|
system_prompt += f"\n\n{identity_hint}"
|
||||||
@@ -222,7 +300,7 @@ class ChatService:
|
|||||||
fn = tool_call["function"]
|
fn = tool_call["function"]
|
||||||
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||||
tool_result = await execute_tool(
|
tool_result = await execute_tool(
|
||||||
self.db, fn["name"], args, session_id=session_id
|
self.db, fn["name"], args, session_id=session_id, user_id=self.user_id
|
||||||
)
|
)
|
||||||
messages.append(
|
messages.append(
|
||||||
{
|
{
|
||||||
@@ -256,6 +334,24 @@ class ChatService:
|
|||||||
|
|
||||||
return content, notices, pomodoro_events
|
return content, notices, pomodoro_events
|
||||||
|
|
||||||
|
def context_preview(self, session_id: int, query: str | None = None) -> dict[str, Any]:
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
return {"ok": False, "error": "Session not found"}
|
||||||
|
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
|
||||||
|
last_user = query or next((m.content for m in reversed(all_chat) if m.role == "user"), "")
|
||||||
|
system_prompt = self._build_system_prompt(session_id, user_query=last_user)
|
||||||
|
memory_snapshot = get_memory_snapshot(self.db, self.user_id, session_id, query=last_user)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"session_id": session_id,
|
||||||
|
"query": last_user,
|
||||||
|
"system_prompt_chars": len(system_prompt),
|
||||||
|
"memory_facts": len(memory_snapshot.get("facts") or []),
|
||||||
|
"memory_total_facts": memory_snapshot.get("total_facts", 0),
|
||||||
|
"system_prompt_preview": system_prompt[:4000],
|
||||||
|
}
|
||||||
|
|
||||||
async def stream_response(
|
async def stream_response(
|
||||||
self,
|
self,
|
||||||
session_id: int,
|
session_id: int,
|
||||||
@@ -272,7 +368,7 @@ class ChatService:
|
|||||||
self._save_message(session_id, "user", user_text)
|
self._save_message(session_id, "user", user_text)
|
||||||
yield self._sse("status", {"phase": "preparing"})
|
yield self._sse("status", {"phase": "preparing"})
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
messages = await asyncio.to_thread(_build_messages_for_session, session_id)
|
messages = await asyncio.to_thread(_build_messages_for_session, session_id, self.user_id)
|
||||||
prepare_sec = time.monotonic() - t0
|
prepare_sec = time.monotonic() - t0
|
||||||
if not messages:
|
if not messages:
|
||||||
yield self._sse("error", {"message": "Session not found"})
|
yield self._sse("error", {"message": "Session not found"})
|
||||||
@@ -365,7 +461,7 @@ class ChatService:
|
|||||||
fn = tool_call["function"]
|
fn = tool_call["function"]
|
||||||
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||||
result = await execute_tool(
|
result = await execute_tool(
|
||||||
self.db, fn["name"], args, session_id=session_id
|
self.db, fn["name"], args, session_id=session_id, user_id=self.user_id
|
||||||
)
|
)
|
||||||
tools_executed += 1
|
tools_executed += 1
|
||||||
tool_message = {
|
tool_message = {
|
||||||
@@ -457,7 +553,7 @@ class ChatService:
|
|||||||
yield self._sse("done", {})
|
yield self._sse("done", {})
|
||||||
if get_settings().memory_auto_extract:
|
if get_settings().memory_auto_extract:
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
_extract_memory_background(session_id, user_text, final_content)
|
_extract_memory_background(session_id, self.user_id, user_text, final_content)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -29,6 +29,17 @@ class Settings(BaseSettings):
|
|||||||
system_prompt_path: str = "./prompts/assistant.md"
|
system_prompt_path: str = "./prompts/assistant.md"
|
||||||
memory_auto_extract: bool = True
|
memory_auto_extract: bool = True
|
||||||
|
|
||||||
|
default_user_username: str = "owner"
|
||||||
|
default_user_display_name: str = ""
|
||||||
|
default_api_token: str = ""
|
||||||
|
auth_required: bool = True
|
||||||
|
|
||||||
|
qdrant_url: str = "http://qdrant:6333"
|
||||||
|
embedding_model: str = "openai/text-embedding-3-small"
|
||||||
|
rag_enabled: bool = False
|
||||||
|
rag_top_k: int = 8
|
||||||
|
memory_facts_in_context: int = 8
|
||||||
|
|
||||||
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
|
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
|
||||||
taiga_base_url: str = "http://host.docker.internal:9000"
|
taiga_base_url: str = "http://host.docker.internal:9000"
|
||||||
taiga_username: str = ""
|
taiga_username: str = ""
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
+100
-3
@@ -1,15 +1,40 @@
|
|||||||
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 User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
api_token_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterCard(Base):
|
||||||
|
__tablename__ = "character_cards"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
|
||||||
|
)
|
||||||
|
card_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChatSession(Base):
|
class ChatSession(Base):
|
||||||
__tablename__ = "chat_sessions"
|
__tablename__ = "chat_sessions"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
|
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
@@ -40,6 +65,7 @@ class PomodoroCycle(Base):
|
|||||||
__tablename__ = "pomodoro_cycles"
|
__tablename__ = "pomodoro_cycles"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||||
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
|
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
|
||||||
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
|
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
|
||||||
@@ -57,6 +83,7 @@ class PomodoroSession(Base):
|
|||||||
__tablename__ = "pomodoro_sessions"
|
__tablename__ = "pomodoro_sessions"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="idle")
|
status: Mapped[str] = mapped_column(String(32), default="idle")
|
||||||
phase: Mapped[str] = mapped_column(String(32), default="work")
|
phase: Mapped[str] = mapped_column(String(32), default="work")
|
||||||
duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||||
@@ -83,9 +110,11 @@ class TaigaProject(Base):
|
|||||||
|
|
||||||
class ProjectBinding(Base):
|
class ProjectBinding(Base):
|
||||||
__tablename__ = "project_bindings"
|
__tablename__ = "project_bindings"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "taiga_slug", name="uq_project_bindings_user_slug"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||||
default_branch: Mapped[str] = mapped_column(String(64), default="main")
|
default_branch: Mapped[str] = mapped_column(String(64), default="main")
|
||||||
@@ -98,6 +127,7 @@ class UserProfile(Base):
|
|||||||
__tablename__ = "user_profile"
|
__tablename__ = "user_profile"
|
||||||
|
|
||||||
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)
|
||||||
data_json: Mapped[str] = mapped_column(Text, default="{}")
|
data_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
@@ -108,6 +138,7 @@ class MemoryFact(Base):
|
|||||||
__tablename__ = "memory_facts"
|
__tablename__ = "memory_facts"
|
||||||
|
|
||||||
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)
|
||||||
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
|
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
|
||||||
content: Mapped[str] = mapped_column(Text)
|
content: Mapped[str] = mapped_column(Text)
|
||||||
source: Mapped[str] = mapped_column(String(32), default="user")
|
source: Mapped[str] = mapped_column(String(32), default="user")
|
||||||
@@ -140,6 +171,7 @@ class FitnessProfile(Base):
|
|||||||
__tablename__ = "fitness_profiles"
|
__tablename__ = "fitness_profiles"
|
||||||
|
|
||||||
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)
|
||||||
sex: Mapped[str] = mapped_column(String(16), default="male")
|
sex: Mapped[str] = mapped_column(String(16), default="male")
|
||||||
age: Mapped[int] = mapped_column(Integer, default=30)
|
age: Mapped[int] = mapped_column(Integer, default=30)
|
||||||
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
|
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
|
||||||
@@ -148,6 +180,8 @@ class FitnessProfile(Base):
|
|||||||
goal: Mapped[str] = mapped_column(String(32), default="maintain")
|
goal: Mapped[str] = mapped_column(String(32), default="maintain")
|
||||||
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
|
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
baseline_steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
baseline_workout_kcal: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
|
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
|
||||||
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
|
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
|
||||||
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
|
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
|
||||||
@@ -162,11 +196,18 @@ class BodyMetric(Base):
|
|||||||
__tablename__ = "body_metrics"
|
__tablename__ = "body_metrics"
|
||||||
|
|
||||||
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)
|
||||||
recorded_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())
|
||||||
weight_kg: Mapped[float] = mapped_column(Float)
|
weight_kg: Mapped[float] = mapped_column(Float)
|
||||||
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
body_fat_method: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
neck_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
hip_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
whr: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
lbm_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
ffmi: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
notes: Mapped[str] = mapped_column(Text, default="")
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
@@ -174,6 +215,7 @@ class FoodLog(Base):
|
|||||||
__tablename__ = "food_logs"
|
__tablename__ = "food_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())
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
|
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
|
||||||
description: Mapped[str] = mapped_column(Text, default="")
|
description: Mapped[str] = mapped_column(Text, default="")
|
||||||
@@ -185,10 +227,23 @@ class FoodLog(Base):
|
|||||||
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
|
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class StepLog(Base):
|
||||||
|
__tablename__ = "step_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
steps: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), default="manual")
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
class WaterLog(Base):
|
class WaterLog(Base):
|
||||||
__tablename__ = "water_logs"
|
__tablename__ = "water_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())
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
amount_ml: Mapped[int] = mapped_column(Integer)
|
amount_ml: Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
@@ -197,10 +252,14 @@ class WorkoutLog(Base):
|
|||||||
__tablename__ = "workout_logs"
|
__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())
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
|
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
|
||||||
notes: Mapped[str] = mapped_column(Text, default="")
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
total_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
|
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||||
|
|
||||||
|
|
||||||
@@ -208,6 +267,7 @@ class FitnessReminder(Base):
|
|||||||
__tablename__ = "fitness_reminders"
|
__tablename__ = "fitness_reminders"
|
||||||
|
|
||||||
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)
|
||||||
kind: Mapped[str] = mapped_column(String(32))
|
kind: Mapped[str] = mapped_column(String(32))
|
||||||
hour: Mapped[int] = mapped_column(Integer, default=12)
|
hour: Mapped[int] = mapped_column(Integer, default=12)
|
||||||
minute: Mapped[int] = mapped_column(Integer, default=0)
|
minute: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
@@ -218,9 +278,11 @@ class FitnessReminder(Base):
|
|||||||
|
|
||||||
class ShoppingList(Base):
|
class ShoppingList(Base):
|
||||||
__tablename__ = "shopping_lists"
|
__tablename__ = "shopping_lists"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_shopping_lists_user_name"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
@@ -255,6 +317,7 @@ class Reminder(Base):
|
|||||||
__tablename__ = "reminders"
|
__tablename__ = "reminders"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
title: Mapped[str] = mapped_column(String(255))
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
notes: Mapped[str] = mapped_column(Text, default="")
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
@@ -284,6 +347,7 @@ class WorkItem(Base):
|
|||||||
__tablename__ = "work_items"
|
__tablename__ = "work_items"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
taiga_project_id: Mapped[int] = mapped_column(Integer)
|
taiga_project_id: Mapped[int] = mapped_column(Integer)
|
||||||
taiga_story_id: Mapped[int] = mapped_column(Integer)
|
taiga_story_id: Mapped[int] = mapped_column(Integer)
|
||||||
@@ -297,3 +361,36 @@ class WorkItem(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(32), default="open")
|
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Document(Base):
|
||||||
|
__tablename__ = "documents"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
filename: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
|
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks: Mapped[list["DocumentChunk"]] = relationship(
|
||||||
|
back_populates="document",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="DocumentChunk.chunk_index",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentChunk(Base):
|
||||||
|
__tablename__ = "document_chunks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
document_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("documents.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
chunk_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
content: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
document: Mapped["Document"] = relationship(back_populates="chunks")
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSession(Base):
|
||||||
|
__tablename__ = "chat_sessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
messages: Mapped[list["Message"]] = relationship(
|
||||||
|
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(32))
|
||||||
|
content: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
session: Mapped["ChatSession"] = relationship(back_populates="messages")
|
||||||
|
|
||||||
|
|
||||||
|
class PomodoroCycle(Base):
|
||||||
|
__tablename__ = "pomodoro_cycles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||||
|
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
|
||||||
|
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
|
||||||
|
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
|
||||||
|
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PomodoroSession(Base):
|
||||||
|
__tablename__ = "pomodoro_sessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="idle")
|
||||||
|
phase: Mapped[str] = mapped_column(String(32), default="work")
|
||||||
|
duration_min: Mapped[int] = mapped_column(Integer, default=25)
|
||||||
|
task_note: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class TaigaProject(Base):
|
||||||
|
__tablename__ = "taiga_projects"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectBinding(Base):
|
||||||
|
__tablename__ = "project_bindings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
default_branch: Mapped[str] = mapped_column(String(64), default="main")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(Base):
|
||||||
|
__tablename__ = "user_profile"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
data_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryFact(Base):
|
||||||
|
__tablename__ = "memory_facts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), default="user")
|
||||||
|
session_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
importance: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSummary(Base):
|
||||||
|
__tablename__ = "session_summaries"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
|
||||||
|
)
|
||||||
|
summary: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FitnessProfile(Base):
|
||||||
|
__tablename__ = "fitness_profiles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
sex: Mapped[str] = mapped_column(String(16), default="male")
|
||||||
|
age: Mapped[int] = mapped_column(Integer, default=30)
|
||||||
|
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
|
||||||
|
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
|
||||||
|
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
|
||||||
|
goal: Mapped[str] = mapped_column(String(32), default="maintain")
|
||||||
|
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
|
||||||
|
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
|
||||||
|
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
|
||||||
|
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
|
||||||
|
water_l: Mapped[float] = mapped_column(Float, default=2.5)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BodyMetric(Base):
|
||||||
|
__tablename__ = "body_metrics"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
weight_kg: Mapped[float] = mapped_column(Float)
|
||||||
|
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class FoodLog(Base):
|
||||||
|
__tablename__ = "food_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
|
||||||
|
description: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
calories: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
protein_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
fat_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
carbs_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), default="llm")
|
||||||
|
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class WaterLog(Base):
|
||||||
|
__tablename__ = "water_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
amount_ml: Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkoutLog(Base):
|
||||||
|
__tablename__ = "workout_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||||
|
|
||||||
|
|
||||||
|
class FitnessReminder(Base):
|
||||||
|
__tablename__ = "fitness_reminders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(32))
|
||||||
|
hour: Mapped[int] = mapped_column(Integer, default=12)
|
||||||
|
minute: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingList(Base):
|
||||||
|
__tablename__ = "shopping_lists"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
items: Mapped[list["ShoppingListItem"]] = relationship(
|
||||||
|
back_populates="shopping_list",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingListItem(Base):
|
||||||
|
__tablename__ = "shopping_list_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
list_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
text: Mapped[str] = mapped_column(String(500))
|
||||||
|
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
unit: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
|
class Reminder(Base):
|
||||||
|
__tablename__ = "reminders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
recurrence: Mapped[str] = mapped_column(String(16), default="none")
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantState(Base):
|
||||||
|
__tablename__ = "assistant_state"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkItem(Base):
|
||||||
|
__tablename__ = "work_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
taiga_project_id: Mapped[int] = mapped_column(Integer)
|
||||||
|
taiga_story_id: Mapped[int] = mapped_column(Integer)
|
||||||
|
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
|
||||||
|
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
raw_text: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
title: Mapped[str] = mapped_column(String(500), default="")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -5,8 +5,8 @@ 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:
|
||||||
@@ -17,7 +17,7 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
|
|||||||
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')} л"
|
||||||
)
|
)
|
||||||
@@ -30,9 +30,20 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
|
|||||||
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 {}
|
||||||
|
targets_base = today.get("targets_base") or {}
|
||||||
|
activity = today.get("activity") or {}
|
||||||
|
steps_total = today.get("steps_total") or 0
|
||||||
water_l = totals.get("water_ml", 0) / 1000
|
water_l = totals.get("water_ml", 0) / 1000
|
||||||
water_target = targets.get("water_ml", 2500) / 1000
|
water_target = targets.get("water_ml", 2500) / 1000
|
||||||
|
|
||||||
|
if profile and (activity.get("total_bonus_kcal") or steps_total):
|
||||||
|
lines.append(
|
||||||
|
f"Активность: шаги {steps_total} (база {activity.get('steps_baseline', 0)}), "
|
||||||
|
f"бонус +{activity.get('total_bonus_kcal', 0)} ккал"
|
||||||
|
)
|
||||||
|
base_cal = targets_base.get("calories", profile.get("calorie_target"))
|
||||||
|
lines.append(f"Эффективная цель ккал: {base_cal} → {targets.get('calories', base_cal)}")
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(
|
lines.append(
|
||||||
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
|
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
|
||||||
@@ -46,10 +57,38 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
|
|||||||
if workouts:
|
if workouts:
|
||||||
lines.append(f"Тренировок сегодня: {len(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("")
|
||||||
lines.append(
|
lines.append(
|
||||||
"Правила: log_meal, log_water, log_weight, log_workout, get_fitness_summary (date/days_ago), get_fitness_history, "
|
"Правила: 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. "
|
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
|
||||||
"Еда — оценка LLM (≈), пользователь может уточнить."
|
"Еда — оценка LLM (≈), пользователь может уточнить."
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return chr(10).join(lines)
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ 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.chat.notice_inbox import post_notice_to_latest_chat
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db.base import SessionLocal
|
from app.db.models import FitnessReminder, User
|
||||||
from app.db.models import ChatSession, FitnessReminder, Message
|
|
||||||
from app.fitness.service import FitnessService
|
from app.fitness.service import FitnessService
|
||||||
|
|
||||||
KIND_LABELS = {
|
KIND_LABELS = {
|
||||||
@@ -16,23 +16,6 @@ KIND_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _post_fitness_notice(content: str) -> None:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
session = db.scalar(
|
|
||||||
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
|
|
||||||
)
|
|
||||||
if not session:
|
|
||||||
session = ChatSession(title="Фитнес")
|
|
||||||
db.add(session)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(session)
|
|
||||||
db.add(Message(session_id=session.id, role="notice", content=content))
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_notice(kind: str, summary: dict) -> str:
|
def _build_notice(kind: str, summary: dict) -> str:
|
||||||
label = KIND_LABELS.get(kind, kind)
|
label = KIND_LABELS.get(kind, kind)
|
||||||
totals = summary.get("totals") or {}
|
totals = summary.get("totals") or {}
|
||||||
@@ -62,17 +45,17 @@ def _build_notice(kind: str, summary: dict) -> str:
|
|||||||
return f"💪 **{label}** · напоминание"
|
return f"💪 **{label}** · напоминание"
|
||||||
|
|
||||||
|
|
||||||
def check_reminders(db: Session) -> list[str]:
|
def _check_user_reminders(db: Session, user_id: int) -> list[str]:
|
||||||
if not get_settings().fitness_reminders_enabled:
|
|
||||||
return []
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
service = FitnessService(db)
|
service = FitnessService(db, user_id)
|
||||||
summary = service.get_daily_summary()
|
summary = service.get_daily_summary()
|
||||||
fired: list[str] = []
|
fired: list[str] = []
|
||||||
|
|
||||||
reminders = db.scalars(
|
reminders = db.scalars(
|
||||||
select(FitnessReminder).where(FitnessReminder.enabled.is_(True))
|
select(FitnessReminder).where(
|
||||||
|
FitnessReminder.user_id == user_id,
|
||||||
|
FitnessReminder.enabled.is_(True),
|
||||||
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
for rem in reminders:
|
for rem in reminders:
|
||||||
@@ -107,8 +90,22 @@ def check_reminders(db: Session) -> list[str]:
|
|||||||
fired.append(notice)
|
fired.append(notice)
|
||||||
|
|
||||||
if fired:
|
if fired:
|
||||||
db.commit()
|
|
||||||
for notice in fired:
|
for notice in fired:
|
||||||
_post_fitness_notice(notice)
|
post_notice_to_latest_chat(notice, user_id)
|
||||||
|
|
||||||
return fired
|
return fired
|
||||||
|
|
||||||
|
|
||||||
|
def check_reminders(db: Session) -> list[str]:
|
||||||
|
if not get_settings().fitness_reminders_enabled:
|
||||||
|
return []
|
||||||
|
|
||||||
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||||
|
all_fired: list[str] = []
|
||||||
|
for user in users:
|
||||||
|
all_fired.extend(_check_user_reminders(db, user.id))
|
||||||
|
|
||||||
|
if all_fired:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return all_fired
|
||||||
|
|||||||
+318
-69
@@ -2,7 +2,7 @@ import json
|
|||||||
from datetime import date, datetime, time, timedelta, timezone
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.models import (
|
from app.db.models import (
|
||||||
@@ -10,10 +10,18 @@ from app.db.models import (
|
|||||||
FitnessProfile,
|
FitnessProfile,
|
||||||
FitnessReminder,
|
FitnessReminder,
|
||||||
FoodLog,
|
FoodLog,
|
||||||
|
StepLog,
|
||||||
WaterLog,
|
WaterLog,
|
||||||
WorkoutLog,
|
WorkoutLog,
|
||||||
)
|
)
|
||||||
|
from app.fitness.activity_budget import (
|
||||||
|
build_base_targets,
|
||||||
|
compute_activity_bonus,
|
||||||
|
estimate_workout_active_kcal,
|
||||||
|
scale_targets,
|
||||||
|
)
|
||||||
from app.fitness.calculators import compute_targets, one_rep_max
|
from app.fitness.calculators import compute_targets, one_rep_max
|
||||||
|
from app.fitness.body_composition import compute_body_composition
|
||||||
|
|
||||||
DEFAULT_REMINDERS = [
|
DEFAULT_REMINDERS = [
|
||||||
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
|
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
|
||||||
@@ -24,11 +32,12 @@ DEFAULT_REMINDERS = [
|
|||||||
|
|
||||||
|
|
||||||
class FitnessService:
|
class FitnessService:
|
||||||
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_profile_row(self) -> FitnessProfile | None:
|
def _get_profile_row(self) -> FitnessProfile | None:
|
||||||
return self.db.scalar(select(FitnessProfile).limit(1))
|
return self.db.scalar(select(FitnessProfile).where(FitnessProfile.user_id == self.user_id).limit(1))
|
||||||
|
|
||||||
def get_profile(self) -> dict[str, Any] | None:
|
def get_profile(self) -> dict[str, Any] | None:
|
||||||
row = self._get_profile_row()
|
row = self._get_profile_row()
|
||||||
@@ -56,6 +65,8 @@ class FitnessService:
|
|||||||
"goal": row.goal,
|
"goal": row.goal,
|
||||||
"target_weight_kg": row.target_weight_kg,
|
"target_weight_kg": row.target_weight_kg,
|
||||||
"weekly_workouts": row.weekly_workouts,
|
"weekly_workouts": row.weekly_workouts,
|
||||||
|
"baseline_steps": row.baseline_steps,
|
||||||
|
"baseline_workout_kcal": row.baseline_workout_kcal,
|
||||||
"calorie_target": row.calorie_target,
|
"calorie_target": row.calorie_target,
|
||||||
"protein_g": row.protein_g,
|
"protein_g": row.protein_g,
|
||||||
"fat_g": row.fat_g,
|
"fat_g": row.fat_g,
|
||||||
@@ -69,13 +80,14 @@ class FitnessService:
|
|||||||
row = self._get_profile_row()
|
row = self._get_profile_row()
|
||||||
is_new = row is None
|
is_new = row is None
|
||||||
if is_new:
|
if is_new:
|
||||||
row = FitnessProfile()
|
row = FitnessProfile(user_id=self.user_id)
|
||||||
self.db.add(row)
|
self.db.add(row)
|
||||||
self.db.flush()
|
self.db.flush()
|
||||||
|
|
||||||
for key in (
|
for key in (
|
||||||
"sex", "age", "height_cm", "weight_kg", "activity_level",
|
"sex", "age", "height_cm", "weight_kg", "activity_level",
|
||||||
"goal", "target_weight_kg", "weekly_workouts",
|
"goal", "target_weight_kg", "weekly_workouts",
|
||||||
|
"baseline_steps", "baseline_workout_kcal",
|
||||||
):
|
):
|
||||||
if key in updates and updates[key] is not None:
|
if key in updates and updates[key] is not None:
|
||||||
setattr(row, key, updates[key])
|
setattr(row, key, updates[key])
|
||||||
@@ -105,15 +117,93 @@ class FitnessService:
|
|||||||
return {"ok": True, "profile": self._profile_to_dict(row)}
|
return {"ok": True, "profile": self._profile_to_dict(row)}
|
||||||
|
|
||||||
def _ensure_default_reminders(self) -> None:
|
def _ensure_default_reminders(self) -> None:
|
||||||
existing = self.db.scalars(select(FitnessReminder)).all()
|
existing = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id)).all()
|
||||||
if existing:
|
if existing:
|
||||||
return
|
return
|
||||||
for item in DEFAULT_REMINDERS:
|
for item in DEFAULT_REMINDERS:
|
||||||
self.db.add(FitnessReminder(**item))
|
self.db.add(FitnessReminder(user_id=self.user_id, **item))
|
||||||
|
|
||||||
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
|
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
return compute_targets(params)
|
return compute_targets(params)
|
||||||
|
|
||||||
|
def calc_body_composition(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = self.get_profile() or {}
|
||||||
|
sex = params.get("sex") or profile.get("sex") or "male"
|
||||||
|
height_cm = float(params.get("height_cm") or profile.get("height_cm") or 170)
|
||||||
|
weight_kg = float(params.get("weight_kg") or profile.get("weight_kg") or 70)
|
||||||
|
return compute_body_composition(
|
||||||
|
sex=str(sex),
|
||||||
|
height_cm=height_cm,
|
||||||
|
weight_kg=weight_kg,
|
||||||
|
neck_cm=params.get("neck_cm"),
|
||||||
|
waist_cm=params.get("waist_cm"),
|
||||||
|
hip_cm=params.get("hip_cm"),
|
||||||
|
body_fat_pct=params.get("body_fat_pct"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_latest_body_composition(self) -> dict[str, Any] | None:
|
||||||
|
rows = self.list_body_metrics(limit=1)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _body_metric_to_dict(row: BodyMetric) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"body_fat_pct": row.body_fat_pct,
|
||||||
|
"body_fat_method": row.body_fat_method,
|
||||||
|
"chest_cm": row.chest_cm,
|
||||||
|
"waist_cm": row.waist_cm,
|
||||||
|
"neck_cm": row.neck_cm,
|
||||||
|
"hip_cm": row.hip_cm,
|
||||||
|
"whr": row.whr,
|
||||||
|
"lbm_kg": row.lbm_kg,
|
||||||
|
"ffmi": row.ffmi,
|
||||||
|
"notes": row.notes,
|
||||||
|
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_logged_at(
|
||||||
|
*,
|
||||||
|
logged_at: datetime | str | None = None,
|
||||||
|
day: date | None = None,
|
||||||
|
days_ago: int | None = None,
|
||||||
|
) -> datetime:
|
||||||
|
if logged_at is not None:
|
||||||
|
if isinstance(logged_at, str):
|
||||||
|
dt = datetime.fromisoformat(logged_at.replace("Z", "+00:00"))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
if logged_at.tzinfo is None:
|
||||||
|
return logged_at.replace(tzinfo=timezone.utc)
|
||||||
|
return logged_at
|
||||||
|
|
||||||
|
target_day = day
|
||||||
|
if target_day is None and days_ago is not None:
|
||||||
|
target_day = datetime.now(timezone.utc).date() - timedelta(days=int(days_ago))
|
||||||
|
|
||||||
|
if target_day is None:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
return datetime.combine(target_day, time(12, 0), tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
def _profile_for_budget(self, profile: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if profile:
|
||||||
|
return profile
|
||||||
|
return {
|
||||||
|
"calorie_target": 2000,
|
||||||
|
"protein_g": 140,
|
||||||
|
"fat_g": 65,
|
||||||
|
"carbs_g": 200,
|
||||||
|
"water_l": 2.5,
|
||||||
|
"weight_kg": 70,
|
||||||
|
"activity_level": "moderate",
|
||||||
|
"weekly_workouts": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
|
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
|
||||||
d = day or datetime.now(timezone.utc).date()
|
d = day or datetime.now(timezone.utc).date()
|
||||||
start = datetime.combine(d, time.min, tzinfo=timezone.utc)
|
start = datetime.combine(d, time.min, tzinfo=timezone.utc)
|
||||||
@@ -122,23 +212,32 @@ class FitnessService:
|
|||||||
|
|
||||||
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
|
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
|
||||||
start, end = self._day_bounds(day)
|
start, end = self._day_bounds(day)
|
||||||
profile = self.get_profile()
|
profile_row = self.get_profile()
|
||||||
|
profile = self._profile_for_budget(profile_row)
|
||||||
|
|
||||||
foods = self.db.scalars(
|
foods = self.db.scalars(
|
||||||
select(FoodLog)
|
select(FoodLog)
|
||||||
.where(FoodLog.logged_at >= start, FoodLog.logged_at <= end)
|
.where(FoodLog.user_id == self.user_id, FoodLog.logged_at >= start, FoodLog.logged_at <= end)
|
||||||
.order_by(FoodLog.logged_at)
|
.order_by(FoodLog.logged_at)
|
||||||
).all()
|
).all()
|
||||||
waters = self.db.scalars(
|
waters = self.db.scalars(
|
||||||
select(WaterLog)
|
select(WaterLog)
|
||||||
.where(WaterLog.logged_at >= start, WaterLog.logged_at <= end)
|
.where(WaterLog.user_id == self.user_id, WaterLog.logged_at >= start, WaterLog.logged_at <= end)
|
||||||
.order_by(WaterLog.logged_at)
|
.order_by(WaterLog.logged_at)
|
||||||
).all()
|
).all()
|
||||||
workouts = self.db.scalars(
|
workouts_rows = self.db.scalars(
|
||||||
select(WorkoutLog)
|
select(WorkoutLog)
|
||||||
.where(WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
|
.where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
|
||||||
.order_by(WorkoutLog.logged_at)
|
.order_by(WorkoutLog.logged_at)
|
||||||
).all()
|
).all()
|
||||||
|
steps_rows = self.db.scalars(
|
||||||
|
select(StepLog)
|
||||||
|
.where(StepLog.user_id == self.user_id, StepLog.logged_at >= start, StepLog.logged_at <= end)
|
||||||
|
.order_by(StepLog.logged_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
workouts = [self._workout_to_dict(w) for w in workouts_rows]
|
||||||
|
steps_total = sum(s.steps for s in steps_rows)
|
||||||
|
|
||||||
totals = {
|
totals = {
|
||||||
"calories": sum(f.calories for f in foods),
|
"calories": sum(f.calories for f in foods),
|
||||||
@@ -146,30 +245,32 @@ class FitnessService:
|
|||||||
"fat_g": sum(f.fat_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),
|
"carbs_g": sum(f.carbs_g for f in foods),
|
||||||
"water_ml": sum(w.amount_ml for w in waters),
|
"water_ml": sum(w.amount_ml for w in waters),
|
||||||
|
"steps": steps_total,
|
||||||
}
|
}
|
||||||
|
|
||||||
targets = profile or {
|
base_targets = build_base_targets(profile)
|
||||||
"calorie_target": 2000,
|
activity = compute_activity_bonus(
|
||||||
"protein_g": 140,
|
profile,
|
||||||
"fat_g": 65,
|
steps_total=steps_total,
|
||||||
"carbs_g": 200,
|
workouts=workouts,
|
||||||
"water_l": 2.5,
|
)
|
||||||
}
|
effective_targets, targets_base = scale_targets(
|
||||||
|
base_targets,
|
||||||
|
activity.total_bonus_kcal,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
|
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
|
||||||
"profile_configured": profile is not None,
|
"profile_configured": profile_row is not None,
|
||||||
"totals": totals,
|
"totals": totals,
|
||||||
"targets": {
|
"targets": effective_targets,
|
||||||
"calories": targets.get("calorie_target", 2000),
|
"targets_base": targets_base,
|
||||||
"protein_g": targets.get("protein_g", 140),
|
"activity": activity.to_dict(),
|
||||||
"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],
|
"meals": [self._food_to_dict(f) for f in foods],
|
||||||
"water": [self._water_to_dict(w) for w in waters],
|
"water": [self._water_to_dict(w) for w in waters],
|
||||||
"workouts": [self._workout_to_dict(w) for w in workouts],
|
"workouts": workouts,
|
||||||
|
"steps": [self._step_to_dict(s) for s in steps_rows],
|
||||||
|
"steps_total": steps_total,
|
||||||
}
|
}
|
||||||
|
|
||||||
def log_meal(
|
def log_meal(
|
||||||
@@ -185,6 +286,7 @@ class FitnessService:
|
|||||||
estimated: bool = True,
|
estimated: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
row = FoodLog(
|
row = FoodLog(
|
||||||
|
user_id=self.user_id,
|
||||||
meal_type=meal_type[:32],
|
meal_type=meal_type[:32],
|
||||||
description=description[:2000],
|
description=description[:2000],
|
||||||
calories=calories,
|
calories=calories,
|
||||||
@@ -200,12 +302,40 @@ class FitnessService:
|
|||||||
return {"ok": True, "meal": self._food_to_dict(row)}
|
return {"ok": True, "meal": self._food_to_dict(row)}
|
||||||
|
|
||||||
def log_water(self, amount_ml: int) -> dict[str, Any]:
|
def log_water(self, amount_ml: int) -> dict[str, Any]:
|
||||||
row = WaterLog(amount_ml=max(0, amount_ml))
|
row = WaterLog(user_id=self.user_id, amount_ml=max(0, amount_ml))
|
||||||
self.db.add(row)
|
self.db.add(row)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(row)
|
self.db.refresh(row)
|
||||||
return {"ok": True, "water": self._water_to_dict(row)}
|
return {"ok": True, "water": self._water_to_dict(row)}
|
||||||
|
|
||||||
|
def log_steps(
|
||||||
|
self,
|
||||||
|
steps: int,
|
||||||
|
*,
|
||||||
|
active_calories: float | None = None,
|
||||||
|
logged_at: datetime | str | None = None,
|
||||||
|
day: date | None = None,
|
||||||
|
days_ago: int | None = None,
|
||||||
|
notes: str = "",
|
||||||
|
source: str = "manual",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = StepLog(
|
||||||
|
user_id=self.user_id,
|
||||||
|
steps=max(0, int(steps)),
|
||||||
|
active_calories=active_calories,
|
||||||
|
notes=notes[:2000],
|
||||||
|
source=source[:32],
|
||||||
|
logged_at=self._resolve_logged_at(
|
||||||
|
logged_at=logged_at,
|
||||||
|
day=day,
|
||||||
|
days_ago=days_ago,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "step_log": self._step_to_dict(row)}
|
||||||
|
|
||||||
def log_weight(
|
def log_weight(
|
||||||
self,
|
self,
|
||||||
weight_kg: float,
|
weight_kg: float,
|
||||||
@@ -213,42 +343,78 @@ class FitnessService:
|
|||||||
body_fat_pct: float | None = None,
|
body_fat_pct: float | None = None,
|
||||||
chest_cm: float | None = None,
|
chest_cm: float | None = None,
|
||||||
waist_cm: float | None = None,
|
waist_cm: float | None = None,
|
||||||
|
neck_cm: float | None = None,
|
||||||
|
hip_cm: float | None = None,
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
|
recorded_at: datetime | str | None = None,
|
||||||
|
day: date | None = None,
|
||||||
|
days_ago: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
row = BodyMetric(
|
profile = self.get_profile() or {}
|
||||||
|
sex = profile.get("sex") or "male"
|
||||||
|
height_cm = float(profile.get("height_cm") or 170)
|
||||||
|
|
||||||
|
computed = compute_body_composition(
|
||||||
|
sex=str(sex),
|
||||||
|
height_cm=height_cm,
|
||||||
weight_kg=weight_kg,
|
weight_kg=weight_kg,
|
||||||
|
neck_cm=neck_cm,
|
||||||
|
waist_cm=waist_cm,
|
||||||
|
hip_cm=hip_cm,
|
||||||
body_fat_pct=body_fat_pct,
|
body_fat_pct=body_fat_pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = BodyMetric(
|
||||||
|
user_id=self.user_id,
|
||||||
|
weight_kg=weight_kg,
|
||||||
|
body_fat_pct=computed.get("body_fat_pct"),
|
||||||
|
body_fat_method=computed.get("body_fat_method"),
|
||||||
chest_cm=chest_cm,
|
chest_cm=chest_cm,
|
||||||
waist_cm=waist_cm,
|
waist_cm=waist_cm,
|
||||||
|
neck_cm=neck_cm,
|
||||||
|
hip_cm=hip_cm,
|
||||||
|
whr=computed.get("whr"),
|
||||||
|
lbm_kg=computed.get("lbm_kg"),
|
||||||
|
ffmi=computed.get("ffmi"),
|
||||||
notes=notes[:1000],
|
notes=notes[:1000],
|
||||||
|
recorded_at=self._resolve_logged_at(
|
||||||
|
logged_at=recorded_at,
|
||||||
|
day=day,
|
||||||
|
days_ago=days_ago,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.db.add(row)
|
self.db.add(row)
|
||||||
profile = self._get_profile_row()
|
profile_row = self._get_profile_row()
|
||||||
if profile:
|
if profile_row:
|
||||||
profile.weight_kg = weight_kg
|
profile_row.weight_kg = weight_kg
|
||||||
targets = compute_targets(
|
targets = compute_targets(
|
||||||
{
|
{
|
||||||
"sex": profile.sex,
|
"sex": profile_row.sex,
|
||||||
"age": profile.age,
|
"age": profile_row.age,
|
||||||
"height_cm": profile.height_cm,
|
"height_cm": profile_row.height_cm,
|
||||||
"weight_kg": weight_kg,
|
"weight_kg": weight_kg,
|
||||||
"activity_level": profile.activity_level,
|
"activity_level": profile_row.activity_level,
|
||||||
"goal": profile.goal,
|
"goal": profile_row.goal,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
profile.calorie_target = targets["calorie_target"]
|
profile_row.calorie_target = targets["calorie_target"]
|
||||||
profile.protein_g = targets["protein_g"]
|
profile_row.protein_g = targets["protein_g"]
|
||||||
profile.fat_g = targets["fat_g"]
|
profile_row.fat_g = targets["fat_g"]
|
||||||
profile.carbs_g = targets["carbs_g"]
|
profile_row.carbs_g = targets["carbs_g"]
|
||||||
profile.water_l = targets["water_l"]
|
profile_row.water_l = targets["water_l"]
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(row)
|
self.db.refresh(row)
|
||||||
|
metric = self._body_metric_to_dict(row)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"metric": {
|
"metric": metric,
|
||||||
"id": row.id,
|
"computed": {
|
||||||
"weight_kg": row.weight_kg,
|
"body_fat_pct": computed.get("body_fat_pct"),
|
||||||
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
|
"body_fat_method": computed.get("body_fat_method"),
|
||||||
|
"whr": computed.get("whr"),
|
||||||
|
"lbm_kg": computed.get("lbm_kg"),
|
||||||
|
"ffmi": computed.get("ffmi"),
|
||||||
|
"warnings": computed.get("warnings") or [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,38 +425,96 @@ class FitnessService:
|
|||||||
notes: str = "",
|
notes: str = "",
|
||||||
duration_min: int | None = None,
|
duration_min: int | None = None,
|
||||||
exercises: list[dict[str, Any]] | None = None,
|
exercises: list[dict[str, Any]] | None = None,
|
||||||
|
active_calories: float | None = None,
|
||||||
|
total_calories: float | None = None,
|
||||||
|
steps: int | None = None,
|
||||||
|
logged_at: datetime | str | None = None,
|
||||||
|
day: date | None = None,
|
||||||
|
days_ago: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
row = WorkoutLog(
|
row = WorkoutLog(
|
||||||
|
user_id=self.user_id,
|
||||||
title=title[:255],
|
title=title[:255],
|
||||||
notes=notes[:2000],
|
notes=notes[:2000],
|
||||||
duration_min=duration_min,
|
duration_min=duration_min,
|
||||||
|
active_calories=active_calories,
|
||||||
|
total_calories=total_calories,
|
||||||
|
steps=steps,
|
||||||
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
|
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
|
||||||
|
logged_at=self._resolve_logged_at(
|
||||||
|
logged_at=logged_at,
|
||||||
|
day=day,
|
||||||
|
days_ago=days_ago,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.db.add(row)
|
self.db.add(row)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(row)
|
self.db.refresh(row)
|
||||||
return {"ok": True, "workout": self._workout_to_dict(row)}
|
return {"ok": True, "workout": self._workout_to_dict(row)}
|
||||||
|
|
||||||
|
def get_workout_stats(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
days: int = 7,
|
||||||
|
end_day: date | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
days = max(1, min(days, 90))
|
||||||
|
end = end_day or datetime.now(timezone.utc).date()
|
||||||
|
start = end - timedelta(days=days - 1)
|
||||||
|
start_dt, _ = self._day_bounds(start)
|
||||||
|
_, end_dt = self._day_bounds(end)
|
||||||
|
|
||||||
|
rows = self.db.scalars(
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start_dt, WorkoutLog.logged_at <= end_dt)
|
||||||
|
.order_by(WorkoutLog.logged_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
profile = self.get_profile() or {}
|
||||||
|
weekly_target = int(profile.get("weekly_workouts") or 3)
|
||||||
|
|
||||||
|
count = len(rows)
|
||||||
|
duration_min = sum(r.duration_min or 0 for r in rows)
|
||||||
|
active_kcal = round(
|
||||||
|
sum(estimate_workout_active_kcal(self._workout_to_dict(r)) for r in rows),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
days_with_workout: set[date] = set()
|
||||||
|
for row in rows:
|
||||||
|
if row.logged_at:
|
||||||
|
days_with_workout.add(row.logged_at.astimezone(timezone.utc).date())
|
||||||
|
|
||||||
|
streak = 0
|
||||||
|
cursor = end
|
||||||
|
while cursor >= start:
|
||||||
|
if cursor in days_with_workout:
|
||||||
|
streak += 1
|
||||||
|
cursor -= timedelta(days=1)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"days": days,
|
||||||
|
"start_date": start.isoformat(),
|
||||||
|
"end_date": end.isoformat(),
|
||||||
|
"count": count,
|
||||||
|
"duration_min": duration_min,
|
||||||
|
"active_kcal": active_kcal,
|
||||||
|
"weekly_target": weekly_target,
|
||||||
|
"streak": streak,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
|
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
|
||||||
rows = self.db.scalars(
|
rows = self.db.scalars(
|
||||||
select(BodyMetric).order_by(BodyMetric.recorded_at.desc()).limit(limit)
|
select(BodyMetric).where(BodyMetric.user_id == self.user_id).order_by(BodyMetric.recorded_at.desc()).limit(limit)
|
||||||
).all()
|
).all()
|
||||||
return [
|
return [self._body_metric_to_dict(r) for r in rows]
|
||||||
{
|
|
||||||
"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:
|
def delete_food_log(self, log_id: int) -> bool:
|
||||||
row = self.db.get(FoodLog, log_id)
|
row = self.db.get(FoodLog, log_id)
|
||||||
if not row:
|
if not row or row.user_id != self.user_id:
|
||||||
return False
|
return False
|
||||||
self.db.delete(row)
|
self.db.delete(row)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
@@ -298,7 +522,7 @@ class FitnessService:
|
|||||||
|
|
||||||
def delete_water_log(self, log_id: int) -> bool:
|
def delete_water_log(self, log_id: int) -> bool:
|
||||||
row = self.db.get(WaterLog, log_id)
|
row = self.db.get(WaterLog, log_id)
|
||||||
if not row:
|
if not row or row.user_id != self.user_id:
|
||||||
return False
|
return False
|
||||||
self.db.delete(row)
|
self.db.delete(row)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
@@ -306,14 +530,22 @@ class FitnessService:
|
|||||||
|
|
||||||
def delete_workout_log(self, log_id: int) -> bool:
|
def delete_workout_log(self, log_id: int) -> bool:
|
||||||
row = self.db.get(WorkoutLog, log_id)
|
row = self.db.get(WorkoutLog, log_id)
|
||||||
if not row:
|
if not row or row.user_id != self.user_id:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_step_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(StepLog, log_id)
|
||||||
|
if not row or row.user_id != self.user_id:
|
||||||
return False
|
return False
|
||||||
self.db.delete(row)
|
self.db.delete(row)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def list_reminders(self) -> list[dict[str, Any]]:
|
def list_reminders(self) -> list[dict[str, Any]]:
|
||||||
rows = self.db.scalars(select(FitnessReminder).order_by(FitnessReminder.kind)).all()
|
rows = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id).order_by(FitnessReminder.kind)).all()
|
||||||
return [self._reminder_to_dict(r) for r in rows]
|
return [self._reminder_to_dict(r) for r in rows]
|
||||||
|
|
||||||
def set_reminder(
|
def set_reminder(
|
||||||
@@ -326,10 +558,10 @@ class FitnessService:
|
|||||||
interval_hours: int | None = None,
|
interval_hours: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
row = self.db.scalar(
|
row = self.db.scalar(
|
||||||
select(FitnessReminder).where(FitnessReminder.kind == kind)
|
select(FitnessReminder).where(FitnessReminder.user_id == self.user_id, FitnessReminder.kind == kind)
|
||||||
)
|
)
|
||||||
if not row:
|
if not row:
|
||||||
row = FitnessReminder(kind=kind)
|
row = FitnessReminder(user_id=self.user_id, kind=kind)
|
||||||
self.db.add(row)
|
self.db.add(row)
|
||||||
if enabled is not None:
|
if enabled is not None:
|
||||||
row.enabled = enabled
|
row.enabled = enabled
|
||||||
@@ -351,6 +583,7 @@ class FitnessService:
|
|||||||
*,
|
*,
|
||||||
days: int = 7,
|
days: int = 7,
|
||||||
end_day: date | None = None,
|
end_day: date | None = None,
|
||||||
|
include_targets_base: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
days = max(1, min(days, 90))
|
days = max(1, min(days, 90))
|
||||||
end = end_day or datetime.now(timezone.utc).date()
|
end = end_day or datetime.now(timezone.utc).date()
|
||||||
@@ -361,9 +594,8 @@ class FitnessService:
|
|||||||
d = start + timedelta(days=offset)
|
d = start + timedelta(days=offset)
|
||||||
full = self.get_daily_summary(d)
|
full = self.get_daily_summary(d)
|
||||||
totals = full["totals"]
|
totals = full["totals"]
|
||||||
has_data = bool(full["meals"] or full["water"] or full["workouts"])
|
has_data = bool(full["meals"] or full["water"] or full["workouts"] or full["steps"])
|
||||||
summaries.append(
|
item: dict[str, Any] = {
|
||||||
{
|
|
||||||
"date": full["date"],
|
"date": full["date"],
|
||||||
"has_data": has_data,
|
"has_data": has_data,
|
||||||
"totals": totals,
|
"totals": totals,
|
||||||
@@ -371,7 +603,9 @@ class FitnessService:
|
|||||||
"meal_count": len(full["meals"]),
|
"meal_count": len(full["meals"]),
|
||||||
"workout_count": len(full["workouts"]),
|
"workout_count": len(full["workouts"]),
|
||||||
}
|
}
|
||||||
)
|
if include_targets_base:
|
||||||
|
item["targets_base"] = full.get("targets_base")
|
||||||
|
summaries.append(item)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"start_date": start.isoformat(),
|
"start_date": start.isoformat(),
|
||||||
@@ -386,6 +620,7 @@ class FitnessService:
|
|||||||
"profile": self.get_profile(),
|
"profile": self.get_profile(),
|
||||||
"today": self.get_daily_summary(today),
|
"today": self.get_daily_summary(today),
|
||||||
"history": self.get_history(days=7, end_day=today),
|
"history": self.get_history(days=7, end_day=today),
|
||||||
|
"workout_stats": self.get_workout_stats(days=7, end_day=today),
|
||||||
"body_metrics": self.list_body_metrics(limit=10),
|
"body_metrics": self.list_body_metrics(limit=10),
|
||||||
"reminders": self.list_reminders(),
|
"reminders": self.list_reminders(),
|
||||||
}
|
}
|
||||||
@@ -413,6 +648,17 @@ class FitnessService:
|
|||||||
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _step_to_dict(row: StepLog) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"steps": row.steps,
|
||||||
|
"active_calories": row.active_calories,
|
||||||
|
"source": row.source,
|
||||||
|
"notes": row.notes,
|
||||||
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]:
|
def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
@@ -424,6 +670,9 @@ class FitnessService:
|
|||||||
"title": row.title,
|
"title": row.title,
|
||||||
"notes": row.notes,
|
"notes": row.notes,
|
||||||
"duration_min": row.duration_min,
|
"duration_min": row.duration_min,
|
||||||
|
"active_calories": row.active_calories,
|
||||||
|
"total_calories": row.total_calories,
|
||||||
|
"steps": row.steps,
|
||||||
"exercises": exercises,
|
"exercises": exercises,
|
||||||
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import json
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import (
|
||||||
|
BodyMetric,
|
||||||
|
FitnessProfile,
|
||||||
|
FitnessReminder,
|
||||||
|
FoodLog,
|
||||||
|
WaterLog,
|
||||||
|
WorkoutLog,
|
||||||
|
)
|
||||||
|
from app.fitness.calculators import compute_targets, one_rep_max
|
||||||
|
|
||||||
|
DEFAULT_REMINDERS = [
|
||||||
|
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
|
||||||
|
{"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None},
|
||||||
|
{"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None},
|
||||||
|
{"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FitnessService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def _get_profile_row(self) -> FitnessProfile | None:
|
||||||
|
return self.db.scalar(select(FitnessProfile).limit(1))
|
||||||
|
|
||||||
|
def get_profile(self) -> dict[str, Any] | None:
|
||||||
|
row = self._get_profile_row()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return self._profile_to_dict(row)
|
||||||
|
|
||||||
|
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
|
||||||
|
targets = compute_targets(
|
||||||
|
{
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"goal": row.goal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"goal": row.goal,
|
||||||
|
"target_weight_kg": row.target_weight_kg,
|
||||||
|
"weekly_workouts": row.weekly_workouts,
|
||||||
|
"calorie_target": row.calorie_target,
|
||||||
|
"protein_g": row.protein_g,
|
||||||
|
"fat_g": row.fat_g,
|
||||||
|
"carbs_g": row.carbs_g,
|
||||||
|
"water_l": row.water_l,
|
||||||
|
"computed": targets,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = self._get_profile_row()
|
||||||
|
is_new = row is None
|
||||||
|
if is_new:
|
||||||
|
row = FitnessProfile()
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"sex", "age", "height_cm", "weight_kg", "activity_level",
|
||||||
|
"goal", "target_weight_kg", "weekly_workouts",
|
||||||
|
):
|
||||||
|
if key in updates and updates[key] is not None:
|
||||||
|
setattr(row, key, updates[key])
|
||||||
|
|
||||||
|
targets = compute_targets(
|
||||||
|
{
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"goal": row.goal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
row.calorie_target = targets["calorie_target"]
|
||||||
|
row.protein_g = targets["protein_g"]
|
||||||
|
row.fat_g = targets["fat_g"]
|
||||||
|
row.carbs_g = targets["carbs_g"]
|
||||||
|
row.water_l = targets["water_l"]
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
self._ensure_default_reminders()
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "profile": self._profile_to_dict(row)}
|
||||||
|
|
||||||
|
def _ensure_default_reminders(self) -> None:
|
||||||
|
existing = self.db.scalars(select(FitnessReminder)).all()
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
for item in DEFAULT_REMINDERS:
|
||||||
|
self.db.add(FitnessReminder(**item))
|
||||||
|
|
||||||
|
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return compute_targets(params)
|
||||||
|
|
||||||
|
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
|
||||||
|
d = day or datetime.now(timezone.utc).date()
|
||||||
|
start = datetime.combine(d, time.min, tzinfo=timezone.utc)
|
||||||
|
end = datetime.combine(d, time.max, tzinfo=timezone.utc)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
|
||||||
|
start, end = self._day_bounds(day)
|
||||||
|
profile = self.get_profile()
|
||||||
|
|
||||||
|
foods = self.db.scalars(
|
||||||
|
select(FoodLog)
|
||||||
|
.where(FoodLog.logged_at >= start, FoodLog.logged_at <= end)
|
||||||
|
.order_by(FoodLog.logged_at)
|
||||||
|
).all()
|
||||||
|
waters = self.db.scalars(
|
||||||
|
select(WaterLog)
|
||||||
|
.where(WaterLog.logged_at >= start, WaterLog.logged_at <= end)
|
||||||
|
.order_by(WaterLog.logged_at)
|
||||||
|
).all()
|
||||||
|
workouts = self.db.scalars(
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
|
||||||
|
.order_by(WorkoutLog.logged_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"calories": sum(f.calories for f in foods),
|
||||||
|
"protein_g": sum(f.protein_g for f in foods),
|
||||||
|
"fat_g": sum(f.fat_g for f in foods),
|
||||||
|
"carbs_g": sum(f.carbs_g for f in foods),
|
||||||
|
"water_ml": sum(w.amount_ml for w in waters),
|
||||||
|
}
|
||||||
|
|
||||||
|
targets = profile or {
|
||||||
|
"calorie_target": 2000,
|
||||||
|
"protein_g": 140,
|
||||||
|
"fat_g": 65,
|
||||||
|
"carbs_g": 200,
|
||||||
|
"water_l": 2.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
|
||||||
|
"profile_configured": profile is not None,
|
||||||
|
"totals": totals,
|
||||||
|
"targets": {
|
||||||
|
"calories": targets.get("calorie_target", 2000),
|
||||||
|
"protein_g": targets.get("protein_g", 140),
|
||||||
|
"fat_g": targets.get("fat_g", 65),
|
||||||
|
"carbs_g": targets.get("carbs_g", 200),
|
||||||
|
"water_ml": targets.get("water_l", 2.5) * 1000,
|
||||||
|
},
|
||||||
|
"meals": [self._food_to_dict(f) for f in foods],
|
||||||
|
"water": [self._water_to_dict(w) for w in waters],
|
||||||
|
"workouts": [self._workout_to_dict(w) for w in workouts],
|
||||||
|
}
|
||||||
|
|
||||||
|
def log_meal(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
description: str,
|
||||||
|
meal_type: str = "snack",
|
||||||
|
calories: float = 0,
|
||||||
|
protein_g: float = 0,
|
||||||
|
fat_g: float = 0,
|
||||||
|
carbs_g: float = 0,
|
||||||
|
source: str = "llm",
|
||||||
|
estimated: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = FoodLog(
|
||||||
|
meal_type=meal_type[:32],
|
||||||
|
description=description[:2000],
|
||||||
|
calories=calories,
|
||||||
|
protein_g=protein_g,
|
||||||
|
fat_g=fat_g,
|
||||||
|
carbs_g=carbs_g,
|
||||||
|
source=source[:32],
|
||||||
|
estimated=estimated,
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "meal": self._food_to_dict(row)}
|
||||||
|
|
||||||
|
def log_water(self, amount_ml: int) -> dict[str, Any]:
|
||||||
|
row = WaterLog(amount_ml=max(0, amount_ml))
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "water": self._water_to_dict(row)}
|
||||||
|
|
||||||
|
def log_weight(
|
||||||
|
self,
|
||||||
|
weight_kg: float,
|
||||||
|
*,
|
||||||
|
body_fat_pct: float | None = None,
|
||||||
|
chest_cm: float | None = None,
|
||||||
|
waist_cm: float | None = None,
|
||||||
|
notes: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = BodyMetric(
|
||||||
|
weight_kg=weight_kg,
|
||||||
|
body_fat_pct=body_fat_pct,
|
||||||
|
chest_cm=chest_cm,
|
||||||
|
waist_cm=waist_cm,
|
||||||
|
notes=notes[:1000],
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
profile = self._get_profile_row()
|
||||||
|
if profile:
|
||||||
|
profile.weight_kg = weight_kg
|
||||||
|
targets = compute_targets(
|
||||||
|
{
|
||||||
|
"sex": profile.sex,
|
||||||
|
"age": profile.age,
|
||||||
|
"height_cm": profile.height_cm,
|
||||||
|
"weight_kg": weight_kg,
|
||||||
|
"activity_level": profile.activity_level,
|
||||||
|
"goal": profile.goal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
profile.calorie_target = targets["calorie_target"]
|
||||||
|
profile.protein_g = targets["protein_g"]
|
||||||
|
profile.fat_g = targets["fat_g"]
|
||||||
|
profile.carbs_g = targets["carbs_g"]
|
||||||
|
profile.water_l = targets["water_l"]
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"metric": {
|
||||||
|
"id": row.id,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def log_workout(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
notes: str = "",
|
||||||
|
duration_min: int | None = None,
|
||||||
|
exercises: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = WorkoutLog(
|
||||||
|
title=title[:255],
|
||||||
|
notes=notes[:2000],
|
||||||
|
duration_min=duration_min,
|
||||||
|
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "workout": self._workout_to_dict(row)}
|
||||||
|
|
||||||
|
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
|
||||||
|
rows = self.db.scalars(
|
||||||
|
select(BodyMetric).order_by(BodyMetric.recorded_at.desc()).limit(limit)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"weight_kg": r.weight_kg,
|
||||||
|
"body_fat_pct": r.body_fat_pct,
|
||||||
|
"chest_cm": r.chest_cm,
|
||||||
|
"waist_cm": r.waist_cm,
|
||||||
|
"notes": r.notes,
|
||||||
|
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def delete_food_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(FoodLog, log_id)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_water_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(WaterLog, log_id)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_workout_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(WorkoutLog, log_id)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_reminders(self) -> list[dict[str, Any]]:
|
||||||
|
rows = self.db.scalars(select(FitnessReminder).order_by(FitnessReminder.kind)).all()
|
||||||
|
return [self._reminder_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
def set_reminder(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
hour: int | None = None,
|
||||||
|
minute: int | None = None,
|
||||||
|
interval_hours: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = self.db.scalar(
|
||||||
|
select(FitnessReminder).where(FitnessReminder.kind == kind)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
row = FitnessReminder(kind=kind)
|
||||||
|
self.db.add(row)
|
||||||
|
if enabled is not None:
|
||||||
|
row.enabled = enabled
|
||||||
|
if hour is not None:
|
||||||
|
row.hour = hour
|
||||||
|
if minute is not None:
|
||||||
|
row.minute = minute
|
||||||
|
if interval_hours is not None:
|
||||||
|
row.interval_hours = interval_hours
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "reminder": self._reminder_to_dict(row)}
|
||||||
|
|
||||||
|
def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]:
|
||||||
|
return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)}
|
||||||
|
|
||||||
|
def get_history(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
days: int = 7,
|
||||||
|
end_day: date | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
days = max(1, min(days, 90))
|
||||||
|
end = end_day or datetime.now(timezone.utc).date()
|
||||||
|
start = end - timedelta(days=days - 1)
|
||||||
|
summaries: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for offset in range(days):
|
||||||
|
d = start + timedelta(days=offset)
|
||||||
|
full = self.get_daily_summary(d)
|
||||||
|
totals = full["totals"]
|
||||||
|
has_data = bool(full["meals"] or full["water"] or full["workouts"])
|
||||||
|
summaries.append(
|
||||||
|
{
|
||||||
|
"date": full["date"],
|
||||||
|
"has_data": has_data,
|
||||||
|
"totals": totals,
|
||||||
|
"targets": full["targets"],
|
||||||
|
"meal_count": len(full["meals"]),
|
||||||
|
"workout_count": len(full["workouts"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"start_date": start.isoformat(),
|
||||||
|
"end_date": end.isoformat(),
|
||||||
|
"days": days,
|
||||||
|
"summaries": summaries,
|
||||||
|
}
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
return {
|
||||||
|
"profile": self.get_profile(),
|
||||||
|
"today": self.get_daily_summary(today),
|
||||||
|
"history": self.get_history(days=7, end_day=today),
|
||||||
|
"body_metrics": self.list_body_metrics(limit=10),
|
||||||
|
"reminders": self.list_reminders(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _food_to_dict(row: FoodLog) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"meal_type": row.meal_type,
|
||||||
|
"description": row.description,
|
||||||
|
"calories": row.calories,
|
||||||
|
"protein_g": row.protein_g,
|
||||||
|
"fat_g": row.fat_g,
|
||||||
|
"carbs_g": row.carbs_g,
|
||||||
|
"source": row.source,
|
||||||
|
"estimated": row.estimated,
|
||||||
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _water_to_dict(row: WaterLog) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"amount_ml": row.amount_ml,
|
||||||
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
exercises = json.loads(row.exercises_json or "[]")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
exercises = []
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"title": row.title,
|
||||||
|
"notes": row.notes,
|
||||||
|
"duration_min": row.duration_min,
|
||||||
|
"exercises": exercises,
|
||||||
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"kind": row.kind,
|
||||||
|
"hour": row.hour,
|
||||||
|
"minute": row.minute,
|
||||||
|
"interval_hours": row.interval_hours,
|
||||||
|
"enabled": row.enabled,
|
||||||
|
"last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None,
|
||||||
|
}
|
||||||
@@ -25,20 +25,37 @@ MEAL_PROMPT = """
|
|||||||
|
|
||||||
WORKOUT_PROMPT = """
|
WORKOUT_PROMPT = """
|
||||||
Преобразуй описание тренировки в JSON. Только JSON.
|
Преобразуй описание тренировки в JSON. Только JSON.
|
||||||
Схема:
|
Формат:
|
||||||
{
|
{
|
||||||
"title": "название",
|
"title": "название",
|
||||||
"duration_min": null,
|
"duration_min": null,
|
||||||
|
"active_calories": null,
|
||||||
|
"total_calories": null,
|
||||||
|
"steps": null,
|
||||||
"notes": "",
|
"notes": "",
|
||||||
"exercises": [
|
"exercises": [
|
||||||
{"name": "жим лёжа", "sets": 3, "reps": 8, "weight_kg": 80}
|
{"name": "имя упраж", "sets": 3, "reps": 8, "weight_kg": 80}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Правила:
|
Правила:
|
||||||
- weight_kg в кг, метрическая система.
|
- weight_kg в кг, округляй разумно.
|
||||||
|
- active_calories / total_calories / steps — если упомянуты в тексте, иначе null.
|
||||||
- Если данных нет — null или пустой массив.
|
- Если данных нет — null или пустой массив.
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
|
STEPS_PROMPT = """
|
||||||
|
Преобразуй запись о шагах в JSON. Только JSON.
|
||||||
|
Формат:
|
||||||
|
{
|
||||||
|
"steps": 0,
|
||||||
|
"active_calories": null,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
Правила:
|
||||||
|
- steps — целое число шагов за день.
|
||||||
|
- active_calories — только если явно указаны.
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
async def structure_meal(raw_text: str) -> dict[str, Any]:
|
async def structure_meal(raw_text: str) -> dict[str, Any]:
|
||||||
llm = LLMClient()
|
llm = LLMClient()
|
||||||
@@ -64,3 +81,16 @@ async def structure_workout(raw_text: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
raw = strip_markdown_json(result.get("content") or "")
|
raw = strip_markdown_json(result.get("content") or "")
|
||||||
return json.loads(raw)
|
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)
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ 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:
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ 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]]:
|
||||||
@@ -39,11 +39,12 @@ def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str:
|
|||||||
async def generate_image(
|
async def generate_image(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
user_id: int,
|
||||||
session_id: int | None = None,
|
session_id: int | None = None,
|
||||||
draw_self: bool = False,
|
draw_self: bool = False,
|
||||||
scene_description: str = "",
|
scene_description: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
card = _card_image_settings()
|
card = _card_image_settings(db, user_id)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
if not card.get("sd_enabled", True):
|
if not card.get("sd_enabled", True):
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from app.chat.notice_inbox import post_notice_to_latest_chat
|
||||||
|
|
||||||
|
|
||||||
|
def post_chat_notice(content: str, user_id: int) -> None:
|
||||||
|
post_notice_to_latest_chat(content, user_id)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
from app.db.models import User
|
||||||
|
from app.homelab.comfyui import ComfyUIClient
|
||||||
|
from app.homelab.context import resolve_timezone
|
||||||
|
from app.homelab.digest import build_morning_digest
|
||||||
|
from app.homelab.monitoring import check_netdata_alerts
|
||||||
|
from app.homelab_scoped.notices import post_chat_notice
|
||||||
|
from app.homelab.state import get_state, set_state
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WATCH_INTERVAL_SEC = 60
|
||||||
|
_netdata_tick = 0
|
||||||
|
|
||||||
|
|
||||||
|
async def homelab_watcher_loop() -> None:
|
||||||
|
global _netdata_tick
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(WATCH_INTERVAL_SEC)
|
||||||
|
await _tick_morning_digest()
|
||||||
|
await _tick_rofl()
|
||||||
|
settings = get_settings()
|
||||||
|
_netdata_tick += WATCH_INTERVAL_SEC
|
||||||
|
if _netdata_tick >= settings.netdata_poll_interval_sec:
|
||||||
|
_netdata_tick = 0
|
||||||
|
await _tick_netdata()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Homelab watcher error")
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick_morning_digest() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.morning_digest_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||||
|
digest = build_morning_digest(db, include_news=True)
|
||||||
|
for user in users:
|
||||||
|
tz_name = resolve_timezone(db, user.id)
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
except Exception:
|
||||||
|
tz = ZoneInfo("Europe/Moscow")
|
||||||
|
|
||||||
|
now = datetime.now(tz)
|
||||||
|
target_min = settings.morning_digest_hour * 60 + settings.morning_digest_minute
|
||||||
|
current_min = now.hour * 60 + now.minute
|
||||||
|
if current_min < target_min or current_min >= target_min + 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
today = now.date().isoformat()
|
||||||
|
state_key = f"last_morning_digest_date:{user.id}"
|
||||||
|
if get_state(db, state_key) == today:
|
||||||
|
continue
|
||||||
|
|
||||||
|
post_chat_notice(digest, user.id)
|
||||||
|
set_state(db, state_key, today)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick_netdata() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
notices = check_netdata_alerts(db)
|
||||||
|
if not notices:
|
||||||
|
return
|
||||||
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||||
|
for user in users:
|
||||||
|
for notice in notices:
|
||||||
|
post_chat_notice(notice, user.id)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _comfyui_reachable(base_url: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(3.0, connect=2.0)) as client:
|
||||||
|
response = await client.get(f"{base_url.rstrip('/')}/system_stats")
|
||||||
|
return response.status_code < 500
|
||||||
|
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick_rofl() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||||
|
for user in users:
|
||||||
|
tz_name = resolve_timezone(db, user.id)
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
except Exception:
|
||||||
|
tz = ZoneInfo("Europe/Moscow")
|
||||||
|
now = datetime.now(tz)
|
||||||
|
last_raw = get_state(db, f"last_comfy_rofl_at:{user.id}")
|
||||||
|
if last_raw:
|
||||||
|
try:
|
||||||
|
last_at = datetime.fromisoformat(last_raw)
|
||||||
|
if last_at.tzinfo is None:
|
||||||
|
last_at = last_at.replace(tzinfo=tz)
|
||||||
|
if (now - last_at).total_seconds() < settings.comfyui_rofl_min_interval_hours * 3600:
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if random.random() > settings.comfyui_rofl_probability:
|
||||||
|
continue
|
||||||
|
|
||||||
|
today = now.date().isoformat()
|
||||||
|
count_key = f"comfy_rofl_count_{today}:{user.id}"
|
||||||
|
count_raw = get_state(db, count_key) or "0"
|
||||||
|
try:
|
||||||
|
count = int(count_raw)
|
||||||
|
except ValueError:
|
||||||
|
count = 0
|
||||||
|
if count >= settings.comfyui_rofl_max_per_day:
|
||||||
|
continue
|
||||||
|
|
||||||
|
client = ComfyUIClient()
|
||||||
|
if not await _comfyui_reachable(client.base_url):
|
||||||
|
continue
|
||||||
|
|
||||||
|
prompt = client.random_rofl_prompt()
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
client.generate_image(prompt),
|
||||||
|
timeout=settings.comfyui_timeout_sec + 15,
|
||||||
|
)
|
||||||
|
except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc:
|
||||||
|
logger.warning("Rofl image skipped (ComfyUI): %s", exc)
|
||||||
|
continue
|
||||||
|
if not result.get("ok"):
|
||||||
|
logger.warning("Rofl image failed: %s", result.get("error"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = result.get("url", "")
|
||||||
|
post_chat_notice(
|
||||||
|
f"🎨 **Рофл дня**\n\n\n\n_{prompt}_",
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
set_state(db, count_key, str(count + 1))
|
||||||
|
set_state(db, f"last_comfy_rofl_at:{user.id}", now.isoformat())
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -13,14 +13,39 @@ 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.reasoning_effort = settings.openrouter_reasoning_effort.strip().lower()
|
|
||||||
self.client = AsyncOpenAI(
|
self.client = AsyncOpenAI(
|
||||||
api_key=settings.openrouter_api_key,
|
api_key=settings.openrouter_api_key,
|
||||||
base_url=settings.openrouter_base_url,
|
base_url=settings.openrouter_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _runtime(self) -> tuple[str, str, str]:
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
from app.settings.service import SettingsService
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
svc = SettingsService(db)
|
||||||
|
model = str(svc.get_effective("openrouter_model"))
|
||||||
|
extract = str(svc.get_effective("memory_extract_model"))
|
||||||
|
effort = str(svc.get_effective("openrouter_reasoning_effort")).strip().lower()
|
||||||
|
return model, extract, effort
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> str:
|
||||||
|
return self._runtime()[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def memory_extract_model(self) -> str:
|
||||||
|
return self._runtime()[1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reasoning_effort(self) -> str:
|
||||||
|
return self._runtime()[2]
|
||||||
|
|
||||||
def _reasoning_extra_body(self) -> dict[str, Any] | None:
|
def _reasoning_extra_body(self) -> dict[str, Any] | None:
|
||||||
if not self.reasoning_effort:
|
if not self.reasoning_effort:
|
||||||
return None
|
return None
|
||||||
@@ -142,6 +167,15 @@ class LLMClient:
|
|||||||
if tool_call.function.arguments:
|
if tool_call.function.arguments:
|
||||||
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
|
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
|
||||||
|
|
||||||
|
usage = getattr(chunk, "usage", None)
|
||||||
|
if usage is not None:
|
||||||
|
logger.info(
|
||||||
|
"LLM stream usage: prompt=%s completion=%s total=%s",
|
||||||
|
getattr(usage, "prompt_tokens", None),
|
||||||
|
getattr(usage, "completion_tokens", None),
|
||||||
|
getattr(usage, "total_tokens", None),
|
||||||
|
)
|
||||||
|
|
||||||
if choice.finish_reason:
|
if choice.finish_reason:
|
||||||
reasoning = "".join(reasoning_parts)
|
reasoning = "".join(reasoning_parts)
|
||||||
normalized_details = self._normalize_reasoning_details(reasoning_details)
|
normalized_details = self._normalize_reasoning_details(reasoning_details)
|
||||||
@@ -194,6 +228,15 @@ class LLMClient:
|
|||||||
kwargs["extra_body"] = extra_body
|
kwargs["extra_body"] = extra_body
|
||||||
|
|
||||||
response = await self.client.chat.completions.create(**kwargs)
|
response = await self.client.chat.completions.create(**kwargs)
|
||||||
|
usage = getattr(response, "usage", None)
|
||||||
|
if usage is not None:
|
||||||
|
logger.info(
|
||||||
|
"LLM complete usage: prompt=%s completion=%s total=%s model=%s",
|
||||||
|
getattr(usage, "prompt_tokens", None),
|
||||||
|
getattr(usage, "completion_tokens", None),
|
||||||
|
getattr(usage, "total_tokens", None),
|
||||||
|
kwargs.get("model"),
|
||||||
|
)
|
||||||
message = response.choices[0].message
|
message = response.choices[0].message
|
||||||
|
|
||||||
content = message.content or ""
|
content = message.content or ""
|
||||||
@@ -267,3 +310,14 @@ class LLMClient:
|
|||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
return data
|
return data
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
settings = get_settings()
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
response = await self.client.embeddings.create(
|
||||||
|
model=settings.embedding_model,
|
||||||
|
input=texts,
|
||||||
|
)
|
||||||
|
return [item.embedding for item in response.data]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.model = settings.openrouter_model
|
||||||
|
self.tools_enabled = settings.openrouter_tools_enabled
|
||||||
|
self.reasoning_effort = settings.openrouter_reasoning_effort.strip().lower()
|
||||||
|
self.client = AsyncOpenAI(
|
||||||
|
api_key=settings.openrouter_api_key,
|
||||||
|
base_url=settings.openrouter_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reasoning_extra_body(self) -> dict[str, Any] | None:
|
||||||
|
if not self.reasoning_effort:
|
||||||
|
return None
|
||||||
|
if self.reasoning_effort == "none":
|
||||||
|
return {"reasoning": {"effort": "none", "exclude": True}}
|
||||||
|
return {"reasoning": {"effort": self.reasoning_effort}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]:
|
||||||
|
parts: list[str] = []
|
||||||
|
for attr in ("reasoning", "reasoning_content"):
|
||||||
|
value = getattr(delta, attr, None)
|
||||||
|
if value:
|
||||||
|
parts.append(str(value))
|
||||||
|
|
||||||
|
details: list[Any] = []
|
||||||
|
raw_details = getattr(delta, "reasoning_details", None)
|
||||||
|
if raw_details:
|
||||||
|
if isinstance(raw_details, list):
|
||||||
|
details.extend(raw_details)
|
||||||
|
else:
|
||||||
|
details.append(raw_details)
|
||||||
|
|
||||||
|
return "".join(parts), details
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_reasoning_details(details: Any) -> list[Any] | None:
|
||||||
|
if not details:
|
||||||
|
return None
|
||||||
|
items = details if isinstance(details, list) else [details]
|
||||||
|
normalized: list[Any] = []
|
||||||
|
for item in items:
|
||||||
|
if hasattr(item, "model_dump"):
|
||||||
|
normalized.append(item.model_dump())
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
normalized.append(item)
|
||||||
|
else:
|
||||||
|
normalized.append(item)
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def attach_reasoning_to_message(
|
||||||
|
message: dict[str, Any],
|
||||||
|
*,
|
||||||
|
reasoning: str = "",
|
||||||
|
reasoning_details: list[Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if reasoning:
|
||||||
|
message["reasoning"] = reasoning
|
||||||
|
message["reasoning_content"] = reasoning
|
||||||
|
normalized = LLMClient._normalize_reasoning_details(reasoning_details)
|
||||||
|
if normalized:
|
||||||
|
message["reasoning_details"] = normalized
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def stream_chat(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
*,
|
||||||
|
model: str | None = None,
|
||||||
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
use_tools = bool(tools) and self.tools_enabled
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"model": model or self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": True,
|
||||||
|
"temperature": 0.7,
|
||||||
|
}
|
||||||
|
if use_tools:
|
||||||
|
kwargs["tools"] = tools
|
||||||
|
extra_body = self._reasoning_extra_body()
|
||||||
|
if extra_body:
|
||||||
|
kwargs["extra_body"] = extra_body
|
||||||
|
|
||||||
|
try:
|
||||||
|
stream = await self.client.chat.completions.create(**kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("LLM stream failed: %s", exc)
|
||||||
|
yield {"type": "error", "content": str(exc)}
|
||||||
|
yield {"type": "done", "finish_reason": "error"}
|
||||||
|
return
|
||||||
|
|
||||||
|
tool_calls: dict[int, dict[str, Any]] = {}
|
||||||
|
reasoning_parts: list[str] = []
|
||||||
|
reasoning_details: list[Any] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for chunk in stream:
|
||||||
|
if not chunk.choices:
|
||||||
|
continue
|
||||||
|
|
||||||
|
choice = chunk.choices[0]
|
||||||
|
delta = choice.delta
|
||||||
|
|
||||||
|
if delta.content:
|
||||||
|
yield {"type": "content", "content": delta.content}
|
||||||
|
|
||||||
|
reasoning_text, details = self._delta_reasoning(delta)
|
||||||
|
if reasoning_text:
|
||||||
|
reasoning_parts.append(reasoning_text)
|
||||||
|
if details:
|
||||||
|
reasoning_details.extend(details)
|
||||||
|
|
||||||
|
if delta.tool_calls:
|
||||||
|
for tool_call in delta.tool_calls:
|
||||||
|
idx = tool_call.index
|
||||||
|
if idx not in tool_calls:
|
||||||
|
tool_calls[idx] = {
|
||||||
|
"id": tool_call.id or "",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "", "arguments": ""},
|
||||||
|
}
|
||||||
|
if tool_call.id:
|
||||||
|
tool_calls[idx]["id"] = tool_call.id
|
||||||
|
if tool_call.function:
|
||||||
|
if tool_call.function.name:
|
||||||
|
tool_calls[idx]["function"]["name"] = tool_call.function.name
|
||||||
|
if tool_call.function.arguments:
|
||||||
|
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
|
||||||
|
|
||||||
|
if choice.finish_reason:
|
||||||
|
reasoning = "".join(reasoning_parts)
|
||||||
|
normalized_details = self._normalize_reasoning_details(reasoning_details)
|
||||||
|
if reasoning or normalized_details:
|
||||||
|
yield {
|
||||||
|
"type": "reasoning",
|
||||||
|
"reasoning": reasoning,
|
||||||
|
"reasoning_details": normalized_details,
|
||||||
|
}
|
||||||
|
if tool_calls:
|
||||||
|
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
|
||||||
|
logger.info(
|
||||||
|
"LLM stream done: model=%s finish_reason=%s tool_calls=%d "
|
||||||
|
"content_in_stream=%d reasoning_len=%d",
|
||||||
|
model or self.model,
|
||||||
|
choice.finish_reason,
|
||||||
|
len(tool_calls),
|
||||||
|
len(reasoning_parts),
|
||||||
|
len(reasoning),
|
||||||
|
)
|
||||||
|
yield {"type": "done", "finish_reason": choice.finish_reason}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("LLM stream read failed: %s", exc)
|
||||||
|
yield {"type": "error", "content": str(exc)}
|
||||||
|
yield {"type": "done", "finish_reason": "error"}
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
*,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
model: str | None = None,
|
||||||
|
for_extraction: bool = False,
|
||||||
|
visible_reply: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
use_tools = bool(tools) and self.tools_enabled and not for_extraction
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"model": model or self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
}
|
||||||
|
if use_tools:
|
||||||
|
kwargs["tools"] = tools
|
||||||
|
if for_extraction:
|
||||||
|
kwargs["extra_body"] = {"reasoning": {"effort": "none"}}
|
||||||
|
else:
|
||||||
|
extra_body = self._reasoning_extra_body()
|
||||||
|
if extra_body:
|
||||||
|
kwargs["extra_body"] = extra_body
|
||||||
|
|
||||||
|
response = await self.client.chat.completions.create(**kwargs)
|
||||||
|
message = response.choices[0].message
|
||||||
|
|
||||||
|
content = message.content or ""
|
||||||
|
reasoning = ""
|
||||||
|
for attr in ("reasoning", "reasoning_content"):
|
||||||
|
value = getattr(message, attr, None)
|
||||||
|
if value:
|
||||||
|
reasoning = str(value)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not content and reasoning and not visible_reply:
|
||||||
|
content = reasoning
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"content": content,
|
||||||
|
"tool_calls": [],
|
||||||
|
"reasoning": reasoning,
|
||||||
|
"reasoning_details": getattr(message, "reasoning_details", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.tool_calls:
|
||||||
|
result["tool_calls"] = [
|
||||||
|
{
|
||||||
|
"id": tc.id,
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tc.function.name,
|
||||||
|
"arguments": tc.function.arguments,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for tc in message.tool_calls
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
|
||||||
|
if not arguments:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(arguments)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def serialize_reasoning(
|
||||||
|
*,
|
||||||
|
reasoning: str = "",
|
||||||
|
reasoning_details: list[Any] | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if reasoning:
|
||||||
|
payload["reasoning"] = reasoning
|
||||||
|
payload["reasoning_content"] = reasoning
|
||||||
|
if reasoning_details:
|
||||||
|
payload["reasoning_details"] = reasoning_details
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deserialize_reasoning(raw: str | None) -> dict[str, Any]:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"reasoning": raw}
|
||||||
|
if isinstance(data, str):
|
||||||
|
return {"reasoning": data, "reasoning_content": data}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
return {}
|
||||||
+13
-2
@@ -8,14 +8,25 @@ 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()
|
||||||
|
from app.db.migrate_fitness import run_fitness_migrations
|
||||||
|
|
||||||
|
run_fitness_migrations()
|
||||||
|
from app.db.migrate_multi_user import run_multi_user_migrations
|
||||||
|
|
||||||
|
run_multi_user_migrations()
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.rag_enabled:
|
||||||
|
from app.rag.store import ensure_collections
|
||||||
|
|
||||||
|
ensure_collections()
|
||||||
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
|
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
|
||||||
fitness_task = asyncio.create_task(fitness_watcher_loop())
|
fitness_task = asyncio.create_task(fitness_watcher_loop())
|
||||||
homelab_task = asyncio.create_task(homelab_watcher_loop())
|
homelab_task = asyncio.create_task(homelab_watcher_loop())
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -2,16 +2,21 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.memory.service import MemoryService
|
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,
|
||||||
|
query: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return MemoryService(db, user_id).snapshot(session_id, query=query)
|
||||||
|
|
||||||
|
|
||||||
def format_memory_context(snapshot: dict[str, Any]) -> str:
|
def format_memory_context(snapshot: dict[str, Any]) -> str:
|
||||||
@@ -39,7 +44,8 @@ def format_memory_context(snapshot: dict[str, Any]) -> str:
|
|||||||
if facts:
|
if facts:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):")
|
lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):")
|
||||||
for fact in facts[:MAX_FACTS_IN_CONTEXT]:
|
limit = get_settings().memory_facts_in_context
|
||||||
|
for fact in facts[:limit]:
|
||||||
lines.append(
|
lines.append(
|
||||||
f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}"
|
f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -99,6 +99,7 @@ async def extract_after_turn(
|
|||||||
user_text: str,
|
user_text: str,
|
||||||
assistant_text: str,
|
assistant_text: str,
|
||||||
*,
|
*,
|
||||||
|
user_id: int,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not force and _should_skip_extraction(user_text):
|
if not force and _should_skip_extraction(user_text):
|
||||||
@@ -107,7 +108,7 @@ async def extract_after_turn(
|
|||||||
if not (assistant_text or "").strip():
|
if not (assistant_text or "").strip():
|
||||||
return {"ok": True, "skipped": "no_assistant_reply", "saved": []}
|
return {"ok": True, "skipped": "no_assistant_reply", "saved": []}
|
||||||
|
|
||||||
memory = MemoryService(db)
|
memory = MemoryService(db, user_id)
|
||||||
snapshot = memory.snapshot(session_id)
|
snapshot = memory.snapshot(session_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -18,11 +20,19 @@ DEFAULT_PROFILE: dict[str, Any] = {
|
|||||||
|
|
||||||
|
|
||||||
class MemoryService:
|
class MemoryService:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session, user_id: int):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
self.user_id = user_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _schedule_rag(coro) -> None:
|
||||||
|
def runner() -> None:
|
||||||
|
asyncio.run(coro)
|
||||||
|
|
||||||
|
threading.Thread(target=runner, daemon=True).start()
|
||||||
|
|
||||||
def get_profile(self) -> dict[str, Any]:
|
def get_profile(self) -> dict[str, Any]:
|
||||||
row = self.db.scalar(select(UserProfile).limit(1))
|
row = self.db.scalar(select(UserProfile).where(UserProfile.user_id == self.user_id).limit(1))
|
||||||
if not row:
|
if not row:
|
||||||
return dict(DEFAULT_PROFILE)
|
return dict(DEFAULT_PROFILE)
|
||||||
try:
|
try:
|
||||||
@@ -34,9 +44,9 @@ class MemoryService:
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
|
def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
|
||||||
row = self.db.scalar(select(UserProfile).limit(1))
|
row = self.db.scalar(select(UserProfile).where(UserProfile.user_id == self.user_id).limit(1))
|
||||||
if not row:
|
if not row:
|
||||||
row = UserProfile(data_json="{}")
|
row = UserProfile(user_id=self.user_id, data_json="{}")
|
||||||
self.db.add(row)
|
self.db.add(row)
|
||||||
self.db.flush()
|
self.db.flush()
|
||||||
|
|
||||||
@@ -54,7 +64,7 @@ class MemoryService:
|
|||||||
|
|
||||||
def _find_similar_fact(self, text: str) -> MemoryFact | None:
|
def _find_similar_fact(self, text: str) -> MemoryFact | None:
|
||||||
for fact in self.db.scalars(
|
for fact in self.db.scalars(
|
||||||
select(MemoryFact).where(MemoryFact.active.is_(True))
|
select(MemoryFact).where(MemoryFact.user_id == self.user_id, MemoryFact.active.is_(True))
|
||||||
):
|
):
|
||||||
if texts_are_similar(fact.content, text):
|
if texts_are_similar(fact.content, text):
|
||||||
return fact
|
return fact
|
||||||
@@ -91,6 +101,9 @@ class MemoryService:
|
|||||||
if session_id:
|
if session_id:
|
||||||
existing.session_id = session_id
|
existing.session_id = session_id
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
from app.rag.ingest import index_memory_fact
|
||||||
|
|
||||||
|
self._schedule_rag(index_memory_fact(existing))
|
||||||
result = {
|
result = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"action": "updated",
|
"action": "updated",
|
||||||
@@ -103,6 +116,7 @@ class MemoryService:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
fact = MemoryFact(
|
fact = MemoryFact(
|
||||||
|
user_id=self.user_id,
|
||||||
category=(category or "fact")[:64],
|
category=(category or "fact")[:64],
|
||||||
content=text[:2000],
|
content=text[:2000],
|
||||||
source=source[:32],
|
source=source[:32],
|
||||||
@@ -112,6 +126,9 @@ class MemoryService:
|
|||||||
self.db.add(fact)
|
self.db.add(fact)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(fact)
|
self.db.refresh(fact)
|
||||||
|
from app.rag.ingest import index_memory_fact
|
||||||
|
|
||||||
|
self._schedule_rag(index_memory_fact(fact))
|
||||||
result = {
|
result = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"action": "created",
|
"action": "created",
|
||||||
@@ -131,7 +148,7 @@ class MemoryService:
|
|||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
active_only: bool = True,
|
active_only: bool = True,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
stmt = select(MemoryFact).order_by(
|
stmt = select(MemoryFact).where(MemoryFact.user_id == self.user_id).order_by(
|
||||||
MemoryFact.importance.desc(),
|
MemoryFact.importance.desc(),
|
||||||
MemoryFact.updated_at.desc(),
|
MemoryFact.updated_at.desc(),
|
||||||
)
|
)
|
||||||
@@ -163,24 +180,32 @@ class MemoryService:
|
|||||||
|
|
||||||
def forget_memory(self, memory_id: int) -> dict[str, Any]:
|
def forget_memory(self, memory_id: int) -> dict[str, Any]:
|
||||||
fact = self.db.get(MemoryFact, memory_id)
|
fact = self.db.get(MemoryFact, memory_id)
|
||||||
if not fact:
|
if not fact or fact.user_id != self.user_id:
|
||||||
raise ValueError(f"Память #{memory_id} не найдена")
|
raise ValueError(f"Память #{memory_id} не найдена")
|
||||||
fact.active = False
|
fact.active = False
|
||||||
fact.updated_at = datetime.now(timezone.utc)
|
fact.updated_at = datetime.now(timezone.utc)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
from app.rag.ingest import deactivate_memory_fact
|
||||||
|
|
||||||
|
self._schedule_rag(deactivate_memory_fact(memory_id))
|
||||||
return {"ok": True, "memory_id": memory_id, "forgotten": fact.content}
|
return {"ok": True, "memory_id": memory_id, "forgotten": fact.content}
|
||||||
|
|
||||||
def get_active_facts(self, limit: int = 25) -> list[MemoryFact]:
|
def get_active_facts(self, limit: int = 25) -> list[MemoryFact]:
|
||||||
return list(
|
return list(
|
||||||
self.db.scalars(
|
self.db.scalars(
|
||||||
select(MemoryFact)
|
select(MemoryFact)
|
||||||
.where(MemoryFact.active.is_(True))
|
.where(MemoryFact.user_id == self.user_id, MemoryFact.active.is_(True))
|
||||||
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc())
|
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_session_summary(self, session_id: int) -> SessionSummary | None:
|
def get_session_summary(self, session_id: int) -> SessionSummary | None:
|
||||||
|
from app.db.models import ChatSession
|
||||||
|
|
||||||
|
session = self.db.get(ChatSession, session_id)
|
||||||
|
if not session or session.user_id != self.user_id:
|
||||||
|
return None
|
||||||
return self.db.scalar(
|
return self.db.scalar(
|
||||||
select(SessionSummary).where(SessionSummary.session_id == session_id)
|
select(SessionSummary).where(SessionSummary.session_id == session_id)
|
||||||
)
|
)
|
||||||
@@ -196,7 +221,15 @@ class MemoryService:
|
|||||||
if not text:
|
if not text:
|
||||||
raise ValueError("Пустая сводка")
|
raise ValueError("Пустая сводка")
|
||||||
|
|
||||||
row = self.get_session_summary(session_id)
|
from app.db.models import ChatSession
|
||||||
|
|
||||||
|
session = self.db.get(ChatSession, session_id)
|
||||||
|
if not session or session.user_id != self.user_id:
|
||||||
|
raise ValueError("Session not found")
|
||||||
|
|
||||||
|
row = self.db.scalar(
|
||||||
|
select(SessionSummary).where(SessionSummary.session_id == session_id)
|
||||||
|
)
|
||||||
if not row:
|
if not row:
|
||||||
row = SessionSummary(session_id=session_id)
|
row = SessionSummary(session_id=session_id)
|
||||||
self.db.add(row)
|
self.db.add(row)
|
||||||
@@ -205,14 +238,36 @@ class MemoryService:
|
|||||||
row.message_count = message_count
|
row.message_count = message_count
|
||||||
row.updated_at = datetime.now(timezone.utc)
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
self.db.commit()
|
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}
|
return {"ok": True, "session_id": session_id, "summary": row.summary}
|
||||||
|
|
||||||
def snapshot(self, session_id: int | None = None) -> dict[str, Any]:
|
def snapshot(self, session_id: int | None = None, query: str | None = None) -> dict[str, Any]:
|
||||||
facts = self.get_active_facts()
|
from app.config import get_settings
|
||||||
summary_row = self.get_session_summary(session_id) if session_id else None
|
from app.settings.service import SettingsService
|
||||||
return {
|
|
||||||
"profile": self.get_profile(),
|
settings = get_settings()
|
||||||
"facts": [
|
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,
|
"id": f.id,
|
||||||
"category": f.category,
|
"category": f.category,
|
||||||
@@ -222,7 +277,24 @@ class MemoryService:
|
|||||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||||
}
|
}
|
||||||
for f in facts
|
for f in facts
|
||||||
],
|
]
|
||||||
"session_summary": summary_row.summary if summary_row else "",
|
else:
|
||||||
"total_facts": len(facts),
|
facts = self.get_active_facts(limit=settings.memory_facts_in_context)
|
||||||
|
facts_payload = [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"category": f.category,
|
||||||
|
"content": f.content,
|
||||||
|
"importance": f.importance,
|
||||||
|
"source": f.source,
|
||||||
|
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||||
|
}
|
||||||
|
for f in facts
|
||||||
|
]
|
||||||
|
summary_row = self.get_session_summary(session_id) if session_id else None
|
||||||
|
return {
|
||||||
|
"profile": self.get_profile(),
|
||||||
|
"facts": facts_payload,
|
||||||
|
"session_summary": summary_row.summary if summary_row else "",
|
||||||
|
"total_facts": total_facts,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import MemoryFact, SessionSummary, UserProfile
|
||||||
|
from app.memory.parse import normalize_text, parse_identity, texts_are_similar
|
||||||
|
|
||||||
|
DEFAULT_PROFILE: dict[str, Any] = {
|
||||||
|
"name": "",
|
||||||
|
"age": "",
|
||||||
|
"timezone": "",
|
||||||
|
"language": "ru",
|
||||||
|
"notes": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_profile(self) -> dict[str, Any]:
|
||||||
|
row = self.db.scalar(select(UserProfile).limit(1))
|
||||||
|
if not row:
|
||||||
|
return dict(DEFAULT_PROFILE)
|
||||||
|
try:
|
||||||
|
data = json.loads(row.data_json or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = {}
|
||||||
|
merged = dict(DEFAULT_PROFILE)
|
||||||
|
merged.update(data)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = self.db.scalar(select(UserProfile).limit(1))
|
||||||
|
if not row:
|
||||||
|
row = UserProfile(data_json="{}")
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
current = self.get_profile()
|
||||||
|
for key, value in updates.items():
|
||||||
|
if value is None:
|
||||||
|
current.pop(key, None)
|
||||||
|
else:
|
||||||
|
current[key] = value
|
||||||
|
|
||||||
|
row.data_json = json.dumps(current, ensure_ascii=False)
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "profile": current}
|
||||||
|
|
||||||
|
def _find_similar_fact(self, text: str) -> MemoryFact | None:
|
||||||
|
for fact in self.db.scalars(
|
||||||
|
select(MemoryFact).where(MemoryFact.active.is_(True))
|
||||||
|
):
|
||||||
|
if texts_are_similar(fact.content, text):
|
||||||
|
return fact
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _sync_identity_to_profile(self, text: str) -> dict[str, Any] | None:
|
||||||
|
parsed = parse_identity(text)
|
||||||
|
if not parsed:
|
||||||
|
return None
|
||||||
|
return self.update_profile(parsed)
|
||||||
|
|
||||||
|
def remember_fact(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
category: str = "fact",
|
||||||
|
source: str = "user",
|
||||||
|
session_id: int | None = None,
|
||||||
|
importance: int = 3,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
text = content.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Пустой факт")
|
||||||
|
|
||||||
|
profile_sync = self._sync_identity_to_profile(text)
|
||||||
|
|
||||||
|
existing = self._find_similar_fact(text)
|
||||||
|
if existing:
|
||||||
|
if len(text) > len(existing.content):
|
||||||
|
existing.content = text[:2000]
|
||||||
|
existing.category = category or existing.category
|
||||||
|
existing.importance = max(existing.importance, min(5, max(1, importance)))
|
||||||
|
existing.updated_at = datetime.now(timezone.utc)
|
||||||
|
if session_id:
|
||||||
|
existing.session_id = session_id
|
||||||
|
self.db.commit()
|
||||||
|
result = {
|
||||||
|
"ok": True,
|
||||||
|
"action": "updated",
|
||||||
|
"memory_id": existing.id,
|
||||||
|
"content": existing.content,
|
||||||
|
"category": existing.category,
|
||||||
|
}
|
||||||
|
if profile_sync:
|
||||||
|
result["profile"] = profile_sync.get("profile")
|
||||||
|
return result
|
||||||
|
|
||||||
|
fact = MemoryFact(
|
||||||
|
category=(category or "fact")[:64],
|
||||||
|
content=text[:2000],
|
||||||
|
source=source[:32],
|
||||||
|
session_id=session_id,
|
||||||
|
importance=min(5, max(1, importance)),
|
||||||
|
)
|
||||||
|
self.db.add(fact)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(fact)
|
||||||
|
result = {
|
||||||
|
"ok": True,
|
||||||
|
"action": "created",
|
||||||
|
"memory_id": fact.id,
|
||||||
|
"content": fact.content,
|
||||||
|
"category": fact.category,
|
||||||
|
}
|
||||||
|
if profile_sync:
|
||||||
|
result["profile"] = profile_sync.get("profile")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def recall_memories(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
query: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
active_only: bool = True,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(MemoryFact).order_by(
|
||||||
|
MemoryFact.importance.desc(),
|
||||||
|
MemoryFact.updated_at.desc(),
|
||||||
|
)
|
||||||
|
if active_only:
|
||||||
|
stmt = stmt.where(MemoryFact.active.is_(True))
|
||||||
|
if category:
|
||||||
|
stmt = stmt.where(MemoryFact.category == category)
|
||||||
|
facts = self.db.scalars(stmt.limit(100)).all()
|
||||||
|
if query:
|
||||||
|
qnorm = normalize_text(query)
|
||||||
|
facts = [
|
||||||
|
f
|
||||||
|
for f in facts
|
||||||
|
if qnorm in normalize_text(f.content)
|
||||||
|
or qnorm in normalize_text(f.category)
|
||||||
|
]
|
||||||
|
facts = facts[: min(limit, 50)]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"category": f.category,
|
||||||
|
"content": f.content,
|
||||||
|
"importance": f.importance,
|
||||||
|
"source": f.source,
|
||||||
|
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||||
|
}
|
||||||
|
for f in facts
|
||||||
|
]
|
||||||
|
|
||||||
|
def forget_memory(self, memory_id: int) -> dict[str, Any]:
|
||||||
|
fact = self.db.get(MemoryFact, memory_id)
|
||||||
|
if not fact:
|
||||||
|
raise ValueError(f"Память #{memory_id} не найдена")
|
||||||
|
fact.active = False
|
||||||
|
fact.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "memory_id": memory_id, "forgotten": fact.content}
|
||||||
|
|
||||||
|
def get_active_facts(self, limit: int = 25) -> list[MemoryFact]:
|
||||||
|
return list(
|
||||||
|
self.db.scalars(
|
||||||
|
select(MemoryFact)
|
||||||
|
.where(MemoryFact.active.is_(True))
|
||||||
|
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_session_summary(self, session_id: int) -> SessionSummary | None:
|
||||||
|
return self.db.scalar(
|
||||||
|
select(SessionSummary).where(SessionSummary.session_id == session_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_session_summary(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
summary: str,
|
||||||
|
*,
|
||||||
|
message_count: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
text = summary.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Пустая сводка")
|
||||||
|
|
||||||
|
row = self.get_session_summary(session_id)
|
||||||
|
if not row:
|
||||||
|
row = SessionSummary(session_id=session_id)
|
||||||
|
self.db.add(row)
|
||||||
|
|
||||||
|
row.summary = text[:4000]
|
||||||
|
row.message_count = message_count
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "session_id": session_id, "summary": row.summary}
|
||||||
|
|
||||||
|
def snapshot(self, session_id: int | None = None) -> dict[str, Any]:
|
||||||
|
facts = self.get_active_facts()
|
||||||
|
summary_row = self.get_session_summary(session_id) if session_id else None
|
||||||
|
return {
|
||||||
|
"profile": self.get_profile(),
|
||||||
|
"facts": [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"category": f.category,
|
||||||
|
"content": f.content,
|
||||||
|
"importance": f.importance,
|
||||||
|
"source": f.source,
|
||||||
|
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||||
|
}
|
||||||
|
for f in facts
|
||||||
|
],
|
||||||
|
"session_summary": summary_row.summary if summary_row else "",
|
||||||
|
"total_facts": len(facts),
|
||||||
|
}
|
||||||
@@ -20,12 +20,13 @@ PHASE_LABELS = {
|
|||||||
|
|
||||||
|
|
||||||
class PomodoroCompletionHandler:
|
class PomodoroCompletionHandler:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session, user_id: int):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.pomodoro = PomodoroService(db)
|
self.user_id = user_id
|
||||||
self.cycle = CycleManager(db)
|
self.pomodoro = PomodoroService(db, user_id)
|
||||||
|
self.cycle = CycleManager(db, user_id)
|
||||||
self.llm = LLMClient()
|
self.llm = LLMClient()
|
||||||
self.character = CharacterService()
|
self.character = CharacterService(db, user_id)
|
||||||
|
|
||||||
async def _generate_llm_comment(
|
async def _generate_llm_comment(
|
||||||
self,
|
self,
|
||||||
@@ -76,12 +77,12 @@ class PomodoroCompletionHandler:
|
|||||||
|
|
||||||
next_phase = self._resolve_next_phase(session)
|
next_phase = self._resolve_next_phase(session)
|
||||||
notice = format_phase_completed_notice(session, next_phase)
|
notice = format_phase_completed_notice(session, next_phase)
|
||||||
post_notice_to_latest_chat(notice)
|
post_notice_to_latest_chat(notice, self.user_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
comment = await self._generate_llm_comment(session, next_phase)
|
comment = await self._generate_llm_comment(session, next_phase)
|
||||||
if comment:
|
if comment:
|
||||||
post_character_comment_to_latest_chat(comment)
|
post_character_comment_to_latest_chat(comment, self.user_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase)
|
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase)
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ PHASE_LONG_BREAK = "long_break"
|
|||||||
|
|
||||||
|
|
||||||
class CycleManager:
|
class CycleManager:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session, user_id: int):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
self.user_id = user_id
|
||||||
|
|
||||||
def get(self) -> PomodoroCycle:
|
def get(self) -> PomodoroCycle:
|
||||||
cycle = self.db.scalar(select(PomodoroCycle).limit(1))
|
cycle = self.db.scalar(select(PomodoroCycle).where(PomodoroCycle.user_id == self.user_id).limit(1))
|
||||||
if not cycle:
|
if not cycle:
|
||||||
cycle = PomodoroCycle()
|
cycle = PomodoroCycle(user_id=self.user_id)
|
||||||
self.db.add(cycle)
|
self.db.add(cycle)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(cycle)
|
self.db.refresh(cycle)
|
||||||
|
|||||||
@@ -17,14 +17,18 @@ def _utcnow() -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
class PomodoroService:
|
class PomodoroService:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session, user_id: int):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.cycle = CycleManager(db)
|
self.user_id = user_id
|
||||||
|
self.cycle = CycleManager(db, user_id)
|
||||||
|
|
||||||
def _get_active(self) -> PomodoroSession | None:
|
def _get_active(self) -> PomodoroSession | None:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(PomodoroSession)
|
select(PomodoroSession)
|
||||||
.where(PomodoroSession.status.in_(("running", "paused")))
|
.where(
|
||||||
|
PomodoroSession.user_id == self.user_id,
|
||||||
|
PomodoroSession.status.in_(("running", "paused")),
|
||||||
|
)
|
||||||
.order_by(PomodoroSession.id.desc())
|
.order_by(PomodoroSession.id.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
@@ -101,6 +105,7 @@ class PomodoroService:
|
|||||||
note = task_note if task_note is not None else cycle.task_note
|
note = task_note if task_note is not None else cycle.task_note
|
||||||
|
|
||||||
session = PomodoroSession(
|
session = PomodoroSession(
|
||||||
|
user_id=self.user_id,
|
||||||
status="running",
|
status="running",
|
||||||
phase=phase,
|
phase=phase,
|
||||||
duration_min=duration,
|
duration_min=duration,
|
||||||
@@ -230,6 +235,7 @@ class PomodoroService:
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(PomodoroSession)
|
select(PomodoroSession)
|
||||||
.where(
|
.where(
|
||||||
|
PomodoroSession.user_id == self.user_id,
|
||||||
PomodoroSession.status == "completed",
|
PomodoroSession.status == "completed",
|
||||||
PomodoroSession.completed.is_(True),
|
PomodoroSession.completed.is_(True),
|
||||||
PomodoroSession.completion_notified.is_(False),
|
PomodoroSession.completion_notified.is_(False),
|
||||||
@@ -266,7 +272,10 @@ class PomodoroService:
|
|||||||
def history(self, limit: int = 20) -> list[dict]:
|
def history(self, limit: int = 20) -> list[dict]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(PomodoroSession)
|
select(PomodoroSession)
|
||||||
.where(PomodoroSession.status.in_(("completed", "cancelled")))
|
.where(
|
||||||
|
PomodoroSession.user_id == self.user_id,
|
||||||
|
PomodoroSession.status.in_(("completed", "cancelled")),
|
||||||
|
)
|
||||||
.order_by(PomodoroSession.finished_at.desc())
|
.order_by(PomodoroSession.finished_at.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.db.base import SessionLocal
|
from app.db.base import SessionLocal
|
||||||
|
from app.db.models import User
|
||||||
from app.pomodoro.completion import PomodoroCompletionHandler
|
from app.pomodoro.completion import PomodoroCompletionHandler
|
||||||
from app.pomodoro.service import PomodoroService
|
from app.pomodoro.service import PomodoroService
|
||||||
|
|
||||||
@@ -24,14 +27,14 @@ async def pomodoro_watcher_loop() -> None:
|
|||||||
async def _tick() -> None:
|
async def _tick() -> None:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
service = PomodoroService(db)
|
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||||
|
for user in users:
|
||||||
|
service = PomodoroService(db, user.id)
|
||||||
service.get_status()
|
service.get_status()
|
||||||
|
|
||||||
pending = service.get_pending_completions()
|
pending = service.get_pending_completions()
|
||||||
if not pending:
|
if not pending:
|
||||||
return
|
continue
|
||||||
|
handler = PomodoroCompletionHandler(db, user.id)
|
||||||
handler = PomodoroCompletionHandler(db)
|
|
||||||
for session in pending:
|
for session in pending:
|
||||||
await handler.process(session)
|
await handler.process(session)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -11,28 +11,30 @@ 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]:
|
def get_projects_snapshot(db: Session, user_id: int, *, force: bool = False) -> dict[str, Any]:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if not force and _cache["data"] is not None and now < _cache["expires_at"]:
|
entry = _cache.get(user_id)
|
||||||
return _cache["data"]
|
if not force and entry and now < entry.get("expires_at", 0):
|
||||||
|
return entry["data"]
|
||||||
|
|
||||||
snapshot = _fetch_projects_snapshot(db)
|
snapshot = _fetch_projects_snapshot(db, user_id)
|
||||||
_cache["data"] = snapshot
|
_cache[user_id] = {"data": snapshot, "expires_at": now + PROJECTS_CACHE_SEC}
|
||||||
_cache["expires_at"] = now + PROJECTS_CACHE_SEC
|
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
def _fetch_projects_snapshot(db: Session) -> dict[str, Any]:
|
def _fetch_projects_snapshot(db: Session, user_id: int) -> dict[str, Any]:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
service = ProjectService(db)
|
service = ProjectService(db, user_id)
|
||||||
|
|
||||||
if not settings.taiga_configured:
|
if not settings.taiga_configured:
|
||||||
return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
|
return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ from app.projects.structuring import (
|
|||||||
|
|
||||||
|
|
||||||
class ProjectService:
|
class ProjectService:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session, user_id: int):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
self.user_id = user_id
|
||||||
self.settings = get_settings()
|
self.settings = get_settings()
|
||||||
|
|
||||||
def sync_taiga_projects(self) -> list[dict[str, Any]]:
|
def sync_taiga_projects(self) -> list[dict[str, Any]]:
|
||||||
@@ -56,7 +57,11 @@ class ProjectService:
|
|||||||
def list_projects(self) -> list[dict[str, Any]]:
|
def list_projects(self) -> list[dict[str, Any]]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(TaigaProject, ProjectBinding)
|
select(TaigaProject, ProjectBinding)
|
||||||
.outerjoin(ProjectBinding, ProjectBinding.taiga_slug == TaigaProject.slug)
|
.outerjoin(
|
||||||
|
ProjectBinding,
|
||||||
|
(ProjectBinding.taiga_slug == TaigaProject.slug)
|
||||||
|
& (ProjectBinding.user_id == self.user_id),
|
||||||
|
)
|
||||||
.order_by(TaigaProject.name)
|
.order_by(TaigaProject.name)
|
||||||
)
|
)
|
||||||
rows = self.db.execute(stmt).all()
|
rows = self.db.execute(stmt).all()
|
||||||
@@ -86,7 +91,7 @@ class ProjectService:
|
|||||||
raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.")
|
raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.")
|
||||||
|
|
||||||
binding = self.db.scalar(
|
binding = self.db.scalar(
|
||||||
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_slug)
|
select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_slug)
|
||||||
)
|
)
|
||||||
if binding:
|
if binding:
|
||||||
binding.gitea_owner = gitea_owner
|
binding.gitea_owner = gitea_owner
|
||||||
@@ -94,6 +99,7 @@ class ProjectService:
|
|||||||
binding.default_branch = default_branch
|
binding.default_branch = default_branch
|
||||||
else:
|
else:
|
||||||
binding = ProjectBinding(
|
binding = ProjectBinding(
|
||||||
|
user_id=self.user_id,
|
||||||
taiga_slug=taiga_slug,
|
taiga_slug=taiga_slug,
|
||||||
gitea_owner=gitea_owner,
|
gitea_owner=gitea_owner,
|
||||||
gitea_repo=gitea_repo,
|
gitea_repo=gitea_repo,
|
||||||
@@ -123,7 +129,7 @@ class ProjectService:
|
|||||||
taiga_proj = projects[0]
|
taiga_proj = projects[0]
|
||||||
|
|
||||||
binding = self.db.scalar(
|
binding = self.db.scalar(
|
||||||
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_proj.slug)
|
select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_proj.slug)
|
||||||
)
|
)
|
||||||
return taiga_proj, binding
|
return taiga_proj, binding
|
||||||
|
|
||||||
@@ -199,6 +205,7 @@ class ProjectService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
work_item = WorkItem(
|
work_item = WorkItem(
|
||||||
|
user_id=self.user_id,
|
||||||
taiga_slug=taiga_proj.slug,
|
taiga_slug=taiga_proj.slug,
|
||||||
taiga_project_id=taiga_proj.taiga_id,
|
taiga_project_id=taiga_proj.taiga_id,
|
||||||
taiga_story_id=story["id"],
|
taiga_story_id=story["id"],
|
||||||
@@ -256,6 +263,7 @@ class ProjectService:
|
|||||||
|
|
||||||
linked_items = self.db.scalars(
|
linked_items = self.db.scalars(
|
||||||
select(WorkItem).where(
|
select(WorkItem).where(
|
||||||
|
WorkItem.user_id == self.user_id,
|
||||||
WorkItem.gitea_owner == owner,
|
WorkItem.gitea_owner == owner,
|
||||||
WorkItem.gitea_repo == repo,
|
WorkItem.gitea_repo == repo,
|
||||||
WorkItem.status == "open",
|
WorkItem.status == "open",
|
||||||
@@ -314,6 +322,7 @@ class ProjectService:
|
|||||||
for ref in taiga_task_refs:
|
for ref in taiga_task_refs:
|
||||||
binding = self.db.scalar(
|
binding = self.db.scalar(
|
||||||
select(ProjectBinding).where(
|
select(ProjectBinding).where(
|
||||||
|
ProjectBinding.user_id == self.user_id,
|
||||||
ProjectBinding.gitea_owner == owner,
|
ProjectBinding.gitea_owner == owner,
|
||||||
ProjectBinding.gitea_repo == repo,
|
ProjectBinding.gitea_repo == repo,
|
||||||
)
|
)
|
||||||
@@ -348,6 +357,7 @@ class ProjectService:
|
|||||||
return item.taiga_project_id
|
return item.taiga_project_id
|
||||||
binding = self.db.scalar(
|
binding = self.db.scalar(
|
||||||
select(ProjectBinding).where(
|
select(ProjectBinding).where(
|
||||||
|
ProjectBinding.user_id == self.user_id,
|
||||||
ProjectBinding.gitea_owner == owner,
|
ProjectBinding.gitea_owner == owner,
|
||||||
ProjectBinding.gitea_repo == repo,
|
ProjectBinding.gitea_repo == repo,
|
||||||
)
|
)
|
||||||
@@ -440,7 +450,7 @@ class ProjectService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]:
|
def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]:
|
||||||
stmt = select(WorkItem).order_by(WorkItem.created_at.desc()).limit(limit)
|
stmt = select(WorkItem).where(WorkItem.user_id == self.user_id).order_by(WorkItem.created_at.desc()).limit(limit)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(WorkItem.status == status)
|
stmt = stmt.where(WorkItem.status == status)
|
||||||
items = self.db.scalars(stmt).all()
|
items = self.db.scalars(stmt).all()
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""RAG: embeddings, Qdrant store, retrieval, ingest."""
|
||||||
|
|
||||||
|
from app.rag import chunker, embeddings, ingest, retriever, store
|
||||||
|
|
||||||
|
__all__ = ["chunker", "embeddings", "ingest", "retriever", "store"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(text: str, *, chunk_size: int = 800, overlap: int = 120) -> list[str]:
|
||||||
|
cleaned = (text or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return []
|
||||||
|
if len(cleaned) <= chunk_size:
|
||||||
|
return [cleaned]
|
||||||
|
chunks: list[str] = []
|
||||||
|
start = 0
|
||||||
|
while start < len(cleaned):
|
||||||
|
end = min(len(cleaned), start + chunk_size)
|
||||||
|
piece = cleaned[start:end].strip()
|
||||||
|
if piece:
|
||||||
|
chunks.append(piece)
|
||||||
|
if end >= len(cleaned):
|
||||||
|
break
|
||||||
|
start = max(0, end - overlap)
|
||||||
|
return chunks
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.llm.client import LLMClient
|
||||||
|
|
||||||
|
|
||||||
|
async def embed_texts(texts: list[str]) -> list[list[float]]:
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
client = LLMClient()
|
||||||
|
return await client.embed(texts)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.models import ChatSession, Document, DocumentChunk, MemoryFact
|
||||||
|
from app.rag import embeddings
|
||||||
|
from app.rag.chunker import chunk_text
|
||||||
|
from app.rag.store import (
|
||||||
|
COLLECTION_DOC_CHUNKS,
|
||||||
|
COLLECTION_FACTS,
|
||||||
|
COLLECTION_SUMMARIES,
|
||||||
|
delete_by_filter,
|
||||||
|
upsert_points,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def index_memory_fact(fact: MemoryFact) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled or not fact.active:
|
||||||
|
return
|
||||||
|
vectors = await embeddings.embed_texts([fact.content])
|
||||||
|
if not vectors:
|
||||||
|
return
|
||||||
|
upsert_points(
|
||||||
|
COLLECTION_FACTS,
|
||||||
|
[
|
||||||
|
qm.PointStruct(
|
||||||
|
id=int(fact.id),
|
||||||
|
vector=vectors[0],
|
||||||
|
payload={
|
||||||
|
"user_id": fact.user_id,
|
||||||
|
"fact_id": fact.id,
|
||||||
|
"category": fact.category,
|
||||||
|
"content": fact.content,
|
||||||
|
"importance": fact.importance,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def deactivate_memory_fact(fact_id: int) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled:
|
||||||
|
return
|
||||||
|
delete_by_filter(
|
||||||
|
COLLECTION_FACTS,
|
||||||
|
[qm.FieldCondition(key="fact_id", match=qm.MatchValue(value=fact_id))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def index_session_summary(session_id: int, summary: str) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled or not summary.strip():
|
||||||
|
return
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
|
||||||
|
user_id = 1
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
session = db.get(ChatSession, session_id)
|
||||||
|
if session:
|
||||||
|
user_id = session.user_id
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
vectors = await embeddings.embed_texts([summary])
|
||||||
|
if not vectors:
|
||||||
|
return
|
||||||
|
upsert_points(
|
||||||
|
COLLECTION_SUMMARIES,
|
||||||
|
[
|
||||||
|
qm.PointStruct(
|
||||||
|
id=int(session_id),
|
||||||
|
vector=vectors[0],
|
||||||
|
payload={"user_id": user_id, "session_id": session_id, "summary": summary[:4000]},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_document_file(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
title: str,
|
||||||
|
filename: str,
|
||||||
|
raw_bytes: bytes,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
text = raw_bytes.decode("utf-8", errors="replace").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Пустой документ")
|
||||||
|
|
||||||
|
digest = hashlib.sha256(raw_bytes).hexdigest()
|
||||||
|
doc = Document(
|
||||||
|
user_id=user_id,
|
||||||
|
title=title or filename,
|
||||||
|
filename=filename,
|
||||||
|
content_hash=digest,
|
||||||
|
size_bytes=len(raw_bytes),
|
||||||
|
)
|
||||||
|
db.add(doc)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
chunks = chunk_text(text)
|
||||||
|
chunk_rows: list[DocumentChunk] = []
|
||||||
|
for idx, piece in enumerate(chunks):
|
||||||
|
row = DocumentChunk(document_id=doc.id, chunk_index=idx, content=piece)
|
||||||
|
db.add(row)
|
||||||
|
chunk_rows.append(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(doc)
|
||||||
|
|
||||||
|
if settings.rag_enabled and chunks:
|
||||||
|
vectors = await embeddings.embed_texts(chunks)
|
||||||
|
points: list[qm.PointStruct] = []
|
||||||
|
for row, vector in zip(chunk_rows, vectors, strict=False):
|
||||||
|
db.refresh(row)
|
||||||
|
point_id = int(row.id)
|
||||||
|
points.append(
|
||||||
|
qm.PointStruct(
|
||||||
|
id=point_id,
|
||||||
|
vector=vector,
|
||||||
|
payload={
|
||||||
|
"user_id": user_id,
|
||||||
|
"document_id": doc.id,
|
||||||
|
"chunk_id": row.id,
|
||||||
|
"chunk_index": row.chunk_index,
|
||||||
|
"title": doc.title,
|
||||||
|
"content": row.content,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upsert_points(COLLECTION_DOC_CHUNKS, points)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": doc.id,
|
||||||
|
"title": doc.title,
|
||||||
|
"filename": doc.filename,
|
||||||
|
"chunk_count": len(chunks),
|
||||||
|
"size_bytes": doc.size_bytes,
|
||||||
|
"created_at": doc.created_at.isoformat() if doc.created_at else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Migrate active memory facts into Qdrant
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.base import SessionLocal, init_db
|
||||||
|
from app.db.models import MemoryFact, SessionSummary
|
||||||
|
from app.rag.ingest import index_memory_fact, index_session_summary
|
||||||
|
from app.rag.store import ensure_collections
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled:
|
||||||
|
print("RAG disabled; set RAG_ENABLED=true")
|
||||||
|
return
|
||||||
|
init_db()
|
||||||
|
ensure_collections()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
facts = db.scalars(select(MemoryFact).where(MemoryFact.active.is_(True))).all()
|
||||||
|
for fact in facts:
|
||||||
|
await index_memory_fact(fact)
|
||||||
|
summaries = db.scalars(select(SessionSummary)).all()
|
||||||
|
for row in summaries:
|
||||||
|
if row.summary:
|
||||||
|
await index_session_summary(row.session_id, row.summary)
|
||||||
|
print(f"Indexed {len(facts)} facts and {len(summaries)} summaries")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.rag import embeddings
|
||||||
|
from app.rag.store import COLLECTION_DOC_CHUNKS, COLLECTION_FACTS, search
|
||||||
|
|
||||||
|
|
||||||
|
def _user_filter(user_id: int) -> qm.Filter:
|
||||||
|
return qm.Filter(
|
||||||
|
must=[qm.FieldCondition(key="user_id", match=qm.MatchValue(value=user_id))]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def retrieve_memory_facts(
|
||||||
|
query: str, *, user_id: int, top_k: int | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled or not query.strip():
|
||||||
|
return []
|
||||||
|
k = top_k or settings.rag_top_k
|
||||||
|
vectors = await embeddings.embed_texts([query])
|
||||||
|
if not vectors:
|
||||||
|
return []
|
||||||
|
hits = search(COLLECTION_FACTS, vectors[0], limit=k, query_filter=_user_filter(user_id))
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for hit in hits:
|
||||||
|
payload = hit.payload or {}
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"id": payload.get("fact_id") or hit.id,
|
||||||
|
"category": payload.get("category", "fact"),
|
||||||
|
"content": payload.get("content", ""),
|
||||||
|
"score": hit.score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def retrieve_document_chunks(
|
||||||
|
query: str, *, user_id: int, top_k: int = 6
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled or not query.strip():
|
||||||
|
return []
|
||||||
|
vectors = await embeddings.embed_texts([query])
|
||||||
|
if not vectors:
|
||||||
|
return []
|
||||||
|
hits = search(
|
||||||
|
COLLECTION_DOC_CHUNKS, vectors[0], limit=top_k, query_filter=_user_filter(user_id)
|
||||||
|
)
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for hit in hits:
|
||||||
|
payload = hit.payload or {}
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"document_id": payload.get("document_id"),
|
||||||
|
"chunk_index": payload.get("chunk_index"),
|
||||||
|
"title": payload.get("title", ""),
|
||||||
|
"content": payload.get("content", ""),
|
||||||
|
"score": hit.score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
COLLECTION_FACTS = "memory_facts"
|
||||||
|
COLLECTION_SUMMARIES = "session_summaries"
|
||||||
|
COLLECTION_DOC_CHUNKS = "document_chunks"
|
||||||
|
VECTOR_SIZE = 1536
|
||||||
|
|
||||||
|
|
||||||
|
def _client() -> QdrantClient:
|
||||||
|
settings = get_settings()
|
||||||
|
return QdrantClient(url=settings.qdrant_url)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_collections() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.rag_enabled:
|
||||||
|
return
|
||||||
|
client = _client()
|
||||||
|
for name in (COLLECTION_FACTS, COLLECTION_SUMMARIES, COLLECTION_DOC_CHUNKS):
|
||||||
|
if client.collection_exists(name):
|
||||||
|
continue
|
||||||
|
client.create_collection(
|
||||||
|
collection_name=name,
|
||||||
|
vectors_config=qm.VectorParams(size=VECTOR_SIZE, distance=qm.Distance.COSINE),
|
||||||
|
)
|
||||||
|
logger.info("Created Qdrant collection %s", name)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_points(collection: str, points: list[qm.PointStruct]) -> None:
|
||||||
|
if not points:
|
||||||
|
return
|
||||||
|
_client().upsert(collection_name=collection, points=points)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_by_filter(collection: str, must: list[qm.FieldCondition]) -> None:
|
||||||
|
_client().delete(
|
||||||
|
collection_name=collection,
|
||||||
|
points_selector=qm.FilterSelector(filter=qm.Filter(must=must)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search(
|
||||||
|
collection: str,
|
||||||
|
vector: list[float],
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
query_filter: qm.Filter | None = None,
|
||||||
|
) -> list[qm.ScoredPoint]:
|
||||||
|
return _client().search(
|
||||||
|
collection_name=collection,
|
||||||
|
query_vector=vector,
|
||||||
|
limit=limit,
|
||||||
|
query_filter=query_filter,
|
||||||
|
)
|
||||||
@@ -21,10 +21,11 @@ def format_reminder_notice(row: Reminder) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class ReminderCompletionHandler:
|
class ReminderCompletionHandler:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session, user_id: int):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
self.user_id = user_id
|
||||||
self.llm = LLMClient()
|
self.llm = LLMClient()
|
||||||
self.character = CharacterService()
|
self.character = CharacterService(db, user_id)
|
||||||
|
|
||||||
async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str:
|
async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str:
|
||||||
notes_part = f"\nЗаметки: {row.notes}" if row.notes else ""
|
notes_part = f"\nЗаметки: {row.notes}" if row.notes else ""
|
||||||
@@ -61,12 +62,12 @@ class ReminderCompletionHandler:
|
|||||||
|
|
||||||
async def process(self, row: Reminder) -> None:
|
async def process(self, row: Reminder) -> None:
|
||||||
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)
|
||||||
post_notice_to_latest_chat(format_reminder_notice(row))
|
post_notice_to_latest_chat(format_reminder_notice(row), self.user_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
comment = await self._generate_llm_comment(row, local_when)
|
comment = await self._generate_llm_comment(row, local_when)
|
||||||
if comment:
|
if comment:
|
||||||
post_character_comment_to_latest_chat(comment)
|
post_character_comment_to_latest_chat(comment, self.user_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Reminder LLM comment failed (id=%s)", row.id)
|
logger.exception("Reminder LLM comment failed (id=%s)", row.id)
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ 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:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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
|
||||||
|
|
||||||
@@ -15,11 +15,12 @@ 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.user_id == user_id,
|
||||||
Reminder.enabled.is_(True),
|
Reminder.enabled.is_(True),
|
||||||
Reminder.completed_at.is_(None),
|
Reminder.completed_at.is_(None),
|
||||||
Reminder.due_at <= now,
|
Reminder.due_at <= now,
|
||||||
@@ -31,15 +32,19 @@ def get_due_reminders(db: Session) -> list[Reminder]:
|
|||||||
|
|
||||||
|
|
||||||
async def process_due_reminders(db: Session) -> int:
|
async def process_due_reminders(db: Session) -> int:
|
||||||
due = get_due_reminders(db)
|
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:
|
if not due:
|
||||||
return 0
|
continue
|
||||||
|
handler = ReminderCompletionHandler(db, user.id)
|
||||||
handler = ReminderCompletionHandler(db)
|
|
||||||
for row in due:
|
for row in due:
|
||||||
await handler.process(row)
|
await handler.process(row)
|
||||||
|
total += len(due)
|
||||||
|
|
||||||
|
if total:
|
||||||
db.commit()
|
db.commit()
|
||||||
bump_notify_seq(db)
|
bump_notify_seq(db)
|
||||||
logger.info("Reminders fired: %d", len(due))
|
logger.info("Reminders fired: %d", total)
|
||||||
return len(due)
|
return total
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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"
|
||||||
@@ -75,11 +75,14 @@ def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str:
|
|||||||
|
|
||||||
|
|
||||||
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:
|
def _tz(self) -> str:
|
||||||
return resolve_timezone(self.db)
|
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]:
|
def _to_dict(self, row: Reminder) -> dict[str, Any]:
|
||||||
tz = row.timezone or self._tz()
|
tz = row.timezone or self._tz()
|
||||||
@@ -110,6 +113,7 @@ class RemindersService:
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(Reminder)
|
select(Reminder)
|
||||||
.where(
|
.where(
|
||||||
|
Reminder.user_id == self.user_id,
|
||||||
Reminder.enabled.is_(True),
|
Reminder.enabled.is_(True),
|
||||||
Reminder.completed_at.is_(None),
|
Reminder.completed_at.is_(None),
|
||||||
)
|
)
|
||||||
@@ -127,6 +131,7 @@ class RemindersService:
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(Reminder)
|
select(Reminder)
|
||||||
.where(
|
.where(
|
||||||
|
Reminder.user_id == self.user_id,
|
||||||
Reminder.enabled.is_(True),
|
Reminder.enabled.is_(True),
|
||||||
Reminder.completed_at.is_(None),
|
Reminder.completed_at.is_(None),
|
||||||
Reminder.due_at >= date_from,
|
Reminder.due_at >= date_from,
|
||||||
@@ -138,7 +143,9 @@ class RemindersService:
|
|||||||
|
|
||||||
def get(self, reminder_id: int) -> dict[str, Any] | None:
|
def get(self, reminder_id: int) -> dict[str, Any] | None:
|
||||||
row = self.db.get(Reminder, reminder_id)
|
row = self.db.get(Reminder, reminder_id)
|
||||||
return self._to_dict(row) if row else None
|
if not row or row.user_id != self.user_id:
|
||||||
|
return None
|
||||||
|
return self._to_dict(row)
|
||||||
|
|
||||||
def create(
|
def create(
|
||||||
self,
|
self,
|
||||||
@@ -159,6 +166,7 @@ class RemindersService:
|
|||||||
tz = self._tz()
|
tz = self._tz()
|
||||||
due = _parse_due_at(due_at, tz)
|
due = _parse_due_at(due_at, tz)
|
||||||
row = Reminder(
|
row = Reminder(
|
||||||
|
user_id=self.user_id,
|
||||||
title=clean_title,
|
title=clean_title,
|
||||||
notes=notes.strip(),
|
notes=notes.strip(),
|
||||||
due_at=due,
|
due_at=due,
|
||||||
@@ -184,7 +192,7 @@ class RemindersService:
|
|||||||
enabled: bool | None = None,
|
enabled: bool | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
row = self.db.get(Reminder, reminder_id)
|
row = self.db.get(Reminder, reminder_id)
|
||||||
if not row:
|
if not row or row.user_id != self.user_id:
|
||||||
raise ValueError("Напоминание не найдено")
|
raise ValueError("Напоминание не найдено")
|
||||||
|
|
||||||
if title is not None:
|
if title is not None:
|
||||||
@@ -215,7 +223,7 @@ class RemindersService:
|
|||||||
|
|
||||||
def delete(self, reminder_id: int) -> dict[str, Any]:
|
def delete(self, reminder_id: int) -> dict[str, Any]:
|
||||||
row = self.db.get(Reminder, reminder_id)
|
row = self.db.get(Reminder, reminder_id)
|
||||||
if not row:
|
if not row or row.user_id != self.user_id:
|
||||||
raise ValueError("Напоминание не найдено")
|
raise ValueError("Напоминание не найдено")
|
||||||
title = row.title
|
title = row.title
|
||||||
self.db.delete(row)
|
self.db.delete(row)
|
||||||
@@ -225,7 +233,7 @@ class RemindersService:
|
|||||||
|
|
||||||
def complete(self, reminder_id: int) -> dict[str, Any]:
|
def complete(self, reminder_id: int) -> dict[str, Any]:
|
||||||
row = self.db.get(Reminder, reminder_id)
|
row = self.db.get(Reminder, reminder_id)
|
||||||
if not row:
|
if not row or row.user_id != self.user_id:
|
||||||
raise ValueError("Напоминание не найдено")
|
raise ValueError("Напоминание не найдено")
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
row.completed_at = now
|
row.completed_at = now
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Runtime settings stored in assistant_state."""
|
||||||
@@ -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()
|
||||||
@@ -8,8 +8,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:
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ 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]:
|
def snapshot(self) -> dict[str, Any]:
|
||||||
lists = self.list_lists(include_items=True)
|
lists = self.list_lists(include_items=True)
|
||||||
@@ -28,7 +29,7 @@ class ShoppingService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def list_lists(self, *, include_items: bool = False) -> list[dict[str, Any]]:
|
def list_lists(self, *, include_items: bool = False) -> list[dict[str, Any]]:
|
||||||
stmt = select(ShoppingList).order_by(ShoppingList.sort_order, ShoppingList.name)
|
stmt = select(ShoppingList).where(ShoppingList.user_id == self.user_id).order_by(ShoppingList.sort_order, ShoppingList.name)
|
||||||
if include_items:
|
if include_items:
|
||||||
stmt = stmt.options(selectinload(ShoppingList.items))
|
stmt = stmt.options(selectinload(ShoppingList.items))
|
||||||
rows = list(self.db.scalars(stmt).all())
|
rows = list(self.db.scalars(stmt).all())
|
||||||
@@ -49,12 +50,12 @@ class ShoppingService:
|
|||||||
clean = name.strip()
|
clean = name.strip()
|
||||||
if not clean:
|
if not clean:
|
||||||
raise ValueError("Название списка не может быть пустым")
|
raise ValueError("Название списка не может быть пустым")
|
||||||
existing = self.db.scalar(select(ShoppingList).where(ShoppingList.name == clean))
|
existing = self.db.scalar(select(ShoppingList).where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean))
|
||||||
if existing:
|
if existing:
|
||||||
return {"ok": True, "list": self._list_to_dict(existing, include_items=True), "created": False}
|
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
|
max_order = self.db.scalar(select(func.max(ShoppingList.sort_order)).where(ShoppingList.user_id == self.user_id)) or 0
|
||||||
row = ShoppingList(name=clean, sort_order=max_order + 1)
|
row = ShoppingList(user_id=self.user_id, name=clean, sort_order=max_order + 1)
|
||||||
self.db.add(row)
|
self.db.add(row)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(row)
|
self.db.refresh(row)
|
||||||
@@ -66,7 +67,7 @@ class ShoppingService:
|
|||||||
if not clean:
|
if not clean:
|
||||||
raise ValueError("Название списка не может быть пустым")
|
raise ValueError("Название списка не может быть пустым")
|
||||||
conflict = self.db.scalar(
|
conflict = self.db.scalar(
|
||||||
select(ShoppingList).where(ShoppingList.name == clean, ShoppingList.id != list_id)
|
select(ShoppingList).where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean, ShoppingList.id != list_id)
|
||||||
)
|
)
|
||||||
if conflict:
|
if conflict:
|
||||||
raise ValueError(f"Список «{clean}» уже существует")
|
raise ValueError(f"Список «{clean}» уже существует")
|
||||||
@@ -175,14 +176,14 @@ class ShoppingService:
|
|||||||
if list_id is not None:
|
if list_id is not None:
|
||||||
return self.db.scalar(
|
return self.db.scalar(
|
||||||
select(ShoppingList)
|
select(ShoppingList)
|
||||||
.where(ShoppingList.id == list_id)
|
.where(ShoppingList.user_id == self.user_id, ShoppingList.id == list_id)
|
||||||
.options(selectinload(ShoppingList.items))
|
.options(selectinload(ShoppingList.items))
|
||||||
)
|
)
|
||||||
if name:
|
if name:
|
||||||
clean = name.strip()
|
clean = name.strip()
|
||||||
return self.db.scalar(
|
return self.db.scalar(
|
||||||
select(ShoppingList)
|
select(ShoppingList)
|
||||||
.where(ShoppingList.name == clean)
|
.where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean)
|
||||||
.options(selectinload(ShoppingList.items))
|
.options(selectinload(ShoppingList.items))
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -195,7 +196,7 @@ class ShoppingService:
|
|||||||
|
|
||||||
def _require_item(self, item_id: int) -> ShoppingListItem:
|
def _require_item(self, item_id: int) -> ShoppingListItem:
|
||||||
item = self.db.get(ShoppingListItem, item_id)
|
item = self.db.get(ShoppingListItem, item_id)
|
||||||
if not item:
|
if not item or item.shopping_list.user_id != self.user_id:
|
||||||
raise ValueError(f"Позиция #{item_id} не найдена")
|
raise ValueError(f"Позиция #{item_id} не найдена")
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|||||||
+122
-10
@@ -14,7 +14,7 @@ from app.integrations.wger import WgerClient
|
|||||||
from app.memory.service import MemoryService
|
from app.memory.service import MemoryService
|
||||||
from app.pomodoro.service import PomodoroService
|
from app.pomodoro.service import PomodoroService
|
||||||
from app.projects.service import ProjectService
|
from app.projects.service import ProjectService
|
||||||
from app.reminders.service import RemindersService
|
from app.reminders_scoped.service import RemindersService
|
||||||
from app.shopping.service import ShoppingService
|
from app.shopping.service import ShoppingService
|
||||||
|
|
||||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||||
@@ -278,6 +278,21 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "search_documents",
|
||||||
|
"description": "Семантический поиск по загруженным документам (RAG).",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "Поисковый запрос"},
|
||||||
|
"limit": {"type": "integer", "description": "Макс. фрагментов"},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -360,6 +375,29 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "calc_body_composition",
|
||||||
|
"description": (
|
||||||
|
"Navy-калькулятор % жира, WHR, LBM, FFMI без сохранения. "
|
||||||
|
"Пол/рост/вес из профиля, если не указаны."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sex": {"type": "string"},
|
||||||
|
"height_cm": {"type": "number"},
|
||||||
|
"weight_kg": {"type": "number"},
|
||||||
|
"neck_cm": {"type": "number"},
|
||||||
|
"waist_cm": {"type": "number"},
|
||||||
|
"hip_cm": {"type": "number"},
|
||||||
|
"body_fat_pct": {"type": "number"},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -393,27 +431,56 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "log_weight",
|
"name": "log_weight",
|
||||||
"description": "Записать вес в кг.",
|
"description": (
|
||||||
|
"Записать антропометрию: вес и обхваты (см). "
|
||||||
|
"При neck+waist(+hip для женщин) автоматически считается Navy % жира."
|
||||||
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"weight_kg": {"type": "number"},
|
"weight_kg": {"type": "number"},
|
||||||
"body_fat_pct": {"type": "number"},
|
"body_fat_pct": {"type": "number"},
|
||||||
|
"neck_cm": {"type": "number"},
|
||||||
|
"waist_cm": {"type": "number"},
|
||||||
|
"hip_cm": {"type": "number"},
|
||||||
|
"chest_cm": {"type": "number"},
|
||||||
"notes": {"type": "string"},
|
"notes": {"type": "string"},
|
||||||
|
"date": {"type": "string"},
|
||||||
|
"days_ago": {"type": "integer"},
|
||||||
},
|
},
|
||||||
"required": ["weight_kg"],
|
"required": ["weight_kg"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "log_steps",
|
||||||
|
"description": "Записать шаги (можно задним числом: date или days_ago).",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"steps": {"type": "integer"},
|
||||||
|
"active_calories": {"type": "number"},
|
||||||
|
"notes": {"type": "string"},
|
||||||
|
"date": {"type": "string"},
|
||||||
|
"days_ago": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["steps"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "log_workout",
|
"name": "log_workout",
|
||||||
"description": "Записать тренировку из текста.",
|
"description": "Записать тренировку из текста (date/days_ago для прошлых дней).",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"text": {"type": "string"},
|
"text": {"type": "string"},
|
||||||
|
"date": {"type": "string"},
|
||||||
|
"days_ago": {"type": "integer"},
|
||||||
},
|
},
|
||||||
"required": ["text"],
|
"required": ["text"],
|
||||||
},
|
},
|
||||||
@@ -731,13 +798,14 @@ async def execute_tool(
|
|||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
session_id: int | None = None,
|
session_id: int | None = None,
|
||||||
|
user_id: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
pomodoro = PomodoroService(db)
|
pomodoro = PomodoroService(db, user_id)
|
||||||
projects = ProjectService(db)
|
projects = ProjectService(db, user_id)
|
||||||
memory = MemoryService(db)
|
memory = MemoryService(db, user_id)
|
||||||
fitness = FitnessService(db)
|
fitness = FitnessService(db, user_id)
|
||||||
shopping = ShoppingService(db)
|
shopping = ShoppingService(db, user_id)
|
||||||
reminders = RemindersService(db)
|
reminders = RemindersService(db, user_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if name == "get_pomodoro_status":
|
if name == "get_pomodoro_status":
|
||||||
@@ -766,7 +834,7 @@ async def execute_tool(
|
|||||||
from app.projects.context import invalidate_projects_snapshot_cache
|
from app.projects.context import invalidate_projects_snapshot_cache
|
||||||
|
|
||||||
result = projects.sync_taiga_projects()
|
result = projects.sync_taiga_projects()
|
||||||
invalidate_projects_snapshot_cache()
|
invalidate_projects_snapshot_cache(user_id)
|
||||||
elif name == "list_taiga_projects":
|
elif name == "list_taiga_projects":
|
||||||
result = projects.list_projects()
|
result = projects.list_projects()
|
||||||
elif name == "list_taiga_tasks":
|
elif name == "list_taiga_tasks":
|
||||||
@@ -812,6 +880,19 @@ async def execute_tool(
|
|||||||
int(arguments["session_id"]),
|
int(arguments["session_id"]),
|
||||||
arguments.get("summary", ""),
|
arguments.get("summary", ""),
|
||||||
)
|
)
|
||||||
|
elif name == "search_documents":
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.rag.retriever import retrieve_document_chunks
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
return await retrieve_document_chunks(
|
||||||
|
arguments.get("query", ""),
|
||||||
|
user_id=user_id,
|
||||||
|
top_k=int(arguments.get("limit") or 6),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(_run())
|
||||||
elif name == "get_fitness_summary":
|
elif name == "get_fitness_summary":
|
||||||
day: date | None = None
|
day: date | None = None
|
||||||
if arguments.get("date"):
|
if arguments.get("date"):
|
||||||
@@ -841,6 +922,8 @@ async def execute_tool(
|
|||||||
result = fitness.set_profile(updates)
|
result = fitness.set_profile(updates)
|
||||||
elif name == "calc_fitness_targets":
|
elif name == "calc_fitness_targets":
|
||||||
result = fitness.calc_targets(arguments)
|
result = fitness.calc_targets(arguments)
|
||||||
|
elif name == "calc_body_composition":
|
||||||
|
result = fitness.calc_body_composition(arguments)
|
||||||
elif name == "log_meal":
|
elif name == "log_meal":
|
||||||
structured = await structure_meal(arguments.get("text", ""))
|
structured = await structure_meal(arguments.get("text", ""))
|
||||||
result = fitness.log_meal(
|
result = fitness.log_meal(
|
||||||
@@ -856,18 +939,46 @@ async def execute_tool(
|
|||||||
elif name == "log_water":
|
elif name == "log_water":
|
||||||
result = fitness.log_water(int(arguments.get("amount_ml", 250)))
|
result = fitness.log_water(int(arguments.get("amount_ml", 250)))
|
||||||
elif name == "log_weight":
|
elif name == "log_weight":
|
||||||
|
day = None
|
||||||
|
if arguments.get("date"):
|
||||||
|
day = date.fromisoformat(str(arguments["date"]))
|
||||||
result = fitness.log_weight(
|
result = fitness.log_weight(
|
||||||
float(arguments["weight_kg"]),
|
float(arguments["weight_kg"]),
|
||||||
body_fat_pct=arguments.get("body_fat_pct"),
|
body_fat_pct=arguments.get("body_fat_pct"),
|
||||||
|
chest_cm=arguments.get("chest_cm"),
|
||||||
|
waist_cm=arguments.get("waist_cm"),
|
||||||
|
neck_cm=arguments.get("neck_cm"),
|
||||||
|
hip_cm=arguments.get("hip_cm"),
|
||||||
notes=arguments.get("notes", ""),
|
notes=arguments.get("notes", ""),
|
||||||
|
day=day,
|
||||||
|
days_ago=arguments.get("days_ago"),
|
||||||
|
)
|
||||||
|
elif name == "log_steps":
|
||||||
|
day = None
|
||||||
|
if arguments.get("date"):
|
||||||
|
day = date.fromisoformat(str(arguments["date"]))
|
||||||
|
result = fitness.log_steps(
|
||||||
|
int(arguments.get("steps") or 0),
|
||||||
|
active_calories=arguments.get("active_calories"),
|
||||||
|
notes=arguments.get("notes", ""),
|
||||||
|
day=day,
|
||||||
|
days_ago=arguments.get("days_ago"),
|
||||||
)
|
)
|
||||||
elif name == "log_workout":
|
elif name == "log_workout":
|
||||||
structured = await structure_workout(arguments.get("text", ""))
|
structured = await structure_workout(arguments.get("text", ""))
|
||||||
|
day = None
|
||||||
|
if arguments.get("date"):
|
||||||
|
day = date.fromisoformat(str(arguments["date"]))
|
||||||
result = fitness.log_workout(
|
result = fitness.log_workout(
|
||||||
title=structured.get("title") or "Тренировка",
|
title=structured.get("title") or "Тренировка",
|
||||||
notes=structured.get("notes") or arguments.get("text", ""),
|
notes=structured.get("notes") or arguments.get("text", ""),
|
||||||
duration_min=structured.get("duration_min"),
|
duration_min=structured.get("duration_min"),
|
||||||
exercises=structured.get("exercises"),
|
exercises=structured.get("exercises"),
|
||||||
|
active_calories=structured.get("active_calories"),
|
||||||
|
total_calories=structured.get("total_calories"),
|
||||||
|
steps=structured.get("steps"),
|
||||||
|
day=day,
|
||||||
|
days_ago=arguments.get("days_ago"),
|
||||||
)
|
)
|
||||||
elif name == "lookup_food":
|
elif name == "lookup_food":
|
||||||
result = OpenFoodFactsClient().search(
|
result = OpenFoodFactsClient().search(
|
||||||
@@ -904,6 +1015,7 @@ async def execute_tool(
|
|||||||
elif name == "generate_image":
|
elif name == "generate_image":
|
||||||
result = await run_generate_image(
|
result = await run_generate_image(
|
||||||
db,
|
db,
|
||||||
|
user_id=user_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
draw_self=bool(arguments.get("draw_self")),
|
draw_self=bool(arguments.get("draw_self")),
|
||||||
scene_description=arguments.get("scene_description", ""),
|
scene_description=arguments.get("scene_description", ""),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
fastapi>=0.115.0
|
fastapi>=0.115.0
|
||||||
uvicorn[standard]>=0.32.0
|
uvicorn[standard]>=0.32.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
sqlalchemy>=2.0.36
|
sqlalchemy>=2.0.36
|
||||||
pydantic-settings>=2.6.0
|
pydantic-settings>=2.6.0
|
||||||
openai>=1.55.0
|
openai>=1.55.0
|
||||||
@@ -7,3 +8,4 @@ python-dotenv>=1.0.1
|
|||||||
aiosqlite>=0.20.0
|
aiosqlite>=0.20.0
|
||||||
httpx>=0.28.0
|
httpx>=0.28.0
|
||||||
feedparser>=6.0.11
|
feedparser>=6.0.11
|
||||||
|
qdrant-client>=1.12.0,<1.13.0
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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"])
|
||||||
@@ -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
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
services:
|
services:
|
||||||
|
qdrant:
|
||||||
|
image: qdrant/qdrant:v1.12.5
|
||||||
|
ports:
|
||||||
|
- "${QDRANT_PORT:-6333}:6333"
|
||||||
|
- "${QDRANT_GRPC_PORT:-6334}:6334"
|
||||||
|
volumes:
|
||||||
|
- qdrant_data:/qdrant/storage
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8080}:${BACKEND_INTERNAL_PORT:-8080}"
|
- "${BACKEND_PORT:-8080}:${BACKEND_INTERNAL_PORT:-8080}"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333}
|
||||||
|
depends_on:
|
||||||
|
- qdrant
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@@ -15,8 +28,12 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ""
|
VITE_API_URL: ""
|
||||||
|
VITE_API_TOKEN: ${VITE_API_TOKEN:-}
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3080}:${FRONTEND_INTERNAL_PORT:-80}"
|
- "${FRONTEND_PORT:-3080}:${FRONTEND_INTERNAL_PORT:-80}"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
qdrant_data:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
"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": {
|
||||||
|
"@tanstack/react-virtual": "^3.14.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,34 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-user {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
border-left: 1px solid #2a2f3a;
|
||||||
|
color: #8b939f;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logout {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border: 1px solid #3a4254;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #c5ccd6;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logout:hover {
|
||||||
|
background: #2b3445;
|
||||||
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -79,3 +106,29 @@
|
|||||||
flex-shrink: 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user