refactor(llm): use response_schema with typed enums in all Gemini calls

Pass response_schema to all three generate_content calls so Gemini
constrains its output to valid enum values and correct JSON structure:

- routines.py: _StepOut.action_type Optional[str] → Optional[GroomingAction]
- skincare.py: add _SkinAnalysisOut(PydanticBase) with OverallSkinState,
  SkinType, SkinTexture, BarrierState, SkinConcern enums; add response_schema
- products.py: pass ProductParseResponse directly as response_schema;
  remove NaN/Infinity/undefined regex cleanup, markdown-fence extraction,
  finish_reason logging, and re import — all now unnecessary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-03-01 00:46:23 +01:00
parent 6e7f715ef2
commit 81b1cacc5c
3 changed files with 20 additions and 27 deletions

View file

@ -1,12 +1,8 @@
import json import json
import logging
import re
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
log = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from google.genai import types as genai_types from google.genai import types as genai_types
from pydantic import ValidationError from pydantic import ValidationError
@ -378,37 +374,17 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
config=genai_types.GenerateContentConfig( config=genai_types.GenerateContentConfig(
system_instruction=_product_parse_system_prompt(), system_instruction=_product_parse_system_prompt(),
response_mime_type="application/json", response_mime_type="application/json",
response_schema=ProductParseResponse,
max_output_tokens=16384, max_output_tokens=16384,
temperature=0.0, temperature=0.0,
), ),
) )
candidate = response.candidates[0] if response.candidates else None
finish_reason = str(candidate.finish_reason) if candidate else "unknown"
raw = response.text raw = response.text
if not raw: if not raw:
raise HTTPException( raise HTTPException(status_code=502, detail="LLM returned an empty response")
status_code=502,
detail=f"LLM returned an empty response (finish_reason={finish_reason})",
)
# Fallback: extract JSON object in case the model adds preamble or markdown fences
if not raw.lstrip().startswith("{"):
start = raw.find("{")
end = raw.rfind("}")
if start != -1 and end != -1:
raw = raw[start : end + 1]
# Replace JS-style non-JSON literals that some models emit
raw = re.sub(r":\s*NaN\b", ": null", raw)
raw = re.sub(r":\s*Infinity\b", ": null", raw)
raw = re.sub(r":\s*undefined\b", ": null", raw)
try: try:
parsed = json.loads(raw) parsed = json.loads(raw)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
log.error(
"Gemini parse-text JSON error at pos %d finish_reason=%s context=%r",
e.pos,
finish_reason,
raw[max(0, e.pos - 80) : e.pos + 80],
)
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}") raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
try: try:
return ProductParseResponse.model_validate(parsed) return ProductParseResponse.model_validate(parsed)

View file

@ -108,7 +108,7 @@ class BatchSuggestion(SQLModel):
class _StepOut(PydanticBase): class _StepOut(PydanticBase):
product_id: Optional[str] = None product_id: Optional[str] = None
action_type: Optional[str] = None action_type: Optional[GroomingAction] = None
dose: Optional[str] = None dose: Optional[str] = None
region: Optional[str] = None region: Optional[str] = None
action_notes: Optional[str] = None action_notes: Optional[str] = None

View file

@ -5,6 +5,7 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from google.genai import types as genai_types from google.genai import types as genai_types
from pydantic import BaseModel as PydanticBase
from pydantic import ValidationError from pydantic import ValidationError
from sqlmodel import Session, SQLModel, select from sqlmodel import Session, SQLModel, select
@ -70,6 +71,21 @@ class SkinPhotoAnalysisResponse(SQLModel):
notes: Optional[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 # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -148,6 +164,7 @@ async def analyze_skin_photos(
config=genai_types.GenerateContentConfig( config=genai_types.GenerateContentConfig(
system_instruction=_skin_photo_system_prompt(), system_instruction=_skin_photo_system_prompt(),
response_mime_type="application/json", response_mime_type="application/json",
response_schema=_SkinAnalysisOut,
max_output_tokens=2048, max_output_tokens=2048,
temperature=0.0, temperature=0.0,
), ),