# Verification Cache — Contract didi-brain ↔ didi-backend Status: **IMPLEMENTAT v2** și live la `http://10.11.10.13:8090`. Backend-side trebuie să implementeze apelurile descrise mai jos. **Schimbare majoră față de v1** (2026-04-23): cheia de cache NU mai include `evidence_hash`. Cheia e acum `(claim_hash, tier)`. URL-urile evidence rămân stocate ca metadata și sunt returnate în response ca `verification_evidence_urls` — backend decide overlap cu evidence-ul curent. **De ce**: URL-urile pe care le avea backend-ul la WRITE-time rareori coincid cu URL-urile pe care brain le întoarce la READ-time (brain face semantic search în corpus-ul propriu, backend avea URL-uri din M17/web sau din altă rulare). Cu evidence_hash în cheie, cache-ul era practic nereachable. --- ## Principiu - **Backend deține prompt-ul + modelul + logica de status/thresholds.** - Brain stochează rezultatul LLM ca **blob opac** și îl returnează 1:1. - Zero sync de prompt între backend și brain. Zero duplicare de lucru LLM. --- ## Ciclul complet per claim ``` ┌─ gather (cu verification cache check) ─────────────────────────────┐ │ │ │ backend → POST http://10.11.10.13:8090/v1/gather │ │ body: { claim, max_evidence, include_verification: true, │ │ tier, prompt_hash, framework_version } │ │ │ │ brain → returnează evidence + brain_meta cu: │ │ ├─ verification_staleness: fresh | stale_framework | │ │ │ stale_prompt | miss │ │ └─ verification: { ... } sau null │ │ │ │ backend decide: │ │ fresh → folosește verification DIRECT la UI │ │ stale_framework → ia raw, RECOMPUTĂ status local (zero LLM) │ │ stale_prompt → LLM call + POST /v1/verification_cache │ │ miss → LLM call + POST /v1/verification_cache │ │ │ └────────────────────────────────────────────────────────────────────┘ ``` --- ## Endpoints ### 1. POST /v1/verification_cache (WRITE) **URL:** `http://10.11.10.13:8090/v1/verification_cache` **Request body:** ```json { "claim": "Romania a câștigat 9 medalii olimpice la Paris 2024.", "evidence_urls": [ "https://adevarul.ro/articol1", "https://somes-tisa.rowater.ro/pdf2" ], "tier": "free", "model": "qwen35:qwen3.5", "prompt_hash": "a3f2b1c9d4e7", "framework_version": "f9e1d2c3b4a5", "schema_name": "didi-v1", "verification_raw": { "sources_analysis": [ {"url": "...", "stance": "SUPPORTS", "reliability": "news", "relevant_quote": "..."} ], "agreement_score": 60, "confidence": 75, "status": "LT", "reasoning": "..." }, "verification_processed": { "id": "claim_1", "text": "...", "status": "LT", "status_name_ro": "Probabil Adevărat", "confidence": 75, "agreement_score": 100, "sources": [...], "reasoning": "..." } } ``` **Required:** `claim`, `evidence_urls` (non-empty), `tier`, `prompt_hash` (≥4 chars), `verification_processed`. **Optional:** `model`, `framework_version`, `schema_name` (default `"didi-v1"`), `verification_raw`. **Response 200:** ```json { "cached": true, "claim_hash": "9b769fa8caba883...", "evidence_hash": "d04318eec4bfc01...", "tier": "free", "created_at": "2026-04-23T11:01:22.607213Z", "updated_at": "2026-04-23T11:01:22.607213Z", "expires_at": "2026-05-23T11:01:22.598217Z" } ``` **Errors:** - `413 Payload Too Large` — body > 64 KB - `422 Unprocessable Entity` — validare pydantic (tier != free/premium, prompt_hash < 4 chars, etc.) - `503 Service Unavailable` — PG indisponibil, retry mai târziu - `500 Internal Server Error` — orice alt eșec **Idempotență:** UPSERT pe cheia `(claim_hash, tier)`. Same-tuple, second write → update `verification_processed`/`verification_raw`/`updated_at`/`expires_at`. Last-writer-wins. --- ### 2. POST /v1/gather (READ extins) **URL:** `http://10.11.10.13:8090/v1/gather` **Request body:** exact ca azi, plus 4 câmpuri noi (toate optional; default behavior nu se schimbă): ```json { "claim": "...", "max_evidence": 5, "run_nli": false, "include_verification": true, // NEW — opt-in, default false "tier": "free", // REQUIRED când include_verification=true "prompt_hash": "a3f2b1c9d4e7", // optional, dar recomandat "framework_version": "f9e1d2c3b4a5" // optional } ``` **Response body:** exact ca azi, plus `brain_meta` îmbogățit: ```json { "claim": "...", "evidence": [ ... ], // URL-uri din corpus-ul brain — pot diferi "brain_meta": { // de cele din verification_evidence_urls "cache_status": "HIT", "evidence_sources": 5, "verification_staleness": "fresh", "verification": { ... payload stocat ... }, "verification_model": "qwen35:qwen3.5", "verification_tier": "free", "verification_prompt_hash": "a3f2b1c9d4e7", "verification_framework_version": "f9e1d2c3b4a5", "verification_cached_at": "2026-04-23T11:01:22.607213Z", "verification_expires_at": "2026-05-23T11:01:22.598217Z", "verification_evidence_urls": [ // URL-urile pe care a fost rulat LLM-ul "https://adevarul.ro/articol1", // când a fost scris cache-ul. "https://somes-tisa.rowater.ro/pdf2" // Backend poate compara cu ], // evidence[].url și decide dacă cache-ul "verification_evidence_hash": "d04318eec4bfc01..." // mai e relevant. } } ``` **Important: verificare overlap evidence (backend-side)** URL-urile din `evidence[]` (ce returnează gather din corpus) pot diferi de `brain_meta.verification_evidence_urls` (ce a primit LLM-ul la WRITE). Backend decide ce face: - **Overlap >= 60%** (recomandat): folosește `verification` direct; `sources` din verification acoperă cele mai multe evidence din `evidence[]`. - **Overlap mic/zero**: corpusul brain s-a schimbat; poți trata ca `miss` și să rulezi LLM din nou. Brain returnează staleness=fresh din perspectiva prompt/framework, dar tu evaluezi semantic dacă sursele mai sunt relevante. Exemplu TypeScript: ```typescript function evidenceOverlap(a: string[], b: string[]): number { const norm = (u: string) => u.trim().toLowerCase().replace(/\/$/, ''); const setA = new Set(a.map(norm)); const setB = new Set(b.map(norm)); const inter = [...setA].filter(x => setB.has(x)).length; return inter / Math.max(setA.size, setB.size); } const gatherEvidence = data.evidence.map(e => e.url); const cachedFor = data.brain_meta.verification_evidence_urls || []; if (evidenceOverlap(gatherEvidence, cachedFor) >= 0.6) { // use cached verification } else { // treat as miss despite staleness=fresh } ``` --- ## Semantica `verification_staleness` | Staleness | Când apare | `verification` conține | Ce face backend | |---|---|---|---| | `fresh` | prompt + framework identice cu cache | `verification_processed` | folosește direct la UI | | `stale_framework` | prompt identic, framework diferit | `verification_raw` | recomputează status local din raw (zero LLM) | | `stale_prompt` | prompt diferit | `null` | LLM call + POST cache | | `miss` | nu există entry sau a expirat | `null` | LLM call + POST cache | --- ## Reguli de hashing (toate calculate server-side de brain) **Backend trimite plain `claim` și `evidence_urls`.** Brain normalizează și hash-uiește. Backend nu trebuie să computeze nimic. ### Normalizare `claim` ```python def normalize_claim(s): s = unicodedata.normalize("NFKD", s) # decompunere diacritice s = "".join(c for c in s if not unicodedata.combining(c)) # strip diacritice s = s.lower() # lowercase s = " ".join(s.split()) # collapse whitespace s = s.rstrip(".?!") # trim trailing punctuation return s ``` **Aceleași hash:** - `"România a câștigat 9 medalii."` - `"romania a castigat 9 medalii"` - `"Romania a câștigat 9 medalii!"` **Hash-uri diferite (corect):** - `"Vaccinul e sigur"` vs `"Vaccinul nu e sigur"` (negație contează) - `"X are 10 mașini"` vs `"X are 11 mașini"` (numere contează) ### Normalizare `evidence_urls` ```python def hash_evidence_urls(urls): canonical = sorted({u.strip().lower().rstrip("/") for u in urls if u}) return sha256("\n".join(canonical)).hexdigest() ``` - Ordinea URL-urilor nu contează (sortate). - Case nu contează (`HTTPS://X.COM` == `https://x.com`). - Trailing `/` nu contează. - Duplicate sunt eliminate. --- ## Isolation pe tier Cache pe `free` și `premium` sunt **complet separate**. Un user `free` nu vede ce a scris un user `premium` (și invers). Este intenționat: modele diferite = nuanțe diferite la stance. Cheia unică în DB: `(claim_hash, tier)`. --- ## TTL Default **30 zile** per entry. Reîmprospătat la fiecare UPSERT. După expirare, rândul există încă în DB dar e invizibil la lookup (filtrat cu `expires_at > now()`). Pruning ulterior — nu e critic pentru corectitudine. --- ## Cum calculezi `prompt_hash` și `framework_version` backend-side Recomandarea ta, pe care o respect în brain: ```javascript // prompt_hash — schimbă la orice editare a prompt-ului (auto-invalidation) const promptData = await redis.get('didi:config:claims:v1:prompts:verification'); const { system, user_template } = JSON.parse(promptData); const promptHash = sha256(system + '|' + user_template).slice(0, 12); // framework_version — hash peste thresholds + scoring config const frameworkData = await redis.get('didi:framework:claims'); const scoringData = await redis.get('didi:config:claims:v1:scoring_config'); const frameworkVersion = sha256(frameworkData + '|' + scoringData).slice(0, 12); ``` Calculat în `loadConfigs()` și cached în memorie până la următorul reload. --- ## Exemplu complet flow backend-side ```typescript // În searchEvidence / verifySingleClaim: async function verifyClaim(claim, evidence, tier) { const evidenceUrls = evidence.map(e => e.url); // 1. Check brain cache via gather extension const brainResp = await fetch(`${DIDI_BRAIN_URL}/v1/gather`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claim, max_evidence: evidence.length, run_nli: false, include_verification: true, tier, prompt_hash: await getPromptHash(), framework_version: await getFrameworkVersion(), }), signal: AbortSignal.timeout(5000), }); const data = await brainResp.json(); const meta = data.brain_meta || {}; if (meta.verification_staleness === 'fresh') { // Direct la UI — zero LLM, zero overhead return meta.verification; } if (meta.verification_staleness === 'stale_framework') { // Backend recomputează din raw (thresholds noi) const raw = meta.verification; // verification_raw return { ...buildProcessedFromRaw(raw), // aplică calculateStatusFromSources reasoning: raw.reasoning, }; } // 2. miss sau stale_prompt — LLM call propriu const llmResponse = await callVerificationLLM(claim, evidence, tier); const processed = buildProcessedFromRaw(llmResponse); // 3. Fire-and-forget push la brain fetch(`${DIDI_BRAIN_URL}/v1/verification_cache`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claim, evidence_urls: evidenceUrls, tier, model: modelUsed, prompt_hash: await getPromptHash(), framework_version: await getFrameworkVersion(), verification_raw: llmResponse, verification_processed: processed, }), signal: AbortSignal.timeout(3000), }).catch(() => {}); // silent failure return processed; } ``` --- ## Smoke tests (poți rula oricând) ```bash # Write test curl -sf -X POST http://10.11.10.13:8090/v1/verification_cache \ -H 'Content-Type: application/json' \ -d '{ "claim": "test claim", "evidence_urls": ["https://example.com/a"], "tier": "free", "prompt_hash": "test_abc_12", "verification_processed": {"status": "LT", "confidence": 80} }' | jq # Read test (should be fresh) curl -sf -X POST http://10.11.10.13:8090/v1/gather \ -H 'Content-Type: application/json' \ -d '{ "claim": "test claim", "include_verification": true, "tier": "free", "prompt_hash": "test_abc_12" }' | jq '.brain_meta.verification_staleness, .brain_meta.verification' ``` --- ## Config brain-side Azi: - `VERIFICATION_CACHE_TTL_DAYS=30` - `VERIFICATION_CACHE_MAX_PAYLOAD_KB=64` Ajustabil via `.env` dacă e nevoie.