From 81b1cacc5ccb764a7012f310f592e2b63636d64e Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 00:46:23 +0100 Subject: [PATCH] refactor(llm): use response_schema with typed enums in all Gemini calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/innercontext/api/products.py | 28 ++-------------------------- backend/innercontext/api/routines.py | 2 +- backend/innercontext/api/skincare.py | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 6dff032..945ab53 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -1,12 +1,8 @@ import json -import logging -import re from datetime import date from typing import Optional from uuid import UUID, uuid4 -log = logging.getLogger(__name__) - from fastapi import APIRouter, Depends, HTTPException, Query from google.genai import types as genai_types from pydantic import ValidationError @@ -378,37 +374,17 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: config=genai_types.GenerateContentConfig( system_instruction=_product_parse_system_prompt(), response_mime_type="application/json", + response_schema=ProductParseResponse, max_output_tokens=16384, 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 if not raw: - raise HTTPException( - 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) + raise HTTPException(status_code=502, detail="LLM returned an empty response") try: parsed = json.loads(raw) 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}") try: return ProductParseResponse.model_validate(parsed) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 48610b5..5cc129f 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -108,7 +108,7 @@ class BatchSuggestion(SQLModel): class _StepOut(PydanticBase): product_id: Optional[str] = None - action_type: Optional[str] = None + action_type: Optional[GroomingAction] = None dose: Optional[str] = None region: Optional[str] = None action_notes: Optional[str] = None diff --git a/backend/innercontext/api/skincare.py b/backend/innercontext/api/skincare.py index 8e2cf9c..57b60b1 100644 --- a/backend/innercontext/api/skincare.py +++ b/backend/innercontext/api/skincare.py @@ -5,6 +5,7 @@ from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, File, HTTPException, 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 @@ -70,6 +71,21 @@ class SkinPhotoAnalysisResponse(SQLModel): 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 # --------------------------------------------------------------------------- @@ -148,6 +164,7 @@ async def analyze_skin_photos( config=genai_types.GenerateContentConfig( system_instruction=_skin_photo_system_prompt(), response_mime_type="application/json", + response_schema=_SkinAnalysisOut, max_output_tokens=2048, temperature=0.0, ),