refactor(skin): replace trend with texture field on SkinConditionSnapshot

Remove the derived `trend` field (better computed from history by the MCP
agent) and add `texture: smooth|rough|flaky|bumpy` which LLM can reliably
assess from photos. Updates model, API, system prompt, tests, and frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-02-28 13:25:57 +01:00
parent abf9593857
commit 4954d4f449
8 changed files with 30 additions and 31 deletions

View file

@ -20,7 +20,7 @@ from innercontext.models.enums import (
BarrierState,
OverallSkinState,
SkinConcern,
SkinTrend,
SkinTexture,
SkinType,
)
@ -39,8 +39,8 @@ class SnapshotCreate(SkinConditionSnapshotBase):
class SnapshotUpdate(SQLModel):
snapshot_date: Optional[date] = None
overall_state: Optional[OverallSkinState] = None
trend: Optional[SkinTrend] = None
skin_type: Optional[SkinType] = None
texture: Optional[SkinTexture] = None
hydration_level: Optional[int] = None
sebum_tzone: Optional[int] = None
@ -57,8 +57,8 @@ class SnapshotUpdate(SQLModel):
class SkinPhotoAnalysisResponse(SQLModel):
overall_state: Optional[OverallSkinState] = None
trend: Optional[SkinTrend] = None
skin_type: Optional[SkinType] = None
texture: Optional[SkinTexture] = None
hydration_level: Optional[int] = None
sebum_tzone: Optional[int] = None
sebum_cheeks: Optional[int] = None
@ -85,14 +85,13 @@ RULES:
- Omit any field you cannot confidently determine from the photos. Do not guess.
- All enum values must exactly match the allowed strings listed below.
- Numeric metrics use a 15 scale (1 = minimal, 5 = maximal).
- For trends: only populate if you have strong visual cues; otherwise omit.
- risks and priorities: short English phrases, max 10 words each.
- notes: 24 sentence paragraph describing key observations.
ENUM VALUES:
overall_state: "excellent" | "good" | "fair" | "poor"
trend: "improving" | "stable" | "worsening" | "fluctuating"
skin_type: "dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone"
texture: "smooth" | "rough" | "flaky" | "bumpy"
barrier_state: "intact" | "mildly_compromised" | "compromised"
active_concerns: "acne" | "rosacea" | "hyperpigmentation" | "aging" | "dehydration" |
"redness" | "damaged_barrier" | "pore_visibility" | "uneven_texture" | "sebum_excess"
@ -104,7 +103,7 @@ sebum_cheeks: 1=very dry cheeks → 5=very oily cheeks
sensitivity_level: 1=no visible signs 5=severe redness/reactivity
OUTPUT (all fields optional):
{"overall_state":, "trend":, "skin_type":, "hydration_level":,
{"overall_state":, "skin_type":, "texture":, "hydration_level":,
"sebum_tzone":, "sebum_cheeks":, "sensitivity_level":,
"barrier_state":, "active_concerns":[], "risks":[], "priorities":[], "notes":}
"""

View file

@ -15,7 +15,7 @@ from .enums import (
ResultFlag,
RoutineRole,
SkinConcern,
SkinTrend,
SkinTexture,
SkinType,
StrengthLevel,
TextureType,
@ -59,7 +59,7 @@ __all__ = [
"ResultFlag",
"RoutineRole",
"SkinConcern",
"SkinTrend",
"SkinTexture",
"SkinType",
"StrengthLevel",
"TextureType",

View file

@ -186,11 +186,11 @@ class OverallSkinState(str, Enum):
POOR = "poor"
class SkinTrend(str, Enum):
IMPROVING = "improving"
STABLE = "stable"
WORSENING = "worsening"
FLUCTUATING = "fluctuating"
class SkinTexture(str, Enum):
SMOOTH = "smooth"
ROUGH = "rough"
FLAKY = "flaky"
BUMPY = "bumpy"
class BarrierState(str, Enum):

View file

@ -9,7 +9,7 @@ from sqlmodel import Field, SQLModel
from .base import utc_now
from .domain import Domain
from .enums import BarrierState, OverallSkinState, SkinConcern, SkinTrend, SkinType
from .enums import BarrierState, OverallSkinState, SkinConcern, SkinTexture, SkinType
# ---------------------------------------------------------------------------
# Base model (pure Python types, no sa_column, no id/created_at)
@ -20,8 +20,8 @@ class SkinConditionSnapshotBase(SQLModel):
snapshot_date: date
overall_state: OverallSkinState | None = None
trend: SkinTrend | None = None
skin_type: SkinType | None = None
texture: SkinTexture | None = None
# Metryki wizualne (1 = minimalne, 5 = maksymalne nasilenie)
hydration_level: int | None = Field(default=None, ge=1, le=5)

View file

@ -16,8 +16,8 @@ def test_create_snapshot_full(client):
json={
"snapshot_date": "2026-02-20",
"overall_state": "good",
"trend": "improving",
"skin_type": "combination",
"texture": "rough",
"hydration_level": 3,
"sebum_tzone": 4,
"sebum_cheeks": 2,
@ -32,7 +32,7 @@ def test_create_snapshot_full(client):
assert r.status_code == 201
data = r.json()
assert data["overall_state"] == "good"
assert data["trend"] == "improving"
assert data["texture"] == "rough"
assert "acne" in data["active_concerns"]
assert data["hydration_level"] == 3

View file

@ -55,7 +55,7 @@ export type SkinConcern =
| 'uneven_texture'
| 'hair_growth'
| 'sebum_excess';
export type SkinTrend = 'improving' | 'stable' | 'worsening' | 'fluctuating';
export type SkinTexture = 'smooth' | 'rough' | 'flaky' | 'bumpy';
export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone';
export type StrengthLevel = 1 | 2 | 3;
export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid';
@ -250,8 +250,8 @@ export interface SkinConditionSnapshot {
id: string;
snapshot_date: string;
overall_state?: OverallSkinState;
trend?: SkinTrend;
skin_type?: SkinType;
texture?: SkinTexture;
hydration_level?: number;
sebum_tzone?: number;
sebum_cheeks?: number;

View file

@ -13,7 +13,7 @@ export const actions: Actions = {
const form = await request.formData();
const snapshot_date = form.get('snapshot_date') as string;
const overall_state = form.get('overall_state') as string;
const trend = form.get('trend') as string;
const texture = form.get('texture') as string;
const notes = form.get('notes') as string;
const hydration_level = form.get('hydration_level') as string;
const sensitivity_level = form.get('sensitivity_level') as string;
@ -35,7 +35,7 @@ export const actions: Actions = {
const body: Record<string, unknown> = { snapshot_date, active_concerns };
if (overall_state) body.overall_state = overall_state;
if (trend) body.trend = trend;
if (texture) body.texture = texture;
if (notes) body.notes = notes;
if (hydration_level) body.hydration_level = Number(hydration_level);
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);

View file

@ -12,7 +12,7 @@
let { data, form }: { data: PageData; form: ActionData } = $props();
const states = ['excellent', 'good', 'fair', 'poor'];
const trends = ['improving', 'stable', 'worsening', 'fluctuating'];
const skinTextures = ['smooth', 'rough', 'flaky', 'bumpy'];
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
@ -28,7 +28,7 @@
// Form state (bound to inputs so AI can pre-fill)
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
let overallState = $state('');
let trend = $state('');
let texture = $state('');
let barrierState = $state('');
let skinType = $state('');
let hydrationLevel = $state('');
@ -64,7 +64,7 @@
try {
const r = await analyzeSkinPhotos(selectedFiles);
if (r.overall_state) overallState = r.overall_state;
if (r.trend) trend = r.trend;
if (r.texture) texture = r.texture;
if (r.skin_type) skinType = r.skin_type;
if (r.barrier_state) barrierState = r.barrier_state;
if (r.hydration_level != null) hydrationLevel = String(r.hydration_level);
@ -178,12 +178,12 @@
</Select>
</div>
<div class="space-y-1">
<Label>Trend</Label>
<input type="hidden" name="trend" value={trend} />
<Select type="single" value={trend} onValueChange={(v) => (trend = v)}>
<SelectTrigger>{trend || 'Select'}</SelectTrigger>
<Label>Texture</Label>
<input type="hidden" name="texture" value={texture} />
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
<SelectTrigger>{texture || 'Select'}</SelectTrigger>
<SelectContent>
{#each trends as t (t)}
{#each skinTextures as t (t)}
<SelectItem value={t}>{t}</SelectItem>
{/each}
</SelectContent>
@ -296,8 +296,8 @@
{snap.overall_state}
</span>
{/if}
{#if snap.trend}
<Badge variant="secondary">{snap.trend}</Badge>
{#if snap.texture}
<Badge variant="secondary">{snap.texture}</Badge>
{/if}
</div>
</div>