229 lines
9.1 KiB
Python
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()
|