diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index a0f8d3d..f8f3c77 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -1,17 +1,16 @@ import json -import os from datetime import date from typing import Optional from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException, Query -from google import genai from google.genai import types as genai_types from pydantic import ValidationError from sqlmodel import Session, SQLModel, select from db import get_session from innercontext.api.utils import get_or_404 +from innercontext.llm import get_gemini_client from innercontext.models import ( Product, ProductBase, @@ -350,11 +349,7 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine): @router.post("/parse-text", response_model=ProductParseResponse) def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: - 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", "gemini-flash-latest") - client = genai.Client(api_key=api_key) + client, model = get_gemini_client() response = client.models.generate_content( model=model, contents=f"Extract product data from this text:\n\n{data.text}", diff --git a/backend/innercontext/api/skincare.py b/backend/innercontext/api/skincare.py index 0bed948..49231fe 100644 --- a/backend/innercontext/api/skincare.py +++ b/backend/innercontext/api/skincare.py @@ -1,12 +1,16 @@ +import json from datetime import date from typing import Optional 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 db import get_session from innercontext.api.utils import get_or_404 +from innercontext.llm import get_gemini_client from innercontext.models import ( SkinConditionSnapshot, SkinConditionSnapshotBase, @@ -51,11 +55,118 @@ class SnapshotUpdate(SQLModel): 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 # --------------------------------------------------------------------------- +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]) def list_snapshots( from_date: Optional[date] = None, diff --git a/backend/innercontext/llm.py b/backend/innercontext/llm.py new file mode 100644 index 0000000..428b744 --- /dev/null +++ b/backend/innercontext/llm.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6ed1bfa..40ca052 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "google-genai>=1.65.0", "psycopg>=3.3.3", "python-dotenv>=1.2.1", + "python-multipart>=0.0.22", "sqlmodel>=0.0.37", "uvicorn[standard]>=0.34.0", ] diff --git a/backend/uv.lock b/backend/uv.lock index 42cbcc4..3e0e142 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -459,6 +459,7 @@ dependencies = [ { name = "google-genai" }, { name = "psycopg" }, { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "sqlmodel" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -479,6 +480,7 @@ requires-dist = [ { name = "google-genai", specifier = ">=1.65.0" }, { name = "psycopg", specifier = ">=3.3.3" }, { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "python-multipart", specifier = ">=0.0.22" }, { name = "sqlmodel", specifier = ">=0.0.37" }, { 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" }, ] +[[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]] name = "pytokens" version = "0.4.1" diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3b0dfd1..99f8ac7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -218,3 +218,29 @@ export const updateSkinSnapshot = ( body: Record ): Promise => api.patch(`/skincare/${id}`, body); export const deleteSkinSnapshot = (id: string): Promise => 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 { + 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(); +} diff --git a/frontend/src/routes/skin/+page.server.ts b/frontend/src/routes/skin/+page.server.ts index 994559c..01accb9 100644 --- a/frontend/src/routes/skin/+page.server.ts +++ b/frontend/src/routes/skin/+page.server.ts @@ -29,6 +29,10 @@ export const actions: Actions = { .map((c) => c.trim()) .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 = { snapshot_date, active_concerns }; if (overall_state) body.overall_state = overall_state; if (trend) body.trend = trend; @@ -36,6 +40,9 @@ export const actions: Actions = { if (hydration_level) body.hydration_level = Number(hydration_level); if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level); 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 { await createSkinSnapshot(body); diff --git a/frontend/src/routes/skin/+page.svelte b/frontend/src/routes/skin/+page.svelte index be4cf74..3f4bbc3 100644 --- a/frontend/src/routes/skin/+page.svelte +++ b/frontend/src/routes/skin/+page.svelte @@ -1,6 +1,7 @@ Skin — innercontext @@ -52,14 +103,67 @@ {/if} {#if showForm} + + + + + + {#if aiPanelOpen} + +

+ Upload 1–3 photos of your skin. AI will pre-fill the form fields below. +

+ + {#if previewUrls.length} +
+ {#each previewUrls as url (url)} + skin preview + {/each} +
+ {/if} + {#if aiError} +

{aiError}

+ {/if} + +
+ {/if} +
+ + New skin snapshot
- +
@@ -67,7 +171,7 @@ (trend = v)}> {trend || 'Select'} - {#each trends as t} + {#each trends as t (t)} {t} {/each}
+
+ + + +
+
- + +
+
+ + +
+
+ +
- +
- +
@@ -122,14 +281,18 @@ {/if}
- {#each sortedSnapshots as snap} + {#each sortedSnapshots as snap (snap.id)}
{snap.snapshot_date}
{#if snap.overall_state} - + {snap.overall_state} {/if} @@ -160,7 +323,7 @@
{#if snap.active_concerns.length}
- {#each snap.active_concerns as c} + {#each snap.active_concerns as c (c)} {c.replace(/_/g, ' ')} {/each}