mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-15 12:15:11 +00:00
fix(docs): 中文搜不到英文注释的 API —— 补关键词层
## 问题(实测)
SDK 里 100 个有摘要的符号中 **66 个是英文注释**,例如:
RegisterTool registers a tool that the LLM can call.
于是搜「注册工具」——中文受众最自然的问法——**RegisterTool 得分 0,一条都搜不到**。
更糟的是逐字匹配把噪声顶上来了:搜「注册工具」返回 24 条,排第一的是
`SetToolBlocks`(描述里有「工具」二字),`RegisterTool` 根本不在列表里。
## 修法
不改源码注释(那会让代码与文档脱节),而是在检索索引上加一层**人工标注的
中文功能词**:`tools/apidoc/keywords.json`。
- `rules`:按符号名前缀/子串批量覆盖(`Register*` 全带「注册」,`*Memory*` 带「记忆」)
- `symbols`:逐符号补充(重点 API、或规则覆盖不到的)
词只进 `api-index.json` 的 `g` 字段,**不影响页面展示**;检索结果里会显示
(「为何命中」),读者能理解排序依据。
同时把中文逐字匹配从主信号降为**弱信号**(要求 60% 以上字符命中)——
它正是噪声来源:凡是含「工具」二字的说明都会被「注册工具」匹上。
## 结果
| 查询 | 修改前 | 修改后 |
|---|---|---|
| 注册工具 | 24 条,RegisterTool 缺席 | **7 条,RegisterTool 第一** |
| 崩溃 | 1 条 | 2 条(SetAutoRestart + AutoRestart)|
| InjectText | 7 条(含重复)| 6 条 |
| memory.recall | 1 条 | 1 条(不变)|
顺带修掉索引重复:接口会同时作为 `type` 符号与接口本身被加两次
(`MemoryAPI` 等 11 个各重复一条)。现在接口只走接口那条路径,索引 200 → 189 条。
keywords.json 是**可选**的:读不到只警告不中断,检索退化为原行为。
This commit is contained in:
94
tools/apidoc/gensite/keywords.go
Normal file
94
tools/apidoc/gensite/keywords.go
Normal file
@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 中文检索关键词。
|
||||
//
|
||||
// 问题:SDK 里 100 个有摘要的符号中 **66 个是英文注释**
|
||||
// (`RegisterTool registers a tool that the LLM can call.`),
|
||||
// 于是搜「注册工具」找不到 RegisterTool —— 而受众主要是中文。
|
||||
//
|
||||
// 解法不是翻译源码注释(那会让代码与文档脱节),而是给检索索引补一层
|
||||
// **人工标注的功能词**:不影响页面展示,只让中文能搜到。
|
||||
//
|
||||
// 词表在 tools/apidoc/keywords.json。规则用「前缀 → 词」批量覆盖
|
||||
// (Register* 全带「注册」),少数重点符号再逐条补充。
|
||||
type keywordTable struct {
|
||||
Rules []keywordRule `json:"rules"`
|
||||
Symbols map[string][]string `json:"symbols"`
|
||||
}
|
||||
|
||||
type keywordRule struct {
|
||||
// Prefix 匹配符号名前缀;Contains 匹配名字里是否含该子串。二选一。
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
Contains string `json:"contains,omitempty"`
|
||||
Words []string `json:"words"`
|
||||
}
|
||||
|
||||
// loadKeywords 读词表。找不到就返回空表(不报错中断)——
|
||||
// 检索关键词是**增强**,缺失时退化为原行为,不该让构建失败。
|
||||
func loadKeywords() (*keywordTable, error) {
|
||||
path := keywordPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return &keywordTable{}, err
|
||||
}
|
||||
var t keywordTable
|
||||
if err := json.Unmarshal(data, &t); err != nil {
|
||||
return &keywordTable{}, fmt.Errorf("解析 %s: %w", path, err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// lookup 返回某符号的中文检索词(可能为空)。
|
||||
func (t *keywordTable) lookup(name, docBrief string) []string {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(ws []string) {
|
||||
for _, w := range ws {
|
||||
w = strings.TrimSpace(w)
|
||||
if w == "" || seen[w] {
|
||||
continue
|
||||
}
|
||||
seen[w] = true
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
for _, r := range t.Rules {
|
||||
if r.Prefix != "" && strings.HasPrefix(name, r.Prefix) {
|
||||
add(r.Words)
|
||||
}
|
||||
if r.Contains != "" && strings.Contains(name, r.Contains) {
|
||||
add(r.Words)
|
||||
}
|
||||
}
|
||||
add(t.Symbols[name])
|
||||
return out
|
||||
}
|
||||
|
||||
// keywordPath 找 keywords.json:可执行文件旁 → 源码目录 → 工作目录。
|
||||
func keywordPath() string {
|
||||
candidates := []string{}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "keywords.json"))
|
||||
}
|
||||
candidates = append(candidates,
|
||||
filepath.Join("tools", "apidoc", "keywords.json"),
|
||||
"keywords.json",
|
||||
)
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
@ -180,7 +180,7 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 先收集所有待渲染的符号名,再扫 example/ 取真实用法。
|
||||
// 收集所有待渲染的符号名,再扫 example/ 取真实用法。
|
||||
want := map[string]bool{}
|
||||
for _, s := range pkg.Symbols {
|
||||
if s.Exported {
|
||||
@ -192,25 +192,41 @@ func main() {
|
||||
}
|
||||
usages := scanUsages(*exDir, want)
|
||||
|
||||
// 中文检索关键词(弥补英文注释搜不到中文的问题)。
|
||||
kw, err := loadKeywords()
|
||||
if err != nil {
|
||||
log.Printf("警告:未加载中文检索关键词(%v),中文检索将只能命中中文注释", err)
|
||||
}
|
||||
|
||||
used := map[string]bool{}
|
||||
var index []indexEntry
|
||||
|
||||
for _, sec := range sections {
|
||||
// 本节的接口(先算出来,下面要用它排除重复的 type 符号)。
|
||||
var ifaces []Interface
|
||||
ifaceNames := map[string]bool{}
|
||||
for _, it := range pkg.Interfaces {
|
||||
if sec.Match(Symbol{Kind: "type", Name: it.Name}) {
|
||||
ifaces = append(ifaces, it)
|
||||
ifaceNames[it.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
var picked []Symbol
|
||||
for _, s := range pkg.Symbols {
|
||||
if !s.Exported || !sec.Match(s) || used[key(s)] {
|
||||
continue
|
||||
}
|
||||
// 接口已作为 ifaces 单独渲染(带方法表),这里跳过它的 `type`
|
||||
// 符号,否则同一个接口会既出现在符号区、又出现在接口区,
|
||||
// 检索索引里也会出现两条。
|
||||
if s.Kind == "type" && ifaceNames[s.Name] {
|
||||
used[key(s)] = true
|
||||
continue
|
||||
}
|
||||
picked = append(picked, s)
|
||||
used[key(s)] = true
|
||||
}
|
||||
// 该章节涉及的接口(其方法单独列在接口下,避免重复)。
|
||||
var ifaces []Interface
|
||||
for _, it := range pkg.Interfaces {
|
||||
if sec.Match(Symbol{Kind: "type", Name: it.Name}) {
|
||||
ifaces = append(ifaces, it)
|
||||
}
|
||||
}
|
||||
if len(picked) == 0 && len(ifaces) == 0 {
|
||||
continue
|
||||
}
|
||||
@ -224,6 +240,7 @@ func main() {
|
||||
N: s.Name, S: s.Signature, D: s.DocBrief,
|
||||
K: s.Kind, R: s.Recv, P: sec.File,
|
||||
B: s.BuiltinOnly, F: s.File, L: s.Line,
|
||||
G: kw.lookup(s.Name, s.DocBrief),
|
||||
})
|
||||
}
|
||||
// 接口本身与其方法也要进索引。此前只加了顶层符号,导致
|
||||
@ -238,6 +255,7 @@ func main() {
|
||||
N: m.Name, S: m.Signature, D: m.DocBrief,
|
||||
K: "method", R: it.Name, P: sec.File,
|
||||
B: m.BuiltinOnly, F: m.File, L: m.Line,
|
||||
G: kw.lookup(m.Name, m.DocBrief),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -253,6 +271,7 @@ func main() {
|
||||
index = append(index, indexEntry{
|
||||
N: c.Name, S: "const " + c.Name, D: c.DocBrief,
|
||||
K: "const", P: "constants", F: c.File, L: c.Line,
|
||||
G: kw.lookup(c.Name, c.DocBrief),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -300,6 +319,14 @@ type indexEntry struct {
|
||||
B bool `json:"b"` // 仅内置
|
||||
F string `json:"f"` // 源文件
|
||||
L int `json:"l"` // 行号
|
||||
// G 是中文检索关键词(同义词/功能词)。
|
||||
//
|
||||
// 为什么需要:SDK 里 100 个有摘要的符号中 **66 个是英文注释**
|
||||
// (`RegisterTool registers a tool that the LLM can call.`),
|
||||
// 于是搜「注册工具」根本找不到 RegisterTool —— 而中文是主要受众。
|
||||
// 这些词由 tools/apidoc/keywords.json 维护,不改源码注释,
|
||||
// 也不依赖机器翻译。
|
||||
G []string `json:"g,omitempty"`
|
||||
}
|
||||
|
||||
func key(s Symbol) string {
|
||||
@ -522,16 +549,19 @@ func renderExamples(usages map[string][]Usage) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// dedupeIndex 按「接收者.名称」去重。无接收者的用「类别.名称」。
|
||||
// 同名但不同接收者(如 MemoryAPI.Recall 与 IOInjector.InjectText)都保留。
|
||||
// dedupeIndex 按「接收者.名称.类别」去重。
|
||||
//
|
||||
// 为什么带类别:接口会被两条路径加进索引——一次作为 `type` 符号(来自
|
||||
// pkg.Symbols),一次作为接口本身(来自 pkg.Interfaces)。两者名字相同、
|
||||
// 接收者都为空,只用「名称」做键漏不掉;带上类别才能区分(并且保留两条
|
||||
// 也不算错,但会让结果重复,所以统一按 kind 去重)。
|
||||
//
|
||||
// 同名但不同接收者的(如 MemoryAPI.Recall 与 IOInjector.InjectText)都保留。
|
||||
func dedupeIndex(in []indexEntry) []indexEntry {
|
||||
seen := map[string]bool{}
|
||||
out := in[:0]
|
||||
for _, e := range in {
|
||||
k := e.R + "." + e.N
|
||||
if e.R == "" {
|
||||
k = e.K + "." + e.N
|
||||
}
|
||||
k := e.K + "|" + e.R + "." + e.N
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user