feat(skills): add fact-checking mode to grounded-citations
Extends the citation ledger with evidence-backed fact-checking: - New `quote` subcommand attaches verbatim supporting quotes to a source; the quote is rejected unless it appears verbatim (whitespace/case-insensitive) in the fetched page text, so a paraphrase or misremembered figure cannot masquerade as evidence. - `verify --evidence` fails a draft whose cited sources carry no attached quote. - `render --style evidence` prints each source's quotes beneath its URL, showing the claim -> source -> exact-text chain. - `[unverified]` marker declares model-knowledge claims; counts toward --min-coverage so provenance is declared for every sentence without forcing fake citations. - SKILL.md: new Fact-Checking Mode section + pitfalls; version 1.1.0. - 10 new tests (40 total), all proven live by sabotage runs. Covers the fact-checking/evidence-transparency half of #28289.
This commit is contained in:
parent
8d755d0748
commit
4660673a3c
|
|
@ -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 <url> [--title T]` |
|
||||
| Register several at once | `sources.py add <url1> <url2> ...` |
|
||||
| Register from JSON tool output | `sources.py ingest results.json` |
|
||||
| Attach verbatim evidence to a source | `sources.py quote <id> --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 <ids>` 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <url> [--title T]` |
|
||||
| Register several at once | `sources.py add <url1> <url2> ...` |
|
||||
| Register from JSON tool output | `sources.py ingest results.json` |
|
||||
| Attach verbatim evidence to a source | `sources.py quote <id> --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 <ids>` 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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue