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>
433 lines
14 KiB
Python
433 lines
14 KiB
Python
import json
|
||
from datetime import date
|
||
from typing import Optional
|
||
from uuid import UUID, uuid4
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
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,
|
||
ProductCategory,
|
||
ProductInventory,
|
||
ProductPublic,
|
||
ProductWithInventory,
|
||
SkinConcern,
|
||
)
|
||
from innercontext.models.enums import (
|
||
AbsorptionSpeed,
|
||
DayTime,
|
||
PriceTier,
|
||
SkinType,
|
||
TextureType,
|
||
)
|
||
from innercontext.models.product import (
|
||
ActiveIngredient,
|
||
ProductContext,
|
||
ProductEffectProfile,
|
||
ProductInteraction,
|
||
)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Request / response schemas
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class ProductCreate(ProductBase):
|
||
pass
|
||
|
||
|
||
class ProductUpdate(SQLModel):
|
||
name: Optional[str] = None
|
||
brand: Optional[str] = None
|
||
line_name: Optional[str] = None
|
||
sku: Optional[str] = None
|
||
url: Optional[str] = None
|
||
barcode: Optional[str] = None
|
||
|
||
category: Optional[ProductCategory] = None
|
||
recommended_time: Optional[DayTime] = None
|
||
|
||
texture: Optional[TextureType] = None
|
||
absorption_speed: Optional[AbsorptionSpeed] = None
|
||
leave_on: Optional[bool] = None
|
||
|
||
price_tier: Optional[PriceTier] = None
|
||
size_ml: Optional[float] = None
|
||
pao_months: Optional[int] = None
|
||
|
||
inci: Optional[list[str]] = None
|
||
actives: Optional[list[ActiveIngredient]] = None
|
||
|
||
recommended_for: Optional[list[SkinType]] = None
|
||
|
||
targets: Optional[list[SkinConcern]] = None
|
||
contraindications: Optional[list[str]] = None
|
||
usage_notes: Optional[str] = None
|
||
|
||
fragrance_free: Optional[bool] = None
|
||
essential_oils_free: Optional[bool] = None
|
||
alcohol_denat_free: Optional[bool] = None
|
||
pregnancy_safe: Optional[bool] = None
|
||
|
||
product_effect_profile: Optional[ProductEffectProfile] = None
|
||
|
||
ph_min: Optional[float] = None
|
||
ph_max: Optional[float] = None
|
||
|
||
incompatible_with: Optional[list[ProductInteraction]] = None
|
||
synergizes_with: Optional[list[str]] = None
|
||
context_rules: Optional[ProductContext] = None
|
||
|
||
min_interval_hours: Optional[int] = None
|
||
max_frequency_per_week: Optional[int] = None
|
||
|
||
is_medication: Optional[bool] = None
|
||
is_tool: Optional[bool] = None
|
||
needle_length_mm: Optional[float] = None
|
||
|
||
personal_tolerance_notes: Optional[str] = None
|
||
personal_repurchase_intent: Optional[bool] = None
|
||
|
||
|
||
class ProductParseRequest(SQLModel):
|
||
text: str
|
||
|
||
|
||
class ProductParseResponse(SQLModel):
|
||
name: Optional[str] = None
|
||
brand: Optional[str] = None
|
||
line_name: Optional[str] = None
|
||
sku: Optional[str] = None
|
||
url: Optional[str] = None
|
||
barcode: Optional[str] = None
|
||
category: Optional[ProductCategory] = None
|
||
recommended_time: Optional[DayTime] = None
|
||
texture: Optional[TextureType] = None
|
||
absorption_speed: Optional[AbsorptionSpeed] = None
|
||
leave_on: Optional[bool] = None
|
||
price_tier: Optional[PriceTier] = None
|
||
size_ml: Optional[float] = None
|
||
full_weight_g: Optional[float] = None
|
||
empty_weight_g: Optional[float] = None
|
||
pao_months: Optional[int] = None
|
||
inci: Optional[list[str]] = None
|
||
actives: Optional[list[ActiveIngredient]] = None
|
||
recommended_for: Optional[list[SkinType]] = None
|
||
targets: Optional[list[SkinConcern]] = None
|
||
contraindications: Optional[list[str]] = None
|
||
usage_notes: Optional[str] = None
|
||
fragrance_free: Optional[bool] = None
|
||
essential_oils_free: Optional[bool] = None
|
||
alcohol_denat_free: Optional[bool] = None
|
||
pregnancy_safe: Optional[bool] = None
|
||
product_effect_profile: Optional[ProductEffectProfile] = None
|
||
ph_min: Optional[float] = None
|
||
ph_max: Optional[float] = None
|
||
incompatible_with: Optional[list[ProductInteraction]] = None
|
||
synergizes_with: Optional[list[str]] = None
|
||
context_rules: Optional[ProductContext] = None
|
||
min_interval_hours: Optional[int] = None
|
||
max_frequency_per_week: Optional[int] = None
|
||
is_medication: Optional[bool] = None
|
||
is_tool: Optional[bool] = None
|
||
needle_length_mm: Optional[float] = None
|
||
|
||
|
||
class InventoryCreate(SQLModel):
|
||
is_opened: bool = False
|
||
opened_at: Optional[date] = None
|
||
finished_at: Optional[date] = None
|
||
expiry_date: Optional[date] = None
|
||
current_weight_g: Optional[float] = None
|
||
last_weighed_at: Optional[date] = None
|
||
notes: Optional[str] = None
|
||
|
||
|
||
class InventoryUpdate(SQLModel):
|
||
is_opened: Optional[bool] = None
|
||
opened_at: Optional[date] = None
|
||
finished_at: Optional[date] = None
|
||
expiry_date: Optional[date] = None
|
||
current_weight_g: Optional[float] = None
|
||
last_weighed_at: Optional[date] = None
|
||
notes: Optional[str] = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Product routes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("", response_model=list[ProductPublic])
|
||
def list_products(
|
||
category: Optional[ProductCategory] = None,
|
||
brand: Optional[str] = None,
|
||
targets: Optional[list[SkinConcern]] = Query(default=None),
|
||
is_medication: Optional[bool] = None,
|
||
is_tool: Optional[bool] = None,
|
||
session: Session = Depends(get_session),
|
||
):
|
||
stmt = select(Product)
|
||
if category is not None:
|
||
stmt = stmt.where(Product.category == category)
|
||
if brand is not None:
|
||
stmt = stmt.where(Product.brand == brand)
|
||
if is_medication is not None:
|
||
stmt = stmt.where(Product.is_medication == is_medication)
|
||
if is_tool is not None:
|
||
stmt = stmt.where(Product.is_tool == is_tool)
|
||
|
||
products = session.exec(stmt).all()
|
||
|
||
# Filter by targets (JSON column — done in Python)
|
||
if targets:
|
||
target_values = {t.value for t in targets}
|
||
products = [
|
||
p
|
||
for p in products
|
||
if any(
|
||
(t.value if hasattr(t, "value") else t) in target_values
|
||
for t in (p.targets or [])
|
||
)
|
||
]
|
||
|
||
return products
|
||
|
||
|
||
@router.post("", response_model=ProductPublic, status_code=201)
|
||
def create_product(data: ProductCreate, session: Session = Depends(get_session)):
|
||
product = Product(
|
||
id=uuid4(),
|
||
**data.model_dump(),
|
||
)
|
||
session.add(product)
|
||
session.commit()
|
||
session.refresh(product)
|
||
return product
|
||
|
||
|
||
def _product_parse_system_prompt() -> str:
|
||
return """\
|
||
You are a skincare and cosmetics product data extraction expert. \
|
||
Given raw text (product page copy, ingredient list, label scan, etc.), \
|
||
extract structured product data and return it as a single JSON object.
|
||
|
||
RULES:
|
||
- Return ONLY raw JSON — no markdown code fences, no explanation, no preamble.
|
||
- Omit any field you cannot confidently determine from the text. Do not guess.
|
||
- All enum values must exactly match the allowed strings listed below.
|
||
- For INCI lists: return each ingredient as a separate string in the array, \
|
||
preserving standard INCI names exactly as they appear.
|
||
- For actives: extract name, concentration (numeric, 0–100), functions \
|
||
(use the allowed strings), and strength/irritation level if inferable.
|
||
- For effect_profile scores (0–5 int): ALWAYS return the full product_effect_profile \
|
||
object with all 13 fields. Infer each score from ingredient activity and product claims. \
|
||
Use 0 only when you truly have no basis for an estimate.
|
||
- For pH: extract from explicit mention (e.g. "pH 5.5", "pH range 4.0–5.0"). \
|
||
Do not infer from ingredients alone.
|
||
- For context_rules: infer from usage instructions and ingredient interactions \
|
||
(e.g. "do not use with AHAs" → safe_after_acids: false).
|
||
- fragrance_free / essential_oils_free / alcohol_denat_free: infer from INCI \
|
||
or explicit claims. Fragrance = "Parfum" or "Fragrance" in INCI → fragrance_free: false.
|
||
- For leave_on: true = leave-on treatment, false = rinse-off (cleanser, mask to rinse).
|
||
- recommended_time: "am" if contains SPF or vitamin C; "pm" if retinoid/retinol; \
|
||
"both" otherwise (when unclear, use "both").
|
||
|
||
ENUM ALLOWED VALUES (use ONLY these exact strings):
|
||
|
||
category: "cleanser" | "toner" | "essence" | "serum" | "moisturizer" | "spf" | \
|
||
"mask" | "exfoliant" | "hair_treatment" | "tool" | "spot_treatment" | "oil"
|
||
|
||
recommended_time: "am" | "pm" | "both"
|
||
|
||
texture: "watery" | "gel" | "emulsion" | "cream" | "oil" | "balm" | "foam" | "fluid"
|
||
|
||
absorption_speed: "very_fast" | "fast" | "moderate" | "slow" | "very_slow"
|
||
|
||
price_tier: "budget" | "mid" | "premium" | "luxury"
|
||
|
||
recommended_for (array, pick applicable):
|
||
"dry" | "oily" | "combination" | "sensitive" | "normal" | "acne_prone"
|
||
|
||
targets (array, pick applicable):
|
||
"acne" | "rosacea" | "hyperpigmentation" | "aging" | "dehydration" | "redness" | \
|
||
"damaged_barrier" | "pore_visibility" | "uneven_texture" | "hair_growth" | "sebum_excess"
|
||
|
||
actives[].functions (array, pick applicable):
|
||
"humectant" | "emollient" | "occlusive" | "exfoliant_aha" | "exfoliant_bha" | \
|
||
"exfoliant_pha" | "retinoid" | "antioxidant" | "soothing" | "barrier_support" | \
|
||
"brightening" | "anti_acne" | "ceramide" | "niacinamide" | "sunscreen" | "peptide" | \
|
||
"hair_growth_stimulant" | "prebiotic" | "vitamin_c"
|
||
|
||
actives[].strength_level: 1 (low) | 2 (medium) | 3 (high)
|
||
actives[].irritation_potential: 1 (low) | 2 (medium) | 3 (high)
|
||
|
||
incompatible_with[].scope: "same_step" | "same_day" | "same_period"
|
||
|
||
OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
|
||
{
|
||
"name": string,
|
||
"brand": string,
|
||
"line_name": string,
|
||
"sku": string,
|
||
"url": string,
|
||
"barcode": string,
|
||
"category": string,
|
||
"recommended_time": string,
|
||
"texture": string,
|
||
"absorption_speed": string,
|
||
"leave_on": boolean,
|
||
"price_tier": string,
|
||
"size_ml": number,
|
||
"full_weight_g": number,
|
||
"empty_weight_g": number,
|
||
"pao_months": integer,
|
||
"inci": [string, ...],
|
||
"actives": [
|
||
{
|
||
"name": string,
|
||
"percent": number,
|
||
"functions": [string, ...],
|
||
"strength_level": 1|2|3,
|
||
"irritation_potential": 1|2|3
|
||
}
|
||
],
|
||
"recommended_for": [string, ...],
|
||
"targets": [string, ...],
|
||
"contraindications": [string, ...],
|
||
"usage_notes": string,
|
||
"fragrance_free": boolean,
|
||
"essential_oils_free": boolean,
|
||
"alcohol_denat_free": boolean,
|
||
"pregnancy_safe": boolean,
|
||
"product_effect_profile": {
|
||
"hydration_immediate": integer (0-5),
|
||
"hydration_long_term": integer (0-5),
|
||
"barrier_repair_strength": integer (0-5),
|
||
"soothing_strength": integer (0-5),
|
||
"exfoliation_strength": integer (0-5),
|
||
"retinoid_strength": integer (0-5),
|
||
"irritation_risk": integer (0-5),
|
||
"comedogenic_risk": integer (0-5),
|
||
"barrier_disruption_risk": integer (0-5),
|
||
"dryness_risk": integer (0-5),
|
||
"brightening_strength": integer (0-5),
|
||
"anti_acne_strength": integer (0-5),
|
||
"anti_aging_strength": integer (0-5)
|
||
},
|
||
"ph_min": number,
|
||
"ph_max": number,
|
||
"incompatible_with": [
|
||
{"target": string, "scope": string, "reason": string}
|
||
],
|
||
"synergizes_with": [string, ...],
|
||
"context_rules": {
|
||
"safe_after_shaving": boolean,
|
||
"safe_after_acids": boolean,
|
||
"safe_after_retinoids": boolean,
|
||
"safe_with_compromised_barrier": boolean,
|
||
"low_uv_only": boolean
|
||
},
|
||
"min_interval_hours": integer,
|
||
"max_frequency_per_week": integer,
|
||
"is_medication": boolean,
|
||
"is_tool": boolean,
|
||
"needle_length_mm": number
|
||
}
|
||
"""
|
||
|
||
|
||
@router.post("/parse-text", response_model=ProductParseResponse)
|
||
def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
|
||
client, model = get_gemini_client()
|
||
response = client.models.generate_content(
|
||
model=model,
|
||
contents=f"Extract product data from this text:\n\n{data.text}",
|
||
config=genai_types.GenerateContentConfig(
|
||
system_instruction=_product_parse_system_prompt(),
|
||
response_mime_type="application/json",
|
||
max_output_tokens=4096,
|
||
temperature=0.0,
|
||
),
|
||
)
|
||
try:
|
||
parsed = json.loads(response.text)
|
||
except (json.JSONDecodeError, Exception) as e:
|
||
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
|
||
try:
|
||
return ProductParseResponse.model_validate(parsed)
|
||
except ValidationError as e:
|
||
raise HTTPException(status_code=422, detail=e.errors())
|
||
|
||
|
||
@router.get("/{product_id}", response_model=ProductWithInventory)
|
||
def get_product(product_id: UUID, session: Session = Depends(get_session)):
|
||
product = get_or_404(session, Product, product_id)
|
||
inventory = session.exec(
|
||
select(ProductInventory).where(ProductInventory.product_id == product_id)
|
||
).all()
|
||
result = ProductWithInventory.model_validate(product, from_attributes=True)
|
||
result.inventory = list(inventory)
|
||
return result
|
||
|
||
|
||
@router.patch("/{product_id}", response_model=ProductPublic)
|
||
def update_product(
|
||
product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session)
|
||
):
|
||
product = get_or_404(session, Product, product_id)
|
||
for key, value in data.model_dump(exclude_unset=True).items():
|
||
setattr(product, key, value)
|
||
session.add(product)
|
||
session.commit()
|
||
session.refresh(product)
|
||
return product
|
||
|
||
|
||
@router.delete("/{product_id}", status_code=204)
|
||
def delete_product(product_id: UUID, session: Session = Depends(get_session)):
|
||
product = get_or_404(session, Product, product_id)
|
||
session.delete(product)
|
||
session.commit()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Product inventory sub-routes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/{product_id}/inventory", response_model=list[ProductInventory])
|
||
def list_product_inventory(product_id: UUID, session: Session = Depends(get_session)):
|
||
get_or_404(session, Product, product_id)
|
||
stmt = select(ProductInventory).where(ProductInventory.product_id == product_id)
|
||
return session.exec(stmt).all()
|
||
|
||
|
||
@router.post(
|
||
"/{product_id}/inventory", response_model=ProductInventory, status_code=201
|
||
)
|
||
def create_product_inventory(
|
||
product_id: UUID,
|
||
data: InventoryCreate,
|
||
session: Session = Depends(get_session),
|
||
):
|
||
get_or_404(session, Product, product_id)
|
||
entry = ProductInventory(
|
||
id=uuid4(),
|
||
product_id=product_id,
|
||
**data.model_dump(),
|
||
)
|
||
session.add(entry)
|
||
session.commit()
|
||
session.refresh(entry)
|
||
return entry
|