From 5e5ff2e9c4e0f6538d4a6deecba653e7df2145df Mon Sep 17 00:00:00 2001 From: zizi Date: Sat, 19 Sep 2026 22:18:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=8F=90=E7=A4=BA=E8=AF=8D):=20=E6=89=B9?= =?UTF-8?q?=E6=AC=A13=E5=86=99=E6=89=8B=E9=98=BB=E5=A1=9E=E5=87=BA?= =?UTF-8?q?=E5=8F=A3=E2=80=94=E2=80=94=E8=BE=93=E5=87=BA=E5=90=88=E5=90=8C?= =?UTF-8?q?oneOf=E4=BA=8C=E9=80=89=E4=B8=80=E4=B8=8E=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E7=82=B9=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 写作输出合同增加 blocked 出口(仅 reason,写手纯写作负荷原则): oneOf paragraphs/blocked 二选一,双现或全缺由 schema 机械拒绝; LF/CR 换行语义移入合同 description(随装配进系统提示,模板减负) - 生成正文.md 输出段改为"输出与阻塞",阻塞出口说明 ≤2 行且注明 不得用于回避普通难度 - 生产消费:无工具写作步骤 blocked 分流——不登记方法消费、不产候选, 以 WRITING_FLOW_INVALID 明确失败呈现 reason(blocked 载荷已随 模型结构化交付留痕) - 评测消费:成对比较(逐例执行._比较材料)与语义检测(读取检测材料) 在读 paragraphs 前分流,blocked 单元明确失败不进比较/检测,杜绝 KeyError - 测试:契约四态(合法blocked/双现/全缺/越权字段);两阶段写手 blocked 集成用例(任务失败于无工具写作、无候选无消费);评测分流单测 证据:离线全量1155项通过;两阶段写手6项、回放资料封存12项、评测 收敛25项、受控模型调用15项数据库用例通过;模型任务备份恢复用例本体 通过(teardown 连接错误在 main 基线同样出现,属本机克隆库存量环境 问题,非本次回归)。合同哈希变更对跨版本存量任务恢复为明确失败 (失败关闭),与回放链路现行测试一致。 --- src/muse/效果评测/语义检测.py | 2 + src/muse/效果评测/逐例执行.py | 2 + src/muse/正文写作/写作任务.py | 18 +++++++- src/muse/正文写作/提示词/生成正文.md | 8 ++-- src/muse/编排/生成正文.py | 4 ++ tests/单元/test_评测阻塞分流.py | 17 +++++++ tests/契约/test_正文与模型输出.py | 31 +++++++++++++ tests/用例清单.json | 68 +++++++++++++++++++++++++++ tests/集成/test_两阶段写手.py | 69 ++++++++++++++++++++++++++++ 9 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 tests/单元/test_评测阻塞分流.py diff --git a/src/muse/效果评测/语义检测.py b/src/muse/效果评测/语义检测.py index 124a952..0674485 100644 --- a/src/muse/效果评测/语义检测.py +++ b/src/muse/效果评测/语义检测.py @@ -11,6 +11,8 @@ from muse.正式变更.接口 import 固定哈希 def 读取检测材料(conn, spec, writer): if writer is None or len(spec["dependencies"]) != 1: raise 评测错误("语义检测缺少单份实际生成交付") + if "blocked" in (writer["output"] or {}): + raise 评测错误("语义检测依赖的写手声明阻塞,无正文可检") text = "\n".join(p["text"] for p in writer["output"]["paragraphs"]) return 组装语义检测材料(text, 读取文学依据(conn, spec)) diff --git a/src/muse/效果评测/逐例执行.py b/src/muse/效果评测/逐例执行.py index c85809a..203c91e 100644 --- a/src/muse/效果评测/逐例执行.py +++ b/src/muse/效果评测/逐例执行.py @@ -930,6 +930,8 @@ def _比较材料(conn, spec, writers): return 组装成对材料(left, right, tuple(spec["dimensions"])) if len(writers) != 2 or any(w is None for w in writers): raise 评测错误("成对比较缺少两侧真实生成交付") + if any("blocked" in (w["output"] or {}) for w in writers): + raise 评测错误("成对比较的写手声明阻塞,本单元无正文可比") if spec.get("comparison_contract") not in {None, "quote-only-v2", "writer-rubric-v1"}: raise 评测错误("成对比较的冻结合同版本未知") texts = ["\n".join(p["text"] for p in w["output"]["paragraphs"]) for w in writers] diff --git a/src/muse/正文写作/写作任务.py b/src/muse/正文写作/写作任务.py index b6f864f..1e162f5 100644 --- a/src/muse/正文写作/写作任务.py +++ b/src/muse/正文写作/写作任务.py @@ -143,6 +143,10 @@ _写作输出合同 = { "type": "object", "properties": { "paragraphs": { + "description": ( + "正文段落数组;每项 text 是完整段落文本,不能包含 LF 或 CR 换行符," + "需要另起一段时另建一个段落对象;系统不改写或规范化段内换行。" + ), "type": "array", "minItems": 1, "items": { @@ -153,9 +157,19 @@ _写作输出合同 = { "required": ["text"], "additionalProperties": False, }, - } + }, + "blocked": { + "description": ( + "写作死结出口:仅当细纲与前置事实存在不可化解冲突" + "(任何写法都必然虚构冲突事实)时输出,reason 一句话说明。" + ), + "type": "object", + "properties": {"reason": {"type": "string", "minLength": 1}}, + "required": ["reason"], + "additionalProperties": False, + }, }, - "required": ["paragraphs"], + "oneOf": [{"required": ["paragraphs"]}, {"required": ["blocked"]}], "additionalProperties": False, } diff --git a/src/muse/正文写作/提示词/生成正文.md b/src/muse/正文写作/提示词/生成正文.md index a806a22..78c58bc 100644 --- a/src/muse/正文写作/提示词/生成正文.md +++ b/src/muse/正文写作/提示词/生成正文.md @@ -17,9 +17,7 @@ 6. 词表命中只作观察线索,判断是否偏移须给出文本依据,不能直接判为缺陷。 7. 输出只包含正文段落,不含大纲、说明、注释或元信息。 -## 输出 +## 输出与阻塞 -输出一个 JSON 对象:`{"paragraphs": [{"text": "段落文本"}, ...]}`。 - -- 每项 `text` 是完整段落文本,不能包含 LF 或 CR 换行符;需要另起一段时另建一个段落对象。系统不会改写或规范化段内换行。 -- 段落顺序即正文顺序;不输出段落标题。 +- 正常输出一个 JSON 对象:`{"paragraphs": [{"text": "段落文本"}, ...]}`;段落顺序即正文顺序,不输出段落标题。 +- 仅当细纲与前置事实材料存在不可化解的死结(任何写法都必然虚构冲突事实)时,不写正文,输出 `{"blocked": {"reason": "一句话说明"}}`;不用它回避普通难度。 diff --git a/src/muse/编排/生成正文.py b/src/muse/编排/生成正文.py index 8a81f66..7307f7b 100644 --- a/src/muse/编排/生成正文.py +++ b/src/muse/编排/生成正文.py @@ -385,6 +385,10 @@ def 登记生成正文(登记: 流程登记, 装配, 计价) -> None: ), ) ) + # 写作死结出口:blocked 载荷已随模型结构化交付留痕(含 reason); + # 此处不登记方法消费、不产出候选,以编排内明确失败把理由呈现给作者。 + if "blocked" in 交付.内容: + raise 生成正文错误(f"写手声明写作死结:{交付.内容['blocked']['reason']}") # 只有真实写作交付后才登记消费,并关联模型调用;冻结不自动产生消费。 from muse.知识方法.接口 import 消费输入, 记录消费 diff --git a/tests/单元/test_评测阻塞分流.py b/tests/单元/test_评测阻塞分流.py new file mode 100644 index 0000000..89279ca --- /dev/null +++ b/tests/单元/test_评测阻塞分流.py @@ -0,0 +1,17 @@ +"""写手阻塞(blocked)交付在评测消费点的分流:不进成对比较、不做语义检测。""" + +import pytest + +from muse.效果评测.模型 import 评测错误 +from muse.效果评测.语义检测 import 读取检测材料 +from muse.效果评测.逐例执行 import _比较材料 + + +@pytest.mark.case_id("NC-eval-blocked-writer-no-material") +def test_写手阻塞交付在比较与检测材料处明确失败(): + 阻塞写手 = {"output": {"blocked": {"reason": "死结"}}} + 正常写手 = {"output": {"paragraphs": [{"text": "雨。"}]}} + with pytest.raises(评测错误, match="语义检测依赖的写手声明阻塞"): + 读取检测材料(None, {"dependencies": ["u1"]}, 阻塞写手) + with pytest.raises(评测错误, match="成对比较的写手声明阻塞"): + _比较材料(None, {"dependencies": ["a", "b"]}, [正常写手, 阻塞写手]) diff --git a/tests/契约/test_正文与模型输出.py b/tests/契约/test_正文与模型输出.py index 9c03c6a..3d5b278 100644 --- a/tests/契约/test_正文与模型输出.py +++ b/tests/契约/test_正文与模型输出.py @@ -225,3 +225,34 @@ def test_领域写作粒度兼容而新生成入口仅接受新章__e13005(): assert 写作任务合同(**领域, 粒度="expand_scene").选区 == () with pytest.raises(写作任务错误, match="选段修改必须携带明确选区"): 写作任务合同(**领域, 粒度="edit_selection") + + +@pytest.mark.case_id( + "NC-b05-writer-output-blocked-or-paragraphs", + when="写手输出只接受正文或阻塞二选一", + given="输出合同含 paragraphs/blocked oneOf 出口", +) +def test_写手输出接受阻塞出口且拒绝双现全缺__e13007(): + from dataclasses import replace + + from jsonschema import Draft202012Validator + + from muse.任务运行.执行合同 import 模型协议错误, 模型结果, 模型请求 + from muse.任务运行.模型调用 import 校验模型输出 + from muse.正文写作.接口 import 写作输出合同 + + 合同 = 写作输出合同() + 请求 = 模型请求("call", "synthetic", "model", "写作", "材料", 合同, 200, 10) + 阻塞 = {"blocked": {"reason": "细纲第2事件与已确认事实冲突,任何写法都必然虚构。"}} + 结果 = 模型结果("completed", json.dumps(阻塞), "model", None) + assert 校验模型输出(请求, 结果) == 阻塞 + for 非法 in ( + {"paragraphs": [{"text": "雨。"}], "blocked": {"reason": "双现"}}, + {}, + {"blocked": {}}, + {"blocked": {"reason": ""}}, + {"blocked": {"reason": "越权字段", "conflicts": []}}, + ): + assert list(Draft202012Validator(合同).iter_errors(非法)), 非法 + with pytest.raises(模型协议错误, match="模型输出不符合固定结构合同"): + 校验模型输出(请求, replace(结果, 文本=json.dumps(非法))) diff --git a/tests/用例清单.json b/tests/用例清单.json index 8c4932a..a0fab2e 100644 --- a/tests/用例清单.json +++ b/tests/用例清单.json @@ -3091,6 +3091,24 @@ ], "markers": [] }, + { + "case_id": "NC-b05-writer-output-blocked-or-paragraphs", + "when": "写手输出只接受正文或阻塞二选一", + "given": "输出合同含 paragraphs/blocked oneOf 出口", + "file": "tests/契约/test_正文与模型输出.py", + "symbol": "test_写手输出接受阻塞出口且拒绝双现全缺__e13007", + "parameter_ids": [], + "node_ids": [ + "tests/契约/test_正文与模型输出.py::test_写手输出接受阻塞出口且拒绝双现全缺__e13007" + ], + "fixtures": [ + "request", + "测试资源接缝", + "源码资源", + "离线防护" + ], + "markers": [] + }, { "case_id": "NC-b05-writer-reject-inline-newline", "when": "写手输出在模型合同阶段拒绝段内换行", @@ -6627,6 +6645,22 @@ "file": "web/tests/编辑/保存冲突.test.tsx", "symbol": "NC-editor-save-retry:未知结果重试原命令,后续输入不改变在途载荷" }, + { + "case_id": "NC-eval-blocked-writer-no-material", + "file": "tests/单元/test_评测阻塞分流.py", + "symbol": "test_写手阻塞交付在比较与检测材料处明确失败", + "parameter_ids": [], + "node_ids": [ + "tests/单元/test_评测阻塞分流.py::test_写手阻塞交付在比较与检测材料处明确失败" + ], + "fixtures": [ + "request", + "测试资源接缝", + "源码资源", + "离线防护" + ], + "markers": [] + }, { "case_id": "NC-evidence-outcome-immutable", "environment": "隔离 PostgreSQL 与合成原文", @@ -12282,6 +12316,40 @@ "数据库" ] }, + { + "case_id": "NC-w15-writer-blocked-exit", + "environment": "真实隔离PostgreSQL", + "given": "细纲与已确认事实存在不可化解死结的写作任务(合成剧本)", + "when": "写作回合输出 blocked 声明而非正文", + "then": [ + "blocked 载荷通过输出合同校验并随结构化交付留痕", + "任务失败于无工具写作步骤,错误说明含写手理由", + "不进入检查与候选步骤,不登记方法消费" + ], + "contract": "docs/系统架构/新版设计/模块设计/B05-正文写作.md", + "file": "tests/集成/test_两阶段写手.py", + "symbol": "test_写手声明写作死结时任务明确失败且不产候选__e72a10", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_两阶段写手.py::test_写手声明写作死结时任务明确失败且不产候选__e72a10" + ], + "fixtures": [ + "request", + "tmp_path", + "tmp_path_factory", + "内置种子方案", + "内置结构测试库", + "数据库底座", + "测试资源接缝", + "源码资源", + "生成环境", + "离线防护", + "隔离数据库URL" + ], + "markers": [ + "数据库" + ] + }, { "case_id": "NC-w16-w16a01", "environment": "真实隔离 PG", diff --git a/tests/集成/test_两阶段写手.py b/tests/集成/test_两阶段写手.py index 34d7954..12fa7ae 100644 --- a/tests/集成/test_两阶段写手.py +++ b/tests/集成/test_两阶段写手.py @@ -885,3 +885,72 @@ def test_模型自填无效输出被输出合同拒绝__93c5b7(生成环境): 运行.执行一步(领取) 终态 = 运行.读取任务(任务ID) assert any(s["state"] == "failed" for s in 终态.步骤) + + +@pytest.mark.case_id( + "NC-w15-writer-blocked-exit", + environment="真实隔离PostgreSQL", + given="细纲与已确认事实存在不可化解死结的写作任务(合成剧本)", + when="写作回合输出 blocked 声明而非正文", + then=[ + "blocked 载荷通过输出合同校验并随结构化交付留痕", + "任务失败于无工具写作步骤,错误说明含写手理由", + "不进入检查与候选步骤,不登记方法消费", + ], + contract="docs/系统架构/新版设计/模块设计/B05-正文写作.md", +) +def test_写手声明写作死结时任务明确失败且不产候选__e72a10(生成环境): + 剧本 = [ + {"类型": "工具调用", "调用ID": "exp-plan", "名称": "query_work_plan", "参数": {}}, + {"类型": "空对象"}, + { + "类型": "文本", + "文本": { + "blocked": {"reason": "细纲要求角色在已确认死亡后出场,任何写法都必然虚构。"} + }, + }, + ] + 环境 = 生成环境 + 回执 = 发起生成正文( + 环境["装配"], + 环境["作者"], + f"gen-{uuid4()}", + "new_chapter", + work_id="gen-work", + chapter_id="ch-3", + 配置ID="gen-config", + ) + 任务ID = 回执["task_id"] + 预算 = 预算管理(环境["库"], "synthetic") + 预算.登记策略(额度策略("synthetic", "1", Decimal("20"), 40)) + 预算.登记任务预算( + 任务ID, + 任务预算计划( + Decimal("10"), + (角色预算("writer", 8, 8, Decimal("1")),), + "approval", + datetime.now(UTC) + timedelta(minutes=10), + ), + ) + 环境["剧本"].extend(剧本) + 运行 = 环境["装配"].任务运行 + with pytest.raises(生成正文错误, match="写手声明写作死结"): + while True: + 快照 = 运行.读取任务(任务ID) + if 快照.状态 in {任务状态.已完成, 任务状态.已失败, 任务状态.已取消}: + break + 领取 = 运行.领取步骤("worker", 处理器能力) + assert 领取 is not None + 运行.执行一步(领取) + 终态 = 运行.读取任务(任务ID) + assert 终态.状态 is 任务状态.已失败 + 失败步 = [s for s in 终态.步骤 if s["state"] == "failed"] + assert 失败步 and 失败步[0]["step_id"] == "无工具写作" + assert not any( + s["step_id"] == "检查与候选" and s["state"] == "completed" for s in 终态.步骤 + ) + with 环境["库"].连接(只读=True) as 连: + 消费数 = 连.execute( + "SELECT COUNT(*) FROM muse_method_usage WHERE task_id=%s", (任务ID,) + ).fetchone() + assert 消费数[0] == 0, "blocked 写作死结不得登记方法消费"