13 KiB
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:
{
"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:
{
"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 KB422 Unprocessable Entity— validare pydantic (tier != free/premium, prompt_hash < 4 chars, etc.)503 Service Unavailable— PG indisponibil, retry mai târziu500 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ă):
{
"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:
{
"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
verificationdirect;sourcesdin verification acoperă cele mai multe evidence dinevidence[]. - 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:
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
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
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:
// 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
// Î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)
# 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=30VERIFICATION_CACHE_MAX_PAYLOAD_KB=64
Ajustabil via .env dacă e nevoie.