muse-agent-example/tests/契约/test_正文与模型输出.py
zizi 5e5ff2e9c4 feat(提示词): 批次3写手阻塞出口——输出合同oneOf二选一与消费点分流
- 写作输出合同增加 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 基线同样出现,属本机克隆库存量环境
问题,非本次回归)。合同哈希变更对跨版本存量任务恢复为明确失败
(失败关闭),与回放链路现行测试一致。
2026-09-19 22:18:59 +08:00

259 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""正文结构的独立接缝:中文原文、段落身份与结构/可见哈希。"""
import json
from pathlib import Path
import pytest
from muse.正文写作.接口 import 可见文本, 文本节点, 正文结构哈希, 正文草稿, 段落
@pytest.mark.case_id(
"TC-7d1f17e48073",
environment="离线正文合同",
given="原输入Cafe+组合重音、CRLF/CR中文换行及对应NFC/LF表示",
when="构造受限正文并计算文本和结构哈希",
then=["原输入保持不变;规范表示不与原文共用哈希,Unicode或换行规范化必须成为显式作者编辑"],
contract="docs/系统架构/新版设计/接口契约/正文结构与中文选区.md",
)
def test_正文不擅自改写NFC与换行__7d1f17():
from muse.正文写作.接口 import 可见文本哈希, 换行节点
原文 = "Cafe\u0301\r\n第二行\r第三行"
规范表示 = "Café\n第二行\n第三行"
草稿 = 正文草稿(
(
段落(
"p1",
(
文本节点("Cafe\u0301"),
换行节点("\r\n"),
文本节点("第二行"),
换行节点("\r"),
文本节点("第三行"),
),
),
)
)
对照 = 正文草稿(
(
段落(
"p1",
(
文本节点("Café"),
换行节点("\n"),
文本节点("第二行"),
换行节点("\n"),
文本节点("第三行"),
),
),
)
)
assert 可见文本(草稿) == 原文 and 可见文本(对照) == 规范表示
assert 可见文本哈希(草稿) != 可见文本哈希(对照)
assert 正文结构哈希(草稿) != 正文结构哈希(对照)
@pytest.mark.case_id(
"NC-document-chinese-format",
environment="离线纯正文格式与合成夹具",
given="已定义中文、空行、组合字符、重复片段和选区夹具",
when="形成受限正文树并读取可见文本与结构哈希",
then=["全部原码点、空段落及区间保留;只改变段落身份会改变结构哈希而不改变可见文本"],
contract="docs/系统架构/新版设计/接口契约/正文结构与中文选区.md",
)
def test_中文夹具往返保留空段落与原码点__aa1001():
样本 = json.loads((Path(__file__).parents[1] / "夹具/中文正文.json").read_text())["samples"]
for 项 in 样本:
段 = tuple(段落(f"p-{i}", (文本节点(文本),)) for i, 文本 in enumerate(项["paragraphs"]))
草稿 = 正文草稿(段)
assert 可见文本(草稿) == "\n".join(项["paragraphs"])
for 选区 in 项.get("selections", []):
文本 = 可见文本(正文草稿((段[选区["paragraph_index"]],)))
assert 文本[选区["start"] : 选区["end"]] == 选区["expected_text"]
另名 = 正文草稿(tuple(段落("new-" + p.paragraph_id, p.content) for p in 段))
assert 可见文本(草稿) == 可见文本(另名)
assert 正文结构哈希(草稿) != 正文结构哈希(另名)
@pytest.mark.case_id(
"NC-document-node-contract",
environment="离线纯正文格式与合成夹具",
given="正文节点包含未声明的HTML属性",
when="运行时解析草稿结构",
then=["拒绝额外属性;不把任意HTML保存为正文权威"],
contract="docs/系统架构/新版设计/接口契约/正文结构与中文选区.md",
)
def test_正文外壳拒绝额外节点属性__aa1002():
from pydantic import TypeAdapter, ValidationError
合同 = TypeAdapter(正文草稿)
输入 = {
"paragraphs": [
{
"paragraph_id": "p1",
"content": [{"type": "text", "text": "文字", "html": "<script/>"}],
}
]
}
with pytest.raises(ValidationError):
合同.validate_python(输入)
@pytest.mark.case_id(
"NC-document-editor-normalization",
environment="离线纯合同",
given="等价行内样式、空文本节点与组合字符",
when="构造格式1正文",
then=["结构合并同样式文本,保留原字符、空段落和稳定身份"],
contract="docs/系统架构/新版设计/接口契约/正文结构与中文选区.md",
)
def test_编辑器结构规范化不改变正文字符__aa1003():
草稿 = 正文草稿(
(
段落(
"p1",
(
文本节点(""),
文本节点(" e\u0301", ("emphasis", "strong")),
文本节点("🧑‍🏽 ", ("strong", "emphasis", "strong")),
),
),
段落("empty", (文本节点(""),)),
)
)
assert 草稿.paragraphs[0].content == (文本节点(" e\u0301🧑‍🏽 ", ("strong", "emphasis")),)
assert 草稿.paragraphs[1].content == ()
assert 可见文本(草稿) == " e\u0301🧑‍🏽 \n"
@pytest.mark.case_id("NC-b05-writer-template-boundary", when="写作模板只发送写作指令并保留声音边界")
def test_写作模板只发送写作指令并保留声音边界__e13002(monkeypatch):
import hashlib
import muse.资源加载 as 资源加载
from muse.正文写作.接口 import 生成正文模板
原字节 = (
Path(__file__).resolve().parents[2] / "src/muse/正文写作/提示词/生成正文.md"
).read_bytes()
读取 = []
def 资源(名称):
读取.append(名称)
return 原字节
monkeypatch.setattr(资源加载, "打开资源", 资源)
模板, 哈希 = 生成正文模板()
assert 读取 == ["正文/生成正文.md"]
assert 哈希 == hashlib.sha256(原字节).hexdigest()
assert "资源身份随发布包" not in 模板 and "权限和预算从各自权威装配" not in 模板
assert all(
边界 in 模板
for 边界 in (
"不强塞到每句",
"不要求新造或照抄剧情",
"人物事实与细纲",
"词表命中只作观察线索",
)
)
@pytest.mark.case_id(
"NC-b05-writer-reject-inline-newline", when="写手输出在模型合同阶段拒绝段内换行"
)
def test_写手输出在模型合同阶段拒绝段内换行__e13001():
from dataclasses import replace
from jsonschema import Draft202012Validator
from muse.任务运行.执行合同 import 模型协议错误, 模型结果, 模型请求
from muse.任务运行.模型调用 import 校验模型输出
from muse.正文写作.接口 import 写作输出合同, 换行节点
合同 = 写作输出合同()
请求 = 模型请求("call", "synthetic", "model", "写作", "材料", 合同, 200, 10)
合法 = {"paragraphs": [{"text": "雨声。"}, {"text": " e\u0301🧑‍🏽 "}]}
结果 = 模型结果("completed", json.dumps(合法), "model", None)
assert 校验模型输出(请求, 结果) == 合法
for 文本 in ("首行\n次行", "首行\r次行", "首行\r\n次行", "末尾\n", "末尾\r"):
非法 = {"paragraphs": [{"text": 文本}]}
错误 = list(Draft202012Validator(合同).iter_errors(非法))
assert len(错误) == 1
assert list(错误[0].absolute_path) == ["paragraphs", 0, "text"]
assert 错误[0].validator == "not"
with pytest.raises(模型协议错误, match="模型输出不符合固定结构合同") as 捕获:
校验模型输出(请求, replace(结果, 文本=json.dumps(非法)))
assert 捕获.value.错误码 == "MODEL_PROTOCOL_INVALID"
# 既有正文协议仍能明确表达换行,不对作者内容做归一化。
for 换行 in ("\n", "\r", "\r\n"):
草稿 = 正文草稿((段落("p", (文本节点("首行"), 换行节点(换行), 文本节点("次行"))),))
assert 可见文本(草稿) == "首行" + 换行 + "次行"
@pytest.mark.case_id(
"NC-b05-domain-granularity-new-chapter", when="领域写作粒度兼容而新生成入口仅接受新章"
)
def test_领域写作粒度兼容而新生成入口仅接受新章__e13005():
from pydantic import ValidationError
from muse.接入.cli.创作命令 import 生成发起请求
from muse.接入.http.路由.创作生成 import 发起生成请求
from muse.正文写作.写作任务 import 写作任务合同, 写作任务错误
输入 = dict(
command_id="cmd", chapter_id="chapter", granularity="new_chapter", config_id="config"
)
for 类型, 附加 in ((生成发起请求, {"work_id": "work"}), (发起生成请求, {})):
assert 类型.model_validate({**输入, **附加}).granularity == "new_chapter"
for 粒度 in ("expand_scene", "edit_selection"):
with pytest.raises(ValidationError) as 错:
类型.model_validate({**输入, **附加, "granularity": 粒度})
assert [(e["loc"], e["type"]) for e in 错.value.errors()] == [
(("granularity",), "literal_error")
]
领域 = dict(
任务ID="task",
命令ID="cmd",
作者="author",
用途说明="",
work_id="work",
chapter_id="chapter",
branch_id="main",
基线版本=3,
)
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(非法)))