innercontext/backend/innercontext/api/routines.py
Piotr Oleszczyk c8fa80be99 fix(api): rename 'metadata' to 'response_metadata' to avoid Pydantic conflict
The field name 'metadata' conflicts with Pydantic's internal ClassVar.
Renamed to 'response_metadata' throughout:
- Backend: RoutineSuggestion, BatchSuggestion, ShoppingSuggestionResponse
- Frontend: TypeScript types and component usages

This fixes the AttributeError when setting metadata on SQLModel instances.
2026-03-06 16:16:35 +01:00

1156 lines
39 KiB
Python

import json
import logging
import math
from datetime import date, timedelta
from typing import Any, 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 Field, Session, SQLModel, col, select
from db import get_session
from innercontext.api.llm_context import (
build_products_context_summary_list,
build_user_profile_context,
)
from innercontext.api.product_llm_tools import (
PRODUCT_DETAILS_FUNCTION_DECLARATION,
)
from innercontext.api.product_llm_tools import (
_extract_requested_product_ids as _shared_extract_requested_product_ids,
)
from innercontext.api.product_llm_tools import (
build_last_used_on_by_product,
build_product_details_tool_handler,
)
from innercontext.api.utils import get_or_404
from innercontext.llm import (
call_gemini,
call_gemini_with_function_tools,
get_creative_config,
)
from innercontext.llm_safety import isolate_user_input, sanitize_user_input
from innercontext.models import (
GroomingSchedule,
Product,
ProductInventory,
Routine,
RoutineStep,
SkinConditionSnapshot,
)
from innercontext.models.ai_log import AICallLog
from innercontext.models.api_metadata import ResponseMetadata, TokenMetrics
from innercontext.models.enums import GroomingAction, PartOfDay
from innercontext.validators import BatchValidator, RoutineSuggestionValidator
from innercontext.validators.batch_validator import BatchValidationContext
from innercontext.validators.routine_validator import RoutineValidationContext
logger = logging.getLogger(__name__)
def _build_response_metadata(session: Session, log_id: Any) -> ResponseMetadata | None:
"""Build ResponseMetadata from AICallLog for Phase 3 observability."""
if not log_id:
return None
log = session.get(AICallLog, log_id)
if not log:
return None
token_metrics = None
if (
log.prompt_tokens is not None
and log.completion_tokens is not None
and log.total_tokens is not None
):
token_metrics = TokenMetrics(
prompt_tokens=log.prompt_tokens,
completion_tokens=log.completion_tokens,
thoughts_tokens=log.thoughts_tokens,
total_tokens=log.total_tokens,
)
return ResponseMetadata(
model_used=log.model,
duration_ms=log.duration_ms or 0,
reasoning_chain=log.reasoning_chain,
token_metrics=token_metrics,
)
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
region: Optional[str] = None
why_this_step: Optional[str] = None
optional: Optional[bool] = None
class SuggestRoutineRequest(SQLModel):
routine_date: date
part_of_day: PartOfDay
notes: Optional[str] = None
include_minoxidil_beard: bool = False
leaving_home: Optional[bool] = None
class RoutineSuggestionSummary(SQLModel):
primary_goal: str = ""
constraints_applied: list[str] = Field(default_factory=list)
confidence: float = 0.0
class RoutineSuggestion(SQLModel):
steps: list[SuggestedStep]
reasoning: str
summary: Optional[RoutineSuggestionSummary] = None
# Phase 3: Observability fields
validation_warnings: Optional[list[str]] = None
auto_fixes_applied: Optional[list[str]] = None
response_metadata: Optional[ResponseMetadata] = None
class SuggestBatchRequest(SQLModel):
from_date: date
to_date: date
notes: Optional[str] = None
include_minoxidil_beard: bool = False
minimize_products: Optional[bool] = 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
# Phase 3: Observability fields
validation_warnings: Optional[list[str]] = None
auto_fixes_applied: Optional[list[str]] = None
response_metadata: Optional[ResponseMetadata] = None
# ---------------------------------------------------------------------------
# Pydantic schemas for Gemini structured output
# ---------------------------------------------------------------------------
class _SingleStepOut(PydanticBase):
product_id: Optional[str] = None
action_type: Optional[GroomingAction] = None
region: Optional[str] = None
action_notes: Optional[str] = None
why_this_step: Optional[str] = None
optional: Optional[bool] = None
class _BatchStepOut(PydanticBase):
product_id: Optional[str] = None
action_type: Optional[GroomingAction] = None
region: Optional[str] = None
action_notes: Optional[str] = None
class _SummaryOut(PydanticBase):
primary_goal: str
constraints_applied: list[str]
confidence: float
class _SuggestionOut(PydanticBase):
steps: list[_SingleStepOut]
reasoning: str
summary: _SummaryOut
class _DayPlanOut(PydanticBase):
date: str
am_steps: list[_BatchStepOut]
pm_steps: list[_BatchStepOut]
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 _contains_minoxidil_text(value: Optional[str]) -> bool:
if not value:
return False
text = value.lower()
return "minoxidil" in text or "minoksydyl" in text
def _is_minoxidil_product(product: Product) -> bool:
if _contains_minoxidil_text(product.name):
return True
if _contains_minoxidil_text(product.brand):
return True
if _contains_minoxidil_text(product.line_name):
return True
if any(_contains_minoxidil_text(i) for i in (product.inci or [])):
return True
actives = product.actives or []
for a in actives:
if isinstance(a, dict):
if _contains_minoxidil_text(str(a.get("name", ""))):
return True
continue
if _contains_minoxidil_text(a.name):
return True
return False
def _ev(v: object) -> str:
if v is None:
return ""
value = getattr(v, "value", None)
if isinstance(value, str):
return value
return str(v)
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 "SKIN CONDITION: no data\n"
ev = _ev
return (
f"SKIN CONDITION (snapshot from {snapshot.snapshot_date}):\n"
f" Overall state: {ev(snapshot.overall_state)}\n"
f" Hydration: {snapshot.hydration_level}/5\n"
f" Barrier: {ev(snapshot.barrier_state)}\n"
f" Active concerns: {', '.join(ev(c) for c in (snapshot.active_concerns or []))}\n"
f" Priorities: {', '.join(snapshot.priorities or [])}\n"
f" Notes: {snapshot.notes or 'none'}\n"
)
def _build_grooming_context(
session: Session, weekdays: Optional[list[int]] = None
) -> str:
entries = session.exec(
select(GroomingSchedule).order_by(col(GroomingSchedule.day_of_week))
).all()
if not entries:
return "GROOMING SCHEDULE: none\n"
lines = ["GROOMING SCHEDULE:"]
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(" (no entries for specified days)")
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 "RECENT ROUTINES: none\n"
lines = ["RECENT ROUTINES:"]
for r in routines:
steps = session.exec(
select(RoutineStep)
.where(RoutineStep.routine_id == r.id)
.order_by(col(RoutineStep.order_index))
).all()
step_names = []
for s in steps:
if s.product_id:
p = session.get(Product, s.product_id)
if p:
short_id = str(p.id)[:8]
step_names.append(f"{_ev(p.category)} [{short_id}]")
else:
step_names.append(f"unknown [{str(s.product_id)[:8]}]")
elif s.action_type:
step_names.append(f"action: {_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 _get_available_products(
session: Session,
time_filter: Optional[str] = None,
include_minoxidil: bool = True,
) -> list[Product]:
stmt = select(Product).where(col(Product.is_tool).is_(False))
products = session.exec(stmt).all()
result: list[Product] = []
for p in products:
if p.is_medication and not _is_minoxidil_product(p):
continue
if not include_minoxidil and _is_minoxidil_product(p):
continue
if time_filter and _ev(p.recommended_time) not in (time_filter, "both"):
continue
result.append(p)
return result
def _filter_products_by_interval(
products: list[Product],
routine_date: date,
last_used_on_by_product: dict[str, date],
) -> list[Product]:
"""Remove products that haven't yet reached their min_interval_hours since last use."""
result = []
for p in products:
if p.min_interval_hours:
last_used = last_used_on_by_product.get(str(p.id))
if last_used is not None:
days_needed = math.ceil(p.min_interval_hours / 24)
if routine_date < last_used + timedelta(days=days_needed):
continue
result.append(p)
return result
def _extract_active_names(product: Product) -> list[str]:
names: list[str] = []
for a in product.actives or []:
if isinstance(a, dict):
name = str(a.get("name") or "").strip()
else:
name = str(getattr(a, "name", "") or "").strip()
if not name:
continue
if name in names:
continue
names.append(name)
if len(names) >= 12:
break
return names
def _extract_requested_product_ids(
args: dict[str, object], max_ids: int = 8
) -> list[str]:
return _shared_extract_requested_product_ids(args, max_ids=max_ids)
def _get_products_with_inventory(
session: Session, product_ids: list[UUID]
) -> set[UUID]:
"""
Return set of product IDs that have active (non-finished) inventory.
Phase 2: Used for tiered context assembly to mark products with available stock.
"""
if not product_ids:
return set()
inventory_rows = session.exec(
select(ProductInventory.product_id)
.where(col(ProductInventory.product_id).in_(product_ids))
.where(col(ProductInventory.finished_at).is_(None))
.distinct()
).all()
return set(inventory_rows)
def _expand_product_id(session: Session, short_or_full_id: str) -> UUID | None:
"""
Expand 8-char short_id to full UUID, or validate full UUID.
Translation layer between LLM world (8-char short_ids) and application world
(36-char UUIDs). LLM sees/uses short_ids for token optimization, but
validators and database use full UUIDs.
Args:
session: Database session
short_or_full_id: Either short_id ("77cbf37c") or full UUID
Returns:
Full UUID if product exists, None otherwise
"""
# Already a full UUID?
if len(short_or_full_id) == 36:
try:
uuid_obj = UUID(short_or_full_id)
# Verify it exists
product = session.get(Product, uuid_obj)
return uuid_obj if product else None
except (ValueError, TypeError):
return None
# Short ID (8 chars) - indexed lookup
if len(short_or_full_id) == 8:
product = session.exec(
select(Product).where(Product.short_id == short_or_full_id)
).first()
return product.id if product else None
# Invalid length
return None
def _build_objectives_context(include_minoxidil_beard: bool) -> str:
if include_minoxidil_beard:
return (
"USER OBJECTIVES:\n"
" - Priority: improve beard and mustache density\n"
" - If a product with minoxidil is available, include it adhering strictly to safety rules\n"
)
return ""
def _build_day_context(leaving_home: Optional[bool]) -> str:
if leaving_home is None:
return ""
val = "yes" if leaving_home else "no"
return f"DAY CONTEXT:\n Leaving home: {val}\n"
_ROUTINES_SYSTEM_PROMPT = """\
Jesteś ekspertem planowania pielęgnacji.
CEL:
Twórz realistyczne, bezpieczne i krótkie rutyny o wysokiej zgodności z danymi wejściowymi.
PRIORYTETY DECYZYJNE (od najwyższego):
1) Bezpieczeństwo (brak realnego ryzyka klinicznego)
2) Cel terapeutyczny użytkownika
3) Reguły częstotliwości i odstępów
4) Zarządzanie inwentarzem
5) Prostota
> Cel terapeutyczny oznacza maksymalizację realnego efektu klinicznego,
> nie tylko zgodność z deklarowanymi targetami produktu.
WYMAGANIA ODPOWIEDZI:
- Zwracaj wyłącznie poprawny JSON (bez markdown, bez komentarzy, bez preambuły).
- Trzymaj się dokładnie przekazanego schematu odpowiedzi.
- Nie twórz produktów spoza listy wejściowej.
- Jeśli nie da się bezpiecznie dodać kroku, pomiń go zamiast zgadywać.
ZASADY PLANOWANIA:
- Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM].
- Respektuj: context_rules, min_interval_hours, max_frequency_per_week.
- Zarządzanie inwentarzem:
- Preferuj produkty już otwarte (miękka preferencja).
- Unikaj funkcjonalnej redundancji (np. wielokrotne źródła panthenolu, ceramidów lub niacynamidu w tej samej rutynie),
chyba że istnieje wyraźne uzasadnienie terapeutyczne.
- Maksymalnie 2 serum w rutynie.
Jeśli 2: jedno jako główny aktywny bodziec, drugie wyłącznie wspierające.
- Dla mildly_compromised nie eliminuj automatycznie umiarkowanych aktywnych;
decyzję opieraj na effect_profile (irritation_risk, barrier_disruption_risk) i regułach bezpieczeństwa.
- Nie zwiększaj intensywności terapii (retinoid/kwasy) dzień po dniu,
jeśli brak wyraźnej poprawy stanu skóry lub brak wskazań klinicznych.
- Nie łącz retinoidów i kwasów w tej samej rutynie ani tego samego dnia (dla planu wielodniowego).
- W AM zawsze uwzględnij SPF, jeśli kompatybilny produkt SPF istnieje na liście.
Wybór filtra (na podstawie KONTEKST DNIA):
- "Wyjście z domu: tak" → najwyższy współczynnik dostępny w nazwie (SPF50+, SPF50, SPF30);
- "Wyjście z domu: nie" → SPF30 wystarczy; wyższy dopuszczalny jeśli brak SPF30;
- brak KONTEKST DNIA → wybierz najwyższy dostępny.
- Dla minoksydylu (jeśli celem jest zarost i produkt jest dostępny): ustaw adekwatny region
broda/wąsy i nie naruszaj ograniczeń bezpieczeństwa.
- Preferuj 4-7 kroków na pojedynczą rutynę; unikaj zbędnych duplikatów aktywnych.
- Jeśli krok to produkt: podaj poprawny UUID z listy.
- Jeśli krok to czynność pielęgnacyjna: product_id = null. Dozwolone akcje są ściśle określone w schemacie (action_type).
- Nie zwracaj "pustych" kroków: każdy krok musi mieć product_id albo action_type.
- Pole region uzupełniaj tylko gdy ma znaczenie kliniczne/praktyczne (np. broda, wąsy, okolica oczu, szyja).
Dla standardowych kroków pielęgnacji całej twarzy pozostaw region puste.
JAK ROZWIĄZYWAĆ KONFLIKTY:
- Bezpieczeństwo > wszystko.
- Jeśli MODE=travel: logistyka podróży > różnorodność terapeutyczna.
- W MODE=travel odejdź od minimalizacji produktów tylko gdy wymaga tego bezpieczeństwo
lub bez dodatkowego produktu nie da się osiągnąć głównego celu terapeutycznego.
- Jeśli MODE=standard i bezpieczeństwo jest zachowane, preferuj różnorodność terapeutyczną.
- Przy niepełnych danych wybierz wariant konserwatywny.
"""
_ROUTINES_SINGLE_EXTRA = """\
DODATKOWE WYMAGANIA DLA TRYBU JEDNEJ RUTYNY:
- Każdy krok powinien mieć zwięzłe why_this_step (maks. jedno zdanie).
- Pole optional ustawiaj na true tylko dla kroków niekrytycznych.
- Uzupełnij summary:
- primary_goal: główny cel tej rutyny,
- constraints_applied: lista kluczowych ograniczeń zastosowanych przy planowaniu,
- confidence: liczba 0-1.
"""
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Routine routes
# ---------------------------------------------------------------------------
@router.get("")
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)
routines = session.exec(stmt).all()
routine_ids = [r.id for r in routines]
steps_by_routine: dict = {}
if routine_ids:
all_steps = session.exec(
select(RoutineStep).where(col(RoutineStep.routine_id).in_(routine_ids))
).all()
for step in all_steps:
steps_by_routine.setdefault(step.routine_id, []).append(step)
result = []
for r in routines:
data = r.model_dump(mode="json")
data["steps"] = [
s.model_dump(mode="json") for s in steps_by_routine.get(r.id, [])
]
result.append(data)
return result
@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),
):
weekday = data.routine_date.weekday()
skin_ctx = _build_skin_context(session)
profile_ctx = build_user_profile_context(session, reference_date=data.routine_date)
grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
history_ctx = _build_recent_history(session)
day_ctx = _build_day_context(data.leaving_home)
available_products = _get_available_products(
session,
time_filter=data.part_of_day.value,
include_minoxidil=data.include_minoxidil_beard,
)
last_used_on_by_product = build_last_used_on_by_product(
session,
product_ids=[p.id for p in available_products],
)
available_products = _filter_products_by_interval(
available_products,
data.routine_date,
last_used_on_by_product,
)
# Phase 2: Use tiered context (summary mode for initial prompt)
products_with_inventory = _get_products_with_inventory(
session, [p.id for p in available_products]
)
products_ctx = build_products_context_summary_list(
available_products, products_with_inventory
)
objectives_ctx = _build_objectives_context(data.include_minoxidil_beard)
mode_line = "MODE: standard"
# Sanitize user notes (Phase 1: input sanitization)
notes_line = ""
if data.notes:
sanitized_notes = sanitize_user_input(data.notes, max_length=500)
isolated_notes = isolate_user_input(sanitized_notes)
notes_line = f"USER CONTEXT:\n{isolated_notes}\n"
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"{mode_line}\n"
"INPUT DATA:\n"
f"{profile_ctx}\n{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}"
"\nNARZEDZIA:\n"
"- Masz dostep do funkcji: get_product_details.\n"
"- Wywoluj narzedzia tylko, gdy potrzebujesz detali do decyzji klinicznej/bezpieczenstwa.\n"
"- Staraj sie grupowac zapytania: podawaj wszystkie potrzebne UUID w jednym wywolaniu narzedzia.\n"
"- Nie zgaduj detali skladu i zasad bezpieczenstwa; jesli potrzebujesz szczegolow, wywolaj odpowiednie narzedzie.\n"
f"{notes_line}"
f"{_ROUTINES_SINGLE_EXTRA}\n"
"Zwróć JSON zgodny ze schematem."
)
config = get_creative_config(
system_instruction=_ROUTINES_SYSTEM_PROMPT,
response_schema=_SuggestionOut,
max_output_tokens=8192,
).model_copy(
update={
"tools": [
genai_types.Tool(
function_declarations=[
PRODUCT_DETAILS_FUNCTION_DECLARATION,
],
)
],
"tool_config": genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode=genai_types.FunctionCallingConfigMode.AUTO,
)
),
}
)
function_handlers = {
"get_product_details": build_product_details_tool_handler(
available_products,
last_used_on_by_product=last_used_on_by_product,
),
}
try:
response, log_id = call_gemini_with_function_tools(
endpoint="routines/suggest",
contents=prompt,
config=config,
function_handlers=function_handlers,
user_input=prompt,
max_tool_roundtrips=3,
)
except HTTPException as exc:
if (
exc.status_code != 502
or str(exc.detail) != "Gemini requested too many function calls"
):
raise
conservative_prompt = (
f"{prompt}\n\n"
"TRYB AWARYJNY (KONSERWATYWNY):\n"
"- Osiagnieto limit wywolan narzedzi.\n"
"- Nie wywoluj narzedzi ponownie.\n"
"- Zaproponuj maksymalnie konserwatywna, bezpieczna rutyne na podstawie dostepnych juz danych,"
" preferujac lagodne produkty wspierajace bariere i fotoprotekcje.\n"
"- Gdy masz watpliwosci, pomijaj ryzykowne aktywne kroki.\n"
)
response, log_id = call_gemini(
endpoint="routines/suggest",
contents=conservative_prompt,
config=get_creative_config(
system_instruction=_ROUTINES_SYSTEM_PROMPT,
response_schema=_SuggestionOut,
max_output_tokens=8192,
),
user_input=conservative_prompt,
tool_trace={
"mode": "fallback_conservative",
"reason": "max_tool_roundtrips_exceeded",
},
)
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}")
# Translation layer: Expand short_ids (8 chars) to full UUIDs (36 chars)
steps = []
for s in parsed.get("steps", []):
product_id_str = s.get("product_id")
product_id_uuid = None
if product_id_str:
# Expand short_id or validate full UUID
product_id_uuid = _expand_product_id(session, product_id_str)
steps.append(
SuggestedStep(
product_id=product_id_uuid,
action_type=s.get("action_type") or None,
action_notes=s.get("action_notes"),
region=s.get("region"),
why_this_step=s.get("why_this_step"),
optional=s.get("optional"),
)
)
summary_raw = parsed.get("summary") or {}
confidence_raw = summary_raw.get("confidence", 0)
try:
confidence = float(confidence_raw)
except (TypeError, ValueError):
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
constraints_applied = summary_raw.get("constraints_applied") or []
if not isinstance(constraints_applied, list):
constraints_applied = []
summary = RoutineSuggestionSummary(
primary_goal=str(summary_raw.get("primary_goal") or ""),
constraints_applied=[str(x) for x in constraints_applied],
confidence=confidence,
)
# Get skin snapshot for barrier state
stmt = select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
skin_snapshot = session.exec(stmt).first()
# Build validation context
products_by_id = {p.id: p for p in available_products}
# Convert last_used_on_by_product from dict[str, date] to dict[UUID, date]
last_used_dates_by_uuid = {UUID(k): v for k, v in last_used_on_by_product.items()}
validation_context = RoutineValidationContext(
valid_product_ids=set(products_by_id.keys()),
routine_date=data.routine_date,
part_of_day=data.part_of_day.value,
leaving_home=data.leaving_home,
barrier_state=skin_snapshot.barrier_state if skin_snapshot else None,
products_by_id=products_by_id,
last_used_dates=last_used_dates_by_uuid,
just_shaved=False, # Could be enhanced with grooming context
)
# Phase 1: Validate the response
validator = RoutineSuggestionValidator()
# Build initial suggestion without metadata
suggestion = RoutineSuggestion(
steps=steps,
reasoning=parsed.get("reasoning", ""),
summary=summary,
)
validation_result = validator.validate(suggestion, validation_context)
if not validation_result.is_valid:
# Log validation errors
logger.error(
f"Routine suggestion validation failed: {validation_result.errors}"
)
# Reject the response
raise HTTPException(
status_code=502,
detail=f"Generated routine failed safety validation: {'; '.join(validation_result.errors)}",
)
# Phase 3: Add warnings, auto-fixes, and metadata to response
if validation_result.warnings:
logger.warning(f"Routine suggestion warnings: {validation_result.warnings}")
suggestion.validation_warnings = validation_result.warnings
if validation_result.auto_fixes:
suggestion.auto_fixes_applied = validation_result.auto_fixes
suggestion.response_metadata = _build_response_metadata(session, log_id)
return suggestion
@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.")
weekdays = list(
{(data.from_date + timedelta(days=i)).weekday() for i in range(delta)}
)
profile_ctx = build_user_profile_context(session, reference_date=data.from_date)
skin_ctx = _build_skin_context(session)
grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
history_ctx = _build_recent_history(session)
batch_products = _get_available_products(
session,
include_minoxidil=data.include_minoxidil_beard,
)
# Phase 2: Use tiered context (summary mode for batch planning)
products_with_inventory = _get_products_with_inventory(
session, [p.id for p in batch_products]
)
products_ctx = build_products_context_summary_list(
batch_products, products_with_inventory
)
objectives_ctx = _build_objectives_context(data.include_minoxidil_beard)
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)
# Sanitize user notes (Phase 1: input sanitization)
notes_line = ""
if data.notes:
sanitized_notes = sanitize_user_input(data.notes, max_length=500)
isolated_notes = isolate_user_input(sanitized_notes)
notes_line = f"USER CONTEXT:\n{isolated_notes}\n"
mode_line = "MODE: travel" if data.minimize_products else "MODE: standard"
minimize_line = (
"\nCONSTRAINTS (TRAVEL MODE):\n"
"- To tryb podróżny: minimalizuj liczbę unikalnych produktów w całym planie (wszystkie dni, AM+PM).\n"
"- Preferuj reużycie tych samych produktów między dniami, jeśli bezpieczeństwo i główny cel terapeutyczny są zachowane.\n"
"- Dodaj nowy produkt tylko gdy to konieczne dla bezpieczeństwa albo realizacji głównego celu terapeutycznego.\n"
if data.minimize_products
else ""
)
prompt = (
f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n{mode_line}\n"
"INPUT DATA:\n"
f"{profile_ctx}\n{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{objectives_ctx}"
f"{notes_line}{minimize_line}"
"\nZwróć JSON zgodny ze schematem."
)
response, log_id = call_gemini(
endpoint="routines/suggest-batch",
contents=prompt,
config=get_creative_config(
system_instruction=_ROUTINES_SYSTEM_PROMPT,
response_schema=_BatchOut,
max_output_tokens=8192,
),
user_input=prompt,
)
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]:
"""Parse steps and expand short_ids to full UUIDs."""
result = []
for s in raw_steps:
product_id_str = s.get("product_id")
product_id_uuid = None
if product_id_str:
# Translation layer: expand short_id to full UUID
product_id_uuid = _expand_product_id(session, product_id_str)
result.append(
SuggestedStep(
product_id=product_id_uuid,
action_type=s.get("action_type") or None,
action_notes=s.get("action_notes"),
region=s.get("region"),
why_this_step=s.get("why_this_step"),
optional=s.get("optional"),
)
)
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", ""),
)
)
# Get skin snapshot for barrier state
stmt = select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
skin_snapshot = session.exec(stmt).first()
# Build validation context
products_by_id = {p.id: p for p in batch_products}
# Get last_used dates (empty for batch - will track within batch period)
last_used_on_by_product = build_last_used_on_by_product(
session,
product_ids=[p.id for p in batch_products],
)
last_used_dates_by_uuid = {UUID(k): v for k, v in last_used_on_by_product.items()}
batch_context = BatchValidationContext(
valid_product_ids=set(products_by_id.keys()),
barrier_state=skin_snapshot.barrier_state if skin_snapshot else None,
products_by_id=products_by_id,
last_used_dates=last_used_dates_by_uuid,
)
# Phase 1: Validate the batch response
batch_validator = BatchValidator()
# Build initial batch suggestion without metadata
batch_suggestion = BatchSuggestion(
days=days, overall_reasoning=parsed.get("overall_reasoning", "")
)
validation_result = batch_validator.validate(batch_suggestion, batch_context)
if not validation_result.is_valid:
# Log validation errors
logger.error(f"Batch routine validation failed: {validation_result.errors}")
# Reject the response
raise HTTPException(
status_code=502,
detail=f"Generated batch plan failed safety validation: {'; '.join(validation_result.errors)}",
)
# Phase 3: Add warnings, auto-fixes, and metadata to response
if validation_result.warnings:
logger.warning(f"Batch routine warnings: {validation_result.warnings}")
batch_suggestion.validation_warnings = validation_result.warnings
if validation_result.auto_fixes:
batch_suggestion.auto_fixes_applied = validation_result.auto_fixes
batch_suggestion.response_metadata = _build_response_metadata(session, log_id)
return batch_suggestion
# 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()