diff --git a/.agent/docs/architecture/domains/05-创作流程领域.md b/.agent/docs/architecture/domains/05-创作流程领域.md index 9870c50..e12c8c6 100644 --- a/.agent/docs/architecture/domains/05-创作流程领域.md +++ b/.agent/docs/architecture/domains/05-创作流程领域.md @@ -262,7 +262,7 @@ DRAFT/CHECKING/PASSED -> DISCARDED(用户明确决策) 正文、规划、提取、检测和评审分别由单一职责角色执行,主代理不把多个角色合成一次模型调用。 -当前生产编排实现(`run_writer_pipeline`)的事实:机械门 → 语义检测 → 有限补证/重写环、CAS 乐观锁 + 失败收敛终态、生成篇幅与机械接受底线分层合同、评测候选四层强制;代码层的 `MAX_EVIDENCE_REQUESTS=3`、`MAX_REWRITES=2` 是技术保护,业务继续/停止的授权归人(见本节目标合同)。生产写手收到 4000–7000 汉字(目标 7000),可信适配器只拒绝不满 3001 汉字或超过 10000 汉字的异常输出;两层区间由 WriterContext 机械校验保持嵌套。 +当前生产编排实现(`run_writer_pipeline`)的事实:机械门 → 语义检测 → 单次收敛、证据缺口收敛为授权终态(AUTHORIZATION_REQUIRED,补证/重写经人授权后以新运行继续)、CAS 乐观锁 + 失败收敛终态、生成篇幅与机械接受底线分层合同、评测候选四层强制;业务继续/停止的授权归人,技术保护(超时、预算、取消)在执行策略层。生产写手收到 4000–7000 汉字(目标 7000),可信适配器只拒绝不满 3001 汉字或超过 10000 汉字的异常输出;两层区间由 WriterContext 机械校验保持嵌套。 ## 6. 运行与落库 @@ -276,7 +276,7 @@ DRAFT/CHECKING/PASSED -> DISCARDED(用户明确决策) > - 状态机持久化:**已建**——运行级 CAS 链 `example_candidate_cas`,业务态落 `example_candidate`;ARCHIVED 态未启用。 > - 结构化事实增量:**已建**——`example_fact_delta`(提案)+ `example_fact_ledger`(正典账本);模型只提六型闭集增量且必须带正文证据引文,只有用户批准的增量随正文同事务入账本,抽取结果不自动升格。 > - 待建:生产链上的质量评分环节(盲评仍只接离线评测);章后抽取的异步执行(接受时只登记 pending 投影)。 -> - 补证重组装:**已建**——语义 `needs_evidence` 时 `production_evidence_reassemble` 检索本作品 as_of 内正文/实体摘录并追加 factEvidence;命中则有限重写(技术保护上限见 §5;业务继续授权归人)。零命中不禁止写手发明新设定、不重写,缺口升格为 `newSettingCandidates`,当前候选进人闸。新设定是否进入正典由人决定;与既有正典冲突仍失败关闭。 +> - 补证重组装:**已建**——语义 `needs_evidence` 时 `production_evidence_reassemble` 检索本作品 as_of 内正文/实体摘录并追加 factEvidence;命中则停在授权终态(AUTHORIZATION_REQUIRED),重写经人授权后以新运行继续。零命中不禁止写手发明新设定、不重写,缺口升格为 `newSettingCandidates`,当前候选进人闸。新设定是否进入正典由人决定;与既有正典冲突仍失败关闭。 ## 7. 开发评测边界 diff --git a/.agent/skills/write-next-chapter/SKILL.md b/.agent/skills/write-next-chapter/SKILL.md index 929017d..990a8b6 100644 --- a/.agent/skills/write-next-chapter/SKILL.md +++ b/.agent/skills/write-next-chapter/SKILL.md @@ -34,7 +34,7 @@ Writer 不接收 `runId`、权限信息、manifest、hash、候选版本、验 2. 伏笔只按细纲动作执行:说埋就埋、说推就推、说收就收;不擅自提前回收,不新开大坑。 3. 前情衔接与上一章末场景无缝;章末钩子按文风画像的钩子风格。 4. 缺少细纲字段、`factConstraints` 字段或篇幅合同属于 adapter 输入错误,必须在模型调用前失败。`factConstraints=[]` 在冻结检索确实没有可确认事实时是合法输入,不等于“事实已验证”。写手可以在正文里设计新设定,但不得把新设定冒充已确认事实。 -5. detector 只把「对已有正典/前文章节的主张检索不够」标成 `evidenceGaps` 并触发补证重写。写手新写出的设定进 `newSettingCandidates`,不因此重写或禁写;与既有正典冲突才失败关闭。新设定是否进入正典由人决定。Writer 输出不承载补证请求或审查结论。 +5. detector 只把「对已有正典/前文章节的主张检索不够」标成 `evidenceGaps`,触发授权终态(补证/重写由人授权后以新运行继续)。写手新写出的设定进 `newSettingCandidates`,不因此重写或禁写;与既有正典冲突才失败关闭。新设定是否进入正典由人决定。Writer 输出不承载补证请求或审查结论。 ## 生产入口 @@ -42,7 +42,7 @@ Dashboard 与人工生产入口统一调用 `scripts/produce_next_chapter.py`。 ## 生产落库 -- 生产编排走 `run_writer_pipeline`(机械门→语义 detector→补证/重写有限环),状态链用 `scripts/candidate_cas.py` 的 `PostgresCasStateStore` 持久化到 `example_candidate_cas`(一次运行一条链,revision 单调,DB 触发器锁方向闭集);内存 `InMemoryCasStateStore` 仅供离线测试。 +- 生产编排走 `run_writer_pipeline`(机械门→语义 detector→单次收敛;证据缺口收敛为 AUTHORIZATION_REQUIRED 授权终态,补证/重写经人授权后以新运行继续),状态链用 `scripts/candidate_cas.py` 的 `PostgresCasStateStore` 持久化到 `example_candidate_cas`(一次运行一条链,revision 单调,DB 触发器锁方向闭集);内存 `InMemoryCasStateStore` 仅供离线测试。 - `run_writer_with_receipt()` 只负责可信 writer adapter;生产编排在组装后调用 `assemble-context/scripts/persist_context_freeze.py`,审查落库调用 `scripts/persist_writer_run.py`。 - `persist_writer_run.py` 要求本次 `run_id` 已有成功 writer 调用的 raw 指针,随后登记 `example_candidate`(传入 `semantic_report` 时校验绑定并把 `semantic_status`/`semantic_report_sha256` 固化到候选行)、追加 `example_run_receipt` 和机械/语义两行 `example_quality_result`;它不接受正文,Shadow→Canonical 仍只能由 `decide-candidate/scripts/write_canonical.py` 完成(接受通道 DB 级兜底复检 `state=passed` 且 `semantic_status=passed`)。 diff --git a/.agent/skills/write-next-chapter/scripts/produce_next_chapter.py b/.agent/skills/write-next-chapter/scripts/produce_next_chapter.py index 3b7b5df..ff9329c 100644 --- a/.agent/skills/write-next-chapter/scripts/produce_next_chapter.py +++ b/.agent/skills/write-next-chapter/scripts/produce_next_chapter.py @@ -5,7 +5,7 @@ 读已确认细纲 + 前章正文基线 → build_retrieval_plan + retrieve_writer_sources(生产仓储;新书无卡诚实返空) → assemble_context 冻结 WriterContext v1 - → run_writer_pipeline(持久 CAS 状态链 + 机械门 + 语义 detector,有限补证/重写合同) + → run_writer_pipeline(持久 CAS 状态链 + 机械门 + 语义 detector,单次收敛 + 授权终态合同) writer 真调经 run_writer_with_receipt:runtime 自动把模型输入/输出原文与调用明细原子落库; 篇幅越界在 writer 适配层自动重抽(最多三遍);语义 detector 走冻结 detector profile 真调; 语义 needs_evidence 时走 production_evidence_reassemble(检索已有正典摘录);零命中当新设定交人闸,不禁写不重写 @@ -524,6 +524,12 @@ def main(): print(f"[警告] 被拒候选留库失败: {persist_exc}", file=sys.stderr) finish_run(run_id, "failed", creator="continuation", trigger_detail={"stage": "writer-production-pipeline", "failureCode": exc.code}) + if exc.code == "AUTHORIZATION_REQUIRED": + gaps = (exc.details or {}).get("evidenceGaps") or [] + print("[需要授权] 语义检查发现证据缺口,补证或重写需要人授权:") + for item in gaps: + print(f" - {item.get('gapId')}: {item.get('reason')}(检索:{item.get('query')})") + print("授权后由主代理发起新运行继续补证(本运行已收敛 REJECTED,新运行接续候选版本)。") print(f"[停止] 生产 pipeline 未通过: code={exc.code};{exc}") print(f"复核 artifacts/{run_id}-pipeline-result.json 后决定下一步。") print(f"\nRUN_ID={run_id}") diff --git a/.agent/skills/write-next-chapter/scripts/run_writer_pipeline.py b/.agent/skills/write-next-chapter/scripts/run_writer_pipeline.py index fc333c9..99a9593 100644 --- a/.agent/skills/write-next-chapter/scripts/run_writer_pipeline.py +++ b/.agent/skills/write-next-chapter/scripts/run_writer_pipeline.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""正文候选的有限补证、重写、机械审查与 CAS 编排。""" +"""正文候选的单次收敛编排:机械审查、语义检查、授权终态与 CAS。""" from __future__ import annotations @@ -30,8 +30,6 @@ from writer_contract import ( # noqa: E402 ) -MAX_EVIDENCE_REQUESTS = 3 -MAX_REWRITES = 2 _HASH_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") @@ -440,7 +438,12 @@ def run_writer_pipeline( result_path: str | pathlib.Path | None = None, initial_candidate_version: int = 1, ) -> dict[str, Any]: - """执行最多 3 次补证、2 次重写的正文候选有限收敛循环。 + """正文候选单次收敛:写手 -> 机械门 -> 语义检查,全分支终态化。 + + 语义检查发现证据缺口时先做确定性检索(evidence_provider,只读): + 零命中升格为新设定提案进人闸;命中则收敛为 AUTHORIZATION_REQUIRED 终态, + 携带缺口报告与重组上下文哈希,由编排方在人授权后发起新运行继续重写。 + 补证/重写业务次数不设硬上限,管控归人;授权门只挡模型重调用,不挡检索。 initial_candidate_version 给生产编排接续既有版本号:候选表对 (作品, 章, candidate_version) 唯一,同一章重跑必须从「已有最大版本+1」起, @@ -529,7 +532,7 @@ def run_writer_pipeline( try: candidate = validate_writer_output(candidate) except ContractError as exc: - # 合同错误作为可定位审查失败进入有限重写,而不是绕过状态机。 + # 合同错误作为可定位审查失败进入机械门定位,而不是绕过状态机。 try: mechanical_report = check_writer_candidate(current_context, candidate, requirements) candidate_failures = _validate_mechanical_report(mechanical_report, candidate) @@ -659,36 +662,11 @@ def run_writer_pipeline( result_path=result_path, ) from exc if evidence_gaps: - if evidence_request_count + len(evidence_gaps) > MAX_EVIDENCE_REQUESTS: - raise _terminal_failure( - code="EVIDENCE_REQUEST_LIMIT_REACHED", - message="detector 证据缺口累计超过 3 次", - token=checking, - state_store=state_store, - run_id=run_id, - evidence_request_count=evidence_request_count, - rewrite_count=rewrite_count, - trace=trace, - candidate=candidate, - result_path=result_path, - ) - if rewrite_count >= MAX_REWRITES: - raise _terminal_failure( - code="REWRITE_LIMIT_REACHED", - message="补证后重写已达到 2 次", - token=checking, - state_store=state_store, - run_id=run_id, - evidence_request_count=evidence_request_count, - rewrite_count=rewrite_count, - trace=trace, - candidate=candidate, - result_path=result_path, - ) - evidence_request_count += len(evidence_gaps) + # 缺口先做确定性检索(只读、无模型开销);结果决定收敛方向: + # 零命中 = 新设定提案,升格 passed 进人闸;命中 = 需要模型重写, + # 停在授权终态等人授权,由编排方以新运行继续,不自动重写。 next_attempt = current_context["attempt"] + 1 previous_snapshot = current_context["contextSnapshot"]["contextSha256"] - previous_creative_input = build_writer_creative_input(current_context) previous_fact_ids = { str(item.get("evidenceId")) for item in (current_context.get("factEvidence") or []) @@ -719,9 +697,9 @@ def run_writer_pipeline( for item in (next_context.get("factEvidence") or []) if isinstance(item, Mapping) } - # 正典检索零命中 = 缺口是新设定提案,不是可补的检索债。 - # 无语义冲突时升格为 passed 交人闸;有冲突则不重写、走失败关闭。 if not (next_fact_ids - previous_fact_ids): + # 正典检索零命中 = 缺口是新设定提案,不是可补的检索债。 + # 无语义冲突时升格为 passed 交人闸;有冲突则不升格、走失败关闭。 if not candidate_failures and semantic_report is not None: semantic_report = _promote_unfillable_gaps_to_settings(semantic_report) evidence_gaps = [] @@ -730,7 +708,6 @@ def run_writer_pipeline( next_context["runId"] != run_id or next_context["attempt"] != next_attempt or next_context["contextSnapshot"]["contextSha256"] == previous_snapshot - or build_writer_creative_input(next_context) == previous_creative_input ): raise _terminal_failure( code="REASSEMBLED_CONTEXT_INVALID", @@ -744,28 +721,35 @@ def run_writer_pipeline( candidate=candidate, result_path=result_path, ) - rejected = _cas_or_fail( - state_store.transition(checking, "REJECTED"), "CHECKING -> REJECTED" - ) - rewrite_count += 1 - candidate_version += 1 - # 补证环的审计痕迹同样携带机械门报告,被拒候选留库不因走补证路径而丢失证据。 trace.append({ "attempt": checking.attempt, "candidateVersion": checking.candidate_version, "candidateSha256": candidate["candidateSha256"], - "status": "evidence_gap", + "status": "needs_authorization", "gapIds": [item["gapId"] for item in evidence_gaps], "mechanicalPassed": mechanical_report.get("passed", False), "mechanicalReport": dict(mechanical_report), - "semanticReport": dict(semantic_report), + "semanticReport": dict(semantic_report) if semantic_report else None, }) - current_context = next_context - draft = _cas_or_fail( - state_store.start_next(rejected, attempt=next_attempt, candidate_version=candidate_version), - "REJECTED -> next DRAFT", + raise _terminal_failure( + code="AUTHORIZATION_REQUIRED", + message="补证检索命中,重写需要人授权(以新运行继续)", + token=checking, + state_store=state_store, + run_id=run_id, + evidence_request_count=evidence_request_count, + rewrite_count=rewrite_count, + trace=trace, + candidate=candidate, + result_path=result_path, + details={ + "evidenceGaps": [dict(item) for item in evidence_gaps], + "mechanicalPassed": mechanical_report.get("passed", False), + "semanticStatus": semantic_report.get("status") if semantic_report else None, + "nextAttempt": next_attempt, + "reassembledContextSha256": next_context["contextSnapshot"]["contextSha256"], + }, ) - continue trace.append( { "attempt": checking.attempt, @@ -824,8 +808,6 @@ def run_writer_pipeline( __all__ = [ - "MAX_EVIDENCE_REQUESTS", - "MAX_REWRITES", "CasToken", "CasStateStore", "InMemoryCasStateStore", diff --git a/dashboard/server.py b/dashboard/server.py index 468d10f..351222c 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -2520,7 +2520,7 @@ def _run_decision_menu_panel(run_id: str, candidates: list, terminal_state, pipe f"写库决策请用 决策通道 :8767" f"(看板只读)

" ) - # 无候选行:本 run 失败关闭常见于 REASSEMBLED_CONTEXT_INVALID + # 无候选行:本 run 失败关闭常见于 AUTHORIZATION_REQUIRED / 语义或机械拒绝 return ( "
决策菜单
" "
" diff --git a/docs/plans/2026-08-22-阶段E-生产链切换-第一部分-授权终态.md b/docs/plans/2026-08-22-阶段E-生产链切换-第一部分-授权终态.md new file mode 100644 index 0000000..694e542 --- /dev/null +++ b/docs/plans/2026-08-22-阶段E-生产链切换-第一部分-授权终态.md @@ -0,0 +1,50 @@ +# 阶段 E:生产链切换(第一部分:授权终态合同) + +日期:2026-08-22 +总 plan:[2026-08-22-agent-example整体收敛总plan.md](2026-08-22-agent-example整体收敛总plan.md) +状态:第一部分完成并提交;第二部分(写作/检测智能体接入框架派发、授权后继续的接线、真实生产烟测)另行启动,真实模型调用需人工授权。 + +## 1. 意图 + +删除补证/重写的业务硬上限(补证≤3 / 重写≤2),改为授权终态:检查发现缺口后系统停在结构化报告,由主代理转述给人,人授权后以新运行继续。技术保护(超时、预算、取消)保留在执行策略层。 + +## 2. 合同设计(本部分核心决策) + +授权门只挡昂贵的模型重调用,不挡廉价的确定性检索: + +```text +语义检查发现证据缺口 + → 确定性检索(evidence_provider,只读、无模型开销) + ├─ 零命中 = 新设定提案 → 升格 passed 进人闸(原行为保留) + └─ 命中 = 需要模型重写 → AUTHORIZATION_REQUIRED 授权终态 + 携带:缺口清单、机械/语义检查快照、下一 attempt、重组上下文哈希 + 人授权后:编排方以新运行继续(新候选版本,会话可复用) +``` + +依据:CAS 链按运行唯一(`ON CONFLICT (run_id) DO NOTHING`),同运行不能开新轮;授权后的继续天然是新运行,与"一章复用同一写作智能体会话"通过框架会话续接(阶段 D 能力)组合。 + +## 3. 文件台账 + +| 处置 | 文件 | 原因 | +|---|---|---| +| 修改 | `run_writer_pipeline.py` | 删除 `MAX_EVIDENCE_REQUESTS/MAX_REWRITES` 常量、自动补证/重写循环与两个上限错误码;缺口分支改为"检索→零命中升格/命中授权终态";单次收敛文档化。CAS 基元(含 `start_next`)保留,状态机闭集不动 | +| 修改 | `produce_next_chapter.py` | AUTHORIZATION_REQUIRED 显式分支:输出结构化缺口报告与授权指引;模块头口径同步 | +| 修改 | `test_run_writer_pipeline.py` | 两个自动补证测试改写为授权终态合同(缺口停终态、授权报告落结果文件);provider 改为真实检索推进 | +| 修改 | `test_production_evidence_reassemble.py` | 命中重写测试改为授权终态(写手单次调用、details 带 nextAttempt 与重组上下文哈希);零命中升格测试不变 | +| 修改 | `test_candidate_cas.py` | CAS 上的补证环测试改为授权终态链断言(create→CHECKING→REJECTED,revision=3) | +| 修改 | `test_writer_acceptance.py` | 失败码 fixture 对齐(REWRITE_LIMIT_REACHED → AUTHORIZATION_REQUIRED) | +| 修改 | `05-创作流程领域`、`write-next-chapter/SKILL.md`、`dashboard/server.py` 注释 | 补证口径全仓同步(检索语义不变、命中停授权终态) | +| 保留 | `production_evidence_reassemble.py` | 确定性检索是新合同的组成部分(授权前置检索),职责不变 | +| 保留 | `start_next` 等 CAS 基元 | 总 plan 不重造状态机;续跑走新运行的 `create` 链 | +| 遗留 | 授权后继续的编排接线(新运行 + 重组上下文 + 会话续接) | 阶段 E 第二部分 | +| 遗留 | 写作/检测智能体框架派发接入 | 阶段 E 第二部分;真实烟测需人工授权 | + +## 4. 验证 + +- 管线测试:10 项通过(含授权终态 2 项新合同测试)。 +- 补证重组装测试:5 项通过(命中授权终态 + 零命中升格)。 +- CAS 测试:离线全通过;真实库集成测试(PostgresCasStateStore)全部通过。 +- 候选接受测试:通过(失败码对齐后)。 +- 全量离线清单:102 通过、8 项外部依赖阻断、0 失败。 +- 技能严格审计:58 个 Skill,阻断 0;索引一致性、架构门禁、`git diff --check` 通过。 +- 旧口径零残留:`补证≤3 / 重写≤2 / EVIDENCE_REQUEST_LIMIT / REWRITE_LIMIT` 全仓检索无生产代码引用(写手篇幅适配器的"≤3 遍重抽"是独立技术重试合同,不属于补证/重写,保留)。 diff --git a/tests/skills/decide-candidate/test_writer_acceptance.py b/tests/skills/decide-candidate/test_writer_acceptance.py index a11b70a..d8383b1 100644 --- a/tests/skills/decide-candidate/test_writer_acceptance.py +++ b/tests/skills/decide-candidate/test_writer_acceptance.py @@ -327,7 +327,7 @@ class WriterAcceptanceTest(unittest.TestCase): rejected_detector = copy.deepcopy(detector) rejected_detector["status"] = "REJECTED" - rejected_detector["failureCode"] = "REWRITE_LIMIT_REACHED" + rejected_detector["failureCode"] = "AUTHORIZATION_REQUIRED" cases.append(("DETECTOR_NOT_PASSED", candidate, rejected_detector, live)) wrong_detector_schema = copy.deepcopy(detector) diff --git a/tests/skills/write-next-chapter/test_candidate_cas.py b/tests/skills/write-next-chapter/test_candidate_cas.py index b5c99c7..98051e7 100644 --- a/tests/skills/write-next-chapter/test_candidate_cas.py +++ b/tests/skills/write-next-chapter/test_candidate_cas.py @@ -207,45 +207,46 @@ class PipelineOnPostgresStoreTest(unittest.TestCase): self.assertEqual(chain["state"], "PASSED") self.assertEqual(chain["revision"], 3) # create→CHECKING→PASSED - def test_pipeline_evidence_loop_uses_start_next(self) -> None: + def test_pipeline_evidence_hit_stops_at_authorization(self) -> None: + """新合同:补证检索命中后停在授权终态,CAS 收敛 REJECTED,不开新轮。""" context, _ = _valid_pair() rows: dict = {} def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: - if candidate["candidateVersion"] == 1: - report = { - "schemaVersion": "semantic-detection-v3", - "runId": current["runId"], "sampleId": "cas-sample", - "opaqueArmId": "cas-candidate", "inputSha256": "sha256:" + "1" * 64, - "candidateVersion": candidate["candidateVersion"], + report = { + "schemaVersion": "semantic-detection-v3", + "runId": current["runId"], "sampleId": "cas-sample", + "opaqueArmId": "cas-candidate", "inputSha256": "sha256:" + "1" * 64, + "candidateVersion": candidate["candidateVersion"], + "candidateSha256": candidate["candidateSha256"], + "contextSnapshotSha256": current["contextSnapshot"]["contextSha256"], + "modelReceiptSha256": "sha256:" + "2" * 64, "status": "needs_evidence", + "claims": [], "findings": [], "assertionVerdicts": [], + "hardConstraintVerdicts": [], "newSettingCandidates": [], + "evidenceGaps": [{ + "gapId": "gap-1", "query": "补证", "reason": "缺口", "priority": "high", "candidateSha256": candidate["candidateSha256"], - "contextSnapshotSha256": current["contextSnapshot"]["contextSha256"], - "modelReceiptSha256": "sha256:" + "2" * 64, "status": "needs_evidence", - "claims": [], "findings": [], "assertionVerdicts": [], - "hardConstraintVerdicts": [], "newSettingCandidates": [], - "evidenceGaps": [{ - "gapId": "gap-1", "query": "补证", "reason": "缺口", "priority": "high", - "candidateSha256": candidate["candidateSha256"], - "candidateQuote": "旧徽章", "startCodePoint": 11, "endCodePoint": 14, - }], - } - report["reportSha256"] = canonical_sha256(report) - return report - return _semantic_pass(current, candidate, _mechanical) + "candidateQuote": "旧徽章", "startCodePoint": 11, "endCodePoint": 14, + }], + } + report["reportSha256"] = canonical_sha256(report) + return report - result = run_writer_pipeline( - context=context, - requirements=_requirements(), - writer=lambda current, version: _writer_output(current, version), - evidence_provider=lambda current, _gaps, attempt: _advance_context(current, attempt), - semantic_detector=detector, - state_store=_store(rows), - ) - self.assertEqual(result["status"], "PASSED") + with self.assertRaises(PipelineError) as caught: + run_writer_pipeline( + context=context, + requirements=_requirements(), + writer=lambda current, version: _writer_output(current, version), + evidence_provider=lambda current, _gaps, attempt: _advance_context(current, attempt), + semantic_detector=detector, + state_store=_store(rows), + ) + self.assertEqual(caught.exception.code, "AUTHORIZATION_REQUIRED") + self.assertEqual(caught.exception.details["nextAttempt"], 2) chain = rows[context["runId"]] - # create→CHECKING→REJECTED→新DRAFT→CHECKING→PASSED + # create→CHECKING→REJECTED:授权终态不开同运行新轮,续跑是新运行的事。 self.assertEqual((chain["state"], chain["revision"], chain["attempt"], - chain["candidate_version"]), ("PASSED", 6, 2, 2)) + chain["candidate_version"]), ("REJECTED", 3, 1, 1)) if __name__ == "__main__": diff --git a/tests/skills/write-next-chapter/test_production_evidence_reassemble.py b/tests/skills/write-next-chapter/test_production_evidence_reassemble.py index c455eae..2f11e1b 100644 --- a/tests/skills/write-next-chapter/test_production_evidence_reassemble.py +++ b/tests/skills/write-next-chapter/test_production_evidence_reassemble.py @@ -21,7 +21,7 @@ from production_evidence_reassemble import ( # noqa: E402 EvidenceReassembleError, reassemble_writer_context_for_gaps, ) -from run_writer_pipeline import InMemoryCasStateStore, run_writer_pipeline # noqa: E402 +from run_writer_pipeline import InMemoryCasStateStore, PipelineError, run_writer_pipeline # noqa: E402 from writer_contract import build_writer_creative_input, retrieval_identity # noqa: E402 from test_check_writer_candidate import _requirements, _valid_pair # noqa: E402 @@ -98,8 +98,8 @@ class ProductionEvidenceReassembleTests(unittest.TestCase): work_id=12, ) - def test_pipeline_needs_evidence_then_pass_with_reassemble(self) -> None: - """needs_evidence + 检索命中 → 重写 → 再检 passed。""" + def test_pipeline_evidence_hit_stops_at_authorization(self) -> None: + """needs_evidence + 检索命中 -> 授权终态(重写需人授权,以新运行继续)。""" context, _ = _valid_pair() calls: list[int] = [] @@ -121,25 +121,23 @@ class ProductionEvidenceReassembleTests(unittest.TestCase): return _writer_output(current, version) def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: - if current["attempt"] == 1: - return _semantic_report( - current, - candidate, - status="needs_evidence", - gaps=[ - { - "gapId": "gap-1", - "query": "旧徽章归属", - "reason": "缺口", - "priority": "high", - "candidateSha256": candidate["candidateSha256"], - "candidateQuote": "旧徽章", - "startCodePoint": 11, - "endCodePoint": 14, - } - ], - ) - return _semantic_report(current, candidate, status="passed") + 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( @@ -150,17 +148,20 @@ class ProductionEvidenceReassembleTests(unittest.TestCase): 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, 2]) - self.assertGreaterEqual(result.get("rewriteCount", 0), 1) + 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,进人闸。""" diff --git a/tests/skills/write-next-chapter/test_run_writer_pipeline.py b/tests/skills/write-next-chapter/test_run_writer_pipeline.py index c3976f1..7950bd4 100644 --- a/tests/skills/write-next-chapter/test_run_writer_pipeline.py +++ b/tests/skills/write-next-chapter/test_run_writer_pipeline.py @@ -109,45 +109,45 @@ class RunWriterPipelineV3Test(unittest.TestCase): for forbidden in ("claimLedger", "evidenceRequests", "newSettingDeclarations"): self.assertNotIn(forbidden, result["candidateArtifact"]) - def test_only_detector_evidence_gaps_trigger_fresh_attempt(self) -> None: + def test_evidence_gaps_stop_at_authorization_terminal(self) -> None: + """新合同:补证检索命中后收敛为 AUTHORIZATION_REQUIRED,重写等人授权。""" context, _ = _valid_pair() - writer_calls: list[tuple[int, int, str]] = [] - detector_calls: list[int] = [] - provider_calls: list[list[str]] = [] + writer_calls: list[tuple[int, int]] = [] def writer(current: dict, version: int) -> dict: - writer_calls.append((current["attempt"], version, current["contextSnapshot"]["contextSha256"])) + writer_calls.append((current["attempt"], version)) return _writer_output(current, version) def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: - detector_calls.append(candidate["candidateVersion"]) - if candidate["candidateVersion"] == 1: - return _semantic_report(current, candidate, status="needs_evidence", gaps=[{ - "gapId": "gap-1", "query": "旧徽章来源", "reason": "证据不足", "priority": "high", - "candidateSha256": candidate["candidateSha256"], "candidateQuote": "旧徽章", - "startCodePoint": 11, "endCodePoint": 14, - }]) - return _semantic_report(current, candidate) + 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: dict, gaps: list[dict], attempt: int) -> dict: - provider_calls.append([item["gapId"] for item in gaps]) - return _advance_context(current, attempt, add_evidence=True) - - 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(writer_calls[0][:2], (1, 1)) - self.assertEqual(writer_calls[1][:2], (2, 2)) - self.assertNotEqual(writer_calls[0][2], writer_calls[1][2]) - self.assertEqual(detector_calls, [1, 2]) - self.assertEqual(provider_calls, [["gap-1"]]) - self.assertEqual(result["evidenceRequestCount"], 1) + store = InMemoryCasStateStore() + with tempfile.TemporaryDirectory() as directory: + result_path = pathlib.Path(directory) / "result.json" + with self.assertRaises(PipelineError) as caught: + run_writer_pipeline( + context=context, + requirements=_requirements(), + writer=writer, + evidence_provider=lambda current, _gaps, attempt: _advance_context(current, attempt, add_evidence=True), + semantic_detector=detector, + state_store=store, + result_path=result_path, + ) + result = json.loads(result_path.read_text(encoding="utf-8")) + self.assertEqual(caught.exception.code, "AUTHORIZATION_REQUIRED") + # 只跑单次收敛:写手只被调用一次,无自动第二轮。 + self.assertEqual(writer_calls, [(1, 1)]) + self.assertEqual(store.latest(context["runId"]).state, "REJECTED") + # 授权终态携带结构化缺口报告,供主代理转述给人。 + gaps = caught.exception.details["evidenceGaps"] + self.assertEqual([item["gapId"] for item in gaps], ["gap-1"]) + self.assertEqual(result["failureCode"], "AUTHORIZATION_REQUIRED") + self.assertEqual(result["status"], "REJECTED") def test_writer_cannot_trigger_evidence_provider(self) -> None: context, _ = _valid_pair() @@ -165,7 +165,8 @@ class RunWriterPipelineV3Test(unittest.TestCase): code="MECHANICAL_DETECTION_REJECTED", ) - def test_reassembled_context_must_have_new_snapshot_and_attempt(self) -> None: + def test_authorization_terminal_publishes_gap_report(self) -> None: + """授权终态结果文件必须带缺口与检查快照,编排方据此向人请求授权。""" context, _ = _valid_pair() def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: @@ -175,15 +176,24 @@ class RunWriterPipelineV3Test(unittest.TestCase): "startCodePoint": 11, "endCodePoint": 14, }]) - same_snapshot = copy.deepcopy(context) - same_snapshot["attempt"] = 2 - self._assert_failure( - context=context, - writer=lambda current, version: _writer_output(current, version), - detector=detector, - provider=lambda *_args: same_snapshot, - code="REASSEMBLED_CONTEXT_INVALID", - ) + store = InMemoryCasStateStore() + with tempfile.TemporaryDirectory() as directory: + result_path = pathlib.Path(directory) / "result.json" + with self.assertRaises(PipelineError) as caught: + run_writer_pipeline( + context=context, + requirements=_requirements(), + writer=lambda current, version: _writer_output(current, version), + evidence_provider=lambda current, _gaps, attempt: _advance_context(current, attempt, add_evidence=True), + semantic_detector=detector, + state_store=store, + result_path=result_path, + ) + result = json.loads(result_path.read_text(encoding="utf-8")) + self.assertEqual(caught.exception.code, "AUTHORIZATION_REQUIRED") + self.assertEqual(result["candidateSha256"], caught.exception.result["candidateSha256"]) + self.assertEqual(caught.exception.details["mechanicalPassed"], True) + self.assertEqual(caught.exception.details["semanticStatus"], "needs_evidence") def test_old_semantic_v2_report_fails_closed(self) -> None: context, _ = _valid_pair()