Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
13
ai_platform/modules/didi_brain/lint/__init__.py
Normal file
13
ai_platform/modules/didi_brain/lint/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Lint pass — cross-corpus contradiction detection.
|
||||
|
||||
For each claim atom in the brain, find semantic neighbors and classify each
|
||||
candidate pair as EQUIVALENT, CONTRADICTORY, or INCOMPARABLE via Qwen 397B.
|
||||
Contradictions are stored in `lint/_contradictions.json` with idempotency
|
||||
markers so reruns only process new pairs.
|
||||
|
||||
Entry points:
|
||||
python scripts/10_run_lint.py # full corpus
|
||||
python scripts/10_run_lint.py --limit N # cap source claims
|
||||
python scripts/10_run_lint.py --force # re-evaluate cached pairs
|
||||
python scripts/11_show_contradictions.py # read state + render
|
||||
"""
|
||||
143
ai_platform/modules/didi_brain/lint/_state.py
Normal file
143
ai_platform/modules/didi_brain/lint/_state.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""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()
|
||||
114
ai_platform/modules/didi_brain/lint/detector.py
Normal file
114
ai_platform/modules/didi_brain/lint/detector.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Single-pair NLI classification for the Lint pass.
|
||||
|
||||
Takes two claim texts and asks Qwen 397B whether they are EQUIVALENT,
|
||||
CONTRADICTORY, or INCOMPARABLE. Returns a dataclass with label +
|
||||
confidence + optional error. Never raises — all failure paths fall
|
||||
through to INCOMPARABLE with an error string.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from shared.config import LlmRole
|
||||
from shared.llm_client import LlmClient, LlmError
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
PROMPT_VERSION = "v1"
|
||||
_PROMPT_PATH = (
|
||||
Path(__file__).resolve().parent / "prompts" / f"pair_nli_{PROMPT_VERSION}.md"
|
||||
)
|
||||
|
||||
ALLOWED_LABELS = {"EQUIVALENT", "CONTRADICTORY", "INCOMPARABLE"}
|
||||
MAX_CLAIM_CHARS = 1500 # truncate long evidence before sending to LLM
|
||||
PER_CALL_TIMEOUT_S = 30.0
|
||||
|
||||
|
||||
_PROMPT: str | None = None
|
||||
|
||||
|
||||
def _load_prompt() -> str:
|
||||
global _PROMPT
|
||||
if _PROMPT is None:
|
||||
_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
|
||||
return _PROMPT
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class PairClassification:
|
||||
label: str
|
||||
confidence: float
|
||||
error: str | None = None
|
||||
|
||||
|
||||
async def classify_pair(
|
||||
llm: LlmClient,
|
||||
*,
|
||||
claim_a: str,
|
||||
claim_b: str,
|
||||
timeout_s: float = PER_CALL_TIMEOUT_S,
|
||||
) -> PairClassification:
|
||||
if not claim_a.strip() or not claim_b.strip():
|
||||
return PairClassification(
|
||||
label="INCOMPARABLE", confidence=0.0, error="empty_claim"
|
||||
)
|
||||
|
||||
prompt = (
|
||||
_load_prompt()
|
||||
.replace("{claim_a}", claim_a[:MAX_CLAIM_CHARS])
|
||||
.replace("{claim_b}", claim_b[:MAX_CLAIM_CHARS])
|
||||
)
|
||||
|
||||
try:
|
||||
result, _usage = await asyncio.wait_for(
|
||||
llm.chat_json(
|
||||
role=LlmRole.REASONING,
|
||||
system=(
|
||||
"You are a precise claim comparison classifier. "
|
||||
"Respond with strictly valid JSON only, no commentary."
|
||||
),
|
||||
user=prompt,
|
||||
max_tokens=120,
|
||||
temperature=0.0,
|
||||
),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return PairClassification(
|
||||
label="INCOMPARABLE", confidence=0.0, error="timeout"
|
||||
)
|
||||
except LlmError as e:
|
||||
return PairClassification(
|
||||
label="INCOMPARABLE", confidence=0.0, error=f"llm:{e}"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return PairClassification(
|
||||
label="INCOMPARABLE",
|
||||
confidence=0.0,
|
||||
error=f"{type(e).__name__}:{e}",
|
||||
)
|
||||
|
||||
if not isinstance(result, dict):
|
||||
return PairClassification(
|
||||
label="INCOMPARABLE", confidence=0.0, error="non_dict_response"
|
||||
)
|
||||
|
||||
raw_label = (result.get("label") or "").strip().upper()
|
||||
try:
|
||||
conf = float(result.get("confidence", 0))
|
||||
except (TypeError, ValueError):
|
||||
conf = 0.0
|
||||
conf = max(0.0, min(1.0, conf))
|
||||
|
||||
if raw_label not in ALLOWED_LABELS:
|
||||
return PairClassification(
|
||||
label="INCOMPARABLE",
|
||||
confidence=0.0,
|
||||
error=f"bad_label:{raw_label[:40]}",
|
||||
)
|
||||
|
||||
return PairClassification(label=raw_label, confidence=conf)
|
||||
93
ai_platform/modules/didi_brain/lint/pairs.py
Normal file
93
ai_platform/modules/didi_brain/lint/pairs.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Candidate pair generation for the Lint pass.
|
||||
|
||||
For each source claim atom, we pull its nearest neighbors via the Atomic
|
||||
/api/atoms/{id}/similar endpoint (which uses pgvector kNN under the hood).
|
||||
Only Type/Claim neighbors are kept; Type/Document neighbors are dropped so
|
||||
we only compare claim-to-claim.
|
||||
|
||||
Pairs are canonicalized as (lower_id, higher_id) so (A, B) and (B, A)
|
||||
produce the same CandidatePair and don't get evaluated twice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from shared.atomic_api import AtomicClient
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# Atoms with similarity below this are considered too unrelated to be worth
|
||||
# an NLI call. Between 0.55 and 0.95 is the interesting band — below is
|
||||
# "probably different topic", above is "almost certainly the same text".
|
||||
MIN_PAIR_SIMILARITY = 0.55
|
||||
NEIGHBORS_PER_CLAIM = 15
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class CandidatePair:
|
||||
"""A pair of claim atom ids to evaluate, canonicalized lo<hi."""
|
||||
|
||||
atom_a_id: str
|
||||
atom_b_id: str
|
||||
similarity: float
|
||||
|
||||
|
||||
def _is_claim_hit(hit_tags: list[dict]) -> bool:
|
||||
return any(t.get("name") == "Claim" for t in hit_tags)
|
||||
|
||||
|
||||
async def generate_candidates(
|
||||
atomic: AtomicClient,
|
||||
*,
|
||||
source_atom_ids: list[str],
|
||||
min_similarity: float = MIN_PAIR_SIMILARITY,
|
||||
neighbors_per: int = NEIGHBORS_PER_CLAIM,
|
||||
) -> list[CandidatePair]:
|
||||
"""Walk each source atom and collect canonical candidate pairs.
|
||||
|
||||
Limitation: Atomic's /similar endpoint returns Document and Claim atoms
|
||||
mixed together. We filter client-side for Type/Claim. It might be worth
|
||||
upstream adding a tag filter later, but for this corpus size the current
|
||||
approach is fine.
|
||||
"""
|
||||
seen: set[tuple[str, str]] = set()
|
||||
out: list[CandidatePair] = []
|
||||
|
||||
for idx, src_id in enumerate(source_atom_ids):
|
||||
try:
|
||||
hits = await atomic.find_similar(
|
||||
src_id, threshold=min_similarity, limit=neighbors_per
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
"find_similar_failed", atom_id=src_id, error=f"{type(e).__name__}:{e}"
|
||||
)
|
||||
continue
|
||||
|
||||
for h in hits:
|
||||
if h.atom_id == src_id:
|
||||
continue
|
||||
if not _is_claim_hit(h.tags):
|
||||
continue
|
||||
lo, hi = sorted((src_id, h.atom_id))
|
||||
key = (lo, hi)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(
|
||||
CandidatePair(
|
||||
atom_a_id=lo, atom_b_id=hi, similarity=round(h.similarity, 4)
|
||||
)
|
||||
)
|
||||
|
||||
if (idx + 1) % 50 == 0:
|
||||
log.info(
|
||||
"candidates_progress",
|
||||
processed=idx + 1,
|
||||
total=len(source_atom_ids),
|
||||
pairs=len(out),
|
||||
)
|
||||
|
||||
return out
|
||||
37
ai_platform/modules/didi_brain/lint/prompts/pair_nli_v1.md
Normal file
37
ai_platform/modules/didi_brain/lint/prompts/pair_nli_v1.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
You are a claim comparison classifier for a knowledge graph auditor.
|
||||
|
||||
Given two factual claims extracted from different sources in the same knowledge base, decide their logical relationship. Both claims are typically about the same general topic.
|
||||
|
||||
# Labels
|
||||
|
||||
- **EQUIVALENT** — A and B assert the same factual proposition, just in different words (or different languages). If one is a paraphrase, translation, or near-restatement of the other, the label is EQUIVALENT.
|
||||
- **CONTRADICTORY** — A and B make claims that cannot both be true. If one asserts X and the other asserts NOT-X (or something logically incompatible), the label is CONTRADICTORY.
|
||||
- **INCOMPARABLE** — A and B are on the same topic but make independent, non-overlapping assertions. One is not entailed or denied by the other. Use this as your default when unsure.
|
||||
|
||||
# Rules
|
||||
|
||||
1. Focus ONLY on the logical/factual relationship between the two claims themselves. Do not consider the sources' credibility or intent.
|
||||
2. Same-language and cross-language pairs are judged identically — meaning matters, not wording.
|
||||
3. Minor numerical differences (e.g., "14 cases" vs "15 cases") count as CONTRADICTORY only when the difference is clearly factual and specific, not approximate reporting.
|
||||
4. A claim about X that is a SUBSET of a claim about X is EQUIVALENT if it means the same thing; otherwise INCOMPARABLE.
|
||||
5. A retraction/correction claim ("study was found to be fraudulent") is CONTRADICTORY to the original claim it retracts.
|
||||
6. If either claim is too vague, too broad, or you cannot decide — use INCOMPARABLE.
|
||||
7. Confidence reflects how clean the relationship is:
|
||||
- 0.9-1.0: unambiguous, single-interpretation
|
||||
- 0.7-0.9: clear but with minor caveats
|
||||
- 0.5-0.7: probable but could be argued
|
||||
- below 0.5: you are guessing — prefer INCOMPARABLE with a low confidence
|
||||
|
||||
# Output format — STRICT
|
||||
|
||||
Respond with ONLY this JSON object. No preamble, no markdown fences, no commentary.
|
||||
|
||||
```
|
||||
{"label": "EQUIVALENT|CONTRADICTORY|INCOMPARABLE", "confidence": 0.0-1.0}
|
||||
```
|
||||
|
||||
# Input
|
||||
|
||||
CLAIM A: {claim_a}
|
||||
|
||||
CLAIM B: {claim_b}
|
||||
92
ai_platform/modules/didi_brain/lint/reporter.py
Normal file
92
ai_platform/modules/didi_brain/lint/reporter.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Pretty printing for the Lint pass output.
|
||||
|
||||
Called from scripts/10_run_lint.py after a run, and from
|
||||
scripts/11_show_contradictions.py for ad-hoc inspection of the state file
|
||||
without re-running classification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from lint._state import LintState, LintStats, PairVerdict
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def render_stats(stats: LintStats) -> None:
|
||||
t = Table(title="Lint pass stats", show_lines=False)
|
||||
t.add_column("Metric", style="bold")
|
||||
t.add_column("Count", justify="right")
|
||||
t.add_row("atoms seen", str(stats.atoms_seen))
|
||||
t.add_row("candidate pairs", str(stats.candidates_generated))
|
||||
t.add_row("[dim]skipped (cached)[/dim]", str(stats.pairs_skipped_cached))
|
||||
t.add_row("pairs evaluated", str(stats.pairs_evaluated))
|
||||
t.add_row("[red]CONTRADICTORY[/red]", str(stats.contradictory))
|
||||
t.add_row("[yellow]EQUIVALENT[/yellow]", str(stats.equivalent))
|
||||
t.add_row("[dim]INCOMPARABLE[/dim]", str(stats.incomparable))
|
||||
t.add_row("errors", str(stats.errors))
|
||||
console.print(t)
|
||||
|
||||
|
||||
def render_contradictions(
|
||||
state: LintState,
|
||||
*,
|
||||
top_n: int = 10,
|
||||
min_confidence: float = 0.7,
|
||||
) -> None:
|
||||
contras = [v for v in state.all_contradictions if v.confidence >= min_confidence]
|
||||
contras.sort(key=lambda v: (-v.confidence, -v.similarity))
|
||||
|
||||
header = (
|
||||
f"[bold red]CONTRADICTIONS[/bold red] "
|
||||
f"({len(contras)} with confidence >= {min_confidence:.2f}, showing top {top_n})"
|
||||
)
|
||||
console.print(Panel.fit(header))
|
||||
|
||||
if not contras:
|
||||
console.print(
|
||||
"[dim]no contradictions above threshold. "
|
||||
"The current Wikipedia-only corpus is self-consistent by design, "
|
||||
"which is expected for a single well-curated source. "
|
||||
"Add diverse sources to surface real disagreement.[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
for i, v in enumerate(contras[:top_n], 1):
|
||||
console.print()
|
||||
console.print(
|
||||
f"[bold]#{i}[/bold] "
|
||||
f"confidence=[red]{v.confidence:.2f}[/red] "
|
||||
f"(embed sim {v.similarity:.2f})"
|
||||
)
|
||||
console.print(f" [green]A:[/green] {v.atom_a_claim[:240]}")
|
||||
console.print(f" [dim]→ {v.atom_a_url}[/dim]")
|
||||
console.print(f" [red]B:[/red] {v.atom_b_claim[:240]}")
|
||||
console.print(f" [dim]→ {v.atom_b_url}[/dim]")
|
||||
|
||||
|
||||
def render_equivalents(
|
||||
state: LintState, *, top_n: int = 10, min_confidence: float = 0.85
|
||||
) -> None:
|
||||
equivs = [v for v in state.all_equivalents if v.confidence >= min_confidence]
|
||||
equivs.sort(key=lambda v: (-v.confidence, -v.similarity))
|
||||
|
||||
header = (
|
||||
f"[bold yellow]PARAPHRASE CLUSTERS[/bold yellow] "
|
||||
f"({len(equivs)} with confidence >= {min_confidence:.2f}, showing top {top_n})"
|
||||
)
|
||||
console.print()
|
||||
console.print(Panel.fit(header))
|
||||
|
||||
if not equivs:
|
||||
console.print("[dim]no paraphrase clusters above threshold[/dim]")
|
||||
return
|
||||
|
||||
for i, v in enumerate(equivs[:top_n], 1):
|
||||
console.print()
|
||||
console.print(f"[bold]#{i}[/bold] confidence=[yellow]{v.confidence:.2f}[/yellow]")
|
||||
console.print(f" · {v.atom_a_claim[:200]}")
|
||||
console.print(f" · {v.atom_b_claim[:200]}")
|
||||
258
ai_platform/modules/didi_brain/lint/runner.py
Normal file
258
ai_platform/modules/didi_brain/lint/runner.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"""Orchestrator for the Lint pass.
|
||||
|
||||
Top-level flow:
|
||||
|
||||
1. Pull every Type/Claim atom id from Atomic
|
||||
2. For each claim, ask Atomic for its nearest neighbors → candidate pairs
|
||||
3. Drop pairs we've already evaluated at the current prompt version
|
||||
4. Fetch the full body of each unique atom referenced by the new pairs
|
||||
5. Run Qwen NLI pair classification with bounded parallelism
|
||||
6. Record every verdict in the state file; save every PERIODIC_SAVE_EVERY
|
||||
pairs so a crash mid-run doesn't lose everything
|
||||
|
||||
The runner is safe to re-run at any time — the state file makes it fully
|
||||
idempotent, and we ALWAYS save on exit even on exception.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from shared.atomic_api import AtomicClient
|
||||
from shared.config import settings
|
||||
from shared.llm_client import LlmClient
|
||||
from shared.logging import get_logger
|
||||
from shared.taxonomy import TagResolver
|
||||
|
||||
# Cross-module reuse: the same function the extractor/brain_api use to
|
||||
# pull (claim_text, stance, parent_id) out of a Type/Claim atom body.
|
||||
from brain_api.services.mapping import parse_claim_atom_body
|
||||
|
||||
from lint._state import LintState, LintStats, PairVerdict, now_iso, pair_hash
|
||||
from lint.detector import PROMPT_VERSION, classify_pair
|
||||
from lint.pairs import CandidatePair, generate_candidates
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# Bounded parallelism for NLI calls. We have two llama.cpp backends behind
|
||||
# the router; anything more than that just queues at the backend and eats
|
||||
# our per-call timeout. See brain_api/services/nli.py for the same reasoning.
|
||||
MAX_PARALLEL = 2
|
||||
|
||||
# Write the state file every N verdicts so a crash or Ctrl-C doesn't erase
|
||||
# the whole run. Saving is cheap (small JSON file).
|
||||
PERIODIC_SAVE_EVERY = 25
|
||||
|
||||
# Chunked atom fetch to avoid hammering /api/atoms/{id} with one huge gather.
|
||||
FETCH_CHUNK = 20
|
||||
|
||||
|
||||
async def _load_claim_atom_ids(
|
||||
atomic: AtomicClient,
|
||||
*,
|
||||
type_claim_id: str,
|
||||
limit: int | None,
|
||||
) -> list[str]:
|
||||
"""Page through /api/atoms?tag_id=<Type/Claim> and collect ids."""
|
||||
page_size = 100
|
||||
offset = 0
|
||||
out: list[str] = []
|
||||
while True:
|
||||
result = await atomic.list_atoms(
|
||||
limit=page_size, offset=offset, tag_id=type_claim_id
|
||||
)
|
||||
atoms = result.get("atoms") or (result if isinstance(result, list) else [])
|
||||
if not atoms:
|
||||
break
|
||||
for a in atoms:
|
||||
aid = a.get("id")
|
||||
if aid:
|
||||
out.append(aid)
|
||||
if limit and len(out) >= limit:
|
||||
return out
|
||||
if len(atoms) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
return out
|
||||
|
||||
|
||||
async def _fetch_atoms_bulk(
|
||||
atomic: AtomicClient, atom_ids: list[str], *, chunk: int = FETCH_CHUNK
|
||||
) -> dict[str, dict]:
|
||||
"""Fetch full atom bodies in parallel chunks. Missing atoms are dropped."""
|
||||
out: dict[str, dict] = {}
|
||||
total = len(atom_ids)
|
||||
for i in range(0, total, chunk):
|
||||
ids = atom_ids[i : i + chunk]
|
||||
results = await asyncio.gather(
|
||||
*[atomic.get_atom(a) for a in ids], return_exceptions=True
|
||||
)
|
||||
for a, r in zip(ids, results, strict=True):
|
||||
if isinstance(r, dict):
|
||||
out[a] = r
|
||||
if (i + chunk) % 200 == 0 or (i + chunk) >= total:
|
||||
log.info("fetch_progress", fetched=len(out), total=total)
|
||||
return out
|
||||
|
||||
|
||||
async def run_lint_pass(
|
||||
*,
|
||||
limit_atoms: int | None = None,
|
||||
force: bool = False,
|
||||
) -> LintStats:
|
||||
if not settings.atomic_token:
|
||||
raise RuntimeError("ATOMIC_TOKEN missing — can't talk to brain")
|
||||
|
||||
resolver = TagResolver()
|
||||
type_claim_id = resolver.require("Type/Claim")
|
||||
|
||||
state = LintState()
|
||||
state.set_prompt_version(PROMPT_VERSION)
|
||||
stats = LintStats()
|
||||
|
||||
async with AtomicClient() as atomic, LlmClient() as llm:
|
||||
# ------------------------------------------------------ 1. load atoms
|
||||
t0 = time.perf_counter()
|
||||
claim_ids = await _load_claim_atom_ids(
|
||||
atomic, type_claim_id=type_claim_id, limit=limit_atoms
|
||||
)
|
||||
stats.atoms_seen = len(claim_ids)
|
||||
log.info(
|
||||
"lint_atoms_loaded",
|
||||
count=stats.atoms_seen,
|
||||
elapsed_s=round(time.perf_counter() - t0, 1),
|
||||
)
|
||||
if not claim_ids:
|
||||
return stats
|
||||
|
||||
# ------------------------------------------ 2. candidate pair generation
|
||||
t0 = time.perf_counter()
|
||||
candidates = await generate_candidates(atomic, source_atom_ids=claim_ids)
|
||||
stats.candidates_generated = len(candidates)
|
||||
log.info(
|
||||
"lint_candidates_built",
|
||||
pairs=stats.candidates_generated,
|
||||
elapsed_s=round(time.perf_counter() - t0, 1),
|
||||
)
|
||||
if not candidates:
|
||||
return stats
|
||||
|
||||
# ----------------------------------- 3. filter out already-evaluated pairs
|
||||
to_eval: list[CandidatePair] = []
|
||||
for c in candidates:
|
||||
h = pair_hash(c.atom_a_id, c.atom_b_id)
|
||||
if not force and state.already_evaluated(h, PROMPT_VERSION):
|
||||
stats.pairs_skipped_cached += 1
|
||||
continue
|
||||
to_eval.append(c)
|
||||
log.info(
|
||||
"lint_filter_done",
|
||||
new_pairs=len(to_eval),
|
||||
cached=stats.pairs_skipped_cached,
|
||||
)
|
||||
|
||||
if not to_eval:
|
||||
state.save()
|
||||
return stats
|
||||
|
||||
# -------------------------------------- 4. pre-fetch full atom bodies
|
||||
needed_ids: set[str] = set()
|
||||
for c in to_eval:
|
||||
needed_ids.add(c.atom_a_id)
|
||||
needed_ids.add(c.atom_b_id)
|
||||
t0 = time.perf_counter()
|
||||
full_atoms = await _fetch_atoms_bulk(atomic, list(needed_ids))
|
||||
log.info(
|
||||
"atoms_fetched",
|
||||
count=len(full_atoms),
|
||||
needed=len(needed_ids),
|
||||
elapsed_s=round(time.perf_counter() - t0, 1),
|
||||
)
|
||||
|
||||
# Cache parsed claim bodies so each one is parsed once, not per-pair
|
||||
parsed_by_id: dict[str, tuple[str, str]] = {}
|
||||
for atom_id, full in full_atoms.items():
|
||||
content = full.get("content") or ""
|
||||
text, _stance, _parent_id = parse_claim_atom_body(content)
|
||||
parent_url = (full.get("source_url") or "").split("#", 1)[0]
|
||||
if text:
|
||||
parsed_by_id[atom_id] = (text, parent_url)
|
||||
|
||||
# -------------------------------------- 5. classify pairs (parallel)
|
||||
sem = asyncio.Semaphore(MAX_PARALLEL)
|
||||
total = len(to_eval)
|
||||
|
||||
# Mutable counters so we can log inside the coroutine
|
||||
progress = {"completed": 0}
|
||||
|
||||
async def _worker(pair: CandidatePair) -> PairVerdict | None:
|
||||
a = parsed_by_id.get(pair.atom_a_id)
|
||||
b = parsed_by_id.get(pair.atom_b_id)
|
||||
if not a or not b:
|
||||
return None
|
||||
text_a, url_a = a
|
||||
text_b, url_b = b
|
||||
async with sem:
|
||||
result = await classify_pair(llm, claim_a=text_a, claim_b=text_b)
|
||||
progress["completed"] += 1
|
||||
if progress["completed"] % 20 == 0:
|
||||
log.info(
|
||||
"lint_progress",
|
||||
done=progress["completed"],
|
||||
total=total,
|
||||
pct=round(progress["completed"] / total * 100, 1),
|
||||
)
|
||||
return PairVerdict(
|
||||
pair_hash=pair_hash(pair.atom_a_id, pair.atom_b_id),
|
||||
atom_a_id=pair.atom_a_id,
|
||||
atom_b_id=pair.atom_b_id,
|
||||
atom_a_url=url_a,
|
||||
atom_b_url=url_b,
|
||||
atom_a_claim=text_a,
|
||||
atom_b_claim=text_b,
|
||||
label=result.label,
|
||||
confidence=result.confidence,
|
||||
similarity=pair.similarity,
|
||||
detected_at=now_iso(),
|
||||
prompt_version=PROMPT_VERSION,
|
||||
error=result.error,
|
||||
)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
tasks = [_worker(c) for c in to_eval]
|
||||
# Process results as they arrive so we can save periodically and
|
||||
# keep the ledger fresh even during long runs.
|
||||
try:
|
||||
for fut in asyncio.as_completed(tasks):
|
||||
verdict = await fut
|
||||
if verdict is None:
|
||||
stats.errors += 1
|
||||
continue
|
||||
state.upsert(verdict)
|
||||
stats.pairs_evaluated += 1
|
||||
if verdict.error is not None:
|
||||
stats.errors += 1
|
||||
elif verdict.label == "CONTRADICTORY":
|
||||
stats.contradictory += 1
|
||||
elif verdict.label == "EQUIVALENT":
|
||||
stats.equivalent += 1
|
||||
else:
|
||||
stats.incomparable += 1
|
||||
if stats.pairs_evaluated % PERIODIC_SAVE_EVERY == 0:
|
||||
state.save()
|
||||
finally:
|
||||
# ALWAYS save — even on Ctrl-C / exception, we keep what we got
|
||||
state.save()
|
||||
|
||||
log.info(
|
||||
"lint_classify_done",
|
||||
evaluated=stats.pairs_evaluated,
|
||||
contradictory=stats.contradictory,
|
||||
equivalent=stats.equivalent,
|
||||
incomparable=stats.incomparable,
|
||||
errors=stats.errors,
|
||||
elapsed_s=round(time.perf_counter() - t0, 1),
|
||||
)
|
||||
|
||||
return stats
|
||||
Loading…
Add table
Add a link
Reference in a new issue