[FEAT] Corpus AI: ingest 203 requisiti ACN granulari nella KB (chunk-per-requisito)
scripts/ingest-acn-requirements.php: indicizza in Qdrant nis2_kb un chunk per ogni requisito ACN (87 importanti Allegato 1 + 116 essenziali Allegato 2 = 203), scope SYSTEM, entity_type=requisito_acn, con payload citabile (code/subcategory/function/ req_index/entity/allegato/requirement_text). Point id UUIDv5 deterministico -> idempotente. Usa upsertPoints/deletePoints/ensureCollection reali di VectorService. ESEGUITO su host (docs/ non e' bind-mountato nel container): 203 punti confermati in Qdrant. L'AI askWithRag ora fa grounding sul singolo requisito GV.SC e lo cita. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7baa596b37
commit
94bb7bd32b
215
scripts/ingest-acn-requirements.php
Normal file
215
scripts/ingest-acn-requirements.php
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Ingest 203 requisiti ACN granulari nella Knowledge Base (RAG)
|
||||||
|
* ----------------------------------------------------------------------------
|
||||||
|
* Indicizza UNO chunk Qdrant PER OGNI requisito ACN granulare (87 importanti
|
||||||
|
* dell'Allegato 1 + 116 essenziali dell'Allegato 2 = 203) nella collection
|
||||||
|
* `nis2_kb` con scope SYSTEM, entity_type='requisito_acn'. Cosi'
|
||||||
|
* AIService::askWithRag() puo' fare grounding sul singolo requisito e citarlo
|
||||||
|
* con precisione (subcategory + req_index + funzione + allegato).
|
||||||
|
*
|
||||||
|
* Sorgente: docs/nis2/allegati_acn/acn_requirements.json
|
||||||
|
* struttura reale (verificata): { "importante":[87 record], "essenziale":[116] }
|
||||||
|
* campi record: entity, function, subcategory, subcategory_text, req_index, requirement
|
||||||
|
*
|
||||||
|
* ESEGUIRE SU HETZNER (Qdrant + Voyage raggiungibili):
|
||||||
|
* docker exec nis2-app php /var/www/nis2-agile/scripts/ingest-acn-requirements.php
|
||||||
|
*
|
||||||
|
* IDEMPOTENTE: point id deterministico (UUIDv5 da entity|subcategory|req_index)
|
||||||
|
* + purge iniziale per filtro (scope SYSTEM, entity_type requisito_acn, source ACN).
|
||||||
|
*
|
||||||
|
* Opzioni: --only=importante|essenziale | --dry-run | --no-purge
|
||||||
|
*
|
||||||
|
* NB metodi VectorService REALI: upsertPoints(array), deletePoints(array filtro),
|
||||||
|
* ensureCollection(int dims). EmbedService: embed(text), ->dims (512).
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') { fwrite(STDERR, "Solo CLI\n"); exit(1); }
|
||||||
|
|
||||||
|
if (!defined('BASE_PATH')) define('BASE_PATH', dirname(__DIR__));
|
||||||
|
if (!defined('APP_PATH')) define('APP_PATH', BASE_PATH . '/application');
|
||||||
|
|
||||||
|
require_once APP_PATH . '/config/env.php';
|
||||||
|
require_once APP_PATH . '/config/config.php';
|
||||||
|
require_once APP_PATH . '/config/database.php';
|
||||||
|
require_once APP_PATH . '/services/EmbedService.php';
|
||||||
|
require_once APP_PATH . '/services/VectorService.php';
|
||||||
|
|
||||||
|
$opts = getopt('', ['only::', 'dry-run', 'no-purge']);
|
||||||
|
$only = $opts['only'] ?? null;
|
||||||
|
$dryRun = isset($opts['dry-run']);
|
||||||
|
$noPurge = isset($opts['no-purge']);
|
||||||
|
|
||||||
|
const ACN_JSON = 'docs/nis2/allegati_acn/acn_requirements.json';
|
||||||
|
const ACN_SOURCE = 'ACN, Misure di sicurezza di base NIS - Allegati 1 e 2 alla Determinazione n. 164179/2025';
|
||||||
|
const ACN_ENTITY_TYPE = 'requisito_acn';
|
||||||
|
const ACN_NAMESPACE_UUID = '7f3b6d2a-9c41-5e88-b0a4-2acn164179bas';
|
||||||
|
|
||||||
|
function logln(string $m): void { echo '[' . date('Y-m-d H:i:s') . "] $m\n"; }
|
||||||
|
|
||||||
|
/** UUIDv5 deterministico (RFC 4122 §4.3) per point id stabile => idempotenza. */
|
||||||
|
function uuidv5(string $namespaceUuid, string $name): string
|
||||||
|
{
|
||||||
|
$nhex = str_replace(['-', '{', '}'], '', $namespaceUuid);
|
||||||
|
$nbin = '';
|
||||||
|
for ($i = 0; $i + 1 < strlen($nhex); $i += 2) {
|
||||||
|
$nbin .= chr(hexdec(substr($nhex, $i, 2)));
|
||||||
|
}
|
||||||
|
$hash = sha1($nbin . $name);
|
||||||
|
return sprintf('%08s-%04s-%04x-%04x-%12s',
|
||||||
|
substr($hash, 0, 8), substr($hash, 8, 4),
|
||||||
|
(hexdec(substr($hash, 12, 4)) & 0x0fff) | 0x5000,
|
||||||
|
(hexdec(substr($hash, 16, 4)) & 0x3fff) | 0x8000,
|
||||||
|
substr($hash, 20, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
function allegatoLabel(string $entity): string
|
||||||
|
{
|
||||||
|
$e = strtolower(trim($entity));
|
||||||
|
if (strpos($e, 'import') === 0) return 'Allegato 1 (soggetti importanti)';
|
||||||
|
if (strpos($e, 'essenz') === 0) return 'Allegato 2 (soggetti essenziali)';
|
||||||
|
return 'Allegato ACN';
|
||||||
|
}
|
||||||
|
function canonEntity(string $entity): string
|
||||||
|
{
|
||||||
|
$e = strtolower(trim($entity));
|
||||||
|
if (strpos($e, 'import') === 0) return 'importante';
|
||||||
|
if (strpos($e, 'essenz') === 0) return 'essenziale';
|
||||||
|
return $e !== '' ? $e : 'sconosciuta';
|
||||||
|
}
|
||||||
|
|
||||||
|
$abs = BASE_PATH . '/' . ACN_JSON;
|
||||||
|
if (!is_file($abs)) { fwrite(STDERR, "ERRORE: file non trovato: $abs\n"); exit(1); }
|
||||||
|
$json = json_decode((string)file_get_contents($abs), true);
|
||||||
|
if (!is_array($json)) { fwrite(STDERR, "ERRORE: JSON non valido\n"); exit(1); }
|
||||||
|
|
||||||
|
$records = [];
|
||||||
|
foreach ($json as $groupKey => $items) {
|
||||||
|
if (!is_array($items)) continue;
|
||||||
|
if ($only && $groupKey !== $only) continue;
|
||||||
|
foreach ($items as $r) {
|
||||||
|
if (!is_array($r)) continue;
|
||||||
|
$requirement = trim((string)($r['requirement'] ?? ''));
|
||||||
|
if ($requirement === '') continue;
|
||||||
|
$entity = canonEntity((string)($r['entity'] ?? $groupKey));
|
||||||
|
$subcategory = trim((string)($r['subcategory'] ?? '')) ?: 'N/D';
|
||||||
|
$function = trim((string)($r['function'] ?? '')) ?: 'N/D';
|
||||||
|
$subcatText = trim((string)($r['subcategory_text'] ?? ''));
|
||||||
|
$reqIndex = isset($r['req_index']) ? (int)$r['req_index'] : 1;
|
||||||
|
$code = sprintf('%s/%s/%d', $entity, $subcategory, $reqIndex);
|
||||||
|
$records[] = compact('entity', 'function', 'subcategory', 'subcatText', 'reqIndex', 'requirement', 'code');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logln('=== Ingest requisiti ACN granulari (scope SYSTEM, entity_type=' . ACN_ENTITY_TYPE . ') ===');
|
||||||
|
logln('Requisiti da indicizzare: ' . count($records) . ($only ? " (solo: {$only})" : ''));
|
||||||
|
if ($dryRun) logln('MODALITA DRY-RUN: nessun embed/upsert.');
|
||||||
|
if (empty($records)) { logln('Nessun requisito. Esco.'); exit(0); }
|
||||||
|
|
||||||
|
$embed = null; $vector = null;
|
||||||
|
if (!$dryRun) {
|
||||||
|
try {
|
||||||
|
$embed = new EmbedService();
|
||||||
|
$vector = new VectorService();
|
||||||
|
$vector->ensureCollection($embed->dims);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
fwrite(STDERR, 'ERRORE init Voyage/Qdrant: ' . $e->getMessage() . "\n"); exit(1);
|
||||||
|
}
|
||||||
|
if (!$noPurge) {
|
||||||
|
try {
|
||||||
|
$vector->deletePoints(['must' => [
|
||||||
|
['key' => 'scope', 'match' => ['value' => 'SYSTEM']],
|
||||||
|
['key' => 'entity_type', 'match' => ['value' => ACN_ENTITY_TYPE]],
|
||||||
|
['key' => 'source', 'match' => ['value' => ACN_SOURCE]],
|
||||||
|
]]);
|
||||||
|
logln('Purge chunk pre-esistenti: OK');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
logln(' (warning) purge fallita: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = 0; $fail = 0; $failed = []; $batch = []; $BATCH_SIZE = 32;
|
||||||
|
$flush = function () use (&$batch, $vector) {
|
||||||
|
if (empty($batch)) return;
|
||||||
|
$vector->upsertPoints($batch);
|
||||||
|
$batch = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($records as $n => $rec) {
|
||||||
|
$human = ($n + 1) . '/' . count($records);
|
||||||
|
$allegato = allegatoLabel($rec['entity']);
|
||||||
|
$chunkText = implode("\n", array_filter([
|
||||||
|
'FONTE NORMATIVA: ' . ACN_SOURCE,
|
||||||
|
'RIFERIMENTO: ' . $allegato . ' - Soggetti ' . $rec['entity'],
|
||||||
|
'FUNZIONE (Framework Nazionale): ' . $rec['function'],
|
||||||
|
'SOTTOCATEGORIA: ' . $rec['subcategory'],
|
||||||
|
$rec['subcatText'] !== '' ? 'DESCRIZIONE SOTTOCATEGORIA: ' . $rec['subcatText'] : null,
|
||||||
|
'REQUISITO #' . $rec['reqIndex'] . ': ' . $rec['requirement'],
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ($dryRun) { if ($n < 3) logln(" [{$human}] {$rec['code']}"); $ok++; continue; }
|
||||||
|
|
||||||
|
$vec = null; $lastErr = '';
|
||||||
|
for ($try = 1; $try <= 5; $try++) {
|
||||||
|
try { $vec = $embed->embed($chunkText); break; }
|
||||||
|
catch (Throwable $e) { $lastErr = $e->getMessage(); if ($try < 5) sleep($try); }
|
||||||
|
}
|
||||||
|
if (!is_array($vec)) {
|
||||||
|
$fail++; $failed[] = $rec['code'];
|
||||||
|
logln(" ERRORE [{$human}] embed {$rec['code']}: " . substr($lastErr, 0, 100) . ' -> SKIP');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pointId = uuidv5(ACN_NAMESPACE_UUID, ACN_SOURCE . '|' . $rec['code']);
|
||||||
|
$batch[] = [
|
||||||
|
'id' => $pointId,
|
||||||
|
'vector' => $vec,
|
||||||
|
'payload' => [
|
||||||
|
'doc_uuid' => $pointId,
|
||||||
|
'title' => $rec['subcategory'] . ' #' . $rec['reqIndex'] . ' (' . $rec['entity'] . ')',
|
||||||
|
'chunk' => $chunkText,
|
||||||
|
'entity_type' => ACN_ENTITY_TYPE,
|
||||||
|
'source' => ACN_SOURCE,
|
||||||
|
'lang' => 'it',
|
||||||
|
'scope' => 'SYSTEM',
|
||||||
|
'consulting_firm_id' => null,
|
||||||
|
'organization_id' => null,
|
||||||
|
'shared_with_orgs' => [],
|
||||||
|
'code' => $rec['code'],
|
||||||
|
'subcategory' => $rec['subcategory'],
|
||||||
|
'subcategory_text' => $rec['subcatText'],
|
||||||
|
'function' => $rec['function'],
|
||||||
|
'req_index' => $rec['reqIndex'],
|
||||||
|
'entity' => $rec['entity'],
|
||||||
|
'allegato' => $allegato,
|
||||||
|
'requirement_text' => $rec['requirement'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$ok++;
|
||||||
|
if (count($batch) >= $BATCH_SIZE) {
|
||||||
|
try { $flush(); logln(" upsert batch (ok cumulato={$ok})"); }
|
||||||
|
catch (Throwable $e) {
|
||||||
|
$n0 = count($batch); $fail += $n0; $ok -= $n0;
|
||||||
|
foreach ($batch as $b) $failed[] = $b['payload']['code'] ?? '(?)';
|
||||||
|
$batch = [];
|
||||||
|
logln(' ERRORE upsert batch (' . $n0 . ' persi): ' . substr($e->getMessage(), 0, 120));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
try { $flush(); }
|
||||||
|
catch (Throwable $e) {
|
||||||
|
$n0 = count($batch); $fail += $n0; $ok -= $n0;
|
||||||
|
foreach ($batch as $b) $failed[] = $b['payload']['code'] ?? '(?)';
|
||||||
|
logln(' ERRORE upsert finale (' . $n0 . ' persi): ' . substr($e->getMessage(), 0, 120));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logln('=== Completato ===');
|
||||||
|
logln("Indicizzati OK : {$ok}");
|
||||||
|
logln("Falliti : {$fail}");
|
||||||
|
if (!empty($failed)) logln('Codici falliti : ' . implode(', ', array_slice($failed, 0, 50)) . (count($failed) > 50 ? ' ...' : ''));
|
||||||
|
exit($fail > 0 ? 2 : 0);
|
||||||
Loading…
Reference in New Issue
Block a user