didi-lot1-ai/ai_platform/modules/didi_brain/lint/_state.py

143 lines
4.6 KiB
Python

"""Persistent state for the Lint pass.
A single JSON file at `lint/_contradictions.json` stores every pair we have
already evaluated, keyed by a stable pair_hash. Re-runs skip any pair already
evaluated at the current prompt version.
Only CONTRADICTORY verdicts are the "interesting output" but we keep
EQUIVALENT / INCOMPARABLE too because:
- EQUIVALENT pairs are paraphrase clusters (useful later for canonicalization)
- All labels matter for the idempotency ledger
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
_STATE_FILE = Path(__file__).resolve().parent / "_contradictions.json"
STATE_VERSION = "v1"
def pair_hash(atom_a_id: str, atom_b_id: str) -> str:
"""Canonical hash of an (a, b) pair, invariant under swap."""
lo, hi = sorted((atom_a_id, atom_b_id))
return hashlib.sha1(f"{lo}|{hi}".encode("utf-8")).hexdigest()[:16]
@dataclass(slots=True, frozen=True)
class PairVerdict:
pair_hash: str
atom_a_id: str
atom_b_id: str
atom_a_url: str
atom_b_url: str
atom_a_claim: str
atom_b_claim: str
label: str # EQUIVALENT / CONTRADICTORY / INCOMPARABLE
confidence: float
similarity: float # the first-stage embedding similarity that selected this pair
detected_at: str
prompt_version: str
error: str | None = None
@dataclass(slots=True)
class LintStats:
atoms_seen: int = 0
candidates_generated: int = 0
pairs_evaluated: int = 0
pairs_skipped_cached: int = 0
contradictory: int = 0
equivalent: int = 0
incomparable: int = 0
errors: int = 0
class LintState:
"""Loads/saves the contradiction ledger JSON with idempotency support."""
def __init__(self, path: Path | None = None):
self._path = path or _STATE_FILE
self._evaluated: dict[str, PairVerdict] = {}
self._prompt_version: str = "v1"
self._last_run: str | None = None
self._load()
def _load(self) -> None:
if not self._path.exists():
return
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
# Corrupt or unreadable state — start fresh rather than crash.
return
self._prompt_version = data.get("prompt_version", "v1")
self._last_run = data.get("last_run")
for raw in data.get("evaluated", []):
try:
pv = PairVerdict(**raw)
except TypeError:
continue
self._evaluated[pv.pair_hash] = pv
def set_prompt_version(self, pv: str) -> None:
self._prompt_version = pv
def already_evaluated(self, h: str, prompt_version: str) -> bool:
pv = self._evaluated.get(h)
return pv is not None and pv.prompt_version == prompt_version
def get(self, h: str) -> PairVerdict | None:
return self._evaluated.get(h)
def upsert(self, verdict: PairVerdict) -> None:
self._evaluated[verdict.pair_hash] = verdict
@property
def all_verdicts(self) -> list[PairVerdict]:
return list(self._evaluated.values())
@property
def all_contradictions(self) -> list[PairVerdict]:
return [v for v in self._evaluated.values() if v.label == "CONTRADICTORY"]
@property
def all_equivalents(self) -> list[PairVerdict]:
return [v for v in self._evaluated.values() if v.label == "EQUIVALENT"]
def save(self) -> None:
"""Write the full ledger to disk atomically (write-temp + rename)."""
self._path.parent.mkdir(parents=True, exist_ok=True)
contras = self.all_contradictions
equivs = self.all_equivalents
body = {
"version": STATE_VERSION,
"prompt_version": self._prompt_version,
"last_run": datetime.now(timezone.utc).isoformat(),
"stats": {
"total_pairs": len(self._evaluated),
"contradictory": len(contras),
"equivalent": len(equivs),
"incomparable": len(self._evaluated) - len(contras) - len(equivs),
},
"evaluated": [asdict(v) for v in self._evaluated.values()],
}
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(
json.dumps(body, indent=2, ensure_ascii=False),
encoding="utf-8",
)
tmp.replace(self._path)
@property
def path(self) -> Path:
return self._path
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()