feat: AI-generated skincare routine suggestions (single + batch)

Add Gemini-powered endpoints and frontend pages for proposing skincare
routines based on skin state, product compatibility, grooming schedule,
and recent history.

Backend (routines.py):
- POST /routines/suggest — single AM/PM routine for a date
- POST /routines/suggest-batch — AM+PM plan for up to 14 days
- Prompt context: skin snapshot, grooming schedule, 7-day history,
  filtered product list with effects/incompatibilities/context rules
- Respects retinoid frequency limits, acid/retinoid separation,
  grooming-aware safe_after_shaving rules

Frontend:
- /routines/suggest page with tab switcher (single / batch)
- Single tab: date + AM/PM + optional notes → generate → preview → save
- Batch tab: date range + notes → collapsible day cards (AM+PM) → save all
- Loading spinner during Gemini calls; product names resolved from map
- "Zaproponuj rutynę AI" button added to routines list page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-03-01 00:34:43 +01:00
parent a3b25d5e46
commit 6e7f715ef2
6 changed files with 918 additions and 5 deletions

View file

@ -1,13 +1,17 @@
from datetime import date
import json
from datetime import date, timedelta
from typing import Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends
from sqlmodel import Session, SQLModel, select
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.models import GroomingSchedule, Routine, RoutineStep
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()
@ -60,6 +64,188 @@ class GroomingScheduleUpdate(SQLModel):
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[str] = 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).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
# ---------------------------------------------------------------------------
@ -96,6 +282,164 @@ def create_routine(data: RoutineCreate, session: Session = Depends(get_session))
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)):