From 340cf543f4e651196dbaf7b65135eda16ad9ffe2 Mon Sep 17 00:00:00 2001 From: Ct201314 <1195214305@qq.com> Date: Sat, 6 Jun 2026 00:14:28 +0800 Subject: [PATCH 1/2] feat(skills): add gitlink-kb skill --- skills/gitlink-kb/SKILL.md | 105 ++++++++++++++++++ skills/gitlink-kb/references/api-reference.md | 53 +++++++++ skills/gitlink-kb/references/search.md | 40 +++++++ 3 files changed, 198 insertions(+) create mode 100644 skills/gitlink-kb/SKILL.md create mode 100644 skills/gitlink-kb/references/api-reference.md create mode 100644 skills/gitlink-kb/references/search.md diff --git a/skills/gitlink-kb/SKILL.md b/skills/gitlink-kb/SKILL.md new file mode 100644 index 0000000..659e1e8 --- /dev/null +++ b/skills/gitlink-kb/SKILL.md @@ -0,0 +1,105 @@ +--- +name: gitlink-kb +version: 1.0.0 +description: "仓库知识库问答:索引 README、docs 目录与各类 Markdown 文档,支持关键词检索、文档地图生成、FAQ 提取,让仓库沉淀的知识可被快速查询。当用户提到「文档里怎么说」「如何使用/安装/配置」「这个项目的文档」「FAQ」「常见问题」「知识库」「文档地图」「搜索文档」时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + optional_bins: ["python"] + cliHelp: "gitlink-cli repo --help" +--- + +# gitlink-kb(仓库知识库问答助手) + +**CRITICAL — 开始前先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** +**CRITICAL — 本技能全程只读,不修改任何远程数据。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。 + +## 何时使用本技能 + +- 用户问「这个项目怎么安装/配置/使用」,希望从仓库文档里找答案 +- 想快速了解一个仓库都有哪些文档、讲了什么(文档地图) +- 想从文档中提取 FAQ / 常见问题 +- 在不克隆仓库的情况下检索文档内容 + +## 何时不使用 + +- 检索代码实现而非文档 → 用代码搜索类工具 +- 仅读取单个文件 → 用 `gitlink-repo` 的 readme/文件接口 + +## 能力概览 + +| 能力 | 说明 | +|------|------| +| 关键词检索 | 在 README + docs 等文档中检索与问题最相关的段落(支持中英文) | +| 文档地图 | 按文档归类所有标题,呈现仓库文档结构 | +| FAQ 提取 | 自动识别文档中形似问题的标题,提取问答对 | + +## 工作流:从仓库文档中查找答案 + +### 方式 A:用配套脚本(推荐) + +```bash +# 关键词/问题检索 +python scripts/kb.py --owner Gitlink --repo gitlink-cli --query "如何安装" + +# 生成文档地图 +python scripts/kb.py --owner Gitlink --repo gitlink-cli --map + +# 提取 FAQ +python scripts/kb.py --owner Gitlink --repo gitlink-cli --faq + +# JSON 输出 +python scripts/kb.py --owner Gitlink --repo gitlink-cli --query "登录" --format json +``` + +参数说明: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|:----:|------| +| `--owner` | string | 是* | 仓库所有者(*或用 `--slug`) | +| `--repo` | string | 是* | 仓库名称 | +| `--slug` | string | 否 | `owner/repo` 或完整 URL | +| `--ref` | string | 否 | 分支或标签,默认 master | +| `--query` | string | 否 | 检索关键词/问题 | +| `--map` | flag | 否 | 生成文档地图 | +| `--faq` | flag | 否 | 提取 FAQ | +| `--max-files` | int | 否 | 最多索引的文档数,默认 20 | +| `--format` | string | 否 | `markdown`(默认)或 `json` | +| `--output` | string | 否 | 输出文件 | + +### 方式 B:用 gitlink-cli 读取文档 + +```bash +# 读取 README +gitlink-cli repo +readme --owner Gitlink --repo gitlink-cli --ref master --format json + +# 列出 docs 目录 +gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=docs&ref=master' --format json + +# 读取某个文档文件 +gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=docs/guide.md&ref=master' --format json +``` + +## 检索说明 + +- 检索基于关键词命中计分,标题命中加权;中文查询会做 2-gram 切分,兼顾中英文文档。 +- 索引范围:README + `docs/`、`doc/`、`.gitlink/`、`wiki/` 等目录下的 Markdown/文本文件。 +- 这是基于规则的检索,不依赖大模型,结果可解释。 + +## API 注意事项 + +- README 通过 `readme` 接口读取,其余文档通过 `sub_entries` 接口读取内容。 +- 数据采集全程只读。 + +## 输出示例 + +参见 [`examples/`](examples/) 的真实检索结果与 FAQ。 + +## References + +- [api-reference.md](references/api-reference.md) — 采集接口、字段与输出结构 +- [search.md](references/search.md) — 索引范围、检索算法与 FAQ 提取规则 +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证、全局参数、安全规则 diff --git a/skills/gitlink-kb/references/api-reference.md b/skills/gitlink-kb/references/api-reference.md new file mode 100644 index 0000000..28fa4b2 --- /dev/null +++ b/skills/gitlink-kb/references/api-reference.md @@ -0,0 +1,53 @@ +# gitlink-kb API 参考 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md)。 + +本技能索引仓库文档所依赖的接口与字段。全程只读。 + +## 采集的接口 + +### README + +``` +GET /:owner/:repo/readme.json?ref={ref} +# 或经 gitlink-cli: +gitlink-cli repo +readme --owner --repo --ref master --format json +``` + +返回 `content` 字段。注意:GitLink 的 readme 接口虽将 `encoding` 标为 base64, +实测 `content` 多为**明文** Markdown,本技能会先探测明文特征,必要时再做 base64 解码。 + +### 列目录与读取文档 + +``` +GET /:owner/:repo/sub_entries.json?filepath={dir}&ref={ref} # 列目录 +GET /:owner/:repo/sub_entries.json?filepath={file}&ref={ref} # 读单文件(entries.content 为明文) +``` + +本技能在根目录、`docs/`、`doc/`、`.gitlink/`、`wiki/` 中查找文档文件。 + +## 使用的字段 + +| 字段 | 说明 | 用途 | +|------|------|------| +| readme `content` | README 内容 | 索引 | +| `entries[].name` | 文件名 | 筛选文档扩展名 | +| `entries[].type` | file / dir | 只索引 file | +| `entries[].content` | 单文件明文内容 | 索引正文 | + +## 输出字段(JSON) + +检索: + +```json +{"query": "如何安装", + "results": [{"doc": "README", "title": "安装", "score": 7, "snippet": "..."}]} +``` + +文档地图:`{"README": [{"title": "安装", "level": 2}, ...]}` + +FAQ:`{"faq": [{"question": "...", "answer": "...", "doc": "README"}]}` + +## 错误处理 + +沿用 gitlink-shared 错误码。某目录不存在时跳过,不中断索引。 diff --git a/skills/gitlink-kb/references/search.md b/skills/gitlink-kb/references/search.md new file mode 100644 index 0000000..dd76078 --- /dev/null +++ b/skills/gitlink-kb/references/search.md @@ -0,0 +1,40 @@ +# 索引与检索规则 + +本技能基于规则做文档检索,不依赖大模型,结果可解释。 + +## 索引范围 + +- README(经 readme 接口) +- 以下目录中的文档文件:根目录、`docs/`、`doc/`、`.gitlink/`、`wiki/` +- 文档扩展名:`.md` / `.markdown` / `.rst` / `.txt` +- 默认最多索引 20 个文档(`--max-files` 可调) + +## 文档切分 + +按 Markdown 标题(`#` ~ `######`)把文档切分为段落,每段记录:所属文档、标题、标题层级、正文。 + +## 检索算法 + +1. 把查询拆为关键词: + - 英文按 `[A-Za-z0-9_]+` 切词; + - 中文额外做 2-gram 切分(如「如何安装」→「如何」「何安」「安装」),兼顾中文无空格分词。 +2. 对每个段落计分: + - 正文 + 标题中每出现一次关键词 +1; + - 关键词命中**标题** 额外 +5(标题更能代表段落主题)。 +3. 按分数降序返回前 N 段(默认 5),附 300 字摘要。 + +## FAQ 提取 + +识别形似问题的标题并提取问答对,判定规则(命中任一): + +- 标题含 `?` 或 `?` +- 标题以 `Q:` / `Q ` / `how` / `what` / `why` / `when` / `如何` / `怎么` / `为什么` / `是否` 开头 + +## 文档地图 + +按文档归类所有标题,保留层级缩进,呈现仓库文档的整体结构。 + +## 局限 + +- 基于关键词命中,不做语义向量检索;对同义词/近义表达的召回有限。 +- 仅索引文本类文档,不索引代码文件。 From 2fdb56d7a6d5ce61eff1abc64ac00a226363bbae Mon Sep 17 00:00:00 2001 From: Ct201314 <1195214305@qq.com> Date: Fri, 12 Jun 2026 18:00:53 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(skills):=20=E8=A1=A5=E5=85=85=20gitlin?= =?UTF-8?q?k-kb=20=E9=85=8D=E5=A5=97=E8=84=9A=E6=9C=AC=E4=B8=8E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依审阅意见,将 SKILL.md 方式A引用的脚本(scripts/)与单元测试(tests/)一并纳入,使 PR 自包含可运行。 --- skills/gitlink-kb/scripts/glapi.py | 241 ++++++++++++++++++++++++ skills/gitlink-kb/scripts/kb.py | 293 +++++++++++++++++++++++++++++ skills/gitlink-kb/tests/test_kb.py | 130 +++++++++++++ 3 files changed, 664 insertions(+) create mode 100644 skills/gitlink-kb/scripts/glapi.py create mode 100644 skills/gitlink-kb/scripts/kb.py create mode 100644 skills/gitlink-kb/tests/test_kb.py diff --git a/skills/gitlink-kb/scripts/glapi.py b/skills/gitlink-kb/scripts/glapi.py new file mode 100644 index 0000000..41db09d --- /dev/null +++ b/skills/gitlink-kb/scripts/glapi.py @@ -0,0 +1,241 @@ +"""GitLink 公开 API 共享客户端。 + +供 gitlink-skills-pack 下各 Skill 的脚本复用。仅依赖 Python 标准库, +无需第三方包,便于在受限环境或 Agent 沙箱中运行。 + +数据全部来自 GitLink 平台公开接口(https://www.gitlink.org.cn/api), +默认无需 token;如需访问私有仓库,可传入 token。 + +所有方法均为只读,不修改任何远程数据。 +""" + +from __future__ import annotations + +import base64 +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +API_BASE = "https://www.gitlink.org.cn/api" +USER_AGENT = "gitlink-skills-pack/1.0 (+https://www.gitlink.org.cn)" +DEFAULT_TIMEOUT = 30 +COMMIT_PAGE_SIZE = 50 # GitLink commits 接口每页硬上限 + + +class GitLinkError(RuntimeError): + """API 调用中不可恢复的错误。""" + + +class GitLinkClient: + """GitLink 公开数据接口客户端。 + + 带可选文件缓存:同一资源重复读取不重复打网,对平台友好。 + """ + + def __init__(self, base: str = API_BASE, token: str | None = None, + timeout: int = DEFAULT_TIMEOUT, cache_dir: Path | None = None) -> None: + self.base = base.rstrip("/") + self.token = token + self.timeout = timeout + self.cache_dir = cache_dir + if self.cache_dir: + self.cache_dir.mkdir(parents=True, exist_ok=True) + + # ------------------------------------------------------------------ + # 底层请求 + # ------------------------------------------------------------------ + def _cache_path(self, url: str) -> Path | None: + if not self.cache_dir: + return None + safe = urllib.parse.quote(url, safe="") + return self.cache_dir / f"{safe}.json" + + def get(self, path: str, query: dict[str, Any] | None = None) -> Any: + """GET 请求,返回解析后的 JSON(dict/list)或 None。""" + url = f"{self.base}/{path.lstrip('/')}" + if query: + url = f"{url}?{urllib.parse.urlencode(query)}" + + cache_path = self._cache_path(url) + if cache_path and cache_path.exists(): + return json.loads(cache_path.read_text(encoding="utf-8")) + + headers = {"Accept": "application/json", "User-Agent": USER_AGENT} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + raw = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + raise GitLinkError(f"HTTP {exc.code}: {url}") from exc + except urllib.error.URLError as exc: + raise GitLinkError(f"网络错误: {url} -> {exc.reason}") from exc + + text = raw.strip() + if not text or text in ("null", "{}", "[]"): + data: Any = None + elif text[0] in "{[": + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise GitLinkError(f"响应非 JSON: {url}") from exc + else: + raise GitLinkError(f"响应非 JSON(可能是 HTML): {url}") + + if cache_path is not None: + cache_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + return data + + # ------------------------------------------------------------------ + # 资源访问(高层封装) + # ------------------------------------------------------------------ + def repo_info(self, owner: str, repo: str) -> dict[str, Any]: + """仓库元信息。""" + data = self.get(f"{owner}/{repo}.json") + return data if isinstance(data, dict) else {} + + def issues(self, owner: str, repo: str, limit: int = 50, + page: int = 1) -> list[dict[str, Any]]: + """Issue 列表。""" + data = self.get(f"{owner}/{repo}/issues.json", {"page": page, "limit": limit}) + return _extract_list(data, ("issues",)) + + def issue_detail(self, owner: str, repo: str, number: int) -> dict[str, Any]: + """单个 Issue 详情(含完整字段)。""" + data = self.get(f"{owner}/{repo}/issues/{number}.json") + return data if isinstance(data, dict) else {} + + def pulls(self, owner: str, repo: str, limit: int = 50, + page: int = 1) -> list[dict[str, Any]]: + """PR 列表。""" + data = self.get(f"{owner}/{repo}/pulls.json", {"page": page, "limit": limit}) + return _extract_list(data, ("issues", "pulls")) + + def contributors(self, owner: str, repo: str) -> list[dict[str, Any]]: + """贡献者列表。""" + data = self.get(f"{owner}/{repo}/contributors.json") + return _extract_list(data, ("list",)) + + def commits(self, owner: str, repo: str, max_pages: int = 4) -> list[dict[str, Any]]: + """提交列表(按需翻页,每页 50 条,以 total_count 为终止依据)。""" + out: list[dict[str, Any]] = [] + total: int | None = None + for page in range(1, max(1, max_pages) + 1): + data = self.get(f"{owner}/{repo}/commits.json", + {"page": page, "limit": COMMIT_PAGE_SIZE}) + if total is None and isinstance(data, dict): + total = _safe_int(data.get("total_count")) or None + page_items = _extract_list(data, ("commits",)) + if not page_items: + break + out.extend(page_items) + if total is not None and len(out) >= total: + break + return out + + def list_dir(self, owner: str, repo: str, path: str = "", + ref: str = "master") -> list[dict[str, Any]]: + """列出目录下的条目(文件与子目录)。 + + 返回的每个 entry 含 name / path / type(file|dir) / sha / size, + 文件类型的 entry 还可能直接带明文 content。 + """ + data = self.get(f"{owner}/{repo}/sub_entries.json", + {"filepath": path, "ref": ref}) + # 查询目录时 entries 为 list;查询单文件时 entries 为单个 dict。 + # 统一归一化为 list,便于下游处理。 + if isinstance(data, dict): + entries = data.get("entries") + if isinstance(entries, dict): + return [entries] + if isinstance(entries, list): + return entries + return _extract_list(data, ("entries",)) + + def file_content(self, owner: str, repo: str, filepath: str, + ref: str = "master") -> str | None: + """读取单个文件的文本内容。 + + GitLink 的 sub_entries 接口对单文件查询会在 entries 中返回明文 content, + 据此取出。文件不存在或无内容时返回 None。 + """ + entries = self.list_dir(owner, repo, filepath, ref) + target = filepath.rsplit("/", 1)[-1] + for entry in entries: + if entry.get("type") == "file" and entry.get("name") == target: + content = entry.get("content") + if isinstance(content, str): + return content + # 回退:部分情况下单文件查询 entries 仅一项 + if len(entries) == 1 and entries[0].get("type") == "file": + content = entries[0].get("content") + if isinstance(content, str): + return content + return None + + def readme(self, owner: str, repo: str, ref: str = "master") -> str | None: + """读取仓库 README(自动 base64 解码)。""" + data = self.get(f"{owner}/{repo}/readme.json", {"ref": ref}) + if not isinstance(data, dict): + return None + content = data.get("content") + if not isinstance(content, str): + return None + # 注意:GitLink 的 readme.json 虽然 encoding 标为 base64, + # 实测 content 多为明文 Markdown。先探测明文特征,命中则直接返回; + # 否则再尝试 base64 解码。 + stripped = content.lstrip() + if stripped.startswith(("#", "<", "[", "-", "*", "本", "这", "项")) or "\n" in content[:200]: + return content + try: + raw = base64.b64decode(content.encode("ascii", "ignore")) + decoded = raw.decode("utf-8", errors="replace") + # 解码结果若不像文本(大量替换符),回退为原文 + if decoded.count("\ufffd") > len(decoded) * 0.1: + return content + return decoded + except (ValueError, TypeError): + return content + + +# ---------------------------------------------------------------------------- +# 辅助 +# ---------------------------------------------------------------------------- + +def _extract_list(payload: Any, keys: tuple[str, ...]) -> list[Any]: + """从可能嵌套的响应中提取第一个匹配键的列表。""" + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return value + return [] + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def split_owner_repo(slug: str) -> tuple[str, str]: + """把 'owner/repo' 或完整 URL 解析为 (owner, repo)。""" + s = slug.strip() + if s.startswith("http"): + parts = urllib.parse.urlparse(s).path.strip("/").split("/") + if len(parts) >= 2: + return parts[0], parts[1].replace(".git", "") + raise GitLinkError(f"无法从 URL 解析 owner/repo: {slug}") + if "/" in s: + owner, repo = s.split("/", 1) + return owner, repo.replace(".git", "") + raise GitLinkError(f"格式应为 owner/repo: {slug}") diff --git a/skills/gitlink-kb/scripts/kb.py b/skills/gitlink-kb/scripts/kb.py new file mode 100644 index 0000000..1176121 --- /dev/null +++ b/skills/gitlink-kb/scripts/kb.py @@ -0,0 +1,293 @@ +"""gitlink-kb:仓库知识库问答助手。 + +把一个 GitLink 仓库的文档(README、docs/ 目录、各类 Markdown)索引起来, +支持关键词检索、文档地图生成与 FAQ 提取,让仓库沉淀的知识可被快速查询。 + +数据来自 GitLink 公开 API(只读),无需登录。 + +用法: + python kb.py --owner Gitlink --repo gitlink-cli --query "如何安装" + python kb.py --owner Gitlink --repo gitlink-cli --map + python kb.py --owner Gitlink --repo gitlink-cli --faq +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from glapi import GitLinkClient, GitLinkError, split_owner_repo + +# Windows 控制台默认 GBK,直接打印含 emoji 的 Markdown 会抛 UnicodeEncodeError。 +# 重配置 stdout 为 UTF-8,确保跨平台正常输出。 +if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +# 文档类文件扩展名 +DOC_EXTS = (".md", ".markdown", ".rst", ".txt") +# 优先索引的文档目录 +DOC_DIRS = ["", "docs", "doc", ".gitlink", "wiki"] + + +# --------------------------------------------------------------------------- +# 文档解析 +# --------------------------------------------------------------------------- + +def split_sections(markdown: str) -> list[dict[str, Any]]: + """按 Markdown 标题切分为段落,每段含标题、层级、正文。""" + sections: list[dict[str, Any]] = [] + current = {"title": "(开头)", "level": 0, "lines": []} + for line in markdown.splitlines(): + m = re.match(r"^(#{1,6})\s+(.*)", line) + if m: + if current["lines"] or current["title"] != "(开头)": + sections.append(current) + current = {"title": m.group(2).strip(), "level": len(m.group(1)), "lines": []} + else: + current["lines"].append(line) + if current["lines"] or current["title"] != "(开头)": + sections.append(current) + for s in sections: + s["body"] = "\n".join(s["lines"]).strip() + del s["lines"] + return sections + + +def collect_docs(owner: str, repo: str, ref: str, + client: GitLinkClient, max_files: int = 20) -> list[dict[str, Any]]: + """收集仓库中的文档文件及其内容。""" + docs: list[dict[str, Any]] = [] + + # README 优先 + readme = client.readme(owner, repo, ref) + if readme: + docs.append({"path": "README", "content": readme}) + + seen = {"readme", "readme.md"} + for d in DOC_DIRS: + if len(docs) >= max_files: + break + try: + entries = client.list_dir(owner, repo, d, ref) + except GitLinkError: + continue + for e in entries: + if len(docs) >= max_files: + break + name = str(e.get("name", "")) + if e.get("type") != "file" or not name.lower().endswith(DOC_EXTS): + continue + path = f"{d}/{name}" if d else name + if path.lower() in seen: + continue + seen.add(path.lower()) + # entries 里可能已带 content,否则单独取 + content = e.get("content") + if not content: + content = client.file_content(owner, repo, path, ref) + if content: + docs.append({"path": path, "content": content}) + return docs + + +def build_index(docs: list[dict[str, Any]]) -> list[dict[str, Any]]: + """把文档切分为可检索的段落索引。""" + index: list[dict[str, Any]] = [] + for doc in docs: + for sec in split_sections(doc["content"]): + if not sec["body"] and sec["title"] == "(开头)": + continue + index.append({ + "doc": doc["path"], + "title": sec["title"], + "level": sec["level"], + "body": sec["body"], + }) + return index + + +# --------------------------------------------------------------------------- +# 检索 +# --------------------------------------------------------------------------- + +def _tokenize_query(query: str) -> list[str]: + """把查询拆为关键词(英文按词,中文按字/词粗切)。""" + tokens = re.findall(r"[A-Za-z0-9_]+", query.lower()) + # 中文:粗略按 2-gram 补充 + zh = re.findall(r"[\u4e00-\u9fff]+", query) + for seg in zh: + tokens.append(seg) + for i in range(len(seg) - 1): + tokens.append(seg[i:i + 2]) + return [t for t in tokens if t] + + +def search(index: list[dict[str, Any]], query: str, top: int = 5) -> list[dict[str, Any]]: + """在索引中检索与查询最相关的段落(基于关键词命中计分)。""" + tokens = _tokenize_query(query) + if not tokens: + return [] + scored: list[tuple[int, dict[str, Any]]] = [] + for sec in index: + haystack = (sec["title"] + "\n" + sec["body"]).lower() + score = 0 + for t in tokens: + score += haystack.count(t.lower()) + # 标题命中加权 + title_low = sec["title"].lower() + for t in tokens: + if t.lower() in title_low: + score += 5 + if score > 0: + scored.append((score, sec)) + scored.sort(key=lambda x: x[0], reverse=True) + results = [] + for score, sec in scored[:top]: + snippet = sec["body"][:300].strip() + results.append({ + "doc": sec["doc"], "title": sec["title"], + "score": score, "snippet": snippet, + }) + return results + + +def extract_faq(index: list[dict[str, Any]]) -> list[dict[str, str]]: + """从文档中提取 FAQ(标题形似问题,或 Q/问 开头的段落)。""" + faq: list[dict[str, str]] = [] + for sec in index: + title = sec["title"] + is_question = ( + "?" in title or "?" in title + or title.lower().startswith(("q:", "q ", "how", "what", "why", "when", "如何", "怎么", "为什么", "是否")) + ) + if is_question and sec["body"]: + faq.append({"question": title, "answer": sec["body"][:400].strip(), "doc": sec["doc"]}) + return faq + + +def build_map(index: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + """按文档归类标题,生成文档地图。""" + doc_map: dict[str, list[dict[str, Any]]] = {} + for sec in index: + if sec["title"] == "(开头)": + continue + doc_map.setdefault(sec["doc"], []).append( + {"title": sec["title"], "level": sec["level"]}) + return doc_map + + +# --------------------------------------------------------------------------- +# 渲染 +# --------------------------------------------------------------------------- + +def render_search(owner: str, repo: str, query: str, + results: list[dict[str, Any]]) -> str: + lines = [f"# 知识库检索 — {owner}/{repo}", "", f"查询:**{query}**", ""] + if not results: + lines += ["未找到相关内容。建议换个关键词,或确认仓库是否有相关文档。", ""] + return "\n".join(lines) + for i, r in enumerate(results, 1): + lines += [ + f"## {i}. {r['title']} `{r['doc']}`(相关度 {r['score']})", + "", + r["snippet"] + ("…" if len(r["snippet"]) >= 300 else ""), + "", + ] + return "\n".join(lines) + + +def render_map(owner: str, repo: str, doc_map: dict[str, list[dict[str, Any]]]) -> str: + lines = [f"# 文档地图 — {owner}/{repo}", "", + f"共索引 {len(doc_map)} 个文档。", ""] + for doc, secs in doc_map.items(): + lines.append(f"## 📄 {doc}") + lines.append("") + for s in secs: + indent = " " * max(0, s["level"] - 1) + lines.append(f"{indent}- {s['title']}") + lines.append("") + return "\n".join(lines) + + +def render_faq(owner: str, repo: str, faq: list[dict[str, str]]) -> str: + lines = [f"# 常见问题(FAQ)— {owner}/{repo}", ""] + if not faq: + lines += ["未从文档中识别出 FAQ 条目。", ""] + return "\n".join(lines) + for item in faq: + lines += [f"### ❓ {item['question']}", "", item["answer"], "", + f"来源:{item['doc']}", ""] + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(prog="gitlink-kb", description="仓库知识库问答助手") + p.add_argument("--owner", help="仓库所有者") + p.add_argument("--repo", help="仓库名称") + p.add_argument("--slug", help="owner/repo 或完整 URL") + p.add_argument("--ref", default="master", help="分支或标签,默认 master") + p.add_argument("--query", help="检索关键词/问题") + p.add_argument("--map", action="store_true", help="生成文档地图") + p.add_argument("--faq", action="store_true", help="提取 FAQ") + p.add_argument("--max-files", type=int, default=20, help="最多索引的文档数") + p.add_argument("--format", choices=["markdown", "json"], default="markdown") + p.add_argument("--output", type=Path, help="输出文件") + args = p.parse_args(argv) + + if args.slug: + owner, repo = split_owner_repo(args.slug) + elif args.owner and args.repo: + owner, repo = args.owner, args.repo + else: + print("错误:请用 --owner/--repo 或 --slug 指定仓库。", file=sys.stderr) + return 2 + + client = GitLinkClient() + try: + docs = collect_docs(owner, repo, args.ref, client, max_files=args.max_files) + index = build_index(docs) + except GitLinkError as exc: + print(f"采集失败:{exc}", file=sys.stderr) + return 1 + + if args.query: + results = search(index, args.query) + out = (json.dumps({"query": args.query, "results": results}, ensure_ascii=False, indent=2) + if args.format == "json" else render_search(owner, repo, args.query, results)) + elif args.map: + doc_map = build_map(index) + out = (json.dumps(doc_map, ensure_ascii=False, indent=2) + if args.format == "json" else render_map(owner, repo, doc_map)) + elif args.faq: + faq = extract_faq(index) + out = (json.dumps({"faq": faq}, ensure_ascii=False, indent=2) + if args.format == "json" else render_faq(owner, repo, faq)) + else: + # 默认输出索引概况 + summary = {"owner": owner, "repo": repo, + "indexed_docs": len({s["doc"] for s in index}), + "sections": len(index)} + out = (json.dumps(summary, ensure_ascii=False, indent=2) + if args.format == "json" + else f"# 知识库索引 — {owner}/{repo}\n\n已索引 {summary['indexed_docs']} 个文档、" + f"{summary['sections']} 个段落。\n\n用 `--query <问题>` 检索、`--map` 看文档地图、`--faq` 提取常见问题。") + + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(out, encoding="utf-8") + print(f"已写入 {args.output}") + else: + print(out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/gitlink-kb/tests/test_kb.py b/skills/gitlink-kb/tests/test_kb.py new file mode 100644 index 0000000..8e4444c --- /dev/null +++ b/skills/gitlink-kb/tests/test_kb.py @@ -0,0 +1,130 @@ +"""gitlink-kb 单元测试。""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import pytest + +from kb import ( + split_sections, build_index, search, extract_faq, build_map, + collect_docs, _tokenize_query, +) + +DOC = """# 项目标题 + +简介段落。 + +## 安装 + +使用 pip 安装这个工具。 + +## 如何配置? + +先创建配置文件,然后运行 init。 + +### 子配置 + +细节内容。 +""" + + +class TestSplitSections: + def test_splits_by_heading(self): + secs = split_sections(DOC) + titles = [s["title"] for s in secs] + assert "安装" in titles + assert "如何配置?" in titles + + def test_level_recorded(self): + secs = split_sections(DOC) + sub = next(s for s in secs if s["title"] == "子配置") + assert sub["level"] == 3 + + def test_body_captured(self): + secs = split_sections(DOC) + install = next(s for s in secs if s["title"] == "安装") + assert "pip" in install["body"] + + +class TestTokenize: + def test_english(self): + assert "install" in _tokenize_query("how to install") + + def test_chinese_bigram(self): + tokens = _tokenize_query("如何安装") + assert "如何" in tokens or "安装" in tokens + + +class TestSearch: + def setup_method(self): + self.index = build_index([{"path": "README", "content": DOC}]) + + def test_finds_install(self): + results = search(self.index, "安装") + assert results + assert any("安装" in r["title"] for r in results) + + def test_english_query(self): + results = search(self.index, "pip") + assert results + assert "pip" in results[0]["snippet"] + + def test_no_match(self): + assert search(self.index, "zzzznotexist") == [] + + def test_empty_query(self): + assert search(self.index, "") == [] + + def test_title_weighted(self): + # 标题命中应排在前面 + results = search(self.index, "配置") + assert results + assert "配置" in results[0]["title"] + + +class TestFaq: + def test_extracts_question(self): + index = build_index([{"path": "README", "content": DOC}]) + faq = extract_faq(index) + assert any("配置" in f["question"] for f in faq) + + def test_no_question(self): + index = build_index([{"path": "x", "content": "# Title\n\nbody"}]) + assert extract_faq(index) == [] + + +class TestMap: + def test_builds_map(self): + index = build_index([{"path": "README", "content": DOC}]) + doc_map = build_map(index) + assert "README" in doc_map + titles = [s["title"] for s in doc_map["README"]] + assert "安装" in titles + + +class TestCollectDocs: + class FakeClient: + def readme(self, owner, repo, ref): + return "# README\n\n内容" + + def list_dir(self, owner, repo, path, ref): + if path == "docs": + return [{"name": "guide.md", "type": "file", "content": "# 指南\n\n步骤"}] + return [] + + def file_content(self, owner, repo, filepath, ref): + return None + + def test_collects_readme_and_docs(self): + docs = collect_docs("o", "r", "master", self.FakeClient()) + paths = {d["path"] for d in docs} + assert "README" in paths + assert any("guide" in p for p in paths) + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))