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
@@ -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;