"""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