Files
TieMeasureFlow/src/backend/services/ai_service.py
T
Adriano 403c923188 fix(ai): risposta AI malformata -> 502, non 400 (#36 punto 7)
json.JSONDecodeError e' un ValueError: KeyError/JSONDecodeError sulla
risposta del modello finivano nell'except ValueError del router (pensato
per i nostri errori di configurazione, es. API key mancante) e uscivano
come 400 invece di 502. Nuova AIResponseError dedicata in ai_service.py
per contenuto malformato o JSON non valido; recipes.py la traduce in 502,
toccato solo per questo except.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:43:42 +02:00

98 lines
3.7 KiB
Python

"""AI service for parsing technical sheets via OpenRouter."""
import asyncio
import json
import logging
from io import BytesIO
import httpx
import pdfplumber
from src.backend.config import settings
logger = logging.getLogger(__name__)
class AIResponseError(Exception):
"""The AI model's response could not be used: missing fields or invalid JSON.
Distinct from ValueError (raised for our own misconfiguration, e.g. a
missing API key) so the router can tell "we are broken" (400) apart from
"the upstream AI service is broken" (502) instead of both landing on the
same except clause.
"""
SYSTEM_PROMPT = """Sei un assistente specializzato nell'analisi di schede tecniche industriali.
Analizza il testo estratto da una scheda tecnica e identifica i blocchi operativi distinti.
Per ogni blocco, estrai:
- title: titolo breve del blocco (es. "MATERIALI", "TEMPERATURA", "MISURA", "IMBALLO")
- directive: istruzione operativa principale (es. "VERIFICARE ATTREZZATURE")
- description: dettagli completi del blocco, preservando gli "a capo" originali
Rispondi SOLO con un array JSON valido, senza markdown o testo aggiuntivo.
Esempio:
[
{"title": "MATERIALI", "directive": "Verificare materiali", "description": "RIGIDO (36): AFT9/UV CRI 7\\nMORBIDO (E38): M70"},
{"title": "TEMPERATURA", "directive": "Verificare impostazioni", "description": "RIGIDO: TEMP: 170/170/170/170/170\\nMORBIDO: TEMP: 130-135-140-145"}
]"""
def extract_text_from_pdf(pdf_bytes: bytes) -> str:
with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
pages = []
for page in pdf.pages:
text = page.extract_text()
if text:
pages.append(text)
return "\n\n---\n\n".join(pages)
async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
# pdfplumber parses synchronously and can take a while on a big PDF: off
# the event loop, or one upload stalls every other request being served.
text = await asyncio.to_thread(extract_text_from_pdf, pdf_bytes)
if not text.strip():
return []
if not settings.openrouter_api_key:
raise ValueError("OPENROUTER_API_KEY non configurata")
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
},
json={
"model": settings.openrouter_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Analizza questa scheda tecnica ed estrai i task:\n\n{text}"},
],
"temperature": 0.1,
},
)
resp.raise_for_status()
data = resp.json()
try:
content = data["choices"][0]["message"]["content"].strip()
except (KeyError, IndexError, TypeError, AttributeError) as e:
raise AIResponseError(f"Risposta del modello AI malformata: {e}") from e
# Strip markdown fences if present
if content.startswith("```"):
content = content.split("\n", 1)[1]
if content.endswith("```"):
content = content[:-3]
content = content.strip()
try:
return json.loads(content)
except json.JSONDecodeError as e:
# json.JSONDecodeError is itself a ValueError: without this, the
# router's `except ValueError` (meant for our own config errors)
# would misreport a broken upstream response as our fault (400).
raise AIResponseError(f"Risposta del modello AI non e' JSON valido: {e}") from e