405 lines
16 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.

#!/usr/bin/env python3
"""生成并机械校验方法技能分级目录与 Muse 编排技能目录。
发现合同(见 AGENTS.md):任何智能体先读方法技能根目录,再按分类目录中的
“相对地址”读取所需 `SKILL.md`;编排技能直接从 `muse/技能目录.md` 发现。
物理位置取自 manifest 的 `skill_path`,技能说明取自 SKILL.md frontmatter。
用法:
.venv/bin/python muse/lifecycle/quality/harness/skills_index.py --check
.venv/bin/python muse/lifecycle/quality/harness/skills_index.py --write
"""
from __future__ import annotations
import argparse
import difflib
import json
from pathlib import Path
from typing import Any, Optional, Sequence
import yaml
try:
from .skill_identity import (
SKILL_NAME_POLICY_TRANSITIONAL,
VALID_SKILL_NAME_POLICIES,
skill_name_validation_error,
)
except ImportError: # 允许直接执行本文件。
from skill_identity import (
SKILL_NAME_POLICY_TRANSITIONAL,
VALID_SKILL_NAME_POLICIES,
skill_name_validation_error,
)
LIFECYCLE_SECTIONS: tuple[tuple[str, str], ...] = (
("platform", "0 平台底座"),
("ingest", "1 素材导入与拆解"),
("knowledge", "2 知识与上下文供给"),
("concept", "3 概念与前期设计"),
("planning", "4 结构与规划"),
("writing", "5 正文写作与呈现"),
("review", "6 检测、评分与诊断"),
("humanization", "7 去 AI 味与人感"),
("sovereignty", "8 候选主权与落库"),
)
METHOD_ROOT_INDEX_RELPATH = Path(".agent") / "skills" / "目录.md"
BUSINESS_INDEX_RELPATH = Path("muse") / "技能目录.md"
LEGACY_INDEX_RELPATHS = (
Path(".agent") / "skills" / "_index.md",
Path("muse") / "_skills_index.md",
)
MANIFEST_RELPATH = (
Path("muse")
/ "lifecycle"
/ "quality"
/ "harness"
/ "manifests"
/ "skills.json"
)
_METHOD_GROUPS: dict[str, dict[str, Any]] = {
"规划": {
"order": 0,
"title": "规划类技能",
"description": "概念破题、故事骨架、伏笔铺垫与叙事动力等故事规划方法",
"usage": "故事构思、搭建节拍表、设计高潮与收尾时",
"requirement": "产出结构骨架与因果链,不代写正文",
},
"写作": {
"order": 1,
"title": "写作类技能",
"description": "人物设计、人物登场、对白暗劲、视角选择与场景执行等写作方法",
"usage": "正文生成、场景加厚、对白打磨与修辞校准时",
"requirement": "坚持具体压倒抽象、无心理描写法与白描",
},
"诊断": {
"order": 2,
"title": "诊断类技能",
"description": "小说全篇体检、结构断裂诊断与质量短板扫描",
"usage": "稿件完成、初审体检、分析弃读与拒稿根因时",
"requirement": "给出客观证据引文,定位具体改进技能",
},
}
_METHOD_HEADER = """# {title}目录
> **发现合同**:从 [`../目录.md`](../目录.md) 进入本分类后,按“相对地址”读取对应 `SKILL.md`。技能名和物理路径来自 manifest,技能说明来自 SKILL.md frontmatter;本文件由 `muse/lifecycle/quality/harness/skills_index.py --write` 生成,手改会被覆盖。
本分类共 {count} 个方法技能。
"""
_BUSINESS_HEADER = """# Muse 编排技能目录
> **业务发现合同**:主代理与业务编排按“技能文件”读取对应 `SKILL.md`。编排技能不进入智能体框架的运行期目录;物理位置完全取自 manifest 的 `skill_path`。
本目录只登记技能名、技能文件和技能说明。分类字段与责任方仍以 [`lifecycle/quality/harness/manifests/skills.json`](lifecycle/quality/harness/manifests/skills.json) 为准,由 `muse/lifecycle/quality/harness` 机械校验。
本文件由 `muse/lifecycle/quality/harness/skills_index.py --write` 生成,手改会被覆盖;按生命周期分域,共 {count} 个编排技能。
"""
def _load_manifest(root: Path) -> dict[str, dict[str, Any]]:
"""读取 skills.json,返回技能名到条目的唯一映射。"""
manifest_path = root / MANIFEST_RELPATH
if not manifest_path.exists():
legacy = root / "harness" / "manifests" / "skills.json"
if legacy.exists():
manifest_path = legacy
data = json.loads(manifest_path.read_text(encoding="utf-8"))
entries = data["skills"] if isinstance(data, dict) and "skills" in data else data
if not isinstance(entries, list):
raise ValueError("skills.json 的 skills 必须是数组")
name_policy = (
data.get("skill_name_policy", SKILL_NAME_POLICY_TRANSITIONAL)
if isinstance(data, dict)
else SKILL_NAME_POLICY_TRANSITIONAL
)
if name_policy not in VALID_SKILL_NAME_POLICIES:
raise ValueError("skills.json 的 skill_name_policy 非法")
result: dict[str, dict[str, Any]] = {}
for entry in entries:
if not isinstance(entry, dict):
raise ValueError("skills.json 的技能条目必须是对象")
name = entry.get("name")
name_error = skill_name_validation_error(name, policy=name_policy)
if name_error is not None:
raise ValueError(f"skills.json 的技能名非法:{name!r}:{name_error}")
assert isinstance(name, str)
if name in result:
raise ValueError(f"skills.json 重复登记技能名:{name}")
result[name] = entry
return result
def _read_description(skill_md: Path) -> str:
"""取 SKILL.md frontmatter 的 description 并折叠成单行。"""
text = skill_md.read_text(encoding="utf-8")
if not text.startswith("---\n"):
raise ValueError(f"{skill_md}: 缺少 frontmatter")
end = text.index("\n---\n", 4)
fields = yaml.safe_load(text[4:end])
description = (fields or {}).get("description")
if not isinstance(description, str) or not description.strip():
raise ValueError(f"{skill_md}: frontmatter 缺少非空 description")
return "".join(line.strip() for line in description.strip().splitlines())
def _skill_link(declared_path: str, index_parent: Path) -> str:
try:
relative_path = Path(declared_path).relative_to(index_parent)
except ValueError as error:
raise ValueError(
f"技能路径不在目录索引范围内:{declared_path} -> {index_parent}"
) from error
return f"[SKILL.md]({relative_path.as_posix()})"
def _business_table_lines(
names: Sequence[str],
disk: dict[str, tuple[str, str]],
) -> list[str]:
lines = ["| 技能名 | 技能文件 | 技能说明 |", "|---|---|---|"]
for name in names:
declared_path, description = disk[name]
lines.append(
f"| {name} | {_skill_link(declared_path, Path('muse'))} | "
f"{description} |"
)
return lines
def _method_table_lines(
names: Sequence[str],
disk: dict[str, tuple[str, str]],
group: str,
) -> list[str]:
lines = [
"| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |",
"|------|----------|----------|----------|----------|",
]
index_parent = Path(".agent") / "skills" / group
for name in names:
declared_path, description = disk[name]
lines.append(
f"| {name} | {_skill_link(declared_path, index_parent)} | "
f"{description} | 命中内容描述中的适用场景时 | "
"先读取完整技能合同,遵守不适用场景与交接边界 |"
)
return lines
def _method_group_from_path(skill_path: str) -> str:
parts = Path(skill_path).parts
if len(parts) < 5 or parts[:2] != (".agent", "skills") or parts[-1] != "SKILL.md":
raise ValueError(f"方法技能路径不符合 .agent/skills/<分类>/<技能>/SKILL.md:{skill_path}")
group = parts[2]
if group not in _METHOD_GROUPS:
raise ValueError(f"方法技能使用了未登记分类目录:{group}")
return group
def build_method_index_texts(root: Path) -> tuple[dict[Path, str], int]:
"""生成方法技能根目录和各分类目录全文。"""
all_manifest = _load_manifest(root)
manifest = {
name: entry
for name, entry in all_manifest.items()
if entry.get("invocation") == "model_routed"
}
skills_root = root / ".agent" / "skills"
disk_by_path: dict[str, tuple[str, str]] = {}
for skill_md in sorted(skills_root.rglob("SKILL.md")):
relative = skill_md.relative_to(root).as_posix()
disk_by_path[relative] = (skill_md.parent.name, _read_description(skill_md))
disk: dict[str, tuple[str, str]] = {}
group_by_name: dict[str, str] = {}
for name, entry in manifest.items():
declared_path = str(entry.get("skill_path", ""))
disk_entry = disk_by_path.get(declared_path)
if disk_entry is None:
raise ValueError(f"skills.json 登记的方法技能不在磁盘上:{name}")
if disk_entry[0] != name:
raise ValueError(f"技能目录名与 name 不一致:{name} -> {declared_path}")
lifecycle = entry.get("lifecycle")
if lifecycle not in dict(LIFECYCLE_SECTIONS):
raise ValueError(f"{name}: 未知 lifecycle={lifecycle!r}")
disk[name] = (declared_path, disk_entry[1])
group_by_name[name] = _method_group_from_path(declared_path)
manifest_paths = {str(entry.get("skill_path", "")) for entry in manifest.values()}
unregistered = sorted(set(disk_by_path) - manifest_paths)
if unregistered:
raise ValueError("挂载面存在未登记技能:" + ", ".join(unregistered))
groups = sorted(
set(group_by_name.values()),
key=lambda group: (_METHOD_GROUPS[group]["order"], group),
)
root_lines = [
"| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |",
"|------|----------|----------|----------|----------|",
]
outputs: dict[Path, str] = {}
for group in groups:
metadata = _METHOD_GROUPS[group]
group_names = sorted(name for name, value in group_by_name.items() if value == group)
group_relpath = Path(".agent") / "skills" / group / "目录.md"
root_lines.append(
f"| {metadata['title']} | [目录.md]({group}/目录.md) | "
f"{metadata['description']} | "
f"{metadata['usage']} | {metadata['requirement']} |"
)
lines = [
_METHOD_HEADER.format(title=metadata["title"], count=len(group_names)).rstrip()
]
for lifecycle, section_title in LIFECYCLE_SECTIONS:
names = [
name
for name in group_names
if manifest[name].get("lifecycle") == lifecycle
]
if not names:
continue
lines.extend(["", f"## {section_title}", ""])
lines.extend(_method_table_lines(names, disk, group))
lines.append("")
outputs[group_relpath] = "\n".join(lines)
root_lines.append("<!-- my-skills-cli:index:v2 -->")
root_lines.append("")
outputs[METHOD_ROOT_INDEX_RELPATH] = "\n".join(root_lines)
return outputs, len(disk)
def build_business_index_text(root: Path) -> tuple[str, int]:
"""按 manifest 生成 Muse 编排技能目录。"""
manifest = {
name: entry
for name, entry in _load_manifest(root).items()
if entry.get("invocation") == "orchestrated"
}
disk: dict[str, tuple[str, str]] = {}
for name, entry in manifest.items():
declared_path = str(entry.get("skill_path", ""))
if not declared_path.startswith("muse/"):
raise ValueError(f"编排技能不在 muse/ 业务树:{name}")
skill_md = root / declared_path
if not skill_md.is_file():
raise ValueError(f"编排技能路径不存在:{declared_path}")
if skill_md.parent.name != name:
raise ValueError(f"技能目录名与 name 不一致:{name} -> {declared_path}")
disk[name] = (declared_path, _read_description(skill_md))
lines = [_BUSINESS_HEADER.format(count=len(disk)).rstrip()]
for lifecycle, title in LIFECYCLE_SECTIONS:
names = sorted(
name for name, entry in manifest.items() if entry.get("lifecycle") == lifecycle
)
if not names:
continue
lines.extend(["", f"## {title}", ""])
lines.extend(_business_table_lines(names, disk))
lines.append("")
return "\n".join(lines), len(disk)
def _diff(relative_path: Path, actual: str, expected: str) -> str:
return "".join(
difflib.unified_diff(
actual.splitlines(keepends=True),
expected.splitlines(keepends=True),
fromfile=str(relative_path) + "(现状)",
tofile=str(relative_path) + "(按磁盘与 skills.json 重建)",
n=2,
)
)
def check(root: Path) -> Optional[str]:
"""校验所有目录与 manifest、磁盘一致;一致返回 None。"""
method_outputs, _ = build_method_index_texts(root)
business_text, _ = build_business_index_text(root)
expected_outputs = dict(method_outputs)
expected_outputs[BUSINESS_INDEX_RELPATH] = business_text
results: list[str] = []
for relative_path, expected in sorted(
expected_outputs.items(), key=lambda item: item[0].as_posix()
):
path = root / relative_path
actual = path.read_text(encoding="utf-8") if path.exists() else ""
if actual != expected:
results.append(_diff(relative_path, actual, expected))
actual_method_indexes = {
path.relative_to(root)
for path in (root / ".agent" / "skills").glob("*/目录.md")
if path.is_file()
}
expected_method_indexes = set(method_outputs) - {METHOD_ROOT_INDEX_RELPATH}
for stale_path in sorted(actual_method_indexes - expected_method_indexes):
results.append(f"存在未登记的方法技能分类目录:{stale_path}")
for legacy_path in LEGACY_INDEX_RELPATHS:
if (root / legacy_path).exists():
results.append(f"旧技能索引仍存在,必须删除:{legacy_path}")
return "\n".join(results) or None
def write(root: Path) -> tuple[int, int]:
"""重建全部技能目录,不删除未登记文件。"""
method_outputs, method_count = build_method_index_texts(root)
business_text, business_count = build_business_index_text(root)
for relative_path, text in method_outputs.items():
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
business_path = root / BUSINESS_INDEX_RELPATH
business_path.parent.mkdir(parents=True, exist_ok=True)
business_path.write_text(business_text, encoding="utf-8")
return method_count, business_count
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser(description="生成与机械校验中文技能目录")
parser.add_argument("--root", default=".", help="项目根目录,默认为当前目录")
group = parser.add_mutually_exclusive_group()
group.add_argument("--check", action="store_true", help="校验目录与磁盘、skills.json 一致")
group.add_argument("--write", action="store_true", help="按磁盘与 skills.json 重新生成目录")
args = parser.parse_args(argv)
root = Path(args.root).resolve()
try:
if args.write:
method_count, business_count = write(root)
print(
f"已重新生成方法技能分级目录({method_count} 个)与 "
f"{BUSINESS_INDEX_RELPATH}({business_count} 个编排技能)"
)
return 0
difference = check(root)
except (ValueError, KeyError, FileNotFoundError) as error:
print(f"技能目录生成失败:{error}")
return 2
if difference is None:
print("技能目录一致:目录与磁盘、skills.json 相符")
return 0
print("技能目录漂移:请用 muse/lifecycle/quality/harness/skills_index.py --write 重新生成")
print(difference)
return 1
if __name__ == "__main__":
raise SystemExit(main())