<?php
/**
 * Ricerca ibrida con AgileV (contratto 1.2): denso + pesi lessicali, senza database vettoriale.
 * Versione PHP dell'esempio ricerca_ibrida.py, per chi tiene i vettori in MySQL (trpg) o in pgvector.
 * Dipendenze: solo ext-curl ed ext-json. Chiave in AGILEV_KEY, indirizzo in AGILEV_URL (lette dalle tre
 * sorgenti di PHP: getenv(), $_SERVER, $_ENV — dentro un server web non è detto che sia la stessa).
 * `php ricerca_ibrida.php --dove` stampa indirizzo e sorgente, e non stampa mai la chiave.
 *
 *     php ricerca_ibrida.php "art. 25-septies" doc1.txt doc2.txt ...
 *     php ricerca_ibrida.php --prova        # autoverifica della fusione, senza rete
 *
 * Come funziona (numeri misurati su d.lgs. 231/2001 + 138/2024, vedi NOVITA.md del 21/9/2026):
 *   - coseno denso: buono sulle domande in linguaggio naturale, sbaglia le citazioni di articolo;
 *   - punteggio sparso (somma dei pesi sui token in comune): risolve le citazioni;
 *   - ibrido = alfa * coseno + (1 - alfa) * sparso / max_sparso_della_domanda. alfa 0,6 è il compromesso
 *     migliore sul nostro corpus; 0,9 se le domande sono quasi tutte in linguaggio naturale.
 *
 * Conservazione: il denso resta com'è (colonna JSON `embedding`); i pesi si salvano in una colonna in più
 * come JSON `{"token": peso, ...}` (la mappa che restituisce embed()). Il denso NON va ricalcolato.
 */

/**
 * Una variabile d'ambiente, letta dalle TRE sorgenti di PHP, che nella stessa stanza non portano lo
 * stesso valore: da riga di comando `$_ENV` è vuoto (misurato da noi il 29/9), sotto Apache
 * `$_SERVER` è null (misurato da trpg lo stesso giorno), e `php-fpm` con `clear_env = yes` svuota
 * l'ambiente del processo lasciando solo `$_SERVER`. Chi ne legge una sola scommette sulla stanza.
 * Un valore presente ma VUOTO è un errore, non un ripiego verso l'indirizzo pubblico.
 * @return array{0: ?string, 1: string} [valore, sorgente]
 */
function ambienteAgileV(string $nome): array
{
    foreach (['getenv' => getenv($nome), 'server' => $_SERVER[$nome] ?? null, 'env' => $_ENV[$nome] ?? null] as $sorgente => $v) {
        if ($v === false || $v === null) {
            continue;
        }
        if (trim((string) $v) === '') {
            throw new RuntimeException("$nome c'e' ma e' vuota (sorgente: $sorgente): toglila o dalle un valore");
        }
        return [trim((string) $v), $sorgente];
    }
    return [null, 'assente'];
}

/**
 * L'indirizzo che questo esempio userebbe, e da dove viene. È una funzione apposta: prima l'indirizzo
 * si decideva dentro embed(), e nessuno — nemmeno noi — poteva controllarlo senza fare una richiesta.
 * @return array{0: string, 1: string} [indirizzo, sorgente]
 */
function indirizzoAgileV(): array
{
    [$u, $s] = ambienteAgileV('AGILEV_URL');
    return [$u ?? 'https://api.agilev.agile.software/v1/embeddings', $u !== null ? $s : 'predefinito'];
}

/** Chiama /v1/embeddings con return_sparse. Ritorna [[denso, [token => peso]], ...] nell'ordine dei testi. */
function embed(array $testi, ?string $chiave = null): array
{
    [$url] = indirizzoAgileV();
    if ($chiave === null) {
        [$chiave] = ambienteAgileV('AGILEV_KEY');
    }
    if (!$chiave) {
        throw new RuntimeException('manca AGILEV_KEY');
    }
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 180,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $chiave, 'Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode(['model' => 'agilev-m3', 'input' => $testi, 'return_sparse' => true]),
    ]);
    $corpo = curl_exec($ch);
    $stato = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($corpo === false || $stato !== 200) {
        throw new RuntimeException("AgileV ha risposto $stato: " . substr((string) $corpo, 0, 200));
    }
    $dati = json_decode($corpo, true)['data'];
    usort($dati, fn($a, $b) => $a['index'] <=> $b['index']);
    $fuori = [];
    foreach ($dati as $d) {
        $sparso = array_combine($d['sparse_embedding']['indices'], $d['sparse_embedding']['values']);
        $fuori[] = [$d['embedding'], $sparso];
    }
    return $fuori;
}

/** I vettori di AgileV sono già normalizzati: il prodotto scalare è il coseno. */
function coseno(array $a, array $b): float
{
    $s = 0.0;
    foreach ($a as $i => $x) {
        $s += $x * $b[$i];
    }
    return $s;
}

/** Somma dei prodotti dei pesi sui token in comune (prodotto scalare fra vettori sparsi). */
function punteggioSparso(array $q, array $d): float
{
    if (count($q) > count($d)) {
        [$q, $d] = [$d, $q];
    }
    $s = 0.0;
    foreach ($q as $t => $w) {
        $s += $w * ($d[$t] ?? 0.0);
    }
    return $s;
}

/**
 * $domanda: [denso, sparso]; $documenti: lista di [denso, sparso].
 * Ritorna [ordine (indici dal migliore al peggiore), punteggi ibridi].
 * Il punteggio sparso è normalizzato sul massimo ottenuto dalla domanda, così è confrontabile col coseno.
 */
function classifica(array $domanda, array $documenti, float $alfa = 0.6): array
{
    [$qd, $qs] = $domanda;
    $densi = array_map(fn($doc) => coseno($qd, $doc[0]), $documenti);
    $sparsi = array_map(fn($doc) => punteggioSparso($qs, $doc[1]), $documenti);
    $massimo = ($sparsi && max($sparsi) > 0) ? max($sparsi) : 1.0;
    $ibridi = [];
    foreach ($densi as $i => $c) {
        $ibridi[$i] = $alfa * $c + (1 - $alfa) * $sparsi[$i] / $massimo;
    }
    $ordine = array_keys($ibridi);
    usort($ordine, fn($a, $b) => $ibridi[$b] <=> $ibridi[$a]);
    return [$ordine, $ibridi];
}

/** Autoverifica senza rete: gli stessi casi dei test dell'esempio Python. Lancia un'eccezione se qualcosa non torna. */
function prova(): void
{
    $verifica = function (bool $ok, string $cosa): void {
        if (!$ok) {
            throw new RuntimeException("prova fallita: $cosa");
        }
    };
    $verifica(abs(punteggioSparso([1 => 0.5, 2 => 0.2], [2 => 0.5, 3 => 0.9]) - 0.1) < 1e-12, 'sparso somma solo i token in comune');
    $verifica(punteggioSparso([], [2 => 0.5]) === 0.0, 'sparso vuoto');
    // denso: il documento 0 sembra più vicino (0,80 contro 0,78); sparso: solo il documento 1 condivide il token raro
    $domanda = [[1.0, 0.0], [10 => 0.3, 99 => 0.2]];
    $documenti = [[[0.80, 0.60], [10 => 0.3]], [[0.78, 0.625], [10 => 0.3, 99 => 0.25]]];
    [$soloDenso] = classifica($domanda, $documenti, 1.0);
    [$ibrido, $punteggi] = classifica($domanda, $documenti, 0.6);
    $verifica($soloDenso[0] === 0 && $ibrido[0] === 1 && $punteggi[1] > $punteggi[0], 'la fusione recupera la citazione che il denso sbaglia');
    [$ordine, $p] = classifica([[1.0, 0.0], [5 => 1.0]], [[[0.9, 0.1], []], [[0.5, 0.5], [5 => 1.0]]], 1.0);
    $verifica($ordine === [0, 1] && abs($p[0] - 0.9) < 1e-9, 'con alfa 1 conta solo il coseno');
    echo "prova ok\n";
}

if (PHP_SAPI === 'cli' && realpath($argv[0]) === __FILE__) {
    if (($argv[1] ?? '') === '--prova') {
        prova();
        exit(0);
    }
    if (($argv[1] ?? '') === '--dove') {
        [$u, $s] = indirizzoAgileV();
        [$k, $sk] = ambienteAgileV('AGILEV_KEY');     // del valore non si stampa niente: solo da dove viene
        printf("indirizzo: %s (sorgente: %s; chiave: %s)\n", $u, $s, $k === null ? 'assente' : $sk);
        exit(0);
    }
    if ($argc < 3) {
        fwrite(STDERR, "uso: php ricerca_ibrida.php \"domanda\" doc1.txt doc2.txt ...\n");
        exit(2);
    }
    $domanda = $argv[1];
    $percorsi = array_slice($argv, 2);
    $testi = array_map(fn($p) => file_get_contents($p), $percorsi);
    $vettori = embed(array_merge([$domanda], $testi));   // una chiamata sola: la domanda e i documenti insieme
    [$ordine, $punteggi] = classifica($vettori[0], array_slice($vettori, 1));
    foreach ($ordine as $i) {
        printf("%.3f  %s\n", $punteggi[$i], $percorsi[$i]);
    }
}
