diff --git a/executive-summary-claim-trace-assistant/README.md b/executive-summary-claim-trace-assistant/README.md new file mode 100644 index 00000000..fde8e425 --- /dev/null +++ b/executive-summary-claim-trace-assistant/README.md @@ -0,0 +1,38 @@ +# Executive Summary Claim Trace Assistant + +This dependency-free module reviews AI-generated executive summaries before they +are shared with collaborators, funders, or editors. It checks whether summary +claims are traceable to manuscript evidence and whether implications or next +steps are framed within the available evidence. + +## Checks + +- Each executive summary bullet includes at least one source evidence ID. +- Referenced evidence IDs exist in the manuscript evidence packet. +- Key findings are backed by results, tables, figures, or methods evidence. +- Implications and next steps avoid unsupported universal language. +- Clinical, policy, or funding claims require appropriate evidence types. +- Numeric claims must match numbers present in the traced evidence. +- Key findings must share enough terminology with the cited evidence to avoid + decorative or unrelated citations. +- Unsupported or over-broad bullets receive reviewer rewrite suggestions. + +## Run + +```bash +python3 executive-summary-claim-trace-assistant/summary_claim_trace_assistant.py \ + --sample \ + --json executive-summary-claim-trace-assistant/demo/report.json \ + --markdown executive-summary-claim-trace-assistant/demo/summary.md \ + --svg executive-summary-claim-trace-assistant/demo/graph.svg +``` + +Generated demo artifacts in `demo/` show the JSON decision payload, a reviewer +Markdown table, an SVG status chart, and a short MP4 walkthrough for bounty +reviewers. + +## Test + +```bash +python3 -m unittest executive-summary-claim-trace-assistant/test_summary_claim_trace_assistant.py +``` diff --git a/executive-summary-claim-trace-assistant/demo/demo.mp4 b/executive-summary-claim-trace-assistant/demo/demo.mp4 new file mode 100644 index 00000000..3f710b1f Binary files /dev/null and b/executive-summary-claim-trace-assistant/demo/demo.mp4 differ diff --git a/executive-summary-claim-trace-assistant/demo/graph.svg b/executive-summary-claim-trace-assistant/demo/graph.svg new file mode 100644 index 00000000..9102bbe4 --- /dev/null +++ b/executive-summary-claim-trace-assistant/demo/graph.svg @@ -0,0 +1,7 @@ + + + Executive Summary Claim Trace + Evidence coverage for AI-generated summary bullets + publish2revise2block2 + Evidence traces: 6 + diff --git a/executive-summary-claim-trace-assistant/demo/report.json b/executive-summary-claim-trace-assistant/demo/report.json new file mode 100644 index 00000000..509784ee --- /dev/null +++ b/executive-summary-claim-trace-assistant/demo/report.json @@ -0,0 +1,134 @@ +{ + "assistant": "executive-summary-claim-trace-assistant", + "bullet_reviews": [ + { + "bullet_id": "SUM-001", + "decision": "publish", + "evidence_trace": [ + { + "evidence_id": "EV-RESULT-001", + "section": "Results", + "type": "result" + } + ], + "findings": [], + "mode": "key_finding", + "rewrite": "" + }, + { + "bullet_id": "SUM-002", + "decision": "publish", + "evidence_trace": [ + { + "evidence_id": "EV-RESULT-001", + "section": "Results", + "type": "result" + }, + { + "evidence_id": "EV-FIG-002", + "section": "Figure 2", + "type": "figure" + }, + { + "evidence_id": "EV-METHOD-003", + "section": "Methods", + "type": "method" + } + ], + "findings": [], + "mode": "implication", + "rewrite": "" + }, + { + "bullet_id": "SUM-003", + "decision": "revise", + "evidence_trace": [ + { + "evidence_id": "EV-RESULT-001", + "section": "Results", + "type": "result" + } + ], + "findings": [ + { + "code": "OVERBROAD_LANGUAGE", + "message": "Summary uses universal or definitive language beyond the evidence.", + "severity": "revise" + }, + { + "code": "CLINICAL_CLAIM_NEEDS_SAFETY_CONTEXT", + "message": "Clinical claims need methods, ethics, trial, or limitation evidence.", + "severity": "revise" + } + ], + "mode": "next_step", + "rewrite": "The tool could be evaluated across similar clinical publishing workflows." + }, + { + "bullet_id": "SUM-004", + "decision": "block", + "evidence_trace": [], + "findings": [ + { + "code": "UNKNOWN_EVIDENCE_ID", + "message": "Summary references evidence IDs that are absent from the packet: EV-MISSING-999", + "severity": "block" + } + ], + "mode": "key_finding", + "rewrite": "Hold this bullet until the source evidence IDs are added or corrected." + }, + { + "bullet_id": "SUM-005", + "decision": "block", + "evidence_trace": [], + "findings": [ + { + "code": "MISSING_EVIDENCE_TRACE", + "message": "Summary bullet has no source evidence IDs.", + "severity": "block" + }, + { + "code": "OVERBROAD_LANGUAGE", + "message": "Summary uses universal or definitive language beyond the evidence.", + "severity": "revise" + } + ], + "mode": "implication", + "rewrite": "Hold this bullet until the source evidence IDs are added or corrected." + }, + { + "bullet_id": "SUM-006", + "decision": "revise", + "evidence_trace": [ + { + "evidence_id": "EV-RESULT-001", + "section": "Results", + "type": "result" + } + ], + "findings": [ + { + "code": "UNSUPPORTED_NUMERIC_DETAIL", + "message": "Summary includes numeric details absent from traced evidence: 50", + "severity": "revise" + }, + { + "code": "OVERBROAD_LANGUAGE", + "message": "Summary uses universal or definitive language beyond the evidence.", + "severity": "revise" + } + ], + "mode": "key_finding", + "rewrite": "Replace unsupported numbers with the exact values in the traced evidence or remove the numeric detail." + } + ], + "manuscript_id": "MS-EXEC-SUMMARY-2026", + "summary": { + "block": 2, + "publish": 2, + "revise": 2, + "total": 6, + "trace_count": 6 + } +} diff --git a/executive-summary-claim-trace-assistant/demo/summary.md b/executive-summary-claim-trace-assistant/demo/summary.md new file mode 100644 index 00000000..df31abff --- /dev/null +++ b/executive-summary-claim-trace-assistant/demo/summary.md @@ -0,0 +1,17 @@ +# Executive Summary Claim Trace Report + +- Manuscript: MS-EXEC-SUMMARY-2026 +- Total bullets: 6 +- Publish: 2 +- Revise: 2 +- Block: 2 +- Evidence traces: 6 + +| Bullet | Decision | Findings | Rewrite | +| --- | --- | --- | --- | +| SUM-001 | publish | none | | +| SUM-002 | publish | none | | +| SUM-003 | revise | OVERBROAD_LANGUAGE, CLINICAL_CLAIM_NEEDS_SAFETY_CONTEXT | The tool could be evaluated across similar clinical publishing workflows. | +| SUM-004 | block | UNKNOWN_EVIDENCE_ID | Hold this bullet until the source evidence IDs are added or corrected. | +| SUM-005 | block | MISSING_EVIDENCE_TRACE, OVERBROAD_LANGUAGE | Hold this bullet until the source evidence IDs are added or corrected. | +| SUM-006 | revise | UNSUPPORTED_NUMERIC_DETAIL, OVERBROAD_LANGUAGE | Replace unsupported numbers with the exact values in the traced evidence or remove the numeric detail. | diff --git a/executive-summary-claim-trace-assistant/sample_summary_packet.json b/executive-summary-claim-trace-assistant/sample_summary_packet.json new file mode 100644 index 00000000..8937855e --- /dev/null +++ b/executive-summary-claim-trace-assistant/sample_summary_packet.json @@ -0,0 +1,84 @@ +{ + "manuscript_id": "MS-EXEC-SUMMARY-2026", + "evidence": [ + { + "id": "EV-RESULT-001", + "type": "result", + "section": "Results", + "text": "The screened model reduced annotation time from 42 minutes to 31 minutes across 84 benchmark manuscripts.", + "metrics": { + "baseline_minutes": 42, + "intervention_minutes": 31, + "n": 84 + } + }, + { + "id": "EV-FIG-002", + "type": "figure", + "section": "Figure 2", + "text": "Figure 2 shows lower reviewer disagreement for biomedical manuscripts after the checklist prompt was enabled." + }, + { + "id": "EV-METHOD-003", + "type": "method", + "section": "Methods", + "text": "Evaluation used a retrospective corpus and did not include prospective clinical deployment." + }, + { + "id": "EV-LIMIT-004", + "type": "limitation", + "section": "Limitations", + "text": "The study was conducted in English-language manuscripts only." + } + ], + "summary_bullets": [ + { + "id": "SUM-001", + "mode": "key_finding", + "text": "The assistant reduced annotation time by roughly one quarter in the benchmark set.", + "evidence_ids": [ + "EV-RESULT-001" + ] + }, + { + "id": "SUM-002", + "mode": "implication", + "text": "These results suggest the workflow may reduce reviewer triage friction for similar manuscript review settings.", + "evidence_ids": [ + "EV-RESULT-001", + "EV-FIG-002", + "EV-METHOD-003" + ] + }, + { + "id": "SUM-003", + "mode": "next_step", + "text": "The tool should now be deployed across all clinical publishing workflows.", + "evidence_ids": [ + "EV-RESULT-001" + ] + }, + { + "id": "SUM-004", + "mode": "key_finding", + "text": "The assistant works equally well for multilingual manuscripts.", + "evidence_ids": [ + "EV-MISSING-999" + ] + }, + { + "id": "SUM-005", + "mode": "implication", + "text": "This will eliminate peer-review bottlenecks.", + "evidence_ids": [] + }, + { + "id": "SUM-006", + "mode": "key_finding", + "text": "The assistant reduced workload by 50% across all manuscripts.", + "evidence_ids": [ + "EV-RESULT-001" + ] + } + ] +} diff --git a/executive-summary-claim-trace-assistant/summary_claim_trace_assistant.py b/executive-summary-claim-trace-assistant/summary_claim_trace_assistant.py new file mode 100644 index 00000000..4dee887a --- /dev/null +++ b/executive-summary-claim-trace-assistant/summary_claim_trace_assistant.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Trace AI executive summary claims back to manuscript evidence.""" + +from __future__ import annotations + +import argparse +import html +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + + +OVERBROAD_RE = re.compile(r"\b(all|always|never|eliminate|guarantee|prove|definitive|across every)\b", re.I) +CLINICAL_RE = re.compile(r"\b(clinical|patient|treatment|diagnosis|therapy|medical)\b", re.I) +POLICY_RE = re.compile(r"\b(policy|funder|mandate|regulator|government|compliance)\b", re.I) +NUMBER_RE = re.compile(r"\b\d+(?:\.\d+)?%?\b") +TOKEN_RE = re.compile(r"[a-z][a-z0-9-]{2,}", re.I) +VALID_MODES = {"key_finding", "implication", "next_step"} +FINDING_EVIDENCE = {"result", "figure", "table", "method"} +SENSITIVE_EVIDENCE = {"clinical_trial", "ethics", "policy", "limitation", "method"} +STOPWORDS = { + "about", + "after", + "also", + "and", + "are", + "because", + "before", + "can", + "could", + "for", + "from", + "has", + "have", + "into", + "may", + "now", + "our", + "should", + "shows", + "that", + "the", + "these", + "this", + "was", + "were", + "will", + "with", +} + + +@dataclass(frozen=True) +class Finding: + code: str + severity: str + message: str + + def to_dict(self) -> dict[str, str]: + return {"code": self.code, "severity": self.severity, "message": self.message} + + +@dataclass +class BulletReview: + bullet_id: str + mode: str + decision: str + findings: list[Finding] = field(default_factory=list) + evidence_trace: list[dict[str, str]] = field(default_factory=list) + rewrite: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "bullet_id": self.bullet_id, + "mode": self.mode, + "decision": self.decision, + "findings": [finding.to_dict() for finding in self.findings], + "evidence_trace": self.evidence_trace, + "rewrite": self.rewrite, + } + + +def sample_path() -> Path: + return Path(__file__).with_name("sample_summary_packet.json") + + +def load_packet(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + packet = json.load(handle) + if not isinstance(packet, dict): + raise ValueError("Summary packet must be a JSON object.") + return packet + + +def review_packet(packet: dict[str, Any]) -> dict[str, Any]: + evidence_index = { + str(item.get("id")): item + for item in packet.get("evidence", []) + if isinstance(item, dict) and item.get("id") + } + reviews = [review_bullet(bullet, evidence_index) for bullet in packet.get("summary_bullets", [])] + summary = { + "total": len(reviews), + "publish": sum(1 for review in reviews if review.decision == "publish"), + "revise": sum(1 for review in reviews if review.decision == "revise"), + "block": sum(1 for review in reviews if review.decision == "block"), + "trace_count": sum(len(review.evidence_trace) for review in reviews), + } + return { + "assistant": "executive-summary-claim-trace-assistant", + "manuscript_id": packet.get("manuscript_id", "unknown"), + "summary": summary, + "bullet_reviews": [review.to_dict() for review in reviews], + } + + +def review_bullet(bullet: dict[str, Any], evidence_index: dict[str, dict[str, Any]]) -> BulletReview: + bullet_id = str(bullet.get("id") or "UNKNOWN") + mode = str(bullet.get("mode") or "") + text = str(bullet.get("text") or "") + evidence_ids = [str(item) for item in bullet.get("evidence_ids") or []] + findings: list[Finding] = [] + trace: list[dict[str, str]] = [] + evidence_texts: list[str] = [] + evidence_numbers: set[str] = set() + + if mode not in VALID_MODES: + findings.append(Finding("UNKNOWN_SUMMARY_MODE", "revise", "Summary bullet mode is not recognized.")) + + if not text.strip(): + findings.append(Finding("EMPTY_SUMMARY_BULLET", "block", "Summary bullet has no text.")) + + if not evidence_ids: + findings.append(Finding("MISSING_EVIDENCE_TRACE", "block", "Summary bullet has no source evidence IDs.")) + + missing = [] + evidence_types = set() + for evidence_id in evidence_ids: + evidence = evidence_index.get(evidence_id) + if not evidence: + missing.append(evidence_id) + continue + evidence_type = str(evidence.get("type") or "unknown") + evidence_types.add(evidence_type) + evidence_text = str(evidence.get("text") or "") + evidence_texts.append(evidence_text) + evidence_numbers.update(extract_numbers(evidence_text)) + metrics = evidence.get("metrics") + if isinstance(metrics, dict): + evidence_numbers.update(normalize_number(value) for value in metrics.values()) + trace.append( + { + "evidence_id": evidence_id, + "type": evidence_type, + "section": str(evidence.get("section") or ""), + } + ) + + if missing: + findings.append( + Finding( + "UNKNOWN_EVIDENCE_ID", + "block", + "Summary references evidence IDs that are absent from the packet: " + ", ".join(missing), + ) + ) + + if mode == "key_finding" and trace and not evidence_types.intersection(FINDING_EVIDENCE): + findings.append( + Finding("WEAK_KEY_FINDING_SUPPORT", "revise", "Key finding lacks result, table, figure, or method support.") + ) + + if trace and mode == "key_finding" and weak_text_overlap(text, evidence_texts): + findings.append( + Finding( + "LOW_EVIDENCE_TEXT_OVERLAP", + "revise", + "Key finding shares too little terminology with the traced evidence.", + ) + ) + + unsupported_numbers = sorted(extract_numbers(text) - evidence_numbers) + if trace and unsupported_numbers: + findings.append( + Finding( + "UNSUPPORTED_NUMERIC_DETAIL", + "revise", + "Summary includes numeric details absent from traced evidence: " + ", ".join(unsupported_numbers), + ) + ) + + if OVERBROAD_RE.search(text): + findings.append( + Finding("OVERBROAD_LANGUAGE", "revise", "Summary uses universal or definitive language beyond the evidence.") + ) + + if CLINICAL_RE.search(text) and not evidence_types.intersection(SENSITIVE_EVIDENCE): + findings.append( + Finding("CLINICAL_CLAIM_NEEDS_SAFETY_CONTEXT", "revise", "Clinical claims need methods, ethics, trial, or limitation evidence.") + ) + + if POLICY_RE.search(text) and "policy" not in evidence_types: + findings.append( + Finding("POLICY_CLAIM_NEEDS_POLICY_EVIDENCE", "revise", "Policy or funder claims need policy evidence.") + ) + + decision = decision_from_findings(findings) + return BulletReview( + bullet_id=bullet_id, + mode=mode, + decision=decision, + findings=findings, + evidence_trace=trace, + rewrite=suggest_rewrite(text, findings), + ) + + +def normalize_number(value: Any) -> str: + return str(value).strip().lower().rstrip("%.,") + + +def extract_numbers(text: str) -> set[str]: + return {normalize_number(match.group(0)) for match in NUMBER_RE.finditer(text)} + + +def normalized_tokens(text: str) -> set[str]: + return {token.lower() for token in TOKEN_RE.findall(text) if token.lower() not in STOPWORDS} + + +def weak_text_overlap(summary_text: str, evidence_texts: list[str]) -> bool: + claim_tokens = normalized_tokens(summary_text) + if len(claim_tokens) < 5: + return False + evidence_tokens = normalized_tokens(" ".join(evidence_texts)) + return len(claim_tokens.intersection(evidence_tokens)) < 2 + + +def decision_from_findings(findings: Iterable[Finding]) -> str: + severities = {finding.severity for finding in findings} + if "block" in severities: + return "block" + if "revise" in severities: + return "revise" + return "publish" + + +def suggest_rewrite(text: str, findings: list[Finding]) -> str: + codes = {finding.code for finding in findings} + if not findings: + return "" + if "MISSING_EVIDENCE_TRACE" in codes or "UNKNOWN_EVIDENCE_ID" in codes: + return "Hold this bullet until the source evidence IDs are added or corrected." + if "UNSUPPORTED_NUMERIC_DETAIL" in codes: + return "Replace unsupported numbers with the exact values in the traced evidence or remove the numeric detail." + revised = text + revised = re.sub(r"\bwill eliminate\b", "may reduce", revised, flags=re.I) + revised = re.sub(r"\ball\b", "similar", revised, flags=re.I) + revised = re.sub(r"\bshould now be deployed\b", "could be evaluated", revised, flags=re.I) + if revised == text: + revised = "Revise to state the claim with narrower scope and cite the supporting evidence." + return revised + + +def write_json(report: dict[str, Any], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def write_markdown(report: dict[str, Any], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + summary = report["summary"] + lines = [ + "# Executive Summary Claim Trace Report", + "", + f"- Manuscript: {report['manuscript_id']}", + f"- Total bullets: {summary['total']}", + f"- Publish: {summary['publish']}", + f"- Revise: {summary['revise']}", + f"- Block: {summary['block']}", + f"- Evidence traces: {summary['trace_count']}", + "", + "| Bullet | Decision | Findings | Rewrite |", + "| --- | --- | --- | --- |", + ] + for review in report["bullet_reviews"]: + findings = ", ".join(finding["code"] for finding in review["findings"]) or "none" + rewrite = review["rewrite"] or "" + lines.append(f"| {review['bullet_id']} | {review['decision']} | {findings} | {rewrite} |") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_svg(report: dict[str, Any], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + summary = report["summary"] + bars = [("publish", "#2f855a", summary["publish"]), ("revise", "#b7791f", summary["revise"]), ("block", "#c53030", summary["block"])] + max_count = max([count for _, _, count in bars] + [1]) + rows = [] + for index, (label, color, count) in enumerate(bars): + y = 82 + index * 58 + width = int(300 * count / max_count) + rows.append(f'{html.escape(label)}') + rows.append(f'') + rows.append(f'{count}') + svg = f""" + + Executive Summary Claim Trace + Evidence coverage for AI-generated summary bullets + {''.join(rows)} + Evidence traces: {summary['trace_count']} + +""" + path.write_text(svg, encoding="utf-8") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--input", type=Path) + group.add_argument("--sample", action="store_true") + parser.add_argument("--json", type=Path) + parser.add_argument("--markdown", type=Path) + parser.add_argument("--svg", type=Path) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + packet = load_packet(sample_path() if args.sample else args.input) + report = review_packet(packet) + if args.json: + write_json(report, args.json) + if args.markdown: + write_markdown(report, args.markdown) + if args.svg: + write_svg(report, args.svg) + summary = report["summary"] + print(f"Executive summary trace: {summary['publish']} publish, {summary['revise']} revise, {summary['block']} block.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/executive-summary-claim-trace-assistant/test_summary_claim_trace_assistant.py b/executive-summary-claim-trace-assistant/test_summary_claim_trace_assistant.py new file mode 100644 index 00000000..7c44febe --- /dev/null +++ b/executive-summary-claim-trace-assistant/test_summary_claim_trace_assistant.py @@ -0,0 +1,107 @@ +import importlib.util +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("summary_claim_trace_assistant.py") +SPEC = importlib.util.spec_from_file_location("summary_claim_trace_assistant", MODULE_PATH) +assistant = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = assistant +SPEC.loader.exec_module(assistant) + + +class ExecutiveSummaryClaimTraceAssistantTest(unittest.TestCase): + def test_sample_summary_counts(self): + report = assistant.review_packet(assistant.load_packet(assistant.sample_path())) + + self.assertEqual(report["summary"]["total"], 6) + self.assertEqual(report["summary"]["publish"], 2) + self.assertEqual(report["summary"]["revise"], 2) + self.assertEqual(report["summary"]["block"], 2) + self.assertGreaterEqual(report["summary"]["trace_count"], 5) + + def test_traced_key_finding_can_publish(self): + report = assistant.review_packet(assistant.load_packet(assistant.sample_path())) + review = next(item for item in report["bullet_reviews"] if item["bullet_id"] == "SUM-001") + + self.assertEqual(review["decision"], "publish") + self.assertEqual(review["findings"], []) + self.assertEqual(review["evidence_trace"][0]["evidence_id"], "EV-RESULT-001") + + def test_overbroad_next_step_requires_revision(self): + report = assistant.review_packet(assistant.load_packet(assistant.sample_path())) + review = next(item for item in report["bullet_reviews"] if item["bullet_id"] == "SUM-003") + codes = {finding["code"] for finding in review["findings"]} + + self.assertEqual(review["decision"], "revise") + self.assertIn("OVERBROAD_LANGUAGE", codes) + self.assertIn("could be evaluated", review["rewrite"]) + + def test_missing_or_unknown_evidence_blocks_bullet(self): + report = assistant.review_packet(assistant.load_packet(assistant.sample_path())) + missing = next(item for item in report["bullet_reviews"] if item["bullet_id"] == "SUM-004") + untraced = next(item for item in report["bullet_reviews"] if item["bullet_id"] == "SUM-005") + + self.assertEqual(missing["decision"], "block") + self.assertEqual(untraced["decision"], "block") + self.assertIn("UNKNOWN_EVIDENCE_ID", {finding["code"] for finding in missing["findings"]}) + self.assertIn("MISSING_EVIDENCE_TRACE", {finding["code"] for finding in untraced["findings"]}) + + def test_unsupported_numeric_detail_requires_revision(self): + report = assistant.review_packet(assistant.load_packet(assistant.sample_path())) + review = next(item for item in report["bullet_reviews"] if item["bullet_id"] == "SUM-006") + codes = {finding["code"] for finding in review["findings"]} + + self.assertEqual(review["decision"], "revise") + self.assertIn("UNSUPPORTED_NUMERIC_DETAIL", codes) + self.assertIn("OVERBROAD_LANGUAGE", codes) + self.assertIn("exact values", review["rewrite"]) + + def test_artifact_writers(self): + report = assistant.review_packet(assistant.load_packet(assistant.sample_path())) + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + json_path = root / "report.json" + markdown_path = root / "summary.md" + svg_path = root / "graph.svg" + + assistant.write_json(report, json_path) + assistant.write_markdown(report, markdown_path) + assistant.write_svg(report, svg_path) + + self.assertEqual(json.loads(json_path.read_text())["summary"]["total"], 6) + self.assertIn("SUM-004", markdown_path.read_text()) + self.assertIn("