feat(routines): enrich single AI suggestions with concise context

This commit is contained in:
Piotr Oleszczyk 2026-03-04 01:22:57 +01:00
parent 083cd055fb
commit 820d58ea37
5 changed files with 113 additions and 8 deletions

View file

@ -5,7 +5,7 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel as PydanticBase
from sqlmodel import Session, SQLModel, col, select
from sqlmodel import Field, Session, SQLModel, col, select
from db import get_session
from innercontext.api.utils import get_or_404
@ -76,6 +76,8 @@ class SuggestedStep(SQLModel):
action_notes: Optional[str] = None
dose: Optional[str] = None
region: Optional[str] = None
why_this_step: Optional[str] = None
optional: Optional[bool] = None
class SuggestRoutineRequest(SQLModel):
@ -86,9 +88,16 @@ class SuggestRoutineRequest(SQLModel):
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
class SuggestBatchRequest(SQLModel):
@ -116,7 +125,17 @@ class BatchSuggestion(SQLModel):
# ---------------------------------------------------------------------------
class _StepOut(PydanticBase):
class _SingleStepOut(PydanticBase):
product_id: Optional[str] = None
action_type: Optional[GroomingAction] = None
dose: Optional[str] = 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
dose: Optional[str] = None
@ -124,15 +143,22 @@ class _StepOut(PydanticBase):
action_notes: Optional[str] = None
class _SummaryOut(PydanticBase):
primary_goal: str
constraints_applied: list[str]
confidence: float
class _SuggestionOut(PydanticBase):
steps: list[_StepOut]
steps: list[_SingleStepOut]
reasoning: str
summary: _SummaryOut
class _DayPlanOut(PydanticBase):
date: str
am_steps: list[_StepOut]
pm_steps: list[_StepOut]
am_steps: list[_BatchStepOut]
pm_steps: list[_BatchStepOut]
reasoning: str
@ -424,6 +450,8 @@ ZASADY PLANOWANIA:
- Jeśli krok to produkt: podaj poprawny UUID z listy.
- Jeśli krok to czynność pielęgnacyjna: product_id = null. Dozwolone akcje ś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:
- Gdy cel użytkownika koliduje z bezpieczeństwem, wybierz bezpieczeństwo.
@ -432,6 +460,17 @@ JAK ROZWIĄZYWAĆ KONFLIKTY:
"""
_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
# ---------------------------------------------------------------------------
@ -515,6 +554,7 @@ def suggest_routine(
"INPUT DATA:\n"
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}"
f"{notes_line}\n"
f"{_ROUTINES_SINGLE_EXTRA}\n"
"Zwróć JSON zgodny ze schematem."
)
@ -545,10 +585,34 @@ def suggest_routine(
action_notes=s.get("action_notes"),
dose=s.get("dose"),
region=s.get("region"),
why_this_step=s.get("why_this_step"),
optional=s.get("optional"),
)
for s in parsed.get("steps", [])
]
return RoutineSuggestion(steps=steps, reasoning=parsed.get("reasoning", ""))
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,
)
return RoutineSuggestion(
steps=steps,
reasoning=parsed.get("reasoning", ""),
summary=summary,
)
@router.post("/suggest-batch", response_model=BatchSuggestion)
@ -624,6 +688,8 @@ def suggest_batch(
action_notes=s.get("action_notes"),
dose=s.get("dose"),
region=s.get("region"),
why_this_step=s.get("why_this_step"),
optional=s.get("optional"),
)
)
return result

View file

@ -171,6 +171,10 @@
"suggest_errorSave": "Error saving.",
"suggest_amMorning": "AM (morning)",
"suggest_pmEvening": "PM (evening)",
"suggest_summaryPrimaryGoal": "Primary goal",
"suggest_summaryConfidence": "Confidence",
"suggest_summaryConstraints": "Constraints",
"suggest_stepOptionalBadge": "optional",
"medications_title": "Medications",
"medications_count": "{count} entries",

View file

@ -171,6 +171,10 @@
"suggest_errorSave": "Błąd podczas zapisywania.",
"suggest_amMorning": "AM (rano)",
"suggest_pmEvening": "PM (wieczór)",
"suggest_summaryPrimaryGoal": "Główny cel",
"suggest_summaryConfidence": "Pewność",
"suggest_summaryConstraints": "Ograniczenia",
"suggest_stepOptionalBadge": "opcjonalny",
"medications_title": "Leki",
"medications_count": "{count} wpisów",

View file

@ -224,11 +224,20 @@ export interface SuggestedStep {
action_notes?: string;
dose?: string;
region?: string;
why_this_step?: string;
optional?: boolean;
}
export interface RoutineSuggestionSummary {
primary_goal: string;
constraints_applied: string[];
confidence: number;
}
export interface RoutineSuggestion {
steps: SuggestedStep[];
reasoning: string;
summary?: RoutineSuggestionSummary;
}
export interface DayPlan {

View file

@ -45,7 +45,7 @@
function stepMeta(step: SuggestedStep): string {
const parts: string[] = [];
if (step.dose) parts.push(step.dose);
if (step.region) parts.push(step.region);
if (step.region && step.region.toLowerCase() !== 'face') parts.push(step.region);
if (step.action_notes && !step.action_type) parts.push(step.action_notes);
return parts.join(' · ');
}
@ -209,6 +209,20 @@
</CardContent>
</Card>
{#if suggestion.summary}
<Card class="border-muted bg-muted/30">
<CardContent class="space-y-2 pt-4">
<p class="text-sm"><span class="font-medium">{m["suggest_summaryPrimaryGoal"]()}:</span> {suggestion.summary.primary_goal || '—'}</p>
<p class="text-sm"><span class="font-medium">{m["suggest_summaryConfidence"]()}:</span> {Math.round((suggestion.summary.confidence ?? 0) * 100)}%</p>
{#if suggestion.summary.constraints_applied?.length}
<p class="text-xs text-muted-foreground">
{m["suggest_summaryConstraints"]()}: {suggestion.summary.constraints_applied.join(' · ')}
</p>
{/if}
</CardContent>
</Card>
{/if}
<!-- Steps -->
<div class="space-y-2">
{#each suggestion.steps as step, i (i)}
@ -217,10 +231,18 @@
{i + 1}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">{stepLabel(step)}</p>
{#if step.optional}
<Badge variant="secondary">{m["suggest_stepOptionalBadge"]()}</Badge>
{/if}
</div>
{#if stepMeta(step)}
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
{/if}
{#if step.why_this_step}
<p class="text-xs text-muted-foreground italic">{step.why_this_step}</p>
{/if}
</div>
</div>
{/each}