From 914c6087bd2dcbda4bc8988c2846ecaf1b8a5bc8 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 22:00:48 +0100 Subject: [PATCH] fix(products): work around Gemini int-enum schema rejection in parse-text Gemini API rejects int-valued enums (StrengthLevel) in response_schema, raising a validation error before any request is sent. Fix by introducing AIActiveIngredient (inherits ActiveIngredient, overrides strength_level and irritation_potential as Optional[int]) and ProductParseLLMResponse used only as the Gemini schema. The two-step validation converts ints back to StrengthLevel via Pydantic coercion. Adds a test covering the numeric strength level path. Co-Authored-By: Claude Sonnet 4.6 --- backend/innercontext/api/products.py | 18 ++++++++++++++++-- backend/tests/test_products.py | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 06b7aa9..bed9556 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -143,6 +143,19 @@ class ProductParseResponse(SQLModel): needle_length_mm: Optional[float] = None +class AIActiveIngredient(ActiveIngredient): + # Gemini API rejects int-enum values in response_schema; override with plain int. + strength_level: Optional[int] = None + irritation_potential: Optional[int] = None + + +class ProductParseLLMResponse(ProductParseResponse): + # Gemini response schema currently requires enum values to be strings. + # Strength fields are numeric in our domain (1-3), so keep them as ints here + # and convert via ProductParseResponse validation afterward. + actives: Optional[list[AIActiveIngredient]] = None + + class InventoryCreate(SQLModel): is_opened: bool = False opened_at: Optional[date] = None @@ -373,7 +386,7 @@ 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, + response_schema=ProductParseLLMResponse, max_output_tokens=16384, temperature=0.0, ), @@ -387,7 +400,8 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: except json.JSONDecodeError as e: raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}") try: - return ProductParseResponse.model_validate(parsed) + llm_parsed = ProductParseLLMResponse.model_validate(parsed) + return ProductParseResponse.model_validate(llm_parsed.model_dump()) except ValidationError as e: raise HTTPException(status_code=422, detail=e.errors()) diff --git a/backend/tests/test_products.py b/backend/tests/test_products.py index 1fae538..956cc75 100644 --- a/backend/tests/test_products.py +++ b/backend/tests/test_products.py @@ -199,3 +199,22 @@ def test_create_inventory(client, created_product): def test_create_inventory_product_not_found(client): r = client.post(f"/products/{uuid.uuid4()}/inventory", json={}) assert r.status_code == 404 + + +def test_parse_text_accepts_numeric_strength_levels(client, monkeypatch): + from innercontext.api import products as products_api + + class _FakeResponse: + text = ( + '{"name":"Test Serum","actives":[{"name":"Niacinamide","percent":10,' + '"functions":["niacinamide"],"strength_level":2,"irritation_potential":1}]}' + ) + + monkeypatch.setattr(products_api, "call_gemini", lambda **kwargs: _FakeResponse()) + + r = client.post("/products/parse-text", json={"text": "dummy input"}) + assert r.status_code == 200 + data = r.json() + assert data["name"] == "Test Serum" + assert data["actives"][0]["strength_level"] == 2 + assert data["actives"][0]["irritation_potential"] == 1