Files
brunoandClaude Opus 4.7 e400e98db4 feat(knowledge): W3.2 line-range chunks + W4.5 drift check
W3.2 (source line ranges per chunk):
- common.MarkdownChunk dataclass + chunk_markdown_with_lines() tracks
  1-indexed source line ranges as it groups paragraphs into chunks.
  Old chunk_markdown() preserved as a thin wrapper for callers that
  don't need line ranges.
- ingest_artifact.build_chunks_for_document now emits
  source_start_line / source_end_line on every chunk.
- knowledge-chunk schema accepts the two new fields (nullable for
  backward compat with pre-W3.2 chunks; required for new ones).
- context_pack.PackSection carries the same fields; module-map
  sections record start=1 / end=line_count of the full file. Manifest
  output and pack.md citation grammar are unchanged.
- validate_citations.verify() for kind=file_line now searches the
  pack for sections whose source_path matches AND whose line range
  overlaps the citation. On hit, inherits that section's tier
  (typically ssot for module.yml/ADR sections). On no overlap,
  falls back to the existing weak verification (bounds-only).

Smoke (against ljwx-platform with module-map):
- spec/modules/license/module.yml:L1-L14 -> tier=ssot, severity=block
- docs/adr/006-security-hardening.md:L40-L60 -> tier=ssot, severity=block
- :L200-L300 (out of bounds) -> unverified, info
- nonexistent/path.md -> unverified, info

W4.5 (Dify drift check):
- new scripts/knowledge/dify_drift_check.py: read-only sweep that for
  each published record verifies (1) the dataset still exists with the
  recorded dataset_id, (2) the document still exists with the recorded
  remote_document_id, (3) the document name on Dify matches.
- Emits per-record + per-repo JSON report; exits non-zero with
  --fail-on-drift for CI.
- Smoke against ljwx-platform: 0 drift in 10-record sample.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-18 00:12:50 +08:00

383 lines
12 KiB
Python

#!/usr/bin/env python3
"""Verify citations in agent output against a context pack and the working
tree.
Agent findings must cite using one of three forms (see context_pack.py):
[kg:{repo}:{document_id}:{chunk_id}]
[kg:{repo}:{source_path}]
{source_path}:L{x}-L{y} or {source_path}:L{x}
For each citation we resolve and produce a triple:
verified True if the cited target actually exists.
tier ssot | proposed | reference | legacy | unknown
severity_cap block | warn | info
- unverified citations always cap at info
- tier=legacy caps at warn (cited source may be stale)
- tier=proposed caps at warn (cannot block on draft spec)
- tier=ssot/reference allows block
The script does not modify findings. It emits a per-citation verdict so a
downstream review pipeline can apply the severity cap and decide what to
surface as blocking, warning, or informational.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Final
# ---------------------------------------------------------------------------
# Citation grammar
# ---------------------------------------------------------------------------
# [kg:repo:doc_id:chunk_id] repo may contain `/`; doc_id and chunk_id may
# contain `:` only inside chunk_id segment. We accept owner/repo with /.
KG_CHUNK_RE: Final[re.Pattern[str]] = re.compile(
r"\[kg:(?P<repo>[\w./\-]+):(?P<doc_id>[\w.\-]+):(?P<chunk_id>[\w.:\-]+)\]"
)
KG_DOC_RE: Final[re.Pattern[str]] = re.compile(
r"\[kg:(?P<repo>[\w./\-]+):(?P<source_path>[\w./\-]+)\]"
)
# path:Lx-Ly or path:Lx; require path to look like a real source file
FILE_LINE_RE: Final[re.Pattern[str]] = re.compile(
r"(?P<path>[\w./\-]+\.(?:py|ts|tsx|js|jsx|java|go|rs|md|yml|yaml|sql|toml|json|sh)):L(?P<start>\d+)(?:-L?(?P<end>\d+))?"
)
@dataclass(slots=True)
class Citation:
kind: str # kg_chunk | kg_doc | file_line
text: str
repo: str | None = None
document_id: str | None = None
chunk_id: str | None = None
source_path: str | None = None
line_start: int | None = None
line_end: int | None = None
@dataclass(slots=True)
class Verdict:
citation: Citation
verified: bool
tier: str
severity_cap: str
reason: str
# ---------------------------------------------------------------------------
# Verification
# ---------------------------------------------------------------------------
SEVERITY_BY_TIER: Final[dict[str, str]] = {
"ssot": "block",
"reference": "block",
"proposed": "warn",
"legacy": "warn",
"unknown": "warn",
}
def extract_citations(text: str) -> list[Citation]:
"""Pull every citation from a blob of agent text.
Order: longest patterns first so kg_chunk doesn't get re-matched as
kg_doc + chunk text.
"""
citations: list[Citation] = []
# Track spans to avoid double-counting
consumed: list[tuple[int, int]] = []
def overlaps(start: int, end: int) -> bool:
return any(not (end <= cs or start >= ce) for cs, ce in consumed)
for match in KG_CHUNK_RE.finditer(text):
if overlaps(match.start(), match.end()):
continue
consumed.append((match.start(), match.end()))
citations.append(
Citation(
kind="kg_chunk",
text=match.group(0),
repo=match.group("repo"),
document_id=match.group("doc_id"),
chunk_id=match.group("chunk_id"),
)
)
for match in KG_DOC_RE.finditer(text):
if overlaps(match.start(), match.end()):
continue
# Skip if this would also match KG_CHUNK
if match.group("source_path").count(":") > 0:
continue
consumed.append((match.start(), match.end()))
citations.append(
Citation(
kind="kg_doc",
text=match.group(0),
repo=match.group("repo"),
source_path=match.group("source_path"),
)
)
for match in FILE_LINE_RE.finditer(text):
if overlaps(match.start(), match.end()):
continue
consumed.append((match.start(), match.end()))
citations.append(
Citation(
kind="file_line",
text=match.group(0),
source_path=match.group("path"),
line_start=int(match.group("start")),
line_end=int(match.group("end")) if match.group("end") else None,
)
)
return citations
def _load_pack(pack_path: Path) -> dict:
return json.loads(pack_path.read_text(encoding="utf-8"))
def _index_pack(pack: dict) -> tuple[dict[tuple[str, str], dict], dict[tuple[str, str], dict]]:
by_chunk: dict[tuple[str, str], dict] = {}
by_doc: dict[tuple[str, str], dict] = {}
for section in pack.get("sections", []) or []:
repo = str(section.get("source_repo", ""))
doc_id = str(section.get("document_id", ""))
source_path = str(section.get("source_path", ""))
chunk_id = section.get("chunk_id")
if chunk_id:
by_chunk[(repo, str(chunk_id))] = section
by_doc[(repo, source_path)] = section
by_doc[(repo, doc_id)] = section
return by_chunk, by_doc
def verify(
citation: Citation,
pack_by_chunk: dict[tuple[str, str], dict],
pack_by_doc: dict[tuple[str, str], dict],
repo_root: Path,
) -> Verdict:
if citation.kind == "kg_chunk":
key = (citation.repo or "", citation.chunk_id or "")
section = pack_by_chunk.get(key)
if section is None:
return Verdict(
citation=citation,
verified=False,
tier="unknown",
severity_cap="info",
reason="chunk_id not present in pack",
)
tier = str(section.get("tier", "unknown"))
return Verdict(
citation=citation,
verified=True,
tier=tier,
severity_cap=SEVERITY_BY_TIER.get(tier, "warn"),
reason="chunk resolved",
)
if citation.kind == "kg_doc":
key = (citation.repo or "", citation.source_path or "")
section = pack_by_doc.get(key)
if section is None:
return Verdict(
citation=citation,
verified=False,
tier="unknown",
severity_cap="info",
reason="source_path not present in pack",
)
tier = str(section.get("tier", "unknown"))
return Verdict(
citation=citation,
verified=True,
tier=tier,
severity_cap=SEVERITY_BY_TIER.get(tier, "warn"),
reason="document resolved",
)
if citation.kind == "file_line":
path = repo_root / (citation.source_path or "")
if not path.exists():
return Verdict(
citation=citation,
verified=False,
tier="unknown",
severity_cap="info",
reason="file does not exist in working tree",
)
try:
line_count = sum(1 for _ in path.open("r", encoding="utf-8"))
except OSError as exc:
return Verdict(
citation=citation,
verified=False,
tier="unknown",
severity_cap="info",
reason=f"file read error: {exc}",
)
end = citation.line_end or citation.line_start or 0
start = citation.line_start or 0
if start < 1 or end > line_count:
return Verdict(
citation=citation,
verified=False,
tier="unknown",
severity_cap="info",
reason=f"line range out of bounds (file has {line_count} lines)",
)
# W3.2: try to match the cited line range to a pack chunk so we
# can adopt that chunk's tier. Pack sections may carry
# source_start_line / source_end_line per chunk (added in the
# W3.2 schema bump); fall back to weak-verification if none of
# the pack chunks for this source_path covers the cited range.
matching_section = None
for section in pack_by_doc.values():
if section.get("source_path") != citation.source_path:
continue
chunk_start = section.get("source_start_line")
chunk_end = section.get("source_end_line")
if not isinstance(chunk_start, int) or not isinstance(chunk_end, int):
continue
# range overlap test
if start <= chunk_end and end >= chunk_start:
matching_section = section
break
if matching_section is not None:
tier = str(matching_section.get("tier", "reference"))
return Verdict(
citation=citation,
verified=True,
tier=tier,
severity_cap=SEVERITY_BY_TIER.get(tier, "warn"),
reason=(
f"file:line in bounds + overlaps pack chunk "
f"({matching_section.get('source_start_line')}-"
f"{matching_section.get('source_end_line')})"
),
)
# No chunk match → weak verification (bounds-only, tier unknown)
return Verdict(
citation=citation,
verified=True,
tier="reference",
severity_cap="warn",
reason="file:line in bounds (weak verification until W3.2)",
)
return Verdict(
citation=citation,
verified=False,
tier="unknown",
severity_cap="info",
reason="unsupported citation kind",
)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _read_findings(path: Path) -> str:
if path == Path("-"):
return sys.stdin.read()
return path.read_text(encoding="utf-8")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate citations in agent findings against a context pack"
)
parser.add_argument(
"--pack", type=Path, required=True, help="context-pack.json"
)
parser.add_argument(
"--findings",
type=Path,
required=True,
help="agent output as text or json; use '-' for stdin",
)
parser.add_argument(
"--repo-root",
type=Path,
default=Path.cwd(),
help="working tree for file_line resolution",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="optional: write verdict json; default is stdout",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
pack = _load_pack(args.pack)
by_chunk, by_doc = _index_pack(pack)
text = _read_findings(args.findings)
citations = extract_citations(text)
verdicts = [verify(c, by_chunk, by_doc, args.repo_root) for c in citations]
summary = {
"pack": str(args.pack),
"total": len(verdicts),
"verified": sum(1 for v in verdicts if v.verified),
"by_severity": {
"block": sum(1 for v in verdicts if v.severity_cap == "block"),
"warn": sum(1 for v in verdicts if v.severity_cap == "warn"),
"info": sum(1 for v in verdicts if v.severity_cap == "info"),
},
"by_tier": {
tier: sum(1 for v in verdicts if v.tier == tier)
for tier in ("ssot", "reference", "proposed", "legacy", "unknown")
},
"verdicts": [
{
"citation_text": v.citation.text,
"kind": v.citation.kind,
"repo": v.citation.repo,
"document_id": v.citation.document_id,
"chunk_id": v.citation.chunk_id,
"source_path": v.citation.source_path,
"line_start": v.citation.line_start,
"line_end": v.citation.line_end,
"verified": v.verified,
"tier": v.tier,
"severity_cap": v.severity_cap,
"reason": v.reason,
}
for v in verdicts
],
}
out = json.dumps(summary, ensure_ascii=False, indent=2)
if args.output is not None:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(out + "\n", encoding="utf-8")
else:
print(out)
return 0
if __name__ == "__main__":
sys.exit(main())