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:
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user