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

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()