Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View file

@ -0,0 +1,311 @@
"""Daily auditor — Pilon 5 of the cache freshness defense.
Sweeps non-stable cache rows whose ``last_audited_at`` is older than the
audit interval, runs the cache_judge against fresh evidence from a /v1/gather
call, and applies KEEP/INVALIDATE decisions. Atoms that survive consecutive
audits accumulate ``consecutive_audit_passes`` so the judge becomes harder
to flip them trusted gold/silver gain inertia over time.
Pipeline per atom:
1. SELECT candidate atom from brain_analysis_atom
2. POST /v1/gather to brain to get fresh top-3 evidence for the claim
3. Extract cached_truth from result_processed (TRUE / FALSE / MIXED / UV)
4. cache_judge.judge_cache_validity(claim, cached_truth, evidence, ...)
5. analysis_atom.apply_judge_verdict(atom_id, verdict)
INVALIDATE expires_at = now()
KEEP_CACHE bump consecutive_audit_passes (rate-limited)
NEEDS_FULL_RECHECK no DB change, just logged
This task imports brain_api directly (same Python code) for DB pool access
and helper functions; HTTP is used only for gather (which orchestrates
search + rerank + NLI inside brain itself).
"""
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
from typing import Any
from brain_api.db import db
from brain_api.services.analysis_atom import (
apply_judge_verdict,
is_effectively_fresh,
)
from brain_api.services.cache_judge import (
EvidenceSnippet,
JudgeVerdict,
judge_cache_validity,
)
from scheduler.brain_client import BrainClient
from scheduler.config import settings
from shared.llm_client import LlmClient
from shared.logging import get_logger
log = get_logger(__name__)
def _extract_cached_truth(processed: dict | None) -> str:
"""Pull a cached_truth label from result_processed.
DIDI v1 schema for the claims component uses status: TRUE/FALSE/UV/OP/MIXED.
For techniques + ai_tampered the result is a structured score object
rather than a truth direction those components don't benefit from the
NLI judge anyway (their result depends on the input text, not the world),
so we return UNVERIFIED to make the judge degrade to NEEDS_FULL_RECHECK
or KEEP_CACHE depending on evidence.
"""
if not isinstance(processed, dict):
return "UNVERIFIED"
raw = processed.get("status") or processed.get("verdict")
if not isinstance(raw, str):
return "UNVERIFIED"
s = raw.strip().upper()
if s in ("TRUE", "VT", "VERIFIED_TRUE"):
return "TRUE"
if s in ("FALSE", "VF", "VERIFIED_FALSE"):
return "FALSE"
if s in ("MIXED",):
return "MIXED"
return "UNVERIFIED"
async def _select_candidates(limit: int) -> list[dict[str, Any]]:
"""Pick atoms eligible for audit, oldest-first.
Eligibility:
- cache_tier IN ('gold','silver')
- volatility IS NOT NULL AND volatility != 'stable'
- last_audited_at IS NULL OR < now() - auditor_interval
- created_at < now() - min_age_hours (let new writes settle)
- expires_at IS NULL OR > now() (don't audit dead rows)
"""
if not db.pool:
raise RuntimeError("brain_db not connected")
sql = """
SELECT atom_id, content_hash, content_preview, component, tier,
cache_tier, volatility, topic_codes, llm_confidence,
result_processed, created_at, last_audited_at,
consecutive_audit_passes
FROM brain_analysis_atom
WHERE cache_tier IN ('gold', 'silver')
AND volatility IS NOT NULL
AND volatility <> 'stable'
AND (expires_at IS NULL OR expires_at > now())
AND created_at < now() - ($1 || ' hours')::interval
AND (
last_audited_at IS NULL
OR last_audited_at < now() - ($2 || ' seconds')::interval
)
ORDER BY last_audited_at NULLS FIRST, created_at ASC
LIMIT $3
"""
async with db.pool.acquire() as conn:
rows = await conn.fetch(
sql,
str(settings.auditor_min_age_hours),
str(settings.auditor_interval_s),
limit,
)
out: list[dict[str, Any]] = []
for r in rows:
result_processed = r["result_processed"]
if isinstance(result_processed, str):
result_processed = json.loads(result_processed)
out.append({
"atom_id": r["atom_id"],
"content_hash": r["content_hash"],
"content_preview": r["content_preview"] or "",
"component": r["component"],
"tier": r["tier"],
"cache_tier": r["cache_tier"],
"volatility": r["volatility"],
"topic_codes": list(r["topic_codes"]) if r["topic_codes"] else [],
"llm_confidence": (
float(r["llm_confidence"])
if r["llm_confidence"] is not None
else None
),
"result_processed": result_processed or {},
"created_at": r["created_at"],
"last_audited_at": r["last_audited_at"],
"consecutive_audit_passes": r["consecutive_audit_passes"],
})
return out
def _evidence_from_gather_response(resp: dict | None) -> list[EvidenceSnippet]:
"""Convert a brain /v1/gather response payload into EvidenceSnippet list."""
if not resp:
return []
items = resp.get("evidence") or []
out: list[EvidenceSnippet] = []
for it in items[:3]: # judge uses top 3 anyway
url = (it.get("url") or "").strip()
text = (
it.get("full_text")
or it.get("summary")
or it.get("snippet")
or ""
)
published = it.get("published_at")
if url and text:
out.append(EvidenceSnippet(url=url, text=text, published_at=published))
return out
async def _gather_fresh_evidence(
brain: BrainClient, *, claim: str, volatility: str
) -> list[EvidenceSnippet]:
"""Call /v1/gather over HTTP to get fresh evidence for the claim.
Disables NLI on this internal call (we run our own NLI via cache_judge
afterwards, so doing it twice would be wasteful).
"""
body = {
"claim": claim[:1500],
"max_evidence": 5,
"include_full_text": True,
"run_nli": False,
"volatility_hint": volatility,
}
try:
resp = await brain._http.post("/v1/gather", json=body)
if resp.status_code >= 400:
log.debug(
"auditor_gather_http_error",
status=resp.status_code,
)
return []
return _evidence_from_gather_response(resp.json())
except Exception as e: # noqa: BLE001
log.debug("auditor_gather_failed", error=f"{type(e).__name__}:{e}")
return []
async def _audit_one(
*,
candidate: dict[str, Any],
brain: BrainClient,
llm: LlmClient,
) -> str:
"""Run a single audit. Returns the decision label for telemetry."""
atom_id = candidate["atom_id"]
claim = candidate["content_preview"]
volatility = candidate["volatility"] or "evolving"
if not claim:
# Can't judge without a claim text — touch last_audited_at to defer
# this row and move on.
log.debug("auditor_no_claim_text", atom_id=atom_id)
return "SKIPPED"
cached_truth = _extract_cached_truth(candidate["result_processed"])
age_hours = (
datetime.now(tz=timezone.utc) - candidate["created_at"]
).total_seconds() / 3600.0
# Cheap path: if effective confidence is still high we don't even need
# to gather. Bumps consecutive_audit_passes via apply_judge_verdict.
eff_fresh = is_effectively_fresh(
base_confidence=candidate["llm_confidence"],
volatility=volatility,
age_hours=age_hours,
consecutive_audit_passes=candidate["consecutive_audit_passes"],
)
if not eff_fresh:
# Confidence has decayed below floor — auto-invalidate without
# spending an LLM call.
verdict = JudgeVerdict(
decision="INVALIDATE",
nli_skipped=True,
reasoning=(
f"effective confidence below floor "
f"(volatility={volatility}, age={age_hours:.0f}h)"
),
evaluated_at=datetime.now(tz=timezone.utc).isoformat(),
)
await apply_judge_verdict(atom_id, verdict)
log.info(
"auditor_invalidated_decay",
atom_id=atom_id,
volatility=volatility,
age_hours=round(age_hours, 1),
)
return "INVALIDATE_DECAY"
# Active path: gather + judge.
evidence = await _gather_fresh_evidence(
brain, claim=claim, volatility=volatility
)
verdict = await judge_cache_validity(
llm,
claim=claim,
cached_truth=cached_truth,
current_evidence=evidence,
volatility=volatility,
age_hours=age_hours,
consecutive_audit_passes=candidate["consecutive_audit_passes"],
)
await apply_judge_verdict(atom_id, verdict)
log.info(
"auditor_decision",
atom_id=atom_id,
decision=verdict.decision,
volatility=volatility,
cached_truth=cached_truth,
)
return verdict.decision
async def _run_one_cycle(brain: BrainClient, llm: LlmClient) -> dict[str, int]:
"""Pull candidates and audit them sequentially.
Sequential rather than parallel because each judge call hits the LLM
router running 200 in parallel would saturate it. The local Qwen
handles ~2-4 concurrent requests well, so we could batch with a
semaphore later if cycle duration becomes an issue.
"""
candidates = await _select_candidates(settings.auditor_batch_limit)
if not candidates:
return {"audited": 0, "candidates": 0}
counts: dict[str, int] = {}
for c in candidates:
try:
decision = await _audit_one(candidate=c, brain=brain, llm=llm)
counts[decision] = counts.get(decision, 0) + 1
except Exception as e: # noqa: BLE001
log.warning(
"auditor_one_failed",
atom_id=c.get("atom_id"),
error=f"{type(e).__name__}:{e}",
)
counts["ERROR"] = counts.get("ERROR", 0) + 1
log.info(
"auditor_cycle_done",
audited=len(candidates),
breakdown=counts,
)
return {"audited": len(candidates), **counts}
async def run_auditor(brain: BrainClient, llm: LlmClient) -> None:
"""Long-running task — sweeps daily by default."""
if not settings.auditor_enabled:
log.info("auditor_disabled")
return
log.info(
"auditor_loop_start",
interval_s=settings.auditor_interval_s,
batch_limit=settings.auditor_batch_limit,
)
while True:
try:
await _run_one_cycle(brain, llm)
except Exception: # noqa: BLE001
log.exception("auditor_cycle_error")
await asyncio.sleep(settings.auditor_interval_s)