"""作者自然语言入口、场景路由与共同写作合同门禁。""" 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.*?)(?=^## |\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()