nis2-agile/application/services/VectorService.php
DevEnv nis2-agile 5c545ea3d0 [FEAT] Integrazione analisi docs/nis2 v1.7.0 — scoring asset, tassonomia incidenti, PIR, NIST CSF, fonti certe
Fase 1 - Asset Relevance Scoring NIS2 (GV.OC-04): metodologia 0-100 a 6 criteri,
  AssetScoringService + endpoint scoringGrid/score/relevantSystems + UI assets.html + registro stampabile.
Fase 2 - Tassonomia incidenti Determina ACN 164179/2025: IS-1..4 + regime essenziale/importante (Allegati 3/4).
Fase 3 - Post-Incident Review (5-Whys) + metriche TTD/TTC/TTR + timestamp di fase.
Fase 4 - Mapping NIST CSF 2.0 (43 controlli) reference-only.
Fonti certe: registry config/nis2_sources.php + grounding AI (vieta riferimenti inventati) +
  citazioni help.js + ingest PDF normativi nella KB RAG (scripts/ingest-nis2-sources.php).
Migrazioni 020/021/022 (additive idempotenti). Fix VectorService IP Qdrant (drift .5->.3).
Analisi concorrenza Evix (docs/EVIX_ANALISI_CONCORRENZA.html, gap-driven).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:15:13 +02:00

159 lines
5.5 KiB
PHP

<?php
/**
* NIS2 Agile - VectorService
*
* Client minimale Qdrant + filtro multi-livello (Migration 012-014).
* Modello a 3 livelli (SYSTEM/FIRM/ORG) coerente con TRPG e SustainAI.
*/
class VectorService
{
private string $qdrantUrl;
private string $collection;
public function __construct(string $collection = 'nis2_kb')
{
// PHP-FPM Alpine non popola correttamente env via getenv() (clear_env non
// applicato + bug DNS musl per hostname senza dots). Workaround: leggi da
// multiple sources e ricadi su IP statico del container Qdrant.
$url = getenv('QDRANT_URL')
?: ($_SERVER['QDRANT_URL'] ?? null)
?: ($_ENV['QDRANT_URL'] ?? null)
?: 'http://172.21.0.3:6333'; // IP nis2-qdrant (agg. 2026-05-29: era .5, container con IP dinamico driftato). TODO: assegnare ipv4_address statico in docker-compose per evitare ricorrenze.
$this->qdrantUrl = rtrim($url, '/');
$this->collection = $collection;
}
public function ensureCollection(int $dims = 1024): void
{
$info = $this->request('GET', "/collections/{$this->collection}");
if ($info['status'] === 200) return;
$this->request('PUT', "/collections/{$this->collection}", [
'vectors' => ['size' => $dims, 'distance' => 'Cosine'],
]);
}
public function upsertBatch(array $points): void
{
if (empty($points)) return;
$resp = $this->request('PUT', "/collections/{$this->collection}/points?wait=true", [
'points' => $points,
]);
if ($resp['status'] !== 200) {
throw new RuntimeException('Qdrant upsert failed (HTTP ' . $resp['status'] . '): ' . json_encode($resp['body']));
}
}
public function deleteByFilter(array $filter): void
{
$this->request('POST', "/collections/{$this->collection}/points/delete", [
'filter' => $filter,
]);
}
public function setPayloadByFilter(array $payload, array $filter): void
{
$this->request('POST', "/collections/{$this->collection}/points/payload", [
'payload' => $payload,
'filter' => $filter,
]);
}
public function search(array $vector, array $filter = [], int $limit = 8, float $minScore = 0.28): array
{
$body = [
'vector' => $vector,
'limit' => $limit,
'with_payload' => true,
'score_threshold'=> $minScore,
];
if (!empty($filter)) {
$body['filter'] = $filter;
}
$resp = $this->request('POST', "/collections/{$this->collection}/points/search", $body);
if ($resp['status'] !== 200) {
return [];
}
return $resp['body']['result'] ?? [];
}
/**
* Filtro a 3 livelli (SYSTEM/FIRM/ORG) basato sull'utente.
* Restituisce SOLO chunks visibili a quell'utente.
*
* @param array $userContext ['user_id'=>int, 'organization_id'=>int|null, 'consulting_firm_id'=>int|null]
*/
public static function buildAuthzFilter(array $userContext): array
{
$firmId = isset($userContext['consulting_firm_id']) && $userContext['consulting_firm_id'] !== null
? (int)$userContext['consulting_firm_id'] : null;
$orgId = isset($userContext['organization_id']) && $userContext['organization_id'] !== null
? (int)$userContext['organization_id'] : null;
$should = [];
// L0 SYSTEM: vendor knowledge (sempre visibile)
$should[] = ['key' => 'scope', 'match' => ['value' => 'SYSTEM']];
// L1 FIRM: KB del proprio studio (visibile a tutti i collaboratori)
if ($firmId !== null) {
$should[] = [
'must' => [
['key' => 'scope', 'match' => ['value' => 'FIRM']],
['key' => 'consulting_firm_id', 'match' => ['value' => $firmId]],
],
];
}
if ($orgId !== null) {
// L1 FIRM con sharing esplicito alla organization corrente
$should[] = [
'must' => [
['key' => 'scope', 'match' => ['value' => 'FIRM']],
['key' => 'shared_with_orgs', 'match' => ['any' => [$orgId]]],
],
];
// L2 ORG: chunk dell'organizzazione corrente
$should[] = [
'must' => [
['key' => 'scope', 'match' => ['value' => 'ORG']],
['key' => 'organization_id', 'match' => ['value' => $orgId]],
],
];
}
return ['should' => $should];
}
/**
* @return array{status:int, body:array}
*/
private function request(string $method, string $path, ?array $body = null): array
{
$url = $this->qdrantUrl . $path;
$ch = curl_init($url);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 30,
];
if ($body !== null) {
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
}
curl_setopt_array($ch, $opts);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'status' => $status,
'body' => $raw ? (json_decode($raw, true) ?? []) : [],
];
}
}