smart tdee

This commit is contained in:
2026-06-16 04:38:23 +00:00
parent f2e98942ff
commit a3f01cd850
56 changed files with 2519 additions and 591 deletions
+102 -8
View File
@@ -1,6 +1,7 @@
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
@@ -11,6 +12,7 @@ from app.api.schemas import (
SessionDetailOut,
SessionOut,
)
from app.auth.deps import get_current_user
from app.chat.generation import (
GenerationBusyError,
get_active_handle,
@@ -19,12 +21,18 @@ from app.chat.generation import (
subscribe_generation,
)
from app.chat.service import ChatService
from app.auth.deps import get_current_user
from app.config import get_settings
from app.db.base import get_db
from app.db.models import User
from app.vision import VisionService, format_user_messages, vision_debug_payloads
from app.vision.analyze import VisionUnavailableError
from app.vision.preprocess import prepare_image
from app.vision.storage import format_upload_images_markdown, save_upload
router = APIRouter()
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
@router.post("/sessions", response_model=SessionOut)
def create_session(payload: SessionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionOut:
@@ -108,11 +116,95 @@ def delete_session(session_id: int, db: Session = Depends(get_db), user: User =
return {"ok": True}
def _collect_form_uploads(form) -> list:
uploads: list = []
seen_ids: set[int] = set()
def _append(item) -> None:
if item is None or not hasattr(item, "read"):
return
item_id = id(item)
if item_id in seen_ids:
return
seen_ids.add(item_id)
uploads.append(item)
if hasattr(form, "getlist"):
for item in form.getlist("images"):
_append(item)
single = form.get("image")
_append(single)
return uploads
async def _analyze_upload(raw: bytes, *, caption: str, user_id: int):
prepared = prepare_image(raw)
filename = save_upload(prepared, user_id=user_id)
result = await VisionService().analyze_prepared(prepared, user_hint=caption)
return result, filename
async def _parse_message_request(
request: Request,
*,
user_id: int,
) -> tuple[str, dict | None]:
content_type = (request.headers.get("content-type") or "").lower()
if "multipart/form-data" not in content_type:
try:
body = await request.json()
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="Invalid JSON body") from exc
payload = MessageCreate.model_validate(body)
return payload.content, None
form = await request.form()
caption = str(form.get("content") or "").strip()
uploads = _collect_form_uploads(form)
if not uploads:
raise HTTPException(status_code=400, detail="Field 'images' or 'image' is required for multipart upload")
max_images = max(1, int(get_settings().vision_max_images))
if len(uploads) > max_images:
raise HTTPException(
status_code=400,
detail=f"Too many images (max {max_images})",
)
raw_images: list[bytes] = []
for upload in uploads:
raw = await upload.read()
if not raw:
raise HTTPException(status_code=400, detail="Empty image file")
mime = getattr(upload, "content_type", None) or "application/octet-stream"
if mime not in ALLOWED_IMAGE_TYPES:
raise HTTPException(status_code=400, detail=f"Unsupported image type: {mime}")
raw_images.append(raw)
try:
analyzed = await asyncio.gather(
*(_analyze_upload(raw, caption=caption, user_id=user_id) for raw in raw_images)
)
except VisionUnavailableError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
results = [item[0] for item in analyzed]
filenames = [item[1] for item in analyzed]
debug = vision_debug_payloads(results)
vision_text = format_user_messages(caption, results)
images_md = format_upload_images_markdown(user_id, filenames)
user_text = f"{images_md}\n\n{vision_text}" if images_md else vision_text
if not user_text.strip():
raise HTTPException(status_code=400, detail="Could not build message from image")
return user_text, debug
@router.post("/sessions/{session_id}/messages")
async def send_message(
session_id: int,
payload: MessageCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
request: Request,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StreamingResponse:
service = ChatService(db, user.id)
if not service.get_session(session_id):
@@ -121,16 +213,19 @@ async def send_message(
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)
user_text, vision_debug = await _parse_message_request(request, user_id=user.id)
service.save_user_message(session_id, user_text)
try:
handle = await start_generation(session_id, user.id, payload.content)
handle = await start_generation(session_id, user.id, user_text)
except GenerationBusyError:
raise HTTPException(status_code=409, detail="Generation already in progress") from None
async def event_stream():
try:
if vision_debug:
yield ChatService._sse("vision", vision_debug)
async for chunk in subscribe_generation(handle):
yield chunk
except asyncio.CancelledError:
@@ -155,4 +250,3 @@ def context_preview(
) -> dict:
service = ChatService(db, user.id)
return service.context_preview(session_id, query=query)
+3 -4
View File
@@ -21,12 +21,9 @@ class ProfileUpdate(BaseModel):
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
neat_base_kcal: float | None = Field(default=None, ge=200, le=300)
class MealCreate(BaseModel):
@@ -254,6 +251,8 @@ async def create_workout(
active_calories=structured.get("active_calories"),
total_calories=structured.get("total_calories"),
steps=structured.get("steps"),
activity_type=structured.get("activity_type"),
met=structured.get("met"),
day=day,
days_ago=payload.days_ago,
logged_at=payload.logged_at,
+4 -2
View File
@@ -48,7 +48,9 @@ def homelab_status() -> dict:
@router.get("/weather")
def weather_dashboard(
hours_ahead: int = 12,
days_ahead: int = 7,
_: User = Depends(get_current_user),
) -> dict:
hours = max(1, min(int(hours_ahead), 48))
return build_weather_dashboard(hours_ahead=hours)
hours = max(1, min(int(hours_ahead), 168))
days = max(1, min(int(days_ahead), 16))
return build_weather_dashboard(hours_ahead=hours, days_ahead=days)
+22 -1
View File
@@ -1,9 +1,11 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from app.auth.deps import get_current_user
from app.config import get_settings
from app.db.models import User
router = APIRouter(prefix="/media", tags=["media"])
@@ -19,3 +21,22 @@ def get_generated_image(filename: str) -> FileResponse:
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(path, media_type="image/png")
@router.get("/uploads/{user_id}/{filename}")
def get_upload_image(
user_id: int,
filename: str,
user: User = Depends(get_current_user),
) -> FileResponse:
if user.id != user_id:
raise HTTPException(status_code=403, detail="Forbidden")
if ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=400, detail="Invalid filename")
settings = get_settings()
path = Path(settings.uploads_dir) / str(user_id) / filename
if not path.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(path, media_type="image/jpeg")
+1
View File
@@ -15,6 +15,7 @@ router = APIRouter()
class SettingsPatch(BaseModel):
openrouter_model: str | None = None
memory_extract_model: str | None = None
openrouter_vision_model: str | None = None
openrouter_reasoning_effort: str | None = None
rag_enabled: bool | None = None
rag_top_k: int | None = Field(default=None, ge=1, le=50)