Files
iomgaa 60e737e2fc feat(search): app/search/skills.py — 技能注册表与 frontmatter 解析
从 TRM4 core/search/skills.py 保真迁移。提供 parse_frontmatter、
strip_frontmatter、SkillRegistry、discover_skills 四个公共 API。
逻辑完全一致,仅调整导入路径并添加中文 docstring。

17 个单元测试全部通过,覆盖正常/异常/边界场景。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 05:46:25 -04:00

196 lines
5.8 KiB
Python

"""技能注册表与 Markdown frontmatter 解析工具。
提供 Skill 文件的 frontmatter 解析、正文提取、注册表管理和目录扫描功能,
供搜索 Agent 装配层使用。
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from loguru import logger
if TYPE_CHECKING:
from pathlib import Path
_FRONTMATTER_FIELDS = {"name", "description", "always", "task_type"}
def _extract_frontmatter_lines(text: str) -> tuple[list[str], int] | None:
"""提取 frontmatter 行与正文起始偏移。
参数:
text: 原始 Markdown 文本。
返回:
(frontmatter 行列表, 正文起始字节偏移) 二元组;
若不存在完整 frontmatter 则返回 None。
"""
lines = text.splitlines(keepends=True)
if not lines or lines[0].strip() != "---":
return None
offset = len(lines[0])
frontmatter_lines: list[str] = []
for line in lines[1:]:
if line.strip() == "---":
return frontmatter_lines, offset + len(line)
frontmatter_lines.append(line)
offset += len(line)
logger.debug("frontmatter 缺少结束分隔符,按普通正文处理")
return None
def strip_frontmatter(text: str) -> str:
"""移除 Markdown 文本开头的 frontmatter,并返回正文。
参数:
text: 原始 Markdown 文本。
返回:
去除 frontmatter 后的正文;若 frontmatter 不完整或不存在,则返回原文。
"""
extracted = _extract_frontmatter_lines(text)
if extracted is None:
return text
_, body_start = extracted
return text[body_start:]
def parse_frontmatter(text: str) -> dict[str, str]:
"""解析 Markdown frontmatter 中的目标字段。
仅识别 ``name``、``description``、``always``、``task_type`` 四个字段,
其余字段会被忽略。引号包裹的值会自动去除引号。
参数:
text: 原始 Markdown 文本。
返回:
仅包含目标字段的字符串字典。
若不存在完整 frontmatter,则返回空字典。
"""
extracted = _extract_frontmatter_lines(text)
if extracted is None:
return {}
frontmatter_lines, _ = extracted
parsed: dict[str, str] = {}
for raw_line in frontmatter_lines:
line = raw_line.strip()
if not line or ":" not in line:
continue
key, _, raw_value = line.partition(":")
normalized_key = key.strip()
if normalized_key not in _FRONTMATTER_FIELDS:
continue
value = raw_value.strip()
if len(value) >= 2 and (
(value.startswith('"') and value.endswith('"'))
or (value.startswith("'") and value.endswith("'"))
):
value = value[1:-1]
parsed[normalized_key] = value
return parsed
class SkillRegistry:
"""管理技能名称到文件路径映射并读取技能正文。
通过 ``set_paths`` 注入名称→路径映射后,
可用 ``read`` 按名读取技能 Markdown 正文(自动去除 frontmatter)。
"""
def __init__(self) -> None:
self._paths: dict[str, Path] = {}
def set_paths(self, mapping: dict[str, Path]) -> None:
"""注入技能名称到文件路径的映射。
参数:
mapping: 技能名到 Markdown 文件路径的映射。
"""
self._paths = dict(mapping)
logger.debug("SkillRegistry 已载入 {} 个技能路径", len(self._paths))
def read(self, name: str) -> str:
"""读取指定技能文件,并返回去除 frontmatter 后的正文。
参数:
name: 技能名称。
返回:
技能 Markdown 正文。
异常:
KeyError: 技能名称未注册时抛出。
"""
try:
path = self._paths[name]
except KeyError:
logger.error("技能未注册: {}", name)
raise
logger.debug("读取技能文件: name={}, path={}", name, path)
return strip_frontmatter(path.read_text(encoding="utf-8"))
def discover_skills(
skills_dir: Path,
) -> tuple[str, dict[str, str], str, SkillRegistry]:
"""扫描 skills 目录,按 frontmatter 分类返回。
遍历 ``*.md`` 文件,根据 frontmatter 的 ``always`` / ``task_type`` 字段分类:
- ``always=true`` 的 skill 拼入 ``always_skills_text``
- 有 ``task_type`` 的 skill 加入 ``task_skill_map``
- 非 always 的 skill 生成 ``catalog_text`` 并注册到 registry
参数:
skills_dir: Skill 文件目录。
返回:
``(always_skills_text, task_skill_map, catalog_text, registry)`` 四元组。
"""
if not skills_dir.exists():
return "", {}, "", SkillRegistry()
always_parts: list[str] = []
task_skill_map: dict[str, str] = {}
catalog_lines: list[str] = []
registry_paths: dict[str, Path] = {}
for path in sorted(skills_dir.glob("*.md")):
raw = path.read_text(encoding="utf-8")
meta = parse_frontmatter(raw)
if "name" not in meta:
logger.warning("跳过无 name 的 skill 文件: {}", path)
continue
body = strip_frontmatter(raw)
name = meta["name"]
desc = meta.get("description", "")
task_type = meta.get("task_type", "")
is_always = str(meta.get("always", "false")).lower() == "true"
if is_always:
always_parts.append(body)
else:
if task_type:
task_skill_map[task_type] = body
catalog_lines.append(f"- **{name}**: {desc}")
registry_paths[name] = path
always_text = "\n\n---\n\n".join(always_parts)
catalog_text = "\n".join(catalog_lines)
registry = SkillRegistry()
registry.set_paths(registry_paths)
return always_text, task_skill_map, catalog_text, registry