muse-agent-example/tests/architecture/test_author_instructions.py

229 lines
9.1 KiB
Python

"""作者自然语言入口、场景路由与共同写作合同门禁。"""
from __future__ import annotations
import json
import pathlib
import re
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[2]
AUTHOR_ROOT = ROOT / ".agent" / "作者"
SCENE_ROOT = AUTHOR_ROOT / "场景"
STANDARD_INDEX_HEADER = (
"| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |\n"
"|------|----------|----------|----------|----------|"
)
SCENE_SKILLS = {
"定故事": {"提炼故事概念", "完善故事基础设定", "合并故事方案"},
"排故事": {
"搭建故事蓝图",
"搭建故事结构",
"增强叙事动力",
"铺设并回收伏笔",
"设计故事结尾",
"制定作品规划",
"规划下一章",
},
"塑人物": {"设计人物", "呈现人物", "打磨人物对话", "选择叙述视角"},
"写下一章": {
"预防机器味",
"准备任务上下文",
"固定任务上下文",
"写下一章",
"核对内容一致性",
"诊断机器味",
},
"改正文": {"重写选段", "扩写场景", "润色正文", "修正正文机器味"},
"查作品": {
"诊断作品问题",
"核对内容一致性",
"评估内容质量",
"分析质量短板",
"诊断机器味",
},
}
ROLE_SKILLS = {
"writer": {"写下一章", "重写选段", "扩写场景", "润色正文"},
"planner": {"完善故事基础设定", "制定作品规划", "规划下一章"},
"extractor": {"拆解书稿", "抽取章节知识"},
"detector": {"核对内容一致性"},
"judge": {"评估内容质量"},
}
RETIRED_ROLE_ALIASES = {
"生成下一章",
"局部重写",
"场景扩写",
"正文润色",
"作品定盘",
"全书规划",
"章级细纲",
"全书解析",
"章后抽取",
"一致性检测",
"质量评分",
}
SKILL_LINK = re.compile(r"\[([^\]\n]+)\]\(([^)\n]+/SKILL\.md)\)")
FRONTMATTER_NAME = re.compile(r"^name:\s*(.+?)\s*$", re.MULTILINE)
def _section(text: str, heading: str) -> str:
match = re.search(
rf"^## {re.escape(heading)}\s*$\n(?P<body>.*?)(?=^## |\Z)",
text,
flags=re.MULTILINE | re.DOTALL,
)
if match is None:
raise AssertionError(f"缺少章节:## {heading}")
return match.group("body")
class AuthorInstructionContractTest(unittest.TestCase):
def test_author_layer_has_discoverable_structure(self) -> None:
expected = {
AUTHOR_ROOT / "目录.md",
AUTHOR_ROOT / "指令.md",
AUTHOR_ROOT / "原生中文写作.md",
SCENE_ROOT / "目录.md",
*(SCENE_ROOT / f"{name}.md" for name in SCENE_SKILLS),
}
missing = sorted(path.relative_to(ROOT).as_posix() for path in expected if not path.is_file())
self.assertEqual(missing, [], "作者指令层文件不完整:\n" + "\n".join(missing))
root_index = (ROOT / ".agent" / "目录.md").read_text(encoding="utf-8")
project_entry = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
author_index = (AUTHOR_ROOT / "目录.md").read_text(encoding="utf-8")
scene_index = (SCENE_ROOT / "目录.md").read_text(encoding="utf-8")
self.assertIn("作者/目录.md", root_index)
self.assertIn(".agent/作者/指令.md", project_entry)
self.assertTrue(author_index.startswith(STANDARD_INDEX_HEADER))
self.assertTrue(scene_index.startswith(STANDARD_INDEX_HEADER))
self.assertIn("[指令.md](指令.md)", author_index)
self.assertIn("[原生中文写作.md](原生中文写作.md)", author_index)
self.assertIn("[目录.md](场景/目录.md)", author_index)
for scene in SCENE_SKILLS:
self.assertIn(f"[{scene}.md]({scene}.md)", scene_index)
def test_scenes_route_only_to_registered_chinese_skills(self) -> None:
manifest = json.loads(
(
ROOT
/ "muse"
/ "lifecycle"
/ "quality"
/ "harness"
/ "manifests"
/ "skills.json"
).read_text(encoding="utf-8")
)
registered = {item["name"] for item in manifest["skills"]}
self.assertNotIn("作者入口", registered)
self.assertFalse((AUTHOR_ROOT / "SKILL.md").exists())
for scene, expected_names in SCENE_SKILLS.items():
source = SCENE_ROOT / f"{scene}.md"
route_section = _section(source.read_text(encoding="utf-8"), "正式技能")
actual_names: set[str] = set()
for label, raw_target in SKILL_LINK.findall(route_section):
target = (source.parent / raw_target).resolve()
self.assertTrue(target.is_file(), f"{source}: 技能链接不存在:{raw_target}")
match = FRONTMATTER_NAME.search(target.read_text(encoding="utf-8"))
self.assertIsNotNone(match, f"{target}: 缺少 frontmatter name")
skill_name = match.group(1).strip() # type: ignore[union-attr]
self.assertEqual(label, skill_name, f"{source}: 链接文字必须使用正式技能名")
actual_names.add(skill_name)
self.assertEqual(actual_names, expected_names, f"{scene}: 正式技能范围漂移")
self.assertLessEqual(actual_names, registered)
def test_interaction_and_native_chinese_contracts_are_complete(self) -> None:
entry = (AUTHOR_ROOT / "指令.md").read_text(encoding="utf-8")
for required in (
"一轮只问一个关键问题",
"二至三个中文选项",
"采用",
"修改",
"丢弃",
"Shadow",
"Canonical",
"只给正文",
"主会话不代替角色",
):
self.assertIn(required, entry)
writing = (AUTHOR_ROOT / "原生中文写作.md").read_text(encoding="utf-8")
for required in (
"中文语序",
"具体名词、动作和关系优先",
"作者声音、叙述者声音和角色声音",
"不得凭空增加",
"润色不得偷改情节事实",
"扩写不得新开场景或改变落点",
"重写不得越过作者指定范围",
"精确诊断",
):
self.assertIn(required, writing)
inspect_scene = (SCENE_ROOT / "查作品.md").read_text(encoding="utf-8")
revise_scene = (SCENE_ROOT / "改正文.md").read_text(encoding="utf-8")
self.assertIn("只查不改", inspect_scene)
self.assertIn("作者指定范围", revise_scene)
def test_role_prompts_route_with_registered_skill_names(self) -> None:
manifest = json.loads(
(
ROOT
/ "muse"
/ "lifecycle"
/ "quality"
/ "harness"
/ "manifests"
/ "skills.json"
).read_text(encoding="utf-8")
)
registered = {item["name"] for item in manifest["skills"]}
for role, expected_names in ROLE_SKILLS.items():
self.assertLessEqual(expected_names, registered)
source = ROOT / ".agent" / "agents" / f"{role}.md"
text = source.read_text(encoding="utf-8")
frontmatter = re.search(r"^skills:\s*(.*?)\s*$", text, re.MULTILINE)
self.assertIsNotNone(frontmatter, f"{source}: 缺少 skills 声明")
actual_names = {
name.strip()
for name in frontmatter.group(1).split(",") # type: ignore[union-attr]
if name.strip()
}
self.assertEqual(actual_names, expected_names, f"{source}: 角色技能路由漂移")
route_section = _section(text, "技能单名")
for skill_name in expected_names:
self.assertIn(f"`{skill_name}`", route_section)
for retired_name in RETIRED_ROLE_ALIASES:
self.assertNotIn(f"`{retired_name}`", route_section)
def test_natural_language_journey_reaches_author_decision(self) -> None:
entry = (AUTHOR_ROOT / "指令.md").read_text(encoding="utf-8")
route_section = _section(entry, "自然语言路由")
for phrase, scene in (
("我只有一个点子", "定故事"),
("帮我排一下整本书", "排故事"),
("这个人物还站不住", "塑人物"),
("接着写下一章", "写下一章"),
("把这段改顺", "改正文"),
("看看这部作品哪里有问题", "查作品"),
):
self.assertRegex(
route_section,
rf"\|\s*{re.escape(phrase)}\s*\|\s*\[{re.escape(scene)}\]",
)
journey = _section(entry, "最小作者旅程")
scene_order = re.findall(r"\[([^\]]+)\]\(场景/[^)]+\)", journey)
self.assertEqual(scene_order, ["定故事", "排故事", "写下一章", "查作品", "改正文"])
self.assertIn("决定正文候选去留", journey)
self.assertIn("采用", journey)
self.assertIn("丢弃", journey)
if __name__ == "__main__":
unittest.main()