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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-24 11:01:00 +00:00
parent 9bd605c958
commit d2bf0b5828
10 changed files with 539 additions and 10 deletions
+32 -2
View File
@@ -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}
+4
View File
@@ -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."""
+74
View File
@@ -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)
@@ -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/<int:recipe_id>/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
@@ -221,14 +221,24 @@
<span x-text="tasks.length"></span>
<span x-text="tasks.length === 1 ? '{{ _('task') }}' : '{{ _('task') }}'"></span>
</p>
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
x-show="!showAddTask"
class="btn btn-primary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Aggiungi Task') }}
</button>
<div class="flex items-center gap-2">
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
x-show="!showAddTask"
class="btn btn-primary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Aggiungi Task') }}
</button>
<button @click="showAiImport = true"
x-show="!showAddTask"
class="btn btn-secondary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _('Importa da PDF') }}
</button>
</div>
</div>
<!-- ============================================================
@@ -930,6 +940,118 @@
</div>
</div>
{# ================================================================
AI IMPORT MODAL — Upload PDF + preview suggested tasks
================================================================ #}
<div x-show="showAiImport"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="showAiImport = false">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col border border-[var(--border-color)]">
{# Header #}
<div class="p-5 border-b border-[var(--border-color)] shrink-0">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Importa da Scheda Tecnica') }}</h3>
<p class="text-xs text-[var(--text-secondary)]">{{ _('Carica un PDF e l\'AI estrarrà i task automaticamente') }}</p>
</div>
</div>
</div>
{# Body #}
<div class="p-5 overflow-y-auto flex-1">
{# Upload area (before analysis) #}
<div x-show="!aiSuggestions.length && !aiParsing">
<label class="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-[var(--border-color)] rounded-xl cursor-pointer
hover:border-primary hover:bg-primary-50/50 dark:hover:bg-primary-900/10 transition-colors">
<svg class="w-10 h-10 text-[var(--text-muted)] mb-2" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
<span class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Clicca per caricare un PDF') }}</span>
<span class="text-xs text-[var(--text-muted)] mt-1">{{ _('Max 20MB') }}</span>
<input type="file" accept=".pdf,application/pdf" class="hidden" @change="uploadAndParse($event)">
</label>
</div>
{# Loading #}
<div x-show="aiParsing" class="flex flex-col items-center justify-center py-12">
<svg class="w-10 h-10 animate-spin text-primary mb-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<p class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Analisi in corso con AI...') }}</p>
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Potrebbe richiedere fino a 30 secondi') }}</p>
</div>
{# Error #}
<div x-show="aiError" class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
<p class="text-sm text-red-700 dark:text-red-300" x-text="aiError"></p>
<button @click="aiError = ''; aiSuggestions = []" class="text-xs text-red-600 underline mt-1">{{ _('Riprova') }}</button>
</div>
{# Suggested tasks (editable preview) #}
<div x-show="aiSuggestions.length > 0" class="space-y-3">
<p class="text-sm font-medium text-[var(--text-secondary)] mb-2">
<span x-text="aiSuggestions.length"></span> {{ _('task suggeriti — modifica o rimuovi prima di confermare') }}
</p>
<template x-for="(suggestion, idx) in aiSuggestions" :key="idx">
<div class="tmf-card">
<div class="p-4 space-y-2">
<div class="flex items-center justify-between gap-2">
<span class="text-xs font-bold text-primary">Task <span x-text="idx + 1"></span></span>
<button @click="aiSuggestions.splice(idx, 1)" class="text-xs text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
<input type="text" x-model="suggestion.title" class="tmf-input text-sm font-semibold"
placeholder="{{ _('Titolo') }}">
<input type="text" x-model="suggestion.directive" class="tmf-input text-sm"
placeholder="{{ _('Direttiva') }}">
<textarea x-model="suggestion.description" class="tmf-input text-sm" rows="3"
placeholder="{{ _('Descrizione') }}"></textarea>
</div>
</div>
</template>
</div>
</div>
{# Footer #}
<div class="p-4 border-t border-[var(--border-color)] shrink-0 flex items-center justify-between gap-2">
<button @click="showAiImport = false; aiSuggestions = []; aiError = ''"
class="btn btn-secondary text-sm">
{{ _('Annulla') }}
</button>
<button x-show="aiSuggestions.length > 0"
@click="createTasksFromSuggestions()"
:disabled="aiCreating"
class="btn btn-primary text-sm gap-1.5">
<svg x-show="!aiCreating" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<svg x-show="aiCreating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
{{ _('Crea') }} <span x-text="aiSuggestions.length"></span> {{ _('task') }}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -983,6 +1105,13 @@ function taskEditor() {
// ---- Drag & Drop state ----
dragState: { dragging: null, over: null, position: null },
// ---- AI Import ----
showAiImport: false,
aiParsing: false,
aiCreating: false,
aiSuggestions: [],
aiError: '',
// ============================================================
// Init
// ============================================================
@@ -1081,6 +1210,80 @@ function taskEditor() {
this.newTask = { title: '', directive: '', description: '' };
},
// ============================================================
// AI Import
// ============================================================
async uploadAndParse(event) {
var file = event.target.files[0];
if (!file) return;
this.aiParsing = true;
this.aiError = '';
this.aiSuggestions = [];
try {
var formData = new FormData();
formData.append('file', file);
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/parse-technical-sheet', {
method: 'POST',
headers: { 'X-CSRFToken': this.csrfToken() },
body: formData
});
var data = await resp.json();
if (!resp.ok || data.error) {
this.aiError = data.detail || {{ _("Errore nell'analisi del PDF")|tojson }};
} else {
this.aiSuggestions = data.suggested_tasks || [];
if (!this.aiSuggestions.length) {
this.aiError = {{ _("Nessun task identificato nel PDF")|tojson }};
}
}
} catch (err) {
console.error('AI parse error:', err);
this.aiError = {{ _("Errore di connessione al server")|tojson }};
}
this.aiParsing = false;
event.target.value = '';
},
async createTasksFromSuggestions() {
this.aiCreating = true;
var created = 0;
for (var i = 0; i < this.aiSuggestions.length; i++) {
var s = this.aiSuggestions[i];
if (!s.title || !s.title.trim()) continue;
try {
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken() },
body: JSON.stringify({
title: s.title.trim(),
directive: (s.directive || '').trim() || null,
description: (s.description || '').trim() || null
})
});
var data = await resp.json();
if (!data.error) {
if (!data.subtasks) data.subtasks = [];
this.tasks.push(data);
created++;
}
} catch (err) {
console.error('Create task error:', err);
}
}
this.aiCreating = false;
this.showAiImport = false;
this.aiSuggestions = [];
if (created > 0) {
this.successMessage = created + ' ' + {{ _("task creati dalla scheda tecnica")|tojson }};
}
},
async addTask() {
if (!this.newTask.title.trim()) return;
this.saving = true;
@@ -768,6 +768,42 @@ msgstr "Send signal to management system to start the line timer"
msgid "Produzione avviata"
msgstr "Production started"
msgid "Importa da PDF"
msgstr "Import from PDF"
msgid "Importa da Scheda Tecnica"
msgstr "Import from Technical Sheet"
msgid "Carica un PDF e l'AI estrarrà i task automaticamente"
msgstr "Upload a PDF and AI will extract tasks automatically"
msgid "Clicca per caricare un PDF"
msgstr "Click to upload a PDF"
msgid "Analisi in corso con AI..."
msgstr "AI analysis in progress..."
msgid "Potrebbe richiedere fino a 30 secondi"
msgstr "May take up to 30 seconds"
msgid "Riprova"
msgstr "Retry"
msgid "task suggeriti — modifica o rimuovi prima di confermare"
msgstr "suggested tasks — edit or remove before confirming"
msgid "Crea"
msgstr "Create"
msgid "Errore nell'analisi del PDF"
msgstr "Error analyzing PDF"
msgid "Nessun task identificato nel PDF"
msgstr "No tasks identified in PDF"
msgid "task creati dalla scheda tecnica"
msgstr "tasks created from technical sheet"
msgid "Prossima misurazione tra"
msgstr "Next measurement in"
@@ -798,6 +798,42 @@ msgstr "Invia segnale al gestionale per avviare il timer della linea"
msgid "Produzione avviata"
msgstr "Produzione avviata"
msgid "Importa da PDF"
msgstr "Importa da PDF"
msgid "Importa da Scheda Tecnica"
msgstr "Importa da Scheda Tecnica"
msgid "Carica un PDF e l'AI estrarrà i task automaticamente"
msgstr "Carica un PDF e l'AI estrarrà i task automaticamente"
msgid "Clicca per caricare un PDF"
msgstr "Clicca per caricare un PDF"
msgid "Analisi in corso con AI..."
msgstr "Analisi in corso con AI..."
msgid "Potrebbe richiedere fino a 30 secondi"
msgstr "Potrebbe richiedere fino a 30 secondi"
msgid "Riprova"
msgstr "Riprova"
msgid "task suggeriti — modifica o rimuovi prima di confermare"
msgstr "task suggeriti — modifica o rimuovi prima di confermare"
msgid "Crea"
msgstr "Crea"
msgid "Errore nell'analisi del PDF"
msgstr "Errore nell'analisi del PDF"
msgid "Nessun task identificato nel PDF"
msgstr "Nessun task identificato nel PDF"
msgid "task creati dalla scheda tecnica"
msgstr "task creati dalla scheda tecnica"
msgid "Prossima misurazione tra"
msgstr "Prossima misurazione tra"