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:
parent
a3b25d5e46
commit
6e7f715ef2
6 changed files with 918 additions and 5 deletions
|
|
@ -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
|
||||
- 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -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)):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue