From 75a678f60e830acb2604e3297f5542802bb69393 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Tue, 17 Mar 2026 13:24:03 +0100 Subject: [PATCH] [FEAT] CertiSource atti-service.php integration: structured data, PAT auth, ATECO fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VisuraService::fetchFromCertiSource: new atti-service.php API (POST richiesta → polling stato → GET dati) - Structured data mapping: sedi/ateco_codes/cariche/addetti → formato interno - mapAtecoToNis2Sector: allineato ENUM DB (digital_infra, water, waste, public_admin, ecc.) - config.php: CERTISOURCE_API_URL, CERTISOURCE_API_KEY, CERTISOURCE_POLL_MAX/SEC - PHP 8.4: curl_close → unset, usato in logAiInteraction Co-Authored-By: Claude Sonnet 4.6 --- application/config/config.php | 8 + application/services/VisuraService.php | 503 ++++++++++++++++--------- 2 files changed, 339 insertions(+), 172 deletions(-) diff --git a/application/config/config.php b/application/config/config.php index 7cbbcac..5c1f193 100644 --- a/application/config/config.php +++ b/application/config/config.php @@ -82,6 +82,14 @@ define('ANTHROPIC_API_KEY', Env::get('ANTHROPIC_API_KEY', '')); define('ANTHROPIC_MODEL', Env::get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929')); define('ANTHROPIC_MAX_TOKENS', Env::int('ANTHROPIC_MAX_TOKENS', 4096)); +// ═══════════════════════════════════════════════════════════════════════════ +// CERTISOURCE (atti-service.php) +// ═══════════════════════════════════════════════════════════════════════════ +define('CERTISOURCE_API_URL', Env::get('CERTISOURCE_API_URL', 'https://certisource.it/atti-service.php')); +define('CERTISOURCE_API_KEY', Env::get('CERTISOURCE_API_KEY', '')); // cs_pat_... +define('CERTISOURCE_POLL_MAX', Env::int('CERTISOURCE_POLL_MAX', 30)); // max tentativi polling +define('CERTISOURCE_POLL_SEC', Env::int('CERTISOURCE_POLL_SEC', 3)); // secondi tra poll + // ═══════════════════════════════════════════════════════════════════════════ // FEEDBACK & SEGNALAZIONI // ═══════════════════════════════════════════════════════════════════════════ diff --git a/application/services/VisuraService.php b/application/services/VisuraService.php index f4194e8..3a8943a 100644 --- a/application/services/VisuraService.php +++ b/application/services/VisuraService.php @@ -2,14 +2,21 @@ /** * NIS2 Agile - Visura Service * - * Estrae dati aziendali da visura camerale PDF tramite AI - * e recupera dati da CertiSource. + * Estrae dati aziendali da visura camerale PDF tramite AI (upload) + * oppure tramite CertiSource atti-service.php (ricerca per P.IVA). + * + * CertiSource API: https://certisource.it/atti-service.php + * Auth: Authorization: Bearer CERTISOURCE_API_KEY (cs_pat_...) */ class VisuraService { + // ───────────────────────────────────────────────────────────────────────── + // METODI PUBBLICI + // ───────────────────────────────────────────────────────────────────────── + /** - * Extract company data from a PDF visura camerale using Claude AI + * Estrai dati aziendali da un PDF visura caricato dall'utente (via Claude AI). */ public function extractFromPdf(string $filePath): array { @@ -21,18 +28,15 @@ class VisuraService throw new RuntimeException('Chiave API Anthropic non configurata'); } - // Read PDF and base64 encode it - $pdfContent = file_get_contents($filePath); - $base64Pdf = base64_encode($pdfContent); + $base64Pdf = base64_encode(file_get_contents($filePath)); - // Call Claude API with the PDF $response = $this->callClaudeApi([ [ - 'type' => 'document', + 'type' => 'document', 'source' => [ - 'type' => 'base64', + 'type' => 'base64', 'media_type' => 'application/pdf', - 'data' => $base64Pdf, + 'data' => $base64Pdf, ], ], [ @@ -42,170 +46,352 @@ class VisuraService ]); if (!$response) { - throw new RuntimeException('Nessuna risposta dall\'AI'); + throw new RuntimeException("Nessuna risposta dall'AI"); } - // Parse JSON response - $jsonStr = trim($response); - // Remove potential markdown code blocks - $jsonStr = preg_replace('/^```(?:json)?\s*/i', '', $jsonStr); + $jsonStr = preg_replace('/^```(?:json)?\s*/i', '', trim($response)); $jsonStr = preg_replace('/\s*```$/', '', $jsonStr); - - $data = json_decode($jsonStr, true); + $data = json_decode($jsonStr, true); if (json_last_error() !== JSON_ERROR_NONE) { - error_log('[VISURA_PARSE_ERROR] Could not parse AI response: ' . $jsonStr); + error_log('[VISURA_PARSE_ERROR] ' . $jsonStr); throw new RuntimeException('Impossibile interpretare i dati estratti dalla visura'); } - // Map to suggested NIS2 sector based on ATECO code - $data['suggested_sector'] = $this->mapAtecoToNis2Sector($data['ateco_code'] ?? '', $data['ateco_description'] ?? ''); + $data['suggested_sector'] = $this->mapAtecoToNis2Sector( + $data['ateco_code'] ?? '', + $data['ateco_description'] ?? '' + ); + $data['source'] = 'pdf_upload'; - // Log AI interaction $this->logAiInteraction('visura_extraction', 'Estrazione dati da visura camerale PDF'); return $data; } /** - * Fetch company data from CertiSource API + * Recupera dati aziendali da CertiSource atti-service.php tramite P.IVA. + * + * Flusso: + * POST ?action=richiesta → ottieni request ID + * GET ?action=stato → polling fino a completed + * GET ?action=dati → structured_data JSON */ public function fetchFromCertiSource(string $vatNumber): array { - // CertiSource is on the same server - call its API internally - $certisourceUrl = $this->getCertiSourceBaseUrl() . '/api/company/enrich'; + $apiKey = defined('CERTISOURCE_API_KEY') ? CERTISOURCE_API_KEY : ''; + if ($apiKey === '') { + error_log('[CERTISOURCE] API key non configurata (CERTISOURCE_API_KEY)'); + throw new RuntimeException('Servizio visure non disponibile. Inserire i dati manualmente.'); + } - $ch = curl_init(); - curl_setopt_array($ch, [ - CURLOPT_URL => $certisourceUrl . '?vat=' . urlencode($vatNumber), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - CURLOPT_HTTPHEADER => [ - 'Content-Type: application/json', - 'Accept: application/json', - 'X-Internal-Service: nis2-agile', - ], - // Same server, skip SSL verification for internal calls - CURLOPT_SSL_VERIFYPEER => false, + // ── Step 1: richiesta visura ───────────────────────────────────────── + $richiesta = $this->callCertiSource('POST', 'richiesta', [ + 'vat' => $vatNumber, + 'type' => 'visura_camerale', ]); - $response = curl_exec($ch); + if (empty($richiesta['success'])) { + $msg = $richiesta['error'] ?? $richiesta['message'] ?? 'Errore richiesta visura'; + throw new RuntimeException('CertiSource: ' . $msg); + } + + $requestId = (int) $richiesta['id']; + $status = $richiesta['status'] ?? 'processing'; + + // ── Step 2: polling se non già completato (cache hit) ──────────────── + if ($status !== 'completed') { + $pollMax = defined('CERTISOURCE_POLL_MAX') ? (int) CERTISOURCE_POLL_MAX : 30; + $pollSec = defined('CERTISOURCE_POLL_SEC') ? (int) CERTISOURCE_POLL_SEC : 3; + + for ($i = 0; $i < $pollMax; $i++) { + sleep($pollSec); + $stato = $this->callCertiSource('GET', 'stato', null, ['id' => $requestId]); + + if (!empty($stato['completed'])) { + $status = 'completed'; + break; + } + if (($stato['status'] ?? '') === 'failed') { + throw new RuntimeException('CertiSource: elaborazione visura fallita'); + } + } + + if ($status !== 'completed') { + throw new RuntimeException('Timeout: visura camerale non disponibile. Riprova tra qualche minuto.'); + } + } + + // ── Step 3: dati strutturati ───────────────────────────────────────── + $datiRes = $this->callCertiSource('GET', 'dati', null, ['id' => $requestId]); + + if (empty($datiRes['success']) || empty($datiRes['structured_data'])) { + throw new RuntimeException('CertiSource: dati strutturati non disponibili'); + } + + return $this->mapStructuredData($datiRes['structured_data'], $vatNumber, $requestId); + } + + // ───────────────────────────────────────────────────────────────────────── + // METODI PRIVATI — CertiSource + // ───────────────────────────────────────────────────────────────────────── + + /** + * Chiama l'API CertiSource atti-service.php. + * + * @param string $method GET|POST + * @param string $action action=richiesta|stato|dati|... + * @param array|null $body JSON body per POST + * @param array $params Query params extra (es. ['id'=>42]) + */ + private function callCertiSource(string $method, string $action, ?array $body = null, array $params = []): array + { + $apiKey = defined('CERTISOURCE_API_KEY') ? CERTISOURCE_API_KEY : ''; + $baseUrl = defined('CERTISOURCE_API_URL') ? CERTISOURCE_API_URL : 'https://certisource.it/atti-service.php'; + + $qs = http_build_query(array_merge(['action' => $action], $params)); + $url = $baseUrl . '?' . $qs; + + $ch = curl_init($url); + $headers = [ + 'Authorization: Bearer ' . $apiKey, + 'Content-Type: application/json', + 'Accept: application/json', + ]; + + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_HTTPHEADER => $headers, + ]); + + if ($method === 'POST' && $body !== null) { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + } + + $raw = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - curl_close($ch); + $curlErr = curl_error($ch); + unset($ch); - if ($error) { - error_log("[CERTISOURCE_CURL_ERROR] $error"); - throw new RuntimeException('Impossibile contattare CertiSource: ' . $error); + if ($curlErr) { + throw new RuntimeException('CertiSource connessione: ' . $curlErr); } - if ($httpCode !== 200) { - error_log("[CERTISOURCE_HTTP_ERROR] HTTP $httpCode: $response"); - throw new RuntimeException('CertiSource ha restituito un errore (HTTP ' . $httpCode . ')'); + if ($httpCode === 402) { + throw new RuntimeException('CertiSource: credito insufficiente'); } - $result = json_decode($response, true); - if (!$result) { - throw new RuntimeException('Risposta CertiSource non valida'); + if ($httpCode >= 500) { + throw new RuntimeException('CertiSource: errore server (HTTP ' . $httpCode . ')'); } - // Map CertiSource response to our format - // CertiSource typically returns data in its own format, normalize it - $companyData = $result['data'] ?? $result; + $result = json_decode($raw ?: '{}', true); + if (!is_array($result)) { + throw new RuntimeException('CertiSource: risposta non valida'); + } + + return $result; + } + + /** + * Mappa structured_data CertiSource nel formato interno NIS2 Agile. + */ + private function mapStructuredData(array $sd, string $vatNumber, int $requestId): array + { + $sedeLegale = $this->extractSedeLegale($sd['sedi'] ?? []); + $ateco = $this->extractPrimaryAteco($sd['ateco_codes'] ?? []); + $atecoCode = $ateco['code'] ?? null; + $atecoDesc = $ateco['description'] ?? ''; return [ - 'company_name' => $companyData['ragione_sociale'] ?? $companyData['denominazione'] ?? $companyData['company_name'] ?? null, - 'vat_number' => $companyData['partita_iva'] ?? $companyData['vat_number'] ?? $vatNumber, - 'fiscal_code' => $companyData['codice_fiscale'] ?? $companyData['fiscal_code'] ?? null, - 'legal_form' => $companyData['forma_giuridica'] ?? $companyData['legal_form'] ?? null, - 'address' => $companyData['indirizzo'] ?? $companyData['address'] ?? null, - 'city' => $companyData['comune'] ?? $companyData['city'] ?? null, - 'province' => $companyData['provincia'] ?? $companyData['province'] ?? null, - 'zip_code' => $companyData['cap'] ?? $companyData['zip_code'] ?? null, - 'pec' => $companyData['pec'] ?? null, - 'phone' => $companyData['telefono'] ?? $companyData['phone'] ?? null, - 'ateco_code' => $companyData['codice_ateco'] ?? $companyData['ateco_code'] ?? null, - 'ateco_description' => $companyData['descrizione_ateco'] ?? $companyData['ateco_description'] ?? null, - 'suggested_sector' => $this->mapAtecoToNis2Sector( - $companyData['codice_ateco'] ?? '', - $companyData['descrizione_ateco'] ?? '' - ), - 'source' => 'certisource', + 'company_name' => $sd['ragione_sociale'] ?? null, + 'vat_number' => $sd['partita_iva'] ?? $vatNumber, + 'fiscal_code' => $sd['codice_fiscale'] ?? null, + 'legal_form' => $sd['forma_giuridica'] ?? null, + 'address' => $sedeLegale['via'] ?? null, + 'city' => $sedeLegale['comune'] ?? null, + 'province' => $sedeLegale['provincia'] ?? null, + 'zip_code' => $sedeLegale['cap'] ?? null, + 'pec' => $sd['pec'] ?? $sedeLegale['pec'] ?? null, + 'phone' => null, + 'ateco_code' => $atecoCode, + 'ateco_description' => $atecoDesc ?: null, + 'incorporation_date' => null, + 'share_capital' => isset($sd['capitale_sociale']) ? (float) $sd['capitale_sociale'] : null, + 'employees_range' => $this->computeEmployeesRange($sd['addetti'] ?? null), + 'legal_representative' => $this->findLegalRepresentative($sd['cariche'] ?? []), + 'suggested_sector' => $this->mapAtecoToNis2Sector($atecoCode ?? '', $atecoDesc), + 'source' => 'certisource_atti', + 'certisource_id' => $requestId, ]; } /** - * Map ATECO code to NIS2 sector + * Trova la sede legale nell'array sedi. + */ + private function extractSedeLegale(array $sedi): array + { + foreach ($sedi as $sede) { + if (($sede['tipo'] ?? '') === 'sede_legale') { + return $sede; + } + } + return $sedi[0] ?? []; + } + + /** + * Trova il codice ATECO primario. + */ + private function extractPrimaryAteco(array $atecos): array + { + foreach ($atecos as $a) { + if (($a['type'] ?? '') === 'primary') { + return $a; + } + } + return $atecos[0] ?? []; + } + + /** + * Calcola il range dipendenti dai dati INPS (ultimo trimestre disponibile). + */ + private function computeEmployeesRange(?array $addetti): ?string + { + if (empty($addetti['trimestri'])) { + return null; + } + $last = end($addetti['trimestri']); + $n = (int) ($last['dipendenti'] ?? 0); + if ($n === 0) return null; + if ($n < 10) return '1-9'; + if ($n < 50) return '10-49'; + if ($n < 250) return '50-249'; + return '250+'; + } + + /** + * Trova il legale rappresentante tra le cariche. + */ + private function findLegalRepresentative(array $cariche): ?string + { + foreach ($cariche as $c) { + if (!empty($c['rappresentante_legale']) || str_contains(strtolower($c['ruolo'] ?? ''), 'legale')) { + return $c['nome'] ?? null; + } + } + // Fallback: primo amministratore + foreach ($cariche as $c) { + if (str_contains(strtolower($c['ruolo'] ?? ''), 'ammin')) { + return $c['nome'] ?? null; + } + } + return null; + } + + // ───────────────────────────────────────────────────────────────────────── + // ATECO → SETTORE NIS2 (valori ENUM DB) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Mappa codice ATECO + descrizione al settore NIS2 (valori ENUM organizations.sector). + * + * Valori validi: energy, transport, banking, health, water, digital_infra, + * public_admin, manufacturing, postal, chemical, food, waste, + * ict_services, digital_providers, space, research, other */ private function mapAtecoToNis2Sector(string $atecoCode, string $atecoDesc): ?string { - $code = substr($atecoCode, 0, 2); // Use first 2 digits - $descLower = strtolower($atecoDesc); + $code2 = substr(preg_replace('/[^0-9]/', '', $atecoCode), 0, 2); + $desc = strtolower($atecoDesc); - // ATECO to NIS2 mapping (approximate) - $mapping = [ - '35' => 'energy_electricity', // Electricity, gas, steam - '49' => 'transport_road', // Land transport - '50' => 'transport_water', // Water transport - '51' => 'transport_air', // Air transport - '64' => 'banking', // Financial services - '65' => 'banking', // Insurance - '66' => 'financial_markets', // Financial auxiliaries - '86' => 'health', // Health - '36' => 'drinking_water', // Water supply - '37' => 'waste_water', // Sewerage - '38' => 'waste_management', // Waste management - '61' => 'digital_infrastructure', // Telecommunications - '62' => 'ict_service_management', // IT services - '63' => 'digital_providers', // Information services - '84' => 'public_administration', // Public admin - '53' => 'postal_courier', // Postal services - '20' => 'chemicals', // Chemicals manufacturing - '10' => 'food', // Food manufacturing - '11' => 'food', // Beverages - '21' => 'manufacturing_medical', // Pharma/medical - '26' => 'manufacturing_computers', // Electronics - '27' => 'manufacturing_electrical', // Electrical equipment - '28' => 'manufacturing_machinery', // Machinery - '29' => 'manufacturing_vehicles', // Motor vehicles - '30' => 'manufacturing_transport', // Other transport - '72' => 'research', // Scientific research + $byCode = [ + '35' => 'energy', // Fornitura energia elettrica, gas + '06' => 'energy', // Estrazione petrolio e gas + '19' => 'energy', // Raffinazione petrolio + '49' => 'transport', // Trasporto terrestre + '50' => 'transport', // Trasporto marittimo + '51' => 'transport', // Trasporto aereo + '52' => 'transport', // Magazzinaggio e attività connesse + '64' => 'banking', // Servizi finanziari + '65' => 'banking', // Assicurazioni + '66' => 'banking', // Attività ausiliarie servizi finanziari + '86' => 'health', // Assistenza sanitaria + '87' => 'health', // Strutture di assistenza + '36' => 'water', // Raccolta e distribuzione acqua + '37' => 'waste', // Raccolta e trattamento acque reflue + '38' => 'waste', // Raccolta e smaltimento rifiuti + '61' => 'digital_infra', // Telecomunicazioni + '62' => 'ict_services', // Produzione software e IT + '63' => 'digital_providers',// Servizi di informazione + '84' => 'public_admin', // Pubblica amministrazione + '53' => 'postal', // Servizi postali e corrieri + '20' => 'chemical', // Fabbricazione prodotti chimici + '10' => 'food', // Industria alimentare + '11' => 'food', // Industria delle bevande + '21' => 'manufacturing', // Fabbricazione farmaci + '26' => 'manufacturing', // Fabbricazione computer e elettronica + '27' => 'manufacturing', // Fabbricazione apparecchiature elettriche + '28' => 'manufacturing', // Fabbricazione macchinari + '29' => 'manufacturing', // Fabbricazione autoveicoli + '30' => 'manufacturing', // Fabbricazione altri mezzi di trasporto + '72' => 'research', // Ricerca e sviluppo + '85' => 'research', // Istruzione ]; - if (isset($mapping[$code])) { - return $mapping[$code]; + if (isset($byCode[$code2])) { + return $byCode[$code2]; } - // Try to match by description keywords - $keywords = [ - 'energia' => 'energy_electricity', - 'elettric' => 'energy_electricity', - 'gas' => 'energy_gas', - 'petroli' => 'energy_oil', - 'trasport' => 'transport_road', - 'ferrov' => 'transport_rail', - 'maritt' => 'transport_water', - 'aere' => 'transport_air', - 'banc' => 'banking', - 'finanz' => 'financial_markets', - 'sanit' => 'health', - 'osped' => 'health', - 'farm' => 'manufacturing_medical', - 'acqua' => 'drinking_water', - 'rifiut' => 'waste_management', - 'telecom' => 'digital_infrastructure', - 'informatica' => 'ict_service_management', - 'software' => 'ict_service_management', - 'digital' => 'digital_providers', - 'postale' => 'postal_courier', - 'corriere' => 'postal_courier', - 'chimic' => 'chemicals', - 'alimentar' => 'food', - 'ricerca' => 'research', + // Fallback per keyword nella descrizione + $byKeyword = [ + 'energia' => 'energy', + 'elettric' => 'energy', + 'gas' => 'energy', + 'petroli' => 'energy', + 'idrogeno' => 'energy', + 'trasport' => 'transport', + 'ferrov' => 'transport', + 'maritt' => 'transport', + 'aereo' => 'transport', + 'aere' => 'transport', + 'logistic' => 'transport', + 'banc' => 'banking', + 'finanz' => 'banking', + 'assicur' => 'banking', + 'sanit' => 'health', + 'osped' => 'health', + 'clinic' => 'health', + 'medic' => 'health', + 'acqua' => 'water', + 'idric' => 'water', + 'rifiut' => 'waste', + 'smaltim' => 'waste', + 'telecom' => 'digital_infra', + 'teleco' => 'digital_infra', + 'fibra' => 'digital_infra', + 'internet' => 'digital_infra', + 'informatica' => 'ict_services', + 'software' => 'ict_services', + 'cloud' => 'ict_services', + 'digital' => 'digital_providers', + 'piattaform' => 'digital_providers', + 'postale' => 'postal', + 'corriere' => 'postal', + 'spediz' => 'postal', + 'chimic' => 'chemical', + 'farmac' => 'manufacturing', + 'alimentar' => 'food', + 'bevand' => 'food', + 'ricerca' => 'research', + 'spazial' => 'space', + 'aerospaz' => 'space', + 'manifattur' => 'manufacturing', ]; - foreach ($keywords as $kw => $sector) { - if (str_contains($descLower, $kw)) { + foreach ($byKeyword as $kw => $sector) { + if (str_contains($desc, $kw)) { return $sector; } } @@ -213,20 +399,16 @@ class VisuraService return null; } - /** - * Call Claude API - */ + // ───────────────────────────────────────────────────────────────────────── + // METODI PRIVATI — Claude AI + // ───────────────────────────────────────────────────────────────────────── + private function callClaudeApi(array $content): ?string { $payload = [ 'model' => ANTHROPIC_MODEL, 'max_tokens' => ANTHROPIC_MAX_TOKENS, - 'messages' => [ - [ - 'role' => 'user', - 'content' => $content, - ], - ], + 'messages' => [['role' => 'user', 'content' => $content]], ]; $ch = curl_init('https://api.anthropic.com/v1/messages'); @@ -244,58 +426,36 @@ class VisuraService $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - curl_close($ch); - - if ($error) { - throw new RuntimeException('Claude API error: ' . $error); - } + $error = curl_error($ch); + unset($ch); + if ($error) throw new RuntimeException('Claude API error: ' . $error); if ($httpCode !== 200) { - error_log("[CLAUDE_API_ERROR] HTTP $httpCode: $response"); + error_log('[CLAUDE_API_ERROR] HTTP ' . $httpCode . ': ' . $response); throw new RuntimeException('Claude API returned HTTP ' . $httpCode); } $result = json_decode($response, true); - return $result['content'][0]['text'] ?? null; } - /** - * Get CertiSource API base URL (same server) - */ - private function getCertiSourceBaseUrl(): string - { - // Both apps are on the same server, use internal URL - if (defined('CERTISOURCE_API_URL')) { - return CERTISOURCE_API_URL; - } - // Default: same server via localhost - return 'https://certisource.it/certisource'; - } - - /** - * Log AI interaction to database - */ private function logAiInteraction(string $type, string $summary): void { try { - // Get current user from JWT if available $userId = null; - $token = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; - if (preg_match('/Bearer\s+(.+)$/i', $token, $matches)) { - $parts = explode('.', $matches[1]); + $token = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + if (preg_match('/Bearer\s+(.+)$/i', $token, $m)) { + $parts = explode('.', $m[1]); if (count($parts) === 3) { - $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); - $userId = $payload['sub'] ?? null; + $pl = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); + $userId = $pl['sub'] ?? null; } } - if ($userId) { Database::insert('ai_interactions', [ - 'organization_id' => 0, // Not yet created during onboarding + 'organization_id' => 0, 'user_id' => $userId, - 'interaction_type' => 'qa', + 'interaction_type' => $type, 'prompt_summary' => $summary, 'response_summary' => 'Dati estratti', 'tokens_used' => 0, @@ -303,7 +463,6 @@ class VisuraService ]); } } catch (Throwable $e) { - // Silently fail - logging should not break the flow error_log('[AI_LOG_ERROR] ' . $e->getMessage()); } }