#!/usr/bin/env python3 """ cite-check — verifiable legal-citation tooling for product counsel reviews. This is the worker invoked by the cite-check Copilot CLI skill (see SKILL.md in the same directory). It exposes a small set of subcommands the agent uses to (a) maintain a local cache of the legal reference library, (b) extract verbatim text from legal sources and product facts, (c) byte-for-byte verify quoted spans (the hallucination guard), and (d) build a Word document of Citation Cards on the user's Desktop. Modeled on dvelton/eyeball. Every claim that ends up in a card must be anchored in verbatim text from a real source; this tool refuses to emit a card that fails verification. Subcommands: setup-check — confirm Python deps + gh CLI are present refresh-corpus — mirror the cached file allowlist from github/ppl-legal-reference into the local cache and build a section index list-corpus — print the current cache state (file, classification, section count) extract-source — given a citation ref (e.g. "03-reg-eu-gdpr.md#Article 6" or a public URL), print verbatim section text + anchors extract-facts — given a GitHub issue ref (owner/repo#N), print body + comments as a structured fact corpus verify — given a quoted span and a source ref, exit 0 iff the quote appears byte-for-byte in the source build — given a JSON file of Citation Cards, render a Word doc Cache layout: ~/.copilot/skills/cite-check/cache/ corpus/ # mirrored markdown files index.json # file → list of (anchor, byte_start, byte_end, # classification, source_url) fetched/ # web-fetched provisions (auto [VERIFY] tagged) facts/ # cached fact corpora keyed by issue ref Exit codes: 0 success 1 generic error 2 verification failed (the hallucination guard tripped) 3 missing dependency or unconfigured environment """ from __future__ import annotations import argparse import base64 import calendar import dataclasses import hashlib import json import os import re import shutil import subprocess import sys import time from pathlib import Path from typing import Any, Iterable, Optional # --- paths ------------------------------------------------------------------- TOOLS_DIR = Path(__file__).resolve().parent TAXONOMY_PATH = TOOLS_DIR / "taxonomy.json" CACHE_ROOT = Path.home() / ".copilot" / "skills" / "cite-check" / "cache" CORPUS_DIR = CACHE_ROOT / "corpus" FETCHED_DIR = CACHE_ROOT / "fetched" FACTS_DIR = CACHE_ROOT / "facts" RENDERED_DIR = CACHE_ROOT / "rendered" AUTH_DIR = CACHE_ROOT / "authoritative" AUTH_MANIFEST = AUTH_DIR / "manifest.json" INDEX_PATH = CACHE_ROOT / "index.json" REFERENCE_REPO = "github/ppl-legal-reference" # --- small helpers ----------------------------------------------------------- def _ensure_dirs() -> None: for p in (CORPUS_DIR, FETCHED_DIR, FACTS_DIR, RENDERED_DIR, AUTH_DIR): p.mkdir(parents=True, exist_ok=True) def _hash_key(*parts: str) -> str: h = hashlib.sha256() for part in parts: h.update(part.encode("utf-8")) h.update(b"\x00") return h.hexdigest()[:16] def _load_taxonomy() -> dict[str, Any]: with TAXONOMY_PATH.open("r", encoding="utf-8") as f: return json.load(f) def _have(cmd: str) -> bool: return shutil.which(cmd) is not None def _run(cmd: list[str], *, check: bool = True, capture: bool = True) -> subprocess.CompletedProcess: return subprocess.run( cmd, check=check, capture_output=capture, text=True, ) def _classification_for(filename: str) -> str: """Map the ppl-legal-reference filename prefix to a source classification. Mirrors the prefix taxonomy described in the user's custom instructions: 01-github-* → github-public (ToS, DPA, Privacy Statement, AUP, ...) 02-internal-* → github-internal (playbooks, training) 03-reg-* → public-law 04-msft-* → github-internal (Microsoft contractual instruments — not posted publicly, only shared with partners; cannot be a primary cite for a legal conclusion) 05-guidance-* → public-guidance 06-ip-* → public-law (Title 17 + open source licenses) 07-process-* → github-internal 08-caselaw-* → public-guidance """ base = filename.lower() if base.startswith("01-github-"): return "github-public" if base.startswith("02-internal-"): return "github-internal" if base.startswith("03-reg-"): return "public-law" if base.startswith("04-msft-"): return "github-internal" if base.startswith("05-guidance-"): return "public-guidance" if base.startswith("06-ip-"): return "public-law" if base.startswith("07-process-"): return "github-internal" if base.startswith("08-caselaw-"): return "public-guidance" return "github-public" def _normalize_allowlist(taxonomy: dict[str, Any]) -> list[dict[str, Any]]: """Return the cached_corpus_allowlist as a list of dicts. The taxonomy file may store entries as plain filename strings (legacy v2 schema) or as objects with `filename` plus authoritative-source metadata (v3 schema). Normalize to a uniform list of dicts so callers don't care. Each returned dict has at least: filename, authoritative_url (or None), authoritative_format (or None), extractor (or None). """ raw = taxonomy.get("cached_corpus_allowlist", []) or [] out: list[dict[str, Any]] = [] for entry in raw: if isinstance(entry, str): out.append({ "filename": entry, "authoritative_url": None, "authoritative_format": None, "extractor": None, }) elif isinstance(entry, dict) and entry.get("filename"): out.append({ "filename": entry["filename"], "authoritative_url": entry.get("authoritative_url"), "authoritative_format": entry.get("authoritative_format"), "extractor": entry.get("extractor"), "_note": entry.get("_note"), }) return out def _allowlist_filenames(taxonomy: dict[str, Any]) -> list[str]: return [e["filename"] for e in _normalize_allowlist(taxonomy)] def _authoritative_meta_for(filename: str) -> Optional[dict[str, Any]]: """Return the authoritative-source metadata for a corpus filename, or None.""" taxonomy = _load_taxonomy() for entry in _normalize_allowlist(taxonomy): if entry["filename"] == filename and entry.get("authoritative_url"): return entry return None # --- subcommand: setup-check ------------------------------------------------- def cmd_setup_check(_args: argparse.Namespace) -> int: print("cite-check setup check") print("=" * 40) ok = True print(f" python: {sys.version.split()[0]}") for mod in ("docx", "fitz", "PIL", "playwright", "yaml", "requests", "markdown", "bs4", "lxml", "pdfminer"): try: __import__(mod if mod != "docx" else "docx") print(f" module {mod:<12} ✓") except ImportError: print(f" module {mod:<12} ✗ (run setup.sh / setup.ps1)") ok = False if _have("gh"): try: who = _run(["gh", "api", "user", "--jq", ".login"]).stdout.strip() print(f" gh CLI ✓ (authenticated as {who})") except subprocess.CalledProcessError: print(" gh CLI ✗ (installed but not authenticated — run `gh auth login`)") ok = False else: print(" gh CLI ✗ (install from https://cli.github.com/)") ok = False print(f" cache root: {CACHE_ROOT} ({'exists' if CACHE_ROOT.exists() else 'will be created on first refresh'})") if INDEX_PATH.exists(): idx = json.loads(INDEX_PATH.read_text(encoding="utf-8")) print(f" corpus: {len(idx.get('files', {}))} files indexed") else: print(" corpus: not yet built — run `cite.py refresh-corpus`") return 0 if ok else 3 # --- subcommand: refresh-corpus --------------------------------------------- _SECTION_PATTERNS: tuple[tuple[str, "re.Pattern[str]"], ...] = ( # Markdown headings ("md-heading", re.compile(r"^(?P#{1,6})\s+(?P.+\S)\s*$", re.MULTILINE)), # Regulation chapter / section / annex headers (GDPR, EU AI Act): # "Article 6", "Article 6a", "Article 6(1)", "CHAPTER III", "SECTION 1", # "ANNEX III" ("regulation-article", re.compile( r"^(?P(?:Article\s+\d+[a-z]?(?:\(\d+\))?" r"|CHAPTER\s+[IVXLCDM]+" r"|SECTION\s+\d+" r"|ANNEX\s+[IVXLCDM]+))\s*$", re.MULTILINE, )), # CCPA-style numbered code sections: "1798.100. Title" ("us-code-section", re.compile( r"^(?P\d{3,4}\.\d{1,3}\.\s+[A-Z][^\n]{3,160})$", re.MULTILINE, )), # US Code section-sign headings (Title 17, etc.): # "§107 · Limitations on exclusive rights: Fair use" # We require the bullet+title because PDF-extracted pages have many bare # "§NNN" page-footer occurrences that aren't real section starts. ("us-code-section-sign", re.compile( r"^(?P§\s?\d{1,4}[A-Z]?\s*·\s*[^\n]{2,200})\s*$", re.MULTILINE, )), # Contract / DPA numbered sections: "1. Definitions.", "9. Subprocessors." # Heuristic: digit(s) + dot + space + capital, ending with a period and short # title text. Restricted enough to not match every numbered list item. ("contract-section", re.compile( r"^(?P\d{1,2}\.\s+[A-Z][A-Za-z][A-Za-z0-9 ,.&'/-]{2,80}\.)\s*(?:[A-Z]|$)", re.MULTILINE, )), ) def _index_document(text: str) -> list[dict[str, Any]]: """Build a list of section anchors from a corpus document. Recognizes four structural conventions used in github/ppl-legal-reference: 1. Markdown ATX headings (#, ##, ...) 2. Regulation articles / chapters / annexes (GDPR, EU AI Act): bare lines like "Article 6", "CHAPTER III", "ANNEX III", "SECTION 1" 3. US-code-style sections: "1798.100. Title" (CCPA) 4. Contract / DPA numbered sections: "9. Subprocessors." Each returned section records (anchor, kind, level, byte_start, byte_end). The byte range covers from the matched line through the byte before the next section start (or end of file). """ raw = text.encode("utf-8") if not raw: return [{"anchor": "(empty)", "kind": "doc", "level": 0, "byte_start": 0, "byte_end": 0}] # Convert character offsets to byte offsets once for the whole doc. char_to_byte = [] cursor = 0 for ch in text: char_to_byte.append(cursor) cursor += len(ch.encode("utf-8")) char_to_byte.append(len(raw)) found: list[dict[str, Any]] = [] seen_starts: set[int] = set() for kind, pat in _SECTION_PATTERNS: for m in pat.finditer(text): start_char = m.start() if start_char in seen_starts: continue seen_starts.add(start_char) anchor_text = m.group("text").strip() level = len(m.group("lvl")) if "lvl" in m.groupdict() and m.group("lvl") else 2 found.append({ "anchor": anchor_text, "kind": kind, "level": level, "_start_char": start_char, "byte_start": char_to_byte[start_char], }) if not found: return [{ "anchor": "(whole document)", "kind": "doc", "level": 0, "byte_start": 0, "byte_end": len(raw), }] found.sort(key=lambda s: s["_start_char"]) sections: list[dict[str, Any]] = [] for i, s in enumerate(found): end = found[i + 1]["byte_start"] if i + 1 < len(found) else len(raw) sections.append({ "anchor": s["anchor"], "kind": s["kind"], "level": s["level"], "byte_start": s["byte_start"], "byte_end": end, }) return sections # Back-compat alias — earlier code (and tests) may reference _index_markdown. _index_markdown = _index_document def cmd_refresh_corpus(args: argparse.Namespace) -> int: if not _have("gh"): print("error: gh CLI not found. Install from https://cli.github.com/", file=sys.stderr) return 3 _ensure_dirs() taxonomy = _load_taxonomy() allowlist: list[str] = _allowlist_filenames(taxonomy) if args.only: allowlist = [f for f in allowlist if f in set(args.only)] print(f"Refreshing {len(allowlist)} files from {REFERENCE_REPO} into {CORPUS_DIR}") # Merge with the existing index so `--only` updates a subset without # discarding entries for files we didn't fetch this run. if INDEX_PATH.exists() and args.only: try: index = json.loads(INDEX_PATH.read_text(encoding="utf-8")) index.setdefault("files", {}) index["reference_repo"] = REFERENCE_REPO except (json.JSONDecodeError, ValueError): index = {"reference_repo": REFERENCE_REPO, "files": {}} else: index = {"reference_repo": REFERENCE_REPO, "files": {}} for filename in allowlist: api_path = f"repos/{REFERENCE_REPO}/contents/{filename}" try: raw = _run(["gh", "api", api_path]).stdout payload = json.loads(raw) content_b64 = payload.get("content", "") sha = payload.get("sha", "") file_size = payload.get("size", 0) encoding = payload.get("encoding", "") content = base64.b64decode(content_b64).decode("utf-8") if content_b64 else "" # GitHub Contents API returns empty content with encoding="none" for # files larger than ~1MB. Fall back to the raw blob endpoint, which # streams the full file regardless of size. if (not content) and file_size > 0 and encoding in ("none", ""): blob_raw = _run([ "gh", "api", "-H", "Accept: application/vnd.github.raw", f"repos/{REFERENCE_REPO}/contents/{filename}", ]).stdout content = blob_raw except subprocess.CalledProcessError as e: print(f" ✗ {filename} (gh api failed — file may not exist yet)") print(f" {e.stderr.strip().splitlines()[0] if e.stderr else ''}") continue except (json.JSONDecodeError, ValueError) as e: print(f" ✗ {filename} (decode error: {e})") continue local = CORPUS_DIR / filename local.write_text(content, encoding="utf-8") sections = _index_document(content) classification = _classification_for(filename) index["files"][filename] = { "sha": sha, "byte_size": len(content.encode("utf-8")), "classification": classification, "source_url": f"https://github.com/{REFERENCE_REPO}/blob/main/{filename}", "sections": sections, } print(f" ✓ {filename:<45} {classification:<18} {len(sections):>3} sections") INDEX_PATH.write_text(json.dumps(index, indent=2), encoding="utf-8") print(f"\nWrote index: {INDEX_PATH}") return 0 # --- authoritative-source layer --------------------------------------------- # # For every publicly-citable file in the corpus, taxonomy.json maps it to an # `authoritative_url` published by the regulator / legislature / standards body # itself (EUR-Lex, leginfo.legislature.ca.gov, gnu.org, docs.github.com, etc.). # `refresh-authoritative` fetches each URL, normalizes the content to plain # text, and stores it under cache/authoritative/.txt with a manifest # entry. `verify-corpus` then diffs the cached corpus copy against the # authoritative copy to detect drift (mirror is stale, edited, or incomplete). # Pressure-test consults the authoritative copy first for byte-for-byte quote # verification, falling back to the mirror only when the auth source is # unreachable. _USER_AGENT = ( "cite-check/0.2 (legal-citation verifier; " "+https://github.com/nkasuku/cite-check)" ) def _auth_text_path(filename: str) -> Path: return AUTH_DIR / (filename.rsplit(".", 1)[0] + ".txt") def _load_auth_manifest() -> dict[str, Any]: if not AUTH_MANIFEST.exists(): return {"files": {}} try: return json.loads(AUTH_MANIFEST.read_text(encoding="utf-8")) except (json.JSONDecodeError, ValueError): return {"files": {}} def _write_auth_manifest(manifest: dict[str, Any]) -> None: AUTH_MANIFEST.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8") def _http_get(url: str, *, accept: str = "text/html,application/xhtml+xml,*/*"): """Polite HTTP GET with a stable user agent and reasonable timeout.""" import requests # noqa: WPS433 — deferred so setup-check can report missing dep return requests.get( url, headers={"User-Agent": _USER_AGENT, "Accept": accept}, timeout=45, allow_redirects=True, ) def _extract_eur_lex(html: str) -> str: """EUR-Lex serves XHTML. Take the body text — it includes all recitals, articles, and annexes in a single document with no navigation chrome to speak of inside
.""" import warnings from bs4 import XMLParsedAsHTMLWarning # type: ignore warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) from bs4 import BeautifulSoup # type: ignore soup = BeautifulSoup(html, "lxml") body = soup.find("body") or soup return body.get_text(separator="\n", strip=True) def _extract_github_docs(html: str) -> str: from bs4 import BeautifulSoup # type: ignore soup = BeautifulSoup(html, "lxml") el = ( soup.select_one("div[data-search='article-body']") or soup.select_one("div.markdown-body") or soup.select_one("article") or soup.select_one("main") ) if el is None: return "" return el.get_text(separator="\n", strip=True) def _extract_legislation_uk(html: str) -> str: from bs4 import BeautifulSoup # type: ignore soup = BeautifulSoup(html, "lxml") el = ( soup.select_one("#content") or soup.select_one("#viewLegContents") or soup.select_one(".LegContent") or soup.find("body") ) return el.get_text(separator="\n", strip=True) if el else "" def _extract_leginfo(html: str) -> str: from bs4 import BeautifulSoup # type: ignore soup = BeautifulSoup(html, "lxml") el = ( soup.select_one("#manylawsections") or soup.select_one("#centerColumn") or soup.find("body") ) return el.get_text(separator="\n", strip=True) if el else "" def _extract_ftc(html: str) -> str: from bs4 import BeautifulSoup # type: ignore soup = BeautifulSoup(html, "lxml") el = ( soup.select_one("article") or soup.select_one("main") or soup.find("body") ) return el.get_text(separator="\n", strip=True) if el else "" def _extract_curia(html: str) -> str: from bs4 import BeautifulSoup # type: ignore soup = BeautifulSoup(html, "lxml") # Curia HTML wraps the judgment text in
or similar # depending on doc; fall back to body text. el = ( soup.select_one("body > div.C19Centre") or soup.find("body") ) return el.get_text(separator="\n", strip=True) if el else "" def _extract_wp29(html: str) -> str: from bs4 import BeautifulSoup # type: ignore soup = BeautifulSoup(html, "lxml") el = soup.select_one("main") or soup.select_one("article") or soup.find("body") return el.get_text(separator="\n", strip=True) if el else "" def _extract_opensource_org(html: str) -> str: from bs4 import BeautifulSoup # type: ignore soup = BeautifulSoup(html, "lxml") el = ( soup.select_one("article") or soup.select_one("main") or soup.select_one(".entry-content") or soup.find("body") ) return el.get_text(separator="\n", strip=True) if el else "" def _extract_spdx(html: str) -> str: from bs4 import BeautifulSoup # type: ignore soup = BeautifulSoup(html, "lxml") el = soup.select_one("table") or soup.find("body") return el.get_text(separator="\n", strip=True) if el else "" def _extract_cornell_lii(html: str) -> str: from bs4 import BeautifulSoup # type: ignore soup = BeautifulSoup(html, "lxml") el = ( soup.select_one("#main-content") or soup.select_one("main") or soup.select_one("#content") or soup.find("body") ) return el.get_text(separator="\n", strip=True) if el else "" def _extract_text(text: str) -> str: """Identity for plain-text sources (gnu.org, apache.org license files).""" return text def _extract_pdf(pdf_bytes: bytes) -> str: """Use pdfminer to pull text out of the PDF.""" from io import BytesIO from pdfminer.high_level import extract_text # type: ignore return extract_text(BytesIO(pdf_bytes)) _EXTRACTORS = { "eur_lex": _extract_eur_lex, "github_docs": _extract_github_docs, "legislation_uk": _extract_legislation_uk, "leginfo": _extract_leginfo, "ftc": _extract_ftc, "curia": _extract_curia, "wp29": _extract_wp29, "opensource_org": _extract_opensource_org, "spdx": _extract_spdx, "cornell_lii": _extract_cornell_lii, "text": _extract_text, "pdf": _extract_pdf, } def _fetch_authoritative(entry: dict[str, Any]) -> dict[str, Any]: """Fetch + extract one authoritative source. Returns a manifest record: {filename, url, fetched_at, http_status, content_length, content_hash, etag (if any), error (if any)}. Writes the extracted text to cache/authoritative/.txt on success. """ filename = entry["filename"] url = entry["authoritative_url"] fmt = entry.get("authoritative_format") or "html" extractor_name = entry.get("extractor") or "html" record: dict[str, Any] = { "filename": filename, "url": url, "format": fmt, "extractor": extractor_name, "fetched_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "http_status": None, "content_length": 0, "content_hash": None, "etag": None, "error": None, } try: accept = "application/pdf" if fmt == "pdf" else "text/html,application/xhtml+xml,*/*" resp = _http_get(url, accept=accept) record["http_status"] = resp.status_code record["etag"] = resp.headers.get("ETag") if resp.status_code != 200: record["error"] = f"HTTP {resp.status_code}" return record extractor = _EXTRACTORS.get(extractor_name) if extractor is None: record["error"] = f"unknown extractor {extractor_name!r}" return record if fmt == "pdf": text = extractor(resp.content) elif fmt == "text": text = extractor(resp.text) else: text = extractor(resp.text) except Exception as e: # noqa: BLE001 — refresh must never crash on one bad source record["error"] = f"{type(e).__name__}: {e}" return record if not text or not text.strip(): record["error"] = "extractor returned empty text" return record record["content_length"] = len(text) record["content_hash"] = hashlib.sha256(text.encode("utf-8")).hexdigest() _auth_text_path(filename).write_text(text, encoding="utf-8") return record def _load_auth_text(filename: str) -> Optional[str]: """Return the cached authoritative text for a corpus filename, or None.""" p = _auth_text_path(filename) if not p.exists(): return None try: return p.read_text(encoding="utf-8") except OSError: return None def _auth_age_days(filename: str) -> Optional[float]: """Return how many days old the cached authoritative copy is, or None. Reads the manifest's `fetched_at` ISO timestamp first; falls back to the on-disk mtime if the manifest is missing or unparseable. Returns None when the file isn't cached at all. """ if not _auth_text_path(filename).exists(): return None manifest = _load_auth_manifest() record = manifest.get("files", {}).get(filename) or {} fetched_at = record.get("fetched_at") if fetched_at: try: t = time.strptime(fetched_at, "%Y-%m-%dT%H:%M:%SZ") fetched_epoch = calendar.timegm(t) return max(0.0, (time.time() - fetched_epoch) / 86400.0) except (ValueError, TypeError): pass try: mtime = _auth_text_path(filename).stat().st_mtime return max(0.0, (time.time() - mtime) / 86400.0) except OSError: return None # --- subcommand: refresh-authoritative -------------------------------------- def cmd_refresh_authoritative(args: argparse.Namespace) -> int: """Fetch every file's authoritative URL, extract clean text, cache it, and write a manifest. Files without an authoritative_url (intentionally: internal docs) are skipped with a clear note.""" _ensure_dirs() taxonomy = _load_taxonomy() entries = _normalize_allowlist(taxonomy) selected = set(args.only or []) if selected: entries = [e for e in entries if e["filename"] in selected] manifest = _load_auth_manifest() files_section: dict[str, Any] = manifest.setdefault("files", {}) fetched = skipped = failed = 0 print(f"Refreshing authoritative sources ({len(entries)} entries)…") for entry in entries: filename = entry["filename"] url = entry.get("authoritative_url") if not url: note = entry.get("_note") or "no authoritative URL configured" print(f" - {filename:<48} skipped — {note[:80]}") skipped += 1 continue if not args.force and filename in files_section and files_section[filename].get("content_hash"): existing_text = _load_auth_text(filename) if existing_text is not None: print(f" ⤳ {filename:<48} cached ({files_section[filename].get('content_length', 0)} chars) — pass --force to re-fetch") continue record = _fetch_authoritative(entry) files_section[filename] = record if record["error"]: print(f" ✗ {filename:<48} {record['error']}") failed += 1 else: print(f" ✓ {filename:<48} {record['content_length']:>7} chars hash={record['content_hash'][:12]}") fetched += 1 time.sleep(args.delay) manifest["files"] = files_section manifest["last_refreshed"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) _write_auth_manifest(manifest) print() print(f"Authoritative manifest: {AUTH_MANIFEST}") print(f" fetched: {fetched} cached: {len(entries) - fetched - skipped - failed} skipped: {skipped} failed: {failed}") return 1 if failed else 0 # --- subcommand: verify-corpus ---------------------------------------------- _MIRROR_ARTIFACT_PATTERNS = ( re.compile(r"^#{1,6}\s"), # markdown headers added by mirror re.compile(r"^\*Extracted from:"), # provenance line added by mirror re.compile(r"^$"), # page-break markers from PDF extraction re.compile(r"^