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