innercontext/backend/innercontext/api/skincare.py

350 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import logging
from datetime import date
from typing import Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from google.genai import types as genai_types
from pydantic import BaseModel as PydanticBase
from pydantic import ValidationError
from sqlmodel import Session, SQLModel, select
from db import get_session
from innercontext.api.auth_deps import get_current_user
from innercontext.api.llm_context import build_user_profile_context
from innercontext.api.utils import get_owned_or_404
from innercontext.auth import CurrentUser
from innercontext.llm import call_gemini, get_extraction_config
from innercontext.models import (
SkinConditionSnapshot,
SkinConditionSnapshotBase,
SkinConditionSnapshotPublic,
)
from innercontext.models.enums import (
BarrierState,
OverallSkinState,
SkinConcern,
SkinTexture,
SkinType,
)
from innercontext.models.enums import Role
from innercontext.validators import PhotoValidator
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class SnapshotCreate(SkinConditionSnapshotBase):
pass
class SnapshotUpdate(SQLModel):
snapshot_date: Optional[date] = None
overall_state: Optional[OverallSkinState] = 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
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
class SkinPhotoAnalysisResponse(SQLModel):
overall_state: Optional[OverallSkinState] = 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
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
class _SkinAnalysisOut(PydanticBase):
overall_state: Optional[OverallSkinState] = 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
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 15 scale (1 = minimal, 5 = maximal).
- 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"
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"
METRICS (int 15, 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":…, "skin_type":…, "texture":…, "hydration_level":…,
"sebum_tzone":…, "sebum_cheeks":…, "sensitivity_level":…,
"barrier_state":…, "active_concerns":[…], "risks":[…], "priorities":[…], "notes":…}
"""
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
def _resolve_target_user_id(
current_user: CurrentUser,
user_id: UUID | None,
) -> UUID:
if user_id is None:
return current_user.user_id
if current_user.role is not Role.ADMIN:
raise HTTPException(status_code=403, detail="Admin role required")
return user_id
def _get_owned_or_admin_override(
session: Session,
snapshot_id: UUID,
current_user: CurrentUser,
user_id: UUID | None,
) -> SkinConditionSnapshot:
if user_id is None:
return get_owned_or_404(
session, SkinConditionSnapshot, snapshot_id, current_user
)
target_user_id = _resolve_target_user_id(current_user, user_id)
snapshot = session.get(SkinConditionSnapshot, snapshot_id)
if snapshot is None or snapshot.user_id != target_user_id:
raise HTTPException(status_code=404, detail="SkinConditionSnapshot not found")
return snapshot
MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
@router.post("/analyze-photos", response_model=SkinPhotoAnalysisResponse)
async def analyze_skin_photos(
photos: list[UploadFile] = File(...),
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
) -> SkinPhotoAnalysisResponse:
if not (1 <= len(photos) <= 3):
raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.")
allowed = {
"image/heic",
"image/heif",
"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(
text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment."
)
)
parts.append(
genai_types.Part.from_text(
text=build_user_profile_context(
session,
reference_date=date.today(),
current_user=current_user,
)
)
)
image_summary = f"{len(photos)} image(s): {', '.join((p.content_type or 'unknown') for p in photos)}"
response, log_id = call_gemini(
endpoint="skincare/analyze-photos",
contents=parts,
config=get_extraction_config(
system_instruction=_skin_photo_system_prompt(),
response_schema=_SkinAnalysisOut,
max_output_tokens=2048,
),
user_input=image_summary,
)
try:
parsed = json.loads(response.text)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
try:
photo_analysis = SkinPhotoAnalysisResponse.model_validate(parsed)
# Phase 1: Validate the photo analysis
validator = PhotoValidator()
validation_result = validator.validate(photo_analysis)
if not validation_result.is_valid:
logger.error(
f"Photo analysis validation failed: {validation_result.errors}"
)
raise HTTPException(
status_code=502,
detail=f"Photo analysis failed validation: {'; '.join(validation_result.errors)}",
)
if validation_result.warnings:
logger.warning(f"Photo analysis warnings: {validation_result.warnings}")
return photo_analysis
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,
to_date: Optional[date] = None,
overall_state: Optional[OverallSkinState] = None,
user_id: UUID | None = Query(default=None),
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
target_user_id = _resolve_target_user_id(current_user, user_id)
stmt = select(SkinConditionSnapshot).where(
SkinConditionSnapshot.user_id == target_user_id
)
if from_date is not None:
stmt = stmt.where(SkinConditionSnapshot.snapshot_date >= from_date)
if to_date is not None:
stmt = stmt.where(SkinConditionSnapshot.snapshot_date <= to_date)
if overall_state is not None:
stmt = stmt.where(SkinConditionSnapshot.overall_state == overall_state)
return session.exec(stmt).all()
@router.post("", response_model=SkinConditionSnapshotPublic, status_code=201)
def create_snapshot(
data: SnapshotCreate,
user_id: UUID | None = Query(default=None),
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
target_user_id = _resolve_target_user_id(current_user, user_id)
snapshot = SkinConditionSnapshot(
id=uuid4(),
user_id=target_user_id,
**data.model_dump(),
)
session.add(snapshot)
session.commit()
session.refresh(snapshot)
return snapshot
@router.get("/{snapshot_id}", response_model=SkinConditionSnapshotPublic)
def get_snapshot(
snapshot_id: UUID,
user_id: UUID | None = Query(default=None),
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
return _get_owned_or_admin_override(
session,
snapshot_id,
current_user,
user_id,
)
@router.patch("/{snapshot_id}", response_model=SkinConditionSnapshotPublic)
def update_snapshot(
snapshot_id: UUID,
data: SnapshotUpdate,
user_id: UUID | None = Query(default=None),
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
snapshot = _get_owned_or_admin_override(
session,
snapshot_id,
current_user,
user_id,
)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(snapshot, key, value)
session.add(snapshot)
session.commit()
session.refresh(snapshot)
return snapshot
@router.delete("/{snapshot_id}", status_code=204)
def delete_snapshot(
snapshot_id: UUID,
user_id: UUID | None = Query(default=None),
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
snapshot = _get_owned_or_admin_override(
session,
snapshot_id,
current_user,
user_id,
)
session.delete(snapshot)
session.commit()