scripts/sources.py
scripts/sources.pyBrowse 5 files
6,197 tokens
25,808 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""Citation ledger for grounded answers and documents.3 4Owns the ``url -> [n]`` mapping used by the ``grounded-citations`` skill.5Ids are assigned at retrieval time and never change, so a draft's ``[3]``6always resolves to the same page. The model only ever emits integers the7ledger handed it, which is what makes the citations verifiable.8 9Subcommands10-----------11 reset start a clean ledger12 add URL [URL ...] register source(s), print their ids13 ingest FILE|- register every url found in JSON tool output14 quote ID --text T --from FILE|- attach verbatim supporting evidence to a source15 list show the ledger16 render render a Sources block17 verify DRAFT check a draft's citations against the ledger18 19Fact-checking is evidence-backed citation: ``quote`` only accepts text that20literally appears in the fetched page text you point it at, ``verify21--evidence`` requires every cited source to carry at least one such quote, and22``render --style evidence`` prints the quotes under each source so the reader23can check the chain themselves. Claims from model knowledge are declared with24an ``[unverified]`` marker rather than silently blended in.25 26Ledger path resolution (first wins):27 --ledger PATH28 $HERMES_CITATION_LEDGER29 $HERMES_HOME/cache/citations/ledger.json30"""31 32from __future__ import annotations33 34import argparse35import json36import os37import re38import sys39import time40from pathlib import Path41from typing import Any, Iterable42 43sys.path.insert(0, str(Path(__file__).resolve().parent))44from _hermes_home import get_hermes_home # noqa: E40245 46SCHEMA_VERSION = 147 48# A citation marker in prose: [12]. Markdown links ([text](url)) and49# reference-style labels are excluded by requiring digits only and no50# following "(" or ":".51_CITE_RE = re.compile(r"\[(\d{1,4})\](?![(:])")52_SOURCES_HEADER_RE = re.compile(r"^\s*(?:#{1,6}\s*)?(?:\*\*)?sources:?(?:\*\*)?\s*$", re.IGNORECASE)53_SOURCE_LINE_RE = re.compile(r"^\s*\[(\d{1,4})\]\s*[-–:]?\s*(\S+)")54_URL_IN_TEXT_RE = re.compile(r"https?://[^\s\"'<>)\]}]+")55_FENCE_RE = re.compile(r"^\s*(?:```|~~~)")56# Explicit declaration that a claim comes from model knowledge, not a source.57_UNVERIFIED_RE = re.compile(r"\[unverified\]", re.IGNORECASE)58 59 60# ---------------------------------------------------------------------------61# Ledger I/O62# ---------------------------------------------------------------------------63 64 65def resolve_ledger_path(explicit: str | None = None) -> Path:66 if explicit:67 return Path(explicit).expanduser()68 env = os.environ.get("HERMES_CITATION_LEDGER", "").strip()69 if env:70 return Path(env).expanduser()71 return get_hermes_home() / "cache" / "citations" / "ledger.json"72 73 74def normalize_url(url: str) -> str:75 """Canonicalize a URL for ledger identity.76 77 Strips the fragment and a trailing slash so ``/page``, ``/page/`` and78 ``/page#section`` are one source. Query strings are significant and are79 kept — they usually select different content.80 """81 u = (url or "").strip()82 if "#" in u:83 u = u.split("#", 1)[0]84 stripped = u.rstrip("/")85 return stripped or u86 87 88def load_ledger(path: Path) -> dict[str, Any]:89 if not path.exists():90 return {"version": SCHEMA_VERSION, "sources": []}91 try:92 data = json.loads(path.read_text(encoding="utf-8"))93 except (json.JSONDecodeError, OSError) as exc:94 raise SystemExit(f"error: ledger at {path} is unreadable ({exc}); run `reset` to start over")95 if not isinstance(data, dict) or not isinstance(data.get("sources"), list):96 raise SystemExit(f"error: ledger at {path} has an unexpected shape; run `reset`")97 return data98 99 100def save_ledger(path: Path, data: dict[str, Any]) -> None:101 path.parent.mkdir(parents=True, exist_ok=True)102 tmp = path.with_suffix(path.suffix + f".tmp{os.getpid()}")103 tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")104 os.replace(tmp, path)105 106 107class _LedgerLock:108 """Best-effort cross-process lock (O_EXCL lockfile, stdlib only).109 110 Parallel subagents can share one ledger via --ledger; without a lock two111 concurrent ``add`` calls can assign the same id. Falls through after the112 timeout rather than blocking a task forever — a stale lock must never113 wedge the ledger.114 """115 116 def __init__(self, path: Path, timeout: float = 5.0) -> None:117 self.lock_path = path.with_suffix(path.suffix + ".lock")118 self.timeout = timeout119 self.fd: int | None = None120 121 def __enter__(self) -> "_LedgerLock":122 self.lock_path.parent.mkdir(parents=True, exist_ok=True)123 deadline = time.monotonic() + self.timeout124 while True:125 try:126 self.fd = os.open(str(self.lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)127 return self128 except FileExistsError:129 if time.monotonic() >= deadline:130 # Assume a stale lock from a crashed run.131 try:132 self.lock_path.unlink()133 except OSError:134 return self135 continue136 time.sleep(0.05)137 138 def __exit__(self, *_exc: object) -> None:139 if self.fd is not None:140 try:141 os.close(self.fd)142 except OSError:143 pass144 try:145 self.lock_path.unlink()146 except OSError:147 pass148 149 150# ---------------------------------------------------------------------------151# Core operations152# ---------------------------------------------------------------------------153 154 155def _by_url(sources: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:156 return {s["url"]: s for s in sources}157 158 159def add_sources(160 path: Path,161 urls: Iterable[str],162 title: str | None = None,163 accessed: str | None = None,164) -> list[dict[str, Any]]:165 """Register urls, returning their ledger entries (existing or new)."""166 urls = [u for u in (str(u).strip() for u in urls) if u]167 if not urls:168 return []169 with _LedgerLock(path):170 data = load_ledger(path)171 sources = data["sources"]172 index = _by_url(sources)173 out: list[dict[str, Any]] = []174 changed = False175 for raw in urls:176 key = normalize_url(raw)177 existing = index.get(key)178 if existing is not None:179 if title and not existing.get("title"):180 existing["title"] = title181 changed = True182 out.append(existing)183 continue184 entry = {185 "id": len(sources) + 1,186 "url": key,187 "title": (title or "").strip(),188 "accessed": accessed or time.strftime("%Y-%m-%d"),189 }190 sources.append(entry)191 index[key] = entry192 out.append(entry)193 changed = True194 if changed:195 save_ledger(path, data)196 return out197 198 199def urls_from_json(payload: Any) -> list[tuple[str, str]]:200 """Walk arbitrary JSON tool output collecting (url, title) pairs.201 202 Handles web_search (``data.web[]``), web_extract (``results[]``) and any203 other nesting, in document order, deduped.204 """205 found: list[tuple[str, str]] = []206 seen: set[str] = set()207 208 def walk(node: Any) -> None:209 if isinstance(node, dict):210 url = node.get("url") or node.get("link") or node.get("source_url")211 if isinstance(url, str) and url.startswith(("http://", "https://")):212 key = normalize_url(url)213 if key not in seen:214 seen.add(key)215 raw_title = node.get("title") or node.get("name") or ""216 found.append((url, raw_title if isinstance(raw_title, str) else ""))217 for value in node.values():218 walk(value)219 elif isinstance(node, list):220 for item in node:221 walk(item)222 223 walk(payload)224 return found225 226 227# ---------------------------------------------------------------------------228# Evidence quotes (fact-checking)229# ---------------------------------------------------------------------------230 231 232def _normalize_ws(text: str) -> str:233 """Collapse all whitespace runs to single spaces for verbatim matching."""234 return " ".join((text or "").split())235 236 237# Markdown artifacts that retrieval tools inject into otherwise-identical prose.238# ``web_extract`` returns markdown, so the most citation-worthy sentences are239# exactly the ones carrying inline links and emphasis around terms:240# "including _[ERAP1](https://…/erap1/)_, _[IL1A](…)_, have also been…"241# reads identically to the page a human sees. Matching has to see through that242# markup, or the skill pushes the agent toward weaker evidence fragments.243_MD_LINK_RE = re.compile(r"\[([^\]]*)\]\((?:[^()\s]|\([^()]*\))*\)")244_MD_NOISE_RE = re.compile(r"[*_`~]|\\(?=[^\w\s])")245 246 247def _match_key(text: str) -> str:248 """Canonicalize text for verbatim comparison.249 250 Whitespace-, case-, and markdown-insensitive: inline links collapse to251 their label, emphasis/code markers and backslash escapes are dropped. The252 stored quote keeps whatever the caller passed, so the rendered evidence253 block shows clean prose rather than extractor artifacts.254 """255 collapsed = _MD_LINK_RE.sub(r"\1", text or "")256 return _normalize_ws(_MD_NOISE_RE.sub("", collapsed)).casefold()257 258 259def quote_in_evidence(quote: str, evidence: str) -> bool:260 """True when ``quote`` appears verbatim in the fetched ``evidence`` text,261 ignoring whitespace, case, and markdown markup on either side."""262 q = _match_key(quote)263 return bool(q) and q in _match_key(evidence)264 265 266def attach_quote(path: Path, source_id: int, quote: str, evidence: str) -> dict[str, Any]:267 """Attach a verbatim quote to a ledger entry after checking it against268 the evidence text. Raises SystemExit on unknown id or non-verbatim text —269 a quote the page does not contain is exactly the fabrication this guards270 against."""271 quote = (quote or "").strip()272 if len(_normalize_ws(quote).split()) < 3:273 raise SystemExit("error: quote too short — use at least 3 words of verbatim text")274 if not quote_in_evidence(quote, evidence):275 raise SystemExit(276 "error: quote not found verbatim in the evidence text — "277 "copy the exact wording from the fetched page, do not paraphrase"278 )279 with _LedgerLock(path):280 data = load_ledger(path)281 entry = next((s for s in data["sources"] if s["id"] == source_id), None)282 if entry is None:283 raise SystemExit(f"error: no source [{source_id}] in the ledger")284 quotes = entry.setdefault("quotes", [])285 norm = _match_key(quote)286 if not any(_match_key(q.get("text", "")) == norm for q in quotes):287 quotes.append({"text": quote, "added": time.strftime("%Y-%m-%d")})288 save_ledger(path, data)289 return entry290 291 292def render_sources(293 sources: list[dict[str, Any]],294 style: str = "markdown",295 only: set[int] | None = None,296) -> str:297 picked = [s for s in sources if only is None or s["id"] in only]298 picked.sort(key=lambda s: s["id"])299 if not picked:300 return ""301 lines: list[str] = []302 if style == "bibtex":303 for s in picked:304 key = f"source{s['id']}"305 title = s.get("title") or s["url"]306 lines.append(307 "@misc{%s,\n title = {%s},\n howpublished = {\\url{%s}},\n note = {Accessed %s}\n}"308 % (key, title, s["url"], s.get("accessed", ""))309 )310 return "\n".join(lines)311 if style == "footnotes":312 for s in picked:313 title = s.get("title")314 suffix = f" — {title}" if title else ""315 lines.append(f"[^{s['id']}]: {s['url']}{suffix}")316 return "\n".join(lines)317 header = "Sources:" if style == "plain" else "## Sources"318 lines.append(header)319 if style != "plain":320 lines.append("")321 for s in picked:322 title = s.get("title")323 suffix = f" — {title}" if title else ""324 lines.append(f"[{s['id']}] {s['url']}{suffix}")325 if style == "evidence":326 for q in s.get("quotes", []):327 lines.append(f' > "{q.get("text", "")}"')328 return "\n".join(lines)329 330 331# ---------------------------------------------------------------------------332# Verification333# ---------------------------------------------------------------------------334 335 336def _split_draft(text: str) -> tuple[str, dict[int, str]]:337 """Split a draft into (prose, sources_block_map).338 339 The sources block is everything after the last ``Sources`` header; its340 ``[n] url`` lines are parsed out so they aren't counted as prose citations.341 Fenced code blocks are dropped from prose.342 """343 lines = text.splitlines()344 header_idx = -1345 for i, line in enumerate(lines):346 if _SOURCES_HEADER_RE.match(line):347 header_idx = i348 listed: dict[int, str] = {}349 if header_idx >= 0:350 for line in lines[header_idx + 1:]:351 m = _SOURCE_LINE_RE.match(line)352 if m:353 url_match = _URL_IN_TEXT_RE.search(line)354 listed[int(m.group(1))] = url_match.group(0) if url_match else m.group(2)355 body_lines = lines[:header_idx]356 else:357 body_lines = lines358 prose: list[str] = []359 in_fence = False360 for line in body_lines:361 if _FENCE_RE.match(line):362 in_fence = not in_fence363 continue364 if not in_fence:365 prose.append(line)366 return "\n".join(prose), listed367 368 369def _strip_sources_block(text: str) -> str:370 """Return the draft with its trailing Sources block removed.371 372 Everything from the last Sources header onward goes; a draft with no such373 header is returned unchanged. This is what makes ``render --replace-in``374 idempotent instead of stacking duplicate blocks.375 """376 lines = text.splitlines()377 header_idx = -1378 for i, line in enumerate(lines):379 if _SOURCES_HEADER_RE.match(line):380 header_idx = i381 if header_idx < 0:382 return text383 return "\n".join(lines[:header_idx])384 385 386def _sentences(prose: str) -> list[str]:387 """Rough sentence split over prose lines, skipping headings and tables."""388 out: list[str] = []389 for line in prose.splitlines():390 stripped = line.strip()391 if not stripped or stripped.startswith("#") or stripped.startswith("|"):392 continue393 if stripped.startswith(">"):394 stripped = stripped.lstrip("> ").strip()395 for part in re.split(r"(?<=[.!?])\s+", stripped):396 part = part.strip()397 if len(part.split()) >= 4:398 out.append(part)399 return out400 401 402def verify_draft(403 draft_path: Path,404 sources: list[dict[str, Any]],405 strict: bool = False,406 min_coverage: float | None = None,407 require_evidence: bool = False,408) -> tuple[int, list[str], list[str]]:409 """Return (exit_code, errors, warnings)."""410 text = draft_path.read_text(encoding="utf-8")411 prose, listed = _split_draft(text)412 by_id = {s["id"]: s for s in sources}413 414 errors: list[str] = []415 warnings: list[str] = []416 417 cited = [int(m) for m in _CITE_RE.findall(prose)]418 cited_set = set(cited)419 420 unknown = sorted(i for i in cited_set if i not in by_id)421 if unknown:422 errors.append(423 "citations not in the ledger (hallucinated or renumbered): "424 + ", ".join(f"[{i}]" for i in unknown)425 )426 427 if cited_set and not listed:428 errors.append("draft cites sources but has no `Sources:` block — run `render --cited-in`")429 430 missing_from_block = sorted(cited_set - set(listed)) if listed else []431 if missing_from_block:432 errors.append(433 "cited but absent from the Sources block: "434 + ", ".join(f"[{i}]" for i in missing_from_block)435 )436 437 for sid, url in sorted(listed.items()):438 entry = by_id.get(sid)439 if entry is None:440 errors.append(f"Sources block lists [{sid}], which is not in the ledger")441 continue442 if normalize_url(url) != entry["url"]:443 errors.append(444 f"Sources block URL for [{sid}] does not match the ledger "445 f"(block: {url} / ledger: {entry['url']}) — re-run `render`"446 )447 448 extra_in_block = sorted(set(listed) - cited_set)449 if extra_in_block:450 warnings.append(451 "listed in Sources but never cited inline: "452 + ", ".join(f"[{i}]" for i in extra_in_block)453 )454 455 registered_uncited = sorted(set(by_id) - cited_set)456 if registered_uncited:457 warnings.append(458 "registered in the ledger but not cited in this draft: "459 + ", ".join(f"[{i}]" for i in registered_uncited)460 )461 462 sentences = _sentences(prose)463 cited_sentences = [s for s in sentences if _CITE_RE.search(s)]464 unverified_sentences = [s for s in sentences if _UNVERIFIED_RE.search(s)]465 covered = [s for s in sentences if _CITE_RE.search(s) or _UNVERIFIED_RE.search(s)]466 coverage = (len(covered) / len(sentences)) if sentences else 0.0467 if min_coverage is not None and sentences and coverage < min_coverage:468 errors.append(469 f"citation coverage {coverage:.0%} is below the required {min_coverage:.0%} "470 f"({len(covered)}/{len(sentences)} sentences cited or marked [unverified])"471 )472 473 if require_evidence:474 unevidenced = sorted(475 i for i in cited_set if i in by_id and not by_id[i].get("quotes")476 )477 if unevidenced:478 errors.append(479 "cited sources carry no verbatim evidence quote (run `quote` with the "480 "fetched page text): " + ", ".join(f"[{i}]" for i in unevidenced)481 )482 483 over_cited = [s for s in sentences if len(_CITE_RE.findall(s)) > 3]484 if over_cited:485 warnings.append(f"{len(over_cited)} sentence(s) carry more than 3 citations")486 487 code = 1 if errors else (1 if (strict and warnings) else 0)488 quoted = sum(1 for s in sources if s.get("quotes"))489 stats = (490 f"{len(sentences)} prose sentence(s), {len(covered)} with declared provenance "491 f"({coverage:.0%}) — {len(cited_sentences)} cited, "492 f"{len(unverified_sentences)} marked [unverified] (a sentence may be both); "493 f"{len(cited_set)} distinct source(s) cited, "494 f"{len(by_id)} in ledger ({quoted} with evidence quotes)"495 )496 warnings.insert(0, f"stats: {stats}")497 return code, errors, warnings498 499 500# ---------------------------------------------------------------------------501# CLI502# ---------------------------------------------------------------------------503 504 505def _parse_only(spec: str | None) -> set[int] | None:506 if not spec:507 return None508 out: set[int] = set()509 for chunk in spec.replace(" ", "").split(","):510 if not chunk:511 continue512 if "-" in chunk:513 lo, _, hi = chunk.partition("-")514 out.update(range(int(lo), int(hi) + 1))515 else:516 out.add(int(chunk))517 return out518 519 520def main(argv: list[str] | None = None) -> int:521 parser = argparse.ArgumentParser(522 prog="sources.py", description="Citation ledger for grounded answers and documents."523 )524 parser.add_argument("--ledger", help="ledger file path (overrides env / default)")525 sub = parser.add_subparsers(dest="cmd", required=True)526 527 sub.add_parser("reset", help="start a clean ledger")528 529 p_add = sub.add_parser("add", help="register source url(s), print their ids")530 p_add.add_argument("urls", nargs="+")531 p_add.add_argument("--title", help="title for the source (single-url calls)")532 p_add.add_argument("--accessed", help="access date (default: today)")533 p_add.add_argument("--json", action="store_true", help="emit JSON instead of ids")534 535 p_ing = sub.add_parser("ingest", help="register every url in JSON tool output")536 p_ing.add_argument("file", help="JSON file, or - for stdin")537 538 p_q = sub.add_parser("quote", help="attach verbatim supporting evidence to a source")539 p_q.add_argument("id", type=int, help="ledger id of the source the quote supports")540 p_q.add_argument("--text", required=True, help="the exact quote, copied from the page")541 p_q.add_argument(542 "--from",543 dest="evidence",544 required=True,545 help="file with the fetched page text (or - for stdin) the quote must appear in",546 )547 548 p_list = sub.add_parser("list", help="show the ledger")549 p_list.add_argument("--json", action="store_true")550 551 p_render = sub.add_parser("render", help="render a Sources block")552 p_render.add_argument(553 "--style", default="markdown", choices=["markdown", "plain", "footnotes", "bibtex", "evidence"]554 )555 p_render.add_argument("--only", help="ids to include, e.g. 1,3,5-7")556 p_render.add_argument("--cited-in", help="include only ids cited in this draft file")557 p_render.add_argument(558 "--replace-in",559 help="rewrite this draft's Sources block in place (implies --cited-in on it)",560 )561 562 p_ver = sub.add_parser("verify", help="check a draft's citations against the ledger")563 p_ver.add_argument("draft")564 p_ver.add_argument("--strict", action="store_true", help="treat warnings as failures")565 p_ver.add_argument("--min-coverage", type=float, help="required cited-sentence share, e.g. 0.5")566 p_ver.add_argument(567 "--evidence",568 action="store_true",569 help="require every cited source to carry at least one verbatim quote",570 )571 572 args = parser.parse_args(argv)573 path = resolve_ledger_path(args.ledger)574 575 if args.cmd == "reset":576 with _LedgerLock(path):577 save_ledger(path, {"version": SCHEMA_VERSION, "sources": []})578 print(f"ledger reset: {path}")579 return 0580 581 if args.cmd == "add":582 title = args.title if len(args.urls) == 1 else None583 entries = add_sources(path, args.urls, title=title, accessed=args.accessed)584 if args.json:585 print(json.dumps(entries, indent=2, ensure_ascii=False))586 else:587 for e in entries:588 print(f"[{e['id']}] {e['url']}")589 return 0590 591 if args.cmd == "ingest":592 raw = sys.stdin.read() if args.file == "-" else Path(args.file).read_text(encoding="utf-8")593 try:594 payload = json.loads(raw)595 except json.JSONDecodeError as exc:596 print(f"error: input is not valid JSON ({exc})", file=sys.stderr)597 return 2598 pairs = urls_from_json(payload)599 if not pairs:600 print("no urls found in input", file=sys.stderr)601 return 1602 for url, title in pairs:603 entry = add_sources(path, [url], title=title or None)[0]604 print(f"[{entry['id']}] {entry['url']}")605 return 0606 607 if args.cmd == "quote":608 raw = (609 sys.stdin.read()610 if args.evidence == "-"611 else Path(args.evidence).read_text(encoding="utf-8")612 )613 entry = attach_quote(path, args.id, args.text, raw)614 print(f"[{entry['id']}] evidence attached ({len(entry.get('quotes', []))} quote(s))")615 return 0616 617 data = load_ledger(path)618 sources = sorted(data["sources"], key=lambda s: s["id"])619 620 if args.cmd == "list":621 if args.json:622 print(json.dumps(sources, indent=2, ensure_ascii=False))623 elif not sources:624 print(f"ledger is empty: {path}")625 else:626 for s in sources:627 title = f" {s['title']}" if s.get("title") else ""628 nq = len(s.get("quotes", []))629 mark = f" ({nq} quote{'s' if nq != 1 else ''})" if nq else ""630 print(f"[{s['id']}] {s['url']}{title}{mark}")631 return 0632 633 if args.cmd == "render":634 only = _parse_only(args.only)635 draft_for_ids = args.replace_in or args.cited_in636 if draft_for_ids:637 draft = Path(draft_for_ids).read_text(encoding="utf-8")638 prose, _ = _split_draft(draft)639 cited = {int(m) for m in _CITE_RE.findall(prose)}640 only = cited if only is None else (only & cited)641 block = render_sources(sources, style=args.style, only=only)642 if not block:643 print("no sources to render", file=sys.stderr)644 return 1645 if args.replace_in:646 target = Path(args.replace_in)647 body = _strip_sources_block(target.read_text(encoding="utf-8"))648 target.write_text(body.rstrip("\n") + "\n\n" + block + "\n", encoding="utf-8")649 print(f"Sources block rewritten in {target}")650 return 0651 print(block)652 return 0653 654 if args.cmd == "verify":655 draft_path = Path(args.draft)656 if not draft_path.is_file():657 print(f"error: no such draft: {draft_path}", file=sys.stderr)658 return 2659 code, errors, warnings = verify_draft(660 draft_path,661 sources,662 strict=args.strict,663 min_coverage=args.min_coverage,664 require_evidence=args.evidence,665 )666 for w in warnings:667 prefix = "info" if w.startswith("stats: ") else "warn"668 print(f"{prefix}: {w}")669 for e in errors:670 print(f"FAIL: {e}", file=sys.stderr)671 print("citations OK" if code == 0 else "verification failed")672 return code673 674 return 2675 676 677if __name__ == "__main__":678 sys.exit(main())679