403c923188
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>
98 lines
3.7 KiB
Python
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
|