added RAG, Multiuser, TG bot

This commit is contained in:
2026-06-13 20:20:56 +00:00
parent 66e1b0e29e
commit c8a9429bed
142 changed files with 19901 additions and 8790 deletions
+121 -106
View File
@@ -1,106 +1,121 @@
# Server (internal bind inside containers)
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
# Server (internal bind inside containers)
HOST=0.0.0.0
BACKEND_INTERNAL_PORT=8080
FRONTEND_INTERNAL_PORT=80
# External ports on the host (docker compose publish)
BACKEND_PORT=8080
FRONTEND_PORT=3080
VITE_DEV_PORT=5173
# OpenRouter
OPENROUTER_API_KEY=sk-or-v1-your-key-here
OPENROUTER_MODEL=deepseek/deepseek-chat
# deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются:
# OPENROUTER_MODEL=deepseek/deepseek-v4-pro
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_TOOLS_ENABLED=true
# none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning.
OPENROUTER_REASONING_EFFORT=none
# JSON-экстракция памяти отдельной моделью (если основная капризничает):
# MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat
# App
DATABASE_URL=sqlite:///./data/assistant.db
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
SYSTEM_PROMPT_PATH=./prompts/assistant.md
MEMORY_AUTO_EXTRACT=true
# Multi-user (API token auth)
DEFAULT_USER_USERNAME=owner
DEFAULT_USER_DISPLAY_NAME=
DEFAULT_API_TOKEN=change-me-to-long-random-string
AUTH_REQUIRED=true
# Опционально для dev (автовход без /login). В prod оставьте пустым.
VITE_API_TOKEN=
# Fitness (wger + Open Food Facts — public HTTPS, no proxy)
WGER_BASE_URL=https://wger.de/api/v2
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
FITNESS_REMINDERS_ENABLED=true
REMINDERS_ENABLED=true
# Taiga (on host :9000, nginx → taiga.grigowashere.ru)
TAIGA_BASE_URL=http://host.docker.internal:9000
TAIGA_USERNAME=your_taiga_user
TAIGA_PASSWORD=your_taiga_password
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
# Gitea (on host :3000, nginx → git.grigowashere.ru)
GITEA_BASE_URL=http://host.docker.internal:3000
GITEA_TOKEN=your_gitea_api_token
GITEA_PUBLIC_URL=https://git.grigowashere.ru
GITEA_WEBHOOK_SECRET=generate_a_random_secret
# Gitea webhook URL (repo Settings → Webhooks):
# https://assistant.your-domain/api/v1/webhooks/gitea ← nginx → 127.0.0.1:BACKEND_PORT
# http://172.17.0.1:8202/api/v1/webhooks/gitea ← если Gitea в Docker (не 127.0.0.1!)
REPOS_DIR=/data/repos
# Homelab — GPU PC 192.168.1.109
OPENMETEO_BASE_URL=http://192.168.1.109:8085
WEATHER_LAT=59.9343
WEATHER_LON=30.3351
WEATHER_LOCATION_NAME=Санкт-Петербург
WEATHER_CACHE_SEC=300
# News RSS (comma-separated)
NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss
NEWS_CACHE_SEC=1800
NEWS_MAX_ITEMS=7
# Morning digest (Europe/Moscow or user profile timezone)
MORNING_DIGEST_ENABLED=true
MORNING_DIGEST_HOUR=8
MORNING_DIGEST_MINUTE=0
# ComfyUI on GPU PC (Anima split-model — как в aiChatBot)
COMFYUI_BASE_URL=http://192.168.1.109:8188
COMFYUI_ENABLED=true
# Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET.
COMFYUI_CHECKPOINT=
COMFYUI_UNET=anima-preview3-base.safetensors
COMFYUI_CLIP=qwen_3_06b_base.safetensors
COMFYUI_VAE=qwen_image_vae.safetensors
COMFYUI_STYLE_LORA=anima-preview-3-masterpieces-v5.safetensors
COMFYUI_STYLE_LORA_WEIGHT=0.7
COMFYUI_STEPS=30
COMFYUI_CFG=4
COMFYUI_SAMPLER=er_sde
COMFYUI_SCHEDULER=simple
COMFYUI_WIDTH=1024
COMFYUI_HEIGHT=720
COMFYUI_NEGATIVE_PROMPT=worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia
COMFYUI_ROFL_ENABLED=true
COMFYUI_ROFL_MAX_PER_DAY=1
COMFYUI_ROFL_PROBABILITY=0.15
COMFYUI_ROFL_MIN_INTERVAL_HOURS=12
GENERATED_MEDIA_DIR=./data/generated
# RP Chat (aiChatBot) — генерация картинок + sd-prompt; persona_id в карточке персонажа
RP_CHAT_BASE_URL=http://host.docker.internal:8201
RP_CHAT_ENABLED=true
RP_CHAT_TIMEOUT_SEC=300
# Netdata on server
NETDATA_BASE_URL=http://host.docker.internal:19999
NETDATA_PUBLIC_URL=
NETDATA_ALERTS_ENABLED=true
NETDATA_POLL_INTERVAL_SEC=120
# Vector DB (phase 3)
QDRANT_PORT=6333
QDRANT_GRPC_PORT=6334
# RAG / embeddings
QDRANT_URL=http://qdrant:6333
EMBEDDING_MODEL=openai/text-embedding-3-small
RAG_ENABLED=true
RAG_TOP_K=8
MEMORY_FACTS_IN_CONTEXT=8
+106
View File
@@ -0,0 +1,106 @@
# Server (internal bind inside containers)
HOST=0.0.0.0
BACKEND_INTERNAL_PORT=8080
FRONTEND_INTERNAL_PORT=80
# External ports on the host (docker compose publish)
BACKEND_PORT=8080
FRONTEND_PORT=3080
VITE_DEV_PORT=5173
# OpenRouter
OPENROUTER_API_KEY=sk-or-v1-your-key-here
OPENROUTER_MODEL=deepseek/deepseek-chat
# deepseek/deepseek-v4-pro — сильная модель, tools поддерживаются:
# OPENROUTER_MODEL=deepseek/deepseek-v4-pro
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_TOOLS_ENABLED=true
# none = без thinking (быстрее, стабильнее с tools). low|medium|high|xhigh — reasoning.
OPENROUTER_REASONING_EFFORT=none
# JSON-экстракция памяти отдельной моделью (если основная капризничает):
# MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat
# App
DATABASE_URL=sqlite:///./data/assistant.db
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
SYSTEM_PROMPT_PATH=./prompts/assistant.md
MEMORY_AUTO_EXTRACT=true
# Fitness (wger + Open Food Facts — public HTTPS, no proxy)
WGER_BASE_URL=https://wger.de/api/v2
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
FITNESS_REMINDERS_ENABLED=true
REMINDERS_ENABLED=true
# Taiga (on host :9000, nginx → taiga.grigowashere.ru)
TAIGA_BASE_URL=http://host.docker.internal:9000
TAIGA_USERNAME=your_taiga_user
TAIGA_PASSWORD=your_taiga_password
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
# Gitea (on host :3000, nginx → git.grigowashere.ru)
GITEA_BASE_URL=http://host.docker.internal:3000
GITEA_TOKEN=your_gitea_api_token
GITEA_PUBLIC_URL=https://git.grigowashere.ru
GITEA_WEBHOOK_SECRET=generate_a_random_secret
# Gitea webhook URL (repo Settings → Webhooks):
# https://assistant.your-domain/api/v1/webhooks/gitea ← nginx → 127.0.0.1:BACKEND_PORT
# http://172.17.0.1:8202/api/v1/webhooks/gitea ← если Gitea в Docker (не 127.0.0.1!)
REPOS_DIR=/data/repos
# Homelab — GPU PC 192.168.1.109
OPENMETEO_BASE_URL=http://192.168.1.109:8085
WEATHER_LAT=59.9343
WEATHER_LON=30.3351
WEATHER_LOCATION_NAME=Санкт-Петербург
WEATHER_CACHE_SEC=300
# News RSS (comma-separated)
NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss
NEWS_CACHE_SEC=1800
NEWS_MAX_ITEMS=7
# Morning digest (Europe/Moscow or user profile timezone)
MORNING_DIGEST_ENABLED=true
MORNING_DIGEST_HOUR=8
MORNING_DIGEST_MINUTE=0
# ComfyUI on GPU PC (Anima split-model — как в aiChatBot)
COMFYUI_BASE_URL=http://192.168.1.109:8188
COMFYUI_ENABLED=true
# Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET.
COMFYUI_CHECKPOINT=
COMFYUI_UNET=anima-preview3-base.safetensors
COMFYUI_CLIP=qwen_3_06b_base.safetensors
COMFYUI_VAE=qwen_image_vae.safetensors
COMFYUI_STYLE_LORA=anima-preview-3-masterpieces-v5.safetensors
COMFYUI_STYLE_LORA_WEIGHT=0.7
COMFYUI_STEPS=30
COMFYUI_CFG=4
COMFYUI_SAMPLER=er_sde
COMFYUI_SCHEDULER=simple
COMFYUI_WIDTH=1024
COMFYUI_HEIGHT=720
COMFYUI_NEGATIVE_PROMPT=worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia
COMFYUI_ROFL_ENABLED=true
COMFYUI_ROFL_MAX_PER_DAY=1
COMFYUI_ROFL_PROBABILITY=0.15
COMFYUI_ROFL_MIN_INTERVAL_HOURS=12
GENERATED_MEDIA_DIR=./data/generated
# RP Chat (aiChatBot) — генерация картинок + sd-prompt; persona_id в карточке персонажа
RP_CHAT_BASE_URL=http://host.docker.internal:8201
RP_CHAT_ENABLED=true
RP_CHAT_TIMEOUT_SEC=300
# Netdata on server
NETDATA_BASE_URL=http://host.docker.internal:19999
NETDATA_PUBLIC_URL=
NETDATA_ALERTS_ENABLED=true
NETDATA_POLL_INTERVAL_SEC=120
# Vector DB (phase 3)
QDRANT_PORT=6333
QDRANT_GRPC_PORT=6334
+34
View File
@@ -0,0 +1,34 @@
import pathlib
ROOT = pathlib.Path(".").resolve()
svc = ROOT / "app/fitness/service.py"
text = svc.read_text(encoding="utf-8")
old = """from app.db.models import (
BodyMetric,
FitnessProfile,
FitnessReminder,
FoodLog,
WaterLog,
WorkoutLog,
)
from app.fitness.calculators import compute_targets, one_rep_max"""
new = """from app.db.models import (
BodyMetric,
FitnessProfile,
FitnessReminder,
FoodLog,
StepLog,
WaterLog,
WorkoutLog,
)
from app.fitness.activity_budget import (
build_base_targets,
compute_activity_bonus,
estimate_workout_active_kcal,
scale_targets,
)
from app.fitness.calculators import compute_targets, one_rep_max"""
if old not in text:
raise SystemExit("import block missing")
text = text.replace(old, new, 1)
svc.write_text(text, encoding="utf-8")
print("ok imports")
+12
View File
@@ -0,0 +1,12 @@
from pydantic import BaseModel
from app.api.schemas import MessageOut
class MessagesPageOut(BaseModel):
messages: list[MessageOut]
has_more: bool
class GenerationStatusOut(BaseModel):
active: bool
+20 -17
View File
@@ -1,17 +1,20 @@
from fastapi import APIRouter
from 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"])
from fastapi import APIRouter
from app.api.routes import auth, character, chat, documents, fitness, health, homelab, media, memory, pomodoro, projects, reminders, settings, shopping, webhooks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router)
api_router.include_router(homelab.router, tags=["homelab"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(character.router, tags=["character"])
api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"])
api_router.include_router(settings.router, tags=["settings"])
api_router.include_router(documents.router, tags=["documents"])
@@ -0,0 +1,17 @@
from fastapi import APIRouter
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, reminders, shopping, webhooks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"])
api_router.include_router(homelab.router, tags=["homelab"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(character.router, tags=["character"])
api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"])
+73
View File
@@ -0,0 +1,73 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.auth.service import create_user, find_user_by_token, user_to_dict
from app.db.base import get_db
from app.db.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
token: str = Field(min_length=8, max_length=256)
class CreateUserRequest(BaseModel):
username: str = Field(min_length=2, max_length=64)
display_name: str = ""
token: str | None = Field(default=None, min_length=8, max_length=256)
@router.post("/login")
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict[str, Any]:
user = find_user_by_token(db, payload.token)
if not user:
raise HTTPException(status_code=401, detail="Неверный токен")
return {"ok": True, "user": user_to_dict(user), "token": payload.token.strip()}
@router.get("/me")
def me(user: User = Depends(get_current_user)) -> dict[str, Any]:
return {"ok": True, "user": user_to_dict(user)}
@router.get("/users")
def list_users(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
rows = db.scalars(select(User).where(User.is_active.is_(True)).order_by(User.id)).all()
return {
"ok": True,
"users": [user_to_dict(row) for row in rows],
"current_user_id": user.id,
}
@router.post("/users")
def register_user(
payload: CreateUserRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
new_user, plain_token = create_user(
db,
username=payload.username,
display_name=payload.display_name,
api_token=payload.token,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"ok": True,
"user": user_to_dict(new_user),
"token": plain_token,
"created_by": user.username,
}
+80 -62
View File
@@ -1,62 +1,80 @@
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.character.service import CharacterService
router = APIRouter()
class CharacterCardData(BaseModel):
name: str = "Ассистент"
description: str = ""
personality: str = ""
scenario: str = ""
first_mes: str = ""
mes_example: str = ""
system_prompt: str = ""
post_history_instructions: str = ""
tags: list[str] = Field(default_factory=list)
creator: str = ""
creator_notes: str = ""
alternate_greetings: list[str] = Field(default_factory=list)
character_version: str = "1.0"
appearance_tags: str = ""
appearance_prose: str = ""
lora_name: str = ""
lora_weight: float = 0.8
rp_persona_id: str = ""
sd_enabled: bool = True
class CharacterCardV2(BaseModel):
spec: str = "chara_card_v2"
spec_version: str = "2.0"
data: CharacterCardData
@router.get("/character")
def get_character() -> dict[str, Any]:
return CharacterService().get_card()
@router.put("/character")
def update_character(payload: CharacterCardV2) -> dict[str, Any]:
return CharacterService().save_card(payload.model_dump())
@router.get("/character/prompt")
def get_character_prompt() -> dict[str, str]:
service = CharacterService()
return {
"system_prompt": service.get_system_prompt(),
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
}
@router.post("/character/import")
def import_character(payload: dict[str, Any]) -> dict[str, Any]:
if not payload:
raise HTTPException(status_code=400, detail="Empty card")
return CharacterService().save_card(payload)
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.character.service import CharacterService
from app.db.base import get_db
from app.db.models import User
router = APIRouter()
class CharacterCardData(BaseModel):
name: str = "Ассистент"
description: str = ""
personality: str = ""
scenario: str = ""
first_mes: str = ""
mes_example: str = ""
system_prompt: str = ""
post_history_instructions: str = ""
tags: list[str] = Field(default_factory=list)
creator: str = ""
creator_notes: str = ""
alternate_greetings: list[str] = Field(default_factory=list)
character_version: str = "1.0"
appearance_tags: str = ""
appearance_prose: str = ""
lora_name: str = ""
lora_weight: float = 0.8
rp_persona_id: str = ""
sd_enabled: bool = True
class CharacterCardV2(BaseModel):
spec: str = "chara_card_v2"
spec_version: str = "2.0"
data: CharacterCardData
@router.get("/character")
def get_character(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
return CharacterService(db, user.id).get_card()
@router.put("/character")
def update_character(
payload: CharacterCardV2,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
return CharacterService(db, user.id).save_card(payload.model_dump())
@router.get("/character/prompt")
def get_character_prompt(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, str]:
service = CharacterService(db, user.id)
return {
"system_prompt": service.get_system_prompt(),
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
}
@router.post("/character/import")
def import_character(
payload: dict[str, Any],
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
if not payload:
raise HTTPException(status_code=400, detail="Empty card")
return CharacterService(db, user.id).save_card(payload)
+158 -70
View File
@@ -1,70 +1,158 @@
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",
},
)
import asyncio
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.api.chat_schemas import GenerationStatusOut, MessagesPageOut
from app.api.schemas import (
MessageCreate,
SessionCreate,
SessionDetailOut,
SessionOut,
)
from app.chat.generation import (
GenerationBusyError,
get_active_handle,
is_generation_active,
start_generation,
subscribe_generation,
)
from app.chat.service import ChatService
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
router = APIRouter()
@router.post("/sessions", response_model=SessionOut)
def create_session(payload: SessionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionOut:
service = ChatService(db, user.id)
return service.create_session(title=payload.title)
@router.get("/sessions", response_model=list[SessionOut])
def list_sessions(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[SessionOut]:
service = ChatService(db, user.id)
return service.list_sessions()
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
def get_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionDetailOut:
service = ChatService(db, user.id)
session = service.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.get("/sessions/{session_id}/messages", response_model=MessagesPageOut)
def list_messages(
session_id: int,
limit: int = 30,
before_id: int | None = None,
after_id: int | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> MessagesPageOut:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
messages, has_more = service.list_messages(
session_id,
limit=min(max(limit, 1), 100),
before_id=before_id,
after_id=after_id,
)
return MessagesPageOut(messages=messages, has_more=has_more)
@router.get("/sessions/{session_id}/generation", response_model=GenerationStatusOut)
def generation_status(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> GenerationStatusOut:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return GenerationStatusOut(active=is_generation_active(session_id))
@router.get("/sessions/{session_id}/generation/stream")
async def generation_stream(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> StreamingResponse:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
handle = get_active_handle(session_id)
if not handle:
raise HTTPException(status_code=404, detail="No active generation")
async def event_stream():
async for chunk in subscribe_generation(handle):
yield chunk
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
service = ChatService(db, user.id)
if not service.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@router.post("/sessions/{session_id}/messages")
async def send_message(
session_id: int,
payload: MessageCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> StreamingResponse:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
if is_generation_active(session_id):
raise HTTPException(status_code=409, detail="Generation already in progress")
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД.
service.save_user_message(session_id, payload.content)
try:
handle = await start_generation(session_id, user.id, payload.content)
except GenerationBusyError:
raise HTTPException(status_code=409, detail="Generation already in progress") from None
async def event_stream():
try:
async for chunk in subscribe_generation(handle):
yield chunk
except asyncio.CancelledError:
raise
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/sessions/{session_id}/context-preview")
def context_preview(
session_id: int,
query: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict:
service = ChatService(db, user.id)
return service.context_preview(session_id, query=query)
@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut
from app.chat.service import ChatService
from app.db.base import get_db
router = APIRouter()
@router.post("/sessions", response_model=SessionOut)
def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut:
service = ChatService(db)
return service.create_session(title=payload.title)
@router.get("/sessions", response_model=list[SessionOut])
def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]:
service = ChatService(db)
return service.list_sessions()
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut:
service = ChatService(db)
session = service.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
service = ChatService(db)
if not service.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@router.post("/sessions/{session_id}/messages")
async def send_message(
session_id: int,
payload: MessageCreate,
db: Session = Depends(get_db),
) -> StreamingResponse:
service = ChatService(db)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД.
service.save_user_message(session_id, payload.content)
async def event_stream():
async for chunk in service.stream_response(
session_id,
payload.content,
user_message_saved=True,
):
yield chunk
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+51
View File
@@ -0,0 +1,51 @@
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.db.models import Document
from app.rag.ingest import ingest_document_file
router = APIRouter()
@router.get("/documents")
def list_documents(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
docs = db.scalars(select(Document).where(Document.user_id == user.id).order_by(Document.created_at.desc())).all()
return [
{
"id": d.id,
"title": d.title,
"filename": d.filename,
"size_bytes": d.size_bytes,
"created_at": d.created_at.isoformat() if d.created_at else None,
}
for d in docs
]
@router.post("/documents/upload")
async def upload_document(
file: UploadFile = File(...),
title: str = Form(""),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="Empty file")
try:
doc = await ingest_document_file(
db,
user_id=user.id,
title=title.strip() or (file.filename or "document"),
filename=file.filename or "upload.txt",
raw_bytes=raw,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "document": doc}
+308 -223
View File
@@ -1,223 +1,308 @@
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.fitness.service import FitnessService
from app.fitness.structuring import structure_meal, structure_workout
from app.integrations.openfoodfacts import OpenFoodFactsClient
from app.integrations.wger import WgerClient
router = APIRouter()
class ProfileUpdate(BaseModel):
sex: str | None = None
age: int | None = None
height_cm: float | None = None
weight_kg: float | None = None
activity_level: str | None = None
goal: str | None = None
target_weight_kg: float | None = None
weekly_workouts: int | None = None
class MealCreate(BaseModel):
text: str = Field(min_length=1)
meal_type: str | None = None
class WaterCreate(BaseModel):
amount_ml: int = Field(gt=0)
class WeightCreate(BaseModel):
weight_kg: float = Field(gt=0)
body_fat_pct: float | None = None
chest_cm: float | None = None
waist_cm: float | None = None
notes: str = ""
class WorkoutCreate(BaseModel):
text: str = Field(min_length=1)
class ReminderUpdate(BaseModel):
enabled: bool | None = None
hour: int | None = Field(default=None, ge=0, le=23)
minute: int | None = Field(default=None, ge=0, le=59)
interval_hours: int | None = Field(default=None, ge=1, le=12)
@router.get("/fitness")
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
return FitnessService(db).snapshot()
@router.get("/fitness/summary")
def get_summary(
day: str | None = None,
db: Session = Depends(get_db),
) -> dict[str, Any]:
d = date.fromisoformat(day) if day else None
return FitnessService(db).get_daily_summary(d)
@router.get("/fitness/history")
def get_history(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db).get_history(days=days, end_day=end_day)
@router.get("/fitness/profile")
def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]:
profile = FitnessService(db).get_profile()
return profile or {"configured": False}
@router.put("/fitness/profile")
def update_profile(
payload: ProfileUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return FitnessService(db).set_profile(payload.model_dump(exclude_none=True))
@router.post("/fitness/profile/calc")
def calc_targets(
payload: ProfileUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
params = payload.model_dump(exclude_none=True)
if not params:
raise HTTPException(status_code=400, detail="No parameters")
return FitnessService(db).calc_targets(params)
@router.post("/fitness/meals")
async def create_meal(
payload: MealCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
service = FitnessService(db)
try:
structured = await structure_meal(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return service.log_meal(
description=structured.get("description") or payload.text,
meal_type=payload.meal_type or structured.get("meal_type") or "snack",
calories=float(structured.get("calories") or 0),
protein_g=float(structured.get("protein_g") or 0),
fat_g=float(structured.get("fat_g") or 0),
carbs_g=float(structured.get("carbs_g") or 0),
source="llm",
estimated=bool(structured.get("estimated", True)),
)
@router.post("/fitness/water")
def create_water(
payload: WaterCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return FitnessService(db).log_water(payload.amount_ml)
@router.post("/fitness/weight")
def create_weight(
payload: WeightCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return FitnessService(db).log_weight(
payload.weight_kg,
body_fat_pct=payload.body_fat_pct,
chest_cm=payload.chest_cm,
waist_cm=payload.waist_cm,
notes=payload.notes,
)
@router.post("/fitness/workouts")
async def create_workout(
payload: WorkoutCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
service = FitnessService(db)
try:
structured = await structure_workout(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return service.log_workout(
title=structured.get("title") or "Тренировка",
notes=structured.get("notes") or payload.text,
duration_min=structured.get("duration_min"),
exercises=structured.get("exercises"),
)
@router.get("/fitness/body-metrics")
def list_metrics(
limit: int = 30,
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
return FitnessService(db).list_body_metrics(limit=limit)
@router.delete("/fitness/meals/{log_id}")
def delete_meal(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
if not FitnessService(db).delete_food_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/water/{log_id}")
def delete_water(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
if not FitnessService(db).delete_water_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/workouts/{log_id}")
def delete_workout(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
if not FitnessService(db).delete_workout_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.get("/fitness/reminders")
def list_reminders(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
return FitnessService(db).list_reminders()
@router.put("/fitness/reminders/{kind}")
def update_reminder(
kind: str,
payload: ReminderUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return FitnessService(db).set_reminder(
kind,
enabled=payload.enabled,
hour=payload.hour,
minute=payload.minute,
interval_hours=payload.interval_hours,
)
@router.get("/fitness/lookup/food")
def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]:
return OpenFoodFactsClient().search(q, limit=limit)
@router.get("/fitness/lookup/exercise")
def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]:
return WgerClient().search_exercises(q, limit=limit)
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.fitness.service import FitnessService
from app.fitness.structuring import structure_meal, structure_workout
from app.integrations.openfoodfacts import OpenFoodFactsClient
from app.integrations.wger import WgerClient
router = APIRouter()
class ProfileUpdate(BaseModel):
sex: str | None = None
age: int | None = None
height_cm: float | None = None
weight_kg: float | None = None
activity_level: str | None = None
goal: str | None = None
target_weight_kg: float | None = None
weekly_workouts: int | None = None
baseline_steps: int | None = None
baseline_workout_kcal: float | None = None
class MealCreate(BaseModel):
text: str = Field(min_length=1)
meal_type: str | None = None
class WaterCreate(BaseModel):
amount_ml: int = Field(gt=0)
class WeightCreate(BaseModel):
weight_kg: float = Field(gt=0)
body_fat_pct: float | None = None
chest_cm: float | None = None
waist_cm: float | None = None
neck_cm: float | None = None
hip_cm: float | None = None
notes: str = ""
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
recorded_at: str | None = None
class BodyCompositionCalc(BaseModel):
weight_kg: float | None = None
height_cm: float | None = None
sex: str | None = None
neck_cm: float | None = None
waist_cm: float | None = None
hip_cm: float | None = None
body_fat_pct: float | None = None
class StepsCreate(BaseModel):
steps: int = Field(ge=0)
active_calories: float | None = None
notes: str = ""
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
logged_at: str | None = None
class WorkoutCreate(BaseModel):
text: str = Field(min_length=1)
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
logged_at: str | None = None
class ReminderUpdate(BaseModel):
enabled: bool | None = None
hour: int | None = Field(default=None, ge=0, le=23)
minute: int | None = Field(default=None, ge=0, le=59)
interval_hours: int | None = Field(default=None, ge=1, le=12)
@router.get("/fitness")
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return FitnessService(db, user.id).snapshot()
@router.get("/fitness/summary")
def get_summary(
day: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
d = date.fromisoformat(day) if day else None
return FitnessService(db, user.id).get_daily_summary(d)
@router.get("/fitness/workout-stats")
def get_workout_stats(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_workout_stats(days=days, end_day=end_day)
@router.get("/fitness/history")
def get_history(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_history(days=days, end_day=end_day)
@router.get("/fitness/profile")
def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
profile = FitnessService(db, user.id).get_profile()
return profile or {"configured": False}
@router.put("/fitness/profile")
def update_profile(
payload: ProfileUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).set_profile(payload.model_dump(exclude_none=True))
@router.post("/fitness/profile/calc")
def calc_targets(
payload: ProfileUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
params = payload.model_dump(exclude_none=True)
if not params:
raise HTTPException(status_code=400, detail="No parameters")
return FitnessService(db, user.id).calc_targets(params)
@router.post("/fitness/meals")
async def create_meal(
payload: MealCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
service = FitnessService(db, user.id)
try:
structured = await structure_meal(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return service.log_meal(
description=structured.get("description") or payload.text,
meal_type=payload.meal_type or structured.get("meal_type") or "snack",
calories=float(structured.get("calories") or 0),
protein_g=float(structured.get("protein_g") or 0),
fat_g=float(structured.get("fat_g") or 0),
carbs_g=float(structured.get("carbs_g") or 0),
source="llm",
estimated=bool(structured.get("estimated", True)),
)
@router.post("/fitness/water")
def create_water(
payload: WaterCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).log_water(payload.amount_ml)
@router.post("/fitness/weight")
def create_weight(
payload: WeightCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
day = date.fromisoformat(payload.day) if payload.day else None
return FitnessService(db, user.id).log_weight(
payload.weight_kg,
body_fat_pct=payload.body_fat_pct,
chest_cm=payload.chest_cm,
waist_cm=payload.waist_cm,
neck_cm=payload.neck_cm,
hip_cm=payload.hip_cm,
notes=payload.notes,
recorded_at=payload.recorded_at,
day=day,
days_ago=payload.days_ago,
)
@router.post("/fitness/body-composition/calc")
def calc_body_composition(
payload: BodyCompositionCalc,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).calc_body_composition(payload.model_dump(exclude_none=True))
@router.post("/fitness/steps")
def create_steps(
payload: StepsCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
day = date.fromisoformat(payload.day) if payload.day else None
return FitnessService(db, user.id).log_steps(
payload.steps,
active_calories=payload.active_calories,
notes=payload.notes,
day=day,
days_ago=payload.days_ago,
logged_at=payload.logged_at,
)
@router.delete("/fitness/steps/{log_id}")
def delete_steps(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_step_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.post("/fitness/workouts")
async def create_workout(
payload: WorkoutCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
service = FitnessService(db, user.id)
try:
structured = await structure_workout(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
day = date.fromisoformat(payload.day) if payload.day else None
return service.log_workout(
title=structured.get("title") or "Тренировка",
notes=structured.get("notes") or payload.text,
duration_min=structured.get("duration_min"),
exercises=structured.get("exercises"),
active_calories=structured.get("active_calories"),
total_calories=structured.get("total_calories"),
steps=structured.get("steps"),
day=day,
days_ago=payload.days_ago,
logged_at=payload.logged_at,
)
@router.get("/fitness/body-metrics")
def list_metrics(
limit: int = 30,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return FitnessService(db, user.id).list_body_metrics(limit=limit)
@router.delete("/fitness/meals/{log_id}")
def delete_meal(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_food_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/water/{log_id}")
def delete_water(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_water_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/workouts/{log_id}")
def delete_workout(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_workout_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.get("/fitness/reminders")
def list_reminders(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return FitnessService(db, user.id).list_reminders()
@router.put("/fitness/reminders/{kind}")
def update_reminder(
kind: str,
payload: ReminderUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).set_reminder(
kind,
enabled=payload.enabled,
hour=payload.hour,
minute=payload.minute,
interval_hours=payload.interval_hours,
)
@router.get("/fitness/lookup/food")
def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]:
return OpenFoodFactsClient().search(q, limit=limit)
@router.get("/fitness/lookup/exercise")
def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]:
return WgerClient().search_exercises(q, limit=limit)
+130 -127
View File
@@ -1,127 +1,130 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.db.models import ChatSession
from app.memory.extract import extract_after_turn
from app.memory.service import MemoryService
router = APIRouter()
class ProfileUpdate(BaseModel):
updates: dict[str, Any] = Field(default_factory=dict)
class FactCreate(BaseModel):
content: str = Field(min_length=1)
category: str = "fact"
importance: int = Field(default=3, ge=1, le=5)
session_id: int | None = None
class SessionSummaryUpdate(BaseModel):
summary: str = Field(min_length=1)
message_count: int = 0
class ExtractRequest(BaseModel):
session_id: int
user_text: str = Field(min_length=1)
assistant_text: str = ""
force: bool = False
@router.get("/memory")
def get_memory_snapshot(
session_id: int | None = None,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return MemoryService(db).snapshot(session_id)
@router.get("/profile")
def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]:
return MemoryService(db).get_profile()
@router.put("/profile")
def update_profile(
payload: ProfileUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return MemoryService(db).update_profile(payload.updates)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/memory/facts")
def list_facts(
query: str | None = None,
category: str | None = None,
limit: int = 30,
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
return MemoryService(db).recall_memories(query=query, category=category, limit=limit)
@router.post("/memory/facts")
def create_fact(
payload: FactCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return MemoryService(db).remember_fact(
payload.content,
category=payload.category,
session_id=payload.session_id,
importance=payload.importance,
source="api",
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/memory/facts/{memory_id}")
def forget_fact(memory_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return MemoryService(db).forget_memory(memory_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/memory/extract")
async def extract_memories(
payload: ExtractRequest,
db: Session = Depends(get_db),
) -> dict:
session = db.get(ChatSession, payload.session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return await extract_after_turn(
db,
payload.session_id,
payload.user_text,
payload.assistant_text,
force=payload.force,
)
@router.put("/memory/sessions/{session_id}/summary")
def update_session_summary(
session_id: int,
payload: SessionSummaryUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return MemoryService(db).update_session_summary(
session_id,
payload.summary,
message_count=payload.message_count,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.db.models import ChatSession
from app.memory.extract import extract_after_turn
from app.memory.service import MemoryService
router = APIRouter()
class ProfileUpdate(BaseModel):
updates: dict[str, Any] = Field(default_factory=dict)
class FactCreate(BaseModel):
content: str = Field(min_length=1)
category: str = "fact"
importance: int = Field(default=3, ge=1, le=5)
session_id: int | None = None
class SessionSummaryUpdate(BaseModel):
summary: str = Field(min_length=1)
message_count: int = 0
class ExtractRequest(BaseModel):
session_id: int
user_text: str = Field(min_length=1)
assistant_text: str = ""
force: bool = False
@router.get("/memory")
def get_memory_snapshot(
session_id: int | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return MemoryService(db, user.id).snapshot(session_id)
@router.get("/profile")
def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return MemoryService(db, user.id).get_profile()
@router.put("/profile")
def update_profile(
payload: ProfileUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return MemoryService(db, user.id).update_profile(payload.updates)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/memory/facts")
def list_facts(
query: str | None = None,
category: str | None = None,
limit: int = 30,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return MemoryService(db, user.id).recall_memories(query=query, category=category, limit=limit)
@router.post("/memory/facts")
def create_fact(
payload: FactCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return MemoryService(db, user.id).remember_fact(
payload.content,
category=payload.category,
session_id=payload.session_id,
importance=payload.importance,
source="api",
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/memory/facts/{memory_id}")
def forget_fact(memory_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return MemoryService(db, user.id).forget_memory(memory_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/memory/extract")
async def extract_memories(
payload: ExtractRequest,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict:
session = db.get(ChatSession, payload.session_id)
if not session or session.user_id != user.id:
raise HTTPException(status_code=404, detail="Session not found")
return await extract_after_turn(
db,
payload.session_id,
payload.user_text,
payload.assistant_text,
user_id=user.id,
force=payload.force,
)
@router.put("/memory/sessions/{session_id}/summary")
def update_session_summary(
session_id: int,
payload: SessionSummaryUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return MemoryService(db, user.id).update_session_summary(
session_id,
payload.summary,
message_count=payload.message_count,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+102 -100
View File
@@ -1,100 +1,102 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.schemas import PomodoroStart, PomodoroStop
from app.db.base import get_db
from app.pomodoro.service import PomodoroService
router = APIRouter()
def _handle_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
@router.get("/status")
def get_status(db: Session = Depends(get_db)) -> dict:
return PomodoroService(db).get_status()
@router.post("/start")
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/pause")
def pause_pomodoro(db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).pause()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/resume")
def resume_pomodoro(db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).resume()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/stop")
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).stop(
result=payload.result,
completed=payload.completed,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]:
return PomodoroService(db).history(limit=limit)
@router.post("/work/start")
def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start_work(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/short/start")
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start_short_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/long/start")
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start_long_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/cycle/reset")
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db)) -> dict:
return PomodoroService(db).reset_cycle(clear_task=clear_task)
@router.post("/skip")
def skip_phase(db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).skip_phase()
except ValueError as exc:
raise _handle_value_error(exc) from exc
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.schemas import PomodoroStart, PomodoroStop
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.pomodoro.service import PomodoroService
router = APIRouter()
def _handle_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
@router.get("/status")
def get_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db, user.id).get_status()
@router.post("/start")
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/pause")
def pause_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).pause()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/resume")
def resume_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).resume()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/stop")
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).stop(
result=payload.result,
completed=payload.completed,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
return PomodoroService(db, user.id).history(limit=limit)
@router.post("/work/start")
def start_work(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start_work(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/short/start")
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start_short_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/long/start")
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start_long_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/cycle/reset")
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db, user.id).reset_cycle(clear_task=clear_task)
@router.post("/skip")
def skip_phase(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).skip_phase()
except ValueError as exc:
raise _handle_value_error(exc) from exc
+78 -76
View File
@@ -1,76 +1,78 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.projects.service import ProjectService
router = APIRouter()
class GiteaBinding(BaseModel):
gitea_owner: str = Field(min_length=1)
gitea_repo: str = Field(min_length=1)
default_branch: str = "main"
class WorkItemCreate(BaseModel):
text: str = Field(min_length=1)
project_slug: str | None = None
@router.get("/projects")
def list_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
return ProjectService(db).list_projects()
@router.post("/projects/sync-taiga")
def sync_taiga_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
try:
return ProjectService(db).sync_taiga_projects()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/projects/{taiga_slug}/gitea")
def bind_gitea(
taiga_slug: str,
payload: GiteaBinding,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return ProjectService(db).bind_gitea(
taiga_slug,
payload.gitea_owner,
payload.gitea_repo,
payload.default_branch,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/work-items")
async def create_work_item(
payload: WorkItemCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return await ProjectService(db).create_work_item(
payload.text,
project_slug=payload.project_slug,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/work-items")
def list_work_items(
limit: int = 30,
status: str | None = None,
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
return ProjectService(db).list_work_items(limit=limit, status=status)
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.projects.service import ProjectService
router = APIRouter()
class GiteaBinding(BaseModel):
gitea_owner: str = Field(min_length=1)
gitea_repo: str = Field(min_length=1)
default_branch: str = "main"
class WorkItemCreate(BaseModel):
text: str = Field(min_length=1)
project_slug: str | None = None
@router.get("/projects")
def list_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return ProjectService(db, user.id).list_projects()
@router.post("/projects/sync-taiga")
def sync_taiga_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
try:
return ProjectService(db, user.id).sync_taiga_projects()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/projects/{taiga_slug}/gitea")
def bind_gitea(
taiga_slug: str,
payload: GiteaBinding,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return ProjectService(db, user.id).bind_gitea(
taiga_slug,
payload.gitea_owner,
payload.gitea_repo,
payload.default_branch,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/work-items")
async def create_work_item(
payload: WorkItemCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return await ProjectService(db, user.id).create_work_item(
payload.text,
project_slug=payload.project_slug,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/work-items")
def list_work_items(
limit: int = 30,
status: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return ProjectService(db, user.id).list_work_items(limit=limit, status=status)
+128 -124
View File
@@ -1,124 +1,128 @@
from datetime import datetime, timezone
from typing import Any
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.homelab.context import resolve_timezone
from app.reminders.service import RemindersService
router = APIRouter()
class ReminderCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
due_at: str = Field(description="ISO datetime, например 2027-05-12T12:16:00")
notes: str = ""
all_day: bool = False
recurrence: str = "none"
class ReminderUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
due_at: str | None = None
notes: str | None = None
all_day: bool | None = None
recurrence: str | None = None
enabled: bool | None = None
@router.get("")
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
return RemindersService(db).snapshot()
@router.get("/upcoming")
def list_upcoming(
limit: int = Query(30, ge=1, le=100),
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
return RemindersService(db).list_upcoming(limit=limit)
@router.get("/calendar")
def calendar(
year: int = Query(..., ge=2000, le=2100),
month: int = Query(..., ge=1, le=12),
db: Session = Depends(get_db),
) -> dict[str, Any]:
tz_name = resolve_timezone(db)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
start = datetime(year, month, 1, tzinfo=tz)
if month == 12:
end = datetime(year + 1, 1, 1, tzinfo=tz)
else:
end = datetime(year, month + 1, 1, tzinfo=tz)
service = RemindersService(db)
items = service.list_in_range(
date_from=start.astimezone(timezone.utc),
date_to=end.astimezone(timezone.utc),
)
return {
"year": year,
"month": month,
"timezone": tz_name,
"reminders": items,
}
@router.post("")
def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return RemindersService(db).create(
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/{reminder_id}")
def update_reminder(
reminder_id: int,
payload: ReminderUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return RemindersService(db).update(
reminder_id,
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
enabled=payload.enabled,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/{reminder_id}")
def delete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return RemindersService(db).delete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{reminder_id}/complete")
def complete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return RemindersService(db).complete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
from datetime import datetime, timezone
from typing import Any
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.homelab.context import resolve_timezone
from app.reminders_scoped.service import RemindersService
router = APIRouter()
class ReminderCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
due_at: str = Field(description="ISO datetime, например 2027-05-12T12:16:00")
notes: str = ""
all_day: bool = False
recurrence: str = "none"
class ReminderUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
due_at: str | None = None
notes: str | None = None
all_day: bool | None = None
recurrence: str | None = None
enabled: bool | None = None
@router.get("")
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return RemindersService(db, user.id).snapshot()
@router.get("/upcoming")
def list_upcoming(
limit: int = Query(30, ge=1, le=100),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return RemindersService(db, user.id).list_upcoming(limit=limit)
@router.get("/calendar")
def calendar(
year: int = Query(..., ge=2000, le=2100),
month: int = Query(..., ge=1, le=12),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
tz_name = resolve_timezone(db, user.id)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
start = datetime(year, month, 1, tzinfo=tz)
if month == 12:
end = datetime(year + 1, 1, 1, tzinfo=tz)
else:
end = datetime(year, month + 1, 1, tzinfo=tz)
service = RemindersService(db, user.id)
items = service.list_in_range(
date_from=start.astimezone(timezone.utc),
date_to=end.astimezone(timezone.utc),
)
return {
"year": year,
"month": month,
"timezone": tz_name,
"reminders": items,
}
@router.post("")
def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return RemindersService(db, user.id).create(
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/{reminder_id}")
def update_reminder(
reminder_id: int,
payload: ReminderUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return RemindersService(db, user.id).update(
reminder_id,
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
enabled=payload.enabled,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/{reminder_id}")
def delete_reminder(reminder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return RemindersService(db, user.id).delete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{reminder_id}/complete")
def complete_reminder(reminder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return RemindersService(db, user.id).complete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+34
View File
@@ -0,0 +1,34 @@
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.settings.service import SETTING_KEYS, SettingsService
router = APIRouter()
class SettingsPatch(BaseModel):
openrouter_model: str | None = None
memory_extract_model: str | None = None
openrouter_reasoning_effort: str | None = None
rag_enabled: bool | None = None
rag_top_k: int | None = Field(default=None, ge=1, le=50)
@router.get("/settings")
def get_settings_route(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return SettingsService(db).snapshot()
@router.patch("/settings")
def patch_settings_route(
payload: SettingsPatch,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
updates = payload.model_dump(exclude_unset=True)
return SettingsService(db).patch(updates)
+118 -116
View File
@@ -1,116 +1,118 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.shopping.service import ShoppingService
router = APIRouter()
class ListCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ListRename(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ItemInput(BaseModel):
text: str = Field(min_length=1, max_length=500)
quantity: float | None = None
unit: str = ""
class ItemsAdd(BaseModel):
list_id: int | None = None
list_name: str | None = None
items: list[ItemInput] = Field(min_length=1)
class ItemChecked(BaseModel):
checked: bool
@router.get("")
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
return ShoppingService(db).snapshot()
@router.get("/lists")
def list_lists(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
return ShoppingService(db).list_lists(include_items=True)
@router.post("/lists")
def create_list(payload: ListCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).create_list(payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/lists/{list_id}")
def get_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
data = ShoppingService(db).get_list(list_id=list_id)
if not data:
raise HTTPException(status_code=404, detail="List not found")
return data
@router.patch("/lists/{list_id}")
def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).rename_list(list_id, payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/lists/{list_id}")
def delete_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).delete_list(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/items")
def add_items(payload: ItemsAdd, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).add_items(
[i.model_dump() for i in payload.items],
list_id=payload.list_id,
list_name=payload.list_name,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/items/{item_id}")
def set_item_checked(
item_id: int,
payload: ItemChecked,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return ShoppingService(db).set_item_checked(item_id, payload.checked)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/items/{item_id}")
def remove_item(item_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).remove_item(item_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/lists/{list_id}/clear-checked")
def clear_checked(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).clear_checked(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.shopping.service import ShoppingService
router = APIRouter()
class ListCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ListRename(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ItemInput(BaseModel):
text: str = Field(min_length=1, max_length=500)
quantity: float | None = None
unit: str = ""
class ItemsAdd(BaseModel):
list_id: int | None = None
list_name: str | None = None
items: list[ItemInput] = Field(min_length=1)
class ItemChecked(BaseModel):
checked: bool
@router.get("")
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return ShoppingService(db, user.id).snapshot()
@router.get("/lists")
def list_lists(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return ShoppingService(db, user.id).list_lists(include_items=True)
@router.post("/lists")
def create_list(payload: ListCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).create_list(payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/lists/{list_id}")
def get_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
data = ShoppingService(db, user.id).get_list(list_id=list_id)
if not data:
raise HTTPException(status_code=404, detail="List not found")
return data
@router.patch("/lists/{list_id}")
def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).rename_list(list_id, payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/lists/{list_id}")
def delete_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).delete_list(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/items")
def add_items(payload: ItemsAdd, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).add_items(
[i.model_dump() for i in payload.items],
list_id=payload.list_id,
list_name=payload.list_name,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/items/{item_id}")
def set_item_checked(
item_id: int,
payload: ItemChecked,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).set_item_checked(item_id, payload.checked)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/items/{item_id}")
def remove_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).remove_item(item_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/lists/{list_id}/clear-checked")
def clear_checked(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).clear_checked(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+106 -118
View File
@@ -1,118 +1,106 @@
import hashlib
import hmac
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.base import SessionLocal, get_db
from app.db.models import ChatSession, Message, ProjectBinding
from app.projects.service import ProjectService
router = APIRouter()
logger = logging.getLogger(__name__)
def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
if not secret:
return True
if not signature:
return False
if signature.startswith("sha256="):
signature = signature[7:]
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def _post_close_notice(results: list[dict[str, Any]], owner: str, repo: str) -> None:
if not results:
return
db = SessionLocal()
try:
session = db.scalar(
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
)
if not session:
session = ChatSession(title="Git")
db.add(session)
db.commit()
db.refresh(session)
lines = [f"🔀 **Push** `{owner}/{repo}`"]
for item in results:
if "closed" in item:
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
elif "error" in item:
lines.append(f"- ошибка: {item['error']}")
db.add(Message(session_id=session.id, role="notice", content="\n".join(lines)))
db.commit()
finally:
db.close()
@router.post("/webhooks/gitea")
async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
body = await request.body()
settings = get_settings()
signature = (
request.headers.get("X-Gitea-Signature")
or request.headers.get("X-Gogs-Signature")
or request.headers.get("X-Hub-Signature-256")
)
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
payload = json.loads(body)
if payload.get("secret") and settings.gitea_webhook_secret:
if payload.get("secret") != settings.gitea_webhook_secret:
raise HTTPException(status_code=401, detail="Invalid webhook secret")
event = request.headers.get("X-Gitea-Event", "")
if event != "push":
return {"ok": True, "skipped": event}
repo = payload.get("repository", {})
owner = repo.get("owner", {}).get("login", "")
repo_name = repo.get("name", "")
if not owner or not repo_name:
raise HTTPException(status_code=400, detail="Missing repository info")
binding = db.scalar(
select(ProjectBinding).where(
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo_name,
)
)
if not binding:
return {"ok": True, "skipped": "unknown repo"}
commits = list(payload.get("commits") or [])
if not commits:
head = payload.get("head_commit")
if head:
commits = [head]
logger.info(
"Gitea push %s/%s ref=%s commits=%d",
owner,
repo_name,
payload.get("ref", ""),
len(commits),
)
service = ProjectService(db)
results = service.process_push(owner, repo_name, commits)
if results:
logger.info("Gitea push results: %s", results)
else:
logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name)
_post_close_notice(results, owner, repo_name)
return {"ok": True, "results": results, "commits_processed": len(commits)}
import hashlib
import hmac
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.chat.notice_inbox import post_notice_to_latest_chat
from app.config import get_settings
from app.db.base import get_db
from app.db.models import ProjectBinding
from app.projects.service import ProjectService
router = APIRouter()
logger = logging.getLogger(__name__)
def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
if not secret:
return True
if not signature:
return False
if signature.startswith("sha256="):
signature = signature[7:]
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def _post_close_notice(
results: list[dict[str, Any]], owner: str, repo: str, user_id: int
) -> None:
if not results:
return
lines = [f"🔀 **Push** `{owner}/{repo}`"]
for item in results:
if "closed" in item:
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
elif "error" in item:
lines.append(f"- ошибка: {item['error']}")
post_notice_to_latest_chat("\n".join(lines), user_id)
@router.post("/webhooks/gitea")
async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
body = await request.body()
settings = get_settings()
signature = (
request.headers.get("X-Gitea-Signature")
or request.headers.get("X-Gogs-Signature")
or request.headers.get("X-Hub-Signature-256")
)
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
payload = json.loads(body)
if payload.get("secret") and settings.gitea_webhook_secret:
if payload.get("secret") != settings.gitea_webhook_secret:
raise HTTPException(status_code=401, detail="Invalid webhook secret")
event = request.headers.get("X-Gitea-Event", "")
if event != "push":
return {"ok": True, "skipped": event}
repo = payload.get("repository", {})
owner = repo.get("owner", {}).get("login", "")
repo_name = repo.get("name", "")
if not owner or not repo_name:
raise HTTPException(status_code=400, detail="Missing repository info")
binding = db.scalar(
select(ProjectBinding).where(
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo_name,
)
)
if not binding:
return {"ok": True, "skipped": "unknown repo"}
commits = list(payload.get("commits") or [])
if not commits:
head = payload.get("head_commit")
if head:
commits = [head]
logger.info(
"Gitea push %s/%s ref=%s commits=%d",
owner,
repo_name,
payload.get("ref", ""),
len(commits),
)
service = ProjectService(db, binding.user_id)
results = service.process_push(owner, repo_name, commits)
if results:
logger.info("Gitea push results: %s", results)
else:
logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name)
_post_close_notice(results, owner, repo_name, binding.user_id)
return {"ok": True, "results": results, "commits_processed": len(commits)}
+5
View File
@@ -0,0 +1,5 @@
from app.auth.deps import get_current_user
from app.auth.service import create_user, find_user_by_token
from app.auth.tokens import hash_token, verify_token
__all__ = ["get_current_user", "hash_token", "verify_token"]
+34
View File
@@ -0,0 +1,34 @@
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.tokens import hash_token
from app.db.base import get_db
from app.db.models import User
def _extract_token(request: Request) -> str | None:
auth = request.headers.get("Authorization", "")
if auth.lower().startswith("bearer "):
token = auth[7:].strip()
if token:
return token
header = request.headers.get("X-API-Token", "").strip()
return header or None
def get_current_user(
request: Request,
db: Session = Depends(get_db),
) -> User:
token = _extract_token(request)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API token")
token_hash = hash_token(token)
user = db.scalar(
select(User).where(User.api_token_hash == token_hash, User.is_active.is_(True))
)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API token")
return user
+61
View File
@@ -0,0 +1,61 @@
import secrets
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.tokens import hash_token
from app.db.models import CharacterCard, User
from app.character.card import DEFAULT_CARD, normalize_card
import json
def find_user_by_token(db: Session, token: str) -> User | None:
token_hash = hash_token(token.strip())
return db.scalar(
select(User).where(User.api_token_hash == token_hash, User.is_active.is_(True))
)
def user_to_dict(user: User) -> dict[str, Any]:
return {
"id": user.id,
"username": user.username,
"display_name": user.display_name or user.username,
}
def create_user(
db: Session,
*,
username: str,
display_name: str = "",
api_token: str | None = None,
) -> tuple[User, str]:
clean = username.strip().lower()
if not clean:
raise ValueError("username не может быть пустым")
existing = db.scalar(select(User).where(User.username == clean))
if existing:
raise ValueError(f"Пользователь «{clean}» уже существует")
plain_token = (api_token or "").strip() or secrets.token_urlsafe(32)
user = User(
username=clean,
display_name=(display_name or clean).strip(),
api_token_hash=hash_token(plain_token),
is_active=True,
)
db.add(user)
db.flush()
card = normalize_card(DEFAULT_CARD)
db.add(
CharacterCard(
user_id=user.id,
card_json=json.dumps(card, ensure_ascii=False),
)
)
db.commit()
db.refresh(user)
return user, plain_token
+9
View File
@@ -0,0 +1,9 @@
import hashlib
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def verify_token(plain: str, token_hash: str) -> bool:
return hash_token(plain) == token_hash
+100 -100
View File
@@ -1,100 +1,100 @@
from typing import Any
TOOLS_INSTRUCTIONS = """
Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс).
Обязательные правила:
- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент.
- Никогда не выдумывай статус таймера или список задач.
- После вызова инструмента кратко объясни результат пользователю по-человечески.
- Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break,
stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history.
- Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items.
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
- Фитнес: get_fitness_summary (date/days_ago), get_fitness_history, set_fitness_profile, log_meal, log_water, log_weight, log_workout,
- «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history.
calc_fitness_targets, lookup_food, lookup_exercise, set_fitness_reminder.
- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary.
- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
- Никогда не пиши «ожидаю ответа от системы».
- В текстовых ответах пользователю не используй эмодзи.
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
- Утренний брифинг (погода + новости) → get_morning_briefing.
- Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй.
- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list.
- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки.
- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.
- «Напомни через 15 минут», «завтра утром», «12 мая в 9:00» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]).
- День рождения, Новый год и другие праздники → recurrence yearly.
- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе.
""".strip()
DEFAULT_CARD: dict[str, Any] = {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {
"name": "Домашний ассистент",
"description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.",
"personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.",
"scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.",
"first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?",
"mes_example": "",
"system_prompt": "",
"post_history_instructions": "",
"alternate_greetings": [],
"tags": ["assistant", "home", "pomodoro"],
"creator": "",
"creator_notes": "",
"character_version": "1.0",
"appearance_tags": "",
"appearance_prose": "",
"lora_name": "",
"lora_weight": 0.8,
"rp_persona_id": "",
"sd_enabled": True,
},
}
def normalize_card(raw: dict[str, Any]) -> dict[str, Any]:
if "data" in raw and isinstance(raw["data"], dict):
card = {
"spec": raw.get("spec", "chara_card_v2"),
"spec_version": raw.get("spec_version", "2.0"),
"data": {**DEFAULT_CARD["data"], **raw["data"]},
}
return card
if "name" in raw or "description" in raw:
return {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {**DEFAULT_CARD["data"], **raw},
}
return DEFAULT_CARD.copy()
def build_system_prompt(card: dict[str, Any]) -> str:
data = card.get("data", {})
parts: list[str] = []
name = data.get("name", "Ассистент")
parts.append(f"Ты — {name}.")
if data.get("system_prompt"):
parts.append(data["system_prompt"])
if data.get("description"):
parts.append(data["description"])
if data.get("personality"):
parts.append(f"Характер: {data['personality']}")
if data.get("scenario"):
parts.append(f"Сценарий: {data['scenario']}")
if data.get("post_history_instructions"):
parts.append(data["post_history_instructions"])
parts.append(TOOLS_INSTRUCTIONS)
return "\n\n".join(part for part in parts if part.strip())
from typing import Any
TOOLS_INSTRUCTIONS = """
Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс).
Обязательные правила:
- Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент.
- Никогда не выдумывай статус таймера или список задач.
- После вызова инструмента кратко объясни результат пользователю по-человечески.
- Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break,
stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history.
- Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items.
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
- Фитнес: get_fitness_summary (date/days_ago), get_fitness_history, set_fitness_profile, log_meal, log_water, log_weight (neck_cm/waist_cm/hip_cm → Navy), log_workout,
- «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history.
calc_fitness_targets, calc_body_composition (расчёт Navy/WHR/LBM/FFMI без записи), lookup_food, lookup_exercise, set_fitness_reminder.
- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary.
- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
- Никогда не пиши «ожидаю ответа от системы».
- В текстовых ответах пользователю не используй эмодзи.
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
- Утренний брифинг (погода + новости) → get_morning_briefing.
- Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй.
- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list.
- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки.
- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.
- «Напомни через 15 минут», «завтра утром», «12 мая в 9:00» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]).
- День рождения, Новый год и другие праздники → recurrence yearly.
- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе.
""".strip()
DEFAULT_CARD: dict[str, Any] = {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {
"name": "Домашний ассистент",
"description": "Дружелюбный ИИ-помощник для дома. Отвечает на вопросы, даёт советы, помогает с помидоро-таймером.",
"personality": "Тёплый, остроумный, по делу. Говорит на русском. Может шутить, но не перегибает.",
"scenario": "Пользователь общается с ассистентом дома через веб-интерфейс.",
"first_mes": "Привет! Чем займёмся — поболтаем или заведём помидоро?",
"mes_example": "",
"system_prompt": "",
"post_history_instructions": "",
"alternate_greetings": [],
"tags": ["assistant", "home", "pomodoro"],
"creator": "",
"creator_notes": "",
"character_version": "1.0",
"appearance_tags": "",
"appearance_prose": "",
"lora_name": "",
"lora_weight": 0.8,
"rp_persona_id": "",
"sd_enabled": True,
},
}
def normalize_card(raw: dict[str, Any]) -> dict[str, Any]:
if "data" in raw and isinstance(raw["data"], dict):
card = {
"spec": raw.get("spec", "chara_card_v2"),
"spec_version": raw.get("spec_version", "2.0"),
"data": {**DEFAULT_CARD["data"], **raw["data"]},
}
return card
if "name" in raw or "description" in raw:
return {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {**DEFAULT_CARD["data"], **raw},
}
return DEFAULT_CARD.copy()
def build_system_prompt(card: dict[str, Any]) -> str:
data = card.get("data", {})
parts: list[str] = []
name = data.get("name", "Ассистент")
parts.append(f"Ты — {name}.")
if data.get("system_prompt"):
parts.append(data["system_prompt"])
if data.get("description"):
parts.append(data["description"])
if data.get("personality"):
parts.append(f"Характер: {data['personality']}")
if data.get("scenario"):
parts.append(f"Сценарий: {data['scenario']}")
if data.get("post_history_instructions"):
parts.append(data["post_history_instructions"])
parts.append(TOOLS_INSTRUCTIONS)
return "\n\n".join(part for part in parts if part.strip())
+43 -27
View File
@@ -1,27 +1,43 @@
import json
from pathlib import Path
from typing import Any
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
CARD_PATH = Path("./data/character.json")
class CharacterService:
def get_card(self) -> dict[str, Any]:
if CARD_PATH.is_file():
try:
raw = json.loads(CARD_PATH.read_text(encoding="utf-8"))
return normalize_card(raw)
except (json.JSONDecodeError, OSError):
pass
return normalize_card(DEFAULT_CARD)
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
card = normalize_card(raw)
CARD_PATH.parent.mkdir(parents=True, exist_ok=True)
CARD_PATH.write_text(json.dumps(card, ensure_ascii=False, indent=2), encoding="utf-8")
return card
def get_system_prompt(self) -> str:
return build_system_prompt(self.get_card())
import json
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.character.card import DEFAULT_CARD, build_system_prompt, normalize_card
from app.db.models import CharacterCard
class CharacterService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
def get_card(self) -> dict[str, Any]:
row = self.db.scalar(
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
)
if not row:
return normalize_card(DEFAULT_CARD)
try:
return normalize_card(json.loads(row.card_json or "{}"))
except json.JSONDecodeError:
return normalize_card(DEFAULT_CARD)
def save_card(self, raw: dict[str, Any]) -> dict[str, Any]:
card = normalize_card(raw)
row = self.db.scalar(
select(CharacterCard).where(CharacterCard.user_id == self.user_id).limit(1)
)
if not row:
row = CharacterCard(user_id=self.user_id, card_json="{}")
self.db.add(row)
self.db.flush()
row.card_json = json.dumps(card, ensure_ascii=False)
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return card
def get_system_prompt(self) -> str:
return build_system_prompt(self.get_card())
+95
View File
@@ -0,0 +1,95 @@
import asyncio
import logging
from dataclasses import dataclass, field
from app.chat.service import ChatService
from app.db.base import SessionLocal
logger = logging.getLogger(__name__)
class GenerationBusyError(Exception):
"""Сессия уже генерирует ответ."""
@dataclass
class GenerationHandle:
session_id: int
user_id: int
user_text: str
task: asyncio.Task | None = None
subscribers: list[asyncio.Queue[str | None]] = field(default_factory=list)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
async def broadcast(self, chunk: str | None) -> None:
async with self._lock:
targets = list(self.subscribers)
for queue in targets:
try:
queue.put_nowait(chunk)
except asyncio.QueueFull:
logger.debug("generation queue full for session=%s, dropping subscriber", self.session_id)
def add_subscriber(self) -> asyncio.Queue[str | None]:
queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=512)
self.subscribers.append(queue)
return queue
def remove_subscriber(self, queue: asyncio.Queue[str | None]) -> None:
if queue in self.subscribers:
self.subscribers.remove(queue)
_registry: dict[int, GenerationHandle] = {}
_registry_lock = asyncio.Lock()
def is_generation_active(session_id: int) -> bool:
return session_id in _registry
def get_active_handle(session_id: int) -> GenerationHandle | None:
return _registry.get(session_id)
async def _run_generation(handle: GenerationHandle) -> None:
db = SessionLocal()
try:
service = ChatService(db, handle.user_id)
async for chunk in service.stream_response(
handle.session_id,
handle.user_text,
user_message_saved=True,
):
await handle.broadcast(chunk)
except Exception as exc:
logger.exception("Background generation failed session=%s", handle.session_id)
await handle.broadcast(ChatService._sse("error", {"message": str(exc)}))
finally:
await handle.broadcast(None)
db.close()
async with _registry_lock:
if _registry.get(handle.session_id) is handle:
_registry.pop(handle.session_id, None)
async def start_generation(session_id: int, user_id: int, user_text: str) -> GenerationHandle:
async with _registry_lock:
if session_id in _registry:
raise GenerationBusyError()
handle = GenerationHandle(session_id=session_id, user_id=user_id, user_text=user_text)
_registry[session_id] = handle
handle.task = asyncio.create_task(_run_generation(handle))
return handle
async def subscribe_generation(handle: GenerationHandle):
queue = handle.add_subscriber()
try:
while True:
chunk = await queue.get()
if chunk is None:
break
yield chunk
finally:
handle.remove_subscriber(queue)
+47 -44
View File
@@ -1,44 +1,47 @@
"""Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю)."""
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import ChatSession, Message
DISPLAY_ONLY_ROLES = frozenset({"notice", "character"})
def _latest_chat_session(db) -> ChatSession:
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)
return session
def post_notice_to_latest_chat(content: str) -> int | None:
"""Сохраняет notice в последний активный чат. Возвращает session_id."""
db = SessionLocal()
try:
session = _latest_chat_session(db)
db.add(Message(session_id=session.id, role="notice", content=content))
db.commit()
return session.id
finally:
db.close()
def post_character_comment_to_latest_chat(content: str) -> int | None:
"""Реплика персонажа в UI; не попадает в контекст LLM (в отличие от assistant)."""
db = SessionLocal()
try:
session = _latest_chat_session(db)
db.add(Message(session_id=session.id, role="character", content=content))
db.commit()
return session.id
finally:
db.close()
"""Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю)."""
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import ChatSession, Message
DISPLAY_ONLY_ROLES = frozenset({"notice", "character"})
def _latest_chat_session(db, user_id: int) -> ChatSession:
session = db.scalar(
select(ChatSession)
.where(ChatSession.user_id == user_id)
.order_by(ChatSession.updated_at.desc())
.limit(1)
)
if not session:
session = ChatSession(user_id=user_id, title="Уведомления")
db.add(session)
db.commit()
db.refresh(session)
return session
def post_notice_to_latest_chat(content: str, user_id: int) -> int | None:
"""Сохраняет notice в последний активный чат пользователя. Возвращает session_id."""
db = SessionLocal()
try:
session = _latest_chat_session(db, user_id)
db.add(Message(session_id=session.id, role="notice", content=content))
db.commit()
return session.id
finally:
db.close()
def post_character_comment_to_latest_chat(content: str, user_id: int) -> int | None:
"""Реплика персонажа в UI; не попадает в контекст LLM (в отличие от assistant)."""
db = SessionLocal()
try:
session = _latest_chat_session(db, user_id)
db.add(Message(session_id=session.id, role="character", content=content))
db.commit()
return session.id
finally:
db.close()
+432 -397
View File
@@ -1,397 +1,432 @@
import json
from typing import Any
from app.db.models import PomodoroSession
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
PHASE_LABELS = {
PHASE_WORK: "Работа",
PHASE_SHORT_BREAK: "Короткий перерыв",
PHASE_LONG_BREAK: "Длинный перерыв",
}
def _format_time(seconds: int) -> str:
minutes, secs = divmod(max(0, seconds), 60)
return f"{minutes:02d}:{secs:02d}"
def format_phase_completed_notice(
session: PomodoroSession,
next_phase: str | None,
) -> str:
phase_label = PHASE_LABELS.get(session.phase, session.phase)
task = session.task_note or "без описания"
lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
if next_phase == PHASE_SHORT_BREAK:
lines.append("Дальше: короткий перерыв ☕")
elif next_phase == PHASE_LONG_BREAK:
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён")
elif next_phase == PHASE_WORK:
lines.append("Дальше: снова работа 💪")
else:
lines.append("Цикл сброшен. Можно отдохнуть и начать заново.")
return "\n".join(lines)
POMODORO_TOOL_NAMES = frozenset({
"get_pomodoro_status",
"start_pomodoro",
"start_short_break",
"start_long_break",
"stop_pomodoro",
"skip_pomodoro_phase",
"reset_pomodoro_cycle",
"get_pomodoro_history",
})
MEMORY_TOOL_NAMES = frozenset({
"remember_fact",
"recall_memories",
"forget_memory",
"update_profile",
"update_session_summary",
})
FITNESS_TOOL_NAMES = frozenset({
"get_fitness_summary",
"get_fitness_history",
"set_fitness_profile",
"calc_fitness_targets",
"log_meal",
"log_water",
"log_weight",
"log_workout",
"lookup_food",
"lookup_exercise",
"set_fitness_reminder",
})
# Не засорять чат служебными ответами
REMINDER_TOOL_NAMES = frozenset({
"list_reminders",
"create_reminder",
"update_reminder",
"delete_reminder",
"complete_reminder",
})
SHOPPING_TOOL_NAMES = frozenset({
"list_shopping_lists",
"create_shopping_list",
"add_shopping_items",
"check_shopping_item",
"remove_shopping_item",
"delete_shopping_list",
})
TOOLS_SKIP_CHAT_NOTICE = frozenset({
"get_pomodoro_status",
"recall_memories",
"get_fitness_summary",
"get_fitness_history",
"lookup_food",
"lookup_exercise",
"calc_fitness_targets",
"get_weather",
"get_morning_briefing",
"list_shopping_lists",
"list_reminders",
})
def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
return None
try:
data = json.loads(raw_result)
except json.JSONDecodeError:
return None
if isinstance(data, dict) and "error" in data:
if tool_name in POMODORO_TOOL_NAMES:
prefix = ""
elif tool_name in MEMORY_TOOL_NAMES:
prefix = "🧠"
elif tool_name in FITNESS_TOOL_NAMES:
prefix = "💪"
elif tool_name in SHOPPING_TOOL_NAMES:
prefix = "🛒"
elif tool_name in REMINDER_TOOL_NAMES:
prefix = "📅"
else:
prefix = "📋"
return f"{prefix} {data['error']}"
if tool_name == "reset_pomodoro_cycle":
cycle = data.get("cycle", data)
return (
"⏱ **Цикл помидоро сброшен** · "
f"прогресс: {cycle.get('completed_work_sessions', 0)}/"
f"{cycle.get('sessions_until_long_break', 4)}"
)
if tool_name in (
"get_pomodoro_status",
"start_pomodoro",
"start_work",
"start_short_break",
"start_long_break",
"stop_pomodoro",
"skip_pomodoro_phase",
):
return _format_status_notice(data)
if tool_name == "get_pomodoro_history":
return _format_history_notice(data)
if tool_name == "create_work_item":
return _format_work_item_notice(data)
if tool_name == "list_work_items":
return _format_work_items_list_notice(data)
if tool_name == "list_taiga_tasks":
return _format_taiga_tasks_notice(data)
if tool_name == "sync_taiga_projects":
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
if tool_name == "list_taiga_projects":
if not isinstance(data, list) or not data:
return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects."
lines = ["📋 **Проекты:**"]
for p in data:
gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else ""
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
return "\n".join(lines)
if tool_name == "remember_fact" and data.get("ok"):
action = "обновлено" if data.get("action") == "updated" else "сохранено"
return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}"
if tool_name == "forget_memory" and data.get("ok"):
return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}"
if tool_name == "update_profile" and data.get("ok"):
profile = data.get("profile") or {}
parts = [f"{k}={v}" for k, v in profile.items() if v]
return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}"
if tool_name == "update_session_summary" and data.get("ok"):
return "🧠 **Сводка чата сохранена**"
if tool_name == "log_meal" and data.get("ok"):
meal = data.get("meal", {})
est = "" if meal.get("estimated") else ""
return (
f"💪 **Приём пищи** · {meal.get('description')} · "
f"{est}{meal.get('calories', 0):.0f} ккал "
f"{meal.get('protein_g', 0):.0f}{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})"
)
if tool_name == "log_water" and data.get("ok"):
w = data.get("water", {})
return f"💪 **Вода** +{w.get('amount_ml')} мл"
if tool_name == "log_weight" and data.get("ok"):
m = data.get("metric", {})
return f"💪 **Вес** {m.get('weight_kg')} кг"
if tool_name == "log_workout" and data.get("ok"):
wo = data.get("workout", {})
return f"💪 **Тренировка** · {wo.get('title')}"
if tool_name == "set_fitness_profile" and data.get("ok"):
p = data.get("profile", {})
return (
f"💪 **Профиль** · {p.get('calorie_target')} ккал, "
f"вода {p.get('water_l')} л"
)
if tool_name == "set_fitness_reminder" and data.get("ok"):
r = data.get("reminder", {})
state = "вкл" if r.get("enabled") else "выкл"
return f"💪 **Напоминание {r.get('kind')}** · {state}"
if tool_name == "generate_image" and data.get("ok"):
url = data.get("url", "")
return f"🎨 **Картинка готова**\n\n![image]({url})"
if tool_name == "create_shopping_list" and data.get("ok"):
lst = data.get("list") or {}
action = "создан" if data.get("created") else "уже был"
return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})"
if tool_name == "add_shopping_items" and data.get("ok"):
added = data.get("added") or []
names = ", ".join(i.get("text", "") for i in added[:5])
extra = f" +{len(added) - 5}" if len(added) > 5 else ""
return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}"
if tool_name == "check_shopping_item" and data.get("ok"):
item = data.get("item") or {}
state = "куплено" if item.get("checked") else "снята отметка"
return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}"
if tool_name == "remove_shopping_item" and data.get("ok"):
removed = data.get("removed") or {}
return f"🛒 **Удалено** · {removed.get('text')}"
if tool_name == "delete_shopping_list" and data.get("ok"):
return f"🛒 **Список удалён** · «{data.get('name')}»"
if tool_name == "create_reminder" and data.get("ok"):
r = data.get("reminder") or {}
rec = r.get("recurrence", "none")
rec_label = f" · повтор {rec}" if rec and rec != "none" else ""
return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}"
if tool_name == "update_reminder" and data.get("ok"):
r = data.get("reminder") or {}
return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}"
if tool_name == "delete_reminder" and data.get("ok"):
return f"📅 **Напоминание удалено** · «{data.get('title')}»"
if tool_name == "complete_reminder" and data.get("ok"):
r = data.get("reminder") or {}
return f"📅 **Готово** · {r.get('title')}"
return None
def _format_work_item_notice(data: dict[str, Any]) -> str | None:
if data.get("error"):
return f"📋 {data['error']}"
if not data.get("ok"):
return None
taiga = data.get("taiga", {})
gitea = data.get("gitea", {})
lines = [
"📋 **Создано:**",
f"- Taiga: #{taiga.get('ref')}{taiga.get('subject')}",
f"- URL: {taiga.get('url')}",
]
if gitea.get("url"):
lines.append(f"- Gitea: {gitea.get('url')}")
if data.get("branch"):
lines.append(f"- Ветка: `{data['branch']}`")
subtasks = data.get("subtasks") or []
if subtasks:
lines.append("**Подзадачи:**")
for t in subtasks:
lines.append(f"- #{t.get('ref')} {t.get('subject')}")
return "\n".join(lines)
def _format_work_items_list_notice(data: Any) -> str | None:
if not isinstance(data, list) or not data:
return "📋 Локальных work items (созданных ассистентом) нет."
lines = ["📋 **Work items ассистента:**"]
for item in data[:15]:
lines.append(
f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} "
f"({item.get('taiga_slug')})"
)
return "\n".join(lines)
def _format_taiga_tasks_notice(data: Any) -> str | None:
if not isinstance(data, dict):
return None
if data.get("error"):
return f"📋 {data['error']}"
blocks = data.get("projects") or []
total_stories = data.get("total_stories", 0)
total_tasks = data.get("total_tasks", 0)
if not blocks or (total_stories == 0 and total_tasks == 0):
slug = blocks[0].get("slug") if len(blocks) == 1 else None
if slug:
return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga."
return "📋 Открытых задач в Taiga не найдено."
lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"]
for block in blocks:
stories = block.get("stories") or []
tasks = block.get("tasks") or []
if not stories and not tasks:
continue
lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):")
for s in stories:
lines.append(f"- story #{s.get('ref')} {s.get('subject')}")
for t in tasks:
lines.append(f"- task #{t.get('ref')} {t.get('subject')}")
return "\n".join(lines)
def _format_status_notice(data: dict[str, Any]) -> str:
status = data.get("status", "idle")
phase = data.get("phase", PHASE_WORK)
phase_label = PHASE_LABELS.get(phase, phase)
task = data.get("task_note") or "без описания"
remaining = data.get("remaining_seconds", 0)
duration = data.get("duration_min", 25)
cycle = data.get("cycle", {})
cycle_info = ""
if cycle:
cycle_info = (
f" · цикл {cycle.get('completed_work_sessions', 0)}/"
f"{cycle.get('sessions_until_long_break', 4)}"
)
if status == "idle":
return f"⏱ **Помидоро:** таймер не запущен{cycle_info}."
if status == "running":
return (
f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** "
f"из {duration} мин · _{task}_{cycle_info}"
)
if status == "paused":
elapsed = data.get("elapsed_seconds", 0)
return (
f"**{phase_label} на паузе** · прошло {_format_time(elapsed)} "
f"из {duration} мин · _{task}_{cycle_info}"
)
if status == "completed":
return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_"
if status == "cancelled":
return f"⏱ **{phase_label} отменена** · _{task}_"
return f"⏱ Помидоро: {status}"
def _format_history_notice(data: Any) -> str:
if not isinstance(data, list) or not data:
return "⏱ **История помидоро** пуста."
lines = ["⏱ **История помидоро:**"]
for item in data[:10]:
task = item.get("task_note") or "без описания"
phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?"))
duration = item.get("duration_min", "?")
lines.append(f"- {phase}: {task} ({duration} мин)")
return "\n".join(lines)
def format_pomodoro_context(status: dict[str, Any]) -> str:
notice = _format_status_notice(status)
cycle = status.get("cycle", {})
extra = ""
if cycle:
extra = (
f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, "
f"перерыв {cycle.get('short_break_min')} мин, "
f"длинный {cycle.get('long_break_min')} мин."
)
return f"[Актуальный статус помидоро]\n{notice}{extra}"
import json
from typing import Any
from app.db.models import PomodoroSession
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK
PHASE_LABELS = {
PHASE_WORK: "Работа",
PHASE_SHORT_BREAK: "Короткий перерыв",
PHASE_LONG_BREAK: "Длинный перерыв",
}
def _format_time(seconds: int) -> str:
minutes, secs = divmod(max(0, seconds), 60)
return f"{minutes:02d}:{secs:02d}"
def format_phase_completed_notice(
session: PomodoroSession,
next_phase: str | None,
) -> str:
phase_label = PHASE_LABELS.get(session.phase, session.phase)
task = session.task_note or "без описания"
lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"]
if next_phase == PHASE_SHORT_BREAK:
lines.append("Дальше: короткий перерыв ☕")
elif next_phase == PHASE_LONG_BREAK:
lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён")
elif next_phase == PHASE_WORK:
lines.append("Дальше: снова работа 💪")
else:
lines.append("Цикл сброшен. Можно отдохнуть и начать заново.")
return "\n".join(lines)
POMODORO_TOOL_NAMES = frozenset({
"get_pomodoro_status",
"start_pomodoro",
"start_short_break",
"start_long_break",
"stop_pomodoro",
"skip_pomodoro_phase",
"reset_pomodoro_cycle",
"get_pomodoro_history",
})
MEMORY_TOOL_NAMES = frozenset({
"remember_fact",
"recall_memories",
"forget_memory",
"update_profile",
"update_session_summary",
})
FITNESS_TOOL_NAMES = frozenset({
"get_fitness_summary",
"get_fitness_history",
"set_fitness_profile",
"calc_fitness_targets",
"calc_body_composition",
"log_meal",
"log_water",
"log_weight",
"log_workout",
"lookup_food",
"lookup_exercise",
"set_fitness_reminder",
})
# Не засорять чат служебными ответами
REMINDER_TOOL_NAMES = frozenset({
"list_reminders",
"create_reminder",
"update_reminder",
"delete_reminder",
"complete_reminder",
})
SHOPPING_TOOL_NAMES = frozenset({
"list_shopping_lists",
"create_shopping_list",
"add_shopping_items",
"check_shopping_item",
"remove_shopping_item",
"delete_shopping_list",
})
TOOLS_SKIP_CHAT_NOTICE = frozenset({
"get_pomodoro_status",
"recall_memories",
"get_fitness_summary",
"get_fitness_history",
"lookup_food",
"lookup_exercise",
"calc_fitness_targets",
"calc_body_composition",
"get_weather",
"get_morning_briefing",
"list_shopping_lists",
"list_reminders",
})
def _format_body_composition_notice(computed: dict[str, Any], *, headline: str) -> str:
parts: list[str] = []
bf = computed.get("body_fat_pct")
if bf is not None:
method = computed.get("body_fat_method")
if method == "navy":
parts.append(f"жир ≈{bf}% (Navy)")
elif method == "manual":
parts.append(f"жир {bf}%")
else:
parts.append(f"жир ≈{bf}%")
if computed.get("whr") is not None:
parts.append(f"WHR {computed.get('whr')}")
if computed.get("ffmi") is not None:
parts.append(f"FFMI {computed.get('ffmi')}")
if parts:
return f"{headline}{', '.join(parts)}"
return headline
def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
if tool_name in TOOLS_SKIP_CHAT_NOTICE:
return None
try:
data = json.loads(raw_result)
except json.JSONDecodeError:
return None
if isinstance(data, dict) and "error" in data:
if tool_name in POMODORO_TOOL_NAMES:
prefix = ""
elif tool_name in MEMORY_TOOL_NAMES:
prefix = "🧠"
elif tool_name in FITNESS_TOOL_NAMES:
prefix = "💪"
elif tool_name in SHOPPING_TOOL_NAMES:
prefix = "🛒"
elif tool_name in REMINDER_TOOL_NAMES:
prefix = "📅"
else:
prefix = "📋"
return f"{prefix} {data['error']}"
if tool_name == "reset_pomodoro_cycle":
cycle = data.get("cycle", data)
return (
"⏱ **Цикл помидоро сброшен** · "
f"прогресс: {cycle.get('completed_work_sessions', 0)}/"
f"{cycle.get('sessions_until_long_break', 4)}"
)
if tool_name in (
"get_pomodoro_status",
"start_pomodoro",
"start_work",
"start_short_break",
"start_long_break",
"stop_pomodoro",
"skip_pomodoro_phase",
):
return _format_status_notice(data)
if tool_name == "get_pomodoro_history":
return _format_history_notice(data)
if tool_name == "create_work_item":
return _format_work_item_notice(data)
if tool_name == "list_work_items":
return _format_work_items_list_notice(data)
if tool_name == "list_taiga_tasks":
return _format_taiga_tasks_notice(data)
if tool_name == "sync_taiga_projects":
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
if tool_name == "list_taiga_projects":
if not isinstance(data, list) or not data:
return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects."
lines = ["📋 **Проекты:**"]
for p in data:
gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else ""
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
return "\n".join(lines)
if tool_name == "remember_fact" and data.get("ok"):
action = "обновлено" if data.get("action") == "updated" else "сохранено"
return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}"
if tool_name == "forget_memory" and data.get("ok"):
return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}"
if tool_name == "update_profile" and data.get("ok"):
profile = data.get("profile") or {}
parts = [f"{k}={v}" for k, v in profile.items() if v]
return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}"
if tool_name == "update_session_summary" and data.get("ok"):
return "🧠 **Сводка чата сохранена**"
if tool_name == "log_meal" and data.get("ok"):
meal = data.get("meal", {})
est = "" if meal.get("estimated") else ""
return (
f"💪 **Приём пищи** · {meal.get('description')} · "
f"{est}{meal.get('calories', 0):.0f} ккал "
f"{meal.get('protein_g', 0):.0f}{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})"
)
if tool_name == "log_water" and data.get("ok"):
w = data.get("water", {})
return f"💪 **Вода** +{w.get('amount_ml')} мл"
if tool_name == "log_weight" and data.get("ok"):
m = data.get("metric", {})
computed = data.get("computed") or {}
headline = f"💪 **Вес** {m.get('weight_kg')} кг"
return _format_body_composition_notice(computed, headline=headline)
if tool_name == "calc_body_composition" and isinstance(data, dict) and "error" not in data:
w = data.get("weight_kg")
headline = "💪 **Состав тела** (расчёт)"
if w is not None:
headline += f" · {w} кг"
msg = _format_body_composition_notice(data, headline=headline)
warnings = data.get("warnings") or []
if warnings:
msg += f" · {'; '.join(warnings[:2])}"
return msg
if tool_name == "log_workout" and data.get("ok"):
wo = data.get("workout", {})
return f"💪 **Тренировка** · {wo.get('title')}"
if tool_name == "set_fitness_profile" and data.get("ok"):
p = data.get("profile", {})
return (
f"💪 **Профиль** · {p.get('calorie_target')} ккал, "
f"вода {p.get('water_l')} л"
)
if tool_name == "set_fitness_reminder" and data.get("ok"):
r = data.get("reminder", {})
state = "вкл" if r.get("enabled") else "выкл"
return f"💪 **Напоминание {r.get('kind')}** · {state}"
if tool_name == "generate_image" and data.get("ok"):
url = data.get("url", "")
return f"🎨 **Картинка готова**\n\n![image]({url})"
if tool_name == "create_shopping_list" and data.get("ok"):
lst = data.get("list") or {}
action = "создан" if data.get("created") else "уже был"
return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})"
if tool_name == "add_shopping_items" and data.get("ok"):
added = data.get("added") or []
names = ", ".join(i.get("text", "") for i in added[:5])
extra = f" +{len(added) - 5}" if len(added) > 5 else ""
return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}"
if tool_name == "check_shopping_item" and data.get("ok"):
item = data.get("item") or {}
state = "куплено" if item.get("checked") else "снята отметка"
return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}"
if tool_name == "remove_shopping_item" and data.get("ok"):
removed = data.get("removed") or {}
return f"🛒 **Удалено** · {removed.get('text')}"
if tool_name == "delete_shopping_list" and data.get("ok"):
return f"🛒 **Список удалён** · «{data.get('name')}»"
if tool_name == "create_reminder" and data.get("ok"):
r = data.get("reminder") or {}
rec = r.get("recurrence", "none")
rec_label = f" · повтор {rec}" if rec and rec != "none" else ""
return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}"
if tool_name == "update_reminder" and data.get("ok"):
r = data.get("reminder") or {}
return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}"
if tool_name == "delete_reminder" and data.get("ok"):
return f"📅 **Напоминание удалено** · «{data.get('title')}»"
if tool_name == "complete_reminder" and data.get("ok"):
r = data.get("reminder") or {}
return f"📅 **Готово** · {r.get('title')}"
return None
def _format_work_item_notice(data: dict[str, Any]) -> str | None:
if data.get("error"):
return f"📋 {data['error']}"
if not data.get("ok"):
return None
taiga = data.get("taiga", {})
gitea = data.get("gitea", {})
lines = [
"📋 **Создано:**",
f"- Taiga: #{taiga.get('ref')}{taiga.get('subject')}",
f"- URL: {taiga.get('url')}",
]
if gitea.get("url"):
lines.append(f"- Gitea: {gitea.get('url')}")
if data.get("branch"):
lines.append(f"- Ветка: `{data['branch']}`")
subtasks = data.get("subtasks") or []
if subtasks:
lines.append("**Подзадачи:**")
for t in subtasks:
lines.append(f"- #{t.get('ref')} {t.get('subject')}")
return "\n".join(lines)
def _format_work_items_list_notice(data: Any) -> str | None:
if not isinstance(data, list) or not data:
return "📋 Локальных work items (созданных ассистентом) нет."
lines = ["📋 **Work items ассистента:**"]
for item in data[:15]:
lines.append(
f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} "
f"({item.get('taiga_slug')})"
)
return "\n".join(lines)
def _format_taiga_tasks_notice(data: Any) -> str | None:
if not isinstance(data, dict):
return None
if data.get("error"):
return f"📋 {data['error']}"
blocks = data.get("projects") or []
total_stories = data.get("total_stories", 0)
total_tasks = data.get("total_tasks", 0)
if not blocks or (total_stories == 0 and total_tasks == 0):
slug = blocks[0].get("slug") if len(blocks) == 1 else None
if slug:
return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga."
return "📋 Открытых задач в Taiga не найдено."
lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"]
for block in blocks:
stories = block.get("stories") or []
tasks = block.get("tasks") or []
if not stories and not tasks:
continue
lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):")
for s in stories:
lines.append(f"- story #{s.get('ref')} {s.get('subject')}")
for t in tasks:
lines.append(f"- task #{t.get('ref')} {t.get('subject')}")
return "\n".join(lines)
def _format_status_notice(data: dict[str, Any]) -> str:
status = data.get("status", "idle")
phase = data.get("phase", PHASE_WORK)
phase_label = PHASE_LABELS.get(phase, phase)
task = data.get("task_note") or "без описания"
remaining = data.get("remaining_seconds", 0)
duration = data.get("duration_min", 25)
cycle = data.get("cycle", {})
cycle_info = ""
if cycle:
cycle_info = (
f" · цикл {cycle.get('completed_work_sessions', 0)}/"
f"{cycle.get('sessions_until_long_break', 4)}"
)
if status == "idle":
return f"⏱ **Помидоро:** таймер не запущен{cycle_info}."
if status == "running":
return (
f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** "
f"из {duration} мин · _{task}_{cycle_info}"
)
if status == "paused":
elapsed = data.get("elapsed_seconds", 0)
return (
f"⏱ **{phase_label} на паузе** · прошло {_format_time(elapsed)} "
f"из {duration} мин · _{task}_{cycle_info}"
)
if status == "completed":
return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_"
if status == "cancelled":
return f"⏱ **{phase_label} отменена** · _{task}_"
return f"⏱ Помидоро: {status}"
def _format_history_notice(data: Any) -> str:
if not isinstance(data, list) or not data:
return "⏱ **История помидоро** пуста."
lines = ["⏱ **История помидоро:**"]
for item in data[:10]:
task = item.get("task_note") or "без описания"
phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?"))
duration = item.get("duration_min", "?")
lines.append(f"- {phase}: {task} ({duration} мин)")
return "\n".join(lines)
def format_pomodoro_context(status: dict[str, Any]) -> str:
notice = _format_status_notice(status)
cycle = status.get("cycle", {})
extra = ""
if cycle:
extra = (
f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, "
f"перерыв {cycle.get('short_break_min')} мин, "
f"длинный {cycle.get('long_break_min')} мин."
)
return f"[Актуальный статус помидоро]\n{notice}{extra}"
File diff suppressed because it is too large Load Diff
+468
View File
@@ -0,0 +1,468 @@
import asyncio
import json
import logging
import time
from collections.abc import AsyncIterator
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.base import SessionLocal
from app.character.service import CharacterService
from app.chat.history import sanitize_openai_messages, strip_historical_reasoning
from app.chat.notice_inbox import DISPLAY_ONLY_ROLES
from app.chat.notices import (
POMODORO_TOOL_NAMES,
format_pomodoro_context,
format_tool_notice,
)
from app.fitness.context import format_fitness_context, get_fitness_snapshot
from app.homelab.context import format_datetime_context
from app.homelab.openmeteo import format_weather_snapshot
from app.memory.context import (
format_identity_hint,
format_memory_context,
get_memory_snapshot,
)
from app.memory.extract import extract_after_turn
from app.projects.context import format_projects_context, get_projects_snapshot
from app.reminders.context import format_reminders_context, get_reminders_snapshot
from app.shopping.context import format_shopping_context, get_shopping_snapshot
from app.db.models import ChatSession, Message
from app.llm.client import LLMClient
from app.pomodoro.service import PomodoroService
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
MAX_TOOL_ROUNDS = 5
MAX_HISTORY_MESSAGES = 40
logger = logging.getLogger(__name__)
def _build_messages_for_session(session_id: int) -> list[dict[str, Any]]:
db = SessionLocal()
try:
service = ChatService(db)
session = service.get_session(session_id)
if not session:
return []
return service._build_messages(session)
finally:
db.close()
async def _extract_memory_background(
session_id: int,
user_text: str,
assistant_text: str,
) -> None:
db = SessionLocal()
try:
await extract_after_turn(db, session_id, user_text, assistant_text)
except Exception as exc:
logger.warning("Background memory extraction failed: %s", exc)
finally:
db.close()
class ChatService:
def __init__(self, db: Session):
self.db = db
self.llm = LLMClient()
self.character = CharacterService()
def list_sessions(self) -> list[ChatSession]:
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc())
return list(self.db.scalars(stmt).all())
def get_session(self, session_id: int) -> ChatSession | None:
return self.db.get(ChatSession, session_id)
def create_session(self, title: str = "Новый чат") -> ChatSession:
session = ChatSession(title=title)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return session
def delete_session(self, session_id: int) -> bool:
session = self.get_session(session_id)
if not session:
return False
self.db.delete(session)
self.db.commit()
return True
def _build_system_prompt(self, session_id: int | None = None) -> str:
status = PomodoroService(self.db).get_status()
memory_snapshot = get_memory_snapshot(self.db, session_id)
fitness_snapshot = get_fitness_snapshot(self.db)
shopping_snapshot = get_shopping_snapshot(self.db)
reminders_snapshot = get_reminders_snapshot(self.db)
projects_snapshot = get_projects_snapshot(self.db)
return (
f"{self.character.get_system_prompt()}\n\n"
f"{format_datetime_context(self.db)}\n\n"
f"{format_memory_context(memory_snapshot)}\n\n"
f"{format_fitness_context(fitness_snapshot)}\n\n"
f"{format_shopping_context(shopping_snapshot)}\n\n"
f"{format_reminders_context(reminders_snapshot)}\n\n"
f"{format_weather_snapshot()}\n\n"
f"{format_pomodoro_context(status)}\n\n"
f"{format_projects_context(projects_snapshot)}"
)
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
system_prompt = self._build_system_prompt(session.id)
all_chat = [m for m in session.messages if m.role not in DISPLAY_ONLY_ROLES]
last_user = next((m.content for m in reversed(all_chat) if m.role == "user"), "")
if last_user:
memory_snapshot = get_memory_snapshot(self.db, session.id)
identity_hint = format_identity_hint(memory_snapshot, last_user)
if identity_hint:
system_prompt += f"\n\n{identity_hint}"
if len(all_chat) > MAX_HISTORY_MESSAGES:
system_prompt += (
f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} "
f"из {len(all_chat)} сообщений. Раннее — в сводке сессии, если сохранена.]"
)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt}
]
chat_messages = all_chat[-MAX_HISTORY_MESSAGES:] if len(all_chat) > MAX_HISTORY_MESSAGES else all_chat
for msg in chat_messages:
content = msg.content or None
entry: dict[str, Any] = {"role": msg.role, "content": content}
if msg.tool_calls_json:
entry["tool_calls"] = json.loads(msg.tool_calls_json)
if not content:
entry["content"] = None
reasoning_data = LLMClient.deserialize_reasoning(msg.reasoning_json)
if reasoning_data:
LLMClient.attach_reasoning_to_message(
entry,
reasoning=reasoning_data.get("reasoning", ""),
reasoning_details=reasoning_data.get("reasoning_details"),
)
if msg.role == "tool" and msg.tool_call_id:
entry["tool_call_id"] = msg.tool_call_id
messages.append(entry)
messages = sanitize_openai_messages(messages)
messages = strip_historical_reasoning(messages)
return messages
def _save_message(
self,
session_id: int,
role: str,
content: str = "",
tool_calls: list[dict[str, Any]] | None = None,
tool_call_id: str | None = None,
reasoning_json: str | None = None,
) -> Message:
message = Message(
session_id=session_id,
role=role,
content=content,
tool_calls_json=json.dumps(tool_calls, ensure_ascii=False) if tool_calls else None,
reasoning_json=reasoning_json,
tool_call_id=tool_call_id,
)
self.db.add(message)
session = self.get_session(session_id)
if session and role == "user" and session.title == "Новый чат" and content:
session.title = content[:60] + ("..." if len(content) > 60 else "")
self.db.commit()
self.db.refresh(message)
return message
def save_user_message(self, session_id: int, user_text: str) -> None:
self._save_message(session_id, "user", user_text)
async def _fallback_complete(
self,
messages: list[dict[str, Any]],
session_id: int,
) -> tuple[str, list[str], list[dict[str, Any]]]:
"""Нестриминговый запасной путь, если stream вернул пустоту."""
logger.info("chat session=%s fallback complete", session_id)
result: dict[str, Any] = {"content": "", "tool_calls": []}
for with_tools in (True, False):
result = await self.llm.complete(
messages,
tools=TOOL_DEFINITIONS if with_tools else None,
temperature=0.5,
visible_reply=True,
)
if (result.get("content") or "").strip() or result.get("tool_calls"):
break
tool_calls = result.get("tool_calls") or []
content = (result.get("content") or "").strip()
notices: list[str] = []
pomodoro_events: list[dict[str, Any]] = []
if tool_calls:
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": content or None,
"tool_calls": tool_calls,
}
messages.append(assistant_msg)
self._save_message(
session_id,
"assistant",
content,
tool_calls=tool_calls,
)
for tool_call in tool_calls:
fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
tool_result = await execute_tool(
self.db, fn["name"], args, session_id=session_id
)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call["id"],
"content": tool_result,
}
)
self._save_message(
session_id,
"tool",
tool_result,
tool_call_id=tool_call["id"],
)
notice = format_tool_notice(fn["name"], tool_result)
if notice:
self._save_message(session_id, "notice", notice)
notices.append(notice)
if fn["name"] in POMODORO_TOOL_NAMES:
pomodoro_events.append(
{"name": fn["name"], "result": json.loads(tool_result)}
)
followup = await self.llm.complete(
messages,
tools=None,
temperature=0.4,
visible_reply=True,
)
return (followup.get("content") or "").strip(), notices, pomodoro_events
return content, notices, pomodoro_events
async def stream_response(
self,
session_id: int,
user_text: str,
*,
user_message_saved: bool = False,
) -> AsyncIterator[str]:
session = self.get_session(session_id)
if not session:
yield self._sse("error", {"message": "Session not found"})
return
if not user_message_saved:
self._save_message(session_id, "user", user_text)
yield self._sse("status", {"phase": "preparing"})
t0 = time.monotonic()
messages = await asyncio.to_thread(_build_messages_for_session, session_id)
prepare_sec = time.monotonic() - t0
if not messages:
yield self._sse("error", {"message": "Session not found"})
return
yield self._sse("status", {"phase": "generating"})
streamed_reply_parts: list[str] = []
all_tool_notices: list[str] = []
tools_executed = 0
tool_round = 0
for _ in range(MAX_TOOL_ROUNDS):
tool_round += 1
t_round = time.monotonic()
content_parts: list[str] = []
tool_calls: list[dict[str, Any]] = []
reasoning = ""
reasoning_details: list[Any] | None = None
finish_reason = ""
# После tool-раунда стримим вживую; до tools — буфер (иначе текст «переписывает» notice).
stream_live = tools_executed > 0
async for event in self.llm.stream_chat(messages, tools=TOOL_DEFINITIONS):
if event["type"] == "content":
content_parts.append(event["content"])
if stream_live:
yield self._sse("token", {"content": event["content"]})
elif event["type"] == "reasoning":
reasoning = event.get("reasoning", "") or reasoning
if event.get("reasoning_details"):
reasoning_details = event["reasoning_details"]
elif event["type"] == "error":
logger.warning(
"chat session=%s llm_error round=%d prepare=%.2fs: %s",
session_id,
tool_round,
prepare_sec,
event.get("content"),
)
yield self._sse("error", {"message": event.get("content", "LLM error")})
return
elif event["type"] == "tool_calls":
tool_calls = event["tool_calls"]
elif event["type"] == "done":
finish_reason = event.get("finish_reason", "")
logger.info(
"chat session=%s round=%d prepare=%.2fs llm=%.2fs "
"content_len=%d tool_calls=%d finish_reason=%s reasoning_len=%d",
session_id,
tool_round,
prepare_sec,
time.monotonic() - t_round,
len("".join(content_parts)),
len(tool_calls),
finish_reason,
len(reasoning),
)
if tool_calls:
round_text = "".join(content_parts)
if round_text.strip():
streamed_reply_parts.append(round_text)
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": round_text or None,
"tool_calls": tool_calls,
}
LLMClient.attach_reasoning_to_message(
assistant_msg,
reasoning=reasoning,
reasoning_details=reasoning_details,
)
reasoning_json = LLMClient.serialize_reasoning(
reasoning=reasoning,
reasoning_details=reasoning_details,
)
messages.append(assistant_msg)
self._save_message(
session_id,
"assistant",
round_text,
tool_calls=tool_calls,
reasoning_json=reasoning_json,
)
round_notices: list[str] = []
for tool_call in tool_calls:
fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
result = await execute_tool(
self.db, fn["name"], args, session_id=session_id
)
tools_executed += 1
tool_message = {
"role": "tool",
"tool_call_id": tool_call["id"],
"content": result,
}
messages.append(tool_message)
self._save_message(session_id, "tool", result, tool_call_id=tool_call["id"])
notice = format_tool_notice(fn["name"], result)
if notice:
self._save_message(session_id, "notice", notice)
round_notices.append(notice)
all_tool_notices.append(notice)
if fn["name"] in POMODORO_TOOL_NAMES:
yield self._sse(
"pomodoro",
{"name": fn["name"], "result": json.loads(result)},
)
yield self._sse("status", {"phase": "tools"})
for notice in round_notices:
yield self._sse("notice", {"content": notice})
continue
if content_parts and not stream_live:
for part in content_parts:
yield self._sse("token", {"content": part})
final_content = "".join(content_parts).strip()
if not final_content and streamed_reply_parts and tools_executed == 0:
final_content = "".join(streamed_reply_parts).strip()
if not final_content and reasoning:
final_content = reasoning.strip()
if not final_content and tools_executed:
retry = await self.llm.complete(
messages,
tools=None,
temperature=0.4,
visible_reply=True,
)
final_content = (retry.get("content") or "").strip()
if final_content:
yield self._sse("token", {"content": final_content})
# Notices уже в чате как role=notice — не дублируем в assistant.
if not final_content:
final_content, fb_notices, fb_pomodoro = await self._fallback_complete(
messages, session_id
)
if final_content:
yield self._sse("token", {"content": final_content})
for notice in fb_notices:
yield self._sse("notice", {"content": notice})
for event in fb_pomodoro:
yield self._sse("pomodoro", event)
if not final_content:
logger.warning(
"chat session=%s empty_reply tools=%d rounds=%d finish_reason=%s",
session_id,
tools_executed,
tool_round,
finish_reason,
)
yield self._sse(
"error",
{
"message": (
"Модель не вернула ответ (finish_reason="
f"{finish_reason or 'unknown'}). "
"Попробуй новый чат или проверь OPENROUTER_MODEL."
),
},
)
return
self._save_message(session_id, "assistant", final_content)
logger.info(
"chat session=%s done tools=%d reply_len=%d total=%.2fs",
session_id,
tools_executed,
len(final_content),
time.monotonic() - t0,
)
yield self._sse("done", {})
if get_settings().memory_auto_extract:
asyncio.create_task(
_extract_memory_background(session_id, user_text, final_content)
)
return
yield self._sse("error", {"message": "Too many tool call rounds"})
@staticmethod
def _sse(event: str, data: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+138 -127
View File
@@ -1,127 +1,138 @@
from functools import lru_cache
from 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()
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file_encoding="utf-8",
extra="ignore",
)
host: str = "0.0.0.0"
port: int = 8080
openrouter_api_key: str = ""
openrouter_model: str = "deepseek/deepseek-chat"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
# Отдельная модель для JSON-задач (память, фитнес). Пусто = та же, что OPENROUTER_MODEL.
memory_extract_model: str = ""
# Некоторые модели (reasoning / без function calling) — выключить tools.
openrouter_tools_enabled: bool = True
# DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking.
openrouter_reasoning_effort: str = "none"
database_url: str = "sqlite:///./data/assistant.db"
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
system_prompt_path: str = "./prompts/assistant.md"
memory_auto_extract: bool = True
default_user_username: str = "owner"
default_user_display_name: str = ""
default_api_token: str = ""
auth_required: bool = True
qdrant_url: str = "http://qdrant:6333"
embedding_model: str = "openai/text-embedding-3-small"
rag_enabled: bool = False
rag_top_k: int = 8
memory_facts_in_context: int = 8
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
taiga_base_url: str = "http://host.docker.internal:9000"
taiga_username: str = ""
taiga_password: str = ""
taiga_public_url: str = "https://taiga.grigowashere.ru"
gitea_base_url: str = "http://host.docker.internal:3000"
gitea_token: str = ""
gitea_public_url: str = "https://git.grigowashere.ru"
gitea_webhook_secret: str = ""
repos_dir: str = "/data/repos"
wger_base_url: str = "https://wger.de/api/v2"
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
fitness_reminders_enabled: bool = True
reminders_enabled: bool = True
openmeteo_base_url: str = "http://192.168.1.109:8085"
weather_lat: float = 59.9343
weather_lon: float = 30.3351
weather_location_name: str = "Санкт-Петербург"
weather_cache_sec: int = 300
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()
+127
View File
@@ -0,0 +1,127 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file_encoding="utf-8",
extra="ignore",
)
host: str = "0.0.0.0"
port: int = 8080
openrouter_api_key: str = ""
openrouter_model: str = "deepseek/deepseek-chat"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
# Отдельная модель для JSON-задач (память, фитнес). Пусто = та же, что OPENROUTER_MODEL.
memory_extract_model: str = ""
# Некоторые модели (reasoning / без function calling) — выключить tools.
openrouter_tools_enabled: bool = True
# DeepSeek V4 / reasoning: none | low | medium | high | xhigh. none = без thinking.
openrouter_reasoning_effort: str = "none"
database_url: str = "sqlite:///./data/assistant.db"
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
system_prompt_path: str = "./prompts/assistant.md"
memory_auto_extract: bool = True
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
taiga_base_url: str = "http://host.docker.internal:9000"
taiga_username: str = ""
taiga_password: str = ""
taiga_public_url: str = "https://taiga.grigowashere.ru"
gitea_base_url: str = "http://host.docker.internal:3000"
gitea_token: str = ""
gitea_public_url: str = "https://git.grigowashere.ru"
gitea_webhook_secret: str = ""
repos_dir: str = "/data/repos"
wger_base_url: str = "https://wger.de/api/v2"
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
fitness_reminders_enabled: bool = True
reminders_enabled: bool = True
openmeteo_base_url: str = "http://192.168.1.109:8085"
weather_lat: float = 59.9343
weather_lon: float = 30.3351
weather_location_name: str = "Санкт-Петербург"
weather_cache_sec: int = 300
news_rss_urls: str = (
"https://habr.com/ru/rss/all/all/,"
"https://www.reddit.com/r/programming/.rss"
)
news_cache_sec: int = 1800
news_max_items: int = 7
morning_digest_enabled: bool = True
morning_digest_hour: int = 8
morning_digest_minute: int = 0
comfyui_base_url: str = "http://192.168.1.109:8188"
comfyui_enabled: bool = True
# Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty
comfyui_checkpoint: str = ""
comfyui_unet: str = "anima-preview3-base.safetensors"
comfyui_clip: str = "qwen_3_06b_base.safetensors"
comfyui_vae: str = "qwen_image_vae.safetensors"
comfyui_style_lora: str = "anima-preview-3-masterpieces-v5.safetensors"
comfyui_style_lora_weight: float = 0.7
comfyui_steps: int = 30
comfyui_cfg: float = 4.0
comfyui_sampler: str = "er_sde"
comfyui_scheduler: str = "simple"
comfyui_width: int = 1024
comfyui_height: int = 720
comfyui_negative_prompt: str = (
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
)
comfyui_poll_interval_sec: float = 2.0
comfyui_timeout_sec: float = 180.0
comfyui_rofl_enabled: bool = True
comfyui_rofl_max_per_day: int = 1
comfyui_rofl_probability: float = 0.15
comfyui_rofl_min_interval_hours: int = 12
generated_media_dir: str = "./data/generated"
netdata_base_url: str = "http://host.docker.internal:19999"
netdata_public_url: str = ""
netdata_alerts_enabled: bool = True
netdata_poll_interval_sec: int = 120
rp_chat_base_url: str = "http://host.docker.internal:8201"
rp_chat_enabled: bool = True
rp_chat_timeout_sec: float = 300.0
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@property
def taiga_configured(self) -> bool:
return bool(self.taiga_username and self.taiga_password)
@property
def gitea_configured(self) -> bool:
return bool(self.gitea_token)
@property
def news_rss_urls_list(self) -> list[str]:
return [u.strip() for u in self.news_rss_urls.split(",") if u.strip()]
def load_system_prompt(self) -> str:
path = Path(self.system_prompt_path)
if path.is_file():
return path.read_text(encoding="utf-8")
return "Ты домашний ИИ-ассистент. Общайся на русском."
@lru_cache
def get_settings() -> Settings:
return Settings()
+94
View File
@@ -0,0 +1,94 @@
from sqlalchemy import inspect, text
from app.db.base import engine
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
inspector = inspect(engine)
if table not in inspector.get_table_names():
return
columns = {col["name"] for col in inspector.get_columns(table)}
if column in columns:
return
with engine.begin() as conn:
conn.execute(text(ddl))
def run_fitness_migrations() -> None:
inspector = inspect(engine)
if "fitness_profiles" in inspector.get_table_names():
_add_column_if_missing(
"fitness_profiles",
"baseline_steps",
"ALTER TABLE fitness_profiles ADD COLUMN baseline_steps INTEGER",
)
_add_column_if_missing(
"fitness_profiles",
"baseline_workout_kcal",
"ALTER TABLE fitness_profiles ADD COLUMN baseline_workout_kcal FLOAT",
)
if "workout_logs" in inspector.get_table_names():
_add_column_if_missing(
"workout_logs",
"active_calories",
"ALTER TABLE workout_logs ADD COLUMN active_calories FLOAT",
)
_add_column_if_missing(
"workout_logs",
"total_calories",
"ALTER TABLE workout_logs ADD COLUMN total_calories FLOAT",
)
_add_column_if_missing(
"workout_logs",
"steps",
"ALTER TABLE workout_logs ADD COLUMN steps INTEGER",
)
if "step_logs" not in inspector.get_table_names():
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE step_logs ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"steps INTEGER DEFAULT 0, "
"active_calories FLOAT, "
"source VARCHAR(32) DEFAULT 'manual', "
"notes TEXT DEFAULT ''"
")"
)
)
if "body_metrics" in inspector.get_table_names():
_add_column_if_missing(
"body_metrics",
"neck_cm",
"ALTER TABLE body_metrics ADD COLUMN neck_cm FLOAT",
)
_add_column_if_missing(
"body_metrics",
"hip_cm",
"ALTER TABLE body_metrics ADD COLUMN hip_cm FLOAT",
)
_add_column_if_missing(
"body_metrics",
"body_fat_method",
"ALTER TABLE body_metrics ADD COLUMN body_fat_method VARCHAR(16)",
)
_add_column_if_missing(
"body_metrics",
"whr",
"ALTER TABLE body_metrics ADD COLUMN whr FLOAT",
)
_add_column_if_missing(
"body_metrics",
"lbm_kg",
"ALTER TABLE body_metrics ADD COLUMN lbm_kg FLOAT",
)
_add_column_if_missing(
"body_metrics",
"ffmi",
"ALTER TABLE body_metrics ADD COLUMN ffmi FLOAT",
)
+249
View File
@@ -0,0 +1,249 @@
from __future__ import annotations
import json
import logging
import secrets
from pathlib import Path
from sqlalchemy import inspect, text
from app.auth.tokens import hash_token
from app.character.card import DEFAULT_CARD, normalize_card
from app.config import get_settings
from app.db.base import engine
logger = logging.getLogger(__name__)
TENANT_TABLES = (
"chat_sessions",
"user_profile",
"memory_facts",
"fitness_profiles",
"body_metrics",
"food_logs",
"water_logs",
"workout_logs",
"step_logs",
"fitness_reminders",
"shopping_lists",
"reminders",
"documents",
"pomodoro_cycles",
"pomodoro_sessions",
"project_bindings",
"work_items",
)
LEGACY_CARD_PATH = Path("./data/character.json")
def _table_exists(name: str) -> bool:
return name in inspect(engine).get_table_names()
def _columns(table: str) -> set[str]:
if not _table_exists(table):
return set()
return {col["name"] for col in inspect(engine).get_columns(table)}
def _add_column_if_missing(table: str, column: str, ddl: str) -> None:
if column in _columns(table):
return
with engine.begin() as conn:
conn.execute(text(ddl))
def _ensure_users_table() -> None:
if _table_exists("users"):
return
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE users ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"username VARCHAR(64) NOT NULL UNIQUE, "
"display_name VARCHAR(255) DEFAULT '', "
"api_token_hash VARCHAR(64) NOT NULL, "
"is_active BOOLEAN DEFAULT 1, "
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP"
")"
)
)
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_api_token_hash ON users (api_token_hash)"))
def _ensure_character_cards_table() -> None:
if _table_exists("character_cards"):
return
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE character_cards ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, "
"card_json TEXT DEFAULT '{}', "
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP"
")"
)
)
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_character_cards_user_id ON character_cards (user_id)"))
def _add_user_id_columns() -> None:
for table in TENANT_TABLES:
if not _table_exists(table):
continue
_add_column_if_missing(
table,
"user_id",
f"ALTER TABLE {table} ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE",
)
with engine.begin() as conn:
conn.execute(text(f"CREATE INDEX IF NOT EXISTS ix_{table}_user_id ON {table} (user_id)"))
def _ensure_default_user() -> tuple[int, str | None]:
settings = get_settings()
with engine.begin() as conn:
row = conn.execute(text("SELECT id FROM users ORDER BY id LIMIT 1")).fetchone()
if row:
return int(row[0]), None
username = settings.default_user_username or "owner"
display_name = settings.default_user_display_name or username
plain_token = (settings.default_api_token or "").strip()
generated = False
if not plain_token:
plain_token = secrets.token_urlsafe(32)
generated = True
token_hash = hash_token(plain_token)
conn.execute(
text(
"INSERT INTO users (id, username, display_name, api_token_hash, is_active) "
"VALUES (1, :username, :display_name, :token_hash, 1)"
),
{"username": username, "display_name": display_name, "token_hash": token_hash},
)
if generated:
logger.warning(
"DEFAULT_API_TOKEN not set — generated token for user '%s': %s",
username,
plain_token,
)
return 1, plain_token
return 1, None
def _backfill_user_id(default_user_id: int = 1) -> None:
with engine.begin() as conn:
for table in TENANT_TABLES:
if not _table_exists(table):
continue
conn.execute(
text(f"UPDATE {table} SET user_id = :uid WHERE user_id IS NULL"),
{"uid": default_user_id},
)
def _rebuild_shopping_unique() -> None:
if not _table_exists("shopping_lists"):
return
with engine.begin() as conn:
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_shopping_lists_user_name ON shopping_lists (user_id, name)"))
def _rebuild_project_bindings_unique() -> None:
if not _table_exists("project_bindings"):
return
with engine.begin() as conn:
conn.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_project_bindings_user_slug "
"ON project_bindings (user_id, taiga_slug)"
)
)
def _import_character_card(user_id: int) -> None:
with engine.begin() as conn:
existing = conn.execute(
text("SELECT id FROM character_cards WHERE user_id = :uid"),
{"uid": user_id},
).fetchone()
if existing:
return
card = normalize_card(DEFAULT_CARD)
if LEGACY_CARD_PATH.is_file():
try:
raw = json.loads(LEGACY_CARD_PATH.read_text(encoding="utf-8"))
card = normalize_card(raw)
except (json.JSONDecodeError, OSError):
pass
conn.execute(
text("INSERT INTO character_cards (user_id, card_json) VALUES (:uid, :json)"),
{"uid": user_id, "json": json.dumps(card, ensure_ascii=False)},
)
def _backfill_qdrant_user_id(default_user_id: int = 1) -> None:
settings = get_settings()
if not settings.rag_enabled:
return
try:
from app.rag.store import COLLECTION_DOC_CHUNKS, COLLECTION_FACTS, COLLECTION_SUMMARIES, _client
except Exception:
logger.exception("Qdrant backfill skipped")
return
try:
client = _client()
except Exception:
logger.warning('Qdrant unavailable, skipping user_id backfill')
return
for collection in (COLLECTION_FACTS, COLLECTION_SUMMARIES, COLLECTION_DOC_CHUNKS):
try:
if not client.collection_exists(collection):
continue
except Exception:
logger.warning('Qdrant unavailable for collection %s', collection)
continue
offset = None
while True:
points, offset = client.scroll(
collection_name=collection,
limit=100,
offset=offset,
with_payload=True,
with_vectors=False,
)
if not points:
break
missing = [point.id for point in points if (point.payload or {}).get("user_id") is None]
if missing:
client.set_payload(
collection_name=collection,
payload={"user_id": default_user_id},
points=missing,
)
if offset is None:
break
logger.info("Qdrant user_id backfill completed for user_id=%s", default_user_id)
def run_multi_user_migrations() -> str | None:
"""Returns newly generated API token if any."""
_ensure_users_table()
_ensure_character_cards_table()
_add_user_id_columns()
user_id, new_token = _ensure_default_user()
_backfill_user_id(user_id)
_rebuild_shopping_unique()
_rebuild_project_bindings_unique()
_import_character_card(user_id)
_backfill_qdrant_user_id(user_id)
return new_token
+396 -299
View File
@@ -1,299 +1,396 @@
from datetime import datetime
from 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)
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(255), default="")
api_token_hash: Mapped[str] = mapped_column(String(64), index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class CharacterCard(Base):
__tablename__ = "character_cards"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
)
card_json: Mapped[str] = mapped_column(Text, default="{}")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class ChatSession(Base):
__tablename__ = "chat_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
messages: Mapped[list["Message"]] = relationship(
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
)
class Message(Base):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
role: Mapped[str] = mapped_column(String(32))
content: Mapped[str] = mapped_column(Text, default="")
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
session: Mapped["ChatSession"] = relationship(back_populates="messages")
class PomodoroCycle(Base):
__tablename__ = "pomodoro_cycles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
task_note: Mapped[str] = mapped_column(Text, default="")
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class PomodoroSession(Base):
__tablename__ = "pomodoro_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
status: Mapped[str] = mapped_column(String(32), default="idle")
phase: Mapped[str] = mapped_column(String(32), default="work")
duration_min: Mapped[int] = mapped_column(Integer, default=25)
task_note: Mapped[str] = mapped_column(Text, default="")
result: Mapped[str | None] = mapped_column(Text, nullable=True)
completed: Mapped[bool] = mapped_column(Boolean, default=False)
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class TaigaProject(Base):
__tablename__ = "taiga_projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
name: Mapped[str] = mapped_column(String(255))
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ProjectBinding(Base):
__tablename__ = "project_bindings"
__table_args__ = (UniqueConstraint("user_id", "taiga_slug", name="uq_project_bindings_user_slug"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
default_branch: Mapped[str] = mapped_column(String(64), default="main")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class UserProfile(Base):
__tablename__ = "user_profile"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
data_json: Mapped[str] = mapped_column(Text, default="{}")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class MemoryFact(Base):
__tablename__ = "memory_facts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
content: Mapped[str] = mapped_column(Text)
source: Mapped[str] = mapped_column(String(32), default="user")
session_id: Mapped[int | None] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
)
importance: Mapped[int] = mapped_column(Integer, default=3)
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class SessionSummary(Base):
__tablename__ = "session_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
)
summary: Mapped[str] = mapped_column(Text, default="")
message_count: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class FitnessProfile(Base):
__tablename__ = "fitness_profiles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
sex: Mapped[str] = mapped_column(String(16), default="male")
age: Mapped[int] = mapped_column(Integer, default=30)
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
goal: Mapped[str] = mapped_column(String(32), default="maintain")
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
baseline_steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
baseline_workout_kcal: Mapped[float | None] = mapped_column(Float, nullable=True)
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
water_l: Mapped[float] = mapped_column(Float, default=2.5)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class BodyMetric(Base):
__tablename__ = "body_metrics"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
weight_kg: Mapped[float] = mapped_column(Float)
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
body_fat_method: Mapped[str | None] = mapped_column(String(16), nullable=True)
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
neck_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
hip_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
whr: Mapped[float | None] = mapped_column(Float, nullable=True)
lbm_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
ffmi: Mapped[float | None] = mapped_column(Float, nullable=True)
notes: Mapped[str] = mapped_column(Text, default="")
class FoodLog(Base):
__tablename__ = "food_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
description: Mapped[str] = mapped_column(Text, default="")
calories: Mapped[float] = mapped_column(Float, default=0)
protein_g: Mapped[float] = mapped_column(Float, default=0)
fat_g: Mapped[float] = mapped_column(Float, default=0)
carbs_g: Mapped[float] = mapped_column(Float, default=0)
source: Mapped[str] = mapped_column(String(32), default="llm")
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
class StepLog(Base):
__tablename__ = "step_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
steps: Mapped[int] = mapped_column(Integer, default=0)
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
source: Mapped[str] = mapped_column(String(32), default="manual")
notes: Mapped[str] = mapped_column(Text, default="")
class WaterLog(Base):
__tablename__ = "water_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
amount_ml: Mapped[int] = mapped_column(Integer)
class WorkoutLog(Base):
__tablename__ = "workout_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
notes: Mapped[str] = mapped_column(Text, default="")
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
active_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
total_calories: Mapped[float | None] = mapped_column(Float, nullable=True)
steps: Mapped[int | None] = mapped_column(Integer, nullable=True)
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
class FitnessReminder(Base):
__tablename__ = "fitness_reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
kind: Mapped[str] = mapped_column(String(32))
hour: Mapped[int] = mapped_column(Integer, default=12)
minute: Mapped[int] = mapped_column(Integer, default=0)
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ShoppingList(Base):
__tablename__ = "shopping_lists"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_shopping_lists_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(255), index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
items: Mapped[list["ShoppingListItem"]] = relationship(
back_populates="shopping_list",
cascade="all, delete-orphan",
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
)
class ShoppingListItem(Base):
__tablename__ = "shopping_list_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
list_id: Mapped[int] = mapped_column(
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
)
text: Mapped[str] = mapped_column(String(500))
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
unit: Mapped[str] = mapped_column(String(64), default="")
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
class Reminder(Base):
__tablename__ = "reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255))
notes: Mapped[str] = mapped_column(Text, default="")
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
recurrence: Mapped[str] = mapped_column(String(16), default="none")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AssistantState(Base):
__tablename__ = "assistant_state"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[str] = mapped_column(Text, default="")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class WorkItem(Base):
__tablename__ = "work_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
taiga_project_id: Mapped[int] = mapped_column(Integer)
taiga_story_id: Mapped[int] = mapped_column(Integer)
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
raw_text: Mapped[str] = mapped_column(Text, default="")
title: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(32), default="open")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Document(Base):
__tablename__ = "documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(255), default="")
filename: Mapped[str] = mapped_column(String(255), default="")
content_hash: Mapped[str] = mapped_column(String(64), default="", index=True)
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
chunks: Mapped[list["DocumentChunk"]] = relationship(
back_populates="document",
cascade="all, delete-orphan",
order_by="DocumentChunk.chunk_index",
)
class DocumentChunk(Base):
__tablename__ = "document_chunks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
document_id: Mapped[int] = mapped_column(
ForeignKey("documents.id", ondelete="CASCADE"), index=True
)
chunk_index: Mapped[int] = mapped_column(Integer, default=0)
content: Mapped[str] = mapped_column(Text, default="")
document: Mapped["Document"] = relationship(back_populates="chunks")
+299
View File
@@ -0,0 +1,299 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class ChatSession(Base):
__tablename__ = "chat_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(255), default="Новый чат")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
messages: Mapped[list["Message"]] = relationship(
back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at"
)
class Message(Base):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True)
role: Mapped[str] = mapped_column(String(32))
content: Mapped[str] = mapped_column(Text, default="")
tool_calls_json: Mapped[str | None] = mapped_column(Text, nullable=True)
reasoning_json: Mapped[str | None] = mapped_column(Text, nullable=True)
tool_call_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
session: Mapped["ChatSession"] = relationship(back_populates="messages")
class PomodoroCycle(Base):
__tablename__ = "pomodoro_cycles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
work_duration_min: Mapped[int] = mapped_column(Integer, default=25)
short_break_min: Mapped[int] = mapped_column(Integer, default=5)
long_break_min: Mapped[int] = mapped_column(Integer, default=15)
sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4)
completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0)
task_note: Mapped[str] = mapped_column(Text, default="")
auto_advance: Mapped[bool] = mapped_column(Boolean, default=True)
chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class PomodoroSession(Base):
__tablename__ = "pomodoro_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
status: Mapped[str] = mapped_column(String(32), default="idle")
phase: Mapped[str] = mapped_column(String(32), default="work")
duration_min: Mapped[int] = mapped_column(Integer, default=25)
task_note: Mapped[str] = mapped_column(Text, default="")
result: Mapped[str | None] = mapped_column(Text, nullable=True)
completed: Mapped[bool] = mapped_column(Boolean, default=False)
completion_notified: Mapped[bool] = mapped_column(Boolean, default=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class TaigaProject(Base):
__tablename__ = "taiga_projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
name: Mapped[str] = mapped_column(String(255))
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ProjectBinding(Base):
__tablename__ = "project_bindings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
default_branch: Mapped[str] = mapped_column(String(64), default="main")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class UserProfile(Base):
__tablename__ = "user_profile"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
data_json: Mapped[str] = mapped_column(Text, default="{}")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class MemoryFact(Base):
__tablename__ = "memory_facts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
content: Mapped[str] = mapped_column(Text)
source: Mapped[str] = mapped_column(String(32), default="user")
session_id: Mapped[int | None] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
)
importance: Mapped[int] = mapped_column(Integer, default=3)
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class SessionSummary(Base):
__tablename__ = "session_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
)
summary: Mapped[str] = mapped_column(Text, default="")
message_count: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class FitnessProfile(Base):
__tablename__ = "fitness_profiles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sex: Mapped[str] = mapped_column(String(16), default="male")
age: Mapped[int] = mapped_column(Integer, default=30)
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
goal: Mapped[str] = mapped_column(String(32), default="maintain")
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
water_l: Mapped[float] = mapped_column(Float, default=2.5)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class BodyMetric(Base):
__tablename__ = "body_metrics"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
weight_kg: Mapped[float] = mapped_column(Float)
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
notes: Mapped[str] = mapped_column(Text, default="")
class FoodLog(Base):
__tablename__ = "food_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
description: Mapped[str] = mapped_column(Text, default="")
calories: Mapped[float] = mapped_column(Float, default=0)
protein_g: Mapped[float] = mapped_column(Float, default=0)
fat_g: Mapped[float] = mapped_column(Float, default=0)
carbs_g: Mapped[float] = mapped_column(Float, default=0)
source: Mapped[str] = mapped_column(String(32), default="llm")
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
class WaterLog(Base):
__tablename__ = "water_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
amount_ml: Mapped[int] = mapped_column(Integer)
class WorkoutLog(Base):
__tablename__ = "workout_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
notes: Mapped[str] = mapped_column(Text, default="")
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
class FitnessReminder(Base):
__tablename__ = "fitness_reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
kind: Mapped[str] = mapped_column(String(32))
hour: Mapped[int] = mapped_column(Integer, default=12)
minute: Mapped[int] = mapped_column(Integer, default=0)
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ShoppingList(Base):
__tablename__ = "shopping_lists"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
items: Mapped[list["ShoppingListItem"]] = relationship(
back_populates="shopping_list",
cascade="all, delete-orphan",
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
)
class ShoppingListItem(Base):
__tablename__ = "shopping_list_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
list_id: Mapped[int] = mapped_column(
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
)
text: Mapped[str] = mapped_column(String(500))
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
unit: Mapped[str] = mapped_column(String(64), default="")
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
class Reminder(Base):
__tablename__ = "reminders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(255))
notes: Mapped[str] = mapped_column(Text, default="")
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
recurrence: Mapped[str] = mapped_column(String(16), default="none")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AssistantState(Base):
__tablename__ = "assistant_state"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[str] = mapped_column(Text, default="")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class WorkItem(Base):
__tablename__ = "work_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
taiga_project_id: Mapped[int] = mapped_column(Integer)
taiga_story_id: Mapped[int] = mapped_column(Integer)
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
raw_text: Mapped[str] = mapped_column(Text, default="")
title: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(32), default="open")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
BASELINE_STEPS_BY_LEVEL: dict[str, int] = {
"sedentary": 5000,
"light": 7000,
"moderate": 9000,
"active": 11000,
"very_active": 13000,
}
WORKOUT_KCAL_PER_SESSION = 200
KCAL_PER_STEP_PER_KG = 0.0005
FALLBACK_KCAL_PER_MIN = 6
@dataclass
class ActivityBonus:
steps: int
steps_baseline: int
steps_bonus_kcal: float
workout_active_kcal: float
workout_baseline_kcal: float
workout_bonus_kcal: float
total_bonus_kcal: float
scale_factor: float
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def baseline_steps(profile: dict[str, Any]) -> int:
override = profile.get("baseline_steps")
if override is not None:
return int(override)
level = str(profile.get("activity_level") or "moderate")
return BASELINE_STEPS_BY_LEVEL.get(level, 9000)
def baseline_workout_kcal_day(profile: dict[str, Any]) -> float:
override = profile.get("baseline_workout_kcal")
if override is not None:
return float(override)
weekly = int(profile.get("weekly_workouts") or 3)
return round(weekly * WORKOUT_KCAL_PER_SESSION / 7, 1)
def estimate_workout_active_kcal(workout: dict[str, Any]) -> float:
active = workout.get("active_calories")
if active is not None:
return float(active)
duration = workout.get("duration_min")
if duration:
return float(duration) * FALLBACK_KCAL_PER_MIN
return 0.0
def steps_bonus_kcal(*, steps: int, baseline_steps: int, weight_kg: float) -> float:
extra_steps = max(0, steps - baseline_steps)
return round(extra_steps * weight_kg * KCAL_PER_STEP_PER_KG, 1)
def compute_activity_bonus(
profile: dict[str, Any],
*,
steps_total: int,
workouts: list[dict[str, Any]],
) -> ActivityBonus:
weight_kg = float(profile.get("weight_kg") or 70)
steps_base = baseline_steps(profile)
workout_base = baseline_workout_kcal_day(profile)
s_bonus = steps_bonus_kcal(steps=steps_total, baseline_steps=steps_base, weight_kg=weight_kg)
workout_active = round(sum(estimate_workout_active_kcal(w) for w in workouts), 1)
w_bonus = max(0.0, round(workout_active - workout_base, 1))
total_bonus = round(s_bonus + w_bonus, 1)
base_cal = float(profile.get("calorie_target") or 2000)
scale_factor = 1.0 if base_cal <= 0 else round((base_cal + total_bonus) / base_cal, 4)
return ActivityBonus(
steps=steps_total,
steps_baseline=steps_base,
steps_bonus_kcal=s_bonus,
workout_active_kcal=workout_active,
workout_baseline_kcal=workout_base,
workout_bonus_kcal=w_bonus,
total_bonus_kcal=total_bonus,
scale_factor=scale_factor,
)
def _targets_dict(
*,
calories: float,
protein_g: float,
fat_g: float,
carbs_g: float,
water_ml: float,
) -> dict[str, float]:
return {
"calories": round(calories),
"protein_g": round(protein_g),
"fat_g": round(fat_g),
"carbs_g": round(carbs_g),
"water_ml": round(water_ml),
}
def build_base_targets(profile: dict[str, Any]) -> dict[str, float]:
water_l = float(profile.get("water_l") or 2.5)
return _targets_dict(
calories=float(profile.get("calorie_target") or 2000),
protein_g=float(profile.get("protein_g") or 140),
fat_g=float(profile.get("fat_g") or 65),
carbs_g=float(profile.get("carbs_g") or 200),
water_ml=water_l * 1000,
)
def scale_targets(
base_targets: dict[str, float],
bonus_kcal: float,
) -> tuple[dict[str, float], dict[str, float]]:
"""Return (effective_targets, targets_base). Water is not scaled."""
targets_base = dict(base_targets)
base_cal = float(base_targets["calories"])
if bonus_kcal <= 0 or base_cal <= 0:
return dict(base_targets), targets_base
scale = (base_cal + bonus_kcal) / base_cal
effective = _targets_dict(
calories=base_cal + bonus_kcal,
protein_g=float(base_targets["protein_g"]) * scale,
fat_g=float(base_targets["fat_g"]) * scale,
carbs_g=float(base_targets["carbs_g"]) * scale,
water_ml=float(base_targets["water_ml"]),
)
return effective, targets_base
+128
View File
@@ -0,0 +1,128 @@
import math
from typing import Any
def _is_female(sex: str) -> bool:
return sex.lower() in ("f", "female", "ж", "женский", "woman")
def _cm_to_inches(cm: float) -> float:
return cm / 2.54
def _clamp_bf(value: float) -> float:
return round(max(3.0, min(50.0, value)), 1)
def navy_body_fat_pct(
*,
sex: str,
height_cm: float,
neck_cm: float,
waist_cm: float,
hip_cm: float | None = None,
) -> float | None:
if height_cm <= 0 or neck_cm <= 0 or waist_cm <= 0:
return None
height_in = _cm_to_inches(height_cm)
neck_in = _cm_to_inches(neck_cm)
waist_in = _cm_to_inches(waist_cm)
if _is_female(sex):
if hip_cm is None or hip_cm <= 0:
return None
hip_in = _cm_to_inches(hip_cm)
sum_in = waist_in + hip_in - neck_in
if sum_in <= 0:
return None
denom = (
1.29579
- 0.35004 * math.log10(sum_in)
+ 0.22100 * math.log10(height_in)
)
else:
diff_in = waist_in - neck_in
if diff_in <= 0:
return None
denom = (
1.0324
- 0.19077 * math.log10(diff_in)
+ 0.15456 * math.log10(height_in)
)
if denom <= 0:
return None
return _clamp_bf(495.0 / denom - 450.0)
def whr(waist_cm: float, hip_cm: float) -> float | None:
if waist_cm <= 0 or hip_cm <= 0:
return None
return round(waist_cm / hip_cm, 2)
def lean_body_mass(weight_kg: float, body_fat_pct: float) -> float:
return round(weight_kg * (1.0 - body_fat_pct / 100.0), 1)
def ffmi(weight_kg: float, height_cm: float, body_fat_pct: float) -> float | None:
if height_cm <= 0:
return None
height_m = height_cm / 100.0
lbm = weight_kg * (1.0 - body_fat_pct / 100.0)
raw = lbm / (height_m * height_m)
normalized = raw + 6.1 * (1.8 - height_m)
return round(normalized, 1)
def compute_body_composition(
*,
sex: str,
height_cm: float,
weight_kg: float,
neck_cm: float | None = None,
waist_cm: float | None = None,
hip_cm: float | None = None,
body_fat_pct: float | None = None,
) -> dict[str, Any]:
warnings: list[str] = []
result: dict[str, Any] = {
"body_fat_pct": None,
"body_fat_method": None,
"whr": None,
"lbm_kg": None,
"ffmi": None,
"warnings": warnings,
}
bf = body_fat_pct
method: str | None = "manual" if bf is not None else None
if bf is None and neck_cm and waist_cm:
navy_bf = navy_body_fat_pct(
sex=sex,
height_cm=height_cm,
neck_cm=neck_cm,
waist_cm=waist_cm,
hip_cm=hip_cm,
)
if navy_bf is not None:
bf = navy_bf
method = "navy"
elif _is_female(sex) and not hip_cm:
warnings.append("Для Navy у женщин нужен обхват бёдер (hip_cm).")
elif neck_cm and waist_cm and waist_cm <= neck_cm:
warnings.append("Обхват талии должен быть больше шеи для Navy.")
if bf is not None:
result["body_fat_pct"] = round(float(bf), 1)
result["body_fat_method"] = method
result["lbm_kg"] = lean_body_mass(weight_kg, float(bf))
result["ffmi"] = ffmi(weight_kg, height_cm, float(bf))
if waist_cm and hip_cm:
result["whr"] = whr(waist_cm, hip_cm)
return result
+94 -55
View File
@@ -1,55 +1,94 @@
from typing import Any
from sqlalchemy.orm import Session
from app.fitness.service import FitnessService
def get_fitness_snapshot(db: Session) -> dict[str, Any]:
return FitnessService(db).snapshot()
def format_fitness_context(snapshot: dict[str, Any]) -> str:
lines = ["[Фитнес — сводка на сегодня]"]
profile = snapshot.get("profile")
if not profile:
lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.")
else:
lines.append(
f"Цели: {profile.get('calorie_target')} ккал, "
f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, "
f"вода {profile.get('water_l')} л"
)
if profile.get("goal"):
lines.append(
f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, "
f"рост {profile.get('height_cm')} см"
)
today = snapshot.get("today") or {}
totals = today.get("totals") or {}
targets = today.get("targets") or {}
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
lines.append("")
lines.append(
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г"
)
lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л")
workouts = today.get("workouts") or []
if workouts:
lines.append(f"Тренировок сегодня: {len(workouts)}")
lines.append("")
lines.append(
"Правила: log_meal, log_water, log_weight, log_workout, get_fitness_summary (date/days_ago), get_fitness_history, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"Еда — оценка LLM (≈), пользователь может уточнить."
)
return "\n".join(lines)
from typing import Any
from sqlalchemy.orm import Session
from app.fitness.service import FitnessService
def get_fitness_snapshot(db: Session, user_id: int) -> dict[str, Any]:
return FitnessService(db, user_id).snapshot()
def format_fitness_context(snapshot: dict[str, Any]) -> str:
lines = ["[Фитнес — сводка на сегодня]"]
profile = snapshot.get("profile")
if not profile:
lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.")
else:
lines.append(
f"Цели (база): {profile.get('calorie_target')} ккал, "
f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, "
f"вода {profile.get('water_l')} л"
)
if profile.get("goal"):
lines.append(
f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, "
f"рост {profile.get('height_cm')} см"
)
today = snapshot.get("today") or {}
totals = today.get("totals") or {}
targets = today.get("targets") or {}
targets_base = today.get("targets_base") or {}
activity = today.get("activity") or {}
steps_total = today.get("steps_total") or 0
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
if profile and (activity.get("total_bonus_kcal") or steps_total):
lines.append(
f"Активность: шаги {steps_total} (база {activity.get('steps_baseline', 0)}), "
f"бонус +{activity.get('total_bonus_kcal', 0)} ккал"
)
base_cal = targets_base.get("calories", profile.get("calorie_target"))
lines.append(f"Эффективная цель ккал: {base_cal}{targets.get('calories', base_cal)}")
lines.append("")
lines.append(
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г"
)
lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л")
workouts = today.get("workouts") or []
if workouts:
lines.append(f"Тренировок сегодня: {len(workouts)}")
stats = snapshot.get("workout_stats") or {}
if stats.get("count"):
lines.append(
f"Тренировки за {stats.get('days', 7)} дн.: {stats.get('count')} "
f"(цель/нед {stats.get('weekly_target')}, серия {stats.get('streak')} дн.)"
)
latest = (snapshot.get("body_metrics") or [None])[0]
if latest:
lines.append("")
lines.append("Антропометрия (последняя):")
parts = [f"{latest.get('weight_kg')} кг"]
if latest.get("body_fat_pct") is not None:
method = latest.get("body_fat_method") or "?"
parts.append(f"жир {latest.get('body_fat_pct')}% ({method})")
if latest.get("neck_cm"):
parts.append(f"шея {latest.get('neck_cm')}")
if latest.get("waist_cm"):
parts.append(f"талия {latest.get('waist_cm')}")
if latest.get("hip_cm"):
parts.append(f"бёдра {latest.get('hip_cm')}")
if latest.get("whr"):
parts.append(f"WHR {latest.get('whr')}")
if latest.get("ffmi"):
parts.append(f"FFMI {latest.get('ffmi')}")
lines.append(" · ".join(parts))
lines.append("")
lines.append(
"Правила: log_meal, log_water, log_weight (обхваты → Navy), log_steps, log_workout (date/days_ago), "
"calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"Еда — оценка LLM (≈), пользователь может уточнить."
)
return chr(10).join(lines)
+111 -114
View File
@@ -1,114 +1,111 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.base import SessionLocal
from app.db.models import ChatSession, FitnessReminder, Message
from app.fitness.service import FitnessService
KIND_LABELS = {
"water": "Вода",
"meal": "Еда",
"workout": "Тренировка",
"weigh_in": "Взвешивание",
}
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:
label = KIND_LABELS.get(kind, kind)
totals = summary.get("totals") or {}
targets = summary.get("targets") or {}
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
cals = totals.get("calories", 0)
cal_target = targets.get("calories", 2000)
if kind == "water":
return (
f"💪 **{label}** · выпито {water_l:.1f}/{water_target:.1f} л сегодня. "
"Пора выпить стакан воды."
)
if kind == "meal":
return (
f"💪 **{label}** · {cals:.0f}/{cal_target:.0f} ккал за день. "
"Не забудь залогировать приём пищи."
)
if kind == "workout":
workouts = summary.get("workouts") or []
if workouts:
return f"💪 **{label}** · сегодня уже была тренировка. Отдыхай или лёгкая активность."
return "💪 **Тренировка** · запланирована на сегодня. Время двигаться!"
if kind == "weigh_in":
return "💪 **Взвешивание** · пора записать вес (log_weight)."
return f"💪 **{label}** · напоминание"
def check_reminders(db: Session) -> list[str]:
if not get_settings().fitness_reminders_enabled:
return []
now = datetime.now(timezone.utc)
service = FitnessService(db)
summary = service.get_daily_summary()
fired: list[str] = []
reminders = db.scalars(
select(FitnessReminder).where(FitnessReminder.enabled.is_(True))
).all()
for rem in reminders:
should_fire = False
if rem.interval_hours:
if rem.last_fired_at is None:
should_fire = now.hour >= rem.hour
else:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
should_fire = delta >= timedelta(hours=rem.interval_hours)
else:
if rem.kind == "weigh_in":
if rem.last_fired_at:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
should_fire = delta >= timedelta(days=7)
else:
should_fire = now.hour == rem.hour and now.minute >= rem.minute
else:
if rem.last_fired_at:
last = rem.last_fired_at.replace(tzinfo=timezone.utc)
already_today = last.date() == now.date()
if already_today:
continue
should_fire = now.hour == rem.hour and now.minute >= rem.minute
if not should_fire:
continue
notice = _build_notice(rem.kind, summary)
rem.last_fired_at = now
fired.append(notice)
if fired:
db.commit()
for notice in fired:
_post_fitness_notice(notice)
return fired
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.chat.notice_inbox import post_notice_to_latest_chat
from app.config import get_settings
from app.db.models import FitnessReminder, User
from app.fitness.service import FitnessService
KIND_LABELS = {
"water": "Вода",
"meal": "Еда",
"workout": "Тренировка",
"weigh_in": "Взвешивание",
}
def _build_notice(kind: str, summary: dict) -> str:
label = KIND_LABELS.get(kind, kind)
totals = summary.get("totals") or {}
targets = summary.get("targets") or {}
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
cals = totals.get("calories", 0)
cal_target = targets.get("calories", 2000)
if kind == "water":
return (
f"💪 **{label}** · выпито {water_l:.1f}/{water_target:.1f} л сегодня. "
"Пора выпить стакан воды."
)
if kind == "meal":
return (
f"💪 **{label}** · {cals:.0f}/{cal_target:.0f} ккал за день. "
"Не забудь залогировать приём пищи."
)
if kind == "workout":
workouts = summary.get("workouts") or []
if workouts:
return f"💪 **{label}** · сегодня уже была тренировка. Отдыхай или лёгкая активность."
return "💪 **Тренировка** · запланирована на сегодня. Время двигаться!"
if kind == "weigh_in":
return "💪 **Взвешивание** · пора записать вес (log_weight)."
return f"💪 **{label}** · напоминание"
def _check_user_reminders(db: Session, user_id: int) -> list[str]:
now = datetime.now(timezone.utc)
service = FitnessService(db, user_id)
summary = service.get_daily_summary()
fired: list[str] = []
reminders = db.scalars(
select(FitnessReminder).where(
FitnessReminder.user_id == user_id,
FitnessReminder.enabled.is_(True),
)
).all()
for rem in reminders:
should_fire = False
if rem.interval_hours:
if rem.last_fired_at is None:
should_fire = now.hour >= rem.hour
else:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
should_fire = delta >= timedelta(hours=rem.interval_hours)
else:
if rem.kind == "weigh_in":
if rem.last_fired_at:
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
should_fire = delta >= timedelta(days=7)
else:
should_fire = now.hour == rem.hour and now.minute >= rem.minute
else:
if rem.last_fired_at:
last = rem.last_fired_at.replace(tzinfo=timezone.utc)
already_today = last.date() == now.date()
if already_today:
continue
should_fire = now.hour == rem.hour and now.minute >= rem.minute
if not should_fire:
continue
notice = _build_notice(rem.kind, summary)
rem.last_fired_at = now
fired.append(notice)
if fired:
for notice in fired:
post_notice_to_latest_chat(notice, user_id)
return fired
def check_reminders(db: Session) -> list[str]:
if not get_settings().fitness_reminders_enabled:
return []
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
all_fired: list[str] = []
for user in users:
all_fired.extend(_check_user_reminders(db, user.id))
if all_fired:
db.commit()
return all_fired
File diff suppressed because it is too large Load Diff
+441
View File
@@ -0,0 +1,441 @@
import json
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db.models import (
BodyMetric,
FitnessProfile,
FitnessReminder,
FoodLog,
WaterLog,
WorkoutLog,
)
from app.fitness.calculators import compute_targets, one_rep_max
DEFAULT_REMINDERS = [
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
{"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None},
{"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None},
{"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None},
]
class FitnessService:
def __init__(self, db: Session):
self.db = db
def _get_profile_row(self) -> FitnessProfile | None:
return self.db.scalar(select(FitnessProfile).limit(1))
def get_profile(self) -> dict[str, Any] | None:
row = self._get_profile_row()
if not row:
return None
return self._profile_to_dict(row)
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
targets = compute_targets(
{
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
}
)
return {
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
"target_weight_kg": row.target_weight_kg,
"weekly_workouts": row.weekly_workouts,
"calorie_target": row.calorie_target,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
"carbs_g": row.carbs_g,
"water_l": row.water_l,
"computed": targets,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
row = self._get_profile_row()
is_new = row is None
if is_new:
row = FitnessProfile()
self.db.add(row)
self.db.flush()
for key in (
"sex", "age", "height_cm", "weight_kg", "activity_level",
"goal", "target_weight_kg", "weekly_workouts",
):
if key in updates and updates[key] is not None:
setattr(row, key, updates[key])
targets = compute_targets(
{
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"activity_level": row.activity_level,
"goal": row.goal,
}
)
row.calorie_target = targets["calorie_target"]
row.protein_g = targets["protein_g"]
row.fat_g = targets["fat_g"]
row.carbs_g = targets["carbs_g"]
row.water_l = targets["water_l"]
row.updated_at = datetime.now(timezone.utc)
if is_new:
self._ensure_default_reminders()
self.db.commit()
self.db.refresh(row)
return {"ok": True, "profile": self._profile_to_dict(row)}
def _ensure_default_reminders(self) -> None:
existing = self.db.scalars(select(FitnessReminder)).all()
if existing:
return
for item in DEFAULT_REMINDERS:
self.db.add(FitnessReminder(**item))
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
return compute_targets(params)
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
d = day or datetime.now(timezone.utc).date()
start = datetime.combine(d, time.min, tzinfo=timezone.utc)
end = datetime.combine(d, time.max, tzinfo=timezone.utc)
return start, end
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
start, end = self._day_bounds(day)
profile = self.get_profile()
foods = self.db.scalars(
select(FoodLog)
.where(FoodLog.logged_at >= start, FoodLog.logged_at <= end)
.order_by(FoodLog.logged_at)
).all()
waters = self.db.scalars(
select(WaterLog)
.where(WaterLog.logged_at >= start, WaterLog.logged_at <= end)
.order_by(WaterLog.logged_at)
).all()
workouts = self.db.scalars(
select(WorkoutLog)
.where(WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
.order_by(WorkoutLog.logged_at)
).all()
totals = {
"calories": sum(f.calories for f in foods),
"protein_g": sum(f.protein_g for f in foods),
"fat_g": sum(f.fat_g for f in foods),
"carbs_g": sum(f.carbs_g for f in foods),
"water_ml": sum(w.amount_ml for w in waters),
}
targets = profile or {
"calorie_target": 2000,
"protein_g": 140,
"fat_g": 65,
"carbs_g": 200,
"water_l": 2.5,
}
return {
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
"profile_configured": profile is not None,
"totals": totals,
"targets": {
"calories": targets.get("calorie_target", 2000),
"protein_g": targets.get("protein_g", 140),
"fat_g": targets.get("fat_g", 65),
"carbs_g": targets.get("carbs_g", 200),
"water_ml": targets.get("water_l", 2.5) * 1000,
},
"meals": [self._food_to_dict(f) for f in foods],
"water": [self._water_to_dict(w) for w in waters],
"workouts": [self._workout_to_dict(w) for w in workouts],
}
def log_meal(
self,
*,
description: str,
meal_type: str = "snack",
calories: float = 0,
protein_g: float = 0,
fat_g: float = 0,
carbs_g: float = 0,
source: str = "llm",
estimated: bool = True,
) -> dict[str, Any]:
row = FoodLog(
meal_type=meal_type[:32],
description=description[:2000],
calories=calories,
protein_g=protein_g,
fat_g=fat_g,
carbs_g=carbs_g,
source=source[:32],
estimated=estimated,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "meal": self._food_to_dict(row)}
def log_water(self, amount_ml: int) -> dict[str, Any]:
row = WaterLog(amount_ml=max(0, amount_ml))
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "water": self._water_to_dict(row)}
def log_weight(
self,
weight_kg: float,
*,
body_fat_pct: float | None = None,
chest_cm: float | None = None,
waist_cm: float | None = None,
notes: str = "",
) -> dict[str, Any]:
row = BodyMetric(
weight_kg=weight_kg,
body_fat_pct=body_fat_pct,
chest_cm=chest_cm,
waist_cm=waist_cm,
notes=notes[:1000],
)
self.db.add(row)
profile = self._get_profile_row()
if profile:
profile.weight_kg = weight_kg
targets = compute_targets(
{
"sex": profile.sex,
"age": profile.age,
"height_cm": profile.height_cm,
"weight_kg": weight_kg,
"activity_level": profile.activity_level,
"goal": profile.goal,
}
)
profile.calorie_target = targets["calorie_target"]
profile.protein_g = targets["protein_g"]
profile.fat_g = targets["fat_g"]
profile.carbs_g = targets["carbs_g"]
profile.water_l = targets["water_l"]
self.db.commit()
self.db.refresh(row)
return {
"ok": True,
"metric": {
"id": row.id,
"weight_kg": row.weight_kg,
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
},
}
def log_workout(
self,
*,
title: str,
notes: str = "",
duration_min: int | None = None,
exercises: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
row = WorkoutLog(
title=title[:255],
notes=notes[:2000],
duration_min=duration_min,
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "workout": self._workout_to_dict(row)}
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
rows = self.db.scalars(
select(BodyMetric).order_by(BodyMetric.recorded_at.desc()).limit(limit)
).all()
return [
{
"id": r.id,
"weight_kg": r.weight_kg,
"body_fat_pct": r.body_fat_pct,
"chest_cm": r.chest_cm,
"waist_cm": r.waist_cm,
"notes": r.notes,
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None,
}
for r in rows
]
def delete_food_log(self, log_id: int) -> bool:
row = self.db.get(FoodLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_water_log(self, log_id: int) -> bool:
row = self.db.get(WaterLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def delete_workout_log(self, log_id: int) -> bool:
row = self.db.get(WorkoutLog, log_id)
if not row:
return False
self.db.delete(row)
self.db.commit()
return True
def list_reminders(self) -> list[dict[str, Any]]:
rows = self.db.scalars(select(FitnessReminder).order_by(FitnessReminder.kind)).all()
return [self._reminder_to_dict(r) for r in rows]
def set_reminder(
self,
kind: str,
*,
enabled: bool | None = None,
hour: int | None = None,
minute: int | None = None,
interval_hours: int | None = None,
) -> dict[str, Any]:
row = self.db.scalar(
select(FitnessReminder).where(FitnessReminder.kind == kind)
)
if not row:
row = FitnessReminder(kind=kind)
self.db.add(row)
if enabled is not None:
row.enabled = enabled
if hour is not None:
row.hour = hour
if minute is not None:
row.minute = minute
if interval_hours is not None:
row.interval_hours = interval_hours
self.db.commit()
self.db.refresh(row)
return {"ok": True, "reminder": self._reminder_to_dict(row)}
def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]:
return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)}
def get_history(
self,
*,
days: int = 7,
end_day: date | None = None,
) -> dict[str, Any]:
days = max(1, min(days, 90))
end = end_day or datetime.now(timezone.utc).date()
start = end - timedelta(days=days - 1)
summaries: list[dict[str, Any]] = []
for offset in range(days):
d = start + timedelta(days=offset)
full = self.get_daily_summary(d)
totals = full["totals"]
has_data = bool(full["meals"] or full["water"] or full["workouts"])
summaries.append(
{
"date": full["date"],
"has_data": has_data,
"totals": totals,
"targets": full["targets"],
"meal_count": len(full["meals"]),
"workout_count": len(full["workouts"]),
}
)
return {
"start_date": start.isoformat(),
"end_date": end.isoformat(),
"days": days,
"summaries": summaries,
}
def snapshot(self) -> dict[str, Any]:
today = datetime.now(timezone.utc).date()
return {
"profile": self.get_profile(),
"today": self.get_daily_summary(today),
"history": self.get_history(days=7, end_day=today),
"body_metrics": self.list_body_metrics(limit=10),
"reminders": self.list_reminders(),
}
@staticmethod
def _food_to_dict(row: FoodLog) -> dict[str, Any]:
return {
"id": row.id,
"meal_type": row.meal_type,
"description": row.description,
"calories": row.calories,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
"carbs_g": row.carbs_g,
"source": row.source,
"estimated": row.estimated,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _water_to_dict(row: WaterLog) -> dict[str, Any]:
return {
"id": row.id,
"amount_ml": row.amount_ml,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]:
try:
exercises = json.loads(row.exercises_json or "[]")
except json.JSONDecodeError:
exercises = []
return {
"id": row.id,
"title": row.title,
"notes": row.notes,
"duration_min": row.duration_min,
"exercises": exercises,
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
}
@staticmethod
def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]:
return {
"id": row.id,
"kind": row.kind,
"hour": row.hour,
"minute": row.minute,
"interval_hours": row.interval_hours,
"enabled": row.enabled,
"last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None,
}
+96 -66
View File
@@ -1,66 +1,96 @@
import json
from typing import Any
from app.llm.client import LLMClient
from app.projects.structuring import strip_markdown_json
MEAL_PROMPT = """
Преобразуй описание еды в JSON. Только JSON, без markdown.
Схема:
{
"meal_type": "breakfast|lunch|dinner|snack",
"description": "краткое описание",
"calories": 0,
"protein_g": 0,
"fat_g": 0,
"carbs_g": 0,
"estimated": true
}
Правила:
- Оцени ккал и БЖУ по типичным значениям для России/СНГ.
- Все числа float/int, метрическая система (г, ккал).
- meal_type угадай из контекста или snack.
- estimated всегда true для LLM-оценки.
""".strip()
WORKOUT_PROMPT = """
Преобразуй описание тренировки в JSON. Только JSON.
Схема:
{
"title": "название",
"duration_min": null,
"notes": "",
"exercises": [
{"name": "жим лёжа", "sets": 3, "reps": 8, "weight_kg": 80}
]
}
Правила:
- weight_kg в кг, метрическая система.
- Если данных нет null или пустой массив.
""".strip()
async def structure_meal(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": MEAL_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
async def structure_workout(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": WORKOUT_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
import json
from typing import Any
from app.llm.client import LLMClient
from app.projects.structuring import strip_markdown_json
MEAL_PROMPT = """
Преобразуй описание еды в JSON. Только JSON, без markdown.
Схема:
{
"meal_type": "breakfast|lunch|dinner|snack",
"description": "краткое описание",
"calories": 0,
"protein_g": 0,
"fat_g": 0,
"carbs_g": 0,
"estimated": true
}
Правила:
- Оцени ккал и БЖУ по типичным значениям для России/СНГ.
- Все числа float/int, метрическая система (г, ккал).
- meal_type угадай из контекста или snack.
- estimated всегда true для LLM-оценки.
""".strip()
WORKOUT_PROMPT = """
Преобразуй описание тренировки в JSON. Только JSON.
Формат:
{
"title": "название",
"duration_min": null,
"active_calories": null,
"total_calories": null,
"steps": null,
"notes": "",
"exercises": [
{"name": "имя упраж", "sets": 3, "reps": 8, "weight_kg": 80}
]
}
Правила:
- weight_kg в кг, округляй разумно.
- active_calories / total_calories / steps если упомянуты в тексте, иначе null.
- Если данных нет null или пустой массив.
""".strip()
STEPS_PROMPT = """
Преобразуй запись о шагах в JSON. Только JSON.
Формат:
{
"steps": 0,
"active_calories": null,
"notes": ""
}
Правила:
- steps целое число шагов за день.
- active_calories только если явно указаны.
""".strip()
async def structure_meal(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": MEAL_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
async def structure_workout(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": WORKOUT_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
async def structure_steps(raw_text: str) -> dict[str, Any]:
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": STEPS_PROMPT},
{"role": "user", "content": raw_text},
],
temperature=0.2,
)
raw = strip_markdown_json(result.get("content") or "")
return json.loads(raw)
+42 -42
View File
@@ -1,42 +1,42 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from app.memory.service import MemoryService
WEEKDAY_RU = (
"понедельник",
"вторник",
"среда",
"четверг",
"пятница",
"суббота",
"воскресенье",
)
DEFAULT_TIMEZONE = "Europe/Moscow"
def resolve_timezone(db: Session) -> str:
profile = MemoryService(db).get_profile()
tz = (profile.get("timezone") or "").strip()
return tz or DEFAULT_TIMEZONE
def format_datetime_context(db: Session) -> str:
tz_name = resolve_timezone(db)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo(DEFAULT_TIMEZONE)
tz_name = DEFAULT_TIMEZONE
now = datetime.now(tz)
weekday = WEEKDAY_RU[now.weekday()]
lines = [
"[Текущее время]",
f"Сейчас: {now.strftime('%Y-%m-%d %H:%M')} ({weekday}), часовой пояс {tz_name}.",
"Учитывай время при ответах о «сегодня», «утром», «вечером» и расписании.",
]
return "\n".join(lines)
from datetime import datetime
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from app.memory.service import MemoryService
WEEKDAY_RU = (
"понедельник",
"вторник",
"среда",
"четверг",
"пятница",
"суббота",
"воскресенье",
)
DEFAULT_TIMEZONE = "Europe/Moscow"
def resolve_timezone(db: Session, user_id: int) -> str:
profile = MemoryService(db, user_id).get_profile()
tz = (profile.get("timezone") or "").strip()
return tz or DEFAULT_TIMEZONE
def format_datetime_context(db: Session, user_id: int) -> str:
tz_name = resolve_timezone(db, user_id)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo(DEFAULT_TIMEZONE)
tz_name = DEFAULT_TIMEZONE
now = datetime.now(tz)
weekday = WEEKDAY_RU[now.weekday()]
lines = [
"[Текущее время]",
f"Сейчас: {now.strftime('%Y-%m-%d %H:%M')} ({weekday}), часовой пояс {tz_name}.",
"Учитывай время при ответах о «сегодня», «утром», «вечером» и расписании.",
]
return "\n".join(lines)
+131 -130
View File
@@ -1,130 +1,131 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.config import get_settings
from app.db.models import Message
from app.homelab.comfyui import ComfyUIClient
from app.integrations.rp_chat import RpChatClient
def _card_image_settings() -> dict[str, Any]:
return CharacterService().get_card().get("data", {})
def _session_messages(db: Session, session_id: int | None, limit: int = 8) -> list[dict[str, str]]:
if not session_id:
return []
rows = db.scalars(
select(Message)
.where(
Message.session_id == session_id,
Message.role.in_(("user", "assistant")),
)
.order_by(Message.created_at.desc())
.limit(limit)
).all()
rows = list(reversed(rows))
return [{"role": m.role, "content": (m.content or "").strip()} for m in rows if m.content.strip()]
def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str:
if not lora_name or f"<lora:{lora_name}" in positive:
return positive
return f"{positive} <lora:{lora_name}:{lora_weight}>"
async def generate_image(
db: Session,
*,
session_id: int | None = None,
draw_self: bool = False,
scene_description: str = "",
) -> dict[str, Any]:
card = _card_image_settings()
settings = get_settings()
if not card.get("sd_enabled", True):
return {"ok": False, "error": "Генерация изображений отключена в настройках персонажа"}
if not draw_self and not scene_description.strip():
return {"ok": False, "error": "Нужен draw_self=true или scene_description"}
appearance = (card.get("appearance_tags") or "").strip()
if draw_self and not appearance:
return {
"ok": False,
"error": "Заполни appearance_tags в настройках персонажа для «нарисуй себя»",
}
messages = _session_messages(db, session_id)
if scene_description.strip():
messages = messages + [{"role": "user", "content": scene_description.strip()}]
elif draw_self and messages:
messages = messages + [{"role": "user", "content": "Illustrate the current scene with the character."}]
elif draw_self:
messages = [{"role": "user", "content": "Portrait of the character, looking at viewer, friendly expression."}]
if settings.rp_chat_enabled:
appearance_override = (card.get("appearance_tags") or "").strip() or None
return await _generate_via_rp_chat(card, messages, appearance_override)
return await _generate_via_local_comfy(scene_description or "anime character portrait")
async def _generate_via_rp_chat(
card: dict[str, Any],
messages: list[dict[str, str]],
appearance_override: str | None,
) -> dict[str, Any]:
client = RpChatClient()
persona_id = (card.get("rp_persona_id") or "").strip() or "default"
override = appearance_override or (card.get("appearance_tags") or "").strip() or None
prompt_result = await client.sd_prompt(
persona_id,
messages,
appearance_override=override,
)
if not prompt_result.get("ok"):
return prompt_result
positive = (
prompt_result.get("hybrid_positive")
or prompt_result.get("tag_positive")
or ""
).strip()
negative = (prompt_result.get("negative") or "").strip()
if not positive:
return {"ok": False, "error": "RP-чат не вернул промпт", "raw": prompt_result}
lora = (card.get("lora_name") or "").strip()
if lora:
weight = float(card.get("lora_weight") or 0.8)
positive = _append_lora(positive, lora, weight)
gen_result = await client.generate(positive, negative)
if not gen_result.get("ok"):
return gen_result
saved = await client.save_image_locally(gen_result["image_path"])
if not saved.get("ok"):
return saved
return {
"ok": True,
"url": saved["url"],
"filename": saved["filename"],
"prompt": positive,
"backend": "rp_chat",
"persona_id": persona_id,
}
async def _generate_via_local_comfy(prompt: str) -> dict[str, Any]:
result = await ComfyUIClient().generate_image(prompt)
if result.get("ok"):
result["backend"] = "comfyui_local"
return result
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.config import get_settings
from app.db.models import Message
from app.homelab.comfyui import ComfyUIClient
from app.integrations.rp_chat import RpChatClient
def _card_image_settings(db: Session, user_id: int) -> dict[str, Any]:
return CharacterService(db, user_id).get_card().get("data", {})
def _session_messages(db: Session, session_id: int | None, limit: int = 8) -> list[dict[str, str]]:
if not session_id:
return []
rows = db.scalars(
select(Message)
.where(
Message.session_id == session_id,
Message.role.in_(("user", "assistant")),
)
.order_by(Message.created_at.desc())
.limit(limit)
).all()
rows = list(reversed(rows))
return [{"role": m.role, "content": (m.content or "").strip()} for m in rows if m.content.strip()]
def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str:
if not lora_name or f"<lora:{lora_name}" in positive:
return positive
return f"{positive} <lora:{lora_name}:{lora_weight}>"
async def generate_image(
db: Session,
*,
user_id: int,
session_id: int | None = None,
draw_self: bool = False,
scene_description: str = "",
) -> dict[str, Any]:
card = _card_image_settings(db, user_id)
settings = get_settings()
if not card.get("sd_enabled", True):
return {"ok": False, "error": "Генерация изображений отключена в настройках персонажа"}
if not draw_self and not scene_description.strip():
return {"ok": False, "error": "Нужен draw_self=true или scene_description"}
appearance = (card.get("appearance_tags") or "").strip()
if draw_self and not appearance:
return {
"ok": False,
"error": "Заполни appearance_tags в настройках персонажа для «нарисуй себя»",
}
messages = _session_messages(db, session_id)
if scene_description.strip():
messages = messages + [{"role": "user", "content": scene_description.strip()}]
elif draw_self and messages:
messages = messages + [{"role": "user", "content": "Illustrate the current scene with the character."}]
elif draw_self:
messages = [{"role": "user", "content": "Portrait of the character, looking at viewer, friendly expression."}]
if settings.rp_chat_enabled:
appearance_override = (card.get("appearance_tags") or "").strip() or None
return await _generate_via_rp_chat(card, messages, appearance_override)
return await _generate_via_local_comfy(scene_description or "anime character portrait")
async def _generate_via_rp_chat(
card: dict[str, Any],
messages: list[dict[str, str]],
appearance_override: str | None,
) -> dict[str, Any]:
client = RpChatClient()
persona_id = (card.get("rp_persona_id") or "").strip() or "default"
override = appearance_override or (card.get("appearance_tags") or "").strip() or None
prompt_result = await client.sd_prompt(
persona_id,
messages,
appearance_override=override,
)
if not prompt_result.get("ok"):
return prompt_result
positive = (
prompt_result.get("hybrid_positive")
or prompt_result.get("tag_positive")
or ""
).strip()
negative = (prompt_result.get("negative") or "").strip()
if not positive:
return {"ok": False, "error": "RP-чат не вернул промпт", "raw": prompt_result}
lora = (card.get("lora_name") or "").strip()
if lora:
weight = float(card.get("lora_weight") or 0.8)
positive = _append_lora(positive, lora, weight)
gen_result = await client.generate(positive, negative)
if not gen_result.get("ok"):
return gen_result
saved = await client.save_image_locally(gen_result["image_path"])
if not saved.get("ok"):
return saved
return {
"ok": True,
"url": saved["url"],
"filename": saved["filename"],
"prompt": positive,
"backend": "rp_chat",
"persona_id": persona_id,
}
async def _generate_via_local_comfy(prompt: str) -> dict[str, Any]:
result = await ComfyUIClient().generate_image(prompt)
if result.get("ok"):
result["backend"] = "comfyui_local"
return result
+5
View File
@@ -0,0 +1,5 @@
from app.chat.notice_inbox import post_notice_to_latest_chat
def post_chat_notice(content: str, user_id: int) -> None:
post_notice_to_latest_chat(content, user_id)
+164
View File
@@ -0,0 +1,164 @@
import asyncio
import logging
import random
from datetime import datetime
from zoneinfo import ZoneInfo
import httpx
from sqlalchemy import select
from app.config import get_settings
from app.db.base import SessionLocal
from app.db.models import User
from app.homelab.comfyui import ComfyUIClient
from app.homelab.context import resolve_timezone
from app.homelab.digest import build_morning_digest
from app.homelab.monitoring import check_netdata_alerts
from app.homelab_scoped.notices import post_chat_notice
from app.homelab.state import get_state, set_state
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 60
_netdata_tick = 0
async def homelab_watcher_loop() -> None:
global _netdata_tick
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick_morning_digest()
await _tick_rofl()
settings = get_settings()
_netdata_tick += WATCH_INTERVAL_SEC
if _netdata_tick >= settings.netdata_poll_interval_sec:
_netdata_tick = 0
await _tick_netdata()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Homelab watcher error")
async def _tick_morning_digest() -> None:
settings = get_settings()
if not settings.morning_digest_enabled:
return
db = SessionLocal()
try:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
digest = build_morning_digest(db, include_news=True)
for user in users:
tz_name = resolve_timezone(db, user.id)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
now = datetime.now(tz)
target_min = settings.morning_digest_hour * 60 + settings.morning_digest_minute
current_min = now.hour * 60 + now.minute
if current_min < target_min or current_min >= target_min + 3:
continue
today = now.date().isoformat()
state_key = f"last_morning_digest_date:{user.id}"
if get_state(db, state_key) == today:
continue
post_chat_notice(digest, user.id)
set_state(db, state_key, today)
finally:
db.close()
async def _tick_netdata() -> None:
db = SessionLocal()
try:
notices = check_netdata_alerts(db)
if not notices:
return
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
for user in users:
for notice in notices:
post_chat_notice(notice, user.id)
finally:
db.close()
async def _comfyui_reachable(base_url: str) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(3.0, connect=2.0)) as client:
response = await client.get(f"{base_url.rstrip('/')}/system_stats")
return response.status_code < 500
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError):
return False
async def _tick_rofl() -> None:
settings = get_settings()
if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled:
return
db = SessionLocal()
try:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
for user in users:
tz_name = resolve_timezone(db, user.id)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
now = datetime.now(tz)
last_raw = get_state(db, f"last_comfy_rofl_at:{user.id}")
if last_raw:
try:
last_at = datetime.fromisoformat(last_raw)
if last_at.tzinfo is None:
last_at = last_at.replace(tzinfo=tz)
if (now - last_at).total_seconds() < settings.comfyui_rofl_min_interval_hours * 3600:
continue
except ValueError:
pass
if random.random() > settings.comfyui_rofl_probability:
continue
today = now.date().isoformat()
count_key = f"comfy_rofl_count_{today}:{user.id}"
count_raw = get_state(db, count_key) or "0"
try:
count = int(count_raw)
except ValueError:
count = 0
if count >= settings.comfyui_rofl_max_per_day:
continue
client = ComfyUIClient()
if not await _comfyui_reachable(client.base_url):
continue
prompt = client.random_rofl_prompt()
try:
result = await asyncio.wait_for(
client.generate_image(prompt),
timeout=settings.comfyui_timeout_sec + 15,
)
except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc:
logger.warning("Rofl image skipped (ComfyUI): %s", exc)
continue
if not result.get("ok"):
logger.warning("Rofl image failed: %s", result.get("error"))
continue
url = result.get("url", "")
post_chat_notice(
f"🎨 **Рофл дня**\n\n![rofl]({url})\n\n_{prompt}_",
user.id,
)
set_state(db, count_key, str(count + 1))
set_state(db, f"last_comfy_rofl_at:{user.id}", now.isoformat())
finally:
db.close()
+323 -269
View File
@@ -1,269 +1,323 @@
import json
import 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 {}
import json
import logging
from collections.abc import AsyncIterator
from typing import Any
from openai import AsyncOpenAI
from app.config import get_settings
logger = logging.getLogger(__name__)
class LLMClient:
def __init__(self) -> None:
settings = get_settings()
self.tools_enabled = settings.openrouter_tools_enabled
self.client = AsyncOpenAI(
api_key=settings.openrouter_api_key,
base_url=settings.openrouter_base_url,
)
def _runtime(self) -> tuple[str, str, str]:
from app.db.base import SessionLocal
from app.settings.service import SettingsService
settings = get_settings()
db = SessionLocal()
try:
svc = SettingsService(db)
model = str(svc.get_effective("openrouter_model"))
extract = str(svc.get_effective("memory_extract_model"))
effort = str(svc.get_effective("openrouter_reasoning_effort")).strip().lower()
return model, extract, effort
finally:
db.close()
@property
def model(self) -> str:
return self._runtime()[0]
@property
def memory_extract_model(self) -> str:
return self._runtime()[1]
@property
def reasoning_effort(self) -> str:
return self._runtime()[2]
def _reasoning_extra_body(self) -> dict[str, Any] | None:
if not self.reasoning_effort:
return None
if self.reasoning_effort == "none":
return {"reasoning": {"effort": "none", "exclude": True}}
return {"reasoning": {"effort": self.reasoning_effort}}
@staticmethod
def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]:
parts: list[str] = []
for attr in ("reasoning", "reasoning_content"):
value = getattr(delta, attr, None)
if value:
parts.append(str(value))
details: list[Any] = []
raw_details = getattr(delta, "reasoning_details", None)
if raw_details:
if isinstance(raw_details, list):
details.extend(raw_details)
else:
details.append(raw_details)
return "".join(parts), details
@staticmethod
def _normalize_reasoning_details(details: Any) -> list[Any] | None:
if not details:
return None
items = details if isinstance(details, list) else [details]
normalized: list[Any] = []
for item in items:
if hasattr(item, "model_dump"):
normalized.append(item.model_dump())
elif isinstance(item, dict):
normalized.append(item)
else:
normalized.append(item)
return normalized or None
@staticmethod
def attach_reasoning_to_message(
message: dict[str, Any],
*,
reasoning: str = "",
reasoning_details: list[Any] | None = None,
) -> dict[str, Any]:
if reasoning:
message["reasoning"] = reasoning
message["reasoning_content"] = reasoning
normalized = LLMClient._normalize_reasoning_details(reasoning_details)
if normalized:
message["reasoning_details"] = normalized
return message
async def stream_chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
*,
model: str | None = None,
) -> AsyncIterator[dict[str, Any]]:
use_tools = bool(tools) and self.tools_enabled
kwargs: dict[str, Any] = {
"model": model or self.model,
"messages": messages,
"stream": True,
"temperature": 0.7,
}
if use_tools:
kwargs["tools"] = tools
extra_body = self._reasoning_extra_body()
if extra_body:
kwargs["extra_body"] = extra_body
try:
stream = await self.client.chat.completions.create(**kwargs)
except Exception as exc:
logger.exception("LLM stream failed: %s", exc)
yield {"type": "error", "content": str(exc)}
yield {"type": "done", "finish_reason": "error"}
return
tool_calls: dict[int, dict[str, Any]] = {}
reasoning_parts: list[str] = []
reasoning_details: list[Any] = []
try:
async for chunk in stream:
if not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
if delta.content:
yield {"type": "content", "content": delta.content}
reasoning_text, details = self._delta_reasoning(delta)
if reasoning_text:
reasoning_parts.append(reasoning_text)
if details:
reasoning_details.extend(details)
if delta.tool_calls:
for tool_call in delta.tool_calls:
idx = tool_call.index
if idx not in tool_calls:
tool_calls[idx] = {
"id": tool_call.id or "",
"type": "function",
"function": {"name": "", "arguments": ""},
}
if tool_call.id:
tool_calls[idx]["id"] = tool_call.id
if tool_call.function:
if tool_call.function.name:
tool_calls[idx]["function"]["name"] = tool_call.function.name
if tool_call.function.arguments:
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
usage = getattr(chunk, "usage", None)
if usage is not None:
logger.info(
"LLM stream usage: prompt=%s completion=%s total=%s",
getattr(usage, "prompt_tokens", None),
getattr(usage, "completion_tokens", None),
getattr(usage, "total_tokens", None),
)
if choice.finish_reason:
reasoning = "".join(reasoning_parts)
normalized_details = self._normalize_reasoning_details(reasoning_details)
if reasoning or normalized_details:
yield {
"type": "reasoning",
"reasoning": reasoning,
"reasoning_details": normalized_details,
}
if tool_calls:
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
logger.info(
"LLM stream done: model=%s finish_reason=%s tool_calls=%d "
"content_in_stream=%d reasoning_len=%d",
model or self.model,
choice.finish_reason,
len(tool_calls),
len(reasoning_parts),
len(reasoning),
)
yield {"type": "done", "finish_reason": choice.finish_reason}
except Exception as exc:
logger.exception("LLM stream read failed: %s", exc)
yield {"type": "error", "content": str(exc)}
yield {"type": "done", "finish_reason": "error"}
async def complete(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
*,
temperature: float = 0.7,
model: str | None = None,
for_extraction: bool = False,
visible_reply: bool = False,
) -> dict[str, Any]:
use_tools = bool(tools) and self.tools_enabled and not for_extraction
kwargs: dict[str, Any] = {
"model": model or self.model,
"messages": messages,
"temperature": temperature,
}
if use_tools:
kwargs["tools"] = tools
if for_extraction:
kwargs["extra_body"] = {"reasoning": {"effort": "none"}}
else:
extra_body = self._reasoning_extra_body()
if extra_body:
kwargs["extra_body"] = extra_body
response = await self.client.chat.completions.create(**kwargs)
usage = getattr(response, "usage", None)
if usage is not None:
logger.info(
"LLM complete usage: prompt=%s completion=%s total=%s model=%s",
getattr(usage, "prompt_tokens", None),
getattr(usage, "completion_tokens", None),
getattr(usage, "total_tokens", None),
kwargs.get("model"),
)
message = response.choices[0].message
content = message.content or ""
reasoning = ""
for attr in ("reasoning", "reasoning_content"):
value = getattr(message, attr, None)
if value:
reasoning = str(value)
break
if not content and reasoning and not visible_reply:
content = reasoning
result: dict[str, Any] = {
"content": content,
"tool_calls": [],
"reasoning": reasoning,
"reasoning_details": getattr(message, "reasoning_details", None),
}
if message.tool_calls:
result["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in message.tool_calls
]
return result
@staticmethod
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
if not arguments:
return {}
try:
return json.loads(arguments)
except json.JSONDecodeError:
return {}
@staticmethod
def serialize_reasoning(
*,
reasoning: str = "",
reasoning_details: list[Any] | None = None,
) -> str | None:
payload: dict[str, Any] = {}
if reasoning:
payload["reasoning"] = reasoning
payload["reasoning_content"] = reasoning
if reasoning_details:
payload["reasoning_details"] = reasoning_details
if not payload:
return None
return json.dumps(payload, ensure_ascii=False)
@staticmethod
def deserialize_reasoning(raw: str | None) -> dict[str, Any]:
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {"reasoning": raw}
if isinstance(data, str):
return {"reasoning": data, "reasoning_content": data}
if isinstance(data, dict):
return data
return {}
async def embed(self, texts: list[str]) -> list[list[float]]:
settings = get_settings()
if not texts:
return []
response = await self.client.embeddings.create(
model=settings.embedding_model,
input=texts,
)
return [item.embedding for item in response.data]
+269
View File
@@ -0,0 +1,269 @@
import json
import logging
from collections.abc import AsyncIterator
from typing import Any
from openai import AsyncOpenAI
from app.config import get_settings
logger = logging.getLogger(__name__)
class LLMClient:
def __init__(self) -> None:
settings = get_settings()
self.model = settings.openrouter_model
self.tools_enabled = settings.openrouter_tools_enabled
self.reasoning_effort = settings.openrouter_reasoning_effort.strip().lower()
self.client = AsyncOpenAI(
api_key=settings.openrouter_api_key,
base_url=settings.openrouter_base_url,
)
def _reasoning_extra_body(self) -> dict[str, Any] | None:
if not self.reasoning_effort:
return None
if self.reasoning_effort == "none":
return {"reasoning": {"effort": "none", "exclude": True}}
return {"reasoning": {"effort": self.reasoning_effort}}
@staticmethod
def _delta_reasoning(delta: Any) -> tuple[str, list[Any]]:
parts: list[str] = []
for attr in ("reasoning", "reasoning_content"):
value = getattr(delta, attr, None)
if value:
parts.append(str(value))
details: list[Any] = []
raw_details = getattr(delta, "reasoning_details", None)
if raw_details:
if isinstance(raw_details, list):
details.extend(raw_details)
else:
details.append(raw_details)
return "".join(parts), details
@staticmethod
def _normalize_reasoning_details(details: Any) -> list[Any] | None:
if not details:
return None
items = details if isinstance(details, list) else [details]
normalized: list[Any] = []
for item in items:
if hasattr(item, "model_dump"):
normalized.append(item.model_dump())
elif isinstance(item, dict):
normalized.append(item)
else:
normalized.append(item)
return normalized or None
@staticmethod
def attach_reasoning_to_message(
message: dict[str, Any],
*,
reasoning: str = "",
reasoning_details: list[Any] | None = None,
) -> dict[str, Any]:
if reasoning:
message["reasoning"] = reasoning
message["reasoning_content"] = reasoning
normalized = LLMClient._normalize_reasoning_details(reasoning_details)
if normalized:
message["reasoning_details"] = normalized
return message
async def stream_chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
*,
model: str | None = None,
) -> AsyncIterator[dict[str, Any]]:
use_tools = bool(tools) and self.tools_enabled
kwargs: dict[str, Any] = {
"model": model or self.model,
"messages": messages,
"stream": True,
"temperature": 0.7,
}
if use_tools:
kwargs["tools"] = tools
extra_body = self._reasoning_extra_body()
if extra_body:
kwargs["extra_body"] = extra_body
try:
stream = await self.client.chat.completions.create(**kwargs)
except Exception as exc:
logger.exception("LLM stream failed: %s", exc)
yield {"type": "error", "content": str(exc)}
yield {"type": "done", "finish_reason": "error"}
return
tool_calls: dict[int, dict[str, Any]] = {}
reasoning_parts: list[str] = []
reasoning_details: list[Any] = []
try:
async for chunk in stream:
if not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
if delta.content:
yield {"type": "content", "content": delta.content}
reasoning_text, details = self._delta_reasoning(delta)
if reasoning_text:
reasoning_parts.append(reasoning_text)
if details:
reasoning_details.extend(details)
if delta.tool_calls:
for tool_call in delta.tool_calls:
idx = tool_call.index
if idx not in tool_calls:
tool_calls[idx] = {
"id": tool_call.id or "",
"type": "function",
"function": {"name": "", "arguments": ""},
}
if tool_call.id:
tool_calls[idx]["id"] = tool_call.id
if tool_call.function:
if tool_call.function.name:
tool_calls[idx]["function"]["name"] = tool_call.function.name
if tool_call.function.arguments:
tool_calls[idx]["function"]["arguments"] += tool_call.function.arguments
if choice.finish_reason:
reasoning = "".join(reasoning_parts)
normalized_details = self._normalize_reasoning_details(reasoning_details)
if reasoning or normalized_details:
yield {
"type": "reasoning",
"reasoning": reasoning,
"reasoning_details": normalized_details,
}
if tool_calls:
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
logger.info(
"LLM stream done: model=%s finish_reason=%s tool_calls=%d "
"content_in_stream=%d reasoning_len=%d",
model or self.model,
choice.finish_reason,
len(tool_calls),
len(reasoning_parts),
len(reasoning),
)
yield {"type": "done", "finish_reason": choice.finish_reason}
except Exception as exc:
logger.exception("LLM stream read failed: %s", exc)
yield {"type": "error", "content": str(exc)}
yield {"type": "done", "finish_reason": "error"}
async def complete(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
*,
temperature: float = 0.7,
model: str | None = None,
for_extraction: bool = False,
visible_reply: bool = False,
) -> dict[str, Any]:
use_tools = bool(tools) and self.tools_enabled and not for_extraction
kwargs: dict[str, Any] = {
"model": model or self.model,
"messages": messages,
"temperature": temperature,
}
if use_tools:
kwargs["tools"] = tools
if for_extraction:
kwargs["extra_body"] = {"reasoning": {"effort": "none"}}
else:
extra_body = self._reasoning_extra_body()
if extra_body:
kwargs["extra_body"] = extra_body
response = await self.client.chat.completions.create(**kwargs)
message = response.choices[0].message
content = message.content or ""
reasoning = ""
for attr in ("reasoning", "reasoning_content"):
value = getattr(message, attr, None)
if value:
reasoning = str(value)
break
if not content and reasoning and not visible_reply:
content = reasoning
result: dict[str, Any] = {
"content": content,
"tool_calls": [],
"reasoning": reasoning,
"reasoning_details": getattr(message, "reasoning_details", None),
}
if message.tool_calls:
result["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in message.tool_calls
]
return result
@staticmethod
def parse_tool_arguments(arguments: str) -> dict[str, Any]:
if not arguments:
return {}
try:
return json.loads(arguments)
except json.JSONDecodeError:
return {}
@staticmethod
def serialize_reasoning(
*,
reasoning: str = "",
reasoning_details: list[Any] | None = None,
) -> str | None:
payload: dict[str, Any] = {}
if reasoning:
payload["reasoning"] = reasoning
payload["reasoning_content"] = reasoning
if reasoning_details:
payload["reasoning_details"] = reasoning_details
if not payload:
return None
return json.dumps(payload, ensure_ascii=False)
@staticmethod
def deserialize_reasoning(raw: str | None) -> dict[str, Any]:
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {"reasoning": raw}
if isinstance(data, str):
return {"reasoning": data, "reasoning_content": data}
if isinstance(data, dict):
return data
return {}
+65 -54
View File
@@ -1,54 +1,65 @@
import asyncio
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()
import asyncio
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router
from app.config import get_settings
from app.db.base import init_db
from app.fitness.watcher import fitness_watcher_loop
from app.homelab_scoped.watcher import homelab_watcher_loop
from app.pomodoro.watcher import pomodoro_watcher_loop
from app.reminders_scoped.watcher import reminders_watcher_loop
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
from app.db.migrate_fitness import run_fitness_migrations
run_fitness_migrations()
from app.db.migrate_multi_user import run_multi_user_migrations
run_multi_user_migrations()
settings = get_settings()
if settings.rag_enabled:
from app.rag.store import ensure_collections
ensure_collections()
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
fitness_task = asyncio.create_task(fitness_watcher_loop())
homelab_task = asyncio.create_task(homelab_watcher_loop())
reminders_task = asyncio.create_task(reminders_watcher_loop())
yield
pomodoro_task.cancel()
fitness_task.cancel()
homelab_task.cancel()
reminders_task.cancel()
with suppress(asyncio.CancelledError):
await pomodoro_task
with suppress(asyncio.CancelledError):
await fitness_task
with suppress(asyncio.CancelledError):
await homelab_task
with suppress(asyncio.CancelledError):
await reminders_task
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title="Home AI Assistant", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
return app
app = create_app()
+54
View File
@@ -0,0 +1,54 @@
import asyncio
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router
from app.config import get_settings
from app.db.base import init_db
from app.fitness.watcher import fitness_watcher_loop
from app.homelab.watcher import homelab_watcher_loop
from app.pomodoro.watcher import pomodoro_watcher_loop
from app.reminders.watcher import reminders_watcher_loop
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
fitness_task = asyncio.create_task(fitness_watcher_loop())
homelab_task = asyncio.create_task(homelab_watcher_loop())
reminders_task = asyncio.create_task(reminders_watcher_loop())
yield
pomodoro_task.cancel()
fitness_task.cancel()
homelab_task.cancel()
reminders_task.cancel()
with suppress(asyncio.CancelledError):
await pomodoro_task
with suppress(asyncio.CancelledError):
await fitness_task
with suppress(asyncio.CancelledError):
await homelab_task
with suppress(asyncio.CancelledError):
await reminders_task
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title="Home AI Assistant", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
return app
app = create_app()
+89 -83
View File
@@ -1,83 +1,89 @@
from typing import Any
from 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)
from typing import Any
from sqlalchemy.orm import Session
from app.config import get_settings
from app.memory.service import MemoryService
from app.memory.parse import is_identity_question
PROFILE_KEYS = ("name", "age", "timezone", "language", "notes")
def get_memory_snapshot(
db: Session,
user_id: int,
session_id: int | None = None,
query: str | None = None,
) -> dict[str, Any]:
return MemoryService(db, user_id).snapshot(session_id, query=query)
def format_memory_context(snapshot: dict[str, Any]) -> str:
lines = ["[Память и профиль — долгосрочный контекст]"]
profile = snapshot.get("profile") or {}
profile_lines = []
for key in PROFILE_KEYS:
value = (profile.get(key) or "").strip()
if value:
profile_lines.append(f"- {key}: {value}")
if profile_lines:
lines.append("Профиль пользователя:")
lines.extend(profile_lines)
else:
lines.append("Профиль: не заполнен (можно уточнить имя, часовой пояс).")
summary = (snapshot.get("session_summary") or "").strip()
if summary:
lines.append("")
lines.append("Сводка текущего чата (ранние сообщения):")
lines.append(summary)
facts = snapshot.get("facts") or []
if facts:
lines.append("")
lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):")
limit = get_settings().memory_facts_in_context
for fact in facts[:limit]:
lines.append(
f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}"
)
else:
lines.append("")
lines.append("Запомненные факты: пока нет.")
lines.append("")
lines.append(
"Правила памяти: "
"«запомни» → remember_fact (имя/возраст также пишутся в профиль). "
"«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. "
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. "
"«забудь #N» → forget_memory. "
"Длинный чат — update_session_summary."
)
return "\n".join(lines)
def format_identity_hint(snapshot: dict[str, Any], user_text: str) -> str:
if not is_identity_question(user_text):
return ""
profile = snapshot.get("profile") or {}
facts = snapshot.get("facts") or []
lines = [
"[Вопрос об идентичности пользователя]",
"Ответь ТОЛЬКО из данных ниже. Не придумывай роли из сценария персонажа.",
]
name = (profile.get("name") or "").strip()
age = (profile.get("age") or "").strip()
if name:
lines.append(f"Имя: {name}")
if age:
lines.append(f"Возраст: {age} лет")
for fact in facts:
lines.append(f"Факт: {fact.get('content')}")
if not name and not age and not facts:
lines.append("Данных нет — скажи, что не помнишь.")
return "\n".join(lines)
@@ -0,0 +1,83 @@
from typing import Any
from sqlalchemy.orm import Session
from app.memory.service import MemoryService
from app.memory.parse import is_identity_question
MAX_FACTS_IN_CONTEXT = 25
PROFILE_KEYS = ("name", "age", "timezone", "language", "notes")
def get_memory_snapshot(db: Session, session_id: int | None = None) -> dict[str, Any]:
return MemoryService(db).snapshot(session_id)
def format_memory_context(snapshot: dict[str, Any]) -> str:
lines = ["[Память и профиль — долгосрочный контекст]"]
profile = snapshot.get("profile") or {}
profile_lines = []
for key in PROFILE_KEYS:
value = (profile.get(key) or "").strip()
if value:
profile_lines.append(f"- {key}: {value}")
if profile_lines:
lines.append("Профиль пользователя:")
lines.extend(profile_lines)
else:
lines.append("Профиль: не заполнен (можно уточнить имя, часовой пояс).")
summary = (snapshot.get("session_summary") or "").strip()
if summary:
lines.append("")
lines.append("Сводка текущего чата (ранние сообщения):")
lines.append(summary)
facts = snapshot.get("facts") or []
if facts:
lines.append("")
lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):")
for fact in facts[:MAX_FACTS_IN_CONTEXT]:
lines.append(
f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}"
)
else:
lines.append("")
lines.append("Запомненные факты: пока нет.")
lines.append("")
lines.append(
"Правила памяти: "
"«запомни» → remember_fact (имя/возраст также пишутся в профиль). "
"«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. "
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. "
"«забудь #N» → forget_memory. "
"Длинный чат — update_session_summary."
)
return "\n".join(lines)
def format_identity_hint(snapshot: dict[str, Any], user_text: str) -> str:
if not is_identity_question(user_text):
return ""
profile = snapshot.get("profile") or {}
facts = snapshot.get("facts") or []
lines = [
"[Вопрос об идентичности пользователя]",
"Ответь ТОЛЬКО из данных ниже. Не придумывай роли из сценария персонажа.",
]
name = (profile.get("name") or "").strip()
age = (profile.get("age") or "").strip()
if name:
lines.append(f"Имя: {name}")
if age:
lines.append(f"Возраст: {age} лет")
for fact in facts:
lines.append(f"Факт: {fact.get('content')}")
if not name and not age and not facts:
lines.append("Данных нет — скажи, что не помнишь.")
return "\n".join(lines)
+153 -152
View File
@@ -1,152 +1,153 @@
import json
import logging
import re
from typing import Any
from sqlalchemy.orm import Session
from app.config import get_settings
from app.llm.client import LLMClient
from app.memory.service import MemoryService
from app.projects.structuring import strip_markdown_json
logger = logging.getLogger(__name__)
SKIP_USER_PATTERN = re.compile(
r"^(ок|ok|да|нет|спасибо|thanks|\.{1,3}|👍|\+1)$",
re.IGNORECASE,
)
EXTRACTION_PROMPT = """
Ты извлекаешь долгосрочные факты о пользователе из фрагмента диалога.
Ответь ТОЛЬКО JSON без markdown.
Схема:
{
"facts": [
{"content": "текст факта", "category": "preference|person|habit|project|fact", "importance": 1}
],
"profile": {"name": "", "age": "", "timezone": "", "notes": ""}
}
Правила:
- Сохраняй устойчивое: имя, возраст, предпочтения, привычки, проекты, семья, работа.
- НЕ сохраняй: статус помидоро, погоду, разовые команды, ролевую игру, выдумки ассистента.
- profile только поля с новыми значениями (пустые строки не включай).
- facts короткие утверждения от первого лица пользователя («люблю кофе», «меня зовут »).
- Если нечего сохранять {"facts": [], "profile": {}}.
- Не дублируй уже известное (см. текущий профиль и факты ниже).
- importance: 5 критично (имя), 4 важно, 3 обычно, 2 мелочь.
""".strip()
def _should_skip_extraction(user_text: str) -> bool:
text = user_text.strip()
if len(text) < 4:
return True
if SKIP_USER_PATTERN.match(text):
return True
return False
async def _call_extractor(
user_text: str,
assistant_text: str,
snapshot: dict[str, Any],
) -> dict[str, Any]:
profile = snapshot.get("profile") or {}
facts = snapshot.get("facts") or []
known = [
f"Профиль: {json.dumps(profile, ensure_ascii=False)}",
"Факты:",
*[f"- {f.get('content')}" for f in facts[:30]],
]
settings = get_settings()
extract_model = settings.memory_extract_model.strip() or None
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": EXTRACTION_PROMPT},
{
"role": "user",
"content": (
"\n".join(known)
+ "\n\n---\nДиалог:\nПользователь: "
+ user_text
+ "\nАссистент: "
+ assistant_text[:1500]
),
},
],
temperature=0.2,
model=extract_model,
for_extraction=True,
)
raw = strip_markdown_json(result.get("content") or "")
if not raw:
return {"facts": [], "profile": {}}
parsed = json.loads(raw)
if not isinstance(parsed, dict):
return {"facts": [], "profile": {}}
return parsed
async def extract_after_turn(
db: Session,
session_id: int,
user_text: str,
assistant_text: str,
*,
force: bool = False,
) -> dict[str, Any]:
if not force and _should_skip_extraction(user_text):
return {"ok": True, "skipped": "short_message", "saved": []}
if not (assistant_text or "").strip():
return {"ok": True, "skipped": "no_assistant_reply", "saved": []}
memory = MemoryService(db)
snapshot = memory.snapshot(session_id)
try:
parsed = await _call_extractor(user_text, assistant_text, snapshot)
except (json.JSONDecodeError, Exception) as exc:
logger.warning("Memory extraction failed: %s", exc)
return {"ok": False, "error": str(exc), "saved": []}
saved: list[dict[str, Any]] = []
profile_updates = parsed.get("profile") or {}
if isinstance(profile_updates, dict):
filtered = {
k: str(v).strip()
for k, v in profile_updates.items()
if v and str(v).strip()
}
if filtered:
memory.update_profile(filtered)
saved.append({"type": "profile", "updates": filtered})
facts = parsed.get("facts") or []
if isinstance(facts, list):
for item in facts:
if not isinstance(item, dict):
continue
content = (item.get("content") or "").strip()
if not content or len(content) < 3:
continue
try:
result = memory.remember_fact(
content,
category=str(item.get("category") or "fact")[:64],
importance=int(item.get("importance") or 3),
session_id=session_id,
source="auto",
)
saved.append({"type": "fact", **result})
except ValueError:
continue
return {"ok": True, "saved": saved, "count": len(saved)}
import json
import logging
import re
from typing import Any
from sqlalchemy.orm import Session
from app.config import get_settings
from app.llm.client import LLMClient
from app.memory.service import MemoryService
from app.projects.structuring import strip_markdown_json
logger = logging.getLogger(__name__)
SKIP_USER_PATTERN = re.compile(
r"^(ок|ok|да|нет|спасибо|thanks|\.{1,3}|👍|\+1)$",
re.IGNORECASE,
)
EXTRACTION_PROMPT = """
Ты извлекаешь долгосрочные факты о пользователе из фрагмента диалога.
Ответь ТОЛЬКО JSON без markdown.
Схема:
{
"facts": [
{"content": "текст факта", "category": "preference|person|habit|project|fact", "importance": 1}
],
"profile": {"name": "", "age": "", "timezone": "", "notes": ""}
}
Правила:
- Сохраняй устойчивое: имя, возраст, предпочтения, привычки, проекты, семья, работа.
- НЕ сохраняй: статус помидоро, погоду, разовые команды, ролевую игру, выдумки ассистента.
- profile только поля с новыми значениями (пустые строки не включай).
- facts короткие утверждения от первого лица пользователя («люблю кофе», «меня зовут »).
- Если нечего сохранять {"facts": [], "profile": {}}.
- Не дублируй уже известное (см. текущий профиль и факты ниже).
- importance: 5 критично (имя), 4 важно, 3 обычно, 2 мелочь.
""".strip()
def _should_skip_extraction(user_text: str) -> bool:
text = user_text.strip()
if len(text) < 4:
return True
if SKIP_USER_PATTERN.match(text):
return True
return False
async def _call_extractor(
user_text: str,
assistant_text: str,
snapshot: dict[str, Any],
) -> dict[str, Any]:
profile = snapshot.get("profile") or {}
facts = snapshot.get("facts") or []
known = [
f"Профиль: {json.dumps(profile, ensure_ascii=False)}",
"Факты:",
*[f"- {f.get('content')}" for f in facts[:30]],
]
settings = get_settings()
extract_model = settings.memory_extract_model.strip() or None
llm = LLMClient()
result = await llm.complete(
[
{"role": "system", "content": EXTRACTION_PROMPT},
{
"role": "user",
"content": (
"\n".join(known)
+ "\n\n---\nДиалог:\nПользователь: "
+ user_text
+ "\nАссистент: "
+ assistant_text[:1500]
),
},
],
temperature=0.2,
model=extract_model,
for_extraction=True,
)
raw = strip_markdown_json(result.get("content") or "")
if not raw:
return {"facts": [], "profile": {}}
parsed = json.loads(raw)
if not isinstance(parsed, dict):
return {"facts": [], "profile": {}}
return parsed
async def extract_after_turn(
db: Session,
session_id: int,
user_text: str,
assistant_text: str,
*,
user_id: int,
force: bool = False,
) -> dict[str, Any]:
if not force and _should_skip_extraction(user_text):
return {"ok": True, "skipped": "short_message", "saved": []}
if not (assistant_text or "").strip():
return {"ok": True, "skipped": "no_assistant_reply", "saved": []}
memory = MemoryService(db, user_id)
snapshot = memory.snapshot(session_id)
try:
parsed = await _call_extractor(user_text, assistant_text, snapshot)
except (json.JSONDecodeError, Exception) as exc:
logger.warning("Memory extraction failed: %s", exc)
return {"ok": False, "error": str(exc), "saved": []}
saved: list[dict[str, Any]] = []
profile_updates = parsed.get("profile") or {}
if isinstance(profile_updates, dict):
filtered = {
k: str(v).strip()
for k, v in profile_updates.items()
if v and str(v).strip()
}
if filtered:
memory.update_profile(filtered)
saved.append({"type": "profile", "updates": filtered})
facts = parsed.get("facts") or []
if isinstance(facts, list):
for item in facts:
if not isinstance(item, dict):
continue
content = (item.get("content") or "").strip()
if not content or len(content) < 3:
continue
try:
result = memory.remember_fact(
content,
category=str(item.get("category") or "fact")[:64],
importance=int(item.get("importance") or 3),
session_id=session_id,
source="auto",
)
saved.append({"type": "fact", **result})
except ValueError:
continue
return {"ok": True, "saved": saved, "count": len(saved)}
+300 -228
View File
@@ -1,228 +1,300 @@
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),
}
import asyncio
import json
import threading
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import MemoryFact, SessionSummary, UserProfile
from app.memory.parse import normalize_text, parse_identity, texts_are_similar
DEFAULT_PROFILE: dict[str, Any] = {
"name": "",
"age": "",
"timezone": "",
"language": "ru",
"notes": "",
}
class MemoryService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
@staticmethod
def _schedule_rag(coro) -> None:
def runner() -> None:
asyncio.run(coro)
threading.Thread(target=runner, daemon=True).start()
def get_profile(self) -> dict[str, Any]:
row = self.db.scalar(select(UserProfile).where(UserProfile.user_id == self.user_id).limit(1))
if not row:
return dict(DEFAULT_PROFILE)
try:
data = json.loads(row.data_json or "{}")
except json.JSONDecodeError:
data = {}
merged = dict(DEFAULT_PROFILE)
merged.update(data)
return merged
def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
row = self.db.scalar(select(UserProfile).where(UserProfile.user_id == self.user_id).limit(1))
if not row:
row = UserProfile(user_id=self.user_id, data_json="{}")
self.db.add(row)
self.db.flush()
current = self.get_profile()
for key, value in updates.items():
if value is None:
current.pop(key, None)
else:
current[key] = value
row.data_json = json.dumps(current, ensure_ascii=False)
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {"ok": True, "profile": current}
def _find_similar_fact(self, text: str) -> MemoryFact | None:
for fact in self.db.scalars(
select(MemoryFact).where(MemoryFact.user_id == self.user_id, MemoryFact.active.is_(True))
):
if texts_are_similar(fact.content, text):
return fact
return None
def _sync_identity_to_profile(self, text: str) -> dict[str, Any] | None:
parsed = parse_identity(text)
if not parsed:
return None
return self.update_profile(parsed)
def remember_fact(
self,
content: str,
*,
category: str = "fact",
source: str = "user",
session_id: int | None = None,
importance: int = 3,
) -> dict[str, Any]:
text = content.strip()
if not text:
raise ValueError("Пустой факт")
profile_sync = self._sync_identity_to_profile(text)
existing = self._find_similar_fact(text)
if existing:
if len(text) > len(existing.content):
existing.content = text[:2000]
existing.category = category or existing.category
existing.importance = max(existing.importance, min(5, max(1, importance)))
existing.updated_at = datetime.now(timezone.utc)
if session_id:
existing.session_id = session_id
self.db.commit()
from app.rag.ingest import index_memory_fact
self._schedule_rag(index_memory_fact(existing))
result = {
"ok": True,
"action": "updated",
"memory_id": existing.id,
"content": existing.content,
"category": existing.category,
}
if profile_sync:
result["profile"] = profile_sync.get("profile")
return result
fact = MemoryFact(
user_id=self.user_id,
category=(category or "fact")[:64],
content=text[:2000],
source=source[:32],
session_id=session_id,
importance=min(5, max(1, importance)),
)
self.db.add(fact)
self.db.commit()
self.db.refresh(fact)
from app.rag.ingest import index_memory_fact
self._schedule_rag(index_memory_fact(fact))
result = {
"ok": True,
"action": "created",
"memory_id": fact.id,
"content": fact.content,
"category": fact.category,
}
if profile_sync:
result["profile"] = profile_sync.get("profile")
return result
def recall_memories(
self,
*,
query: str | None = None,
category: str | None = None,
limit: int = 20,
active_only: bool = True,
) -> list[dict[str, Any]]:
stmt = select(MemoryFact).where(MemoryFact.user_id == self.user_id).order_by(
MemoryFact.importance.desc(),
MemoryFact.updated_at.desc(),
)
if active_only:
stmt = stmt.where(MemoryFact.active.is_(True))
if category:
stmt = stmt.where(MemoryFact.category == category)
facts = self.db.scalars(stmt.limit(100)).all()
if query:
qnorm = normalize_text(query)
facts = [
f
for f in facts
if qnorm in normalize_text(f.content)
or qnorm in normalize_text(f.category)
]
facts = facts[: min(limit, 50)]
return [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
]
def forget_memory(self, memory_id: int) -> dict[str, Any]:
fact = self.db.get(MemoryFact, memory_id)
if not fact or fact.user_id != self.user_id:
raise ValueError(f"Память #{memory_id} не найдена")
fact.active = False
fact.updated_at = datetime.now(timezone.utc)
self.db.commit()
from app.rag.ingest import deactivate_memory_fact
self._schedule_rag(deactivate_memory_fact(memory_id))
return {"ok": True, "memory_id": memory_id, "forgotten": fact.content}
def get_active_facts(self, limit: int = 25) -> list[MemoryFact]:
return list(
self.db.scalars(
select(MemoryFact)
.where(MemoryFact.user_id == self.user_id, MemoryFact.active.is_(True))
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc())
.limit(limit)
).all()
)
def get_session_summary(self, session_id: int) -> SessionSummary | None:
from app.db.models import ChatSession
session = self.db.get(ChatSession, session_id)
if not session or session.user_id != self.user_id:
return None
return self.db.scalar(
select(SessionSummary).where(SessionSummary.session_id == session_id)
)
def update_session_summary(
self,
session_id: int,
summary: str,
*,
message_count: int = 0,
) -> dict[str, Any]:
text = summary.strip()
if not text:
raise ValueError("Пустая сводка")
from app.db.models import ChatSession
session = self.db.get(ChatSession, session_id)
if not session or session.user_id != self.user_id:
raise ValueError("Session not found")
row = self.db.scalar(
select(SessionSummary).where(SessionSummary.session_id == session_id)
)
if not row:
row = SessionSummary(session_id=session_id)
self.db.add(row)
row.summary = text[:4000]
row.message_count = message_count
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
from app.rag.ingest import index_session_summary
self._schedule_rag(index_session_summary(session_id, row.summary))
return {"ok": True, "session_id": session_id, "summary": row.summary}
def snapshot(self, session_id: int | None = None, query: str | None = None) -> dict[str, Any]:
from app.config import get_settings
from app.settings.service import SettingsService
settings = get_settings()
svc = SettingsService(self.db)
rag_on = bool(svc.get_effective("rag_enabled")) and settings.rag_enabled
facts_payload: list[dict[str, Any]]
total_facts = len(self.get_active_facts(limit=500))
if rag_on and (query or "").strip():
async def _load() -> list[dict[str, Any]]:
from app.rag.retriever import retrieve_memory_facts
top_k = int(svc.get_effective("rag_top_k"))
return await retrieve_memory_facts(query or "", user_id=self.user_id, top_k=top_k)
try:
rag_facts = asyncio.run(_load())
except Exception:
rag_facts = []
if rag_facts:
facts_payload = rag_facts
else:
facts = self.get_active_facts(limit=settings.memory_facts_in_context)
facts_payload = [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
]
else:
facts = self.get_active_facts(limit=settings.memory_facts_in_context)
facts_payload = [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
]
summary_row = self.get_session_summary(session_id) if session_id else None
return {
"profile": self.get_profile(),
"facts": facts_payload,
"session_summary": summary_row.summary if summary_row else "",
"total_facts": total_facts,
}
+228
View File
@@ -0,0 +1,228 @@
import json
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import MemoryFact, SessionSummary, UserProfile
from app.memory.parse import normalize_text, parse_identity, texts_are_similar
DEFAULT_PROFILE: dict[str, Any] = {
"name": "",
"age": "",
"timezone": "",
"language": "ru",
"notes": "",
}
class MemoryService:
def __init__(self, db: Session):
self.db = db
def get_profile(self) -> dict[str, Any]:
row = self.db.scalar(select(UserProfile).limit(1))
if not row:
return dict(DEFAULT_PROFILE)
try:
data = json.loads(row.data_json or "{}")
except json.JSONDecodeError:
data = {}
merged = dict(DEFAULT_PROFILE)
merged.update(data)
return merged
def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
row = self.db.scalar(select(UserProfile).limit(1))
if not row:
row = UserProfile(data_json="{}")
self.db.add(row)
self.db.flush()
current = self.get_profile()
for key, value in updates.items():
if value is None:
current.pop(key, None)
else:
current[key] = value
row.data_json = json.dumps(current, ensure_ascii=False)
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {"ok": True, "profile": current}
def _find_similar_fact(self, text: str) -> MemoryFact | None:
for fact in self.db.scalars(
select(MemoryFact).where(MemoryFact.active.is_(True))
):
if texts_are_similar(fact.content, text):
return fact
return None
def _sync_identity_to_profile(self, text: str) -> dict[str, Any] | None:
parsed = parse_identity(text)
if not parsed:
return None
return self.update_profile(parsed)
def remember_fact(
self,
content: str,
*,
category: str = "fact",
source: str = "user",
session_id: int | None = None,
importance: int = 3,
) -> dict[str, Any]:
text = content.strip()
if not text:
raise ValueError("Пустой факт")
profile_sync = self._sync_identity_to_profile(text)
existing = self._find_similar_fact(text)
if existing:
if len(text) > len(existing.content):
existing.content = text[:2000]
existing.category = category or existing.category
existing.importance = max(existing.importance, min(5, max(1, importance)))
existing.updated_at = datetime.now(timezone.utc)
if session_id:
existing.session_id = session_id
self.db.commit()
result = {
"ok": True,
"action": "updated",
"memory_id": existing.id,
"content": existing.content,
"category": existing.category,
}
if profile_sync:
result["profile"] = profile_sync.get("profile")
return result
fact = MemoryFact(
category=(category or "fact")[:64],
content=text[:2000],
source=source[:32],
session_id=session_id,
importance=min(5, max(1, importance)),
)
self.db.add(fact)
self.db.commit()
self.db.refresh(fact)
result = {
"ok": True,
"action": "created",
"memory_id": fact.id,
"content": fact.content,
"category": fact.category,
}
if profile_sync:
result["profile"] = profile_sync.get("profile")
return result
def recall_memories(
self,
*,
query: str | None = None,
category: str | None = None,
limit: int = 20,
active_only: bool = True,
) -> list[dict[str, Any]]:
stmt = select(MemoryFact).order_by(
MemoryFact.importance.desc(),
MemoryFact.updated_at.desc(),
)
if active_only:
stmt = stmt.where(MemoryFact.active.is_(True))
if category:
stmt = stmt.where(MemoryFact.category == category)
facts = self.db.scalars(stmt.limit(100)).all()
if query:
qnorm = normalize_text(query)
facts = [
f
for f in facts
if qnorm in normalize_text(f.content)
or qnorm in normalize_text(f.category)
]
facts = facts[: min(limit, 50)]
return [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
]
def forget_memory(self, memory_id: int) -> dict[str, Any]:
fact = self.db.get(MemoryFact, memory_id)
if not fact:
raise ValueError(f"Память #{memory_id} не найдена")
fact.active = False
fact.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {"ok": True, "memory_id": memory_id, "forgotten": fact.content}
def get_active_facts(self, limit: int = 25) -> list[MemoryFact]:
return list(
self.db.scalars(
select(MemoryFact)
.where(MemoryFact.active.is_(True))
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc())
.limit(limit)
).all()
)
def get_session_summary(self, session_id: int) -> SessionSummary | None:
return self.db.scalar(
select(SessionSummary).where(SessionSummary.session_id == session_id)
)
def update_session_summary(
self,
session_id: int,
summary: str,
*,
message_count: int = 0,
) -> dict[str, Any]:
text = summary.strip()
if not text:
raise ValueError("Пустая сводка")
row = self.get_session_summary(session_id)
if not row:
row = SessionSummary(session_id=session_id)
self.db.add(row)
row.summary = text[:4000]
row.message_count = message_count
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {"ok": True, "session_id": session_id, "summary": row.summary}
def snapshot(self, session_id: int | None = None) -> dict[str, Any]:
facts = self.get_active_facts()
summary_row = self.get_session_summary(session_id) if session_id else None
return {
"profile": self.get_profile(),
"facts": [
{
"id": f.id,
"category": f.category,
"content": f.content,
"importance": f.importance,
"source": f.source,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in facts
],
"session_summary": summary_row.summary if summary_row else "",
"total_facts": len(facts),
}
+92 -91
View File
@@ -1,91 +1,92 @@
import logging
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
from app.chat.notices import format_phase_completed_notice
from app.db.models import PomodoroSession
from app.llm.client import LLMClient
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
from app.pomodoro.service import PomodoroService
logger = logging.getLogger(__name__)
PHASE_LABELS = {
PHASE_WORK: "работа",
PHASE_SHORT_BREAK: "короткий перерыв",
PHASE_LONG_BREAK: "длинный перерыв",
}
class PomodoroCompletionHandler:
def __init__(self, db: Session):
self.db = db
self.pomodoro = PomodoroService(db)
self.cycle = CycleManager(db)
self.llm = LLMClient()
self.character = CharacterService()
async def _generate_llm_comment(
self,
session: PomodoroSession,
next_phase: str | None,
) -> str:
cycle = self.cycle.to_dict()
phase_label = PHASE_LABELS.get(session.phase, session.phase)
next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен"
work_done = cycle["completed_work_sessions"]
if session.phase == PHASE_WORK:
work_done += 1
system = self.character.get_system_prompt()
user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась.
Задача: {session.task_note or 'без описания'}
Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ.
Следующая фаза: {next_label}.
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown и без эмодзи."""
result = await self.llm.complete(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
],
temperature=0.8,
visible_reply=True,
)
return (result.get("content") or "").strip() or "Фаза завершена. Хорошая работа."
def _resolve_next_phase(self, session: PomodoroSession) -> str | None:
phase = session.phase
cycle = self.cycle.get()
if phase == PHASE_WORK:
if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break:
return PHASE_LONG_BREAK
return PHASE_SHORT_BREAK
if phase == PHASE_SHORT_BREAK:
return PHASE_WORK
if phase == PHASE_LONG_BREAK:
return None
return None
async def process(self, session: PomodoroSession) -> None:
if session.completion_notified:
return
next_phase = self._resolve_next_phase(session)
notice = format_phase_completed_notice(session, next_phase)
post_notice_to_latest_chat(notice)
try:
comment = await self._generate_llm_comment(session, next_phase)
if comment:
post_character_comment_to_latest_chat(comment)
except Exception:
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase)
self.cycle.bump_notify_seq()
self.pomodoro.mark_notified(session)
self.pomodoro.advance_after_completion(session)
logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase)
import logging
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
from app.chat.notices import format_phase_completed_notice
from app.db.models import PomodoroSession
from app.llm.client import LLMClient
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
from app.pomodoro.service import PomodoroService
logger = logging.getLogger(__name__)
PHASE_LABELS = {
PHASE_WORK: "работа",
PHASE_SHORT_BREAK: "короткий перерыв",
PHASE_LONG_BREAK: "длинный перерыв",
}
class PomodoroCompletionHandler:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self.pomodoro = PomodoroService(db, user_id)
self.cycle = CycleManager(db, user_id)
self.llm = LLMClient()
self.character = CharacterService(db, user_id)
async def _generate_llm_comment(
self,
session: PomodoroSession,
next_phase: str | None,
) -> str:
cycle = self.cycle.to_dict()
phase_label = PHASE_LABELS.get(session.phase, session.phase)
next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен"
work_done = cycle["completed_work_sessions"]
if session.phase == PHASE_WORK:
work_done += 1
system = self.character.get_system_prompt()
user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась.
Задача: {session.task_note or 'без описания'}
Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ.
Следующая фаза: {next_label}.
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown и без эмодзи."""
result = await self.llm.complete(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
],
temperature=0.8,
visible_reply=True,
)
return (result.get("content") or "").strip() or "Фаза завершена. Хорошая работа."
def _resolve_next_phase(self, session: PomodoroSession) -> str | None:
phase = session.phase
cycle = self.cycle.get()
if phase == PHASE_WORK:
if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break:
return PHASE_LONG_BREAK
return PHASE_SHORT_BREAK
if phase == PHASE_SHORT_BREAK:
return PHASE_WORK
if phase == PHASE_LONG_BREAK:
return None
return None
async def process(self, session: PomodoroSession) -> None:
if session.completion_notified:
return
next_phase = self._resolve_next_phase(session)
notice = format_phase_completed_notice(session, next_phase)
post_notice_to_latest_chat(notice, self.user_id)
try:
comment = await self._generate_llm_comment(session, next_phase)
if comment:
post_character_comment_to_latest_chat(comment, self.user_id)
except Exception:
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase)
self.cycle.bump_notify_seq()
self.pomodoro.mark_notified(session)
self.pomodoro.advance_after_completion(session)
logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase)
+90 -89
View File
@@ -1,89 +1,90 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import PomodoroCycle
PHASE_WORK = "work"
PHASE_SHORT_BREAK = "short_break"
PHASE_LONG_BREAK = "long_break"
class CycleManager:
def __init__(self, db: Session):
self.db = db
def get(self) -> PomodoroCycle:
cycle = self.db.scalar(select(PomodoroCycle).limit(1))
if not cycle:
cycle = PomodoroCycle()
self.db.add(cycle)
self.db.commit()
self.db.refresh(cycle)
return cycle
def to_dict(self, cycle: PomodoroCycle | None = None) -> dict:
c = cycle or self.get()
return {
"completed_work_sessions": c.completed_work_sessions,
"sessions_until_long_break": c.sessions_until_long_break,
"task_note": c.task_note,
"work_duration_min": c.work_duration_min,
"short_break_min": c.short_break_min,
"long_break_min": c.long_break_min,
"auto_advance": c.auto_advance,
"chat_notify_seq": c.chat_notify_seq,
}
def reset(self, clear_task: bool = False) -> dict:
cycle = self.get()
cycle.completed_work_sessions = 0
if clear_task:
cycle.task_note = ""
self.db.commit()
self.db.refresh(cycle)
return self.to_dict(cycle)
def bump_notify_seq(self) -> int:
cycle = self.get()
cycle.chat_notify_seq += 1
self.db.commit()
self.db.refresh(cycle)
return cycle.chat_notify_seq
def on_work_completed(self) -> str:
"""Returns next phase: short_break or long_break."""
cycle = self.get()
cycle.completed_work_sessions += 1
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
next_phase = PHASE_LONG_BREAK
else:
next_phase = PHASE_SHORT_BREAK
self.db.commit()
return next_phase
def on_long_break_completed(self) -> None:
cycle = self.get()
cycle.completed_work_sessions = 0
self.db.commit()
def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int:
c = cycle or self.get()
if phase == PHASE_WORK:
return c.work_duration_min
if phase == PHASE_SHORT_BREAK:
return c.short_break_min
if phase == PHASE_LONG_BREAK:
return c.long_break_min
return c.work_duration_min
def next_phase_after(self, completed_phase: str) -> str | None:
if completed_phase == PHASE_WORK:
cycle = self.get()
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
return PHASE_LONG_BREAK
return PHASE_SHORT_BREAK
if completed_phase == PHASE_SHORT_BREAK:
return PHASE_WORK
if completed_phase == PHASE_LONG_BREAK:
return None
return None
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import PomodoroCycle
PHASE_WORK = "work"
PHASE_SHORT_BREAK = "short_break"
PHASE_LONG_BREAK = "long_break"
class CycleManager:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
def get(self) -> PomodoroCycle:
cycle = self.db.scalar(select(PomodoroCycle).where(PomodoroCycle.user_id == self.user_id).limit(1))
if not cycle:
cycle = PomodoroCycle(user_id=self.user_id)
self.db.add(cycle)
self.db.commit()
self.db.refresh(cycle)
return cycle
def to_dict(self, cycle: PomodoroCycle | None = None) -> dict:
c = cycle or self.get()
return {
"completed_work_sessions": c.completed_work_sessions,
"sessions_until_long_break": c.sessions_until_long_break,
"task_note": c.task_note,
"work_duration_min": c.work_duration_min,
"short_break_min": c.short_break_min,
"long_break_min": c.long_break_min,
"auto_advance": c.auto_advance,
"chat_notify_seq": c.chat_notify_seq,
}
def reset(self, clear_task: bool = False) -> dict:
cycle = self.get()
cycle.completed_work_sessions = 0
if clear_task:
cycle.task_note = ""
self.db.commit()
self.db.refresh(cycle)
return self.to_dict(cycle)
def bump_notify_seq(self) -> int:
cycle = self.get()
cycle.chat_notify_seq += 1
self.db.commit()
self.db.refresh(cycle)
return cycle.chat_notify_seq
def on_work_completed(self) -> str:
"""Returns next phase: short_break or long_break."""
cycle = self.get()
cycle.completed_work_sessions += 1
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
next_phase = PHASE_LONG_BREAK
else:
next_phase = PHASE_SHORT_BREAK
self.db.commit()
return next_phase
def on_long_break_completed(self) -> None:
cycle = self.get()
cycle.completed_work_sessions = 0
self.db.commit()
def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int:
c = cycle or self.get()
if phase == PHASE_WORK:
return c.work_duration_min
if phase == PHASE_SHORT_BREAK:
return c.short_break_min
if phase == PHASE_LONG_BREAK:
return c.long_break_min
return c.work_duration_min
def next_phase_after(self, completed_phase: str) -> str | None:
if completed_phase == PHASE_WORK:
cycle = self.get()
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
return PHASE_LONG_BREAK
return PHASE_SHORT_BREAK
if completed_phase == PHASE_SHORT_BREAK:
return PHASE_WORK
if completed_phase == PHASE_LONG_BREAK:
return None
return None
+296 -287
View File
@@ -1,287 +1,296 @@
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import PomodoroSession
from app.pomodoro.cycle import (
PHASE_LONG_BREAK,
PHASE_SHORT_BREAK,
PHASE_WORK,
CycleManager,
)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class PomodoroService:
def __init__(self, db: Session):
self.db = db
self.cycle = CycleManager(db)
def _get_active(self) -> PomodoroSession | None:
stmt = (
select(PomodoroSession)
.where(PomodoroSession.status.in_(("running", "paused")))
.order_by(PomodoroSession.id.desc())
.limit(1)
)
return self.db.scalar(stmt)
def _elapsed(self, session: PomodoroSession) -> int:
elapsed = session.elapsed_seconds
if session.status == "running" and session.started_at:
started = session.started_at
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
delta = _utcnow() - started
elapsed += int(delta.total_seconds())
return elapsed
def _remaining(self, session: PomodoroSession) -> int:
total = session.duration_min * 60
return max(0, total - self._elapsed(session))
def _try_auto_complete(self, session: PomodoroSession) -> bool:
if session.status != "running":
return False
if self._remaining(session) > 0:
return False
self._finalize_session(session, auto=True)
return True
def _finalize_session(
self,
session: PomodoroSession,
*,
auto: bool,
result: str = "",
completed: bool | None = None,
cancelled: bool = False,
) -> None:
session.elapsed_seconds = self._elapsed(session)
session.started_at = None
session.finished_at = _utcnow()
session.completion_notified = False
session.result = result or None
if cancelled:
session.status = "cancelled"
session.completed = False
elif completed is not None:
session.status = "completed"
session.completed = completed
else:
session.status = "completed"
session.completed = True
self.db.commit()
self.db.refresh(session)
def _start_phase(
self,
phase: str,
*,
duration_min: int | None = None,
task_note: str | None = None,
) -> PomodoroSession:
active = self._get_active()
if active:
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
cycle = self.cycle.get()
if task_note is not None:
cycle.task_note = task_note
elif phase == PHASE_WORK and not cycle.task_note:
cycle.task_note = ""
duration = duration_min or self.cycle.duration_for_phase(phase, cycle)
note = task_note if task_note is not None else cycle.task_note
session = PomodoroSession(
status="running",
phase=phase,
duration_min=duration,
task_note=note,
started_at=_utcnow(),
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return session
def _to_status_dict(self, session: PomodoroSession | None) -> dict:
cycle_dict = self.cycle.to_dict()
if not session:
return {
"status": "idle",
"phase": PHASE_WORK,
"duration_min": cycle_dict["work_duration_min"],
"task_note": cycle_dict["task_note"],
"elapsed_seconds": 0,
"remaining_seconds": 0,
"session_id": None,
"cycle": cycle_dict,
}
elapsed = self._elapsed(session)
total = session.duration_min * 60
remaining = max(0, total - elapsed)
return {
"status": session.status,
"phase": session.phase,
"duration_min": session.duration_min,
"task_note": session.task_note,
"elapsed_seconds": elapsed,
"remaining_seconds": remaining,
"session_id": session.id,
"started_at": session.started_at.isoformat() if session.started_at else None,
"finished_at": session.finished_at.isoformat() if session.finished_at else None,
"cycle": cycle_dict,
}
def get_status(self) -> dict:
active = self._get_active()
if active:
self._try_auto_complete(active)
active = self._get_active()
return self._to_status_dict(active)
def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict:
session = self._start_phase(
PHASE_WORK,
duration_min=duration_min,
task_note=task_note,
)
return self._to_status_dict(session)
def start_short_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min)
return self._to_status_dict(session)
def start_long_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min)
return self._to_status_dict(session)
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
return self.start_work(duration_min=duration_min, task_note=task_note)
def pause(self) -> dict:
session = self._get_active()
if not session or session.status != "running":
raise ValueError("Нет активного запущенного таймера.")
session.elapsed_seconds = self._elapsed(session)
session.status = "paused"
session.paused_at = _utcnow()
session.started_at = None
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def resume(self) -> dict:
session = self._get_active()
if not session or session.status != "paused":
raise ValueError("Нет таймера на паузе.")
session.status = "running"
session.started_at = _utcnow()
session.paused_at = None
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def stop(self, result: str = "", completed: bool = False) -> dict:
session = self._get_active()
if not session:
raise ValueError("Нет активного таймера.")
if completed:
self._finalize_session(session, auto=False, result=result, completed=True)
else:
self._finalize_session(session, auto=False, result=result, cancelled=True)
session.completion_notified = True
self.db.commit()
return self._to_status_dict(None)
def reset_cycle(self, clear_task: bool = False) -> dict:
active = self._get_active()
if active:
self._finalize_session(active, auto=False, cancelled=True)
active.completion_notified = True
self.db.commit()
cycle = self.cycle.reset(clear_task=clear_task)
status = self._to_status_dict(None)
status["cycle"] = cycle
return status
def skip_phase(self) -> dict:
session = self._get_active()
if not session:
raise ValueError("Нет активного таймера.")
self._finalize_session(session, auto=True)
return self._to_status_dict(None)
def get_pending_completions(self) -> list[PomodoroSession]:
stmt = (
select(PomodoroSession)
.where(
PomodoroSession.status == "completed",
PomodoroSession.completed.is_(True),
PomodoroSession.completion_notified.is_(False),
)
.order_by(PomodoroSession.id.asc())
)
return list(self.db.scalars(stmt))
def mark_notified(self, session: PomodoroSession) -> None:
session.completion_notified = True
self.db.commit()
def advance_after_completion(self, session: PomodoroSession) -> dict | None:
"""Update cycle counters and auto-start next phase. Returns new status or None."""
phase = session.phase
cycle = self.cycle.get()
if phase == PHASE_WORK:
next_phase = self.cycle.on_work_completed()
elif phase == PHASE_SHORT_BREAK:
next_phase = PHASE_WORK
elif phase == PHASE_LONG_BREAK:
self.cycle.on_long_break_completed()
next_phase = None
else:
next_phase = None
if not cycle.auto_advance or next_phase is None:
return None
new_session = self._start_phase(next_phase)
return self._to_status_dict(new_session)
def history(self, limit: int = 20) -> list[dict]:
stmt = (
select(PomodoroSession)
.where(PomodoroSession.status.in_(("completed", "cancelled")))
.order_by(PomodoroSession.finished_at.desc())
.limit(limit)
)
sessions = self.db.scalars(stmt).all()
return [
{
"id": s.id,
"status": s.status,
"phase": s.phase,
"duration_min": s.duration_min,
"task_note": s.task_note,
"result": s.result,
"completed": s.completed,
"elapsed_seconds": s.elapsed_seconds,
"finished_at": s.finished_at.isoformat() if s.finished_at else None,
}
for s in sessions
]
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import PomodoroSession
from app.pomodoro.cycle import (
PHASE_LONG_BREAK,
PHASE_SHORT_BREAK,
PHASE_WORK,
CycleManager,
)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class PomodoroService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self.cycle = CycleManager(db, user_id)
def _get_active(self) -> PomodoroSession | None:
stmt = (
select(PomodoroSession)
.where(
PomodoroSession.user_id == self.user_id,
PomodoroSession.status.in_(("running", "paused")),
)
.order_by(PomodoroSession.id.desc())
.limit(1)
)
return self.db.scalar(stmt)
def _elapsed(self, session: PomodoroSession) -> int:
elapsed = session.elapsed_seconds
if session.status == "running" and session.started_at:
started = session.started_at
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
delta = _utcnow() - started
elapsed += int(delta.total_seconds())
return elapsed
def _remaining(self, session: PomodoroSession) -> int:
total = session.duration_min * 60
return max(0, total - self._elapsed(session))
def _try_auto_complete(self, session: PomodoroSession) -> bool:
if session.status != "running":
return False
if self._remaining(session) > 0:
return False
self._finalize_session(session, auto=True)
return True
def _finalize_session(
self,
session: PomodoroSession,
*,
auto: bool,
result: str = "",
completed: bool | None = None,
cancelled: bool = False,
) -> None:
session.elapsed_seconds = self._elapsed(session)
session.started_at = None
session.finished_at = _utcnow()
session.completion_notified = False
session.result = result or None
if cancelled:
session.status = "cancelled"
session.completed = False
elif completed is not None:
session.status = "completed"
session.completed = completed
else:
session.status = "completed"
session.completed = True
self.db.commit()
self.db.refresh(session)
def _start_phase(
self,
phase: str,
*,
duration_min: int | None = None,
task_note: str | None = None,
) -> PomodoroSession:
active = self._get_active()
if active:
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
cycle = self.cycle.get()
if task_note is not None:
cycle.task_note = task_note
elif phase == PHASE_WORK and not cycle.task_note:
cycle.task_note = ""
duration = duration_min or self.cycle.duration_for_phase(phase, cycle)
note = task_note if task_note is not None else cycle.task_note
session = PomodoroSession(
user_id=self.user_id,
status="running",
phase=phase,
duration_min=duration,
task_note=note,
started_at=_utcnow(),
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return session
def _to_status_dict(self, session: PomodoroSession | None) -> dict:
cycle_dict = self.cycle.to_dict()
if not session:
return {
"status": "idle",
"phase": PHASE_WORK,
"duration_min": cycle_dict["work_duration_min"],
"task_note": cycle_dict["task_note"],
"elapsed_seconds": 0,
"remaining_seconds": 0,
"session_id": None,
"cycle": cycle_dict,
}
elapsed = self._elapsed(session)
total = session.duration_min * 60
remaining = max(0, total - elapsed)
return {
"status": session.status,
"phase": session.phase,
"duration_min": session.duration_min,
"task_note": session.task_note,
"elapsed_seconds": elapsed,
"remaining_seconds": remaining,
"session_id": session.id,
"started_at": session.started_at.isoformat() if session.started_at else None,
"finished_at": session.finished_at.isoformat() if session.finished_at else None,
"cycle": cycle_dict,
}
def get_status(self) -> dict:
active = self._get_active()
if active:
self._try_auto_complete(active)
active = self._get_active()
return self._to_status_dict(active)
def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict:
session = self._start_phase(
PHASE_WORK,
duration_min=duration_min,
task_note=task_note,
)
return self._to_status_dict(session)
def start_short_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min)
return self._to_status_dict(session)
def start_long_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min)
return self._to_status_dict(session)
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
return self.start_work(duration_min=duration_min, task_note=task_note)
def pause(self) -> dict:
session = self._get_active()
if not session or session.status != "running":
raise ValueError("Нет активного запущенного таймера.")
session.elapsed_seconds = self._elapsed(session)
session.status = "paused"
session.paused_at = _utcnow()
session.started_at = None
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def resume(self) -> dict:
session = self._get_active()
if not session or session.status != "paused":
raise ValueError("Нет таймера на паузе.")
session.status = "running"
session.started_at = _utcnow()
session.paused_at = None
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def stop(self, result: str = "", completed: bool = False) -> dict:
session = self._get_active()
if not session:
raise ValueError("Нет активного таймера.")
if completed:
self._finalize_session(session, auto=False, result=result, completed=True)
else:
self._finalize_session(session, auto=False, result=result, cancelled=True)
session.completion_notified = True
self.db.commit()
return self._to_status_dict(None)
def reset_cycle(self, clear_task: bool = False) -> dict:
active = self._get_active()
if active:
self._finalize_session(active, auto=False, cancelled=True)
active.completion_notified = True
self.db.commit()
cycle = self.cycle.reset(clear_task=clear_task)
status = self._to_status_dict(None)
status["cycle"] = cycle
return status
def skip_phase(self) -> dict:
session = self._get_active()
if not session:
raise ValueError("Нет активного таймера.")
self._finalize_session(session, auto=True)
return self._to_status_dict(None)
def get_pending_completions(self) -> list[PomodoroSession]:
stmt = (
select(PomodoroSession)
.where(
PomodoroSession.user_id == self.user_id,
PomodoroSession.status == "completed",
PomodoroSession.completed.is_(True),
PomodoroSession.completion_notified.is_(False),
)
.order_by(PomodoroSession.id.asc())
)
return list(self.db.scalars(stmt))
def mark_notified(self, session: PomodoroSession) -> None:
session.completion_notified = True
self.db.commit()
def advance_after_completion(self, session: PomodoroSession) -> dict | None:
"""Update cycle counters and auto-start next phase. Returns new status or None."""
phase = session.phase
cycle = self.cycle.get()
if phase == PHASE_WORK:
next_phase = self.cycle.on_work_completed()
elif phase == PHASE_SHORT_BREAK:
next_phase = PHASE_WORK
elif phase == PHASE_LONG_BREAK:
self.cycle.on_long_break_completed()
next_phase = None
else:
next_phase = None
if not cycle.auto_advance or next_phase is None:
return None
new_session = self._start_phase(next_phase)
return self._to_status_dict(new_session)
def history(self, limit: int = 20) -> list[dict]:
stmt = (
select(PomodoroSession)
.where(
PomodoroSession.user_id == self.user_id,
PomodoroSession.status.in_(("completed", "cancelled")),
)
.order_by(PomodoroSession.finished_at.desc())
.limit(limit)
)
sessions = self.db.scalars(stmt).all()
return [
{
"id": s.id,
"status": s.status,
"phase": s.phase,
"duration_min": s.duration_min,
"task_note": s.task_note,
"result": s.result,
"completed": s.completed,
"elapsed_seconds": s.elapsed_seconds,
"finished_at": s.finished_at.isoformat() if s.finished_at else None,
}
for s in sessions
]
+41 -38
View File
@@ -1,38 +1,41 @@
import asyncio
import logging
from app.db.base import SessionLocal
from app.pomodoro.completion import PomodoroCompletionHandler
from app.pomodoro.service import PomodoroService
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 2
async def pomodoro_watcher_loop() -> None:
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Pomodoro watcher error")
async def _tick() -> None:
db = SessionLocal()
try:
service = PomodoroService(db)
service.get_status()
pending = service.get_pending_completions()
if not pending:
return
handler = PomodoroCompletionHandler(db)
for session in pending:
await handler.process(session)
finally:
db.close()
import asyncio
import logging
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import User
from app.pomodoro.completion import PomodoroCompletionHandler
from app.pomodoro.service import PomodoroService
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 2
async def pomodoro_watcher_loop() -> None:
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Pomodoro watcher error")
async def _tick() -> None:
db = SessionLocal()
try:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
for user in users:
service = PomodoroService(db, user.id)
service.get_status()
pending = service.get_pending_completions()
if not pending:
continue
handler = PomodoroCompletionHandler(db, user.id)
for session in pending:
await handler.process(session)
finally:
db.close()
+155 -153
View File
@@ -1,153 +1,155 @@
import time
from typing import Any
from sqlalchemy.orm import Session
from app.config import get_settings
from app.integrations.taiga import TaigaClient
from app.projects.service import ProjectService
MAX_PROJECTS_IN_CONTEXT = 20
MAX_OPEN_PER_PROJECT = 8
PROJECTS_CACHE_SEC = 120
_cache: dict[str, Any] = {"data": None, "expires_at": 0.0}
def invalidate_projects_snapshot_cache() -> None:
_cache["data"] = None
_cache["expires_at"] = 0.0
def get_projects_snapshot(db: Session, *, force: bool = False) -> dict[str, Any]:
now = time.time()
if not force and _cache["data"] is not None and now < _cache["expires_at"]:
return _cache["data"]
snapshot = _fetch_projects_snapshot(db)
_cache["data"] = snapshot
_cache["expires_at"] = now + PROJECTS_CACHE_SEC
return snapshot
def _fetch_projects_snapshot(db: Session) -> dict[str, Any]:
settings = get_settings()
service = ProjectService(db)
if not settings.taiga_configured:
return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
projects = service.list_projects()
if not projects:
try:
projects = service.sync_taiga_projects()
except Exception as exc:
return {
"configured": True,
"projects": [],
"open_items": [],
"taiga_open": [],
"error": str(exc),
}
open_items = service.list_work_items(limit=15, status="open")
taiga_open: list[dict[str, Any]] = []
fetch_error: str | None = None
try:
client = TaigaClient()
for proj in projects[:MAX_PROJECTS_IN_CONTEXT]:
stories = client.list_open_userstories(
proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT
)
tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT)
taiga_open.append(
{
"slug": proj["slug"],
"name": proj["name"],
"stories": [
{
"ref": s.get("ref"),
"subject": s.get("subject", "")[:120],
}
for s in stories
],
"tasks": [
{
"ref": t.get("ref"),
"subject": t.get("subject", "")[:120],
}
for t in tasks
],
}
)
except Exception as exc:
fetch_error = str(exc)
return {
"configured": True,
"projects": projects,
"open_items": open_items,
"taiga_open": taiga_open,
"error": fetch_error,
}
def format_projects_context(snapshot: dict[str, Any]) -> str:
if not snapshot.get("configured"):
return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)."
lines = ["[Проекты и задачи — снимок на начало ответа]"]
if snapshot.get("error"):
lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}")
projects = snapshot.get("projects") or []
if not projects:
lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.")
else:
lines.append(f"Проекты Taiga ({len(projects)}):")
for p in projects[:MAX_PROJECTS_IN_CONTEXT]:
gitea = (
f"{p.get('gitea_owner')}/{p.get('gitea_repo')}"
if p.get("gitea_configured")
else "Gitea не привязан"
)
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}")
taiga_open = snapshot.get("taiga_open") or []
if taiga_open:
lines.append("")
lines.append("Открытые задачи в Taiga (live):")
for block in taiga_open:
stories = block.get("stories") or []
tasks = block.get("tasks") or []
if not stories and not tasks:
lines.append(f" `{block.get('slug')}`: нет открытых")
continue
lines.append(f" `{block.get('slug')}`:")
for story in stories:
lines.append(f" story #{story.get('ref')} {story.get('subject')}")
for task in tasks:
lines.append(f" task #{task.get('ref')} {task.get('subject')}")
open_items = snapshot.get("open_items") or []
if open_items:
lines.append("")
lines.append("Work items созданные ассистентом (локальная БД):")
for item in open_items[:10]:
gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else ""
lines.append(
f"- #{item.get('taiga_ref')} {item.get('title')} "
f"({item.get('taiga_slug')}{gitea_part})"
)
lines.append("")
lines.append(
"Правила: "
"«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. "
"list_work_items — только созданные через ассистента. "
"Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. "
"create_work_item — для новых фич/багов из вольного текста."
)
return "\n".join(lines)
import time
from typing import Any
from sqlalchemy.orm import Session
from app.config import get_settings
from app.integrations.taiga import TaigaClient
from app.projects.service import ProjectService
MAX_PROJECTS_IN_CONTEXT = 20
MAX_OPEN_PER_PROJECT = 8
PROJECTS_CACHE_SEC = 120
_cache: dict[int, dict[str, Any]] = {}
def invalidate_projects_snapshot_cache(user_id: int | None = None) -> None:
if user_id is None:
_cache.clear()
else:
_cache.pop(user_id, None)
def get_projects_snapshot(db: Session, user_id: int, *, force: bool = False) -> dict[str, Any]:
now = time.time()
entry = _cache.get(user_id)
if not force and entry and now < entry.get("expires_at", 0):
return entry["data"]
snapshot = _fetch_projects_snapshot(db, user_id)
_cache[user_id] = {"data": snapshot, "expires_at": now + PROJECTS_CACHE_SEC}
return snapshot
def _fetch_projects_snapshot(db: Session, user_id: int) -> dict[str, Any]:
settings = get_settings()
service = ProjectService(db, user_id)
if not settings.taiga_configured:
return {"configured": False, "projects": [], "open_items": [], "taiga_open": []}
projects = service.list_projects()
if not projects:
try:
projects = service.sync_taiga_projects()
except Exception as exc:
return {
"configured": True,
"projects": [],
"open_items": [],
"taiga_open": [],
"error": str(exc),
}
open_items = service.list_work_items(limit=15, status="open")
taiga_open: list[dict[str, Any]] = []
fetch_error: str | None = None
try:
client = TaigaClient()
for proj in projects[:MAX_PROJECTS_IN_CONTEXT]:
stories = client.list_open_userstories(
proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT
)
tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT)
taiga_open.append(
{
"slug": proj["slug"],
"name": proj["name"],
"stories": [
{
"ref": s.get("ref"),
"subject": s.get("subject", "")[:120],
}
for s in stories
],
"tasks": [
{
"ref": t.get("ref"),
"subject": t.get("subject", "")[:120],
}
for t in tasks
],
}
)
except Exception as exc:
fetch_error = str(exc)
return {
"configured": True,
"projects": projects,
"open_items": open_items,
"taiga_open": taiga_open,
"error": fetch_error,
}
def format_projects_context(snapshot: dict[str, Any]) -> str:
if not snapshot.get("configured"):
return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)."
lines = ["[Проекты и задачи — снимок на начало ответа]"]
if snapshot.get("error"):
lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}")
projects = snapshot.get("projects") or []
if not projects:
lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.")
else:
lines.append(f"Проекты Taiga ({len(projects)}):")
for p in projects[:MAX_PROJECTS_IN_CONTEXT]:
gitea = (
f"{p.get('gitea_owner')}/{p.get('gitea_repo')}"
if p.get("gitea_configured")
else "Gitea не привязан"
)
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}")
taiga_open = snapshot.get("taiga_open") or []
if taiga_open:
lines.append("")
lines.append("Открытые задачи в Taiga (live):")
for block in taiga_open:
stories = block.get("stories") or []
tasks = block.get("tasks") or []
if not stories and not tasks:
lines.append(f" `{block.get('slug')}`: нет открытых")
continue
lines.append(f" `{block.get('slug')}`:")
for story in stories:
lines.append(f" story #{story.get('ref')} {story.get('subject')}")
for task in tasks:
lines.append(f" task #{task.get('ref')} {task.get('subject')}")
open_items = snapshot.get("open_items") or []
if open_items:
lines.append("")
lines.append("Work items созданные ассистентом (локальная БД):")
for item in open_items[:10]:
gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else ""
lines.append(
f"- #{item.get('taiga_ref')} {item.get('title')} "
f"({item.get('taiga_slug')}{gitea_part})"
)
lines.append("")
lines.append(
"Правила: "
"«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. "
"list_work_itemsтолько созданные через ассистента. "
"Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. "
"create_work_item — для новых фич/багов из вольного текста."
)
return "\n".join(lines)
+476 -466
View File
@@ -1,466 +1,476 @@
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.models import ProjectBinding, TaigaProject, WorkItem
from app.integrations.gitea import GiteaClient
from app.integrations.taiga import TaigaClient
from app.projects.commit_parser import parse_commit_message
from app.projects.structuring import (
format_gitea_body,
format_story_description,
slugify_branch,
structure_work_item,
)
class ProjectService:
def __init__(self, db: Session):
self.db = db
self.settings = get_settings()
def sync_taiga_projects(self) -> list[dict[str, Any]]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD")
client = TaigaClient()
remote = client.list_projects()
now = datetime.now(timezone.utc)
for item in remote:
slug = item.get("slug") or ""
if not slug:
continue
existing = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == slug)
)
if existing:
existing.name = item.get("name", slug)
existing.taiga_id = item["id"]
existing.synced_at = now
else:
self.db.add(
TaigaProject(
taiga_id=item["id"],
name=item.get("name", slug),
slug=slug,
synced_at=now,
)
)
self.db.commit()
return self.list_projects()
def list_projects(self) -> list[dict[str, Any]]:
stmt = (
select(TaigaProject, ProjectBinding)
.outerjoin(ProjectBinding, ProjectBinding.taiga_slug == TaigaProject.slug)
.order_by(TaigaProject.name)
)
rows = self.db.execute(stmt).all()
result = []
for taiga_proj, binding in rows:
result.append(
{
"taiga_id": taiga_proj.taiga_id,
"name": taiga_proj.name,
"slug": taiga_proj.slug,
"gitea_owner": binding.gitea_owner if binding else "",
"gitea_repo": binding.gitea_repo if binding else "",
"default_branch": binding.default_branch if binding else "main",
"gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo),
}
)
return result
def bind_gitea(
self,
taiga_slug: str,
gitea_owner: str,
gitea_repo: str,
default_branch: str = "main",
) -> dict[str, Any]:
if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)):
raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.")
binding = self.db.scalar(
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_slug)
)
if binding:
binding.gitea_owner = gitea_owner
binding.gitea_repo = gitea_repo
binding.default_branch = default_branch
else:
binding = ProjectBinding(
taiga_slug=taiga_slug,
gitea_owner=gitea_owner,
gitea_repo=gitea_repo,
default_branch=default_branch,
)
self.db.add(binding)
self.db.commit()
for proj in self.list_projects():
if proj["slug"] == taiga_slug:
return proj
raise ValueError("Binding failed")
def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]:
projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all()
if not projects:
raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.")
taiga_proj: TaigaProject | None = None
if slug:
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == slug)
)
if not taiga_proj:
raise ValueError(f"Проект '{slug}' не найден")
else:
taiga_proj = projects[0]
binding = self.db.scalar(
select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_proj.slug)
)
return taiga_proj, binding
async def create_work_item(
self, raw_text: str, project_slug: str | None = None
) -> dict[str, Any]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена")
project_list = self.list_projects()
if not project_list:
self.sync_taiga_projects()
project_list = self.list_projects()
structured = await structure_work_item(raw_text, project_list)
slug = project_slug or structured.get("project_slug")
taiga_proj, binding = self._resolve_project(slug)
if binding and not (binding.gitea_owner and binding.gitea_repo):
binding = None
taiga = TaigaClient()
title = (structured.get("title") or raw_text).strip()[:500]
description = format_story_description(structured, raw_text)
tags = structured.get("tags") or []
issue_type = structured.get("issue_type", "feature")
if issue_type == "bug" and "bug" not in [t.lower() for t in tags]:
tags.append("bug")
story = taiga.create_userstory(
taiga_proj.taiga_id,
title,
description,
tags=tags,
)
subtasks = []
for child in structured.get("children") or []:
if isinstance(child, dict):
subtasks.append(
taiga.create_task(
taiga_proj.taiga_id,
story["id"],
child.get("title", "Подзадача"),
child.get("description", ""),
)
)
branch = f"feature/{story['ref']}-{slugify_branch(title)}"
gitea_issue_number = None
gitea_url = ""
if binding and self.settings.gitea_configured:
gitea = GiteaClient()
gitea_body = format_gitea_body(
structured,
raw_text,
story["ref"],
taiga.story_url(taiga_proj.taiga_id, story["ref"]),
branch,
)
if issue_type:
gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}"
issue = gitea.create_issue(
binding.gitea_owner,
binding.gitea_repo,
title,
gitea_body,
)
gitea_issue_number = issue["number"]
gitea_url = gitea.issue_url(
binding.gitea_owner, binding.gitea_repo, gitea_issue_number
)
work_item = WorkItem(
taiga_slug=taiga_proj.slug,
taiga_project_id=taiga_proj.taiga_id,
taiga_story_id=story["id"],
taiga_story_ref=story["ref"],
gitea_owner=binding.gitea_owner if binding else "",
gitea_repo=binding.gitea_repo if binding else "",
gitea_issue_number=gitea_issue_number,
suggested_branch=branch,
raw_text=raw_text,
title=title,
status="open",
)
self.db.add(work_item)
self.db.commit()
self.db.refresh(work_item)
return {
"ok": True,
"work_item_id": work_item.id,
"taiga": {
"ref": story["ref"],
"id": story["id"],
"subject": story["subject"],
"url": taiga.story_url(taiga_proj.taiga_id, story["ref"]),
},
"gitea": {
"number": gitea_issue_number,
"url": gitea_url,
},
"branch": branch,
"issue_type": issue_type,
"subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks],
"questions": structured.get("questions") or [],
"project_slug": taiga_proj.slug,
}
def process_push(
self, owner: str, repo: str, commits: list[dict[str, Any]]
) -> list[dict[str, Any]]:
if not self.settings.taiga_configured:
return []
taiga = TaigaClient()
gitea = GiteaClient() if self.settings.gitea_configured else None
results: list[dict[str, Any]] = []
for commit in commits:
message = commit.get("message", "")
parsed = parse_commit_message(message)
sha = commit.get("id", "")[:8]
gitea_refs = set(parsed["gitea"])
taiga_story_refs = set(parsed["taiga_story"])
taiga_task_refs = set(parsed["taiga_task"])
linked_items = self.db.scalars(
select(WorkItem).where(
WorkItem.gitea_owner == owner,
WorkItem.gitea_repo == repo,
WorkItem.status == "open",
)
).all()
for item in linked_items:
if item.gitea_issue_number and item.gitea_issue_number in gitea_refs:
taiga_story_refs.add(item.taiga_story_ref)
if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number:
gitea_refs.add(item.gitea_issue_number)
for gitea_num in gitea_refs:
if gitea:
try:
gitea.close_issue(owner, repo, gitea_num)
except Exception as exc:
results.append({"error": f"gitea #{gitea_num}: {exc}"})
continue
for item in linked_items:
if item.gitea_issue_number == gitea_num:
try:
self._close_work_item(item, taiga)
results.append(
{
"commit": sha,
"closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}",
}
)
except Exception as exc:
results.append(
{"error": f"work item {item.id} (gitea #{gitea_num}): {exc}"}
)
for ref in taiga_story_refs:
project_id = self._project_id_for_ref(owner, repo, ref, linked_items)
if not project_id:
continue
story = taiga.get_by_ref(project_id, ref, kind="userstory")
if story and not story.get("is_closed"):
try:
taiga.close_userstory(story["id"], project_id)
results.append({"commit": sha, "closed": f"taiga #{ref}"})
except Exception as exc:
results.append({"error": f"taiga #{ref}: {exc}"})
for item in linked_items:
if item.taiga_story_ref == ref and item.status != "closed":
try:
self._close_work_item(item, taiga, close_gitea=bool(gitea))
except Exception as exc:
results.append(
{"error": f"work item {item.id} (taiga #{ref}): {exc}"}
)
for ref in taiga_task_refs:
binding = self.db.scalar(
select(ProjectBinding).where(
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo,
)
)
if not binding:
continue
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
)
if not taiga_proj:
continue
task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task")
if task and not task.get("is_closed"):
try:
taiga.close_task(task["id"], taiga_proj.taiga_id)
results.append({"commit": sha, "closed": f"taiga task #{ref}"})
except Exception as exc:
results.append({"error": f"taiga task #{ref}: {exc}"})
self.db.commit()
return results
def _project_id_for_ref(
self,
owner: str,
repo: str,
ref: int,
items: list[WorkItem],
) -> int | None:
for item in items:
if item.taiga_story_ref == ref:
return item.taiga_project_id
binding = self.db.scalar(
select(ProjectBinding).where(
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo,
)
)
if binding:
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
)
return taiga_proj.taiga_id if taiga_proj else None
return None
def _close_work_item(
self,
item: WorkItem,
taiga: TaigaClient,
*,
close_gitea: bool = True,
) -> None:
if item.status == "closed":
return
story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory")
if story:
taiga.close_userstory(story["id"], item.taiga_project_id)
if (
close_gitea
and item.gitea_issue_number
and self.settings.gitea_configured
):
GiteaClient().close_issue(
item.gitea_owner, item.gitea_repo, item.gitea_issue_number
)
item.status = "closed"
item.closed_at = datetime.now(timezone.utc)
def list_taiga_open_tasks(
self,
project_slug: str | None = None,
limit: int = 20,
) -> dict[str, Any]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена")
projects = self.list_projects()
if not projects:
projects = self.sync_taiga_projects()
if project_slug:
projects = [p for p in projects if p["slug"] == project_slug]
if not projects:
raise ValueError(
f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects."
)
client = TaigaClient()
blocks: list[dict[str, Any]] = []
for proj in projects:
stories = client.list_open_userstories(proj["taiga_id"], limit=limit)
tasks = client.list_open_tasks(proj["taiga_id"], limit=limit)
blocks.append(
{
"slug": proj["slug"],
"name": proj["name"],
"taiga_id": proj["taiga_id"],
"stories": [
{
"ref": s.get("ref"),
"subject": s.get("subject", ""),
"url": client.story_url(proj["taiga_id"], s.get("ref", 0)),
}
for s in stories
],
"tasks": [
{
"ref": t.get("ref"),
"subject": t.get("subject", ""),
"user_story": t.get("user_story"),
}
for t in tasks
],
}
)
total_stories = sum(len(b["stories"]) for b in blocks)
total_tasks = sum(len(b["tasks"]) for b in blocks)
return {
"projects": blocks,
"total_stories": total_stories,
"total_tasks": total_tasks,
}
def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]:
stmt = select(WorkItem).order_by(WorkItem.created_at.desc()).limit(limit)
if status:
stmt = stmt.where(WorkItem.status == status)
items = self.db.scalars(stmt).all()
settings = get_settings()
return [
{
"id": i.id,
"title": i.title,
"status": i.status,
"taiga_slug": i.taiga_slug,
"taiga_ref": i.taiga_story_ref,
"gitea_issue": i.gitea_issue_number,
"branch": i.suggested_branch,
"taiga_url": f"{settings.taiga_public_url}/project/0/{i.taiga_project_id}/us/{i.taiga_story_ref}",
"gitea_url": (
f"{settings.gitea_public_url}/{i.gitea_owner}/{i.gitea_repo}/issues/{i.gitea_issue_number}"
if i.gitea_issue_number
else ""
),
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in items
]
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.models import ProjectBinding, TaigaProject, WorkItem
from app.integrations.gitea import GiteaClient
from app.integrations.taiga import TaigaClient
from app.projects.commit_parser import parse_commit_message
from app.projects.structuring import (
format_gitea_body,
format_story_description,
slugify_branch,
structure_work_item,
)
class ProjectService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self.settings = get_settings()
def sync_taiga_projects(self) -> list[dict[str, Any]]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD")
client = TaigaClient()
remote = client.list_projects()
now = datetime.now(timezone.utc)
for item in remote:
slug = item.get("slug") or ""
if not slug:
continue
existing = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == slug)
)
if existing:
existing.name = item.get("name", slug)
existing.taiga_id = item["id"]
existing.synced_at = now
else:
self.db.add(
TaigaProject(
taiga_id=item["id"],
name=item.get("name", slug),
slug=slug,
synced_at=now,
)
)
self.db.commit()
return self.list_projects()
def list_projects(self) -> list[dict[str, Any]]:
stmt = (
select(TaigaProject, ProjectBinding)
.outerjoin(
ProjectBinding,
(ProjectBinding.taiga_slug == TaigaProject.slug)
& (ProjectBinding.user_id == self.user_id),
)
.order_by(TaigaProject.name)
)
rows = self.db.execute(stmt).all()
result = []
for taiga_proj, binding in rows:
result.append(
{
"taiga_id": taiga_proj.taiga_id,
"name": taiga_proj.name,
"slug": taiga_proj.slug,
"gitea_owner": binding.gitea_owner if binding else "",
"gitea_repo": binding.gitea_repo if binding else "",
"default_branch": binding.default_branch if binding else "main",
"gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo),
}
)
return result
def bind_gitea(
self,
taiga_slug: str,
gitea_owner: str,
gitea_repo: str,
default_branch: str = "main",
) -> dict[str, Any]:
if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)):
raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.")
binding = self.db.scalar(
select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_slug)
)
if binding:
binding.gitea_owner = gitea_owner
binding.gitea_repo = gitea_repo
binding.default_branch = default_branch
else:
binding = ProjectBinding(
user_id=self.user_id,
taiga_slug=taiga_slug,
gitea_owner=gitea_owner,
gitea_repo=gitea_repo,
default_branch=default_branch,
)
self.db.add(binding)
self.db.commit()
for proj in self.list_projects():
if proj["slug"] == taiga_slug:
return proj
raise ValueError("Binding failed")
def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]:
projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all()
if not projects:
raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.")
taiga_proj: TaigaProject | None = None
if slug:
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == slug)
)
if not taiga_proj:
raise ValueError(f"Проект '{slug}' не найден")
else:
taiga_proj = projects[0]
binding = self.db.scalar(
select(ProjectBinding).where(ProjectBinding.user_id == self.user_id, ProjectBinding.taiga_slug == taiga_proj.slug)
)
return taiga_proj, binding
async def create_work_item(
self, raw_text: str, project_slug: str | None = None
) -> dict[str, Any]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена")
project_list = self.list_projects()
if not project_list:
self.sync_taiga_projects()
project_list = self.list_projects()
structured = await structure_work_item(raw_text, project_list)
slug = project_slug or structured.get("project_slug")
taiga_proj, binding = self._resolve_project(slug)
if binding and not (binding.gitea_owner and binding.gitea_repo):
binding = None
taiga = TaigaClient()
title = (structured.get("title") or raw_text).strip()[:500]
description = format_story_description(structured, raw_text)
tags = structured.get("tags") or []
issue_type = structured.get("issue_type", "feature")
if issue_type == "bug" and "bug" not in [t.lower() for t in tags]:
tags.append("bug")
story = taiga.create_userstory(
taiga_proj.taiga_id,
title,
description,
tags=tags,
)
subtasks = []
for child in structured.get("children") or []:
if isinstance(child, dict):
subtasks.append(
taiga.create_task(
taiga_proj.taiga_id,
story["id"],
child.get("title", "Подзадача"),
child.get("description", ""),
)
)
branch = f"feature/{story['ref']}-{slugify_branch(title)}"
gitea_issue_number = None
gitea_url = ""
if binding and self.settings.gitea_configured:
gitea = GiteaClient()
gitea_body = format_gitea_body(
structured,
raw_text,
story["ref"],
taiga.story_url(taiga_proj.taiga_id, story["ref"]),
branch,
)
if issue_type:
gitea_body = f"**Тип:** {issue_type}\n\n{gitea_body}"
issue = gitea.create_issue(
binding.gitea_owner,
binding.gitea_repo,
title,
gitea_body,
)
gitea_issue_number = issue["number"]
gitea_url = gitea.issue_url(
binding.gitea_owner, binding.gitea_repo, gitea_issue_number
)
work_item = WorkItem(
user_id=self.user_id,
taiga_slug=taiga_proj.slug,
taiga_project_id=taiga_proj.taiga_id,
taiga_story_id=story["id"],
taiga_story_ref=story["ref"],
gitea_owner=binding.gitea_owner if binding else "",
gitea_repo=binding.gitea_repo if binding else "",
gitea_issue_number=gitea_issue_number,
suggested_branch=branch,
raw_text=raw_text,
title=title,
status="open",
)
self.db.add(work_item)
self.db.commit()
self.db.refresh(work_item)
return {
"ok": True,
"work_item_id": work_item.id,
"taiga": {
"ref": story["ref"],
"id": story["id"],
"subject": story["subject"],
"url": taiga.story_url(taiga_proj.taiga_id, story["ref"]),
},
"gitea": {
"number": gitea_issue_number,
"url": gitea_url,
},
"branch": branch,
"issue_type": issue_type,
"subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks],
"questions": structured.get("questions") or [],
"project_slug": taiga_proj.slug,
}
def process_push(
self, owner: str, repo: str, commits: list[dict[str, Any]]
) -> list[dict[str, Any]]:
if not self.settings.taiga_configured:
return []
taiga = TaigaClient()
gitea = GiteaClient() if self.settings.gitea_configured else None
results: list[dict[str, Any]] = []
for commit in commits:
message = commit.get("message", "")
parsed = parse_commit_message(message)
sha = commit.get("id", "")[:8]
gitea_refs = set(parsed["gitea"])
taiga_story_refs = set(parsed["taiga_story"])
taiga_task_refs = set(parsed["taiga_task"])
linked_items = self.db.scalars(
select(WorkItem).where(
WorkItem.user_id == self.user_id,
WorkItem.gitea_owner == owner,
WorkItem.gitea_repo == repo,
WorkItem.status == "open",
)
).all()
for item in linked_items:
if item.gitea_issue_number and item.gitea_issue_number in gitea_refs:
taiga_story_refs.add(item.taiga_story_ref)
if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number:
gitea_refs.add(item.gitea_issue_number)
for gitea_num in gitea_refs:
if gitea:
try:
gitea.close_issue(owner, repo, gitea_num)
except Exception as exc:
results.append({"error": f"gitea #{gitea_num}: {exc}"})
continue
for item in linked_items:
if item.gitea_issue_number == gitea_num:
try:
self._close_work_item(item, taiga)
results.append(
{
"commit": sha,
"closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}",
}
)
except Exception as exc:
results.append(
{"error": f"work item {item.id} (gitea #{gitea_num}): {exc}"}
)
for ref in taiga_story_refs:
project_id = self._project_id_for_ref(owner, repo, ref, linked_items)
if not project_id:
continue
story = taiga.get_by_ref(project_id, ref, kind="userstory")
if story and not story.get("is_closed"):
try:
taiga.close_userstory(story["id"], project_id)
results.append({"commit": sha, "closed": f"taiga #{ref}"})
except Exception as exc:
results.append({"error": f"taiga #{ref}: {exc}"})
for item in linked_items:
if item.taiga_story_ref == ref and item.status != "closed":
try:
self._close_work_item(item, taiga, close_gitea=bool(gitea))
except Exception as exc:
results.append(
{"error": f"work item {item.id} (taiga #{ref}): {exc}"}
)
for ref in taiga_task_refs:
binding = self.db.scalar(
select(ProjectBinding).where(
ProjectBinding.user_id == self.user_id,
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo,
)
)
if not binding:
continue
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
)
if not taiga_proj:
continue
task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task")
if task and not task.get("is_closed"):
try:
taiga.close_task(task["id"], taiga_proj.taiga_id)
results.append({"commit": sha, "closed": f"taiga task #{ref}"})
except Exception as exc:
results.append({"error": f"taiga task #{ref}: {exc}"})
self.db.commit()
return results
def _project_id_for_ref(
self,
owner: str,
repo: str,
ref: int,
items: list[WorkItem],
) -> int | None:
for item in items:
if item.taiga_story_ref == ref:
return item.taiga_project_id
binding = self.db.scalar(
select(ProjectBinding).where(
ProjectBinding.user_id == self.user_id,
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo,
)
)
if binding:
taiga_proj = self.db.scalar(
select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug)
)
return taiga_proj.taiga_id if taiga_proj else None
return None
def _close_work_item(
self,
item: WorkItem,
taiga: TaigaClient,
*,
close_gitea: bool = True,
) -> None:
if item.status == "closed":
return
story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory")
if story:
taiga.close_userstory(story["id"], item.taiga_project_id)
if (
close_gitea
and item.gitea_issue_number
and self.settings.gitea_configured
):
GiteaClient().close_issue(
item.gitea_owner, item.gitea_repo, item.gitea_issue_number
)
item.status = "closed"
item.closed_at = datetime.now(timezone.utc)
def list_taiga_open_tasks(
self,
project_slug: str | None = None,
limit: int = 20,
) -> dict[str, Any]:
if not self.settings.taiga_configured:
raise ValueError("Taiga не настроена")
projects = self.list_projects()
if not projects:
projects = self.sync_taiga_projects()
if project_slug:
projects = [p for p in projects if p["slug"] == project_slug]
if not projects:
raise ValueError(
f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects."
)
client = TaigaClient()
blocks: list[dict[str, Any]] = []
for proj in projects:
stories = client.list_open_userstories(proj["taiga_id"], limit=limit)
tasks = client.list_open_tasks(proj["taiga_id"], limit=limit)
blocks.append(
{
"slug": proj["slug"],
"name": proj["name"],
"taiga_id": proj["taiga_id"],
"stories": [
{
"ref": s.get("ref"),
"subject": s.get("subject", ""),
"url": client.story_url(proj["taiga_id"], s.get("ref", 0)),
}
for s in stories
],
"tasks": [
{
"ref": t.get("ref"),
"subject": t.get("subject", ""),
"user_story": t.get("user_story"),
}
for t in tasks
],
}
)
total_stories = sum(len(b["stories"]) for b in blocks)
total_tasks = sum(len(b["tasks"]) for b in blocks)
return {
"projects": blocks,
"total_stories": total_stories,
"total_tasks": total_tasks,
}
def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]:
stmt = select(WorkItem).where(WorkItem.user_id == self.user_id).order_by(WorkItem.created_at.desc()).limit(limit)
if status:
stmt = stmt.where(WorkItem.status == status)
items = self.db.scalars(stmt).all()
settings = get_settings()
return [
{
"id": i.id,
"title": i.title,
"status": i.status,
"taiga_slug": i.taiga_slug,
"taiga_ref": i.taiga_story_ref,
"gitea_issue": i.gitea_issue_number,
"branch": i.suggested_branch,
"taiga_url": f"{settings.taiga_public_url}/project/0/{i.taiga_project_id}/us/{i.taiga_story_ref}",
"gitea_url": (
f"{settings.gitea_public_url}/{i.gitea_owner}/{i.gitea_repo}/issues/{i.gitea_issue_number}"
if i.gitea_issue_number
else ""
),
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in items
]
+5
View File
@@ -0,0 +1,5 @@
"""RAG: embeddings, Qdrant store, retrieval, ingest."""
from app.rag import chunker, embeddings, ingest, retriever, store
__all__ = ["chunker", "embeddings", "ingest", "retriever", "store"]
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
def chunk_text(text: str, *, chunk_size: int = 800, overlap: int = 120) -> list[str]:
cleaned = (text or "").strip()
if not cleaned:
return []
if len(cleaned) <= chunk_size:
return [cleaned]
chunks: list[str] = []
start = 0
while start < len(cleaned):
end = min(len(cleaned), start + chunk_size)
piece = cleaned[start:end].strip()
if piece:
chunks.append(piece)
if end >= len(cleaned):
break
start = max(0, end - overlap)
return chunks
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
from app.llm.client import LLMClient
async def embed_texts(texts: list[str]) -> list[list[float]]:
if not texts:
return []
client = LLMClient()
return await client.embed(texts)
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
import hashlib
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from qdrant_client.http import models as qm
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.models import ChatSession, Document, DocumentChunk, MemoryFact
from app.rag import embeddings
from app.rag.chunker import chunk_text
from app.rag.store import (
COLLECTION_DOC_CHUNKS,
COLLECTION_FACTS,
COLLECTION_SUMMARIES,
delete_by_filter,
upsert_points,
)
async def index_memory_fact(fact: MemoryFact) -> None:
settings = get_settings()
if not settings.rag_enabled or not fact.active:
return
vectors = await embeddings.embed_texts([fact.content])
if not vectors:
return
upsert_points(
COLLECTION_FACTS,
[
qm.PointStruct(
id=int(fact.id),
vector=vectors[0],
payload={
"user_id": fact.user_id,
"fact_id": fact.id,
"category": fact.category,
"content": fact.content,
"importance": fact.importance,
},
)
],
)
async def deactivate_memory_fact(fact_id: int) -> None:
settings = get_settings()
if not settings.rag_enabled:
return
delete_by_filter(
COLLECTION_FACTS,
[qm.FieldCondition(key="fact_id", match=qm.MatchValue(value=fact_id))],
)
async def index_session_summary(session_id: int, summary: str) -> None:
settings = get_settings()
if not settings.rag_enabled or not summary.strip():
return
from app.db.base import SessionLocal
user_id = 1
db = SessionLocal()
try:
session = db.get(ChatSession, session_id)
if session:
user_id = session.user_id
finally:
db.close()
vectors = await embeddings.embed_texts([summary])
if not vectors:
return
upsert_points(
COLLECTION_SUMMARIES,
[
qm.PointStruct(
id=int(session_id),
vector=vectors[0],
payload={"user_id": user_id, "session_id": session_id, "summary": summary[:4000]},
)
],
)
async def ingest_document_file(
db: Session,
*,
user_id: int,
title: str,
filename: str,
raw_bytes: bytes,
) -> dict[str, Any]:
settings = get_settings()
text = raw_bytes.decode("utf-8", errors="replace").strip()
if not text:
raise ValueError("Пустой документ")
digest = hashlib.sha256(raw_bytes).hexdigest()
doc = Document(
user_id=user_id,
title=title or filename,
filename=filename,
content_hash=digest,
size_bytes=len(raw_bytes),
)
db.add(doc)
db.flush()
chunks = chunk_text(text)
chunk_rows: list[DocumentChunk] = []
for idx, piece in enumerate(chunks):
row = DocumentChunk(document_id=doc.id, chunk_index=idx, content=piece)
db.add(row)
chunk_rows.append(row)
db.commit()
db.refresh(doc)
if settings.rag_enabled and chunks:
vectors = await embeddings.embed_texts(chunks)
points: list[qm.PointStruct] = []
for row, vector in zip(chunk_rows, vectors, strict=False):
db.refresh(row)
point_id = int(row.id)
points.append(
qm.PointStruct(
id=point_id,
vector=vector,
payload={
"user_id": user_id,
"document_id": doc.id,
"chunk_id": row.id,
"chunk_index": row.chunk_index,
"title": doc.title,
"content": row.content,
},
)
)
upsert_points(COLLECTION_DOC_CHUNKS, points)
return {
"id": doc.id,
"title": doc.title,
"filename": doc.filename,
"chunk_count": len(chunks),
"size_bytes": doc.size_bytes,
"created_at": doc.created_at.isoformat() if doc.created_at else None,
}
@@ -0,0 +1,37 @@
# Migrate active memory facts into Qdrant
from __future__ import annotations
import asyncio
from sqlalchemy import select
from app.config import get_settings
from app.db.base import SessionLocal, init_db
from app.db.models import MemoryFact, SessionSummary
from app.rag.ingest import index_memory_fact, index_session_summary
from app.rag.store import ensure_collections
async def main() -> None:
settings = get_settings()
if not settings.rag_enabled:
print("RAG disabled; set RAG_ENABLED=true")
return
init_db()
ensure_collections()
db = SessionLocal()
try:
facts = db.scalars(select(MemoryFact).where(MemoryFact.active.is_(True))).all()
for fact in facts:
await index_memory_fact(fact)
summaries = db.scalars(select(SessionSummary)).all()
for row in summaries:
if row.summary:
await index_session_summary(row.session_id, row.summary)
print(f"Indexed {len(facts)} facts and {len(summaries)} summaries")
finally:
db.close()
if __name__ == "__main__":
asyncio.run(main())
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from typing import Any
from qdrant_client.http import models as qm
from app.config import get_settings
from app.rag import embeddings
from app.rag.store import COLLECTION_DOC_CHUNKS, COLLECTION_FACTS, search
def _user_filter(user_id: int) -> qm.Filter:
return qm.Filter(
must=[qm.FieldCondition(key="user_id", match=qm.MatchValue(value=user_id))]
)
async def retrieve_memory_facts(
query: str, *, user_id: int, top_k: int | None = None
) -> list[dict[str, Any]]:
settings = get_settings()
if not settings.rag_enabled or not query.strip():
return []
k = top_k or settings.rag_top_k
vectors = await embeddings.embed_texts([query])
if not vectors:
return []
hits = search(COLLECTION_FACTS, vectors[0], limit=k, query_filter=_user_filter(user_id))
results: list[dict[str, Any]] = []
for hit in hits:
payload = hit.payload or {}
results.append(
{
"id": payload.get("fact_id") or hit.id,
"category": payload.get("category", "fact"),
"content": payload.get("content", ""),
"score": hit.score,
}
)
return results
async def retrieve_document_chunks(
query: str, *, user_id: int, top_k: int = 6
) -> list[dict[str, Any]]:
settings = get_settings()
if not settings.rag_enabled or not query.strip():
return []
vectors = await embeddings.embed_texts([query])
if not vectors:
return []
hits = search(
COLLECTION_DOC_CHUNKS, vectors[0], limit=top_k, query_filter=_user_filter(user_id)
)
out: list[dict[str, Any]] = []
for hit in hits:
payload = hit.payload or {}
out.append(
{
"document_id": payload.get("document_id"),
"chunk_index": payload.get("chunk_index"),
"title": payload.get("title", ""),
"content": payload.get("content", ""),
"score": hit.score,
}
)
return out
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
import logging
from typing import Any
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
from app.config import get_settings
logger = logging.getLogger(__name__)
COLLECTION_FACTS = "memory_facts"
COLLECTION_SUMMARIES = "session_summaries"
COLLECTION_DOC_CHUNKS = "document_chunks"
VECTOR_SIZE = 1536
def _client() -> QdrantClient:
settings = get_settings()
return QdrantClient(url=settings.qdrant_url)
def ensure_collections() -> None:
settings = get_settings()
if not settings.rag_enabled:
return
client = _client()
for name in (COLLECTION_FACTS, COLLECTION_SUMMARIES, COLLECTION_DOC_CHUNKS):
if client.collection_exists(name):
continue
client.create_collection(
collection_name=name,
vectors_config=qm.VectorParams(size=VECTOR_SIZE, distance=qm.Distance.COSINE),
)
logger.info("Created Qdrant collection %s", name)
def upsert_points(collection: str, points: list[qm.PointStruct]) -> None:
if not points:
return
_client().upsert(collection_name=collection, points=points)
def delete_by_filter(collection: str, must: list[qm.FieldCondition]) -> None:
_client().delete(
collection_name=collection,
points_selector=qm.FilterSelector(filter=qm.Filter(must=must)),
)
def search(
collection: str,
vector: list[float],
*,
limit: int,
query_filter: qm.Filter | None = None,
) -> list[qm.ScoredPoint]:
return _client().search(
collection_name=collection,
query_vector=vector,
limit=limit,
query_filter=query_filter,
)
+74 -73
View File
@@ -1,73 +1,74 @@
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
from app.db.models import Reminder
from app.llm.client import LLMClient
from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local
logger = logging.getLogger(__name__)
def format_reminder_notice(row: Reminder) -> str:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_"
if row.notes:
notice += f"\n{row.notes}"
return notice
class ReminderCompletionHandler:
def __init__(self, db: Session):
self.db = db
self.llm = LLMClient()
self.character = CharacterService()
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))
try:
comment = await self._generate_llm_comment(row, local_when)
if comment:
post_character_comment_to_latest_chat(comment)
except Exception:
logger.exception("Reminder LLM comment failed (id=%s)", row.id)
self._mark_fired(row, datetime.now(timezone.utc))
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
from app.db.models import Reminder
from app.llm.client import LLMClient
from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local
logger = logging.getLogger(__name__)
def format_reminder_notice(row: Reminder) -> str:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_"
if row.notes:
notice += f"\n{row.notes}"
return notice
class ReminderCompletionHandler:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self.llm = LLMClient()
self.character = CharacterService(db, user_id)
async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str:
notes_part = f"\nЗаметки: {row.notes}" if row.notes else ""
rec_part = ""
if row.recurrence and row.recurrence != RECURRENCE_NONE:
rec_part = f"\nПовтор: {row.recurrence}"
system = self.character.get_system_prompt()
user_prompt = f"""Сработало напоминание.
Заголовок: {row.title}
Время: {local_when}{notes_part}{rec_part}
Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи."""
result = await self.llm.complete(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
],
temperature=0.8,
visible_reply=True,
)
return (result.get("content") or "").strip() or f"Напоминание: {row.title}"
def _mark_fired(self, row: Reminder, now: datetime) -> None:
row.last_fired_at = now
if row.recurrence == RECURRENCE_NONE:
row.completed_at = now
row.enabled = False
else:
row.due_at = _advance_due(row.due_at, row.recurrence)
row.last_fired_at = None
row.updated_at = now
async def process(self, row: Reminder) -> None:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
post_notice_to_latest_chat(format_reminder_notice(row), self.user_id)
try:
comment = await self._generate_llm_comment(row, local_when)
if comment:
post_character_comment_to_latest_chat(comment, self.user_id)
except Exception:
logger.exception("Reminder LLM comment failed (id=%s)", row.id)
self._mark_fired(row, datetime.now(timezone.utc))
+33 -33
View File
@@ -1,33 +1,33 @@
from typing import Any
from sqlalchemy.orm import Session
from app.reminders.service import RemindersService
MAX_IN_CONTEXT = 10
def get_reminders_snapshot(db: Session) -> dict[str, Any]:
return RemindersService(db).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)
from typing import Any
from sqlalchemy.orm import Session
from app.reminders.service import RemindersService
MAX_IN_CONTEXT = 10
def get_reminders_snapshot(db: Session, user_id: int) -> dict[str, Any]:
return RemindersService(db, user_id).snapshot()
def format_reminders_context(snapshot: dict[str, Any]) -> str:
lines = ["[Напоминания]"]
upcoming = snapshot.get("upcoming") or []
tz = snapshot.get("timezone", "Europe/Moscow")
if not upcoming:
lines.append(
"Ближайших напоминаний нет. "
"create_reminder для «напомни через 15 минут», «завтра утром», точной даты."
)
return "\n".join(lines)
lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.")
for item in upcoming[:MAX_IN_CONTEXT]:
rec = item.get("recurrence", "none")
rec_label = f" · повтор: {rec}" if rec and rec != "none" else ""
lines.append(
f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}"
)
return "\n".join(lines)
+50 -45
View File
@@ -1,45 +1,50 @@
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import Reminder
from app.reminders.completion import ReminderCompletionHandler
from app.reminders.notify import bump_notify_seq
logger = logging.getLogger(__name__)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def get_due_reminders(db: Session) -> list[Reminder]:
now = _utcnow()
stmt = (
select(Reminder)
.where(
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:
due = get_due_reminders(db)
if not due:
return 0
handler = ReminderCompletionHandler(db)
for row in due:
await handler.process(row)
db.commit()
bump_notify_seq(db)
logger.info("Reminders fired: %d", len(due))
return len(due)
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import Reminder, User
from app.reminders.completion import ReminderCompletionHandler
from app.reminders.notify import bump_notify_seq
logger = logging.getLogger(__name__)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def get_due_reminders(db: Session, user_id: int) -> list[Reminder]:
now = _utcnow()
stmt = (
select(Reminder)
.where(
Reminder.user_id == user_id,
Reminder.enabled.is_(True),
Reminder.completed_at.is_(None),
Reminder.due_at <= now,
)
.order_by(Reminder.due_at.asc())
)
rows = list(db.scalars(stmt).all())
return [row for row in rows if not (row.last_fired_at and row.last_fired_at >= row.due_at)]
async def process_due_reminders(db: Session) -> int:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
total = 0
for user in users:
due = get_due_reminders(db, user.id)
if not due:
continue
handler = ReminderCompletionHandler(db, user.id)
for row in due:
await handler.process(row)
total += len(due)
if total:
db.commit()
bump_notify_seq(db)
logger.info("Reminders fired: %d", total)
return total
+245 -237
View File
@@ -1,237 +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.homelab.context import resolve_timezone
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):
self.db = db
def _tz(self) -> str:
return resolve_timezone(self.db)
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.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.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)
return self._to_dict(row) if row else None
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(
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:
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:
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:
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)}
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)}
+3
View File
@@ -0,0 +1,3 @@
from app.reminders_scoped.service import RemindersService
__all__ = ["RemindersService"]
@@ -0,0 +1,74 @@
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
from app.db.models import Reminder
from app.llm.client import LLMClient
from app.reminders_scoped.service import RECURRENCE_NONE, _advance_due, _format_local
logger = logging.getLogger(__name__)
def format_reminder_notice(row: Reminder) -> str:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_"
if row.notes:
notice += f"\n{row.notes}"
return notice
class ReminderCompletionHandler:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self.llm = LLMClient()
self.character = CharacterService(db, user_id)
async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str:
notes_part = f"\nЗаметки: {row.notes}" if row.notes else ""
rec_part = ""
if row.recurrence and row.recurrence != RECURRENCE_NONE:
rec_part = f"\nПовтор: {row.recurrence}"
system = self.character.get_system_prompt()
user_prompt = f"""Сработало напоминание.
Заголовок: {row.title}
Время: {local_when}{notes_part}{rec_part}
Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи."""
result = await self.llm.complete(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
],
temperature=0.8,
visible_reply=True,
)
return (result.get("content") or "").strip() or f"Напоминание: {row.title}"
def _mark_fired(self, row: Reminder, now: datetime) -> None:
row.last_fired_at = now
if row.recurrence == RECURRENCE_NONE:
row.completed_at = now
row.enabled = False
else:
row.due_at = _advance_due(row.due_at, row.recurrence)
row.last_fired_at = None
row.updated_at = now
async def process(self, row: Reminder) -> None:
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
post_notice_to_latest_chat(format_reminder_notice(row), self.user_id)
try:
comment = await self._generate_llm_comment(row, local_when)
if comment:
post_character_comment_to_latest_chat(comment, self.user_id)
except Exception:
logger.exception("Reminder LLM comment failed (id=%s)", row.id)
self._mark_fired(row, datetime.now(timezone.utc))
+33
View File
@@ -0,0 +1,33 @@
from typing import Any
from sqlalchemy.orm import Session
from app.reminders_scoped.service import RemindersService
MAX_IN_CONTEXT = 10
def get_reminders_snapshot(db: Session, user_id: int) -> dict[str, Any]:
return RemindersService(db, user_id).snapshot()
def format_reminders_context(snapshot: dict[str, Any]) -> str:
lines = ["[Напоминания]"]
upcoming = snapshot.get("upcoming") or []
tz = snapshot.get("timezone", "Europe/Moscow")
if not upcoming:
lines.append(
"Ближайших напоминаний нет. "
"create_reminder для «напомни через 15 минут», «завтра утром», точной даты."
)
return "\n".join(lines)
lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.")
for item in upcoming[:MAX_IN_CONTEXT]:
rec = item.get("recurrence", "none")
rec_label = f" · повтор: {rec}" if rec and rec != "none" else ""
lines.append(
f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}"
)
return "\n".join(lines)
+50
View File
@@ -0,0 +1,50 @@
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import Reminder, User
from app.reminders_scoped.completion import ReminderCompletionHandler
from app.reminders.notify import bump_notify_seq
logger = logging.getLogger(__name__)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def get_due_reminders(db: Session, user_id: int) -> list[Reminder]:
now = _utcnow()
stmt = (
select(Reminder)
.where(
Reminder.user_id == user_id,
Reminder.enabled.is_(True),
Reminder.completed_at.is_(None),
Reminder.due_at <= now,
)
.order_by(Reminder.due_at.asc())
)
rows = list(db.scalars(stmt).all())
return [row for row in rows if not (row.last_fired_at and row.last_fired_at >= row.due_at)]
async def process_due_reminders(db: Session) -> int:
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
total = 0
for user in users:
due = get_due_reminders(db, user.id)
if not due:
continue
handler = ReminderCompletionHandler(db, user.id)
for row in due:
await handler.process(row)
total += len(due)
if total:
db.commit()
bump_notify_seq(db)
logger.info("Reminders fired: %d", total)
return total
+245
View File
@@ -0,0 +1,245 @@
import calendar
from datetime import datetime, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import Reminder
from app.memory.service import MemoryService
from app.reminders.notify import bump_notify_seq, get_notify_seq
RECURRENCE_NONE = "none"
RECURRENCE_DAILY = "daily"
RECURRENCE_WEEKLY = "weekly"
RECURRENCE_MONTHLY = "monthly"
RECURRENCE_YEARLY = "yearly"
VALID_RECURRENCE = frozenset({
RECURRENCE_NONE,
RECURRENCE_DAILY,
RECURRENCE_WEEKLY,
RECURRENCE_MONTHLY,
RECURRENCE_YEARLY,
})
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _parse_due_at(raw: str, tz_name: str) -> datetime:
clean = raw.strip()
if not clean:
raise ValueError("due_at не может быть пустым")
try:
dt = datetime.fromisoformat(clean.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"Неверный формат даты: {raw}") from exc
if dt.tzinfo is None:
try:
dt = dt.replace(tzinfo=ZoneInfo(tz_name))
except Exception:
dt = dt.replace(tzinfo=ZoneInfo("Europe/Moscow"))
return dt.astimezone(timezone.utc)
def _advance_due(due_at: datetime, recurrence: str) -> datetime:
if recurrence == RECURRENCE_DAILY:
return due_at + timedelta(days=1)
if recurrence == RECURRENCE_WEEKLY:
return due_at + timedelta(weeks=1)
if recurrence == RECURRENCE_MONTHLY:
month = due_at.month + 1
year = due_at.year
if month > 12:
month = 1
year += 1
day = min(due_at.day, calendar.monthrange(year, month)[1])
return due_at.replace(year=year, month=month, day=day)
if recurrence == RECURRENCE_YEARLY:
year = due_at.year + 1
day = min(due_at.day, calendar.monthrange(year, due_at.month)[1])
return due_at.replace(year=year, day=day)
return due_at
def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str:
try:
local = dt.astimezone(ZoneInfo(tz_name))
except Exception:
local = dt.astimezone(ZoneInfo("Europe/Moscow"))
if all_day:
return local.strftime("%Y-%m-%d")
return local.strftime("%Y-%m-%d %H:%M")
class RemindersService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
def _tz(self) -> str:
profile = MemoryService(self.db, self.user_id).get_profile()
tz = (profile.get("timezone") or "").strip()
return tz or "Europe/Moscow"
def _to_dict(self, row: Reminder) -> dict[str, Any]:
tz = row.timezone or self._tz()
return {
"id": row.id,
"title": row.title,
"notes": row.notes,
"due_at": row.due_at.isoformat(),
"due_at_local": _format_local(row.due_at, tz, all_day=row.all_day),
"all_day": row.all_day,
"recurrence": row.recurrence,
"enabled": row.enabled,
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
"timezone": tz,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def snapshot(self) -> dict[str, Any]:
upcoming = self.list_upcoming(limit=12)
return {
"notify_seq": get_notify_seq(self.db),
"upcoming": upcoming,
"upcoming_count": len(upcoming),
"timezone": self._tz(),
}
def list_upcoming(self, *, limit: int = 30) -> list[dict[str, Any]]:
stmt = (
select(Reminder)
.where(
Reminder.user_id == self.user_id,
Reminder.enabled.is_(True),
Reminder.completed_at.is_(None),
)
.order_by(Reminder.due_at.asc())
.limit(limit)
)
return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
def list_in_range(
self,
*,
date_from: datetime,
date_to: datetime,
) -> list[dict[str, Any]]:
stmt = (
select(Reminder)
.where(
Reminder.user_id == self.user_id,
Reminder.enabled.is_(True),
Reminder.completed_at.is_(None),
Reminder.due_at >= date_from,
Reminder.due_at < date_to,
)
.order_by(Reminder.due_at.asc())
)
return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
def get(self, reminder_id: int) -> dict[str, Any] | None:
row = self.db.get(Reminder, reminder_id)
if not row or row.user_id != self.user_id:
return None
return self._to_dict(row)
def create(
self,
*,
title: str,
due_at: str,
notes: str = "",
all_day: bool = False,
recurrence: str = RECURRENCE_NONE,
) -> dict[str, Any]:
clean_title = title.strip()
if not clean_title:
raise ValueError("Название напоминания не может быть пустым")
rec = (recurrence or RECURRENCE_NONE).strip().lower()
if rec not in VALID_RECURRENCE:
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
tz = self._tz()
due = _parse_due_at(due_at, tz)
row = Reminder(
user_id=self.user_id,
title=clean_title,
notes=notes.strip(),
due_at=due,
all_day=all_day,
recurrence=rec,
timezone=tz,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
bump_notify_seq(self.db)
return {"ok": True, "reminder": self._to_dict(row), "created": True}
def update(
self,
reminder_id: int,
*,
title: str | None = None,
due_at: str | None = None,
notes: str | None = None,
all_day: bool | None = None,
recurrence: str | None = None,
enabled: bool | None = None,
) -> dict[str, Any]:
row = self.db.get(Reminder, reminder_id)
if not row or row.user_id != self.user_id:
raise ValueError("Напоминание не найдено")
if title is not None:
clean = title.strip()
if not clean:
raise ValueError("Название не может быть пустым")
row.title = clean
if notes is not None:
row.notes = notes.strip()
if due_at is not None:
row.due_at = _parse_due_at(due_at, row.timezone or self._tz())
row.last_fired_at = None
if all_day is not None:
row.all_day = all_day
if recurrence is not None:
rec = recurrence.strip().lower()
if rec not in VALID_RECURRENCE:
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
row.recurrence = rec
if enabled is not None:
row.enabled = enabled
row.updated_at = _utcnow()
self.db.commit()
self.db.refresh(row)
bump_notify_seq(self.db)
return {"ok": True, "reminder": self._to_dict(row)}
def delete(self, reminder_id: int) -> dict[str, Any]:
row = self.db.get(Reminder, reminder_id)
if not row or row.user_id != self.user_id:
raise ValueError("Напоминание не найдено")
title = row.title
self.db.delete(row)
self.db.commit()
bump_notify_seq(self.db)
return {"ok": True, "deleted_id": reminder_id, "title": title}
def complete(self, reminder_id: int) -> dict[str, Any]:
row = self.db.get(Reminder, reminder_id)
if not row or row.user_id != self.user_id:
raise ValueError("Напоминание не найдено")
now = _utcnow()
row.completed_at = now
row.enabled = False
row.updated_at = now
self.db.commit()
self.db.refresh(row)
bump_notify_seq(self.db)
return {"ok": True, "reminder": self._to_dict(row)}
+31
View File
@@ -0,0 +1,31 @@
import asyncio
import logging
from app.config import get_settings
from app.db.base import SessionLocal
from app.reminders_scoped.fire import process_due_reminders
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 30
async def reminders_watcher_loop() -> None:
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
if not get_settings().reminders_enabled:
continue
await _tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Reminders watcher error")
async def _tick() -> None:
db = SessionLocal()
try:
await process_due_reminders(db)
finally:
db.close()
+1
View File
@@ -0,0 +1 @@
"""Runtime settings stored in assistant_state."""
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db.models import AssistantState
SETTING_KEYS = (
"openrouter_model",
"memory_extract_model",
"openrouter_reasoning_effort",
"rag_enabled",
"rag_top_k",
)
class SettingsService:
def __init__(self, db: Session):
self.db = db
self._defaults = get_settings()
def _get_row(self, key: str) -> AssistantState | None:
return self.db.get(AssistantState, key)
def get_raw(self, key: str) -> str | None:
row = self._get_row(key)
if not row or not (row.value or "").strip():
return None
return row.value.strip()
def set_raw(self, key: str, value: str) -> None:
row = self._get_row(key)
if not row:
row = AssistantState(key=key, value=value)
self.db.add(row)
else:
row.value = value
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
def _default_for(self, key: str) -> Any:
defaults: Settings = self._defaults
mapping = {
"openrouter_model": defaults.openrouter_model,
"memory_extract_model": defaults.memory_extract_model or defaults.openrouter_model,
"openrouter_reasoning_effort": defaults.openrouter_reasoning_effort,
"rag_enabled": defaults.rag_enabled,
"rag_top_k": defaults.rag_top_k,
}
return mapping[key]
def get_effective(self, key: str) -> Any:
raw = self.get_raw(key)
if raw is None:
return self._default_for(key)
if key == "rag_enabled":
return raw.lower() in ("1", "true", "yes", "on")
if key == "rag_top_k":
try:
return max(1, min(50, int(raw)))
except ValueError:
return self._default_for(key)
return raw
def snapshot(self) -> dict[str, Any]:
data: dict[str, Any] = {}
for key in SETTING_KEYS:
data[key] = self.get_effective(key)
data["embedding_model"] = self._defaults.embedding_model
data["memory_facts_in_context"] = self._defaults.memory_facts_in_context
data["qdrant_url"] = self._defaults.qdrant_url
return data
def patch(self, updates: dict[str, Any]) -> dict[str, Any]:
for key, value in updates.items():
if key not in SETTING_KEYS:
continue
if value is None:
row = self._get_row(key)
if row:
self.db.delete(row)
self.db.commit()
continue
if key == "rag_enabled":
stored = "true" if bool(value) else "false"
elif key == "rag_top_k":
stored = str(int(value))
else:
stored = str(value).strip()
if not stored and key != "rag_enabled":
continue
self.set_raw(key, stored)
return self.snapshot()
+47 -47
View File
@@ -1,47 +1,47 @@
from typing import Any
from sqlalchemy.orm import Session
from app.shopping.service import ShoppingService
MAX_LISTS_IN_CONTEXT = 8
MAX_ITEMS_PER_LIST = 12
def get_shopping_snapshot(db: Session) -> dict[str, Any]:
return ShoppingService(db).snapshot()
def format_shopping_context(snapshot: dict[str, Any]) -> str:
lines = ["[Списки покупок]"]
lists = snapshot.get("lists") or []
if not lists:
lines.append("Списков пока нет. create_shopping_list или add_shopping_items.")
return "\n".join(lines)
lines.append(
f"Всего списков: {snapshot.get('list_count', len(lists))}, "
f"неотмеченных позиций: {snapshot.get('unchecked_items', 0)}."
)
lines.append("Для изменений вызывай tools: list_shopping_lists, add_shopping_items, check_shopping_item.")
for lst in lists[:MAX_LISTS_IN_CONTEXT]:
items = lst.get("items") or []
unchecked = [i for i in items if not i.get("checked")]
preview = unchecked[:MAX_ITEMS_PER_LIST]
parts = []
for item in preview:
qty = item.get("quantity")
unit = (item.get("unit") or "").strip()
label = item["text"]
if qty is not None:
label = f"{label} ({qty}{' ' + unit if unit else ''})"
parts.append(f"#{item['id']} {label}")
tail = f" +{len(unchecked) - len(preview)} ещё" if len(unchecked) > len(preview) else ""
if parts:
lines.append(f"- «{lst['name']}» (#{lst['id']}): {', '.join(parts)}{tail}")
else:
lines.append(f"- «{lst['name']}» (#{lst['id']}): всё отмечено или пусто")
return "\n".join(lines)
from typing import Any
from sqlalchemy.orm import Session
from app.shopping.service import ShoppingService
MAX_LISTS_IN_CONTEXT = 8
MAX_ITEMS_PER_LIST = 12
def get_shopping_snapshot(db: Session, user_id: int) -> dict[str, Any]:
return ShoppingService(db, user_id).snapshot()
def format_shopping_context(snapshot: dict[str, Any]) -> str:
lines = ["[Списки покупок]"]
lists = snapshot.get("lists") or []
if not lists:
lines.append("Списков пока нет. create_shopping_list или add_shopping_items.")
return "\n".join(lines)
lines.append(
f"Всего списков: {snapshot.get('list_count', len(lists))}, "
f"неотмеченных позиций: {snapshot.get('unchecked_items', 0)}."
)
lines.append("Для изменений вызывай tools: list_shopping_lists, add_shopping_items, check_shopping_item.")
for lst in lists[:MAX_LISTS_IN_CONTEXT]:
items = lst.get("items") or []
unchecked = [i for i in items if not i.get("checked")]
preview = unchecked[:MAX_ITEMS_PER_LIST]
parts = []
for item in preview:
qty = item.get("quantity")
unit = (item.get("unit") or "").strip()
label = item["text"]
if qty is not None:
label = f"{label} ({qty}{' ' + unit if unit else ''})"
parts.append(f"#{item['id']} {label}")
tail = f" +{len(unchecked) - len(preview)} ещё" if len(unchecked) > len(preview) else ""
if parts:
lines.append(f"- «{lst['name']}» (#{lst['id']}): {', '.join(parts)}{tail}")
else:
lines.append(f"- «{lst['name']}» (#{lst['id']}): всё отмечено или пусто")
return "\n".join(lines)
+224 -223
View File
@@ -1,223 +1,224 @@
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.db.models import ShoppingList, ShoppingListItem
class ShoppingService:
def __init__(self, db: Session):
self.db = db
def snapshot(self) -> dict[str, Any]:
lists = self.list_lists(include_items=True)
total_items = sum(len(lst.get("items") or []) for lst in lists)
unchecked = sum(
1
for lst in lists
for item in (lst.get("items") or [])
if not item.get("checked")
)
return {
"lists": lists,
"list_count": len(lists),
"total_items": total_items,
"unchecked_items": unchecked,
}
def list_lists(self, *, include_items: bool = False) -> list[dict[str, Any]]:
stmt = select(ShoppingList).order_by(ShoppingList.sort_order, ShoppingList.name)
if include_items:
stmt = stmt.options(selectinload(ShoppingList.items))
rows = list(self.db.scalars(stmt).all())
return [self._list_to_dict(row, include_items=include_items) for row in rows]
def get_list(
self,
list_id: int | None = None,
*,
name: str | None = None,
) -> dict[str, Any] | None:
row = self._resolve_list(list_id=list_id, name=name)
if not row:
return None
return self._list_to_dict(row, include_items=True)
def create_list(self, name: str) -> dict[str, Any]:
clean = name.strip()
if not clean:
raise ValueError("Название списка не может быть пустым")
existing = self.db.scalar(select(ShoppingList).where(ShoppingList.name == clean))
if existing:
return {"ok": True, "list": self._list_to_dict(existing, include_items=True), "created": False}
max_order = self.db.scalar(select(func.max(ShoppingList.sort_order))) or 0
row = ShoppingList(name=clean, sort_order=max_order + 1)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "list": self._list_to_dict(row, include_items=True), "created": True}
def rename_list(self, list_id: int, name: str) -> dict[str, Any]:
row = self._require_list(list_id)
clean = name.strip()
if not clean:
raise ValueError("Название списка не может быть пустым")
conflict = self.db.scalar(
select(ShoppingList).where(ShoppingList.name == clean, ShoppingList.id != list_id)
)
if conflict:
raise ValueError(f"Список «{clean}» уже существует")
row.name = clean
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "list": self._list_to_dict(row, include_items=True)}
def delete_list(self, list_id: int) -> dict[str, Any]:
row = self._require_list(list_id)
name = row.name
self.db.delete(row)
self.db.commit()
return {"ok": True, "deleted_list_id": list_id, "name": name}
def add_items(
self,
items: list[dict[str, Any]],
*,
list_id: int | None = None,
list_name: str | None = None,
create_list: bool = True,
) -> dict[str, Any]:
if not items:
raise ValueError("Нужен хотя бы один товар")
row = self._resolve_list(list_id=list_id, name=list_name)
if not row and list_name and create_list:
created = self.create_list(list_name)
row = self._require_list(created["list"]["id"])
if not row:
raise ValueError("Укажи list_id или list_name")
max_order = self.db.scalar(
select(func.max(ShoppingListItem.sort_order)).where(ShoppingListItem.list_id == row.id)
) or 0
added: list[dict[str, Any]] = []
for idx, raw in enumerate(items, start=1):
text = (raw.get("text") or "").strip()
if not text:
continue
item = ShoppingListItem(
list_id=row.id,
text=text,
quantity=raw.get("quantity"),
unit=(raw.get("unit") or "").strip(),
sort_order=max_order + idx,
)
self.db.add(item)
added.append(item)
if not added:
raise ValueError("Нет валидных товаров для добавления")
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
for item in added:
self.db.refresh(item)
return {
"ok": True,
"list_id": row.id,
"list_name": row.name,
"added": [self._item_to_dict(i) for i in added],
"list": self._list_to_dict(self._require_list(row.id), include_items=True),
}
def set_item_checked(self, item_id: int, checked: bool) -> dict[str, Any]:
item = self._require_item(item_id)
item.checked = checked
item.shopping_list.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {"ok": True, "item": self._item_to_dict(item)}
def remove_item(self, item_id: int) -> dict[str, Any]:
item = self._require_item(item_id)
data = self._item_to_dict(item)
list_id = item.list_id
self.db.delete(item)
self.db.commit()
return {"ok": True, "removed": data, "list_id": list_id}
def clear_checked(self, list_id: int) -> dict[str, Any]:
row = self._require_list(list_id)
removed = 0
for item in list(row.items):
if item.checked:
self.db.delete(item)
removed += 1
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {
"ok": True,
"list_id": list_id,
"removed_count": removed,
"list": self._list_to_dict(self._require_list(list_id), include_items=True),
}
def _resolve_list(
self,
*,
list_id: int | None = None,
name: str | None = None,
) -> ShoppingList | None:
if list_id is not None:
return self.db.scalar(
select(ShoppingList)
.where(ShoppingList.id == list_id)
.options(selectinload(ShoppingList.items))
)
if name:
clean = name.strip()
return self.db.scalar(
select(ShoppingList)
.where(ShoppingList.name == clean)
.options(selectinload(ShoppingList.items))
)
return None
def _require_list(self, list_id: int) -> ShoppingList:
row = self._resolve_list(list_id=list_id)
if not row:
raise ValueError(f"Список #{list_id} не найден")
return row
def _require_item(self, item_id: int) -> ShoppingListItem:
item = self.db.get(ShoppingListItem, item_id)
if not item:
raise ValueError(f"Позиция #{item_id} не найдена")
return item
def _item_to_dict(self, item: ShoppingListItem) -> dict[str, Any]:
return {
"id": item.id,
"list_id": item.list_id,
"text": item.text,
"quantity": item.quantity,
"unit": item.unit,
"checked": item.checked,
"sort_order": item.sort_order,
}
def _list_to_dict(self, row: ShoppingList, *, include_items: bool) -> dict[str, Any]:
data: dict[str, Any] = {
"id": row.id,
"name": row.name,
"sort_order": row.sort_order,
"item_count": len(row.items) if row.items is not None else 0,
"unchecked_count": sum(1 for i in (row.items or []) if not i.checked),
}
if include_items:
data["items"] = [self._item_to_dict(i) for i in (row.items or [])]
return data
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.db.models import ShoppingList, ShoppingListItem
class ShoppingService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
def snapshot(self) -> dict[str, Any]:
lists = self.list_lists(include_items=True)
total_items = sum(len(lst.get("items") or []) for lst in lists)
unchecked = sum(
1
for lst in lists
for item in (lst.get("items") or [])
if not item.get("checked")
)
return {
"lists": lists,
"list_count": len(lists),
"total_items": total_items,
"unchecked_items": unchecked,
}
def list_lists(self, *, include_items: bool = False) -> list[dict[str, Any]]:
stmt = select(ShoppingList).where(ShoppingList.user_id == self.user_id).order_by(ShoppingList.sort_order, ShoppingList.name)
if include_items:
stmt = stmt.options(selectinload(ShoppingList.items))
rows = list(self.db.scalars(stmt).all())
return [self._list_to_dict(row, include_items=include_items) for row in rows]
def get_list(
self,
list_id: int | None = None,
*,
name: str | None = None,
) -> dict[str, Any] | None:
row = self._resolve_list(list_id=list_id, name=name)
if not row:
return None
return self._list_to_dict(row, include_items=True)
def create_list(self, name: str) -> dict[str, Any]:
clean = name.strip()
if not clean:
raise ValueError("Название списка не может быть пустым")
existing = self.db.scalar(select(ShoppingList).where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean))
if existing:
return {"ok": True, "list": self._list_to_dict(existing, include_items=True), "created": False}
max_order = self.db.scalar(select(func.max(ShoppingList.sort_order)).where(ShoppingList.user_id == self.user_id)) or 0
row = ShoppingList(user_id=self.user_id, name=clean, sort_order=max_order + 1)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "list": self._list_to_dict(row, include_items=True), "created": True}
def rename_list(self, list_id: int, name: str) -> dict[str, Any]:
row = self._require_list(list_id)
clean = name.strip()
if not clean:
raise ValueError("Название списка не может быть пустым")
conflict = self.db.scalar(
select(ShoppingList).where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean, ShoppingList.id != list_id)
)
if conflict:
raise ValueError(f"Список «{clean}» уже существует")
row.name = clean
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "list": self._list_to_dict(row, include_items=True)}
def delete_list(self, list_id: int) -> dict[str, Any]:
row = self._require_list(list_id)
name = row.name
self.db.delete(row)
self.db.commit()
return {"ok": True, "deleted_list_id": list_id, "name": name}
def add_items(
self,
items: list[dict[str, Any]],
*,
list_id: int | None = None,
list_name: str | None = None,
create_list: bool = True,
) -> dict[str, Any]:
if not items:
raise ValueError("Нужен хотя бы один товар")
row = self._resolve_list(list_id=list_id, name=list_name)
if not row and list_name and create_list:
created = self.create_list(list_name)
row = self._require_list(created["list"]["id"])
if not row:
raise ValueError("Укажи list_id или list_name")
max_order = self.db.scalar(
select(func.max(ShoppingListItem.sort_order)).where(ShoppingListItem.list_id == row.id)
) or 0
added: list[dict[str, Any]] = []
for idx, raw in enumerate(items, start=1):
text = (raw.get("text") or "").strip()
if not text:
continue
item = ShoppingListItem(
list_id=row.id,
text=text,
quantity=raw.get("quantity"),
unit=(raw.get("unit") or "").strip(),
sort_order=max_order + idx,
)
self.db.add(item)
added.append(item)
if not added:
raise ValueError("Нет валидных товаров для добавления")
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
for item in added:
self.db.refresh(item)
return {
"ok": True,
"list_id": row.id,
"list_name": row.name,
"added": [self._item_to_dict(i) for i in added],
"list": self._list_to_dict(self._require_list(row.id), include_items=True),
}
def set_item_checked(self, item_id: int, checked: bool) -> dict[str, Any]:
item = self._require_item(item_id)
item.checked = checked
item.shopping_list.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {"ok": True, "item": self._item_to_dict(item)}
def remove_item(self, item_id: int) -> dict[str, Any]:
item = self._require_item(item_id)
data = self._item_to_dict(item)
list_id = item.list_id
self.db.delete(item)
self.db.commit()
return {"ok": True, "removed": data, "list_id": list_id}
def clear_checked(self, list_id: int) -> dict[str, Any]:
row = self._require_list(list_id)
removed = 0
for item in list(row.items):
if item.checked:
self.db.delete(item)
removed += 1
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {
"ok": True,
"list_id": list_id,
"removed_count": removed,
"list": self._list_to_dict(self._require_list(list_id), include_items=True),
}
def _resolve_list(
self,
*,
list_id: int | None = None,
name: str | None = None,
) -> ShoppingList | None:
if list_id is not None:
return self.db.scalar(
select(ShoppingList)
.where(ShoppingList.user_id == self.user_id, ShoppingList.id == list_id)
.options(selectinload(ShoppingList.items))
)
if name:
clean = name.strip()
return self.db.scalar(
select(ShoppingList)
.where(ShoppingList.user_id == self.user_id, ShoppingList.name == clean)
.options(selectinload(ShoppingList.items))
)
return None
def _require_list(self, list_id: int) -> ShoppingList:
row = self._resolve_list(list_id=list_id)
if not row:
raise ValueError(f"Список #{list_id} не найден")
return row
def _require_item(self, item_id: int) -> ShoppingListItem:
item = self.db.get(ShoppingListItem, item_id)
if not item or item.shopping_list.user_id != self.user_id:
raise ValueError(f"Позиция #{item_id} не найдена")
return item
def _item_to_dict(self, item: ShoppingListItem) -> dict[str, Any]:
return {
"id": item.id,
"list_id": item.list_id,
"text": item.text,
"quantity": item.quantity,
"unit": item.unit,
"checked": item.checked,
"sort_order": item.sort_order,
}
def _list_to_dict(self, row: ShoppingList, *, include_items: bool) -> dict[str, Any]:
data: dict[str, Any] = {
"id": row.id,
"name": row.name,
"sort_order": row.sort_order,
"item_count": len(row.items) if row.items is not None else 0,
"unchecked_count": sum(1 for i in (row.items or []) if not i.checked),
}
if include_items:
data["items"] = [self._item_to_dict(i) for i in (row.items or [])]
return data
File diff suppressed because it is too large Load Diff
+961
View File
@@ -0,0 +1,961 @@
import json
from datetime import date, datetime, timedelta, timezone
from typing import Any
from sqlalchemy.orm import Session
from app.fitness.service import FitnessService
from app.fitness.structuring import structure_meal, structure_workout
from app.homelab.digest import build_weather_briefing
from app.homelab.image_gen import generate_image as run_generate_image
from app.homelab.openmeteo import OpenMeteoClient
from app.integrations.openfoodfacts import OpenFoodFactsClient
from app.integrations.wger import WgerClient
from app.memory.service import MemoryService
from app.pomodoro.service import PomodoroService
from app.projects.service import ProjectService
from app.reminders.service import RemindersService
from app.shopping.service import ShoppingService
TOOL_DEFINITIONS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "get_pomodoro_status",
"description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Статус, фаза и прогресс цикла.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "start_pomodoro",
"description": "Запустить фазу работы в цикле помидоро (25 мин по умолчанию).",
"parameters": {
"type": "object",
"properties": {
"duration_min": {"type": "integer", "description": "Минуты работы"},
"task_note": {"type": "string", "description": "Над чем работаем"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "start_short_break",
"description": "Запустить короткий перерыв между работами.",
"parameters": {
"type": "object",
"properties": {
"duration_min": {"type": "integer", "description": "Минуты перерыва"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "start_long_break",
"description": "Запустить длинный перерыв после завершения цикла работ.",
"parameters": {
"type": "object",
"properties": {
"duration_min": {"type": "integer", "description": "Минуты перерыва"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "stop_pomodoro",
"description": "Остановить текущую фазу таймера.",
"parameters": {
"type": "object",
"properties": {
"result": {"type": "string", "description": "Отчёт о сделанном"},
"completed": {
"type": "boolean",
"description": "True если фаза полностью завершена",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "skip_pomodoro_phase",
"description": "Досрочно завершить текущую фазу и перейти к следующей в цикле.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "reset_pomodoro_cycle",
"description": "Сбросить цикл помидоро: обнулить счётчик работ и остановить таймер.",
"parameters": {
"type": "object",
"properties": {
"clear_task": {
"type": "boolean",
"description": "Также очистить текущую задачу",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_pomodoro_history",
"description": "История помидоро-сессий (таймер), не Taiga-задачи.",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Сколько сессий вернуть"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "sync_taiga_projects",
"description": "Синхронизировать список проектов из Taiga API. Вызывай если проекты неизвестны.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "list_taiga_projects",
"description": "Список проектов Taiga с привязкой Gitea.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "list_taiga_tasks",
"description": (
"ОБЯЗАТЕЛЬНО при вопросах «какие задачи», «покажи задачи проекта», «что открыто в Taiga». "
"Живые user stories и tasks из Taiga API. НЕ путать с list_work_items."
),
"parameters": {
"type": "object",
"properties": {
"project_slug": {
"type": "string",
"description": "Slug проекта, например home_assistant. Пусто = все проекты.",
},
"limit": {"type": "integer", "description": "Макс. на проект, по умолчанию 20"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "create_work_item",
"description": (
"Создать фичу/баг из вольного текста: структурировать через LLM, "
"создать Taiga story + Gitea issue. Вызывай при «заведи баг», «оформи фичу», «добавь в таигу»."
),
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Полное описание от пользователя"},
"project_slug": {
"type": "string",
"description": "Slug проекта Taiga, если известен",
},
},
"required": ["text"],
},
},
},
{
"type": "function",
"function": {
"name": "remember_fact",
"description": (
"Сохранить факт в долгосрочную память. "
"Когда пользователь просит «запомни», или сообщает устойчивое предпочтение/факт."
),
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "Что запомнить"},
"category": {
"type": "string",
"description": "preference, person, habit, project, fact",
},
"importance": {"type": "integer", "description": "1-5, по умолчанию 3"},
},
"required": ["content"],
},
},
},
{
"type": "function",
"function": {
"name": "recall_memories",
"description": (
"Поиск в долгосрочной памяти. "
"Когда спрашивают «что ты помнишь», «что я говорил про X»."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Подстрока для поиска"},
"category": {"type": "string"},
"limit": {"type": "integer"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "forget_memory",
"description": "Удалить (деактивировать) факт по id из recall_memories или снимка памяти.",
"parameters": {
"type": "object",
"properties": {
"memory_id": {"type": "integer"},
},
"required": ["memory_id"],
},
},
},
{
"type": "function",
"function": {
"name": "update_profile",
"description": (
"Обновить профиль пользователя: name, timezone, language, notes. "
"Передавай только изменившиеся поля."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "string", "description": "Возраст пользователя"},
"timezone": {"type": "string"},
"language": {"type": "string"},
"notes": {"type": "string"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "update_session_summary",
"description": (
"Сохранить краткую сводку темы текущего чата "
"(когда диалог длинный или пользователь просит «сожми контекст»)."
),
"parameters": {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "2-5 предложений о теме чата"},
"session_id": {"type": "integer"},
},
"required": ["summary", "session_id"],
},
},
},
{
"type": "function",
"function": {
"name": "get_fitness_summary",
"description": (
"Сводка фитнеса за день: ккал, БЖУ, вода, еда, тренировки. "
"Без даты — сегодня; date=YYYY-MM-DD или days_ago=1 (вчера)."
),
"parameters": {
"type": "object",
"properties": {
"date": {"type": "string", "description": "Дата YYYY-MM-DD"},
"days_ago": {
"type": "integer",
"description": "0 сегодня, 1 вчера, 2 позавчера…",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_fitness_history",
"description": (
"Краткая история за несколько дней (ккал, вода, тренировки по дням). "
"«На прошлой неделе», «за 7 дней»."
),
"parameters": {
"type": "object",
"properties": {
"days": {"type": "integer", "description": "Сколько дней, по умолчанию 7"},
"end_date": {"type": "string", "description": "Конец периода YYYY-MM-DD, по умолчанию сегодня"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "set_fitness_profile",
"description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды.",
"parameters": {
"type": "object",
"properties": {
"sex": {"type": "string", "description": "male/female"},
"age": {"type": "integer"},
"height_cm": {"type": "number"},
"weight_kg": {"type": "number"},
"activity_level": {
"type": "string",
"description": "sedentary/light/moderate/active/very_active",
},
"goal": {"type": "string", "description": "lose/maintain/gain"},
"target_weight_kg": {"type": "number"},
"weekly_workouts": {"type": "integer"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "calc_fitness_targets",
"description": "Калькулятор BMR/TDEE/макросов без сохранения.",
"parameters": {
"type": "object",
"properties": {
"sex": {"type": "string"},
"age": {"type": "integer"},
"height_cm": {"type": "number"},
"weight_kg": {"type": "number"},
"activity_level": {"type": "string"},
"goal": {"type": "string"},
},
"required": ["weight_kg", "height_cm", "age"],
},
},
},
{
"type": "function",
"function": {
"name": "log_meal",
"description": "Записать приём пищи. LLM оценит ккал и БЖУ из текста.",
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Что съел"},
"meal_type": {"type": "string"},
},
"required": ["text"],
},
},
},
{
"type": "function",
"function": {
"name": "log_water",
"description": "Записать воду в мл.",
"parameters": {
"type": "object",
"properties": {
"amount_ml": {"type": "integer"},
},
"required": ["amount_ml"],
},
},
},
{
"type": "function",
"function": {
"name": "log_weight",
"description": "Записать вес в кг.",
"parameters": {
"type": "object",
"properties": {
"weight_kg": {"type": "number"},
"body_fat_pct": {"type": "number"},
"notes": {"type": "string"},
},
"required": ["weight_kg"],
},
},
},
{
"type": "function",
"function": {
"name": "log_workout",
"description": "Записать тренировку из текста.",
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string"},
},
"required": ["text"],
},
},
},
{
"type": "function",
"function": {
"name": "lookup_food",
"description": "Поиск продукта в Open Food Facts (ккал на 100г).",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "lookup_exercise",
"description": "Поиск упражнения в базе wger.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "set_fitness_reminder",
"description": "Вкл/выкл или настроить напоминание: water, meal, workout, weigh_in.",
"parameters": {
"type": "object",
"properties": {
"kind": {"type": "string"},
"enabled": {"type": "boolean"},
"hour": {"type": "integer"},
"minute": {"type": "integer"},
"interval_hours": {"type": "integer"},
},
"required": ["kind"],
},
},
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": (
"ОБЯЗАТЕЛЬНО для вопросов о погоде, «что на улице», «будет ли дождь». "
"Текущая погода и прогноз по часам."
),
"parameters": {
"type": "object",
"properties": {
"hours_ahead": {
"type": "integer",
"description": "Сколько часов прогноза (по умолчанию 12)",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_morning_briefing",
"description": "Утренний брифинг: погода и заголовки новостей.",
"parameters": {
"type": "object",
"properties": {
"include_news": {
"type": "boolean",
"description": "Включить новости (по умолчанию true)",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "generate_image",
"description": (
"Аниме-картинка (Anima через RP-чат). "
"«Нарисуй себя» / портрет персонажа → draw_self=true. "
"Другая сцена → scene_description на английском (booru-теги). "
"Внешность берётся из карточки персонажа. Только по запросу или когда уместно."
),
"parameters": {
"type": "object",
"properties": {
"draw_self": {
"type": "boolean",
"description": "Нарисовать персонажа из карточки в контексте текущего чата",
},
"scene_description": {
"type": "string",
"description": "Описание сцены на английском (booru-теги), если не draw_self",
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "list_shopping_lists",
"description": "Все списки покупок с позициями. «Что купить», «покажи списки».",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "create_shopping_list",
"description": "Создать новый список покупок.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Название списка, например «Продукты»"},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "add_shopping_items",
"description": "Добавить товары в список. Список создаётся, если не существует.",
"parameters": {
"type": "object",
"properties": {
"list_name": {"type": "string", "description": "Название списка"},
"list_id": {"type": "integer"},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": {"type": "string"},
"quantity": {"type": "number"},
"unit": {"type": "string"},
},
"required": ["text"],
},
},
},
"required": ["items"],
},
},
},
{
"type": "function",
"function": {
"name": "check_shopping_item",
"description": "Отметить позицию как купленную (checked=true) или снять отметку (false).",
"parameters": {
"type": "object",
"properties": {
"item_id": {"type": "integer"},
"checked": {"type": "boolean"},
},
"required": ["item_id", "checked"],
},
},
},
{
"type": "function",
"function": {
"name": "remove_shopping_item",
"description": "Удалить позицию из списка по item_id.",
"parameters": {
"type": "object",
"properties": {"item_id": {"type": "integer"}},
"required": ["item_id"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_shopping_list",
"description": "Удалить весь список покупок.",
"parameters": {
"type": "object",
"properties": {"list_id": {"type": "integer"}},
"required": ["list_id"],
},
},
},
{
"type": "function",
"function": {
"name": "list_reminders",
"description": "Список активных напоминаний. «Что напомнил», «мои напоминания».",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Макс. записей, по умолчанию 20"},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "create_reminder",
"description": (
"Создать напоминание. due_at — ISO datetime в часовом поясе пользователя "
"(см. [Текущее время]). Примеры: через 15 мин, завтра 09:00, 2027-05-12T12:16:00."
),
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "О чём напомнить"},
"due_at": {"type": "string", "description": "ISO datetime"},
"notes": {"type": "string"},
"all_day": {"type": "boolean"},
"recurrence": {
"type": "string",
"enum": ["none", "daily", "weekly", "monthly", "yearly"],
"description": "Повтор (yearly — день рождения, Новый год)",
},
},
"required": ["title", "due_at"],
},
},
},
{
"type": "function",
"function": {
"name": "update_reminder",
"description": "Изменить напоминание по id.",
"parameters": {
"type": "object",
"properties": {
"reminder_id": {"type": "integer"},
"title": {"type": "string"},
"due_at": {"type": "string"},
"notes": {"type": "string"},
"all_day": {"type": "boolean"},
"recurrence": {
"type": "string",
"enum": ["none", "daily", "weekly", "monthly", "yearly"],
},
"enabled": {"type": "boolean"},
},
"required": ["reminder_id"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_reminder",
"description": "Удалить напоминание по id.",
"parameters": {
"type": "object",
"properties": {"reminder_id": {"type": "integer"}},
"required": ["reminder_id"],
},
},
},
{
"type": "function",
"function": {
"name": "complete_reminder",
"description": "Отметить напоминание выполненным (снять с календаря).",
"parameters": {
"type": "object",
"properties": {"reminder_id": {"type": "integer"}},
"required": ["reminder_id"],
},
},
},
{
"type": "function",
"function": {
"name": "list_work_items",
"description": (
"Только задачи, созданные ЭТИМ ассистентом через create_work_item (локальная БД). "
"НЕ использовать для общего вопроса «какие задачи в Taiga» — для того list_taiga_tasks."
),
"parameters": {
"type": "object",
"properties": {
"status": {"type": "string", "description": "open или closed"},
"limit": {"type": "integer"},
},
"required": [],
},
},
},
]
async def execute_tool(
db: Session,
name: str,
arguments: dict[str, Any],
*,
session_id: int | None = None,
) -> str:
pomodoro = PomodoroService(db)
projects = ProjectService(db)
memory = MemoryService(db)
fitness = FitnessService(db)
shopping = ShoppingService(db)
reminders = RemindersService(db)
try:
if name == "get_pomodoro_status":
result = pomodoro.get_status()
elif name == "start_pomodoro":
result = pomodoro.start_work(
duration_min=arguments.get("duration_min"),
task_note=arguments.get("task_note", ""),
)
elif name == "start_short_break":
result = pomodoro.start_short_break(duration_min=arguments.get("duration_min"))
elif name == "start_long_break":
result = pomodoro.start_long_break(duration_min=arguments.get("duration_min"))
elif name == "stop_pomodoro":
result = pomodoro.stop(
result=arguments.get("result", ""),
completed=arguments.get("completed", False),
)
elif name == "skip_pomodoro_phase":
result = pomodoro.skip_phase()
elif name == "reset_pomodoro_cycle":
result = pomodoro.reset_cycle(clear_task=arguments.get("clear_task", False))
elif name == "get_pomodoro_history":
result = pomodoro.history(limit=arguments.get("limit", 10))
elif name == "sync_taiga_projects":
from app.projects.context import invalidate_projects_snapshot_cache
result = projects.sync_taiga_projects()
invalidate_projects_snapshot_cache()
elif name == "list_taiga_projects":
result = projects.list_projects()
elif name == "list_taiga_tasks":
result = projects.list_taiga_open_tasks(
project_slug=arguments.get("project_slug"),
limit=arguments.get("limit", 20),
)
elif name == "create_work_item":
result = await projects.create_work_item(
arguments.get("text", ""),
project_slug=arguments.get("project_slug"),
)
elif name == "list_work_items":
result = projects.list_work_items(
limit=arguments.get("limit", 20),
status=arguments.get("status"),
)
elif name == "remember_fact":
result = memory.remember_fact(
arguments.get("content", ""),
category=arguments.get("category", "fact"),
importance=arguments.get("importance", 3),
session_id=session_id,
source="tool",
)
elif name == "recall_memories":
result = memory.recall_memories(
query=arguments.get("query"),
category=arguments.get("category"),
limit=arguments.get("limit", 20),
)
elif name == "forget_memory":
result = memory.forget_memory(int(arguments["memory_id"]))
elif name == "update_profile":
updates = {
k: arguments[k]
for k in ("name", "age", "timezone", "language", "notes")
if k in arguments and arguments[k] is not None
}
result = memory.update_profile(updates)
elif name == "update_session_summary":
result = memory.update_session_summary(
int(arguments["session_id"]),
arguments.get("summary", ""),
)
elif name == "get_fitness_summary":
day: date | None = None
if arguments.get("date"):
day = date.fromisoformat(str(arguments["date"]))
elif arguments.get("days_ago") is not None:
day = datetime.now(timezone.utc).date() - timedelta(
days=int(arguments["days_ago"])
)
result = fitness.get_daily_summary(day)
elif name == "get_fitness_history":
end_day = None
if arguments.get("end_date"):
end_day = date.fromisoformat(str(arguments["end_date"]))
result = fitness.get_history(
days=int(arguments.get("days") or 7),
end_day=end_day,
)
elif name == "set_fitness_profile":
updates = {
k: arguments[k]
for k in (
"sex", "age", "height_cm", "weight_kg", "activity_level",
"goal", "target_weight_kg", "weekly_workouts",
)
if k in arguments and arguments[k] is not None
}
result = fitness.set_profile(updates)
elif name == "calc_fitness_targets":
result = fitness.calc_targets(arguments)
elif name == "log_meal":
structured = await structure_meal(arguments.get("text", ""))
result = fitness.log_meal(
description=structured.get("description") or arguments.get("text", ""),
meal_type=arguments.get("meal_type") or structured.get("meal_type") or "snack",
calories=float(structured.get("calories") or 0),
protein_g=float(structured.get("protein_g") or 0),
fat_g=float(structured.get("fat_g") or 0),
carbs_g=float(structured.get("carbs_g") or 0),
source="llm",
estimated=True,
)
elif name == "log_water":
result = fitness.log_water(int(arguments.get("amount_ml", 250)))
elif name == "log_weight":
result = fitness.log_weight(
float(arguments["weight_kg"]),
body_fat_pct=arguments.get("body_fat_pct"),
notes=arguments.get("notes", ""),
)
elif name == "log_workout":
structured = await structure_workout(arguments.get("text", ""))
result = fitness.log_workout(
title=structured.get("title") or "Тренировка",
notes=structured.get("notes") or arguments.get("text", ""),
duration_min=structured.get("duration_min"),
exercises=structured.get("exercises"),
)
elif name == "lookup_food":
result = OpenFoodFactsClient().search(
arguments.get("query", ""),
limit=arguments.get("limit", 5),
)
elif name == "lookup_exercise":
result = WgerClient().search_exercises(
arguments.get("query", ""),
limit=arguments.get("limit", 8),
)
elif name == "set_fitness_reminder":
result = fitness.set_reminder(
arguments.get("kind", "water"),
enabled=arguments.get("enabled"),
hour=arguments.get("hour"),
minute=arguments.get("minute"),
interval_hours=arguments.get("interval_hours"),
)
elif name == "get_weather":
hours = int(arguments.get("hours_ahead") or 12)
client = OpenMeteoClient()
weather = client.fetch_current_and_hourly(hours_ahead=hours)
result = {
"weather": weather,
"rain_summary": client.rain_summary(hours_ahead=hours) if weather.get("ok") else "",
}
elif name == "get_morning_briefing":
include_news = arguments.get("include_news", True)
result = build_weather_briefing(
hours_ahead=12,
include_news=bool(include_news),
)
elif name == "generate_image":
result = await run_generate_image(
db,
session_id=session_id,
draw_self=bool(arguments.get("draw_self")),
scene_description=arguments.get("scene_description", ""),
)
elif name == "list_shopping_lists":
result = shopping.list_lists(include_items=True)
elif name == "create_shopping_list":
result = shopping.create_list(arguments.get("name", ""))
elif name == "add_shopping_items":
result = shopping.add_items(
arguments.get("items") or [],
list_id=arguments.get("list_id"),
list_name=arguments.get("list_name"),
)
elif name == "check_shopping_item":
result = shopping.set_item_checked(
int(arguments["item_id"]),
bool(arguments.get("checked", True)),
)
elif name == "remove_shopping_item":
result = shopping.remove_item(int(arguments["item_id"]))
elif name == "delete_shopping_list":
result = shopping.delete_list(int(arguments["list_id"]))
elif name == "list_reminders":
result = reminders.list_upcoming(limit=int(arguments.get("limit") or 20))
elif name == "create_reminder":
result = reminders.create(
title=arguments.get("title", ""),
due_at=arguments.get("due_at", ""),
notes=arguments.get("notes", ""),
all_day=bool(arguments.get("all_day", False)),
recurrence=arguments.get("recurrence", "none"),
)
elif name == "update_reminder":
result = reminders.update(
int(arguments["reminder_id"]),
title=arguments.get("title"),
due_at=arguments.get("due_at"),
notes=arguments.get("notes"),
all_day=arguments.get("all_day"),
recurrence=arguments.get("recurrence"),
enabled=arguments.get("enabled"),
)
elif name == "delete_reminder":
result = reminders.delete(int(arguments["reminder_id"]))
elif name == "complete_reminder":
result = reminders.complete(int(arguments["reminder_id"]))
else:
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
return json.dumps(result, ensure_ascii=False)
except ValueError as exc:
return json.dumps({"error": str(exc)}, ensure_ascii=False)
except Exception as exc:
return json.dumps({"error": str(exc)}, ensure_ascii=False)
+72
View File
@@ -0,0 +1,72 @@
"""Backfill workout active_calories / steps from notes via regex."""
from __future__ import annotations
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import WorkoutLog
ACTIVE_PATTERNS = [
re.compile(r"(?:active|активн(?:ые|ых)?)\s*(?:калор|kcal|ккал)[^\d]*(\d+(?:[.,]\d+)?)", re.I),
re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:ккал|kcal)\s*(?:active|актив)", re.I),
re.compile(r"актив[^\d]{0,20}(\d+(?:[.,]\d+)?)", re.I),
]
TOTAL_PATTERNS = [
re.compile(r"(?:total|всего|сожжено|burned)[^\d]*(\d+(?:[.,]\d+)?)\s*(?:ккал|kcal)", re.I),
re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:ккал|kcal)\s*(?:total|всего)", re.I),
]
STEPS_PATTERNS = [
re.compile(r"(?:шаг|step)s?\s*[:\-]?\s*(\d+)", re.I),
re.compile(r"(\d+)\s*(?:шаг|steps)", re.I),
]
def _first(patterns: list[re.Pattern[str]], text: str, *, as_int: bool = False):
for pat in patterns:
m = pat.search(text)
if not m:
continue
raw = m.group(1).replace(",", ".")
return int(float(raw)) if as_int else float(raw)
return None
def main() -> None:
db = SessionLocal()
updated = 0
try:
rows = db.scalars(select(WorkoutLog)).all()
for row in rows:
text = f"{row.title or ''}\n{row.notes or ''}"
changed = False
if row.active_calories is None:
val = _first(ACTIVE_PATTERNS, text)
if val is not None:
row.active_calories = float(val)
changed = True
if row.total_calories is None:
val = _first(TOTAL_PATTERNS, text)
if val is not None:
row.total_calories = float(val)
changed = True
if row.steps is None:
val = _first(STEPS_PATTERNS, text, as_int=True)
if val is not None:
row.steps = int(val)
changed = True
if changed:
updated += 1
db.commit()
print(f"updated {updated} workout rows")
finally:
db.close()
if __name__ == "__main__":
main()
+11 -9
View File
@@ -1,9 +1,11 @@
fastapi>=0.115.0
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
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
python-multipart>=0.0.9
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
qdrant-client>=1.12.0,<1.13.0
+9
View File
@@ -0,0 +1,9 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
sqlalchemy>=2.0.36
pydantic-settings>=2.6.0
openai>=1.55.0
python-dotenv>=1.0.1
aiosqlite>=0.20.0
httpx>=0.28.0
feedparser>=6.0.11
+42
View File
@@ -0,0 +1,42 @@
"""Create a user with API token. Usage:
python -m scripts.create_user testuser --display-name "Test User"
python -m scripts.create_user guest --token my-custom-token-32chars-min
"""
from __future__ import annotations
import argparse
import sys
from app.db.base import SessionLocal, init_db
from app.auth.service import create_user
def main() -> int:
parser = argparse.ArgumentParser(description="Create Home Assistant user")
parser.add_argument("username", help="Unique username (lowercase)")
parser.add_argument("--display-name", default="", help="Display name")
parser.add_argument("--token", default="", help="Custom API token (auto-generated if empty)")
args = parser.parse_args()
init_db()
db = SessionLocal()
try:
user, plain_token = create_user(
db,
username=args.username,
display_name=args.display_name or args.username,
api_token=args.token or None,
)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
finally:
db.close()
print(f"Created user id={user.id} username={user.username}")
print(f"API token (save it, shown once):\n{plain_token}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+84
View File
@@ -0,0 +1,84 @@
import unittest
from app.fitness.activity_budget import (
compute_activity_bonus,
scale_targets,
steps_bonus_kcal,
)
PROFILE = {
"weight_kg": 70,
"activity_level": "moderate",
"weekly_workouts": 3,
"calorie_target": 2000,
"protein_g": 126,
"fat_g": 56,
"carbs_g": 250,
"water_l": 2.5,
}
class ActivityBudgetTests(unittest.TestCase):
def test_no_bonus_at_baseline(self) -> None:
bonus = compute_activity_bonus(
PROFILE,
steps_total=9000,
workouts=[{"active_calories": 85}],
)
self.assertEqual(bonus.steps_bonus_kcal, 0.0)
self.assertEqual(bonus.workout_bonus_kcal, 0.0)
self.assertEqual(bonus.total_bonus_kcal, 0.0)
self.assertEqual(bonus.scale_factor, 1.0)
def test_steps_and_workout_bonus(self) -> None:
bonus = compute_activity_bonus(
PROFILE,
steps_total=21800,
workouts=[{"active_calories": 155}],
)
self.assertGreater(bonus.steps_bonus_kcal, 0)
self.assertGreater(bonus.workout_bonus_kcal, 0)
self.assertEqual(
bonus.total_bonus_kcal,
round(bonus.steps_bonus_kcal + bonus.workout_bonus_kcal, 1),
)
self.assertGreater(bonus.scale_factor, 1.0)
def test_steps_bonus_formula(self) -> None:
kcal = steps_bonus_kcal(steps=21800, baseline_steps=9000, weight_kg=70)
self.assertEqual(kcal, round(12800 * 70 * 0.0005, 1))
def test_proportional_macro_scale(self) -> None:
base = {
"calories": 2000,
"protein_g": 100,
"fat_g": 50,
"carbs_g": 200,
"water_ml": 2500,
}
effective, targets_base = scale_targets(base, 500)
self.assertEqual(effective["calories"], 2500)
self.assertEqual(targets_base, base)
self.assertEqual(effective["water_ml"], 2500)
ratio = effective["calories"] / base["calories"]
self.assertAlmostEqual(effective["protein_g"] / base["protein_g"], ratio, places=1)
self.assertAlmostEqual(effective["fat_g"] / base["fat_g"], ratio, places=1)
self.assertAlmostEqual(effective["carbs_g"] / base["carbs_g"], ratio, places=1)
def test_floor_at_base_when_no_activity(self) -> None:
effective, _ = scale_targets(
{
"calories": 2045,
"protein_g": 156,
"fat_g": 57,
"carbs_g": 227,
"water_ml": 2900,
},
0,
)
self.assertEqual(effective["calories"], 2045)
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
import pytest
from app.fitness.body_composition import (
compute_body_composition,
ffmi,
lean_body_mass,
navy_body_fat_pct,
whr,
)
def test_navy_male_reasonable_range():
bf = navy_body_fat_pct(
sex="male",
height_cm=180,
neck_cm=38,
waist_cm=84,
)
assert bf is not None
assert 5 <= bf <= 35
def test_navy_female_requires_hip():
assert navy_body_fat_pct(
sex="female",
height_cm=165,
neck_cm=34,
waist_cm=72,
hip_cm=None,
) is None
bf = navy_body_fat_pct(
sex="female",
height_cm=165,
neck_cm=34,
waist_cm=72,
hip_cm=98,
)
assert bf is not None
assert 10 <= bf <= 45
def test_navy_invalid_waist_neck():
assert navy_body_fat_pct(
sex="male",
height_cm=180,
neck_cm=40,
waist_cm=39,
) is None
def test_whr():
assert whr(80, 100) == 0.8
def test_lean_body_mass_and_ffmi():
lbm = lean_body_mass(80, 20)
assert lbm == 64.0
score = ffmi(80, 180, 20)
assert score is not None
assert 15 <= score <= 25
def test_compute_manual_body_fat():
result = compute_body_composition(
sex="male",
height_cm=180,
weight_kg=80,
body_fat_pct=18,
waist_cm=84,
hip_cm=100,
)
assert result["body_fat_pct"] == 18.0
assert result["body_fat_method"] == "manual"
assert result["whr"] == 0.84
assert result["lbm_kg"] == 65.6
assert result["ffmi"] is not None
def test_compute_navy_auto():
result = compute_body_composition(
sex="male",
height_cm=180,
weight_kg=82,
neck_cm=38,
waist_cm=84,
)
assert result["body_fat_pct"] is not None
assert result["body_fat_method"] == "navy"
assert result["lbm_kg"] is not None
def test_compute_female_warning_without_hip():
result = compute_body_composition(
sex="female",
height_cm=165,
weight_kg=60,
neck_cm=34,
waist_cm=72,
)
assert result["body_fat_pct"] is None
assert any("бёдер" in w for w in result["warnings"])
+154
View File
@@ -0,0 +1,154 @@
import os
import tempfile
import uuid
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.auth.tokens import hash_token
from app.db.base import Base, get_db
from app.db.models import CharacterCard, ChatSession, MemoryFact, ShoppingList, User
@pytest.fixture()
def client():
db_path = Path(tempfile.gettempdir()) / f"test_multi_{uuid.uuid4().hex}.db"
os.environ["DATABASE_URL"] = f"sqlite:///{db_path.as_posix()}"
os.environ["DEFAULT_API_TOKEN"] = "unused-in-tests"
os.environ["AUTH_REQUIRED"] = "true"
os.environ["RAG_ENABLED"] = "false"
from app.config import get_settings
get_settings.cache_clear()
from app.main import create_app
engine = create_engine(
f"sqlite:///{db_path.as_posix()}",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
token_a = "token-user-a"
token_b = "token-user-b"
db = TestingSessionLocal()
user_a = User(
username="alice",
display_name="Alice",
api_token_hash=hash_token(token_a),
is_active=True,
)
user_b = User(
username="bob",
display_name="Bob",
api_token_hash=hash_token(token_b),
is_active=True,
)
db.add_all([user_a, user_b])
db.commit()
db.refresh(user_a)
db.refresh(user_b)
db.add(ChatSession(user_id=user_a.id, title="Alice chat"))
db.add(ChatSession(user_id=user_b.id, title="Bob chat"))
db.add(ShoppingList(user_id=user_a.id, name="groceries"))
db.add(ShoppingList(user_id=user_b.id, name="groceries"))
db.add(
CharacterCard(
user_id=user_a.id,
card_json='{"spec":"chara_card_v2","spec_version":"2.0","data":{"name":"A","rp_persona_id":"persona-a"}}',
)
)
db.add(
CharacterCard(
user_id=user_b.id,
card_json='{"spec":"chara_card_v2","spec_version":"2.0","data":{"name":"B","rp_persona_id":"persona-b"}}',
)
)
db.add(
MemoryFact(
user_id=user_a.id,
category="person",
content="Секрет только для owner",
source="test",
)
)
db.commit()
db.close()
app = create_app()
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
test_client.tokens = {"a": token_a, "b": token_b}
yield test_client
app.dependency_overrides.clear()
get_settings.cache_clear()
try:
db_path.unlink(missing_ok=True)
except OSError:
pass
def _headers(client: TestClient, who: str) -> dict[str, str]:
return {"Authorization": f"Bearer {client.tokens[who]}"}
def test_chat_sessions_isolated(client: TestClient):
res_a = client.get("/api/v1/chat/sessions", headers=_headers(client, "a"))
res_b = client.get("/api/v1/chat/sessions", headers=_headers(client, "b"))
assert res_a.status_code == 200
assert res_b.status_code == 200
titles_a = {s["title"] for s in res_a.json()}
titles_b = {s["title"] for s in res_b.json()}
assert titles_a == {"Alice chat"}
assert titles_b == {"Bob chat"}
def test_character_cards_isolated(client: TestClient):
res_a = client.get("/api/v1/character", headers=_headers(client, "a"))
res_b = client.get("/api/v1/character", headers=_headers(client, "b"))
assert res_a.json()["data"]["rp_persona_id"] == "persona-a"
assert res_b.json()["data"]["rp_persona_id"] == "persona-b"
def test_shopping_same_name_different_users(client: TestClient):
res_a = client.get("/api/v1/shopping", headers=_headers(client, "a"))
res_b = client.get("/api/v1/shopping", headers=_headers(client, "b"))
assert res_a.status_code == 200
assert res_b.status_code == 200
assert len(res_a.json()["lists"]) == 1
assert len(res_b.json()["lists"]) == 1
def test_missing_token_unauthorized(client: TestClient):
res = client.get("/api/v1/chat/sessions")
assert res.status_code == 401
def test_memory_facts_isolated(client: TestClient):
res_a = client.get("/api/v1/memory", headers=_headers(client, "a"))
res_b = client.get("/api/v1/memory", headers=_headers(client, "b"))
assert res_a.status_code == 200
assert res_b.status_code == 200
facts_a = res_a.json().get("facts") or []
facts_b = res_b.json().get("facts") or []
assert any("Секрет только для owner" in f.get("content", "") for f in facts_a)
assert not any("Секрет только для owner" in f.get("content", "") for f in facts_b)
assert res_b.json().get("total_facts", 0) == 0
+39 -22
View File
@@ -1,22 +1,39 @@
services:
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
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:
build: ./backend
ports:
- "${BACKEND_PORT:-8080}:${BACKEND_INTERNAL_PORT:-8080}"
env_file: .env
environment:
QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333}
depends_on:
- qdrant
volumes:
- ./data:/app/data
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_API_URL: ""
VITE_API_TOKEN: ${VITE_API_TOKEN:-}
ports:
- "${FRONTEND_PORT:-3080}:${FRONTEND_INTERNAL_PORT:-80}"
depends_on:
- backend
restart: unless-stopped
volumes:
qdrant_data:
+22
View File
@@ -0,0 +1,22 @@
services:
backend:
build: ./backend
ports:
- "${BACKEND_PORT:-8080}:${BACKEND_INTERNAL_PORT:-8080}"
env_file: .env
volumes:
- ./data:/app/data
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_API_URL: ""
ports:
- "${FRONTEND_PORT:-3080}:${FRONTEND_INTERNAL_PORT:-80}"
depends_on:
- backend
restart: unless-stopped
+3
View File
@@ -5,6 +5,9 @@ WORKDIR /app
ARG VITE_API_URL=
ENV VITE_API_URL=$VITE_API_URL
ARG VITE_API_TOKEN=
ENV VITE_API_TOKEN=$VITE_API_TOKEN
COPY package.json ./
RUN npm install
+25 -24
View File
@@ -1,24 +1,25 @@
{
"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"
}
}
{
"name": "home-assistant-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "node -e \"try{require('fs').unlinkSync('src/pages/Chat.old.tsx')}catch(e){}\" && tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-virtual": "^3.14.2",
"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"
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "home-assistant-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+134 -81
View File
@@ -1,81 +1,134 @@
.app {
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;
}
}
.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-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 {
position: relative;
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;
}
}
.app-main-chat {
overflow: hidden;
}
.route-panel {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
.route-panel-active {
visibility: visible;
pointer-events: auto;
}
.app-main:not(.app-main-chat) > .route-panel-active {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
+81
View File
@@ -0,0 +1,81 @@
.app {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #2a2f3a;
background: #151922;
}
.app-header h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.app-header nav {
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header nav a {
padding: 0.45rem 0.9rem;
border-radius: 8px;
color: #a8b0bd;
}
.app-header nav a.active {
background: #2b3445;
color: #fff;
}
.app-main {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
@media (max-width: 768px) {
.app-header {
padding: 0.55rem 0.75rem;
gap: 0.5rem;
flex-shrink: 0;
}
.app-header h1 {
display: none;
}
.app-header nav {
flex: 1;
overflow-x: auto;
flex-wrap: nowrap;
gap: 0.35rem;
padding-bottom: 0.1rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.app-header nav::-webkit-scrollbar {
display: none;
}
.app-header nav a {
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
white-space: nowrap;
flex-shrink: 0;
}
}

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