"""Check the numeric claims in public texts (README, blog posts, guide, drafts) against the validation reports. Code finds the claims and the candidate evidence; a TypeSafe System One model (Jev) only judges whether a passage supports, contradicts or says nothing about a claim (the "double checking citations" pattern, https://docs.typesafe.ai/cookbooks/citation_check). Only this repository's own documentation text is sent; no data, no sample sheets, no vendor files. Needs ``TYPESAFE_API_KEY`` in the environment, or in ``~/.config/typesafe/credentials`` (mode 600), for the judgment step. python scripts/docs/check_claims.py README.md blog/*.md # check, write claims_report.md python scripts/docs/check_claims.py --dry-run linkedin_draft.txt # extraction and retrieval only, no API calls Verdicts: verified (a passage supports the claim, confidence >= threshold), contradicted, unsupported (passages found but none addresses the claim), no_source (no passage contains any of the claim's numbers), review (low confidence). Exit code 1 when any claim is contradicted or has no source, so the script can gate a publishing step. """ from __future__ import annotations import argparse import re import sys from dataclasses import dataclass, field from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "jev")) from client import MODEL, api_key, system_one ROOT = Path(__file__).resolve().parents[2] AUTO_ACCEPT = 0.8 # cookbook default; evaluate on our own claims before trusting it TOP_K = 3 DEFAULT_SOURCES = ["docs/validation", "docs/known_unknowns.md", "SIMURGARRAY_FUNCTIONAL_SPEC.md"] RELATION = { "type": "choice", "criteria": { "supports": "The section states the claim's numbers and meaning, or directly implies that the claim is true", "contradicts": "The section gives a different value for the same quantity, or implies the claim is false", "says_nothing": "The section does not address what the claim asserts, either way", }, } # ------------------------------------------------------------------ numbers _NUM = re.compile(r"(? set[str]: """Normalised number tokens of a text, with percent/fraction twins: '99.34%' -> {'99.34', '0.9934'}.""" out: set[str] = set() for m in _NUM.finditer(text): raw, pct = m.group(1), m.group(2) v = raw.replace(",", "").replace(" ", "") try: f = float(v) except ValueError: continue out.add(_fmt(f)) if pct: out.add(_fmt(f / 100)) elif 0 < f < 1: out.add(_fmt(f * 100)) return out def _fmt(f: float) -> str: s = f"{f:.6f}".rstrip("0").rstrip(".") return s or "0" _MONTHS = "January|February|March|April|May|June|July|August|September|October|November|December" _DATE = re.compile( rf"\b({_MONTHS})\s+\d{{4}}\b|\b({_MONTHS})\s+\d{{1,2}}(,\s*\d{{4}})?|\b(19|20)\d{{2}}\b|\d{{4}}-\d{{2}}-\d{{2}}" r"|\b(version|v)\s?\d+(\.\d+)+\b" ) def significant(nums: set[str]) -> set[str]: """Numbers worth matching: drop tiny integers that appear everywhere (0 to 12).""" return {n for n in nums if not (n.isdigit() and int(n) <= 12)} def claim_numbers(text: str) -> set[str]: """Significant numbers of a claim; dates and years are context, not claims.""" return significant(numbers(_DATE.sub(" ", text))) # ------------------------------------------------------------------ claims @dataclass class Claim: path: str line: int text: str context: str nums: set[str] def _strip_md(s: str) -> str: s = re.sub(r"<[^>]+>", " ", s) s = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", s) s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s) s = re.sub(r"https?://\S+", " ", s) return re.sub(r"\s+", " ", s.replace("**", "").replace("`", "")).strip() def extract_claims(path: Path, text: str) -> list[Claim]: """Sentences of paragraphs and bullets, and table rows, that carry significant numbers. Lines of one paragraph or bullet are joined first, so a sentence wrapped over several lines stays whole. Code blocks and commands are skipped.""" claims: list[Claim] = [] heading, table_head, in_code = "", "", False buf: list[str] = [] start = 0 def add(unit: str, line: int) -> None: nums = claim_numbers(unit) if nums and not re.search(r"\b(simurg|uv|pip|bash|docker|conda) ", unit): ctx = " / ".join(x for x in (heading, table_head) if x) claims.append(Claim(str(path), line, unit, ctx, nums)) def flush() -> None: if buf: para = _strip_md(" ".join(buf)) for u in re.split(r"(?<=[.!?])\s+(?=[A-Z])", para): if u: add(u, start) buf.clear() for i, raw in enumerate([*text.splitlines(), ""], 1): ln = raw.rstrip() if ln.lstrip().startswith("```"): flush() in_code = not in_code continue if in_code: continue if not ln.strip(): flush() table_head = "" continue if ln.startswith("#"): flush() heading = _strip_md(ln.lstrip("#")) continue if ln.lstrip().startswith("|"): flush() if re.fullmatch(r"\s*\|[\s:|-]+\|?\s*", ln): continue if not table_head: table_head = _strip_md(ln) continue add(_strip_md(ln), i) continue bullet = re.match(r"\s*([*>-]|\d+\.)\s+", ln) if bullet: flush() ln = ln[bullet.end() :] if not buf: start = i buf.append(ln.strip()) return claims # ------------------------------------------------------------------ evidence @dataclass class Passage: path: str line: int text: str nums: set[str] = field(default_factory=set) def load_passages(sources: list[Path]) -> list[Passage]: """Paragraphs and table rows (with their header) of the source documents.""" out: list[Passage] = [] files = [p for s in sources for p in (sorted(s.rglob("*.md")) if s.is_dir() else [s])] for f in files: rel = str(f.relative_to(ROOT)) if f.is_relative_to(ROOT) else str(f) heading, table_head = "", "" buf: list[str] = [] start = 0 for i, ln in enumerate([*f.read_text(encoding="utf-8").splitlines(), ""], 1): is_row = ln.lstrip().startswith("|") if (ln.startswith("#") or is_row or not ln.strip()) and buf: t = (heading + ": " if heading else "") + " ".join(buf) out.append(Passage(rel, start, t, numbers(t))) buf = [] if ln.startswith("#"): heading, table_head = _strip_md(ln.lstrip("#")), "" elif is_row: if re.fullmatch(r"\s*\|[\s:|-]+\|?\s*", ln): continue if not table_head: table_head = _strip_md(ln) continue t = f"{heading}: {table_head} || {_strip_md(ln)}" out.append(Passage(rel, i, t, numbers(t))) elif not ln.strip(): table_head = "" else: if not buf: start = i buf.append(_strip_md(ln)) return out def _shared(a: set[str], b: set[str], tol: float = 0.03) -> int: """Numbers of ``a`` found in ``b`` exactly, or within 3 % for values of 10 and more (rounded figures such as 'about 550 MB' for 545 and 551 MB).""" fb = [float(x) for x in b] n = 0 for x in a: if x in b: n += 1 continue v = float(x) if v >= 10 and any(w >= 10 and abs(v - w) <= tol * max(v, w) for w in fb): n += 1 return n def candidates(claim: Claim, passages: list[Passage], k: int = TOP_K) -> list[Passage]: """Passages sharing the most of the claim's numbers (exactly or approximately), then the most words.""" words = set(re.findall(r"[a-z]{4,}", claim.text.lower())) scored = [] for p in passages: shared = _shared(claim.nums, p.nums) if shared: overlap = len(words & set(re.findall(r"[a-z]{4,}", p.text.lower()))) scored.append((shared, overlap, p)) scored.sort(key=lambda t: (-t[0], -t[1], t[2].path, t[2].line)) return [p for _, _, p in scored[:k]] # ------------------------------------------------------------------ judgment def build_request(claim: Claim, cands: list[Passage]) -> dict[str, object]: state = { "claim": claim.text, "claim_context": claim.context, "sections": [{"source": f"{p.path}:{p.line}", "text": p.text[:1500]} for p in cands], } ask = "How does `sections[{i}].text` relate to `claim` (read `claim` together with `claim_context`)?" questions = {f"s{i}": {**RELATION, "instructions": ask.format(i=i)} for i in range(len(cands))} return {"model": MODEL, "state": state, "questions": questions} def verdict(answers: dict[str, dict[str, object]], threshold: float = AUTO_ACCEPT) -> tuple[str, str | None, float]: """Combine per-section answers: any confident 'supports' wins; else a confident 'contradicts'; else a confident 'says_nothing' from every section; anything else goes to a person.""" best: dict[str, tuple[float, str]] = {} for qid, a in answers.items(): ch, conf = str(a["choice"]), float(a["confidence"]) # type: ignore[arg-type] if conf >= threshold and conf > best.get(ch, (-1.0, ""))[0]: best[ch] = (conf, qid) if "supports" in best: return "verified", best["supports"][1], best["supports"][0] support = max((float(a["probabilities"].get("supports", 0.0)) for a in answers.values()), default=0.0) # type: ignore[attr-defined] if "contradicts" in best: if support >= 0.3: # another section leans towards support: a person decides return "review", best["contradicts"][1], best["contradicts"][0] return "contradicted", best["contradicts"][1], best["contradicts"][0] confident = [float(a["confidence"]) >= threshold and a["choice"] == "says_nothing" for a in answers.values()] # type: ignore[arg-type] if confident and all(confident): return "unsupported", None, min(float(a["confidence"]) for a in answers.values()) # type: ignore[arg-type] return "review", None, max((float(a["confidence"]) for a in answers.values()), default=0.0) # type: ignore[arg-type] # ------------------------------------------------------------------ report def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("files", nargs="+", type=Path, help="texts to check (markdown or plain text)") ap.add_argument("--sources", nargs="+", type=Path, default=[ROOT / s for s in DEFAULT_SOURCES]) ap.add_argument("--out", type=Path, default=Path("claims_report.md")) ap.add_argument("--threshold", type=float, default=AUTO_ACCEPT) ap.add_argument("--dry-run", action="store_true", help="extract claims and evidence, no API calls") a = ap.parse_args(argv) key = api_key() if not a.dry_run and not key: print("TYPESAFE_API_KEY is not set (use --dry-run to check extraction without the API).", file=sys.stderr) return 2 passages = load_passages(a.sources) rows: list[tuple[str, Claim, Passage | None, float, str]] = [] for f in a.files: for c in extract_claims(f, f.read_text(encoding="utf-8")): cands = candidates(c, passages) if not cands: rows.append(("no_source", c, None, 1.0, "")) continue if a.dry_run: rows.append(("planned", c, cands[0], 0.0, f"{len(cands)} candidate(s)")) continue req = build_request(c, cands) answers = system_one(req["state"], req["questions"], key) # type: ignore[arg-type] v, qid, conf = verdict(answers, a.threshold) # type: ignore[arg-type] src = cands[int(qid[1:])] if qid else cands[0] probs = ", ".join(f"{q}: {answers[q]['choice']} {answers[q]['confidence']:.2f}" for q in answers) # type: ignore[index] rows.append((v, c, src, conf, probs)) order = ["contradicted", "no_source", "review", "unsupported", "verified", "planned"] counts = {v: sum(r[0] == v for r in rows) for v in order} lines = ["# Claim check", "", " · ".join(f"{v} {n}" for v, n in counts.items() if n), ""] for v in order: sel = [r for r in rows if r[0] == v] if not sel: continue lines += [f"## {v} ({len(sel)})", ""] for _, c, src, conf, extra in sel: lines.append(f"* `{c.path}:{c.line}` {c.text}") if src: lines.append(f" * evidence `{src.path}:{src.line}` (confidence {conf:.2f}) {src.text[:220]}") if extra: lines.append(f" * {extra}") lines.append("") a.out.write_text("\n".join(lines)) print(f"{len(rows)} claims: " + ", ".join(f"{v} {n}" for v, n in counts.items() if n) + f" → {a.out}") return 1 if counts["contradicted"] or counts["no_source"] else 0 if __name__ == "__main__": sys.exit(main())