实现侧: - 上下文:任务范围拆分为 范围校验/范围授权;索引按可发现口径重建、索引新鲜度改对称差;依赖校验统一快照漂移说明。 - 知识方法:方法与材料读取口径统一;超限方法材料按可选省略,核对路径不再二次计费;删除无合同的读时重算。 - 任务运行:新增 context.usage/tool.denied 事件类型;连接池常驻并在装配生命周期内开关;调用结算与核对分列。 - 效果评测/审校修订/交付连载/作者经验/作品规划:凭据冻结、标定消费、导出补证、事实引文核对等收尾修复。 - 资源加载:能力正文不再夹带索引用的导航注记(该注记此前进入角色与技能的模型提示)。 - 元数据:受保护骨架与代码保护属性对齐;字段校验与内置结构口径同步。 - 基础设施:环境预检进入装配生命周期;数据库连接运行期字段不参与相等比较;索引指纹归一化 jsonb 浮点。 - 删除被替代实现:7 份旧提示词模板与空壳 资料来源 读取器。 用例侧: - 用例身份与导航元信息迁移;夹具补生命周期、同库暴露与模板封存; - 本轮定向修复:方法材料省略、事实引文、迁移回执、额度与暂停用例、慢用例超时预算等。
806 lines
32 KiB
Python
806 lines
32 KiB
Python
"""私人探索与正式交接分开;局部草稿不受最终目录约束。"""
|
||
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from muse.作品规划.接口 import 作品错误, 探索服务, 校验交付格式
|
||
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
|
||
|
||
|
||
def 作者(id_="author"):
|
||
return 调用身份(id_, None, 用途.生产, 内容用途.规划)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-exploration-private-cas",
|
||
environment="离线OS禁网,临时私人目录;真实HTTP/CLI无数据库连接",
|
||
given="同作者私人探索原文",
|
||
when="两次并发保存并以相同内容重试",
|
||
then=["只一次推进版本,原话保留,冲突拒绝,作者之间分区"],
|
||
contract="docs/系统架构/新版设计/模块设计/B01-作品规划.md",
|
||
)
|
||
def test_私人稿保存原话且并发冲突不覆盖__ad1001(tmp_path):
|
||
服务 = 探索服务(tmp_path / "私人探索")
|
||
首次 = 服务.保存(作者(), "umbrella", "雨伞", "哥哥没回来。\n妹妹望着门口的伞。", None)
|
||
assert 首次["revision"] == 1
|
||
assert 服务.读取(作者(), "umbrella")["content"] == "哥哥没回来。\n妹妹望着门口的伞。"
|
||
assert 服务.列出(作者("second")) == []
|
||
with pytest.raises(作品错误):
|
||
服务.读取(作者("second"), "umbrella")
|
||
|
||
def 修改(正文):
|
||
try:
|
||
return 服务.保存(作者(), "umbrella", "雨伞", 正文, 首次["draft_hash"])
|
||
except 作品错误 as exc:
|
||
return exc.错误码
|
||
|
||
with ThreadPoolExecutor(2) as pool:
|
||
结果 = list(pool.map(修改, ["她把伞撑开。", "她把伞收起。"]))
|
||
assert sum(isinstance(x, dict) for x in 结果) == 1
|
||
assert "REVISION_CONFLICT" in 结果
|
||
当前 = 服务.读取(作者(), "umbrella")
|
||
assert 当前["revision"] == 2
|
||
# 同一内容的未知返回可以重试,不额外产生版本。
|
||
assert 服务.保存(作者(), "umbrella", 当前["title"], 当前["content"], 首次["draft_hash"]) == 当前
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-exploration-partial-draft",
|
||
environment="离线OS禁网,临时私人目录;真实HTTP/CLI无数据库连接",
|
||
given="只有一个动作的探索片段",
|
||
when="保存后请求最终格式检查",
|
||
then=["片段保存成功,格式问题单独返回,不改变内容"],
|
||
contract="docs/系统架构/新版设计/模块设计/B01-作品规划.md",
|
||
)
|
||
def test_探索不先查最终格式且检查不产生正式内容__ad1002(tmp_path):
|
||
服务 = 探索服务(tmp_path / "私人探索")
|
||
稿 = 服务.保存(作者(), "first-fragment", "只有一个动作", "有人敲门。", None)
|
||
检查 = 服务.检查交付(作者(), "first-fragment", 稿["draft_hash"])
|
||
assert not 检查["valid"] and 检查["problems"]
|
||
assert 服务.读取(作者(), "first-fragment") == 稿
|
||
assert [v["draft_id"] for v in 服务.列出(作者())] == ["first-fragment"]
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-61c0a6864824",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
@pytest.mark.parametrize("形式,编号数", [("短稿", 0), ("局部展开", 0), ("带编号", 1)])
|
||
def test_局部展开的最终稿不设编号和篇幅门__61c0a6(形式, 编号数):
|
||
文本 = 原探索稿
|
||
if 形式 == "短稿":
|
||
文本 = "\n".join(
|
||
行 if 行.startswith("#") or not 行 else "待定。" for 行 in 文本.splitlines()
|
||
)
|
||
elif 形式 == "局部展开":
|
||
文本 = 文本.replace("待定:先从人物行动中寻找中心。", "### 1.1 作品形态与定位\n暂定。")
|
||
else:
|
||
文本 = 文本.replace("哥哥每天", "- S900 哥哥每天")
|
||
结果 = 校验交付格式(文本)
|
||
assert 结果["valid"] is True
|
||
assert 结果["problems"] == []
|
||
assert 结果["setting_count"] == 编号数
|
||
assert 结果["contract_version"] == "完善故事基础设定/output-v3"
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-8eed6ecb704b",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
def test_不同探索稿可展开不同子集__8eed6e():
|
||
展开 = 原探索稿.replace(
|
||
"待定:先从人物行动中寻找中心。",
|
||
"### 1.1 作品形态与定位\n\n### 1.1.1 题材、载体与目标读者\n\n暂定现实题材。",
|
||
)
|
||
assert all(校验交付格式(s)["valid"] for s in (原探索稿, 展开))
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-c6ad4215a8fa",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
@pytest.mark.parametrize(
|
||
"场景", ["unknown", "missing", "reordered"], ids=["unknown", "missing", "reordered"]
|
||
)
|
||
def test_最终导航标题未知缺失或倒序拒绝__c6ad42(场景):
|
||
输入 = {
|
||
"unknown": 原探索稿.replace("## 三、角色与关系", "## 三、人物"),
|
||
"missing": 原探索稿.replace("## 三、角色与关系\n", ""),
|
||
"reordered": 原探索稿.replace(
|
||
"待定:先从人物行动中寻找中心。",
|
||
"### 1.2 故事承诺与一句话梗概\n\n设想。\n\n### 1.1 作品形态与定位\n\n设想。",
|
||
),
|
||
}[场景]
|
||
assert "TREE_MISMATCH" in {v["code"] for v in 校验交付格式(输入)["problems"]}
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-64d9e4509499",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
def test_空节和裸占位拒绝而明确未知允许__64d9e4():
|
||
for 原, 替换, 错误码 in [
|
||
("未探索:尚未讨论社会制度。", "<!-- 留空 -->", "SECTION_EMPTY"),
|
||
("待定:哥哥为何留着伞。", "TODO", "CANDIDATE_PLACEHOLDER"),
|
||
]:
|
||
assert 错误码 in {v["code"] for v in 校验交付格式(原探索稿.replace(原, 替换))["problems"]}
|
||
assert 校验交付格式(原探索稿)["valid"]
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-ec3fc32c7779",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
def test_可选编号无冻结号段但必须唯一__ec3fc3():
|
||
编号稿 = 原探索稿.replace("哥哥每天", "- **S090|哥哥:** 哥哥每天").replace(
|
||
"先从动作里表现关系。", "- **S003|表达:** 先从动作里表现关系。"
|
||
)
|
||
assert 校验交付格式(编号稿)["valid"]
|
||
assert "SETTING_ID_DUPLICATE" in {
|
||
v["code"] for v in 校验交付格式(编号稿.replace("S003", "S090"))["problems"]
|
||
}
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-1d35b43692d1",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
@pytest.mark.parametrize("场景", ["no-title", "deep-heading"], ids=["no-title", "deep-heading"])
|
||
def test_最终稿缺标题或标题过深拒绝__1d35b4(场景):
|
||
内容 = (
|
||
原探索稿.replace("# 雨伞|作品探索\n", "")
|
||
if 场景 == "no-title"
|
||
else 原探索稿 + "\n#### 额外层级\n说明。"
|
||
)
|
||
assert "CANDIDATE_STRUCTURE" in {v["code"] for v in 校验交付格式(内容)["problems"]}
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-3a6d20c30f35",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
def test_缺失私人稿不会得到交付成功__3a6d20(tmp_path):
|
||
with pytest.raises(作品错误) as 错:
|
||
探索服务(tmp_path / "私人探索").读取(作者(), "missing")
|
||
assert 错.value.错误码 == "EXPLORATION_NOT_FOUND"
|
||
|
||
|
||
def _探索配置(tmp_path, *, 口令="synthetic", 来源="http://localhost"):
|
||
secret = tmp_path / "口令.txt"
|
||
secret.write_text(口令)
|
||
secret.chmod(0o600)
|
||
cfg = tmp_path / "作者.toml"
|
||
cfg.write_text(
|
||
'["数据库"]\n"取值方式"="受控存储"\n"位置"="missing-database.txt"\n'
|
||
'["资源"]\n"发布身份"="synthetic"\n[HTTP]\n"口令文件"="口令.txt"\n"作者ID"="author"\n'
|
||
f'"公开地址"="{来源}"\n"允许来源"=["{来源}"]\n'
|
||
'["文件"]\n"探索草稿"="私人探索"\n'
|
||
)
|
||
return cfg
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-exploration-http-cli-offline",
|
||
environment="离线OS禁网,临时私人目录;真实HTTP/CLI无数据库连接",
|
||
given="明确私人目录与不存在的数据库连接文件",
|
||
when="真实HTTP登录保存,CLI读回及检查",
|
||
then=["身份来自会话,私有原话可读,格式失败非零,默认OS禁网仍通过"],
|
||
contract="docs/系统架构/新版设计/模块设计/B01-作品规划.md",
|
||
)
|
||
def test_HTTP与CLI保存探索不访问数据库__ad1003(tmp_path, 源码CLI):
|
||
import json
|
||
import subprocess
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from muse.接入.http.应用 import 创建应用
|
||
from muse.配置 import 读取配置
|
||
|
||
cfg = _探索配置(tmp_path, 口令="synthetic-exploration-password", 来源="http://testserver")
|
||
app = 创建应用(读取配置(cfg))
|
||
with TestClient(app, headers={"origin": "http://testserver"}) as client:
|
||
assert client.get("/api/v1/explorations").status_code == 401
|
||
assert (
|
||
client.post(
|
||
"/api/v1/session", json={"password": "synthetic-exploration-password"}
|
||
).status_code
|
||
== 200
|
||
)
|
||
req = {
|
||
"draft_id": "http-draft",
|
||
"title": "原话",
|
||
"content": "只是一个念头。",
|
||
"expected_hash": None,
|
||
}
|
||
assert (
|
||
client.post(
|
||
"/api/v1/explorations/http-draft", json={**req, "author_id": "forged"}
|
||
).status_code
|
||
== 422
|
||
)
|
||
saved = client.post("/api/v1/explorations/http-draft", json=req)
|
||
assert saved.status_code == 200, saved.text
|
||
assert client.get("/api/v1/explorations").json()[0]["title"] == "原话"
|
||
checked = client.post(
|
||
"/api/v1/explorations/http-draft/check",
|
||
json={"expected_hash": saved.json()["draft_hash"]},
|
||
)
|
||
assert checked.status_code == 200 and not checked.json()["valid"]
|
||
result = subprocess.run(
|
||
[*源码CLI, "探索", str(cfg), "查看", "http-draft"],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
assert result.returncode == 0, result.stderr
|
||
assert json.loads(result.stdout) == saved.json()
|
||
invalid = subprocess.run(
|
||
[
|
||
*源码CLI,
|
||
"探索",
|
||
str(cfg),
|
||
"检查",
|
||
"http-draft",
|
||
"--预期哈希",
|
||
saved.json()["draft_hash"],
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
assert invalid.returncode == 1 and not json.loads(invalid.stdout)["valid"]
|
||
|
||
|
||
# 保留LC原始探索输入,不借旧测试的导入和运行顺序。
|
||
原探索稿 = """# 雨伞|作品探索
|
||
|
||
## 根设定
|
||
作者希望保留雨天的兄妹关系。
|
||
|
||
## 一、作品定位与核心命题
|
||
待定:先从人物行动中寻找中心。
|
||
|
||
## 二、故事世界与社会系统
|
||
未探索:尚未讨论社会制度。
|
||
|
||
## 三、角色与关系
|
||
哥哥每天擦一把从不用的伞,妹妹故意把它拿走。
|
||
|
||
## 四、故事机制与关键实体
|
||
未探索:尚未决定是否有超自然力量。
|
||
|
||
## 五、阶段与状态变化
|
||
未探索:只有当前这一幕。
|
||
|
||
## 六、情节架构与全书大纲
|
||
未探索:兄妹争执后的事情还没想好。
|
||
|
||
## 七、主题与读者体验弧线
|
||
暂想保留亲近中带着疑问的感觉。
|
||
|
||
## 八、信息、悬念与伏笔管理
|
||
待定:哥哥为何留着伞。
|
||
|
||
## 九、叙事执行与一致性
|
||
先从动作里表现关系。
|
||
|
||
## 待决与取舍
|
||
保留争执这一幕,暂缓决定雨伞的来历。
|
||
"""
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"场景,输入",
|
||
[
|
||
pytest.param(
|
||
"missing",
|
||
None,
|
||
id="missing",
|
||
marks=pytest.mark.case_id(
|
||
"TC-e7566b7729ef",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
),
|
||
),
|
||
pytest.param(
|
||
"unconfirmed",
|
||
False,
|
||
id="unconfirmed",
|
||
marks=pytest.mark.case_id(
|
||
"TC-5ed750849bb8",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
),
|
||
),
|
||
pytest.param(
|
||
"string-false",
|
||
"false",
|
||
id="string-false",
|
||
marks=pytest.mark.case_id(
|
||
"TC-802d1addb6c9",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
),
|
||
),
|
||
pytest.param(
|
||
"string-true",
|
||
"true",
|
||
id="string-true",
|
||
marks=pytest.mark.case_id(
|
||
"TC-802d1addb6c9",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
),
|
||
),
|
||
pytest.param(
|
||
"user-string",
|
||
"user",
|
||
id="user-string",
|
||
marks=pytest.mark.case_id(
|
||
"TC-802d1addb6c9",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
),
|
||
),
|
||
pytest.param(
|
||
"integer-true",
|
||
1,
|
||
id="integer-true",
|
||
marks=pytest.mark.case_id(
|
||
"TC-802d1addb6c9",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
),
|
||
),
|
||
pytest.param(
|
||
"list-true",
|
||
["true"],
|
||
id="list-true",
|
||
marks=pytest.mark.case_id(
|
||
"TC-802d1addb6c9",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
),
|
||
),
|
||
pytest.param(
|
||
"empty-id",
|
||
"",
|
||
id="empty-id",
|
||
marks=pytest.mark.case_id(
|
||
"TC-ed5e97c5e169",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
),
|
||
),
|
||
pytest.param(
|
||
"list-id",
|
||
["x"],
|
||
id="list-id",
|
||
marks=pytest.mark.case_id(
|
||
"TC-dd77a90a8a79",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
),
|
||
),
|
||
pytest.param(
|
||
"blank-time",
|
||
" ",
|
||
id="blank-time",
|
||
marks=pytest.mark.case_id(
|
||
"TC-1f742f4ca972",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
),
|
||
),
|
||
],
|
||
ids=[
|
||
"missing",
|
||
"unconfirmed",
|
||
"string-false",
|
||
"string-true",
|
||
"user-string",
|
||
"integer-true",
|
||
"list-true",
|
||
"empty-id",
|
||
"list-id",
|
||
"blank-time",
|
||
],
|
||
)
|
||
def test_选定输入不能自报确认时间或缺失稿件__ad1004(场景, 输入):
|
||
from pydantic import ValidationError
|
||
|
||
from muse.接入.创作操作 import 探索选定请求
|
||
|
||
selection = {
|
||
"work_id": "work",
|
||
"draft_id": "x",
|
||
"expected_hash": "source-hash",
|
||
"expected_revision": 0,
|
||
}
|
||
if 场景 == "missing":
|
||
selection = {}
|
||
elif 场景 in {"empty-id", "list-id"}:
|
||
selection["draft_id"] = 输入
|
||
elif 场景 == "blank-time":
|
||
selection["confirmedAt"] = 输入
|
||
else:
|
||
selection["confirmedByUser"] = 输入
|
||
# 基准先通过,再只变异目标字段,避免错误前置冒充拒绝。
|
||
基准 = {
|
||
"work_id": "work",
|
||
"draft_id": "x",
|
||
"expected_hash": "source-hash",
|
||
"expected_revision": 0,
|
||
}
|
||
assert (
|
||
探索选定请求.model_validate(
|
||
{"command_id": "select-direction", "selection": 基准}
|
||
).selection.draft_id
|
||
== "x"
|
||
)
|
||
if 场景 == "empty-id":
|
||
with pytest.raises(作品错误, match="探索交接需要作品、明确稿件、内容哈希和选定版本") as 错:
|
||
探索选定请求.model_validate({"command_id": "select-direction", "selection": selection})
|
||
assert 错.value.错误码 == "INVALID_REQUEST"
|
||
return
|
||
with pytest.raises(ValidationError) as 错:
|
||
探索选定请求.model_validate({"command_id": "select-direction", "selection": selection})
|
||
实际 = {(tuple(e["loc"]), e["type"]) for e in 错.value.errors()}
|
||
if 场景 == "missing":
|
||
assert 实际 == {(("selection", f), "missing") for f in 基准}
|
||
elif 场景 == "list-id":
|
||
assert 实际 == {(("selection", "draft_id"), "string_type")}
|
||
else:
|
||
字段 = "confirmedAt" if 场景 == "blank-time" else "confirmedByUser"
|
||
assert 实际 == {(("selection", 字段), "unexpected_keyword_argument")}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 作者旅程、细纲合同与失败边界(原 AuthorInstruction/FineOutlineContract)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
作者根 = Path(__file__).resolve().parents[2] / ".agent" / "作者"
|
||
仓库根 = Path(__file__).resolve().parents[2]
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-aab5b6ff836c",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
def test_自然语言旅程到达作者决定__aab5b6():
|
||
"""路由表命中六大场景;最小旅程按序推进,最终决定交给新版候选决定技能。"""
|
||
import re
|
||
|
||
入口 = (作者根 / "指令.md").read_text(encoding="utf-8")
|
||
路由 = re.search(
|
||
r"^## 自然语言路由\s*$\n(?P<body>.*?)(?=^## )",
|
||
入口,
|
||
flags=re.MULTILINE | re.DOTALL,
|
||
).group("body")
|
||
for 说法, 场景 in (
|
||
("我只有一个点子", "定故事"),
|
||
("帮我排一下整本书", "排故事"),
|
||
("这个人物还站不住", "塑人物"),
|
||
("接着写下一章", "写下一章"),
|
||
("把这段改顺", "改正文"),
|
||
("看看这部作品哪里有问题", "查作品"),
|
||
):
|
||
assert re.search(rf"\|\s*{re.escape(说法)}\s*\|\s*\[{re.escape(场景)}\]", 路由)
|
||
|
||
旅程 = re.search(
|
||
r"^## 最小作者旅程\s*$\n(?P<body>.*?)(?=^## |\Z)",
|
||
入口,
|
||
flags=re.MULTILINE | re.DOTALL,
|
||
).group("body")
|
||
顺序 = re.findall(r"\[([^\]]+)\]\(场景/[^)]+\)", 旅程)
|
||
assert 顺序 == ["定故事", "排故事", "写下一章", "查作品", "改正文"]
|
||
决定 = re.search(r"\[决定正文候选去留\]\(([^)]+)\)", 旅程)
|
||
assert 决定 and "skills/操作/决定正文候选去留/SKILL.md" in 决定.group(1)
|
||
for 动作 in ("采用", "丢弃", "修改"):
|
||
assert f"`{动作}`" in 旅程
|
||
|
||
|
||
def _细纲结构():
|
||
from muse.元数据.接口 import 合成结构, 读取内置结构
|
||
|
||
return 合成结构(读取内置结构("fine_outline"))
|
||
|
||
|
||
def _完整细纲值():
|
||
return {
|
||
"targetChapter": 3,
|
||
"sourceRef": {"sourceId": "outline:v1", "sourceVersion": 1},
|
||
"chapterGoal": "本章戏剧任务",
|
||
"hardConstraints": ["衔接上一章结尾"],
|
||
"keyEvents": [
|
||
{
|
||
"id": "e1",
|
||
"order": 1,
|
||
"event": "事件",
|
||
"participants": ["林深"],
|
||
"trigger": "触发",
|
||
"resultDirection": "结果方向",
|
||
}
|
||
],
|
||
"adjustableBeats": ["次序可调整"],
|
||
"declaredNewFacts": [
|
||
{
|
||
"factId": "f1",
|
||
"text": "新事实",
|
||
"sourceRef": {"sourceId": "outline:v1", "sourceVersion": 1},
|
||
}
|
||
],
|
||
"mustAppearEntities": ["林深"],
|
||
"chapterEndHook": "章末钩子",
|
||
}
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-aaae60775fc0",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
def test_细纲技能引用结构为字段权威__aaae60():
|
||
"""字段权威是S04结构:合同由结构生成并带字段身份;操作技能不再自持字段清单。"""
|
||
from muse.元数据.接口 import 生成结构合同
|
||
|
||
结构 = _细纲结构()
|
||
合同 = 生成结构合同(结构)
|
||
assert 合同["required"], "细纲合同必须声明必填字段"
|
||
for 字段 in ("targetChapter", "keyEvents", "chapterEndHook"):
|
||
属性 = 合同["properties"][字段]
|
||
assert 属性["x-field-id"].startswith("fine_outline."), 字段
|
||
assert 属性["title"], f"{字段} 缺中文名"
|
||
技能 = (仓库根 / ".agent" / "skills" / "操作" / "确认规划候选" / "SKILL.md").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
for 字段名 in ("targetChapter", "chapterGoal", "hardConstraints"):
|
||
assert 字段名 not in 技能, "操作技能不得自持细纲字段清单,字段权威在结构"
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-7592152c80cc",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
@pytest.mark.parametrize(
|
||
"缺失字段",
|
||
[
|
||
"targetChapter",
|
||
"sourceRef",
|
||
"chapterGoal",
|
||
"hardConstraints",
|
||
"keyEvents",
|
||
"adjustableBeats",
|
||
"declaredNewFacts",
|
||
"mustAppearEntities",
|
||
"chapterEndHook",
|
||
],
|
||
)
|
||
def test_细纲必填字段齐全且缺一拒绝__759215(缺失字段):
|
||
from muse.元数据.接口 import 校验值, 生成结构合同
|
||
from muse.元数据.模型 import 元数据错误
|
||
|
||
结构 = _细纲结构()
|
||
合同 = 生成结构合同(结构)
|
||
assert set(合同["required"]) == {
|
||
"targetChapter",
|
||
"sourceRef",
|
||
"chapterGoal",
|
||
"hardConstraints",
|
||
"keyEvents",
|
||
"adjustableBeats",
|
||
"declaredNewFacts",
|
||
"mustAppearEntities",
|
||
"chapterEndHook",
|
||
}
|
||
值 = {k: v for k, v in _完整细纲值().items() if k != 缺失字段}
|
||
with pytest.raises(元数据错误) as 错:
|
||
校验值(结构, 值)
|
||
assert 错.value.错误码 == "FIELD_INVALID"
|
||
assert 缺失字段 in 错.value.呈现()["details"]["字段"]
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-0766daae232f",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
def test_细纲推荐字段缺失不拦类型错误拒__0766da():
|
||
from muse.元数据.接口 import 校验值
|
||
from muse.元数据.模型 import 元数据错误
|
||
|
||
结构 = _细纲结构()
|
||
结果 = 校验值(结构, _完整细纲值())
|
||
assert 结果.值["targetChapter"] == 3 # 推荐字段全部缺失仍通过
|
||
with pytest.raises(元数据错误) as 错:
|
||
校验值(结构, {**_完整细纲值(), "unknowns": "不是列表"})
|
||
assert 错.value.错误码 == "FIELD_INVALID"
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-a5ee390b62d2",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
def test_阴影边界与红线明确__a5ee39():
|
||
"""只核对作者路由文档的边界说明;正式写入保护由 S01 集成测试验收。"""
|
||
决定正文 = (仓库根 / ".agent" / "skills" / "操作" / "决定正文候选去留" / "SKILL.md").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
确认规划 = (仓库根 / ".agent" / "skills" / "操作" / "确认规划候选" / "SKILL.md").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
assert "不能自行填补或改用最新版本" in 决定正文
|
||
assert "必须重新展示再决定" in 决定正文
|
||
assert "不代替正文或故事事实的独立确认" in 确认规划
|
||
assert "不自动刷新成最新版本后重试采纳" in 确认规划
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-9bb8291586df",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
def test_冻结与来源两行规则明确__9bb829():
|
||
"""审阅绑定确切版本与哈希;检索呈现内容与来源版本两份信息。"""
|
||
from muse.元数据.接口 import 投影字段, 授权摘要, 策略快照
|
||
|
||
确认规划 = (仓库根 / ".agent" / "skills" / "操作" / "确认规划候选" / "SKILL.md").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
for 锚 in ("candidate_hash", "review_hash", "expected_revision"):
|
||
assert 锚 in 确认规划, f"确认规划缺少冻结锚点 {锚}"
|
||
结构 = _细纲结构()
|
||
投影 = 投影字段(
|
||
结构,
|
||
策略快照("fine_outline", 1),
|
||
授权摘要("a1", "s1"),
|
||
字段用途名="aiContext",
|
||
内容用途="generation",
|
||
运行用途="production",
|
||
)
|
||
# 检索规则:细纲进入模型上下文的字段带来源引用,正文不在细纲内自造。
|
||
键 = {f.key for f in 投影.允许字段}
|
||
assert {"targetChapter", "chapterGoal", "keyEvents", "sourceRef"} <= 键
|
||
assert not any("正文" in k or k == "content" for k in 键), "细纲检索不得携带正文字段"
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-1f5c8cd807f7",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
given="版本冲突或探索交接非法输入",
|
||
when="通过对应 CLI 业务入口触发失败",
|
||
then=["返回稳定错误码和非零退出状态,保留原内容;不以继承某个旧 Click 异常类作为行为证明"],
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
def test_CLI失败可识别且不转为成功__1f5c8c(tmp_path):
|
||
"""版本冲突与非法输入经CLI返回非零与稳定错误码,不静默成功。"""
|
||
import json
|
||
import subprocess
|
||
import sys
|
||
|
||
cfg = _探索配置(tmp_path)
|
||
请求 = tmp_path / "保存.json"
|
||
首次 = {
|
||
"draft_id": "cli-draft",
|
||
"title": "伞",
|
||
"content": "第一稿。",
|
||
"expected_hash": None,
|
||
}
|
||
请求.write_text(json.dumps(首次, ensure_ascii=False))
|
||
保存 = subprocess.run(
|
||
[sys.executable, "-m", "muse", "探索", str(cfg), "保存", str(请求)],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
assert 保存.returncode == 0, 保存.stderr
|
||
冲突 = {**首次, "content": "第二稿。", "expected_hash": "stale-hash"}
|
||
请求.write_text(json.dumps(冲突, ensure_ascii=False))
|
||
失败 = subprocess.run(
|
||
[sys.executable, "-m", "muse", "探索", str(cfg), "保存", str(请求)],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
assert 失败.returncode == 1, (失败.stdout, 失败.stderr)
|
||
呈现 = json.loads(失败.stderr)
|
||
assert 呈现["code"] == "REVISION_CONFLICT"
|
||
缺稿 = subprocess.run(
|
||
[sys.executable, "-m", "muse", "探索", str(cfg), "查看", "missing-draft"],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
assert 缺稿.returncode == 1
|
||
assert json.loads(缺稿.stderr)["code"] == "EXPLORATION_NOT_FOUND"
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-d55c2da4ad82",
|
||
environment="离线,合成探索稿与明确作者选择",
|
||
when="验证最终交接与规划字段。",
|
||
contract="docs/系统架构/新版设计/功能规格/P01-作品创建与连续写作.md",
|
||
)
|
||
def test_细纲合同绑定既有规划槽位__d55c2d():
|
||
"""细纲归属B01规划族并由规划员角色承担;不再靠旧技能文档字符串绑定。"""
|
||
import json as _json
|
||
|
||
from muse.元数据.接口 import 读取内置种子, 选择实例维护者
|
||
|
||
类型 = next(s.类型 for s in 读取内置种子() if s.类型.type_id == "fine_outline")
|
||
assert "planning" in 类型.实例族
|
||
assert 选择实例维护者(类型, "planning") == "B01"
|
||
登记 = _json.loads((仓库根 / "工具" / "资源登记.json").read_text(encoding="utf-8"))
|
||
角色文件 = 仓库根 / ".agent" / "角色" / "规划员.md"
|
||
assert ".agent/角色/规划员.md" in 登记["能力文件"]
|
||
头部 = 角色文件.read_text(encoding="utf-8").split("---")[1]
|
||
assert "id: planner" in 头部
|
||
正文 = 角色文件.read_text(encoding="utf-8")
|
||
assert "规划候选" in 正文 and "不替正文" in 正文
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-b01-delivery-format-section-hints", when="交付格式按实际章节数提示并拒绝缺失乱序"
|
||
)
|
||
def test_交付格式按实际章节数提示并拒绝缺失乱序__e13003(monkeypatch):
|
||
import json
|
||
|
||
import muse.作品规划.探索交接 as 交接
|
||
|
||
结构 = {
|
||
"contractVersion": "test-two",
|
||
"chapters": [
|
||
{"heading": "一、人物", "sections": []},
|
||
{"heading": "二、后果", "sections": []},
|
||
],
|
||
}
|
||
monkeypatch.setattr(交接, "打开资源", lambda _: json.dumps(结构))
|
||
正确 = (
|
||
"# 作品\n## 根设定\n未知。\n## 一、人物\n她。\n## 二、后果\n待定。\n## 待决与取舍\n保留。"
|
||
)
|
||
assert 校验交付格式(正确) == {
|
||
"valid": True,
|
||
"problems": [],
|
||
"setting_count": 0,
|
||
"contract_version": "test-two",
|
||
}
|
||
for 错稿 in (
|
||
正确.replace("## 二、后果\n", ""),
|
||
正确.replace("## 一、人物", "## 二、后果").replace(
|
||
"## 二、后果\n待定", "## 一、人物\n待定"
|
||
),
|
||
):
|
||
assert 校验交付格式(错稿)["problems"] == [
|
||
{"code": "TREE_MISMATCH", "message": "整理稿须按导航顺序包含根设定、2章和待决与取舍"}
|
||
]
|