生产链: 补证/重写删除业务硬上限,改为授权终态(阶段E第一部分)

This commit is contained in:
zizi 2026-08-23 01:26:03 +08:00
parent 860771a28e
commit fabcd4ff91
10 changed files with 214 additions and 164 deletions

View File

@ -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. 运行与落库 ## 6. 运行与落库
@ -276,7 +276,7 @@ DRAFT/CHECKING/PASSED -> DISCARDED(用户明确决策)
> - 状态机持久化:**已建**——运行级 CAS 链 `example_candidate_cas`,业务态落 `example_candidate`;ARCHIVED 态未启用。 > - 状态机持久化:**已建**——运行级 CAS 链 `example_candidate_cas`,业务态落 `example_candidate`;ARCHIVED 态未启用。
> - 结构化事实增量:**已建**——`example_fact_delta`(提案)+ `example_fact_ledger`(正典账本);模型只提六型闭集增量且必须带正文证据引文,只有用户批准的增量随正文同事务入账本,抽取结果不自动升格。 > - 结构化事实增量:**已建**——`example_fact_delta`(提案)+ `example_fact_ledger`(正典账本);模型只提六型闭集增量且必须带正文证据引文,只有用户批准的增量随正文同事务入账本,抽取结果不自动升格。
> - 待建:生产链上的质量评分环节(盲评仍只接离线评测);章后抽取的异步执行(接受时只登记 pending 投影)。 > - 待建:生产链上的质量评分环节(盲评仍只接离线评测);章后抽取的异步执行(接受时只登记 pending 投影)。
> - 补证重组装:**已建**——语义 `needs_evidence` 时 `production_evidence_reassemble` 检索本作品 as_of 内正文/实体摘录并追加 factEvidence;命中则有限重写(技术保护上限见 §5;业务继续授权归人)。零命中不禁止写手发明新设定、不重写,缺口升格为 `newSettingCandidates`,当前候选进人闸。新设定是否进入正典由人决定;与既有正典冲突仍失败关闭。 > - 补证重组装:**已建**——语义 `needs_evidence` 时 `production_evidence_reassemble` 检索本作品 as_of 内正文/实体摘录并追加 factEvidence;命中则停在授权终态(AUTHORIZATION_REQUIRED),重写经人授权后以新运行继续。零命中不禁止写手发明新设定、不重写,缺口升格为 `newSettingCandidates`,当前候选进人闸。新设定是否进入正典由人决定;与既有正典冲突仍失败关闭。
## 7. 开发评测边界 ## 7. 开发评测边界

View File

@ -34,7 +34,7 @@ Writer 不接收 `runId`、权限信息、manifest、hash、候选版本、验
2. 伏笔只按细纲动作执行:说埋就埋、说推就推、说收就收;不擅自提前回收,不新开大坑。 2. 伏笔只按细纲动作执行:说埋就埋、说推就推、说收就收;不擅自提前回收,不新开大坑。
3. 前情衔接与上一章末场景无缝;章末钩子按文风画像的钩子风格。 3. 前情衔接与上一章末场景无缝;章末钩子按文风画像的钩子风格。
4. 缺少细纲字段、`factConstraints` 字段或篇幅合同属于 adapter 输入错误,必须在模型调用前失败。`factConstraints=[]` 在冻结检索确实没有可确认事实时是合法输入,不等于“事实已验证”。写手可以在正文里设计新设定,但不得把新设定冒充已确认事实。 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`。 - `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`)。 - `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`)。

View File

@ -5,7 +5,7 @@
读已确认细纲 + 前章正文基线 读已确认细纲 + 前章正文基线
→ build_retrieval_plan + retrieve_writer_sources(生产仓储;新书无卡诚实返空) → build_retrieval_plan + retrieve_writer_sources(生产仓储;新书无卡诚实返空)
→ assemble_context 冻结 WriterContext v1 → assemble_context 冻结 WriterContext v1
→ run_writer_pipeline(持久 CAS 状态链 + 机械门 + 语义 detector,有限补证/重写合同) → run_writer_pipeline(持久 CAS 状态链 + 机械门 + 语义 detector,单次收敛 + 授权终态合同)
writer 真调经 run_writer_with_receipt:runtime 自动把模型输入/输出原文与调用明细原子落库; writer 真调经 run_writer_with_receipt:runtime 自动把模型输入/输出原文与调用明细原子落库;
篇幅越界在 writer 适配层自动重抽(最多三遍);语义 detector 走冻结 detector profile 真调; 篇幅越界在 writer 适配层自动重抽(最多三遍);语义 detector 走冻结 detector profile 真调;
语义 needs_evidence 时走 production_evidence_reassemble(检索已有正典摘录);零命中当新设定交人闸,不禁写不重写 语义 needs_evidence 时走 production_evidence_reassemble(检索已有正典摘录);零命中当新设定交人闸,不禁写不重写
@ -524,6 +524,12 @@ def main():
print(f"[警告] 被拒候选留库失败: {persist_exc}", file=sys.stderr) print(f"[警告] 被拒候选留库失败: {persist_exc}", file=sys.stderr)
finish_run(run_id, "failed", creator="continuation", finish_run(run_id, "failed", creator="continuation",
trigger_detail={"stage": "writer-production-pipeline", "failureCode": exc.code}) 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"[停止] 生产 pipeline 未通过: code={exc.code};{exc}")
print(f"复核 artifacts/{run_id}-pipeline-result.json 后决定下一步。") print(f"复核 artifacts/{run_id}-pipeline-result.json 后决定下一步。")
print(f"\nRUN_ID={run_id}") print(f"\nRUN_ID={run_id}")

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""正文候选的有限补证、重写、机械审查与 CAS 编排。""" """正文候选的单次收敛编排:机械审查、语义检查、授权终态与 CAS。"""
from __future__ import annotations 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}$") _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, result_path: str | pathlib.Path | None = None,
initial_candidate_version: int = 1, initial_candidate_version: int = 1,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""执行最多 3 次补证、2 次重写的正文候选有限收敛循环。 """正文候选单次收敛:写手 -> 机械门 -> 语义检查,全分支终态化。
语义检查发现证据缺口时先做确定性检索(evidence_provider,只读):
零命中升格为新设定提案进人闸;命中则收敛为 AUTHORIZATION_REQUIRED 终态,
携带缺口报告与重组上下文哈希,由编排方在人授权后发起新运行继续重写。
补证/重写业务次数不设硬上限,管控归人;授权门只挡模型重调用,不挡检索。
initial_candidate_version 给生产编排接续既有版本号:候选表对 initial_candidate_version 给生产编排接续既有版本号:候选表对
(作品, 章, candidate_version) 唯一,同一章重跑必须从「已有最大版本+1」起, (作品, 章, candidate_version) 唯一,同一章重跑必须从「已有最大版本+1」起,
@ -529,7 +532,7 @@ def run_writer_pipeline(
try: try:
candidate = validate_writer_output(candidate) candidate = validate_writer_output(candidate)
except ContractError as exc: except ContractError as exc:
# 合同错误作为可定位审查失败进入有限重写,而不是绕过状态机。 # 合同错误作为可定位审查失败进入机械门定位,而不是绕过状态机。
try: try:
mechanical_report = check_writer_candidate(current_context, candidate, requirements) mechanical_report = check_writer_candidate(current_context, candidate, requirements)
candidate_failures = _validate_mechanical_report(mechanical_report, candidate) candidate_failures = _validate_mechanical_report(mechanical_report, candidate)
@ -659,36 +662,11 @@ def run_writer_pipeline(
result_path=result_path, result_path=result_path,
) from exc ) from exc
if evidence_gaps: if evidence_gaps:
if evidence_request_count + len(evidence_gaps) > MAX_EVIDENCE_REQUESTS: # 缺口先做确定性检索(只读、无模型开销);结果决定收敛方向:
raise _terminal_failure( # 零命中 = 新设定提案,升格 passed 进人闸;命中 = 需要模型重写,
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)
next_attempt = current_context["attempt"] + 1 next_attempt = current_context["attempt"] + 1
previous_snapshot = current_context["contextSnapshot"]["contextSha256"] previous_snapshot = current_context["contextSnapshot"]["contextSha256"]
previous_creative_input = build_writer_creative_input(current_context)
previous_fact_ids = { previous_fact_ids = {
str(item.get("evidenceId")) str(item.get("evidenceId"))
for item in (current_context.get("factEvidence") or []) for item in (current_context.get("factEvidence") or [])
@ -719,9 +697,9 @@ def run_writer_pipeline(
for item in (next_context.get("factEvidence") or []) for item in (next_context.get("factEvidence") or [])
if isinstance(item, Mapping) if isinstance(item, Mapping)
} }
# 正典检索零命中 = 缺口是新设定提案,不是可补的检索债。
# 无语义冲突时升格为 passed 交人闸;有冲突则不重写、走失败关闭。
if not (next_fact_ids - previous_fact_ids): if not (next_fact_ids - previous_fact_ids):
# 正典检索零命中 = 缺口是新设定提案,不是可补的检索债。
# 无语义冲突时升格为 passed 交人闸;有冲突则不升格、走失败关闭。
if not candidate_failures and semantic_report is not None: if not candidate_failures and semantic_report is not None:
semantic_report = _promote_unfillable_gaps_to_settings(semantic_report) semantic_report = _promote_unfillable_gaps_to_settings(semantic_report)
evidence_gaps = [] evidence_gaps = []
@ -730,7 +708,6 @@ def run_writer_pipeline(
next_context["runId"] != run_id next_context["runId"] != run_id
or next_context["attempt"] != next_attempt or next_context["attempt"] != next_attempt
or next_context["contextSnapshot"]["contextSha256"] == previous_snapshot or next_context["contextSnapshot"]["contextSha256"] == previous_snapshot
or build_writer_creative_input(next_context) == previous_creative_input
): ):
raise _terminal_failure( raise _terminal_failure(
code="REASSEMBLED_CONTEXT_INVALID", code="REASSEMBLED_CONTEXT_INVALID",
@ -744,28 +721,35 @@ def run_writer_pipeline(
candidate=candidate, candidate=candidate,
result_path=result_path, result_path=result_path,
) )
rejected = _cas_or_fail(
state_store.transition(checking, "REJECTED"), "CHECKING -> REJECTED"
)
rewrite_count += 1
candidate_version += 1
# 补证环的审计痕迹同样携带机械门报告,被拒候选留库不因走补证路径而丢失证据。
trace.append({ trace.append({
"attempt": checking.attempt, "attempt": checking.attempt,
"candidateVersion": checking.candidate_version, "candidateVersion": checking.candidate_version,
"candidateSha256": candidate["candidateSha256"], "candidateSha256": candidate["candidateSha256"],
"status": "evidence_gap", "status": "needs_authorization",
"gapIds": [item["gapId"] for item in evidence_gaps], "gapIds": [item["gapId"] for item in evidence_gaps],
"mechanicalPassed": mechanical_report.get("passed", False), "mechanicalPassed": mechanical_report.get("passed", False),
"mechanicalReport": dict(mechanical_report), "mechanicalReport": dict(mechanical_report),
"semanticReport": dict(semantic_report), "semanticReport": dict(semantic_report) if semantic_report else None,
}) })
current_context = next_context raise _terminal_failure(
draft = _cas_or_fail( code="AUTHORIZATION_REQUIRED",
state_store.start_next(rejected, attempt=next_attempt, candidate_version=candidate_version), message="补证检索命中,重写需要人授权(以新运行继续)",
"REJECTED -> next DRAFT", 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( trace.append(
{ {
"attempt": checking.attempt, "attempt": checking.attempt,
@ -824,8 +808,6 @@ def run_writer_pipeline(
__all__ = [ __all__ = [
"MAX_EVIDENCE_REQUESTS",
"MAX_REWRITES",
"CasToken", "CasToken",
"CasStateStore", "CasStateStore",
"InMemoryCasStateStore", "InMemoryCasStateStore",

View File

@ -2520,7 +2520,7 @@ def _run_decision_menu_panel(run_id: str, candidates: list, terminal_state, pipe
f"写库决策请用 <a href='http://127.0.0.1:8767/candidates/{esc(cid)}'>决策通道 :8767</a>" f"写库决策请用 <a href='http://127.0.0.1:8767/candidates/{esc(cid)}'>决策通道 :8767</a>"
f"(看板只读)</p></div>" f"(看板只读)</p></div>"
) )
# 无候选行:本 run 失败关闭常见于 REASSEMBLED_CONTEXT_INVALID # 无候选行:本 run 失败关闭常见于 AUTHORIZATION_REQUIRED / 语义或机械拒绝
return ( return (
"<div class='card' style='border-color:var(--serious,#a40)'><div class='hd'>决策菜单</div>" "<div class='card' style='border-color:var(--serious,#a40)'><div class='hd'>决策菜单</div>"
"<div style='padding:12px 16px'>" "<div style='padding:12px 16px'>"

View File

@ -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 遍重抽"是独立技术重试合同,不属于补证/重写,保留)。

View File

@ -327,7 +327,7 @@ class WriterAcceptanceTest(unittest.TestCase):
rejected_detector = copy.deepcopy(detector) rejected_detector = copy.deepcopy(detector)
rejected_detector["status"] = "REJECTED" rejected_detector["status"] = "REJECTED"
rejected_detector["failureCode"] = "REWRITE_LIMIT_REACHED" rejected_detector["failureCode"] = "AUTHORIZATION_REQUIRED"
cases.append(("DETECTOR_NOT_PASSED", candidate, rejected_detector, live)) cases.append(("DETECTOR_NOT_PASSED", candidate, rejected_detector, live))
wrong_detector_schema = copy.deepcopy(detector) wrong_detector_schema = copy.deepcopy(detector)

View File

@ -207,45 +207,46 @@ class PipelineOnPostgresStoreTest(unittest.TestCase):
self.assertEqual(chain["state"], "PASSED") self.assertEqual(chain["state"], "PASSED")
self.assertEqual(chain["revision"], 3) # create→CHECKING→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() context, _ = _valid_pair()
rows: dict = {} rows: dict = {}
def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: def detector(current: dict, candidate: dict, _mechanical: dict) -> dict:
if candidate["candidateVersion"] == 1: report = {
report = { "schemaVersion": "semantic-detection-v3",
"schemaVersion": "semantic-detection-v3", "runId": current["runId"], "sampleId": "cas-sample",
"runId": current["runId"], "sampleId": "cas-sample", "opaqueArmId": "cas-candidate", "inputSha256": "sha256:" + "1" * 64,
"opaqueArmId": "cas-candidate", "inputSha256": "sha256:" + "1" * 64, "candidateVersion": candidate["candidateVersion"],
"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"], "candidateSha256": candidate["candidateSha256"],
"contextSnapshotSha256": current["contextSnapshot"]["contextSha256"], "candidateQuote": "旧徽章", "startCodePoint": 11, "endCodePoint": 14,
"modelReceiptSha256": "sha256:" + "2" * 64, "status": "needs_evidence", }],
"claims": [], "findings": [], "assertionVerdicts": [], }
"hardConstraintVerdicts": [], "newSettingCandidates": [], report["reportSha256"] = canonical_sha256(report)
"evidenceGaps": [{ return report
"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)
result = run_writer_pipeline( with self.assertRaises(PipelineError) as caught:
context=context, run_writer_pipeline(
requirements=_requirements(), context=context,
writer=lambda current, version: _writer_output(current, version), requirements=_requirements(),
evidence_provider=lambda current, _gaps, attempt: _advance_context(current, attempt), writer=lambda current, version: _writer_output(current, version),
semantic_detector=detector, evidence_provider=lambda current, _gaps, attempt: _advance_context(current, attempt),
state_store=_store(rows), semantic_detector=detector,
) state_store=_store(rows),
self.assertEqual(result["status"], "PASSED") )
self.assertEqual(caught.exception.code, "AUTHORIZATION_REQUIRED")
self.assertEqual(caught.exception.details["nextAttempt"], 2)
chain = rows[context["runId"]] chain = rows[context["runId"]]
# create→CHECKING→REJECTED→新DRAFT→CHECKING→PASSED # create→CHECKING→REJECTED:授权终态不开同运行新轮,续跑是新运行的事。
self.assertEqual((chain["state"], chain["revision"], chain["attempt"], 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__": if __name__ == "__main__":

View File

@ -21,7 +21,7 @@ from production_evidence_reassemble import ( # noqa: E402
EvidenceReassembleError, EvidenceReassembleError,
reassemble_writer_context_for_gaps, 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 writer_contract import build_writer_creative_input, retrieval_identity # noqa: E402
from test_check_writer_candidate import _requirements, _valid_pair # noqa: E402 from test_check_writer_candidate import _requirements, _valid_pair # noqa: E402
@ -98,8 +98,8 @@ class ProductionEvidenceReassembleTests(unittest.TestCase):
work_id=12, work_id=12,
) )
def test_pipeline_needs_evidence_then_pass_with_reassemble(self) -> None: def test_pipeline_evidence_hit_stops_at_authorization(self) -> None:
"""needs_evidence + 检索命中 → 重写 → 再检 passed。""" """needs_evidence + 检索命中 -> 授权终态(重写需人授权,以新运行继续)。"""
context, _ = _valid_pair() context, _ = _valid_pair()
calls: list[int] = [] calls: list[int] = []
@ -121,25 +121,23 @@ class ProductionEvidenceReassembleTests(unittest.TestCase):
return _writer_output(current, version) return _writer_output(current, version)
def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: def detector(current: dict, candidate: dict, _mechanical: dict) -> dict:
if current["attempt"] == 1: return _semantic_report(
return _semantic_report( current,
current, candidate,
candidate, status="needs_evidence",
status="needs_evidence", gaps=[
gaps=[ {
{ "gapId": "gap-1",
"gapId": "gap-1", "query": "旧徽章归属",
"query": "旧徽章归属", "reason": "缺口",
"reason": "缺口", "priority": "high",
"priority": "high", "candidateSha256": candidate["candidateSha256"],
"candidateSha256": candidate["candidateSha256"], "candidateQuote": "旧徽章",
"candidateQuote": "旧徽章", "startCodePoint": 11,
"startCodePoint": 11, "endCodePoint": 14,
"endCodePoint": 14, }
} ],
], )
)
return _semantic_report(current, candidate, status="passed")
def provider(current, gaps, attempt): def provider(current, gaps, attempt):
with patch( with patch(
@ -150,17 +148,20 @@ class ProductionEvidenceReassembleTests(unittest.TestCase):
current, gaps, attempt, work_id=int(current["workId"]) current, gaps, attempt, work_id=int(current["workId"])
) )
result = run_writer_pipeline( with self.assertRaises(PipelineError) as caught:
context=context, run_writer_pipeline(
requirements=_requirements(), context=context,
writer=writer, requirements=_requirements(),
evidence_provider=provider, writer=writer,
semantic_detector=detector, evidence_provider=provider,
state_store=InMemoryCasStateStore(), semantic_detector=detector,
) state_store=InMemoryCasStateStore(),
self.assertEqual(result["status"], "PASSED") )
self.assertEqual(calls, [1, 2]) self.assertEqual(caught.exception.code, "AUTHORIZATION_REQUIRED")
self.assertGreaterEqual(result.get("rewriteCount", 0), 1) # 只跑单次收敛:写手只被调用一次,重写等人授权。
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: def test_unfillable_gaps_pass_without_rewrite(self) -> None:
"""正典零命中的缺口是新设定提案:不重写,报告升格 passed,进人闸。""" """正典零命中的缺口是新设定提案:不重写,报告升格 passed,进人闸。"""

View File

@ -109,45 +109,45 @@ class RunWriterPipelineV3Test(unittest.TestCase):
for forbidden in ("claimLedger", "evidenceRequests", "newSettingDeclarations"): for forbidden in ("claimLedger", "evidenceRequests", "newSettingDeclarations"):
self.assertNotIn(forbidden, result["candidateArtifact"]) 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() context, _ = _valid_pair()
writer_calls: list[tuple[int, int, str]] = [] writer_calls: list[tuple[int, int]] = []
detector_calls: list[int] = []
provider_calls: list[list[str]] = []
def writer(current: dict, version: int) -> dict: 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) return _writer_output(current, version)
def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: def detector(current: dict, candidate: dict, _mechanical: dict) -> dict:
detector_calls.append(candidate["candidateVersion"]) return _semantic_report(current, candidate, status="needs_evidence", gaps=[{
if candidate["candidateVersion"] == 1: "gapId": "gap-1", "query": "旧徽章来源", "reason": "证据不足", "priority": "high",
return _semantic_report(current, candidate, status="needs_evidence", gaps=[{ "candidateSha256": candidate["candidateSha256"], "candidateQuote": "旧徽章",
"gapId": "gap-1", "query": "旧徽章来源", "reason": "证据不足", "priority": "high", "startCodePoint": 11, "endCodePoint": 14,
"candidateSha256": candidate["candidateSha256"], "candidateQuote": "旧徽章", }])
"startCodePoint": 11, "endCodePoint": 14,
}])
return _semantic_report(current, candidate)
def provider(current: dict, gaps: list[dict], attempt: int) -> dict: store = InMemoryCasStateStore()
provider_calls.append([item["gapId"] for item in gaps]) with tempfile.TemporaryDirectory() as directory:
return _advance_context(current, attempt, add_evidence=True) result_path = pathlib.Path(directory) / "result.json"
with self.assertRaises(PipelineError) as caught:
result = run_writer_pipeline( run_writer_pipeline(
context=context, context=context,
requirements=_requirements(), requirements=_requirements(),
writer=writer, writer=writer,
evidence_provider=provider, evidence_provider=lambda current, _gaps, attempt: _advance_context(current, attempt, add_evidence=True),
semantic_detector=detector, semantic_detector=detector,
state_store=InMemoryCasStateStore(), state_store=store,
) result_path=result_path,
self.assertEqual(result["status"], "PASSED") )
self.assertEqual(writer_calls[0][:2], (1, 1)) result = json.loads(result_path.read_text(encoding="utf-8"))
self.assertEqual(writer_calls[1][:2], (2, 2)) self.assertEqual(caught.exception.code, "AUTHORIZATION_REQUIRED")
self.assertNotEqual(writer_calls[0][2], writer_calls[1][2]) # 只跑单次收敛:写手只被调用一次,无自动第二轮。
self.assertEqual(detector_calls, [1, 2]) self.assertEqual(writer_calls, [(1, 1)])
self.assertEqual(provider_calls, [["gap-1"]]) self.assertEqual(store.latest(context["runId"]).state, "REJECTED")
self.assertEqual(result["evidenceRequestCount"], 1) # 授权终态携带结构化缺口报告,供主代理转述给人。
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: def test_writer_cannot_trigger_evidence_provider(self) -> None:
context, _ = _valid_pair() context, _ = _valid_pair()
@ -165,7 +165,8 @@ class RunWriterPipelineV3Test(unittest.TestCase):
code="MECHANICAL_DETECTION_REJECTED", 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() context, _ = _valid_pair()
def detector(current: dict, candidate: dict, _mechanical: dict) -> dict: def detector(current: dict, candidate: dict, _mechanical: dict) -> dict:
@ -175,15 +176,24 @@ class RunWriterPipelineV3Test(unittest.TestCase):
"startCodePoint": 11, "endCodePoint": 14, "startCodePoint": 11, "endCodePoint": 14,
}]) }])
same_snapshot = copy.deepcopy(context) store = InMemoryCasStateStore()
same_snapshot["attempt"] = 2 with tempfile.TemporaryDirectory() as directory:
self._assert_failure( result_path = pathlib.Path(directory) / "result.json"
context=context, with self.assertRaises(PipelineError) as caught:
writer=lambda current, version: _writer_output(current, version), run_writer_pipeline(
detector=detector, context=context,
provider=lambda *_args: same_snapshot, requirements=_requirements(),
code="REASSEMBLED_CONTEXT_INVALID", 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: def test_old_semantic_v2_report_fails_closed(self) -> None:
context, _ = _valid_pair() context, _ = _valid_pair()