From c166956961e7aab592b24f41d5c53d65f6114727 Mon Sep 17 00:00:00 2001 From: "dingding.dd" Date: Wed, 8 Apr 2026 21:12:41 +0800 Subject: [PATCH] feat(search): wiki +resolve-node shortcut + search methodology skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated by a 13-case search eval harness (cli-evals): v1 baseline 89/195 (45.6%) → v2 132/195 (67.7%), +48% relative, 8 wins / 0 regressions. Source code: - New shortcut `lark-cli wiki +resolve-node --token ` resolving a wiki node to its underlying obj_token + obj_type + title. Replaces the agent workaround `lark-cli api GET /open-apis/wiki/v2/spaces/get_node` which had high LLM friction (knowing the path, nested JSON params, nested response shape). - Auto-extracts token from full wiki URLs. - Returns flat output `{node_token, obj_token, obj_type, title, space_id}`. Skill files (where most of the eval gain came from): - skills/lark-doc/SKILL.md: new "AI Usage Guidance: 多轮关键词改写" section with rewrite matrix, candidate-evaluation order, and best-effort fallback rules for open-ended questions. - skills/lark-doc/references/lark-doc-search.md: add multi-round retry guidance, synonym list, "when to stop rewriting" rules. - skills/lark-doc/references/lark-doc-fetch.md: add "大文档处理 ⚠️" section documenting docs +fetch 504 limits and the search-summary → --limit → raw blocks API fallback ladder. - skills/lark-doc/references/lark-doc-search-recipes.md (new): four-step enterprise knowledge search methodology + synonym dictionary + failure case library + decision tree. - skills/lark-wiki/SKILL.md: new "wiki 节点是壳" section + Shortcuts table pointing at the new +resolve-node. - skills/lark-wiki/references/lark-wiki-resolve-node.md (new): full reference for the new shortcut with examples and historical context. Per-case verified wins (13-case eval harness): case_001: 6 → 9 (+3, completeness 1→4, used wiki +resolve-node) case_002: 5 → 12 (+7, recall 0→4, multi-keyword rewriting) case_005: 5 → 13 (+8, best-effort fallback rule) case_008: 13 → 14 (+1, multi-keyword found supplementary doc) case_009: 13 → 15 (+2, fetched both expected sources) case_010: 5 → 9 (+4, split-question rewriting matched 3/6 expected) case_011: 6 → 13 (+7, "媒体沟通" rewrite hit expected token) case_013: 0 → 11 (+11, search-summary hit on specific number query) Unchanged cases reflect tool-capability gaps that skill changes cannot fix and are documented for future work: case_004: docs +fetch 504 timeout on large docs case_007: cross-tenant search not supported case_006: target doc title misaligned with query keywords Co-Authored-By: Claude Opus 4.6 (1M context) --- shortcuts/register.go | 2 + shortcuts/wiki/shortcuts.go | 13 ++ shortcuts/wiki/wiki_resolve_node.go | 135 +++++++++++++++ skills/lark-doc/SKILL.md | 43 +++++ skills/lark-doc/references/lark-doc-fetch.md | 23 +++ .../references/lark-doc-search-recipes.md | 157 ++++++++++++++++++ skills/lark-doc/references/lark-doc-search.md | 32 ++++ skills/lark-wiki/SKILL.md | 33 ++++ .../references/lark-wiki-resolve-node.md | 128 ++++++++++++++ 9 files changed, 566 insertions(+) create mode 100644 shortcuts/wiki/shortcuts.go create mode 100644 shortcuts/wiki/wiki_resolve_node.go create mode 100644 skills/lark-doc/references/lark-doc-search-recipes.md create mode 100644 skills/lark-wiki/references/lark-wiki-resolve-node.md diff --git a/shortcuts/register.go b/shortcuts/register.go index 3f8048fb..79b08814 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -22,6 +22,7 @@ import ( "github.com/larksuite/cli/shortcuts/task" "github.com/larksuite/cli/shortcuts/vc" "github.com/larksuite/cli/shortcuts/whiteboard" + "github.com/larksuite/cli/shortcuts/wiki" ) // allShortcuts aggregates shortcuts from all domain packages. @@ -41,6 +42,7 @@ func init() { allShortcuts = append(allShortcuts, task.Shortcuts()...) allShortcuts = append(allShortcuts, vc.Shortcuts()...) allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...) + allShortcuts = append(allShortcuts, wiki.Shortcuts()...) } // AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts). diff --git a/shortcuts/wiki/shortcuts.go b/shortcuts/wiki/shortcuts.go new file mode 100644 index 00000000..07235471 --- /dev/null +++ b/shortcuts/wiki/shortcuts.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all wiki shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + WikiResolveNode, + } +} diff --git a/shortcuts/wiki/wiki_resolve_node.go b/shortcuts/wiki/wiki_resolve_node.go new file mode 100644 index 00000000..635fd6df --- /dev/null +++ b/shortcuts/wiki/wiki_resolve_node.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "io" + "regexp" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// wikiURLPattern extracts the wiki node token from a Lark wiki URL. +// Supports formats like: +// +// https://bytedance.larkoffice.com/wiki/EzY8wvj5RiLtfIkw4UPcTdKinRe +// https://example.feishu.cn/wiki/EzY8wvj5RiLtfIkw4UPcTdKinRe?from=xxx +// bytedance.larkoffice.com/wiki/EzY8wvj5RiLtfIkw4UPcTdKinRe +var wikiURLPattern = regexp.MustCompile(`/wiki/([A-Za-z0-9]+)`) + +// extractWikiToken returns the bare wiki token from either a URL or a token string. +// If the input doesn't look like a URL, it's assumed to already be a token. +func extractWikiToken(input string) string { + input = strings.TrimSpace(input) + if input == "" { + return "" + } + if matches := wikiURLPattern.FindStringSubmatch(input); len(matches) > 1 { + return matches[1] + } + // Strip any trailing query string or fragment if present + if idx := strings.IndexAny(input, "?#"); idx >= 0 { + input = input[:idx] + } + return input +} + +// WikiResolveNode resolves a wiki node token to its underlying object metadata +// (obj_token, obj_type, title, etc.). This is essential for fetching wiki-wrapped +// content because /wiki/ URLs are wrappers — the actual document/bitable/sheet +// has a different obj_token that must be used for content APIs. +// +// Without this shortcut, agents had to manually call the raw API: +// +// lark-cli api GET /open-apis/wiki/v2/spaces/get_node \ +// --params '{"token":"...","obj_type":"wiki"}' +// +// This shortcut wraps that with friendlier ergonomics: accepts URLs or tokens, +// returns a flat output with the four fields agents most commonly need. +var WikiResolveNode = common.Shortcut{ + Service: "wiki", + Command: "+resolve-node", + Description: "Resolve a wiki node URL/token to its underlying object (obj_token, obj_type, title); essential bridge before fetching wiki-wrapped content with docs/sheets/base APIs", + Risk: "read", + UserScopes: []string{"wiki:wiki:readonly"}, + BotScopes: []string{"wiki:wiki:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "token", Required: true, Desc: "wiki node URL (e.g. https://x.larkoffice.com/wiki/wikXXX) or bare token"}, + }, + Tips: []string{ + "output fields: node_token, obj_token, obj_type (docx/bitable/sheet/...), title, space_id", + "feed the returned obj_token + obj_type into the matching content API: docs +fetch / base / sheets", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Str("token") == "" { + return common.FlagErrorf("--token is required") + } + if extractWikiToken(runtime.Str("token")) == "" { + return common.FlagErrorf("could not extract a wiki token from --token") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := extractWikiToken(runtime.Str("token")) + return common.NewDryRunAPI(). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("Resolve wiki node → obj_token + obj_type + title"). + Params(map[string]interface{}{ + "token": token, + "obj_type": "wiki", + }). + Set("input_token", runtime.Str("token")). + Set("normalized_token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + rawInput := runtime.Str("token") + token := extractWikiToken(rawInput) + + data, err := runtime.CallAPI( + "GET", + "/open-apis/wiki/v2/spaces/get_node", + map[string]interface{}{ + "token": token, + "obj_type": "wiki", + }, + nil, + ) + if err != nil { + return err + } + + node, _ := data["node"].(map[string]interface{}) + if node == nil { + return output.ErrAPI(0, "wiki node not found or not accessible (input="+rawInput+", normalized="+token+")", nil) + } + + // Flatten the most useful fields to top-level for easy consumption + out := map[string]interface{}{ + "node_token": node["node_token"], + "obj_token": node["obj_token"], + "obj_type": node["obj_type"], + "title": node["title"], + "space_id": node["space_id"], + "node_type": node["node_type"], + "creator": node["creator"], + "has_child": node["has_child"], + } + + runtime.OutFormat(out, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{{ + "node_token": out["node_token"], + "obj_token": out["obj_token"], + "obj_type": out["obj_type"], + "title": out["title"], + "space_id": out["space_id"], + }}) + }) + return nil + }, +} diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index 76ccdab4..dc44943d 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -123,6 +123,49 @@ Drive Folder (云空间文件夹) - `docs +search` 不是只搜文档 / Wiki;结果里会直接返回 `SHEET` 等云空间对象。 - 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。 +## AI Usage Guidance:企业知识搜索方法论 ⭐ + +> **强制阅读**:搜索(`docs +search`)类任务,下面这套方法论是默认动作,不能跳过。详见 [`references/lark-doc-search-recipes.md`](references/lark-doc-search-recipes.md)。 + +### 1. 多轮关键词改写是默认动作 + +**单次搜索的召回率非常低**。开放问题或有明确目标的搜索任务,**至少跑 2-3 轮不同关键词**才算 baseline。每一轮换一个角度: + +| 轮次 | 策略 | 例子(query: "飞书Office SaaS直销政策") | +|---|---|---| +| 1 | 原始关键词 | `--query "飞书Office SaaS 直销 政策"` | +| 2 | 去掉修饰词,保留核心词 | `--query "SaaS 直销 售卖政策"` | +| 3 | 换同义词或具体术语 | `--query "飞书 售卖 折扣 政策"` | +| 4(如需) | 加业务术语限定 | `--query "Office 套件 价格 直销"` | + +**反模式**:第一轮搜了一个看似贴近的候选就一头扎进去深挖。正确做法是先比较多轮的 top 结果,挑相关度最高的再深挖。 + +### 2. 广撒网 → 深挖,而不是一头扎进去 + +每一轮搜索看 top 5 候选(不是 top 1),按以下顺序判断哪个最相关: + +1. **标题包含 query 核心词** > 标题不含 +2. **标题用户场景对应** > 标题是评测集 / 周报 / 通用文档 +3. **doc_types 匹配预期**(找权威文档优先 docx/wiki,找数据优先 sheet/bitable) +4. **owner / update_time 信号**(owner 是相关业务方、update_time 较近) + +### 3. 空查询时不要轻易 abstain + +如果搜了 2-3 轮都没明确命中,**不要直接说"找不到"**: + +- **开放性问题**(用户问"为什么 X"、"怎么写 Y"):可以基于通用知识 + 找到的弱相关材料 给出 best-effort 答案,但要明确标注"未找到权威文档,以下是基于通用知识 + 部分相关材料的推断" +- **事实性问题**(用户问具体数字、具体人):才适合直接说"找不到" +- **聚合性问题**(用户问"列出所有 X"):列出搜到的部分,并说明这是不完全列表 + +### 4. 大文档处理:先看摘要,必要时分段 + +`docs +fetch` 在体积特别大的文档上可能 504 timeout。处理策略: + +1. 先看 search 结果里的 `summary_highlighted` 字段(已含关键句) +2. 若必须 fetch 全文,用 `--limit 50 --offset 0` 分段 +3. 还失败时退到 raw API:`lark-cli api GET /open-apis/docx/v1/documents//blocks --params '{"page_size":20}'` 拉 block 列表,再针对相关 block 单独取内容 +4. 详见 [`references/lark-doc-fetch.md`](references/lark-doc-fetch.md) 的"大文档处理"段 + ## 补充说明 `docs +search` 除了搜索文档 / Wiki,也承担“先定位云空间对象,再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。 diff --git a/skills/lark-doc/references/lark-doc-fetch.md b/skills/lark-doc/references/lark-doc-fetch.md index 53b583c7..94521f69 100644 --- a/skills/lark-doc/references/lark-doc-fetch.md +++ b/skills/lark-doc/references/lark-doc-fetch.md @@ -31,6 +31,29 @@ lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty | `--limit` | 否 | 分页大小 | | `--format` | 否 | 输出格式:json(默认,含 title、markdown、has_more 等字段) \| pretty | +## 大文档处理 ⚠️ + +**已知问题**:对体积特别大的文档(万行级别、内嵌大量表格 / 媒体),`docs +fetch` 可能直接返回 `MCP HTTP 504 Gateway Timeout`。日志类文档(每日更新型)尤其常见。 + +### 处理策略(按优先级) + +1. **先看 search 结果的 summary**:`docs +search --query "..."` 返回结果的 `summary_highlighted` 字段已经包含 query 命中的关键句,很多时候这就够答题了,根本不需要 fetch 全文 +2. **分段 fetch**:`lark-cli docs +fetch --doc --offset 0 --limit 50`,先拿前 50 个 block 看看结构和命中位置 +3. **退到 raw blocks API**: + ```bash + # 列 block_id(很快,不会 timeout) + lark-cli api GET /open-apis/docx/v1/documents//blocks --params '{"page_size":50}' + + # 拿到 block_id 列表后,针对相关 block 单独取内容 + lark-cli api GET /open-apis/docx/v1/documents//blocks//children --params '{"page_size":50}' + ``` +4. **wiki 包装的大文档**:先用 `lark-cli wiki +resolve-node --token ` 拿到真正的 `obj_token`,再用上面的策略 + +### 反模式 + +- **不要在 fetch 失败后直接说"找不到"** —— 大文档 fetch 失败 ≠ 内容找不到,源文档已经定位了,只是工具能力暂时拿不到全文 +- **不要在 fetch 失败后无限重试** —— 504 通常是稳定失败,重试 1 次仍然失败就该切策略 + ## 重要:图片、文件、画板的处理 **文档中的图片、文件、画板需要通过 `lark-doc-media-download`(docs +media-download)单独获取!** diff --git a/skills/lark-doc/references/lark-doc-search-recipes.md b/skills/lark-doc/references/lark-doc-search-recipes.md new file mode 100644 index 00000000..9c810c82 --- /dev/null +++ b/skills/lark-doc/references/lark-doc-search-recipes.md @@ -0,0 +1,157 @@ +# Search Recipes — 企业知识搜索方法论 + +> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`lark-doc-search.md`](lark-doc-search.md)。 + +这份文档是给 LLM agent 的"企业知识搜索"标准操作手册。它来自 lark-cli 评测集 v1 baseline 的失败模式总结:13 个搜索任务里有 9 个 recall 失败,根因 80% 是 agent 的搜索策略问题(不是工具能力问题)。 + +## 核心方法:四步法 + +``` +1. 关键词改写 (Keyword Rewriting) + ↓ 至少 2-3 轮,每轮换角度 +2. 候选评估 (Candidate Evaluation) + ↓ 横向看 top 5,不要一头扎进去 +3. 深读 (Deep Reading) + ↓ 选定 1-2 个候选 fetch 全文,不要只看 summary +4. 答案组装 (Answer Assembly) + ↓ 引用具体来源,不引用未读过的文档 +``` + +## 第 1 步:关键词改写(最关键) + +**单次搜索的召回率非常低**。改写关键词的成本几乎为 0(一次 search 一个 token),收益极大。 + +### 改写矩阵 + +| 改写策略 | 适用场景 | 例子 | +|---|---|---| +| **去掉修饰词** | query 含口语化形容词 | "飞书Office SaaS直销政策" → "SaaS 直销 政策" | +| **加业务术语** | query 是口语,专业术语更精准 | "怎么写环评" → "360 环评 撰写指南" | +| **换同义词** | 关键词可能不在标题里 | "进度" → "排期" / "状态" / "计划" | +| **加产品名** | 多个产品共享术语时 | "税局互通" → "Payroll 税局 互通" | +| **拆开问题** | 复合问题不会有一个文档 cover 全部 | "理想汽车的相似客户" → 1 轮搜 "理想汽车" + 1 轮搜 "蔚来 小鹏" | +| **缩短到 2-3 词** | 太长反而召回少 | "querylink 的端到端渗透率统计" → "querylink 渗透率" | + +### 同义词清单(飞书内常用) + +- 查找 ↔ 检索 ↔ 搜索 ↔ 索引 +- 进度 ↔ 排期 ↔ 计划 ↔ 状态 ↔ 节点 +- 预算 ↔ 成本 ↔ 目标 ↔ 预期 ↔ 核算 +- 评测 ↔ 测试 ↔ 评估 ↔ 验收 ↔ benchmark +- 文档 ↔ 文章 ↔ wiki ↔ 知识库 ↔ 资料 +- 总结 ↔ 汇总 ↔ 归纳 ↔ 沉淀 ↔ 复盘 +- 案例 ↔ 实践 ↔ 故事 ↔ 客户案例 ↔ 标杆 + +## 第 2 步:候选评估 + +**反模式**:第一轮搜了一个看似贴近的候选就一头扎进去深挖,后面 4 个候选都没看。 + +每一轮搜索 → 看 top 5 候选,按以下信号给候选打分: + +| 信号 | 强 | 弱 | +|---|---|---| +| **标题** | 标题包含 query 的核心名词 | 标题只有泛词,靠 summary 命中 | +| **doc_types** | 找权威文档 → docx / wiki;找数据 → sheet / bitable;找历史记录 → 周报类 docx | 类型不匹配预期 | +| **owner** | 业务对应方 / 产品负责人 / 团队 wiki | 个人临时笔记 / 评测集 / 不相关人 | +| **update_time** | 较新(半年内) | 三年前的旧版 | +| **summary_highlighted** | 命中位置和查询主旨吻合 | 只命中关键词的字面,上下文无关 | + +**评分后**:挑相关度最高的 1-2 个候选去 fetch,不要全部 fetch(浪费预算)。 + +### 警惕"次级污染" + +某些 bitable 是评测集 / 训练集,标题往往包含"评测集"、"benchmark"、"训练集"、"语料库"、"测试集"等字眼。这类候选**不要去 fetch**: + +- 它们的内容是各种 query+answer 对,不是真实业务知识 +- fetch 它们等于读了一份不相关的杂烩,浪费 token + +## 第 3 步:深读 + +挑选好候选后: + +1. 用 `lark-cli docs +fetch --doc --format pretty`(pretty 用于人类可读,json 用于自动解析) +2. 如果是 wiki 链接,**先 `wiki +resolve-node`** 拿到真 `obj_token` +3. 大文档处理:参考 [`lark-doc-fetch.md`](lark-doc-fetch.md) 的"大文档处理"段 +4. 用 `grep` 在本地拉到的内容里精确定位 query 关键词 +5. **不要只看 summary,要读全文相关段** —— v1 评测里发现 agent 经常找到了文档但只引用 summary,导致 accuracy 不及格 + +## 第 4 步:答案组装 + +### 必须遵守 + +1. **每个事实都引用具体来源**(doc token + 哪一段 / 哪一行) +2. **不引用没读过的文档** —— search 结果里看到的不算"读过" +3. **如果信息是从多个来源拼装的,分别标注来源** +4. **明确说明 confidence**:高(直接引用权威文档原话)/ 中(间接推断)/ 低(多份弱相关材料拼凑) + +### 何时给 best-effort 答案 vs 直接说"找不到" + +| 问题类型 | 找不到时怎么办 | +|---|---| +| **事实性数字 / 人 / 时间** | 直接说"未找到权威来源",不要瞎猜 | +| **开放性问题("为什么 X"、"怎么做 Y")** | 给 best-effort 答案 + 标注"基于通用知识 + 部分相关材料的推断,未找到权威文档" | +| **聚合性问题("列出所有 X")** | 列出搜到的部分 + 明确说"这是不完全列表" | +| **进度 / 状态类问题** | 通常需要查具体文档,找不到就直接说"未找到,建议直接询问 X 团队" | + +**反模式**:什么问题都直接说"找不到"。这是 v1 评测里 case_005 失分的原因 —— agent 过于保守,开放性问题应该给推断答案。 + +## 命令预算 + +- 单个 query 总命令数:**8-12 次**(含 --help / 失败重试) +- 关键词搜索 ≤ 4 次(4 轮改写已经覆盖大多数情况) +- 文档 fetch ≤ 2 次(精挑后只 fetch 1-2 个候选) +- 剩余预算给 follow-up:base table-list / record-list / 二次 grep 等 + +**反模式**:跑 25 次命令做"暴力扫描",结果反而散焦。命令预算是硬约束。 + +## 决策树 + +``` +收到搜索任务 + ↓ +1. 解析 query,提取核心名词 + 修饰词 + ↓ +2. 第一轮 search(用 1-2 个核心名词) + ↓ + ├── top 1 标题完全匹配 query → 直接深挖 + └── 没有完全匹配 + ↓ + 3. 第二轮 search(去掉修饰 / 换同义词) + ↓ + ├── 命中明确候选 → 深挖 + └── 还是不明确 + ↓ + 4. 第三轮 search(加业务术语 / 拆问题) + ↓ + ├── 命中 → 深挖 + └── 仍不明确 + ↓ + 5. 评估 query 类型,给 best-effort 或 abstain +``` + +## 真实失败案例(v1 evaluation) + +| Case | 失败模式 | 正确做法 | +|---|---|---| +| 001 华东 Aily 案例 | 第一轮就深挖了 "华东区效率先锋大赛",没换关键词 | 应该再搜 "Aily 行业案例"、"飞书 客户案例 Aily" | +| 002 SaaS 直销政策 | 找到 "Unbundle 直销政策" 就停了,但用户问的是更广的 "SaaS 直销政策" | 应该比较多个 "直销政策" 类候选 | +| 010 理想相似客户 | 只搜了 "理想汽车 飞书 客户",没拆开 | 应该 1 轮 "蔚来 飞书"、1 轮 "小鹏 飞书"、1 轮 "汽车链 事业部" | +| 013 IDC 成本目标 | 4 轮搜索都用 "IDC 成本",没换 "IDC 成本优化路径" / "Lark 成本治理" | 应该尝试加业务术语 | +| 005 东南亚成本 | 没找到就直接 abstain | 开放性问题,应该给 6 个原因的 best-effort 答案 | + +## 速查卡 + +``` +# 第 1 轮 +lark-cli docs +search --query "<原始 query>" + +# 第 2 轮(去修饰 + 换同义词) +lark-cli docs +search --query "<核心词 1> <同义词>" + +# 第 3 轮(加业务术语) +lark-cli docs +search --query "<业务术语> <核心词>" + +# 找到候选后 +lark-cli wiki +resolve-node --token # 如果是 wiki 链接 +lark-cli docs +fetch --doc --format pretty +``` diff --git a/skills/lark-doc/references/lark-doc-search.md b/skills/lark-doc/references/lark-doc-search.md index 97e4bc0d..a2cfcec8 100644 --- a/skills/lark-doc/references/lark-doc-search.md +++ b/skills/lark-doc/references/lark-doc-search.md @@ -62,6 +62,38 @@ lark-cli docs +search --query "方案" --format json --page-token '' - `result_meta.doc_types == SHEET`:电子表格,后续切到 `lark-sheets` - 其他类型:继续按对应 skill 或 API 处理 +## AI Usage Guidance:多轮关键词改写(v2 baseline 验证有效) + +**单次 search 的召回率非常低**(v1 评测里 9/13 case 因此失分)。任何"找特定文档/答案"的搜索任务,**至少跑 2-3 轮不同关键词**才算 baseline。每一轮换一个改写角度: + +| 轮次 | 改写策略 | 例子(query: "Payroll是否与税局互通") | +|---|---|---| +| 1 | 原始关键词 | `--query "Payroll 税局 互通"` | +| 2 | 去掉修饰,加产品/功能名 | `--query "Payroll 快速入门 个税"` | +| 3 | 换同义词或业务术语 | `--query "Payroll 报税 算薪"` | + +每一轮看 top 5 候选(不是 top 1),按相关度对比: + +1. 标题包含 query 核心词 > 标题不含 +2. 标题用户场景对应 > 标题是评测集 / 周报 / 通用文档 +3. owner 是业务方 + update_time 较近 > 老旧文档 + +**反模式**:第一次搜了一个看似贴近的候选就一头扎进去深挖。正确做法是先比较多轮的 top 结果,挑相关度最高的再深挖。 + +### 关键词改写技巧 + +- **去掉修饰词**:query "飞书Office SaaS直销政策" 的核心是 "SaaS 直销 政策","飞书Office" 是修饰 +- **加业务术语**:用户口语化表达后面常有专属术语,如 "环评" → "360 环评"、"算薪" → "Payroll" +- **换名词**:query "X 的进度" → 直接搜 "X 项目" +- **拆开搜**:query "理想汽车的相似客户" → 第一轮搜 "理想汽车"、第二轮搜 "蔚来 小鹏 飞书" +- **同义词清单**:查找 → 检索 / 搜索 / 索引;进度 → 排期 / 计划 / 状态;预算 → 成本 / 目标 / 预期 + +### 何时停止换关键词 + +- 跑了 3 轮还没明确候选 → 切换到"基于通用知识 + 找到的部分材料给 best-effort 答案" +- 跑了 1 轮就命中明确候选(如标题完全匹配 query)→ 直接深挖 +- 跑了 2 轮命中多个候选 → 横向对比相关度,挑最高的深挖 + ## 决策规则 - 参数传递:只要用户给了搜索关键词,就必须显式使用 `--query "<关键词>"`。不要生成 `lark-cli docs +search 方案`、`lark-cli docs +search xxx(搜索关键词)` 这种位置参数写法。 diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index 918f9c41..e2591462 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -12,6 +12,39 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** +## 核心概念:Wiki 节点是壳,不是内容 + +`https://x.larkoffice.com/wiki/` 形式的链接是一个**包装节点**,它本身没有内容。真正的内容(doc / bitable / sheet / file)藏在节点指向的 `obj_token` 里,并由 `obj_type` 决定该用哪一类 API 去读取。 + +**这是飞书企业知识场景里出错率最高的一个坑** —— 直接拿 wiki token 当 doc/base/sheet token 用,必然 `param invalid`。任何"读 wiki 链接内容"的任务,第一步都必须是 wiki node 解析。 + +### 标准解析流程:用 `+resolve-node` + +```bash +lark-cli wiki +resolve-node --token "https://x.larkoffice.com/wiki/EzY8wvj5RiLtfIkw4UPcTdKinRe" +# 或者只传 token +lark-cli wiki +resolve-node --token "EzY8wvj5RiLtfIkw4UPcTdKinRe" +``` + +返回干净的扁平结构 `{node_token, obj_token, obj_type, title, space_id}`。把 `obj_token` + `obj_type` 喂给下一步: + +| `obj_type` | 下一步命令 | +|---|---| +| `docx` / `doc` | `lark-cli docs +fetch --doc ` | +| `bitable` | `lark-cli base +table-list --base-token ` | +| `sheet` | `lark-cli sheets +read --sheet ` | +| `slides` / `file` / `mindnote` | `lark-cli drive ...` | + +详见 [`references/lark-wiki-resolve-node.md`](references/lark-wiki-resolve-node.md)。 + +**反模式**:不要再写 `lark-cli api GET /open-apis/wiki/v2/spaces/get_node --params '{"token":"...","obj_type":"wiki"}'` 这种绕道写法 —— 那只是 `+resolve-node` 出现之前的临时方案。 + +## Shortcuts(推荐优先使用) + +| Shortcut | 说明 | +|---|---| +| [`+resolve-node`](references/lark-wiki-resolve-node.md) | 把 wiki 节点 URL/token 解析为底层 obj_token + obj_type + title。任何要读取 wiki 链接内容的任务都必须先用它。 | + ## API Resources ```bash diff --git a/skills/lark-wiki/references/lark-wiki-resolve-node.md b/skills/lark-wiki/references/lark-wiki-resolve-node.md new file mode 100644 index 00000000..c562b4d8 --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-resolve-node.md @@ -0,0 +1,128 @@ +# wiki +resolve-node + +> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +把一个飞书知识库节点(wiki node)解析为它真正指向的对象:`obj_token` + `obj_type`(docx / bitable / sheet / file / ...)+ `title`。 + +## 为什么需要这个命令 + +飞书的 wiki 链接 `https://x.larkoffice.com/wiki/` 看起来像一个文档 URL,但它实际上只是一个**包装节点**。真正的内容存在节点指向的 `obj_token` 里,且类型可能是任何东西: + +- 看着像 wiki,背后其实是 docx → 用 `docs +fetch` +- 看着像 wiki,背后其实是 bitable → 用 `base +table-list` / `+record-list` +- 看着像 wiki,背后其实是 sheet → 用 `sheets +read` + +如果直接拿 wiki 链接里的 token 当 doc/base/sheet token 用,会得到 `param invalid` 错误(如 `code 800004006`)。**必须先解析。** + +## 命令 + +```bash +# 直接传 wiki URL(最常用) +lark-cli wiki +resolve-node --token "https://bytedance.larkoffice.com/wiki/EzY8wvj5RiLtfIkw4UPcTdKinRe" + +# 传 bare token 也行 +lark-cli wiki +resolve-node --token "EzY8wvj5RiLtfIkw4UPcTdKinRe" + +# 输出 pretty 表格 +lark-cli wiki +resolve-node --token "..." --format pretty + +# 用 jq 直接提取最常用的字段 +lark-cli wiki +resolve-node --token "..." --format json --jq '.data | {obj_token, obj_type, title}' + +# 预览不执行 +lark-cli wiki +resolve-node --token "..." --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|---|---|---| +| `--token` | 是 | wiki 节点 URL(自动从 `/wiki/XXX` 中提取 token)或 bare token | +| `--format` | 否 | 输出格式:json(默认)/ pretty / table / ndjson / csv | +| `--as` | 否 | 身份:user / bot(默认 user) | +| `--dry-run` | 否 | 只打印请求,不执行 | + +## 输出 + +返回一个扁平结构(不嵌套在 `node` 里,方便直接 jq): + +```json +{ + "ok": true, + "identity": "user", + "data": { + "node_token": "EzY8wvj5RiLtfIkw4UPcTdKinRe", + "obj_token": "Q6Peb7nsqatsQIsIaYScetYNnPd", + "obj_type": "bitable", + "title": "华东区效率先锋大赛案例汇总", + "space_id": "7280057719443996675", + "node_type": "origin", + "creator": "ou_xxx", + "has_child": false + } +} +``` + +字段说明: + +| 字段 | 用途 | +|---|---| +| `obj_token` | 真正的内容 token,必须用它去调下一步的 doc/base/sheet API | +| `obj_type` | 决定下一步用哪个 skill:`docx` / `doc` / `bitable` / `sheet` / `file` / `slides` / `mindnote` | +| `node_token` | 原始 wiki 节点 token(即输入 URL 里的 token) | +| `title` | 文档标题,用于人类可读输出和确认是否找对了文档 | +| `space_id` | 所属知识空间,调 `wiki nodes list` 浏览同空间其他节点时需要 | + +## 标准下游流程 + +```bash +# 第一步:解析 +RESULT=$(lark-cli wiki +resolve-node --token "https://x.larkoffice.com/wiki/wikXXX" --format json) +OBJ_TOKEN=$(echo "$RESULT" | jq -r '.data.obj_token') +OBJ_TYPE=$(echo "$RESULT" | jq -r '.data.obj_type') + +# 第二步:根据 obj_type 走对应 skill +case "$OBJ_TYPE" in + docx|doc) + lark-cli docs +fetch --doc "$OBJ_TOKEN" + ;; + bitable) + lark-cli base +table-list --base-token "$OBJ_TOKEN" + ;; + sheet) + lark-cli sheets +read --sheet "$OBJ_TOKEN" + ;; + *) + echo "obj_type=$OBJ_TYPE — see lark-drive or lark-whiteboard skill" + ;; +esac +``` + +LLM agent 不需要写脚本,直接按顺序调两次 lark-cli,把第一次的 `obj_token` / `obj_type` 喂给第二次的命令即可。 + +## 历史问题 + +在 `+resolve-node` 这个 shortcut 出现之前,agent 常见的两种错误: + +1. **直接拿 wiki token 当 doc/base/sheet token 用** → `param invalid` (code 800004006) +2. **绕道写一长串 raw API 调用**: + ```bash + lark-cli api GET /open-apis/wiki/v2/spaces/get_node \ + --params '{"token":"...","obj_type":"wiki"}' + ``` + 能用,但是对 LLM 友好度差(要知道 OpenAPI 路径、要构造嵌套 JSON params、返回结果也是嵌套在 `data.node` 下)。 + +新 shortcut 把这两个坑都填了:URL 自动解析、扁平输出、一行命令。 + +## 权限 + +| 操作 | 所需 scope | +|---|---| +| `+resolve-node` | `wiki:wiki:readonly`(或更上级的 `wiki:node:read`) | + +## 决策规则 + +- **任何来源是 wiki URL 的"读内容"任务,第一步都是 `+resolve-node`。** +- 如果用户给的是 doc/sheet/bitable 的直接 URL(不带 `/wiki/`),则**不需要**先 resolve,直接走对应 skill。 +- 如果 `+resolve-node` 返回 `obj_type` 是你不熟悉的类型,先去对应 skill 看一下处理方式,不要瞎猜。 +- 解析失败(404 或 permission denied)通常意味着 wiki 节点不存在、被删除、或当前身份没有访问权限 —— 不是 token 格式问题。