callClaudeApi([ [ 'type' => 'document', 'source' => [ 'type' => 'base64', 'media_type' => 'application/pdf', 'data' => $base64Pdf, ], ], [ 'type' => 'text', 'text' => "Analizza questa visura camerale italiana ed estrai i seguenti dati in formato JSON. Rispondi SOLO con il JSON, senza testo aggiuntivo, senza markdown code blocks.\n\nCampi da estrarre:\n- company_name: ragione sociale completa\n- vat_number: partita IVA (solo numeri, senza prefisso IT)\n- fiscal_code: codice fiscale\n- legal_form: forma giuridica (es. S.R.L., S.P.A., ecc.)\n- address: indirizzo sede legale (via/piazza e numero civico)\n- city: comune sede legale\n- province: sigla provincia (es. MI, RM, TO)\n- zip_code: CAP\n- pec: indirizzo PEC se presente\n- phone: telefono se presente\n- ateco_code: codice ATECO principale se presente\n- ateco_description: descrizione attività ATECO se presente\n- incorporation_date: data di costituzione (formato YYYY-MM-DD)\n- share_capital: capitale sociale in EUR (solo numero)\n- employees_range: stima range dipendenti se indicato (es. \"10-49\", \"50-249\", \"250+\")\n- legal_representative: nome e cognome del legale rappresentante\n\nSe un campo non è presente nella visura, usa null come valore.", ], ]); if (!$response) { throw new RuntimeException("Nessuna risposta dall'AI"); } $jsonStr = preg_replace('/^```(?:json)?\s*/i', '', trim($response)); $jsonStr = preg_replace('/\s*```$/', '', $jsonStr); $data = json_decode($jsonStr, true); if (json_last_error() !== JSON_ERROR_NONE) { error_log('[VISURA_PARSE_ERROR] ' . $jsonStr); throw new RuntimeException('Impossibile interpretare i dati estratti dalla visura'); } $data['suggested_sector'] = $this->mapAtecoToNis2Sector( $data['ateco_code'] ?? '', $data['ateco_description'] ?? '' ); $data['source'] = 'pdf_upload'; $this->logAiInteraction('visura_extraction', 'Estrazione dati da visura camerale PDF'); return $data; } /** * 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 { $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.'); } // ── Step 1: richiesta visura ───────────────────────────────────────── $richiesta = $this->callCertiSource('POST', 'richiesta', [ 'vat' => $vatNumber, 'type' => 'visura_camerale', ]); 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); $curlErr = curl_error($ch); unset($ch); if ($curlErr) { throw new RuntimeException('CertiSource connessione: ' . $curlErr); } if ($httpCode === 402) { throw new RuntimeException('CertiSource: credito insufficiente'); } if ($httpCode >= 500) { throw new RuntimeException('CertiSource: errore server (HTTP ' . $httpCode . ')'); } $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' => $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, ]; } /** * 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 { $code2 = substr(preg_replace('/[^0-9]/', '', $atecoCode), 0, 2); $desc = strtolower($atecoDesc); $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($byCode[$code2])) { return $byCode[$code2]; } // 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 ($byKeyword as $kw => $sector) { if (str_contains($desc, $kw)) { return $sector; } } return null; } // ───────────────────────────────────────────────────────────────────────── // METODI PRIVATI — Claude AI // ───────────────────────────────────────────────────────────────────────── private function callClaudeApi(array $content): ?string { $payload = [ 'model' => ANTHROPIC_MODEL, 'max_tokens' => ANTHROPIC_MAX_TOKENS, 'messages' => [['role' => 'user', 'content' => $content]], ]; $ch = curl_init('https://api.anthropic.com/v1/messages'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'x-api-key: ' . ANTHROPIC_API_KEY, 'anthropic-version: 2023-06-01', ], ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $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); throw new RuntimeException('Claude API returned HTTP ' . $httpCode); } $result = json_decode($response, true); return $result['content'][0]['text'] ?? null; } private function logAiInteraction(string $type, string $summary): void { try { $userId = null; $token = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; if (preg_match('/Bearer\s+(.+)$/i', $token, $m)) { $parts = explode('.', $m[1]); if (count($parts) === 3) { $pl = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); $userId = $pl['sub'] ?? null; } } if ($userId) { Database::insert('ai_interactions', [ 'organization_id' => 0, 'user_id' => $userId, 'interaction_type' => $type, 'prompt_summary' => $summary, 'response_summary' => 'Dati estratti', 'tokens_used' => 0, 'model_used' => ANTHROPIC_MODEL, ]); } } catch (Throwable $e) { error_log('[AI_LOG_ERROR] ' . $e->getMessage()); } } }