innercontext/backend/innercontext/api/routines.py
2026-03-01 17:27:07 +01:00

598 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
from datetime import date, timedelta
from typing import Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
from google.genai import types as genai_types
from pydantic import BaseModel as PydanticBase
from sqlmodel import Session, SQLModel, col, select
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.llm import get_gemini_client
from innercontext.models import (
GroomingSchedule,
Product,
Routine,
RoutineStep,
SkinConditionSnapshot,
)
from innercontext.models.enums import GroomingAction, PartOfDay
router = APIRouter()
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class RoutineCreate(SQLModel):
routine_date: date
part_of_day: PartOfDay
notes: Optional[str] = None
class RoutineUpdate(SQLModel):
routine_date: Optional[date] = None
part_of_day: Optional[PartOfDay] = None
notes: Optional[str] = None
class RoutineStepCreate(SQLModel):
product_id: Optional[UUID] = None
order_index: int
action_type: Optional[GroomingAction] = None
action_notes: Optional[str] = None
dose: Optional[str] = None
region: Optional[str] = None
class RoutineStepUpdate(SQLModel):
product_id: Optional[UUID] = None
order_index: Optional[int] = None
action_type: Optional[GroomingAction] = None
action_notes: Optional[str] = None
dose: Optional[str] = None
region: Optional[str] = None
class GroomingScheduleCreate(SQLModel):
day_of_week: int
action: GroomingAction
notes: Optional[str] = None
class GroomingScheduleUpdate(SQLModel):
day_of_week: Optional[int] = None
action: Optional[GroomingAction] = None
notes: Optional[str] = None
class SuggestedStep(SQLModel):
product_id: Optional[UUID] = None
action_type: Optional[GroomingAction] = None
action_notes: Optional[str] = None
dose: Optional[str] = None
region: Optional[str] = None
class SuggestRoutineRequest(SQLModel):
routine_date: date
part_of_day: PartOfDay
notes: Optional[str] = None
class RoutineSuggestion(SQLModel):
steps: list[SuggestedStep]
reasoning: str
class SuggestBatchRequest(SQLModel):
from_date: date
to_date: date
notes: Optional[str] = None
class DayPlan(SQLModel):
date: date
am_steps: list[SuggestedStep]
pm_steps: list[SuggestedStep]
reasoning: str
class BatchSuggestion(SQLModel):
days: list[DayPlan]
overall_reasoning: str
# ---------------------------------------------------------------------------
# Pydantic schemas for Gemini structured output
# ---------------------------------------------------------------------------
class _StepOut(PydanticBase):
product_id: Optional[str] = None
action_type: Optional[GroomingAction] = None
dose: Optional[str] = None
region: Optional[str] = None
action_notes: Optional[str] = None
class _SuggestionOut(PydanticBase):
steps: list[_StepOut]
reasoning: str
class _DayPlanOut(PydanticBase):
date: str
am_steps: list[_StepOut]
pm_steps: list[_StepOut]
reasoning: str
class _BatchOut(PydanticBase):
days: list[_DayPlanOut]
overall_reasoning: str
# ---------------------------------------------------------------------------
# Prompt helpers
# ---------------------------------------------------------------------------
_DAY_NAMES = [
"poniedziałek",
"wtorek",
"środa",
"czwartek",
"piątek",
"sobota",
"niedziela",
]
def _ev(v: object) -> str:
return (
v.value
if v is not None and hasattr(v, "value")
else str(v) if v is not None else ""
)
def _build_skin_context(session: Session) -> str:
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).first()
if snapshot is None:
return "STAN SKÓRY: brak danych\n"
ev = _ev
return (
f"STAN SKÓRY (snapshot z {snapshot.snapshot_date}):\n"
f" Ogólny stan: {ev(snapshot.overall_state)}\n"
f" Nawilżenie: {snapshot.hydration_level}/5\n"
f" Bariera: {ev(snapshot.barrier_state)}\n"
f" Aktywne problemy: {', '.join(ev(c) for c in (snapshot.active_concerns or []))}\n"
f" Priorytety: {', '.join(snapshot.priorities or [])}\n"
f" Uwagi: {snapshot.notes or 'brak'}\n"
)
def _build_grooming_context(
session: Session, weekdays: Optional[list[int]] = None
) -> str:
entries = session.exec(
select(GroomingSchedule).order_by(GroomingSchedule.day_of_week)
).all()
if not entries:
return "HARMONOGRAM PIELĘGNACJI: brak\n"
lines = ["HARMONOGRAM PIELĘGNACJI:"]
for e in entries:
if weekdays is not None and e.day_of_week not in weekdays:
continue
day_name = (
_DAY_NAMES[e.day_of_week] if 0 <= e.day_of_week <= 6 else str(e.day_of_week)
)
lines.append(
f" {day_name}: {_ev(e.action)}" + (f" ({e.notes})" if e.notes else "")
)
if len(lines) == 1:
lines.append(" (brak wpisów dla podanych dni)")
return "\n".join(lines) + "\n"
def _build_recent_history(session: Session) -> str:
cutoff = date.today() - timedelta(days=7)
routines = session.exec(
select(Routine)
.where(Routine.routine_date >= cutoff)
.order_by(col(Routine.routine_date).desc())
).all()
if not routines:
return "OSTATNIE RUTYNY (7 dni): brak\n"
lines = ["OSTATNIE RUTYNY (7 dni):"]
for r in routines:
steps = session.exec(
select(RoutineStep)
.where(RoutineStep.routine_id == r.id)
.order_by(RoutineStep.order_index)
).all()
step_names = []
for s in steps:
if s.product_id:
p = session.get(Product, s.product_id)
step_names.append(p.name if p else str(s.product_id))
elif s.action_type:
step_names.append(_ev(s.action_type))
lines.append(
f" {r.routine_date} {_ev(r.part_of_day).upper()}: {', '.join(step_names)}"
)
return "\n".join(lines) + "\n"
def _build_products_context(session: Session, time_filter: Optional[str] = None) -> str:
stmt = (
select(Product)
.where(Product.is_medication == False) # noqa: E712
.where(Product.is_tool == False) # noqa: E712
)
products = session.exec(stmt).all()
lines = ["DOSTĘPNE PRODUKTY:"]
for p in products:
if time_filter and _ev(p.recommended_time) not in (time_filter, "both"):
continue
ctx = p.to_llm_context()
entry = (
f" - id={ctx['id']} name=\"{ctx['name']}\" brand=\"{ctx['brand']}\""
f" category={ctx.get('category', '')} recommended_time={ctx.get('recommended_time', '')}"
f" targets={ctx.get('targets', [])}"
)
profile = ctx.get("product_effect_profile", {})
if profile:
notable = {k: v for k, v in profile.items() if v and v > 0}
if notable:
entry += f" effects={notable}"
if ctx.get("incompatible_with"):
entry += f" incompatible_with={ctx['incompatible_with']}"
if ctx.get("context_rules"):
entry += f" context_rules={ctx['context_rules']}"
if ctx.get("min_interval_hours"):
entry += f" min_interval_hours={ctx['min_interval_hours']}"
if ctx.get("max_frequency_per_week"):
entry += f" max_frequency_per_week={ctx['max_frequency_per_week']}"
lines.append(entry)
return "\n".join(lines) + "\n"
_RULES = """\
ZASADY:
- Kolejność warstw: cleanser → toner → essence → serum → moisturizer → [SPF dla AM]
- Respektuj incompatible_with (scope: same_step / same_day / same_period)
- Respektuj context_rules (safe_after_shaving, safe_after_acids itp.)
- Respektuj min_interval_hours i max_frequency_per_week
- 47 kroków na rutynę
- product_id musi być UUID produktu z listy lub null dla czynności pielęgnacyjnych
- action_type: tylko shaving_razor | shaving_oneblade | dermarolling (lub null)
- Nie używaj retinoidów i kwasów w tej samej rutynie
- W AM zawsze uwzględnij SPF jeśli dostępny
"""
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Routine routes
# ---------------------------------------------------------------------------
@router.get("", response_model=list[Routine])
def list_routines(
from_date: Optional[date] = None,
to_date: Optional[date] = None,
part_of_day: Optional[PartOfDay] = None,
session: Session = Depends(get_session),
):
stmt = select(Routine)
if from_date is not None:
stmt = stmt.where(Routine.routine_date >= from_date)
if to_date is not None:
stmt = stmt.where(Routine.routine_date <= to_date)
if part_of_day is not None:
stmt = stmt.where(Routine.part_of_day == part_of_day)
return session.exec(stmt).all()
@router.post("", response_model=Routine, status_code=201)
def create_routine(data: RoutineCreate, session: Session = Depends(get_session)):
routine = Routine(id=uuid4(), **data.model_dump())
session.add(routine)
session.commit()
session.refresh(routine)
return routine
# ---------------------------------------------------------------------------
# AI suggestion endpoints (must appear BEFORE /{routine_id})
# ---------------------------------------------------------------------------
@router.post("/suggest", response_model=RoutineSuggestion)
def suggest_routine(
data: SuggestRoutineRequest,
session: Session = Depends(get_session),
):
client, model = get_gemini_client()
weekday = data.routine_date.weekday()
skin_ctx = _build_skin_context(session)
grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
history_ctx = _build_recent_history(session)
products_ctx = _build_products_context(session, time_filter=data.part_of_day.value)
notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else ""
day_name = _DAY_NAMES[weekday]
prompt = (
f"Zaproponuj rutynę pielęgnacyjną {data.part_of_day.value.upper()} "
f"na {data.routine_date} ({day_name}).\n\n"
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{_RULES}{notes_line}"
"\nZwróć JSON zgodny ze schematem."
)
try:
response = client.models.generate_content(
model=model,
contents=prompt,
config=genai_types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=_SuggestionOut,
max_output_tokens=4096,
temperature=0.4,
),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
raw = response.text
if not raw:
raise HTTPException(status_code=502, detail="LLM returned an empty response")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
steps = [
SuggestedStep(
product_id=UUID(s["product_id"]) if s.get("product_id") else None,
action_type=s.get("action_type") or None,
action_notes=s.get("action_notes"),
dose=s.get("dose"),
region=s.get("region"),
)
for s in parsed.get("steps", [])
]
return RoutineSuggestion(steps=steps, reasoning=parsed.get("reasoning", ""))
@router.post("/suggest-batch", response_model=BatchSuggestion)
def suggest_batch(
data: SuggestBatchRequest,
session: Session = Depends(get_session),
):
delta = (data.to_date - data.from_date).days + 1
if delta > 14:
raise HTTPException(
status_code=400, detail="Date range must not exceed 14 days."
)
if data.from_date > data.to_date:
raise HTTPException(status_code=400, detail="from_date must be <= to_date.")
client, model = get_gemini_client()
weekdays = list(
{(data.from_date + timedelta(days=i)).weekday() for i in range(delta)}
)
skin_ctx = _build_skin_context(session)
grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
history_ctx = _build_recent_history(session)
products_ctx = _build_products_context(session)
date_range_lines = []
for i in range(delta):
d = data.from_date + timedelta(days=i)
date_range_lines.append(f" {d} ({_DAY_NAMES[d.weekday()]})")
dates_str = "\n".join(date_range_lines)
notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else ""
prompt = (
f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n"
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{_RULES}{notes_line}"
"\nDodatkowe zasady dla planu wielodniowego:\n"
" - Retinol/retinoidy: przestrzegaj max_frequency_per_week i min_interval_hours między użyciami\n"
" - Nie stosuj kwasów i retinoidów tego samego dnia\n"
" - Uwzględnij safe_after_shaving dla dni golenia\n"
" - Zmienność aktywnych składników przez dni dla lepszej tolerancji\n"
" - Pole date w każdym dniu MUSI być w formacie YYYY-MM-DD\n"
"\nZwróć JSON zgodny ze schematem."
)
try:
response = client.models.generate_content(
model=model,
contents=prompt,
config=genai_types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=_BatchOut,
max_output_tokens=8192,
temperature=0.4,
),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
raw = response.text
if not raw:
raise HTTPException(status_code=502, detail="LLM returned an empty response")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
def _parse_steps(raw_steps: list) -> list[SuggestedStep]:
result = []
for s in raw_steps:
result.append(
SuggestedStep(
product_id=UUID(s["product_id"]) if s.get("product_id") else None,
action_type=s.get("action_type") or None,
action_notes=s.get("action_notes"),
dose=s.get("dose"),
region=s.get("region"),
)
)
return result
days = []
for day_raw in parsed.get("days", []):
try:
day_date = date.fromisoformat(day_raw["date"])
except (KeyError, ValueError):
continue
days.append(
DayPlan(
date=day_date,
am_steps=_parse_steps(day_raw.get("am_steps", [])),
pm_steps=_parse_steps(day_raw.get("pm_steps", [])),
reasoning=day_raw.get("reasoning", ""),
)
)
return BatchSuggestion(
days=days, overall_reasoning=parsed.get("overall_reasoning", "")
)
# Grooming-schedule GET must appear before /{routine_id} to avoid being shadowed
@router.get("/grooming-schedule", response_model=list[GroomingSchedule])
def list_grooming_schedule(session: Session = Depends(get_session)):
return session.exec(select(GroomingSchedule)).all()
@router.get("/{routine_id}")
def get_routine(routine_id: UUID, session: Session = Depends(get_session)):
routine = get_or_404(session, Routine, routine_id)
steps = session.exec(
select(RoutineStep).where(RoutineStep.routine_id == routine_id)
).all()
data = routine.model_dump(mode="json")
data["steps"] = [step.model_dump(mode="json") for step in steps]
return data
@router.patch("/{routine_id}", response_model=Routine)
def update_routine(
routine_id: UUID,
data: RoutineUpdate,
session: Session = Depends(get_session),
):
routine = get_or_404(session, Routine, routine_id)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(routine, key, value)
session.add(routine)
session.commit()
session.refresh(routine)
return routine
@router.delete("/{routine_id}", status_code=204)
def delete_routine(routine_id: UUID, session: Session = Depends(get_session)):
routine = get_or_404(session, Routine, routine_id)
session.delete(routine)
session.commit()
# ---------------------------------------------------------------------------
# RoutineStep sub-routes
# ---------------------------------------------------------------------------
@router.post("/{routine_id}/steps", response_model=RoutineStep, status_code=201)
def add_step(
routine_id: UUID,
data: RoutineStepCreate,
session: Session = Depends(get_session),
):
get_or_404(session, Routine, routine_id)
step = RoutineStep(id=uuid4(), routine_id=routine_id, **data.model_dump())
session.add(step)
session.commit()
session.refresh(step)
return step
@router.patch("/steps/{step_id}", response_model=RoutineStep)
def update_step(
step_id: UUID,
data: RoutineStepUpdate,
session: Session = Depends(get_session),
):
step = get_or_404(session, RoutineStep, step_id)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(step, key, value)
session.add(step)
session.commit()
session.refresh(step)
return step
@router.delete("/steps/{step_id}", status_code=204)
def delete_step(step_id: UUID, session: Session = Depends(get_session)):
step = get_or_404(session, RoutineStep, step_id)
session.delete(step)
session.commit()
# ---------------------------------------------------------------------------
# GroomingSchedule routes
# ---------------------------------------------------------------------------
@router.post("/grooming-schedule", response_model=GroomingSchedule, status_code=201)
def create_grooming_schedule(
data: GroomingScheduleCreate, session: Session = Depends(get_session)
):
entry = GroomingSchedule(id=uuid4(), **data.model_dump())
session.add(entry)
session.commit()
session.refresh(entry)
return entry
@router.patch("/grooming-schedule/{entry_id}", response_model=GroomingSchedule)
def update_grooming_schedule(
entry_id: UUID,
data: GroomingScheduleUpdate,
session: Session = Depends(get_session),
):
entry = get_or_404(session, GroomingSchedule, entry_id)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(entry, key, value)
session.add(entry)
session.commit()
session.refresh(entry)
return entry
@router.delete("/grooming-schedule/{entry_id}", status_code=204)
def delete_grooming_schedule(entry_id: UUID, session: Session = Depends(get_session)):
entry = get_or_404(session, GroomingSchedule, entry_id)
session.delete(entry)
session.commit()