From d2bf0b5828f8041a48ec977b5151d64bb4367bc2 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 24 May 2026 11:01:00 +0000 Subject: [PATCH] feat(maker): AI parsing schede tecniche PDF via OpenRouter - Nuovo servizio ai_service.py: estrae testo da PDF (pdfplumber) + analisi AI (OpenRouter) - Endpoint POST /api/recipes/{id}/parse-technical-sheet con validazione file - UI: bottone "Importa da PDF" nel task editor con modale upload + preview editabile - Task suggeriti modificabili/rimovibili prima della creazione bulk - Config: OPENROUTER_API_KEY e OPENROUTER_MODEL in .env - Dipendenze: pdfplumber + httpx aggiunti a server deps Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 4 + pyproject.toml | 2 + src/backend/api/routers/recipes.py | 34 ++- src/backend/config.py | 4 + src/backend/services/ai_service.py | 74 ++++++ src/frontend/flask_app/blueprints/maker.py | 21 ++ .../templates/maker/task_editor.html | 219 +++++++++++++++++- .../translations/en/LC_MESSAGES/messages.po | 36 +++ .../translations/it/LC_MESSAGES/messages.po | 36 +++ uv.lock | 119 ++++++++++ 10 files changed, 539 insertions(+), 10 deletions(-) create mode 100644 src/backend/services/ai_service.py diff --git a/.env.example b/.env.example index 7fd29a8..e4f24b2 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,10 @@ MAX_UPLOAD_SIZE_MB=50 # --- Setup Page --- SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato +# --- AI (OpenRouter) --- +OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato +OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare + # --- Docker --- DB_ROOT_PASSWORD=root_password_change_me NGINX_PORT=80 diff --git a/pyproject.toml b/pyproject.toml index 98409dc..2fb2215 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ server = [ "plotly>=5.0.0", "kaleido>=0.2.0", "weasyprint>=62.0", + "pdfplumber>=0.10.0", + "httpx>=0.27.0", ] # Frontend (Flask tablet UI). diff --git a/src/backend/api/routers/recipes.py b/src/backend/api/routers/recipes.py index 3447770..11cabcf 100644 --- a/src/backend/api/routers/recipes.py +++ b/src/backend/api/routers/recipes.py @@ -1,5 +1,7 @@ -"""Recipe router - CRUD, versioning, barcode lookup.""" -from fastapi import APIRouter, Depends, Query +"""Recipe router - CRUD, versioning, barcode lookup, AI parsing.""" +import logging + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from sqlalchemy.ext.asyncio import AsyncSession from src.backend.database import get_db @@ -164,3 +166,31 @@ async def get_measurement_count( """ count = await recipe_service.get_measurement_count(db, recipe_id, version_number) return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count} + + +@router.post("/{recipe_id}/parse-technical-sheet") +async def parse_technical_sheet( + recipe_id: int, + file: UploadFile = File(...), + _user: User = Depends(require_maker), + db: AsyncSession = Depends(get_db), +): + """Parse a PDF technical sheet using AI and return suggested tasks.""" + from src.backend.services import ai_service + + if not file.content_type or "pdf" not in file.content_type: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Il file deve essere un PDF") + + pdf_bytes = await file.read() + if len(pdf_bytes) > 20 * 1024 * 1024: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File troppo grande (max 20MB)") + + try: + tasks = await ai_service.parse_technical_sheet(pdf_bytes) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: + logging.getLogger(__name__).error("AI parsing error: %s", e) + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI") + + return {"recipe_id": recipe_id, "suggested_tasks": tasks} diff --git a/src/backend/config.py b/src/backend/config.py index 4281430..e850149 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -34,6 +34,10 @@ class Settings(BaseSettings): # Setup page (empty = disabled) setup_password: str | None = None + # AI / OpenRouter (for technical sheet parsing) + openrouter_api_key: str | None = None + openrouter_model: str = "anthropic/claude-sonnet-4" + @property def database_url(self) -> str: """Async MySQL connection string.""" diff --git a/src/backend/services/ai_service.py b/src/backend/services/ai_service.py new file mode 100644 index 0000000..852273d --- /dev/null +++ b/src/backend/services/ai_service.py @@ -0,0 +1,74 @@ +"""AI service for parsing technical sheets via OpenRouter.""" +import json +import logging +from io import BytesIO + +import httpx +import pdfplumber + +from src.backend.config import settings + +logger = logging.getLogger(__name__) + +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]: + text = 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() + content = data["choices"][0]["message"]["content"].strip() + + # Strip markdown fences if present + if content.startswith("```"): + content = content.split("\n", 1)[1] + if content.endswith("```"): + content = content[:-3] + content = content.strip() + + return json.loads(content) diff --git a/src/frontend/flask_app/blueprints/maker.py b/src/frontend/flask_app/blueprints/maker.py index f0dd07e..769d54f 100644 --- a/src/frontend/flask_app/blueprints/maker.py +++ b/src/frontend/flask_app/blueprints/maker.py @@ -424,3 +424,24 @@ def api_get_measurement_count(recipe_id: int, version_number: int): return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), 200 + + +@maker_bp.route("/api/recipes//parse-technical-sheet", methods=["POST"]) +@login_required +@role_required("Maker") +def api_parse_technical_sheet(recipe_id: int): + """Proxy: Parse PDF technical sheet with AI.""" + file = request.files.get("file") + if not file: + return jsonify({"error": True, "detail": "Nessun file caricato"}), 400 + + api_key = session.get("api_key", "") + base_url = Config.API_SERVER_URL.rstrip("/") + resp = http_requests.post( + f"{base_url}/api/recipes/{recipe_id}/parse-technical-sheet", + headers={"X-API-Key": api_key}, + files={"file": (file.filename, file.stream, file.content_type)}, + timeout=90, + ) + + return jsonify(resp.json()), resp.status_code diff --git a/src/frontend/flask_app/templates/maker/task_editor.html b/src/frontend/flask_app/templates/maker/task_editor.html index 8ab8b69..a2c858c 100644 --- a/src/frontend/flask_app/templates/maker/task_editor.html +++ b/src/frontend/flask_app/templates/maker/task_editor.html @@ -221,14 +221,24 @@

- +
+ + +