- 写作输出合同增加 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 基线同样出现,属本机克隆库存量环境 问题,非本次回归)。合同哈希变更对跨版本存量任务恢复为明确失败 (失败关闭),与回放链路现行测试一致。
259 lines
11 KiB
Python
259 lines
11 KiB
Python
"""正文结构的独立接缝:中文原文、段落身份与结构/可见哈希。"""
|
||
|
||
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(非法)))
|