diff --git a/skills/research/grounded-citations/SKILL.md b/skills/research/grounded-citations/SKILL.md index a73c901ff5f0b..a40e709e96736 100644 --- a/skills/research/grounded-citations/SKILL.md +++ b/skills/research/grounded-citations/SKILL.md @@ -1,7 +1,7 @@ --- name: grounded-citations description: "Ground answers and documents in cited, verifiable sources." -version: 1.0.0 +version: 1.1.0 author: Hermes Agent + Teknium license: MIT platforms: [linux, macos, windows] @@ -19,6 +19,11 @@ Every claim taken from an outside source gets an inline numbered citation and a so the numbers and URLs come from retrieval, never from memory — the model only ever emits small integers it was handed. +For high-stakes work the same ledger doubles as a fact-checking chain: verbatim +quotes are attached to each source (rejected unless they literally appear in +the fetched page text), claims from model knowledge are flagged `[unverified]`, +and `verify --evidence` fails any draft whose cited sources carry no evidence. + This skill covers answers in chat, written documents (markdown, PDF, docx, slides), and research reports. It does not cover academic BibTeX pipelines — for conference papers use the `research-paper-writing` skill, which this skill @@ -72,10 +77,11 @@ id within a ledger, so ids stay stable across many search/extract rounds. | Register a source, get its id | `sources.py add [--title T]` | | Register several at once | `sources.py add ...` | | Register from JSON tool output | `sources.py ingest results.json` | +| Attach verbatim evidence to a source | `sources.py quote --text "exact wording" --from page.txt` | | Show ledger | `sources.py list [--json]` | -| Render the Sources block | `sources.py render [--style markdown\|plain\|footnotes\|bibtex] [--only 1,3]` | +| Render the Sources block | `sources.py render [--style markdown\|plain\|footnotes\|bibtex\|evidence] [--only 1,3]` | | Render only what a draft cites | `sources.py render --cited-in draft.md` | -| Check a draft's citations | `sources.py verify draft.md [--strict] [--min-coverage 0.6]` | +| Check a draft's citations | `sources.py verify draft.md [--strict] [--min-coverage 0.6] [--evidence]` | ## Procedure @@ -119,6 +125,53 @@ sources, cite inline, end with the rendered `Sources:` list. For a short answer you may render the block from `sources.py render --only ` instead of writing to a file. +## Fact-Checking Mode + +For work where the reader must be able to check the chain — medical, legal, +financial, safety, disputed claims, or when the user asks for fact-checking — +upgrade from citations to evidence: + +① **Attach a verbatim quote per source.** After extracting a page, save its +text to a file and attach the sentence(s) that carry each claim: + +```bash +python3 "$S" quote 1 --text "Ice is about 9% less dense than liquid water." --from page1.txt +``` + +The quote is rejected unless it appears verbatim in the evidence text +(whitespace- and case-insensitive), so a paraphrase or misremembered figure +cannot masquerade as evidence. Copy-paste from the fetched text; never retype. + +② **Flag model-knowledge claims with `[unverified]`.** A load-bearing claim +you could not source gets an explicit marker instead of a citation: + +``` +The refactor likely predates the 2.0 release.[unverified] +``` + +`verify --min-coverage` counts `[unverified]` sentences as covered — the goal +is declared provenance for every claim, not a citation on every sentence. +If a key claim can be checked, check it; `[unverified]` is for what genuinely +cannot be, and a fact-check deliverable dominated by `[unverified]` markers +should say so in its summary. + +③ **Cross-check disputed facts against a second independent source.** When two +sources disagree, cite both readings with their own ids and quotes, and say +which you weight and why. One source is reporting; two independent sources are +corroboration. + +④ **Verify with the evidence gate and render the evidence block:** + +```bash +python3 "$S" verify report.md --evidence --min-coverage 0.5 +python3 "$S" render --style evidence --cited-in report.md +``` + +`--evidence` fails the draft if any cited source has no attached quote. The +`evidence` render style prints each source's quotes beneath its URL, so the +deliverable shows claim → source → exact supporting text with nothing taken on +faith. + ## Pitfalls - **Registering after writing.** The ledger must be populated from tool output, @@ -139,6 +192,14 @@ writing to a file. - **Parallel subagents.** Each subagent has its own working directory; point them all at one ledger with `--ledger` (or `HERMES_CITATION_LEDGER`) if their outputs get merged, otherwise their ids will collide. +- **Quoting from a snippet instead of the page.** Evidence quotes must come + from the extracted page text, not a search-result description — `web_extract` + first, save the text, then `quote --from` that file. +- **Paraphrasing into `quote --text`.** The verbatim check will reject it; the + fix is to find the actual sentence, not to reword until something matches. +- **Using `[unverified]` as an escape hatch.** It marks the rare claim that + genuinely cannot be sourced; if most sentences carry it, the task needed more + retrieval, not more markers. ## Verification diff --git a/skills/research/grounded-citations/scripts/sources.py b/skills/research/grounded-citations/scripts/sources.py index a80e2dda0df76..ac4f71d4b4c62 100644 --- a/skills/research/grounded-citations/scripts/sources.py +++ b/skills/research/grounded-citations/scripts/sources.py @@ -11,10 +11,18 @@ Subcommands reset start a clean ledger add URL [URL ...] register source(s), print their ids ingest FILE|- register every url found in JSON tool output + quote ID --text T --from FILE|- attach verbatim supporting evidence to a source list show the ledger render render a Sources block verify DRAFT check a draft's citations against the ledger +Fact-checking is evidence-backed citation: ``quote`` only accepts text that +literally appears in the fetched page text you point it at, ``verify +--evidence`` requires every cited source to carry at least one such quote, and +``render --style evidence`` prints the quotes under each source so the reader +can check the chain themselves. Claims from model knowledge are declared with +an ``[unverified]`` marker rather than silently blended in. + Ledger path resolution (first wins): --ledger PATH $HERMES_CITATION_LEDGER @@ -45,6 +53,8 @@ _SOURCES_HEADER_RE = re.compile(r"^\s*(?:#{1,6}\s*)?(?:\*\*)?sources:?(?:\*\*)?\ _SOURCE_LINE_RE = re.compile(r"^\s*\[(\d{1,4})\]\s*[-–:]?\s*(\S+)") _URL_IN_TEXT_RE = re.compile(r"https?://[^\s\"'<>)\]}]+") _FENCE_RE = re.compile(r"^\s*(?:```|~~~)") +# Explicit declaration that a claim comes from model knowledge, not a source. +_UNVERIFIED_RE = re.compile(r"\[unverified\]", re.IGNORECASE) # --------------------------------------------------------------------------- @@ -214,6 +224,49 @@ def urls_from_json(payload: Any) -> list[tuple[str, str]]: return found +# --------------------------------------------------------------------------- +# Evidence quotes (fact-checking) +# --------------------------------------------------------------------------- + + +def _normalize_ws(text: str) -> str: + """Collapse all whitespace runs to single spaces for verbatim matching.""" + return " ".join((text or "").split()) + + +def quote_in_evidence(quote: str, evidence: str) -> bool: + """True when ``quote`` appears verbatim (whitespace-insensitively, + case-insensitively) in the fetched ``evidence`` text.""" + q = _normalize_ws(quote).casefold() + return bool(q) and q in _normalize_ws(evidence).casefold() + + +def attach_quote(path: Path, source_id: int, quote: str, evidence: str) -> dict[str, Any]: + """Attach a verbatim quote to a ledger entry after checking it against + the evidence text. Raises SystemExit on unknown id or non-verbatim text — + a quote the page does not contain is exactly the fabrication this guards + against.""" + quote = (quote or "").strip() + if len(_normalize_ws(quote).split()) < 3: + raise SystemExit("error: quote too short — use at least 3 words of verbatim text") + if not quote_in_evidence(quote, evidence): + raise SystemExit( + "error: quote not found verbatim in the evidence text — " + "copy the exact wording from the fetched page, do not paraphrase" + ) + with _LedgerLock(path): + data = load_ledger(path) + entry = next((s for s in data["sources"] if s["id"] == source_id), None) + if entry is None: + raise SystemExit(f"error: no source [{source_id}] in the ledger") + quotes = entry.setdefault("quotes", []) + norm = _normalize_ws(quote).casefold() + if not any(_normalize_ws(q.get("text", "")).casefold() == norm for q in quotes): + quotes.append({"text": quote, "added": time.strftime("%Y-%m-%d")}) + save_ledger(path, data) + return entry + + def render_sources( sources: list[dict[str, Any]], style: str = "markdown", @@ -247,6 +300,9 @@ def render_sources( title = s.get("title") suffix = f" — {title}" if title else "" lines.append(f"[{s['id']}] {s['url']}{suffix}") + if style == "evidence": + for q in s.get("quotes", []): + lines.append(f' > "{q.get("text", "")}"') return "\n".join(lines) @@ -309,6 +365,7 @@ def verify_draft( sources: list[dict[str, Any]], strict: bool = False, min_coverage: float | None = None, + require_evidence: bool = False, ) -> tuple[int, list[str], list[str]]: """Return (exit_code, errors, warnings).""" text = draft_path.read_text(encoding="utf-8") @@ -365,22 +422,36 @@ def verify_draft( sentences = _sentences(prose) cited_sentences = [s for s in sentences if _CITE_RE.search(s)] - coverage = (len(cited_sentences) / len(sentences)) if sentences else 0.0 + unverified_sentences = [s for s in sentences if _UNVERIFIED_RE.search(s)] + covered = [s for s in sentences if _CITE_RE.search(s) or _UNVERIFIED_RE.search(s)] + coverage = (len(covered) / len(sentences)) if sentences else 0.0 if min_coverage is not None and sentences and coverage < min_coverage: errors.append( f"citation coverage {coverage:.0%} is below the required {min_coverage:.0%} " - f"({len(cited_sentences)}/{len(sentences)} sentences cited)" + f"({len(covered)}/{len(sentences)} sentences cited or marked [unverified])" ) + if require_evidence: + unevidenced = sorted( + i for i in cited_set if i in by_id and not by_id[i].get("quotes") + ) + if unevidenced: + errors.append( + "cited sources carry no verbatim evidence quote (run `quote` with the " + "fetched page text): " + ", ".join(f"[{i}]" for i in unevidenced) + ) + over_cited = [s for s in sentences if len(_CITE_RE.findall(s)) > 3] if over_cited: warnings.append(f"{len(over_cited)} sentence(s) carry more than 3 citations") code = 1 if errors else (1 if (strict and warnings) else 0) + quoted = sum(1 for s in sources if s.get("quotes")) stats = ( - f"{len(sentences)} prose sentence(s), {len(cited_sentences)} cited " - f"({coverage:.0%}), {len(cited_set)} distinct source(s) cited, " - f"{len(by_id)} in ledger" + f"{len(sentences)} prose sentence(s), {len(cited_sentences)} cited, " + f"{len(unverified_sentences)} marked [unverified] ({coverage:.0%} covered), " + f"{len(cited_set)} distinct source(s) cited, " + f"{len(by_id)} in ledger ({quoted} with evidence quotes)" ) warnings.insert(0, f"stats: {stats}") return code, errors, warnings @@ -424,12 +495,22 @@ def main(argv: list[str] | None = None) -> int: p_ing = sub.add_parser("ingest", help="register every url in JSON tool output") p_ing.add_argument("file", help="JSON file, or - for stdin") + p_q = sub.add_parser("quote", help="attach verbatim supporting evidence to a source") + p_q.add_argument("id", type=int, help="ledger id of the source the quote supports") + p_q.add_argument("--text", required=True, help="the exact quote, copied from the page") + p_q.add_argument( + "--from", + dest="evidence", + required=True, + help="file with the fetched page text (or - for stdin) the quote must appear in", + ) + p_list = sub.add_parser("list", help="show the ledger") p_list.add_argument("--json", action="store_true") p_render = sub.add_parser("render", help="render a Sources block") p_render.add_argument( - "--style", default="markdown", choices=["markdown", "plain", "footnotes", "bibtex"] + "--style", default="markdown", choices=["markdown", "plain", "footnotes", "bibtex", "evidence"] ) p_render.add_argument("--only", help="ids to include, e.g. 1,3,5-7") p_render.add_argument("--cited-in", help="include only ids cited in this draft file") @@ -438,6 +519,11 @@ def main(argv: list[str] | None = None) -> int: p_ver.add_argument("draft") p_ver.add_argument("--strict", action="store_true", help="treat warnings as failures") p_ver.add_argument("--min-coverage", type=float, help="required cited-sentence share, e.g. 0.5") + p_ver.add_argument( + "--evidence", + action="store_true", + help="require every cited source to carry at least one verbatim quote", + ) args = parser.parse_args(argv) path = resolve_ledger_path(args.ledger) @@ -474,6 +560,16 @@ def main(argv: list[str] | None = None) -> int: print(f"[{entry['id']}] {entry['url']}") return 0 + if args.cmd == "quote": + raw = ( + sys.stdin.read() + if args.evidence == "-" + else Path(args.evidence).read_text(encoding="utf-8") + ) + entry = attach_quote(path, args.id, args.text, raw) + print(f"[{entry['id']}] evidence attached ({len(entry.get('quotes', []))} quote(s))") + return 0 + data = load_ledger(path) sources = sorted(data["sources"], key=lambda s: s["id"]) @@ -485,7 +581,9 @@ def main(argv: list[str] | None = None) -> int: else: for s in sources: title = f" {s['title']}" if s.get("title") else "" - print(f"[{s['id']}] {s['url']}{title}") + nq = len(s.get("quotes", [])) + mark = f" ({nq} quote{'s' if nq != 1 else ''})" if nq else "" + print(f"[{s['id']}] {s['url']}{title}{mark}") return 0 if args.cmd == "render": @@ -508,7 +606,11 @@ def main(argv: list[str] | None = None) -> int: print(f"error: no such draft: {draft_path}", file=sys.stderr) return 2 code, errors, warnings = verify_draft( - draft_path, sources, strict=args.strict, min_coverage=args.min_coverage + draft_path, + sources, + strict=args.strict, + min_coverage=args.min_coverage, + require_evidence=args.evidence, ) for w in warnings: print(f"warn: {w}") diff --git a/tests/skills/test_grounded_citations_skill.py b/tests/skills/test_grounded_citations_skill.py index 08f597e588ad7..c21a2d58c1866 100644 --- a/tests/skills/test_grounded_citations_skill.py +++ b/tests/skills/test_grounded_citations_skill.py @@ -337,3 +337,132 @@ def test_corrupt_ledger_raises_actionable_error(sources_mod, tmp_path: Path) -> with pytest.raises(SystemExit) as exc: sources_mod.load_ledger(bad) assert "reset" in str(exc.value) + + +# --------------------------------------------------------------------------- +# Fact-checking: evidence quotes and [unverified] markers +# --------------------------------------------------------------------------- + +_PAGE = ( + "Water expands when it freezes.\n" + "Ice is about 9% less dense than liquid water,\n" + "which is why icebergs float.\n" +) + + +def test_quote_verbatim_match_is_whitespace_and_case_insensitive(sources_mod, ledger: Path) -> None: + sources_mod.add_sources(ledger, ["https://a.example"]) + entry = sources_mod.attach_quote( + ledger, 1, "ice is about 9% less dense than liquid water,", _PAGE + ) + assert len(entry["quotes"]) == 1 + + +def test_quote_rejects_paraphrase(sources_mod, ledger: Path) -> None: + sources_mod.add_sources(ledger, ["https://a.example"]) + with pytest.raises(SystemExit) as exc: + sources_mod.attach_quote(ledger, 1, "Frozen water is roughly 9% lighter", _PAGE) + assert "not found verbatim" in str(exc.value) + + +def test_quote_rejects_unknown_id_and_short_text(sources_mod, ledger: Path) -> None: + sources_mod.add_sources(ledger, ["https://a.example"]) + with pytest.raises(SystemExit) as exc: + sources_mod.attach_quote(ledger, 7, "which is why icebergs float.", _PAGE) + assert "no source [7]" in str(exc.value) + with pytest.raises(SystemExit) as exc: + sources_mod.attach_quote(ledger, 1, "icebergs float.", _PAGE) + assert "too short" in str(exc.value) + + +def test_quote_is_idempotent(sources_mod, ledger: Path) -> None: + sources_mod.add_sources(ledger, ["https://a.example"]) + sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE) + entry = sources_mod.attach_quote(ledger, 1, "water expands when it freezes.", _PAGE) + assert len(entry["quotes"]) == 1 + + +def test_verify_evidence_gate_requires_quotes(sources_mod, ledger: Path, tmp_path: Path) -> None: + _seed(sources_mod, ledger) + text = ( + "A claim supported by the first source.[1]\n\n" + "Sources:\n[1] https://a.example\n" + ) + code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, require_evidence=True) + assert code == 1 + assert any("no verbatim evidence quote" in e for e in errors) + + sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE) + code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, require_evidence=True) + assert (code, errors) == (0, []) + + +def test_evidence_gate_only_applies_to_cited_sources(sources_mod, ledger: Path, tmp_path: Path) -> None: + _seed(sources_mod, ledger) + sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE) + # [2] and [3] have no quotes but are not cited — the gate must not fail on them. + text = "A claim supported by the first source.[1]\n\nSources:\n[1] https://a.example\n" + code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, require_evidence=True) + assert (code, errors) == (0, []) + + +def test_unverified_marker_counts_toward_coverage(sources_mod, ledger: Path, tmp_path: Path) -> None: + _seed(sources_mod, ledger) + text = ( + "A cited claim about the subject matter.[1]\n" + "A model-knowledge claim declared as such.[unverified]\n\n" + "Sources:\n[1] https://a.example\n" + ) + code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, min_coverage=0.9) + assert (code, errors) == (0, []) + + +def test_unverified_marker_does_not_hide_uncited_sentences(sources_mod, ledger: Path, tmp_path: Path) -> None: + _seed(sources_mod, ledger) + text = ( + "A cited claim about the subject matter.[1]\n" + "An uncited, unmarked claim about the subject.\n" + "Another uncited, unmarked claim about the subject.\n\n" + "Sources:\n[1] https://a.example\n" + ) + code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, min_coverage=0.9) + assert code == 1 + assert any("coverage" in e for e in errors) + + +def test_render_evidence_style_includes_quotes(sources_mod, ledger: Path) -> None: + _seed(sources_mod, ledger) + sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE) + sources = json.loads(ledger.read_text(encoding="utf-8"))["sources"] + block = sources_mod.render_sources(sources, style="evidence", only={1}) + assert "[1] https://a.example" in block + assert '> "Water expands when it freezes."' in block + plain = sources_mod.render_sources(sources, style="markdown", only={1}) + assert "Water expands" not in plain + + +def test_cli_quote_and_evidence_verify_roundtrip(sources_mod, tmp_path: Path, capsys) -> None: + ledger = tmp_path / "ev.json" + page = tmp_path / "page.txt" + page.write_text(_PAGE, encoding="utf-8") + args = ["--ledger", str(ledger)] + assert sources_mod.main(args + ["add", "https://a.example"]) == 0 + capsys.readouterr() + + assert ( + sources_mod.main( + args + ["quote", "1", "--text", "Water expands when it freezes.", "--from", str(page)] + ) + == 0 + ) + assert "evidence attached" in capsys.readouterr().out + + draft = tmp_path / "d.md" + draft.write_text( + "A claim resting on the source page.[1]\n\nSources:\n[1] https://a.example\n", + encoding="utf-8", + ) + assert sources_mod.main(args + ["verify", str(draft), "--evidence"]) == 0 + capsys.readouterr() + assert sources_mod.main(args + ["render", "--style", "evidence"]) == 0 + assert '> "Water expands when it freezes."' in capsys.readouterr().out diff --git a/website/docs/user-guide/skills/bundled/research/research-grounded-citations.md b/website/docs/user-guide/skills/bundled/research/research-grounded-citations.md index 4bd8951d53830..0d37d54eec7a1 100644 --- a/website/docs/user-guide/skills/bundled/research/research-grounded-citations.md +++ b/website/docs/user-guide/skills/bundled/research/research-grounded-citations.md @@ -16,7 +16,7 @@ Ground answers and documents in cited, verifiable sources. |---|---| | Source | Bundled (installed by default) | | Path | `skills/research/grounded-citations` | -| Version | `1.0.0` | +| Version | `1.1.0` | | Author | Hermes Agent + Teknium | | License | MIT | | Platforms | linux, macos, windows | @@ -36,6 +36,11 @@ Every claim taken from an outside source gets an inline numbered citation and a so the numbers and URLs come from retrieval, never from memory — the model only ever emits small integers it was handed. +For high-stakes work the same ledger doubles as a fact-checking chain: verbatim +quotes are attached to each source (rejected unless they literally appear in +the fetched page text), claims from model knowledge are flagged `[unverified]`, +and `verify --evidence` fails any draft whose cited sources carry no evidence. + This skill covers answers in chat, written documents (markdown, PDF, docx, slides), and research reports. It does not cover academic BibTeX pipelines — for conference papers use the `research-paper-writing` skill, which this skill @@ -89,10 +94,11 @@ id within a ledger, so ids stay stable across many search/extract rounds. | Register a source, get its id | `sources.py add [--title T]` | | Register several at once | `sources.py add ...` | | Register from JSON tool output | `sources.py ingest results.json` | +| Attach verbatim evidence to a source | `sources.py quote --text "exact wording" --from page.txt` | | Show ledger | `sources.py list [--json]` | -| Render the Sources block | `sources.py render [--style markdown\|plain\|footnotes\|bibtex] [--only 1,3]` | +| Render the Sources block | `sources.py render [--style markdown\|plain\|footnotes\|bibtex\|evidence] [--only 1,3]` | | Render only what a draft cites | `sources.py render --cited-in draft.md` | -| Check a draft's citations | `sources.py verify draft.md [--strict] [--min-coverage 0.6]` | +| Check a draft's citations | `sources.py verify draft.md [--strict] [--min-coverage 0.6] [--evidence]` | ## Procedure @@ -136,6 +142,53 @@ sources, cite inline, end with the rendered `Sources:` list. For a short answer you may render the block from `sources.py render --only ` instead of writing to a file. +## Fact-Checking Mode + +For work where the reader must be able to check the chain — medical, legal, +financial, safety, disputed claims, or when the user asks for fact-checking — +upgrade from citations to evidence: + +① **Attach a verbatim quote per source.** After extracting a page, save its +text to a file and attach the sentence(s) that carry each claim: + +```bash +python3 "$S" quote 1 --text "Ice is about 9% less dense than liquid water." --from page1.txt +``` + +The quote is rejected unless it appears verbatim in the evidence text +(whitespace- and case-insensitive), so a paraphrase or misremembered figure +cannot masquerade as evidence. Copy-paste from the fetched text; never retype. + +② **Flag model-knowledge claims with `[unverified]`.** A load-bearing claim +you could not source gets an explicit marker instead of a citation: + +``` +The refactor likely predates the 2.0 release.[unverified] +``` + +`verify --min-coverage` counts `[unverified]` sentences as covered — the goal +is declared provenance for every claim, not a citation on every sentence. +If a key claim can be checked, check it; `[unverified]` is for what genuinely +cannot be, and a fact-check deliverable dominated by `[unverified]` markers +should say so in its summary. + +③ **Cross-check disputed facts against a second independent source.** When two +sources disagree, cite both readings with their own ids and quotes, and say +which you weight and why. One source is reporting; two independent sources are +corroboration. + +④ **Verify with the evidence gate and render the evidence block:** + +```bash +python3 "$S" verify report.md --evidence --min-coverage 0.5 +python3 "$S" render --style evidence --cited-in report.md +``` + +`--evidence` fails the draft if any cited source has no attached quote. The +`evidence` render style prints each source's quotes beneath its URL, so the +deliverable shows claim → source → exact supporting text with nothing taken on +faith. + ## Pitfalls - **Registering after writing.** The ledger must be populated from tool output, @@ -156,6 +209,14 @@ writing to a file. - **Parallel subagents.** Each subagent has its own working directory; point them all at one ledger with `--ledger` (or `HERMES_CITATION_LEDGER`) if their outputs get merged, otherwise their ids will collide. +- **Quoting from a snippet instead of the page.** Evidence quotes must come + from the extracted page text, not a search-result description — `web_extract` + first, save the text, then `quote --from` that file. +- **Paraphrasing into `quote --text`.** The verbatim check will reject it; the + fix is to find the actual sentence, not to reword until something matches. +- **Using `[unverified]` as an escape hatch.** It marks the rare claim that + genuinely cannot be sourced; if most sentences carry it, the task needed more + retrieval, not more markers. ## Verification