Phase 5: MCP 薄殼(search_wiki/read_page)+ BM25 檢索 + 三個 Copilot Custom Agents
Some checks failed
kb-lint / lint (push) Has been cancelled

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LittleYellow
2026-07-14 21:05:01 +08:00
parent 156c6db2f8
commit cb2c92452e
8 changed files with 302 additions and 0 deletions

View File

33
.github/agents/kb-ingest.agent.md vendored Normal file
View File

@@ -0,0 +1,33 @@
---
name: kb-ingest
description: 攝入文件到 QA 知識庫:本地轉換 → 本地 Ollama 攝入 → 開 PR。agent 只編排,不處理文件內容。
tools: ['runCommands']
---
<!-- ASSUMPTION: frontmatter 欄位依 VS Code Copilot Custom Agent 公開慣例
name/description/tools撰寫若組織版格式有異只調整 frontmatter內文不變。 -->
你是 QA 知識庫的攝入編排者。規範正本:`AGENTS.md`(尤其 §1 硬性約束、§6 Ingest 工作流)。
## 成本紀律(不可違反)
你是**編排者不是處理者**:文件內容的 LLM 處理一律由本地 Ollama 腳本完成。
**禁止**把來源文件、轉換後 Markdown 或 wiki 頁全文讀進你的 context——
只讀取腳本 stdout/stderr 的精簡結果狀態、統計、路徑、PR 連結)。
## 工作流
輸入:一個或多個檔案路徑或網頁 URL。
1. 轉換並登記 manifest
`.venv\Scripts\python tools\convert\convert.py <路徑或URL...>`
- 從 stdout 取得 `raw/converted/...` 路徑;`needs_ocr` 表示掃描件已登記待人工,不要嘗試 OCR。
2. 攝入(本地 Ollama 處理、自動建分支與 commit
`.venv\Scripts\python tools\ingest.py <轉換後路徑...>`
3. 回報使用者:成功/失敗份數、變更頁面清單stdout 已含、PR 建立網址。
攝入失敗時引用 stderr 的錯誤摘要(一兩行即可)。
## 禁止事項
- 不直接編輯 `wiki/``index.md``log.md``raw/`(一律經由腳本)。
- 不 commit 或 push 到 mainHITL 閘門AGENTS.md §1.5)。
- 不呼叫任何雲端 API 處理文件內容AGENTS.md §1.1)。

31
.github/agents/kb-lint.agent.md vendored Normal file
View File

@@ -0,0 +1,31 @@
---
name: kb-lint
description: 執行 QA 知識庫健檢並摘要報告重點列出待人工核准項目。agent 只編排,不處理頁面內容。
tools: ['runCommands']
---
<!-- ASSUMPTION: frontmatter 欄位依 VS Code Copilot Custom Agent 公開慣例撰寫。 -->
你是 QA 知識庫的健檢編排者。規範正本:`AGENTS.md`(尤其 §7 Lint 工作流)。
## 成本紀律(不可違反)
只讀取 lint 腳本的 stdout 與報告檔的**開頭摘要區**(前 30 行左右),
不把全部 wiki 頁面內容拉進 context。
## 工作流
1. 執行健檢LLM 比對由本地 Ollama 完成):
`.venv\Scripts\python tools\lint.py --output reports`
- 結束碼 0 = 無發現1 = 有待人工核准項目。
- stdout 會印出報告路徑。
2. 讀取報告開頭的統計摘要,回報使用者:
- 各類發現數量schema / stale / coverage / orphan / duplicate / contradiction
- 需要人工核准的重點項目(最多列 10 項,附頁面路徑)。
- 完整報告路徑。
## 禁止事項
- 絕不刪除或修改任何 wiki 頁、raw 檔lint 本身也不會AGENTS.md §7
- 不自行「修復」發現的問題——一律留給人工核准後處理。
- 若同類問題重複出現,建議使用者修改 AGENTS.md schema 或 lint 規則
change the ruler, not the outputAGENTS.md §12

25
.github/agents/kb-query.agent.md vendored Normal file
View File

@@ -0,0 +1,25 @@
---
name: kb-query
description: 查詢 QA 知識庫BM25 檢索候選頁 → 只讀命中頁 → 綜合回答並附 source_ref。
tools: ['runCommands', 'search']
---
<!-- ASSUMPTION: frontmatter 欄位依 VS Code Copilot Custom Agent 公開慣例撰寫。 -->
你是 QA 知識庫的查詢助手。規範正本:`AGENTS.md`(尤其 §8 Query 工作流)。
## 工作流
1. 以使用者問題的關鍵詞檢索(繁中或英文皆可):
`.venv\Scripts\python tools\search.py "<關鍵詞>" -k 10`
- 回傳 JSON候選頁的 path / title / description / tags / score。
2. 只開啟**命中的少數頁面**(最多 10 頁,通常前 35 頁已足夠)讀取內文。
3. 綜合回答使用者問題:
- **必附 source_ref**(各頁 frontmatter 的 `sources` 欄位,格式 `路徑#hash前8碼`)。
- 若各頁說法矛盾,兩種說法並陳並指出出處。
- 候選頁都不相關時,直說知識庫沒有涵蓋,不要腦補。
## 禁止事項
- 禁止全庫掃描:不遍歷 `wiki/` 全部頁面、不讀 `raw/` 原始文件全文AGENTS.md §8
- 不修改任何檔案——查詢是唯讀操作。
- 回答只根據 wiki 頁內容wiki 沒有的知識明說沒有。

View File

56
mcp/server.py Normal file
View File

@@ -0,0 +1,56 @@
"""FastMCP 薄殼AGENTS.md §8search_wiki 與 read_page 兩個 tool。
查詢時只讀已編譯的 wiki 頁——不觸碰 raw 層、不做全庫掃描。
啟動stdio.venv/Scripts/python mcp/server.py
測試時可用環境變數 PP_QA_ROOT 指定專案根目錄。
"""
import os
import pathlib
import sys
ROOT = pathlib.Path(os.environ.get("PP_QA_ROOT",
pathlib.Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(ROOT / "tools"))
import kb
import search as searchmod
from fastmcp import FastMCP
mcp = FastMCP("pp-qa-knowledge")
def _truncate(body, max_tokens):
# ponytail: token 估算用「1 token ≈ 1.5 字元」的 naive heuristic
# (繁中約 1 字/token、英文約 4 字元/token 的折衷);
# 升級路徑:以實際模型 tokenizer 校正。
limit = int(max_tokens * 1.5)
return (body, False) if len(body) <= limit else (body[:limit], True)
def search_wiki(query: str, top_k: int = 10) -> list:
"""BM25 檢索 wiki 頁,回傳 index 條目path/title/description/tags+ 相關度 score。
query 用繁體中文或英文關鍵詞。"""
return searchmod.search(ROOT, query, max(1, min(int(top_k), 10))) # 上限 10AGENTS.md §8
def read_page(path: str) -> dict:
"""讀取單一 wiki 頁全文frontmatter + 內文(依 models.yaml token 上限截斷)
+ source_refs。path 例wiki/entities/payment-gateway.md"""
cfg = kb.load_config(ROOT)
p = (ROOT / path).resolve()
wiki = (ROOT / "wiki").resolve()
# 信任邊界:擋路徑跳脫,只允許 wiki/ 下的 .md
if not p.is_relative_to(wiki) or p.suffix != ".md":
raise ValueError("path 須指向 wiki/ 下的 .md 頁面")
if not p.is_file():
raise FileNotFoundError(f"頁面不存在:{path}")
meta, body = kb.parse_page(p.read_text(encoding="utf-8"))
body, truncated = _truncate(body, cfg["limits"]["mcp_response_max_tokens"])
return {"frontmatter": meta, "body": body, "truncated": truncated,
"source_refs": [str(s) for s in meta.get("sources") or []]}
mcp.tool(search_wiki)
mcp.tool(read_page)
if __name__ == "__main__":
mcp.run()

69
tools/search.py Normal file
View File

@@ -0,0 +1,69 @@
"""wiki BM25 全文檢索第一版不用向量庫AGENTS.md §0
用法python tools/search.py "查詢詞" [-k 10] [--root DIR]
輸出JSON 陣列path、title、description、tags、score供 agent 與 MCP 使用。
"""
import argparse
import json
import pathlib
import re
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import kb
from rank_bm25 import BM25Okapi
def tokenize(text):
"""英數字詞 + CJK bigram。
ponytail: bigram 斷詞無語意理解(同義詞不匹配),天花板是字面重疊;
升級路徑Phase 6 後評估 EmbeddingGemma / snowflake-arctic-embed 向量檢索。
"""
text = text.lower()
tokens = re.findall(r"[a-z0-9]+", text)
for run in re.findall(r"[一-鿿]+", text):
tokens += [run] if len(run) == 1 else [run[i:i + 2] for i in range(len(run) - 1)]
return tokens
def build_corpus(root):
docs = []
for p in kb.iter_pages(root):
try:
meta, body = kb.parse_page(p.read_text(encoding="utf-8"))
except ValueError:
continue # 壞頁由 lint 報,檢索直接略過
text = (f"{meta['title']} " * 3 + f"{meta['description']} " * 2
+ " ".join(meta.get("tags") or []) + " " + body)
docs.append({"path": p.relative_to(root).as_posix(), "title": str(meta["title"]),
"description": str(meta["description"]),
"tags": [str(t) for t in meta.get("tags") or []],
"tokens": tokenize(text)})
return docs
def search(root, query, k=10):
docs = build_corpus(pathlib.Path(root))
if not docs:
return []
bm = BM25Okapi([d["tokens"] for d in docs])
scores = bm.get_scores(tokenize(query))
ranked = sorted(zip(docs, scores), key=lambda x: -x[1])[:k]
return [{"path": d["path"], "title": d["title"], "description": d["description"],
"tags": d["tags"], "score": round(float(s), 4)}
for d, s in ranked if s > 0]
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("query")
ap.add_argument("-k", type=int, default=10, help="回傳筆數上限(查詢工作流上限 10")
ap.add_argument("--root", default=str(kb.ROOT))
a = ap.parse_args(argv)
hits = search(a.root, a.query, max(1, min(a.k, 10)))
print(json.dumps(hits, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

88
tools/selfcheck_search.py Normal file
View File

@@ -0,0 +1,88 @@
"""Phase 5 自檢BM25 檢索排序、MCP search_wiki / read_page、
路徑跳脫防護信任邊界、token 上限截斷。
執行:.venv/Scripts/python tools/selfcheck_search.py
"""
import os
import pathlib
import shutil
import sys
import tempfile
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import kb
import search
CFG = """ollama: {base_url: "http://localhost:11434", timeout_seconds: 5}
tasks:
ingest: {model: "fake:test", temperature: 0.2, max_retries: 2}
lint: {model: "fake:test", temperature: 0.1, max_retries: 2}
fallback: {model: "fake:fb"}
limits: {mcp_response_max_tokens: 100, ingest_chunk_max_chars: 12000}
"""
def page(root, rel, title, desc, tags, body):
meta = {"type": {"summaries": "summary", "entities": "entity",
"concepts": "concept"}[rel.split("/")[1]],
"title": title, "description": desc, "tags": tags,
"timestamp": "2026-07-14", "sources": ["raw/converted/x.md#abcd1234"],
"status": "draft"}
(root / rel).write_text(kb.dump_page(meta, body), encoding="utf-8")
def main():
tmp = pathlib.Path(tempfile.mkdtemp(prefix="ppqa-search-"))
try:
root = tmp / "repo"
for sub in ("config", "wiki/summaries", "wiki/entities", "wiki/concepts"):
(root / sub).mkdir(parents=True)
(root / "config/models.yaml").write_text(CFG, encoding="utf-8")
page(root, "wiki/entities/payment-gateway.md", "支付閘道", "支付閘道模組",
["gateway"], "壓力測試時出現逾時缺陷,重試機制未生效。")
page(root, "wiki/concepts/aml-testing.md", "AML 測試準則", "可疑交易監控測試綜合",
["aml", "compliance"], "大額交易與分散交易樣態的監控測試要求。")
page(root, "wiki/summaries/long-doc.md", "長文件摘要", "截斷測試用",
["test"], "逾時。" * 500)
hits = search.search(root, "支付閘道 逾時")
assert hits and hits[0]["path"] == "wiki/entities/payment-gateway.md", hits
assert all(h["score"] > 0 for h in hits)
hits2 = search.search(root, "可疑交易監控")
assert hits2[0]["path"] == "wiki/concepts/aml-testing.md", hits2
assert search.search(root, "zzz-nonexistent-term") == []
print("PASS: BM25 檢索排序(繁中 bigram")
os.environ["PP_QA_ROOT"] = str(root)
import importlib
sys.path.insert(0, str(pathlib.Path(__file__).parents[1] / "mcp"))
server = importlib.import_module("server")
res = server.search_wiki("支付閘道 逾時", top_k=99) # top_k 超限 → 收斂為 10
assert res[0]["path"] == "wiki/entities/payment-gateway.md"
pg = server.read_page("wiki/entities/payment-gateway.md")
assert pg["frontmatter"]["title"] == "支付閘道" and not pg["truncated"]
assert pg["source_refs"] == ["raw/converted/x.md#abcd1234"]
long = server.read_page("wiki/summaries/long-doc.md")
assert long["truncated"] and len(long["body"]) <= 150 and long["source_refs"]
print("PASS: MCP search_wiki + read_page含 token 截斷、source_refs 保留)")
for bad in ("../AGENTS.md", "wiki/../config/models.yaml", "wiki/entities/x.txt"):
try:
server.read_page(bad)
raise AssertionError(f"應拒絕:{bad}")
except (ValueError, FileNotFoundError) as e:
assert isinstance(e, ValueError), f"{bad} 應為 ValueError"
try:
server.read_page("wiki/entities/no-such.md")
raise AssertionError("應拋 FileNotFoundError")
except FileNotFoundError:
pass
print("PASS: 路徑跳脫防護(信任邊界)")
print("ALL PASS")
finally:
os.environ.pop("PP_QA_ROOT", None)
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()