feat(skillmgr): skill_info 支持按章节读取 —— 长 SKILL 不再一次吞一万 token

## 问题

skill_info 此前**整份返回** SKILL.md。生产最坏一例:

    commondev-discipline v2.3.0 → 983 行 / 53810 字节 / ≈10600 token

模型为查「怎么开 PR」(§14,21 行)必须把 hmap 插件开发纪律(320 行)
和红牌一起读进上下文。一次误读一万 token,还挤掉了真正要用的历史。

拆 skill(一个插件声明多个)能解决"跨文档绑定",但解决不了
"单文档内部按需读" —— 本提交补的就是后者。

## 改法

skill_info(name, section=...) 只返回匹配的章节。不传 section 时:

  短文档(<=200 行)  直接返回全文(加目录只是噪音)
  长文档(>200 行)   返回「目录 + 开头 30 行」,模型据此决定读哪节

目录每行都标 `(N 行)` —— 行数不是装饰,它是模型在"一次吞一万 token"
和"先读目录"之间做选择的唯一依据。

## 三个实现要点

① **切分必须感知围栏代码块**

   SKILL.md 里有大量 shell 注释:

       ```bash
       # 期望 200;401/403 = 凭据无效
       curl -s ...
       ```

   硬按 `#` 切会把一条 curl 示例拆成两半 —— 模型拿到的是无法执行的
   碎片,比不给还糟。parseSections 跟踪 ``` / ~~~ 围栏,块内不切。

② **返回行号区间,不复制字符串**

   章节内容的唯一事实来源仍是文件本身,目录与正文用同一套边界计算,
   不会漂移成两份。

③ **点名顶层章节给整棵子树**

   问 `## Setup` 时要的是 §11–§16 全部,不是只有标题那一行。
   边界 = 下一个**同级或更浅**的标题。

## 判据(均经变异验证)

  TestSectionsRebuildEqualsOriginal    ★ 主判据
      所有区间拼接必须逐字节等于原文
      一次同时抓漏行 / 重行 / 错位三类缺陷
      变异:end 改 heads[i+1].line-1 ⇒ "原文 16 行,拼回 13 行"(实测红)

  TestSectionsShellCommentIsNotHeading
      变异:删掉 fence 判断 ⇒ "代码块内的 shell 注释被当成标题:
      \"期望 200;401/403 = 凭据无效\"(行 4)"(实测红)

  TestResolveSectionReturnsWholeSubtree
      变异:`<=` 改 `<` ⇒ "Setup 越界吃进了 Usage 的内容"(实测红)

  TestResolveSectionToleratesHashPrefix
      模型常把 `## Setup: git 操作` 整行传进来,必须容错

  TestRenderTOCListsLevels
      目录只列到 maxLevel,且必须带行数

## 判据本身最容易写废的地方(已避开)

① 用人造短文档测切分 —— 那种文档没有 shell 注释、没有嵌套层级、
   没有围栏代码块,测过了不代表能切真实文件。
   主判据**用生产那份 983 行的真实文件**。
② 只断言"切出 N 个章节" —— 数量对不代表边界对。

## 真实数据验证

    文档 983 行 → 切出 46 个章节(## 顶层 7 个)
    区间拼接 == 原文(逐字节)
    section="审批"    →  21 行(§16)
    section="Setup"   → 123 行(含 §11–§16 全部子节)
    section="hmap"    → 320 行

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    63 包全过,0 FAIL
    干净检出(worktree)       build + skillmgr 全绿
This commit is contained in:
JianFeeeee
2026-11-04 15:06:04 +08:00
parent 0ed3c894f5
commit 67be114647
3 changed files with 503 additions and 1 deletions

View File

@ -0,0 +1,259 @@
package skillmgr
// SKILL.md 的章节切分(2026-11-04)。
//
// ## 为什么需要
//
// skill_info 此前**整份返回** SKILL.md。生产实测最坏的一例:
//
// commondev-discipline v2.3.0 982 行 / 31925 字节 ≈ 10600 token
//
// 模型为了查一次「怎么开 PR」(§14,约 30 行)要把 hmap 插件开发纪律
// (§32–§41,320 行)和红牌(11 行)一起读进上下文 —— 三块互不相关
// 的内容被强绑在一份文档里。一次误读就是一万 token。
//
// 拆 skill(一个插件声明多个)能解决"跨文档绑定",但解决不了
// "单文档内部按需读"。本文件补的就是后者。
//
// ## 为什么不能按 `#` 硬切
//
// SKILL.md 正文里有大量 **shell 注释**,形如:
//
// ```bash
// # 期望 200;401/403 = 凭据无效
// curl -s ...
// ```
//
// 这些 `#` 在代码块内,不是标题。硬切会把一条 curl 示例拆成两半,
// 模型拿到的是一段无法执行的碎片 —— 比不给还糟。
//
// 所以切分必须**感知围栏代码块**(``` 与 ~~~,允许信息串如 ```bash)。
//
// ## 为什么用行号区间而不是复制字符串
//
// 返回的是 [start, end] 行区间,由调用方从原文切片。这样:
// - 章节内容的唯一事实来源仍是文件本身(不会出现两份漂移)
// - 目录(TOC)与正文用同一套边界计算,不会对不上
import (
"fmt"
"strings"
)
// skillSection 是 SKILL.md 里的一个章节。
type skillSection struct {
Level int // 标题的 # 个数(1 = 顶章,2 = ##,3 = ###)
Title string // 标题行去掉 # 与首尾空白后的文本
Start int // 标题行在原文里的行号(0-based,含)
End int // 本章节最后一行之后的行号(0-based,不含)
}
// parseSections 按层级切分 SKILL.md 正文。
//
// 只切**围栏代码块之外**的 ATX 标题(`#` 开头)。frontmatter 里的
// 内容不参与切分(它是 YAML,`#` 是注释,不是标题)。
//
// 返回的区间覆盖整个文档:第一个标题之前的正文归入一个合成章节
// (Title 为空),这样"全文 = 所有章节拼接"成立,调用方不会漏内容。
func parseSections(content string) []skillSection {
lines := strings.Split(content, "\n")
// frontmatter 区间:首个 --- 到闭合 --- 之间不参与切分。
fmEnd := 0
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
fmEnd = i + 1
break
}
}
}
// 找出所有真标题的行号。
type head struct {
line int
level int
title string
}
var heads []head
fence := "" // 当前所处围栏标记("```" 或 "~~~"),空 = 不在代码块内
for i := fmEnd; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
// 围栏开/合:只在行首(允许缩进)出现时生效。
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
marker := trimmed[:3]
if fence == "" {
fence = marker
} else if strings.HasPrefix(trimmed, fence) {
fence = ""
}
continue
}
if fence != "" {
continue // 代码块内的 # 是注释/内容,不是标题
}
if lv, title, ok := atxHeading(line); ok {
heads = append(heads, head{i, lv, title})
}
}
if len(heads) == 0 {
// 没有标题:整份文档作为一个匿名章节。
return []skillSection{{Level: 0, Title: "", Start: 0, End: len(lines)}}
}
var out []skillSection
// 第一个标题之前的内容(含 frontmatter)作为匿名前导章节。
if heads[0].line > 0 {
out = append(out, skillSection{Level: 0, Title: "", Start: 0, End: heads[0].line})
}
for i, h := range heads {
end := len(lines)
if i+1 < len(heads) {
end = heads[i+1].line
}
out = append(out, skillSection{
Level: h.level,
Title: h.title,
Start: h.line,
End: end,
})
}
return out
}
// atxHeading 判断一行是否是 ATX 标题(# 到 ######,后接空格或行尾)。
//
// 与 CommonMark 对齐:`#foo` 不是标题(缺空格);`#` 单独成行是空标题。
// 这条规则正是把 shell 注释 `#期望 200`(无空格)与真标题区分开的依据 ——
// 不过代码块内的行已经在 parseSections 里被排除了,这里是第二道闸。
func atxHeading(line string) (level int, title string, ok bool) {
i := 0
for i < len(line) && line[i] == ' ' {
i++ // 允许至多 3 个前导空格(这里放宽,不影响正确性)
}
if i >= len(line) || line[i] != '#' {
return 0, "", false
}
j := i
for j < len(line) && line[j] == '#' {
j++
}
level = j - i
if level > 6 {
return 0, "", false
}
// 标题标记后必须是空格或行尾
if j < len(line) && line[j] != ' ' && line[j] != '\t' {
return 0, "", false
}
return level, strings.TrimSpace(line[j:]), true
}
// renderTOC 把章节列表渲染成目录(供 skill_info 不传 section 时返回)。
//
// 只列 Level <= maxLevel 的章节,避免把 #### 这类细节全铺出来。
// 顶层标题带行数,让模型能判断"读它值不值" —— 这是它在
// "一次读一万 token" 和 "先读目录再决定" 之间做选择的唯一依据。
func renderTOC(sections []skillSection, maxLevel int) string {
var b strings.Builder
for _, s := range sections {
if s.Title == "" {
continue
}
if s.Level == 0 || (maxLevel > 0 && s.Level > maxLevel) {
continue
}
indent := strings.Repeat(" ", s.Level-1)
fmt.Fprintf(&b, "%s%s%s (%d 行)\n", indent, strings.Repeat("#", s.Level), s.Title, s.End-s.Start)
}
return b.String()
}
// 长文档判定与目录预览行的取值。
//
// 依据:生产上 26 个随包 skill 的行数分布,绝大多数在 20–250 行之间,
// 其中 <=200 行的占了大部分。200 行约 2000 token,一次读完仍算合理;
// 超过它才值得加一层目录(实测 commondev-discipline 982 行 ≈ 10600 token,
// 是唯一明显"必须按需读"的一份)。
const (
shortSkillLines = 200 // <= 此行数直接返回全文
tocHeadLines = 30 // 长文档先给开头多少行
)
// resolveSection 在章节列表里按标题匹配,返回正文行区间。
//
// 匹配顺序(越靠前越优先,保证 "Setup" 不会误中 "Setup 细节"):
//
// ① 标题完全相同(忽略大小写与首尾空白)
// ② 标题包含查询(子串,忽略大小写)
// ③ 查询包含标题(模型可能把 `## Setup: ...` 整行连同 # 一起传进来)
//
// 命中多个时取**层级最浅、位置最靠前**的那个 —— 顶层章节包含它的子节,
// 返回整块更符合"我要看 Setup 这部分"的意图。
func resolveSection(raw string, sections []skillSection, want string) (start, end, idx int, ok bool) {
q := strings.ToLower(strings.TrimSpace(want))
// 允许模型把 # 一起传进来
q = strings.TrimLeft(q, "#")
q = strings.TrimSpace(q)
if q == "" {
return 0, 0, 0, false
}
best := -1
for i, s := range sections {
title := strings.ToLower(s.Title)
if title == "" {
continue
}
if title != q && !strings.Contains(title, q) && !strings.Contains(q, title) {
continue
}
if best < 0 {
best = i
continue
}
// 相同命中质量下:层级浅优先,其次位置靠前。
if s.Level < sections[best].Level {
best = i
}
}
if best < 0 {
return 0, 0, 0, false
}
lines := strings.Split(raw, "\n")
root := sections[best]
// ★ 关键:返回**整棵子树**,而不只是标题直到下一个同级标题。
//
// 例:`## Setup` 下面有 §11–§16 六个 `###` 子节。若只切到下一个
// 同级标题,遇到 `## Usage` 才停 —— 那没问题;但如果模型点名的是
// `### 14. 常见操作`,下一个标题就是 `### 15.`,只返回 §14 本身,
// 这是对的。两种情况都是"到下一个同级或更浅的标题为止"。
end = len(lines)
for j := best + 1; j < len(sections); j++ {
if sections[j].Level != 0 && sections[j].Level <= root.Level {
end = sections[j].Start
break
}
}
start = root.Start
if start >= len(lines) {
return 0, 0, 0, false
}
if end > len(lines) {
end = len(lines)
}
return start, end, best, true
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}