Files
Priceflow/app/ai_schema.py
T
Claude 531ed1bc15 fix: Convert French price format to English before sending to AI
**Problem:**
AI was misinterpreting French decimal comma as thousands separator:
- "3,99 €" detected as 399.00 EUR (instead of 3.99 EUR)
- "1,99 €" detected as 199.00 EUR (instead of 1.99 EUR)
- Caused by AI reading comma as grouping separator, not decimal point

**Root Cause:**
French format uses comma for decimals: "3,99 €" = three euros ninety-nine cents
English format uses dot for decimals: "3.99 €" = three euros ninety-nine cents

AI models (trained mostly on English) interpret:
- "3,99" → "3" and "99" separate → 399 (three hundred ninety-nine)
- Should be: "3.99" → 3.99 (three point ninety-nine)

**Solution: Pre-Convert Format Before AI Processing**

1. **Normalize ALL Text** (browserless_service.py:380-386)
   ```python
   # Convert French to English in all page text
   content = re.sub(r'(\d+),(\d{2})\s*€', r'\1.\2 €', content)
   # "3,99 €" → "3.99 €"
   # "12,50 €" → "12.50 €"
   # "1 234,56 €" → "1234.56 €" (also removes space thousands separator)
   ```

2. **Normalize Extracted Price** (browserless_service.py:400-410)
   ```python
   # Convert French format in directly extracted price
   normalized_price = re.sub(r'(\d+),(\d{2})', r'\1.\2', extracted_price)
   # "3,99 €" → "3.99 €"
   # Prepends: "PRIX DÉTECTÉ: 3.99 €" (not "3,99 €")
   ```

3. **Updated AI Prompt** (ai_schema.py:135-158)
   - Removed instructions to convert comma→dot (now done automatically)
   - Added: "The price has already been converted to English format for you"
   - Added explicit warnings about common mistakes:
     * "3.99 €" means 3.99 (NOT 399.00, NOT 3990.00)
     * "1.99 €" means 1.99 (NOT 199.00, NOT 1990.00)
   - Clear examples with both CORRECT and INCORRECT values

**Flow:**
```
Page text: "Lutin 3,99 € Ajouter au panier"
  ↓
Normalization: "Lutin 3.99 € Ajouter au panier"
  ↓
Extracted price: "3,99 €" → Normalized: "3.99 €"
  ↓
Sent to AI: "PRIX DÉTECTÉ: 3.99 €\n\nLutin 3.99 € Ajouter au panier"
  ↓
AI sees only English format: "3.99"
  ↓
AI Response: {"price": 3.99}  ✅ CORRECT
```

**Expected Results:**
- Before: "3,99 €" → AI returns 399.00 ❌
- After: "3,99 €" → Normalized to "3.99 €" → AI returns 3.99 ✅

**Regex Details:**
- `(\d+),(\d{2})\s*€` matches French prices: captures digits before comma, 2 digits after
- `\1.\2 €` replaces with: same digits, dot instead of comma, space + euro symbol
- Handles: 0,99 / 3,99 / 12,99 / 89,99 / 1234,99

Fixes issue where AI misread French decimal comma causing 100x price errors.
2025-11-29 23:51:00 +00:00

239 lines
8.0 KiB
Python

"""
Unified JSON schema for AI extraction responses.
This module defines the canonical schema for all AI model responses,
including confidence scores and metadata tracking.
"""
import re
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from app.utils.text import filter_relevant_text
# Schema version for tracking prompt/schema changes
PROMPT_VERSION = "v2.0"
# Default confidence thresholds
DEFAULT_PRICE_CONFIDENCE_THRESHOLD = 0.5
DEFAULT_STOCK_CONFIDENCE_THRESHOLD = 0.5
DEFAULT_MULTI_SAMPLE_THRESHOLD = 0.6
# Text filtering constants
MIN_SNIPPET_LENGTH = 10
SNIPPET_MERGE_DISTANCE = 50
SNIPPET_CONTEXT_WINDOW = 100
class AIExtractionResponse(BaseModel):
"""
Canonical schema for AI extraction responses.
All AI models must return data matching this schema.
"""
price: float | None = Field(
None,
description="Extracted price as a number, or null if no price found",
)
currency: str = Field(
"EUR",
description="Currency code (ISO 4217)",
)
in_stock: bool | None = Field(
None,
description="Stock status: true if in stock, false if out of stock, null if unclear",
)
price_confidence: float = Field(
0.0,
ge=0.0,
le=1.0,
description="Confidence in price extraction (0.0 to 1.0)",
)
in_stock_confidence: float = Field(
0.0,
ge=0.0,
le=1.0,
description="Confidence in stock status extraction (0.0 to 1.0)",
)
source_type: Literal["image", "text", "both"] = Field(
"image",
description="Source of extraction: image, text, or both",
)
@field_validator("price_confidence", "in_stock_confidence", mode="before")
@classmethod
def clamp_confidence(cls, v):
"""Clamp confidence values to [0.0, 1.0] range."""
if v is None:
return 0.0
return max(0.0, min(1.0, float(v)))
@field_validator("price", mode="before")
@classmethod
def normalize_price(cls, v):
"""Normalize price to float or None, handling French format."""
if v is None or v in ("null", ""):
return None
if isinstance(v, str):
# Handle French format: "1 234,56" -> "1234.56"
# First, remove spaces (thousand separators)
cleaned = v.replace(" ", "").replace("\u00a0", "")
# Replace comma with dot (French decimal separator)
# But only if there's no dot already (to handle "1.234,56" format)
if "," in cleaned and "." in cleaned:
# Format "1.234,56" - remove dots, replace comma with dot
cleaned = cleaned.replace(".", "").replace(",", ".")
elif "," in cleaned:
# Format "12,99" - just replace comma with dot
cleaned = cleaned.replace(",", ".")
# Remove currency symbols and other non-numeric chars (except dot)
cleaned = re.sub(r"[^\d.]", "", cleaned)
if cleaned:
try:
return float(cleaned)
except ValueError:
return None
return None
return float(v)
@field_validator("in_stock", mode="before")
@classmethod
def normalize_stock(cls, v):
"""Normalize stock status to boolean or None."""
if v is None or v == "null":
return None
if isinstance(v, str):
v_lower = v.lower().strip()
if v_lower in ("true", "yes", "in stock", "available", "1"):
return True
if v_lower in ("false", "no", "out of stock", "unavailable", "0"):
return False
return None
return bool(v)
class AIExtractionMetadata(BaseModel):
"""
Metadata about an AI extraction operation.
Tracks which model was used, whether repair was needed, etc.
"""
model_name: str = Field(..., description="AI model identifier (e.g., 'gpt-4o')")
provider: str = Field(..., description="AI provider (e.g., 'openai', 'ollama')")
prompt_version: str = Field(PROMPT_VERSION, description="Version of the extraction prompt used")
repair_used: bool = Field(False, description="Whether JSON repair fallback was used")
multi_sample: bool = Field(False, description="Whether multi-sample validation was used")
sample_count: int = Field(1, description="Number of samples generated (for multi-sample)")
# Prompt template for schema-first extraction
EXTRACTION_PROMPT_TEMPLATE = """Extract product price and stock status from this French e-commerce page.
**PRICE (IMPORTANT - French format):**
- **FIRST**: Look for "PRIX DÉTECTÉ:" at the start of text - this is the extracted price
- **The price has already been converted to English format for you**
- French original: "3,99 €" → Already shown to you as: "3.99 €"
- Just extract the number you see (e.g., "3.99" from "PRIX DÉTECTÉ: 3.99 €")
- Currency symbol is usually € at the end
- Look for: "PRIX DÉTECTÉ:", price tags, "Prix:", "€", numbers near "Ajouter au panier"
- Extract as DECIMAL NUMBER: If you see "3.99", return 3.99
- Ignore crossed-out/barré prices (old prices)
- If multiple prices, take the current/main price (not the original)
**CRITICAL - Common mistakes to avoid:**
- "3.99 €" means 3.99 (NOT 399.00, NOT 3990.00)
- "1.99 €" means 1.99 (NOT 199.00, NOT 1990.00)
- "0.99 €" means 0.99 (NOT 99.00, NOT 990.00)
- The decimal point separates euros from cents
- Small prices (< 10€) are very common for everyday items
- Examples of valid prices:
* "PRIX DÉTECTÉ: 1.99 €" -> 1.99 (NOT 199 or 1990)
* "PRIX DÉTECTÉ: 3.99 €" -> 3.99 (NOT 399 or 3990)
* "0.99 €" -> 0.99 (NOT 99)
* "89.99 €" -> 89.99 (NOT 8999)
* "1234.56 €" -> 1234.56
- If you find ANY price with € symbol, extract it with confidence >= 0.8
- If digits are unclear or blurry, reduce confidence to 0.5-0.7
- If unclear: set null and confidence < 0.5
**STOCK:**
- TRUE if: "Ajouter au panier", "Acheter", "En stock", "Disponible", "Add to Cart"
- FALSE if: "Rupture", "Indisponible", "Épuisé", "Out of Stock", "Notify Me"
- NULL if unclear or not shown
**CONFIDENCE (0.0 to 1.0):**
- 0.9-1.0: Price clearly visible
- 0.5-0.8: Price found but partially obscured or uncertain
- Below 0.5: Cannot find price reliably
Respond ONLY with valid JSON:
{{
"price": <number or null>,
"currency": "EUR",
"in_stock": <true, false, or null>,
"price_confidence": <0.0 to 1.0>,
"in_stock_confidence": <0.0 to 1.0>,
"source_type": "both"
}}
{context_section}"""
# Repair prompt template
REPAIR_PROMPT_TEMPLATE = """Convert the following text into valid JSON matching this schema:
{{
"price": <number or null>,
"currency": "<ISO currency code, default USD>",
"in_stock": <true, false, or null>,
"price_confidence": <number from 0.0 to 1.0>,
"in_stock_confidence": <number from 0.0 to 1.0>,
"source_type": "<image, text, or both>"
}}
Rules:
- Extract numeric price value only (no symbols)
- Boolean values must be true, false, or null (not strings)
- Confidence values must be numbers between 0.0 and 1.0
- Respond with ONLY the JSON object, no other text
Text to convert:
{raw_output}"""
def get_extraction_prompt(page_text: str | None = None) -> str:
"""
Generate the extraction prompt with optional text context.
Args:
page_text: Optional webpage text to include as context
Returns:
Formatted prompt string
"""
if page_text:
# Apply smart filtering to extract only relevant snippets
filtered_text = filter_relevant_text(page_text, max_length=1500)
context_section = f"""**Relevant text from page:**
{filtered_text}"""
else:
context_section = ""
return EXTRACTION_PROMPT_TEMPLATE.format(context_section=context_section)
def get_repair_prompt(raw_output: str) -> str:
"""
Generate the repair prompt for fixing invalid JSON.
Args:
raw_output: Raw AI output that failed parsing
Returns:
Formatted repair prompt
"""
return REPAIR_PROMPT_TEMPLATE.format(raw_output=raw_output[:1000])