350 lines
12 KiB
Python
350 lines
12 KiB
Python
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 1–5 scale (1 = minimal, 5 = maximal).
|
||
- 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"
|
||
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 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":…, "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()
|