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

View file

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

View file

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

View file

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

View file

@ -45,7 +45,7 @@
function stepMeta(step: SuggestedStep): string { function stepMeta(step: SuggestedStep): string {
const parts: string[] = []; const parts: string[] = [];
if (step.dose) parts.push(step.dose); 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); if (step.action_notes && !step.action_type) parts.push(step.action_notes);
return parts.join(' · '); return parts.join(' · ');
} }
@ -209,6 +209,20 @@
</CardContent> </CardContent>
</Card> </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 --> <!-- Steps -->
<div class="space-y-2"> <div class="space-y-2">
{#each suggestion.steps as step, i (i)} {#each suggestion.steps as step, i (i)}
@ -217,10 +231,18 @@
{i + 1} {i + 1}
</span> </span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">{stepLabel(step)}</p> <p class="text-sm font-medium">{stepLabel(step)}</p>
{#if step.optional}
<Badge variant="secondary">{m["suggest_stepOptionalBadge"]()}</Badge>
{/if}
</div>
{#if stepMeta(step)} {#if stepMeta(step)}
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p> <p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
{/if} {/if}
{#if step.why_this_step}
<p class="text-xs text-muted-foreground italic">{step.why_this_step}</p>
{/if}
</div> </div>
</div> </div>
{/each} {/each}