muse-agent-example/tests/单元/test_探索交接与规划.py
zizi 224df00706 W13 渐进探索、分层规划与近期细化:私人探索、明确选定、分层规划与近期细化;含竞态修复新用例。
按 R2 串行阶段整理提交;包内文件为该阶段交付(含后续小增量),状态以工作包清单为准。
2026-09-10 19:25:41 +08:00

517 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

"""私人探索与正式交接分开;局部草稿不受最终目录约束。"""
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from muse.作品规划.接口 import 作品错误, 探索服务, 校验交付格式
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
def 作者(id_="author"):
return 调用身份(id_, None, 用途.生产, 内容用途.规划)
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"]) == 当前
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"]
def test_局部展开的最终稿不设编号和篇幅门__61c0a6():
assert 校验交付格式(原探索稿)["valid"]
def test_不同探索稿可展开不同子集__8eed6e():
展开 = 原探索稿.replace(
"待定:先从人物行动中寻找中心。",
"### 1.1 作品形态与定位\n\n### 1.1.1 题材、载体与目标读者\n\n暂定现实题材。",
)
assert all(校验交付格式(s)["valid"] for s in (原探索稿, 展开))
@pytest.mark.parametrize("场景", ["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"]}
def test_空节和裸占位拒绝而明确未知允许__64d9e4():
for 原, 替换, 错误码 in [
("未探索:尚未讨论社会制度。", "<!-- 留空 -->", "SECTION_EMPTY"),
("待定:哥哥为何留着伞。", "TODO", "CANDIDATE_PLACEHOLDER"),
]:
assert 错误码 in {v["code"] for v in 校验交付格式(原探索稿.replace(原, 替换))["problems"]}
assert 校验交付格式(原探索稿)["valid"]
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.parametrize("场景", ["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"]}
def test_缺失私人稿不会得到交付成功__3a6d20(tmp_path):
with pytest.raises(作品错误) as 错:
探索服务(tmp_path / "私人探索").读取(作者(), "missing")
assert 错.value.错误码 == "EXPLORATION_NOT_FOUND"
def test_HTTP与CLI保存探索不访问数据库__ad1003(tmp_path):
import json
import subprocess
import sys
from fastapi.testclient import TestClient
from muse.接入.http.应用 import 创建应用
from muse.配置 import 读取配置
secret = tmp_path / "口令.txt"
secret.write_text("synthetic-exploration-password")
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"公开地址"="http://testserver"\n"允许来源"=["http://testserver"]\n["文件"]\n"探索草稿"="私人探索"\n'
)
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(
[sys.executable, "-m", "muse", "探索", 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(
[
sys.executable,
"-m",
"muse",
"探索",
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(
"场景,输入",
[
("missing", None),
("unconfirmed", False),
("string-false", "false"),
("string-true", "true"),
("user-string", "user"),
("integer-true", 1),
("list-true", ["true"]),
("empty-id", ""),
("list-id", ["x"]),
("blank-time", " "),
],
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"] = 输入
with pytest.raises((ValidationError, 作品错误)):
探索选定请求.model_validate({"command_id": "select-direction", "selection": selection})
# ---------------------------------------------------------------------------
# W13 承接:作者旅程、细纲合同与失败边界(原 AuthorInstruction/FineOutlineContract)
# ---------------------------------------------------------------------------
作者根 = Path(__file__).resolve().parents[2] / ".agent" / "作者"
仓库根 = Path(__file__).resolve().parents[2]
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": "章末钩子",
}
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.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"]["字段"]
def test_细纲推荐字段缺失不拦类型错误拒__0766da():
from muse.元数据.接口 import 校验值
from muse.元数据.模型 import 元数据错误
结构 = _细纲结构()
结果 = 校验值(结构, _完整细纲值())
assert 结果.值["targetChapter"] == 3 # 推荐字段全部缺失仍通过
with pytest.raises(元数据错误) as 错:
校验值(结构, {**_完整细纲值(), "unknowns": "不是列表"})
assert 错.value.错误码 == "FIELD_INVALID"
def test_阴影边界与红线明确__a5ee39():
"""候选保持Shadow;正文与规划决定入口分离;决定必须引用被审版本。"""
决定正文 = (仓库根 / ".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 确认规划
# 作者层红线:候选未经作者明确确认不进入正式内容。
入口 = (作者根 / "指令.md").read_text(encoding="utf-8")
assert "不进入 Canonical" in 入口 and "主会话不代替角色" in 入口
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 键), "细纲检索不得携带正文字段"
def test_CLI失败可识别且不转为成功__1f5c8c(tmp_path):
"""版本冲突与非法输入经CLI返回非零与稳定错误码,不静默成功。"""
import json
import subprocess
import sys
cfg = tmp_path / "作者.toml"
cfg.write_text(
'["数据库"]\n"取值方式"="受控存储"\n"位置"="missing-database.txt"\n'
'["资源"]\n"发布身份"="synthetic"\n[HTTP]\n"口令文件"="口令.txt"\n"作者ID"="author"\n'
'"公开地址"="http://localhost"\n"允许来源"=["http://localhost"]\n'
'["文件"]\n"探索草稿"="私人探索"\n'
)
(tmp_path / "口令.txt").write_text("synthetic")
请求 = 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"
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 正文