feat: AI photo analysis for skin snapshots
Add POST /skincare/analyze-photos endpoint that accepts 1–3 skin photos, sends them to Gemini vision, and returns a structured SkinPhotoAnalysisResponse for pre-filling the snapshot form. Extract shared Gemini client setup into innercontext/llm.py (get_gemini_client) so both products and skincare use a single default model (gemini-flash-latest) and API key check. Frontend: AI photo card on /skin page with file picker, previews, and auto-fill of all form fields from the analysis result. New fields (skin_type, sebum_tzone, sebum_cheeks) added to form and server action. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cc25ac4e65
commit
66ee473deb
8 changed files with 356 additions and 21 deletions
|
|
@ -1,17 +1,16 @@
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from google import genai
|
|
||||||
from google.genai import types as genai_types
|
from google.genai import types as genai_types
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlmodel import Session, SQLModel, select
|
from sqlmodel import Session, SQLModel, 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
|
||||||
|
from innercontext.llm import get_gemini_client
|
||||||
from innercontext.models import (
|
from innercontext.models import (
|
||||||
Product,
|
Product,
|
||||||
ProductBase,
|
ProductBase,
|
||||||
|
|
@ -350,11 +349,7 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
|
||||||
|
|
||||||
@router.post("/parse-text", response_model=ProductParseResponse)
|
@router.post("/parse-text", response_model=ProductParseResponse)
|
||||||
def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
|
def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
|
||||||
api_key = os.environ.get("GEMINI_API_KEY")
|
client, model = get_gemini_client()
|
||||||
if not api_key:
|
|
||||||
raise HTTPException(status_code=503, detail="GEMINI_API_KEY not configured")
|
|
||||||
model = os.environ.get("GEMINI_MODEL", "gemini-flash-latest")
|
|
||||||
client = genai.Client(api_key=api_key)
|
|
||||||
response = client.models.generate_content(
|
response = client.models.generate_content(
|
||||||
model=model,
|
model=model,
|
||||||
contents=f"Extract product data from this text:\n\n{data.text}",
|
contents=f"Extract product data from this text:\n\n{data.text}",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
|
import json
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
|
from google.genai import types as genai_types
|
||||||
|
from pydantic import ValidationError
|
||||||
from sqlmodel import Session, SQLModel, select
|
from sqlmodel import Session, SQLModel, 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
|
||||||
|
from innercontext.llm import get_gemini_client
|
||||||
from innercontext.models import (
|
from innercontext.models import (
|
||||||
SkinConditionSnapshot,
|
SkinConditionSnapshot,
|
||||||
SkinConditionSnapshotBase,
|
SkinConditionSnapshotBase,
|
||||||
|
|
@ -51,11 +55,118 @@ class SnapshotUpdate(SQLModel):
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkinPhotoAnalysisResponse(SQLModel):
|
||||||
|
overall_state: Optional[OverallSkinState] = None
|
||||||
|
trend: Optional[SkinTrend] = None
|
||||||
|
skin_type: Optional[SkinType] = None
|
||||||
|
hydration_level: Optional[int] = None
|
||||||
|
sebum_tzone: Optional[int] = None
|
||||||
|
sebum_cheeks: Optional[int] = None
|
||||||
|
sensitivity_level: Optional[int] = None
|
||||||
|
barrier_state: Optional[BarrierState] = None
|
||||||
|
active_concerns: Optional[list[SkinConcern]] = None
|
||||||
|
risks: Optional[list[str]] = None
|
||||||
|
priorities: Optional[list[str]] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _skin_photo_system_prompt() -> str:
|
||||||
|
return """\
|
||||||
|
You are a dermatology-trained skin assessment AI. Analyze the provided photo(s) of a person's
|
||||||
|
skin and return a structured JSON assessment.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Return ONLY raw JSON — no markdown fences, no explanation.
|
||||||
|
- 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 1–5 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: 2–4 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"
|
||||||
|
barrier_state: "intact" | "mildly_compromised" | "compromised"
|
||||||
|
active_concerns: "acne" | "rosacea" | "hyperpigmentation" | "aging" | "dehydration" |
|
||||||
|
"redness" | "damaged_barrier" | "pore_visibility" | "uneven_texture" | "sebum_excess"
|
||||||
|
|
||||||
|
METRICS (int 1–5, omit if not assessable):
|
||||||
|
hydration_level: 1=very dehydrated/dull → 5=plump/luminous
|
||||||
|
sebum_tzone: 1=very dry T-zone → 5=very oily T-zone
|
||||||
|
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":…,
|
||||||
|
"sebum_tzone":…, "sebum_cheeks":…, "sensitivity_level":…,
|
||||||
|
"barrier_state":…, "active_concerns":[…], "risks":[…], "priorities":[…], "notes":…}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyze-photos", response_model=SkinPhotoAnalysisResponse)
|
||||||
|
async def analyze_skin_photos(
|
||||||
|
photos: list[UploadFile] = File(...),
|
||||||
|
) -> SkinPhotoAnalysisResponse:
|
||||||
|
if not (1 <= len(photos) <= 3):
|
||||||
|
raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.")
|
||||||
|
|
||||||
|
client, model = get_gemini_client()
|
||||||
|
|
||||||
|
allowed = {"image/jpeg", "image/png", "image/webp"}
|
||||||
|
parts: list[genai_types.Part] = []
|
||||||
|
for photo in photos:
|
||||||
|
if photo.content_type not in allowed:
|
||||||
|
raise HTTPException(status_code=422, detail=f"Unsupported type: {photo.content_type}")
|
||||||
|
data = await photo.read()
|
||||||
|
if len(data) > MAX_IMAGE_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail=f"{photo.filename} exceeds 5 MB.")
|
||||||
|
parts.append(genai_types.Part.from_bytes(data=data, mime_type=photo.content_type))
|
||||||
|
parts.append(
|
||||||
|
genai_types.Part.from_text(
|
||||||
|
"Analyze the skin condition visible in the above photo(s) and return the JSON assessment."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model=model,
|
||||||
|
contents=parts,
|
||||||
|
config=genai_types.GenerateContentConfig(
|
||||||
|
system_instruction=_skin_photo_system_prompt(),
|
||||||
|
response_mime_type="application/json",
|
||||||
|
max_output_tokens=1024,
|
||||||
|
temperature=0.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(response.text)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return SkinPhotoAnalysisResponse.model_validate(parsed)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=e.errors())
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SkinConditionSnapshotPublic])
|
@router.get("", response_model=list[SkinConditionSnapshotPublic])
|
||||||
def list_snapshots(
|
def list_snapshots(
|
||||||
from_date: Optional[date] = None,
|
from_date: Optional[date] = None,
|
||||||
|
|
|
||||||
21
backend/innercontext/llm.py
Normal file
21
backend/innercontext/llm.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Shared helpers for Gemini API access."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from google import genai
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_MODEL = "gemini-flash-latest"
|
||||||
|
|
||||||
|
|
||||||
|
def get_gemini_client() -> tuple[genai.Client, str]:
|
||||||
|
"""Return an authenticated Gemini client and the configured model name.
|
||||||
|
|
||||||
|
Raises HTTP 503 if GEMINI_API_KEY is not set.
|
||||||
|
"""
|
||||||
|
api_key = os.environ.get("GEMINI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=503, detail="GEMINI_API_KEY not configured")
|
||||||
|
model = os.environ.get("GEMINI_MODEL", _DEFAULT_MODEL)
|
||||||
|
return genai.Client(api_key=api_key), model
|
||||||
|
|
@ -9,6 +9,7 @@ dependencies = [
|
||||||
"google-genai>=1.65.0",
|
"google-genai>=1.65.0",
|
||||||
"psycopg>=3.3.3",
|
"psycopg>=3.3.3",
|
||||||
"python-dotenv>=1.2.1",
|
"python-dotenv>=1.2.1",
|
||||||
|
"python-multipart>=0.0.22",
|
||||||
"sqlmodel>=0.0.37",
|
"sqlmodel>=0.0.37",
|
||||||
"uvicorn[standard]>=0.34.0",
|
"uvicorn[standard]>=0.34.0",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
11
backend/uv.lock
generated
11
backend/uv.lock
generated
|
|
@ -459,6 +459,7 @@ dependencies = [
|
||||||
{ name = "google-genai" },
|
{ name = "google-genai" },
|
||||||
{ name = "psycopg" },
|
{ name = "psycopg" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "sqlmodel" },
|
{ name = "sqlmodel" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
@ -479,6 +480,7 @@ requires-dist = [
|
||||||
{ name = "google-genai", specifier = ">=1.65.0" },
|
{ name = "google-genai", specifier = ">=1.65.0" },
|
||||||
{ name = "psycopg", specifier = ">=3.3.3" },
|
{ name = "psycopg", specifier = ">=3.3.3" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.22" },
|
||||||
{ name = "sqlmodel", specifier = ">=0.0.37" },
|
{ name = "sqlmodel", specifier = ">=0.0.37" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
||||||
]
|
]
|
||||||
|
|
@ -710,6 +712,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-multipart"
|
||||||
|
version = "0.0.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytokens"
|
name = "pytokens"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|
|
||||||
|
|
@ -218,3 +218,29 @@ export const updateSkinSnapshot = (
|
||||||
body: Record<string, unknown>
|
body: Record<string, unknown>
|
||||||
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body);
|
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body);
|
||||||
export const deleteSkinSnapshot = (id: string): Promise<void> => api.del(`/skincare/${id}`);
|
export const deleteSkinSnapshot = (id: string): Promise<void> => api.del(`/skincare/${id}`);
|
||||||
|
|
||||||
|
export interface SkinPhotoAnalysisResponse {
|
||||||
|
overall_state?: string;
|
||||||
|
trend?: string;
|
||||||
|
skin_type?: string;
|
||||||
|
hydration_level?: number;
|
||||||
|
sebum_tzone?: number;
|
||||||
|
sebum_cheeks?: number;
|
||||||
|
sensitivity_level?: number;
|
||||||
|
barrier_state?: string;
|
||||||
|
active_concerns?: string[];
|
||||||
|
risks?: string[];
|
||||||
|
priorities?: string[];
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeSkinPhotos(files: File[]): Promise<SkinPhotoAnalysisResponse> {
|
||||||
|
const body = new FormData();
|
||||||
|
for (const file of files) body.append('photos', file);
|
||||||
|
const res = await fetch(`${PUBLIC_API_BASE}/skincare/analyze-photos`, { method: 'POST', body });
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
throw new Error(detail?.detail ?? res.statusText);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ export const actions: Actions = {
|
||||||
.map((c) => c.trim())
|
.map((c) => c.trim())
|
||||||
.filter(Boolean) ?? [];
|
.filter(Boolean) ?? [];
|
||||||
|
|
||||||
|
const skin_type = form.get('skin_type') as string;
|
||||||
|
const sebum_tzone = form.get('sebum_tzone') as string;
|
||||||
|
const sebum_cheeks = form.get('sebum_cheeks') as string;
|
||||||
|
|
||||||
const body: Record<string, unknown> = { snapshot_date, active_concerns };
|
const body: Record<string, unknown> = { snapshot_date, active_concerns };
|
||||||
if (overall_state) body.overall_state = overall_state;
|
if (overall_state) body.overall_state = overall_state;
|
||||||
if (trend) body.trend = trend;
|
if (trend) body.trend = trend;
|
||||||
|
|
@ -36,6 +40,9 @@ export const actions: Actions = {
|
||||||
if (hydration_level) body.hydration_level = Number(hydration_level);
|
if (hydration_level) body.hydration_level = Number(hydration_level);
|
||||||
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
|
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
|
||||||
if (barrier_state) body.barrier_state = barrier_state;
|
if (barrier_state) body.barrier_state = barrier_state;
|
||||||
|
if (skin_type) body.skin_type = skin_type;
|
||||||
|
if (sebum_tzone) body.sebum_tzone = Number(sebum_tzone);
|
||||||
|
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createSkinSnapshot(body);
|
await createSkinSnapshot(body);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { analyzeSkinPhotos } from '$lib/api';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
|
@ -13,6 +14,7 @@
|
||||||
const states = ['excellent', 'good', 'fair', 'poor'];
|
const states = ['excellent', 'good', 'fair', 'poor'];
|
||||||
const trends = ['improving', 'stable', 'worsening', 'fluctuating'];
|
const trends = ['improving', 'stable', 'worsening', 'fluctuating'];
|
||||||
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
|
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
|
||||||
|
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
|
||||||
|
|
||||||
const stateColors: Record<string, string> = {
|
const stateColors: Record<string, string> = {
|
||||||
excellent: 'bg-green-100 text-green-800',
|
excellent: 'bg-green-100 text-green-800',
|
||||||
|
|
@ -22,13 +24,62 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
|
|
||||||
|
// Form state (bound to inputs so AI can pre-fill)
|
||||||
|
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
|
||||||
let overallState = $state('');
|
let overallState = $state('');
|
||||||
let trend = $state('');
|
let trend = $state('');
|
||||||
let barrierState = $state('');
|
let barrierState = $state('');
|
||||||
|
let skinType = $state('');
|
||||||
|
let hydrationLevel = $state('');
|
||||||
|
let sensitivityLevel = $state('');
|
||||||
|
let sebumTzone = $state('');
|
||||||
|
let sebumCheeks = $state('');
|
||||||
|
let activeConcernsRaw = $state('');
|
||||||
|
let notes = $state('');
|
||||||
|
|
||||||
|
// AI photo analysis state
|
||||||
|
let aiPanelOpen = $state(false);
|
||||||
|
let selectedFiles = $state<File[]>([]);
|
||||||
|
let previewUrls = $state<string[]>([]);
|
||||||
|
let aiLoading = $state(false);
|
||||||
|
let aiError = $state('');
|
||||||
|
|
||||||
const sortedSnapshots = $derived(
|
const sortedSnapshots = $derived(
|
||||||
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
|
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function handleFileSelect(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files ?? []).slice(0, 3);
|
||||||
|
selectedFiles = files;
|
||||||
|
previewUrls.forEach(URL.revokeObjectURL);
|
||||||
|
previewUrls = files.map((f) => URL.createObjectURL(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzePhotos() {
|
||||||
|
if (!selectedFiles.length) return;
|
||||||
|
aiLoading = true;
|
||||||
|
aiError = '';
|
||||||
|
try {
|
||||||
|
const r = await analyzeSkinPhotos(selectedFiles);
|
||||||
|
if (r.overall_state) overallState = r.overall_state;
|
||||||
|
if (r.trend) trend = r.trend;
|
||||||
|
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);
|
||||||
|
if (r.sensitivity_level != null) sensitivityLevel = String(r.sensitivity_level);
|
||||||
|
if (r.sebum_tzone != null) sebumTzone = String(r.sebum_tzone);
|
||||||
|
if (r.sebum_cheeks != null) sebumCheeks = String(r.sebum_cheeks);
|
||||||
|
if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', ');
|
||||||
|
if (r.notes) notes = r.notes;
|
||||||
|
aiPanelOpen = false;
|
||||||
|
} catch (e) {
|
||||||
|
aiError = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
aiLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Skin — innercontext</title></svelte:head>
|
<svelte:head><title>Skin — innercontext</title></svelte:head>
|
||||||
|
|
@ -52,14 +103,67 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
|
<!-- AI photo analysis card -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between text-left"
|
||||||
|
onclick={() => (aiPanelOpen = !aiPanelOpen)}
|
||||||
|
>
|
||||||
|
<CardTitle>AI analysis from photos</CardTitle>
|
||||||
|
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
{#if aiPanelOpen}
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Upload 1–3 photos of your skin. AI will pre-fill the form fields below.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
class="block w-full text-sm text-muted-foreground
|
||||||
|
file:mr-4 file:rounded-md file:border-0 file:bg-primary
|
||||||
|
file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
|
||||||
|
/>
|
||||||
|
{#if previewUrls.length}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each previewUrls as url (url)}
|
||||||
|
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md object-cover border" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if aiError}
|
||||||
|
<p class="text-sm text-destructive">{aiError}</p>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onclick={analyzePhotos}
|
||||||
|
disabled={aiLoading || !selectedFiles.length}
|
||||||
|
>
|
||||||
|
{aiLoading ? 'Analyzing…' : 'Analyze photos'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- New snapshot form -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>New skin snapshot</CardTitle></CardHeader>
|
<CardHeader><CardTitle>New skin snapshot</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
|
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="snapshot_date">Date *</Label>
|
<Label for="snapshot_date">Date *</Label>
|
||||||
<Input id="snapshot_date" name="snapshot_date" type="date"
|
<Input
|
||||||
value={new Date().toISOString().slice(0, 10)} required />
|
id="snapshot_date"
|
||||||
|
name="snapshot_date"
|
||||||
|
type="date"
|
||||||
|
bind:value={snapshotDate}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label>Overall state</Label>
|
<Label>Overall state</Label>
|
||||||
|
|
@ -67,7 +171,7 @@
|
||||||
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
|
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
|
||||||
<SelectTrigger>{overallState || 'Select'}</SelectTrigger>
|
<SelectTrigger>{overallState || 'Select'}</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{#each states as s}
|
{#each states as s (s)}
|
||||||
<SelectItem value={s}>{s}</SelectItem>
|
<SelectItem value={s}>{s}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -79,19 +183,33 @@
|
||||||
<Select type="single" value={trend} onValueChange={(v) => (trend = v)}>
|
<Select type="single" value={trend} onValueChange={(v) => (trend = v)}>
|
||||||
<SelectTrigger>{trend || 'Select'}</SelectTrigger>
|
<SelectTrigger>{trend || 'Select'}</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{#each trends as t}
|
{#each trends as t (t)}
|
||||||
<SelectItem value={t}>{t}</SelectItem>
|
<SelectItem value={t}>{t}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Skin type</Label>
|
||||||
|
<input type="hidden" name="skin_type" value={skinType} />
|
||||||
|
<Select type="single" value={skinType} onValueChange={(v) => (skinType = v)}>
|
||||||
|
<SelectTrigger>{skinType ? skinType.replace(/_/g, ' ') : 'Select'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each skinTypes as st (st)}
|
||||||
|
<SelectItem value={st}>{st.replace(/_/g, ' ')}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label>Barrier state</Label>
|
<Label>Barrier state</Label>
|
||||||
<input type="hidden" name="barrier_state" value={barrierState} />
|
<input type="hidden" name="barrier_state" value={barrierState} />
|
||||||
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
|
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
|
||||||
<SelectTrigger>{barrierState ? barrierState.replace(/_/g, ' ') : 'Select'}</SelectTrigger>
|
<SelectTrigger
|
||||||
|
>{barrierState ? barrierState.replace(/_/g, ' ') : 'Select'}</SelectTrigger
|
||||||
|
>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{#each barrierStates as b}
|
{#each barrierStates as b (b)}
|
||||||
<SelectItem value={b}>{b.replace(/_/g, ' ')}</SelectItem>
|
<SelectItem value={b}>{b.replace(/_/g, ' ')}</SelectItem>
|
||||||
{/each}
|
{/each}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -99,19 +217,60 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="hydration_level">Hydration (1–5)</Label>
|
<Label for="hydration_level">Hydration (1–5)</Label>
|
||||||
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" />
|
<Input
|
||||||
|
id="hydration_level"
|
||||||
|
name="hydration_level"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
bind:value={hydrationLevel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="sensitivity_level">Sensitivity (1–5)</Label>
|
<Label for="sensitivity_level">Sensitivity (1–5)</Label>
|
||||||
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" />
|
<Input
|
||||||
|
id="sensitivity_level"
|
||||||
|
name="sensitivity_level"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
bind:value={sensitivityLevel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="sebum_tzone">Sebum T-zone (1–5)</Label>
|
||||||
|
<Input
|
||||||
|
id="sebum_tzone"
|
||||||
|
name="sebum_tzone"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
bind:value={sebumTzone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="sebum_cheeks">Sebum cheeks (1–5)</Label>
|
||||||
|
<Input
|
||||||
|
id="sebum_cheeks"
|
||||||
|
name="sebum_cheeks"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
bind:value={sebumCheeks}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1 col-span-2">
|
<div class="space-y-1 col-span-2">
|
||||||
<Label for="active_concerns">Active concerns (comma-separated)</Label>
|
<Label for="active_concerns">Active concerns (comma-separated)</Label>
|
||||||
<Input id="active_concerns" name="active_concerns" placeholder="acne, redness, dehydration" />
|
<Input
|
||||||
|
id="active_concerns"
|
||||||
|
name="active_concerns"
|
||||||
|
placeholder="acne, redness, dehydration"
|
||||||
|
bind:value={activeConcernsRaw}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1 col-span-2">
|
<div class="space-y-1 col-span-2">
|
||||||
<Label for="notes">Notes</Label>
|
<Label for="notes">Notes</Label>
|
||||||
<Input id="notes" name="notes" />
|
<Input id="notes" name="notes" bind:value={notes} />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<Button type="submit">Add snapshot</Button>
|
<Button type="submit">Add snapshot</Button>
|
||||||
|
|
@ -122,14 +281,18 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#each sortedSnapshots as snap}
|
{#each sortedSnapshots as snap (snap.id)}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="pt-4">
|
<CardContent class="pt-4">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<span class="font-medium">{snap.snapshot_date}</span>
|
<span class="font-medium">{snap.snapshot_date}</span>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{#if snap.overall_state}
|
{#if snap.overall_state}
|
||||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[
|
||||||
|
snap.overall_state
|
||||||
|
] ?? ''}"
|
||||||
|
>
|
||||||
{snap.overall_state}
|
{snap.overall_state}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -160,7 +323,7 @@
|
||||||
</div>
|
</div>
|
||||||
{#if snap.active_concerns.length}
|
{#if snap.active_concerns.length}
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#each snap.active_concerns as c}
|
{#each snap.active_concerns as c (c)}
|
||||||
<Badge variant="secondary" class="text-xs">{c.replace(/_/g, ' ')}</Badge>
|
<Badge variant="secondary" class="text-xs">{c.replace(/_/g, ' ')}</Badge>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue