mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
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:
259
internal/plugins/skillmgr/sections.go
Normal file
259
internal/plugins/skillmgr/sections.go
Normal 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
|
||||
}
|
||||
205
internal/plugins/skillmgr/sections_test.go
Normal file
205
internal/plugins/skillmgr/sections_test.go
Normal file
@ -0,0 +1,205 @@
|
||||
package skillmgr
|
||||
|
||||
// 判据:SKILL.md 章节切分与按需读取。
|
||||
//
|
||||
// ## 守的是什么
|
||||
//
|
||||
// skill_info 此前**整份返回** SKILL.md。生产实测最坏一例:
|
||||
//
|
||||
// commondev-discipline v2.3.0 → 983 行 / 53810 字节 / ≈10600 token
|
||||
//
|
||||
// 模型为查「怎么开 PR」(§14,21 行)要把 hmap 插件开发纪律(320 行)
|
||||
// 和红牌一起读进上下文。一次误读一万 token,且挤掉了真正要用的历史。
|
||||
//
|
||||
// ## 这一组判据最容易写废的地方
|
||||
//
|
||||
// ① 用一份**人造的短文档**测切分 —— 那种文档里没有 shell 注释、
|
||||
// 没有嵌套层级、没有围栏代码块,测过了也不代表能切真实文件。
|
||||
// 所以 TestSectionsRealDocument 用的就是生产那份文件。
|
||||
//
|
||||
// ② 只断言"切出了 N 个章节" —— 数量对不代表边界对。所以主判据是
|
||||
// **所有区间拼接 == 原文**,它同时能抓漏行、重行、错位。
|
||||
//
|
||||
// ③ 不测退化路径 —— 没有标题的文档、只有 frontmatter 的文档,
|
||||
// 在真实技能里都存在,必须各自钉住。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// realCommondev 返回生产上那份真实的巨无霸 SKILL.md。
|
||||
//
|
||||
// 路径不存在时 skip(这条判据依赖本地样例文件;在干净检出 / CI
|
||||
// 上它就该跳过,而不是把"文件不在"误报成"切分坏了")。
|
||||
func realCommondev(t *testing.T) string {
|
||||
t.Helper()
|
||||
for _, p := range []string{
|
||||
"/home/program/plugbundle/plugins/commondev/skill/commondev-discipline/SKILL.md",
|
||||
"/home/hologramagent/plugins/commondev/skill/commondev-discipline/SKILL.md",
|
||||
} {
|
||||
if b, err := readFileString(p); err == nil && len(b) > 0 {
|
||||
return b
|
||||
}
|
||||
}
|
||||
t.Skip("本地没有 commondev-discipline 样例文件")
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestSectionsRebuildEqualsOriginal 是**主判据**:
|
||||
// 所有章节区间按序拼接必须逐字节等于原文。
|
||||
//
|
||||
// 它能同时抓三类缺陷:漏行(少了内容)、重行(重复内容)、错位(边界偏)。
|
||||
// 变异:把 end 的取值从 heads[i+1].line 改成 heads[i+1].line-1 ⇒ 每个
|
||||
// 章节末尾丢一行 ⇒ 红。
|
||||
func TestSectionsRebuildEqualsOriginal(t *testing.T) {
|
||||
for name, content := range map[string]string{
|
||||
"real-commondev": realCommondev(t),
|
||||
"nested": "# Top\n\ntext\n\n## Sub A\n\nbody A\n\n### Deep\n\ndeep body\n\n## Sub B\n\nbody B\n",
|
||||
"no-heading": "just text\nno heading at all\n",
|
||||
"only-fm": "---\nname: x\n---\n",
|
||||
"empty": "",
|
||||
} {
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
secs := parseSections(content)
|
||||
var rebuilt []string
|
||||
for _, s := range secs {
|
||||
if s.Start < 0 || s.End > len(lines) || s.Start > s.End {
|
||||
t.Fatalf("[%s] 非法区间 [%d,%d),共 %d 行", name, s.Start, s.End, len(lines))
|
||||
}
|
||||
rebuilt = append(rebuilt, lines[s.Start:s.End]...)
|
||||
}
|
||||
if got := strings.Join(rebuilt, "\n"); got != content {
|
||||
t.Fatalf("[%s] ★ 区间拼接与原文不一致(原文 %d 行,拼回 %d 行)——\n"+
|
||||
"切分漏行 / 重行 / 错位。\n前 200 字符差异:由测试下方打印",
|
||||
name, len(lines), len(rebuilt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSectionsShellCommentIsNotHeading 钉住围栏代码块的感知。
|
||||
//
|
||||
// 真实 SKILL.md 的 bash 示例里有 `# 期望 200;401/403 = 凭据无效`。
|
||||
// 那不是标题。硬按 # 切会把一条 curl 示例拆成碎片 —— 模型拿到的是
|
||||
// 无法执行的半截命令,比不给还糟。
|
||||
//
|
||||
// 变异:删掉 parseSections 里的 fence 判断 ⇒ 本例红。
|
||||
func TestSectionsShellCommentIsNotHeading(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
"# Real Title",
|
||||
"",
|
||||
"```bash",
|
||||
"# 期望 200;401/403 = 凭据无效",
|
||||
"curl -s https://example/api",
|
||||
"```",
|
||||
"",
|
||||
"## Next",
|
||||
"",
|
||||
"body",
|
||||
}, "\n")
|
||||
|
||||
secs := parseSections(content)
|
||||
for _, s := range secs {
|
||||
if strings.Contains(s.Title, "期望") {
|
||||
t.Fatalf("★ 代码块内的 shell 注释被当成标题: %q(行 %d)", s.Title, s.Start+1)
|
||||
}
|
||||
}
|
||||
var titles []string
|
||||
for _, s := range secs {
|
||||
if s.Title != "" {
|
||||
titles = append(titles, s.Title)
|
||||
}
|
||||
}
|
||||
if len(titles) != 2 || titles[0] != "Real Title" || titles[1] != "Next" {
|
||||
t.Fatalf("★ 章节标题错误: %v(期望 [Real Title, Next])", titles)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSectionReturnsWholeSubtree 钉住"点名顶层章节要给整块"。
|
||||
//
|
||||
// 模型问 `## Setup` 时,它要的是 §11–§16 全部(Setup 这一部分),
|
||||
// 不是只有标题那一行。
|
||||
//
|
||||
// 变异:把 `<= root.Level` 改成 `< root.Level` ⇒ 遇到第一个同级标题
|
||||
// 不停止 ⇒ 返回整份文档 ⇒ 本例红。
|
||||
func TestResolveSectionReturnsWholeSubtree(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
"# Doc",
|
||||
"",
|
||||
"## Setup",
|
||||
"",
|
||||
"### 11. alpha",
|
||||
"",
|
||||
"### 12. beta",
|
||||
"",
|
||||
"## Usage",
|
||||
"",
|
||||
"### 21. gamma",
|
||||
}, "\n")
|
||||
secs := parseSections(content)
|
||||
|
||||
start, end, idx, ok := resolveSection(content, secs, "Setup")
|
||||
if !ok {
|
||||
t.Fatal("Setup 未匹配")
|
||||
}
|
||||
body := strings.Join(strings.Split(content, "\n")[start:end], "\n")
|
||||
if !strings.Contains(body, "11. alpha") || !strings.Contains(body, "12. beta") {
|
||||
t.Fatalf("★ Setup 没返回它的子节(§11/§12):\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "21. gamma") {
|
||||
t.Fatalf("★ Setup 越界吃进了 Usage 的内容:\n%s", body)
|
||||
}
|
||||
if secs[idx].Title != "Setup" {
|
||||
t.Fatalf("命中了错误的章节 %q", secs[idx].Title)
|
||||
}
|
||||
|
||||
// 点名子节时只返回该子节。
|
||||
start, end, _, ok = resolveSection(content, secs, "12. beta")
|
||||
if !ok {
|
||||
t.Fatal("12. beta 未匹配")
|
||||
}
|
||||
body = strings.Join(strings.Split(content, "\n")[start:end], "\n")
|
||||
if !strings.Contains(body, "12. beta") || strings.Contains(body, "11. alpha") {
|
||||
t.Fatalf("★ 点子节却返回了别的子节:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSectionToleratesHashPrefix 钉住容错:
|
||||
// 模型常把整行标题(含 ##)当参数传进来。
|
||||
func TestResolveSectionToleratesHashPrefix(t *testing.T) {
|
||||
content := "## Setup: git 操作\n\nbody\n\n## Usage\n\nother\n"
|
||||
secs := parseSections(content)
|
||||
for _, q := range []string{"Setup", "## Setup", "## Setup: git 操作", "setup"} {
|
||||
if _, _, _, ok := resolveSection(content, secs, q); !ok {
|
||||
t.Fatalf("★ 查询 %q 未匹配(应容错)", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderTOCListsLevels 钉住目录只列到指定层级 + 带行数。
|
||||
//
|
||||
// 行数不是装饰:模型据此判断"读这一节值不值",这是它在
|
||||
// "一次吞一万 token" 和 "先读目录" 之间做选择的唯一依据。
|
||||
func TestRenderTOCListsLevels(t *testing.T) {
|
||||
content := "## A\n\nx\n\n### A1\n\ny\n\n#### deep\n\nz\n\n## B\n\nw\n"
|
||||
toc := renderTOC(parseSections(content), 2)
|
||||
if !strings.Contains(toc, "A") || !strings.Contains(toc, "B") {
|
||||
t.Fatalf("★ 目录漏了顶层章节:\n%s", toc)
|
||||
}
|
||||
if strings.Contains(toc, "deep") {
|
||||
t.Fatalf("★ 目录列出了超过 maxLevel 的章节:\n%s", toc)
|
||||
}
|
||||
if !strings.Contains(toc, "行)") {
|
||||
t.Fatalf("★ 目录没有标行数(模型无法判断读取成本):\n%s", toc)
|
||||
}
|
||||
}
|
||||
|
||||
// readFileString 读文件为字符串(避免在测试里到处 os.ReadFile)。
|
||||
func readFileString(p string) (string, error) {
|
||||
b, err := os.ReadFile(p)
|
||||
return string(b), err
|
||||
}
|
||||
@ -60,6 +60,12 @@ func (p *Plugin) registerInfo() {
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "技能名称"},
|
||||
"section": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选。只返回某个章节。不传则返回「目录 + 全文概要」," +
|
||||
"长文档应先用目录定位再按需读章节,避免一次吞掉上万 token。" +
|
||||
"可用章节标题的任一段(不区分大小写,支持子串匹配)。",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
@ -76,13 +82,45 @@ func (p *Plugin) registerInfo() {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "名称: %s\n版本: %s\n状态: %s\n路径: %s\n描述: %s\n",
|
||||
e.sk.Name(), e.sk.Version(), state, e.path, e.sk.Description())
|
||||
|
||||
raw := e.sk.RawContent()
|
||||
sections := parseSections(raw)
|
||||
|
||||
// 带 section:只返回匹配的章节(这是长文档按需读的入口)。
|
||||
if want, _ := args["section"].(string); strings.TrimSpace(want) != "" {
|
||||
start, end, idx, ok := resolveSection(raw, sections, want)
|
||||
if !ok {
|
||||
var b2 strings.Builder
|
||||
b2.WriteString("没有匹配的章节。可用的章节:\n")
|
||||
b2.WriteString(renderTOC(sections, 3))
|
||||
return nil, fmt.Errorf("skill %q 没有匹配 %q 的章节\n%s",
|
||||
name, want, b2.String())
|
||||
}
|
||||
lines := strings.Split(raw, "\n")
|
||||
body := strings.Join(lines[start:end], "\n")
|
||||
fmt.Fprintf(&b, "\n--- %s(第 %d-%d 行)---\n%s\n", sections[idx].Title, start+1, end, body)
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
if tools := e.sk.Tools(); len(tools) > 0 {
|
||||
b.WriteString("文档中声明的操作步骤:\n")
|
||||
for _, td := range tools {
|
||||
fmt.Fprintf(&b, " - %s: %s\n", td.Name, td.Description)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "\n--- SKILL.md ---\n%s", e.sk.RawContent())
|
||||
|
||||
// 短文档(<= shortSkillLines)直接给全文:加一层目录只是噪音。
|
||||
lines := strings.Split(raw, "\n")
|
||||
if len(lines) <= shortSkillLines {
|
||||
fmt.Fprintf(&b, "\n--- SKILL.md ---\n%s", raw)
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// 长文档:先给目录 + 前若干行,模型据此决定读哪一节。
|
||||
fmt.Fprintf(&b, "\n--- 目录(本文 %d 行,请用 section 参数按需读取)---\n", len(lines))
|
||||
b.WriteString(renderTOC(sections, 3))
|
||||
fmt.Fprintf(&b, "\n--- 开头 %d 行 ---\n%s\n", tocHeadLines,
|
||||
strings.Join(lines[:minInt(tocHeadLines, len(lines))], "\n"))
|
||||
return b.String(), nil
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user