598 lines
19 KiB
Python
598 lines
19 KiB
Python
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
|
||
- 4–7 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()
|