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/