#!/usr/bin/env python3 """生产补证重组装离线测试:缺口 → 新 attempt / 新快照 / 创作输入变化。""" from __future__ import annotations import sys import unittest from pathlib import Path from unittest.mock import patch PROJECT_ROOT = Path(__file__).resolve().parents[3] SKILLS_DIR = PROJECT_ROOT / ".agent" / "skills" TEST_DIR = PROJECT_ROOT / "tests" / "skills" / "写下一章" CHECK_TEST_DIR = PROJECT_ROOT / "tests" / "skills" / "核对内容一致性" SCRIPT_DIR = PROJECT_ROOT / "muse" / "content" / "work" / "skills" / "generate" / "写下一章" / "scripts" READ_CONTEXT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "context" / "skills" / "准备任务上下文" / "scripts" for path in (READ_CONTEXT_DIR, SCRIPT_DIR, TEST_DIR, CHECK_TEST_DIR): sys.path.insert(0, str(path)) from production_evidence_reassemble import ( # noqa: E402 EvidenceReassembleError, reassemble_writer_context_for_gaps, ) from run_writer_pipeline import ( # noqa: E402 InMemoryCasStateStore, PipelineError, run_writer_pipeline, ) from test_check_writer_candidate import _requirements, _valid_pair # noqa: E402 from test_run_writer_pipeline import _semantic_report, _writer_output # noqa: E402 from writer_contract import build_writer_creative_input, retrieval_identity # noqa: E402 class ProductionEvidenceReassembleTests(unittest.TestCase): def test_zero_hit_still_changes_creative_input(self) -> None: context, _ = _valid_pair() gaps = [ { "gapId": "gap-ch3-1", "query": "污染链路深度数值含义", "reason": "无正典依据", "priority": "low", } ] with patch( "production_evidence_reassemble._search_canonical_snippets", return_value=[], ): advanced = reassemble_writer_context_for_gaps(context, gaps, 2, work_id=12) self.assertEqual(advanced["runId"], context["runId"]) self.assertEqual(advanced["attempt"], 2) self.assertNotEqual( advanced["contextSnapshot"]["contextSha256"], context["contextSnapshot"]["contextSha256"], ) self.assertEqual( advanced["contextSnapshot"]["contextSha256"], retrieval_identity(advanced), ) self.assertNotEqual( build_writer_creative_input(advanced), build_writer_creative_input(context), ) styles = " ".join(advanced.get("styleConstraints") or []) self.assertNotIn("禁止写入", styles) self.assertIn("补证轮元数据", styles) self.assertIn("未解决", styles) def test_prose_hit_appends_fact_evidence(self) -> None: context, _ = _valid_pair() gaps = [{"gapId": "gap-a", "query": "徽章", "reason": "需补证", "priority": "high"}] hits = [ { "kind": "prose", "term": "徽章", "chapter": 1, "blockId": 99, "revision": 3, "start": 0, "end": 12, "text": "旧徽章属于林澈", } ] with patch( "production_evidence_reassemble._search_canonical_snippets", return_value=hits, ): advanced = reassemble_writer_context_for_gaps(context, gaps, 2, work_id=12) ids = {item["evidenceId"] for item in advanced["factEvidence"]} self.assertIn("gap-gap-a-prose-2-0", ids) styles = " ".join(advanced.get("styleConstraints") or []) self.assertIn("已追加", styles) def test_invalid_attempt_fails_closed(self) -> None: context, _ = _valid_pair() with self.assertRaises(EvidenceReassembleError): reassemble_writer_context_for_gaps( context, [{"gapId": "g1", "query": "x", "reason": "y"}], 1, work_id=12, ) def test_pipeline_evidence_hit_stops_at_authorization(self) -> None: """needs_evidence + 检索命中 -> 授权终态(重写需人授权,以新运行继续)。""" context, _ = _valid_pair() calls: list[int] = [] hits = [ { "kind": "prose", "term": "徽章", "chapter": 1, "blockId": 99, "revision": 3, "start": 0, "end": 12, "text": "旧徽章属于林澈", } ] def writer(current: dict, version: int) -> dict: calls.append(int(current["attempt"])) return _writer_output(current, version) def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: return _semantic_report( current, candidate, status="needs_evidence", gaps=[ { "gapId": "gap-1", "query": "旧徽章归属", "reason": "缺口", "priority": "high", "candidateSha256": candidate["candidateSha256"], "candidateQuote": "旧徽章", "startCodePoint": 11, "endCodePoint": 14, } ], ) def provider(current, gaps, attempt): with patch( "production_evidence_reassemble._search_canonical_snippets", return_value=hits, ): return reassemble_writer_context_for_gaps( current, gaps, attempt, work_id=int(current["workId"]) ) with self.assertRaises(PipelineError) as caught: run_writer_pipeline( context=context, requirements=_requirements(), writer=writer, evidence_provider=provider, semantic_detector=detector, state_store=InMemoryCasStateStore(), ) self.assertEqual(caught.exception.code, "AUTHORIZATION_REQUIRED") # 只跑单次收敛:写手只被调用一次,重写等人授权。 self.assertEqual(calls, [1]) self.assertEqual(caught.exception.details["nextAttempt"], 2) self.assertTrue(caught.exception.details["reassembledContextSha256"].startswith("sha256:")) def test_unfillable_gaps_pass_without_rewrite(self) -> None: """正典零命中的缺口是新设定提案:不重写,报告升格 passed,进人闸。""" context, _ = _valid_pair() calls: list[int] = [] def writer(current: dict, version: int) -> dict: calls.append(int(current["attempt"])) return _writer_output(current, version) def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: return _semantic_report( current, candidate, status="needs_evidence", gaps=[ { "gapId": "gap-ch3-01", "query": "深渊洋流完整规则", "reason": "尚无设定文档", "priority": "medium", "candidateSha256": candidate["candidateSha256"], "candidateQuote": "旧徽章", "startCodePoint": 11, "endCodePoint": 14, } ], ) def provider(current, gaps, attempt): with patch( "production_evidence_reassemble._search_canonical_snippets", return_value=[], ): return reassemble_writer_context_for_gaps( current, gaps, attempt, work_id=int(current["workId"]) ) result = run_writer_pipeline( context=context, requirements=_requirements(), writer=writer, evidence_provider=provider, semantic_detector=detector, state_store=InMemoryCasStateStore(), ) self.assertEqual(result["status"], "PASSED") self.assertEqual(calls, [1]) self.assertEqual(result.get("rewriteCount"), 0) final_report = result["trace"][-1]["semanticReport"] self.assertEqual(final_report["status"], "passed") self.assertEqual(final_report["evidenceGaps"], []) self.assertTrue(final_report["newSettingCandidates"]) if __name__ == "__main__": unittest.main()