From cb2c92452e6faed24a3047add261fbafa269deac Mon Sep 17 00:00:00 2001 From: LittleYellow Date: Tue, 14 Jul 2026 21:05:01 +0800 Subject: [PATCH] =?UTF-8?q?Phase=205:=20MCP=20=E8=96=84=E6=AE=BC=EF=BC=88s?= =?UTF-8?q?earch=5Fwiki/read=5Fpage=EF=BC=89+=20BM25=20=E6=AA=A2=E7=B4=A2?= =?UTF-8?q?=20+=20=E4=B8=89=E5=80=8B=20Copilot=20Custom=20Agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/agents/.gitkeep | 0 .github/agents/kb-ingest.agent.md | 33 ++++++++++++ .github/agents/kb-lint.agent.md | 31 +++++++++++ .github/agents/kb-query.agent.md | 25 +++++++++ mcp/.gitkeep | 0 mcp/server.py | 56 ++++++++++++++++++++ tools/search.py | 69 ++++++++++++++++++++++++ tools/selfcheck_search.py | 88 +++++++++++++++++++++++++++++++ 8 files changed, 302 insertions(+) delete mode 100644 .github/agents/.gitkeep create mode 100644 .github/agents/kb-ingest.agent.md create mode 100644 .github/agents/kb-lint.agent.md create mode 100644 .github/agents/kb-query.agent.md delete mode 100644 mcp/.gitkeep create mode 100644 mcp/server.py create mode 100644 tools/search.py create mode 100644 tools/selfcheck_search.py diff --git a/.github/agents/.gitkeep b/.github/agents/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.github/agents/kb-ingest.agent.md b/.github/agents/kb-ingest.agent.md new file mode 100644 index 0000000..3a9919e --- /dev/null +++ b/.github/agents/kb-ingest.agent.md @@ -0,0 +1,33 @@ +--- +name: kb-ingest +description: 攝入文件到 QA 知識庫:本地轉換 → 本地 Ollama 攝入 → 開 PR。agent 只編排,不處理文件內容。 +tools: ['runCommands'] +--- + + +你是 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 到 main(HITL 閘門,AGENTS.md §1.5)。 +- 不呼叫任何雲端 API 處理文件內容(AGENTS.md §1.1)。 diff --git a/.github/agents/kb-lint.agent.md b/.github/agents/kb-lint.agent.md new file mode 100644 index 0000000..863f1c0 --- /dev/null +++ b/.github/agents/kb-lint.agent.md @@ -0,0 +1,31 @@ +--- +name: kb-lint +description: 執行 QA 知識庫健檢並摘要報告重點,列出待人工核准項目。agent 只編排,不處理頁面內容。 +tools: ['runCommands'] +--- + + +你是 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 output,AGENTS.md §12)。 diff --git a/.github/agents/kb-query.agent.md b/.github/agents/kb-query.agent.md new file mode 100644 index 0000000..79c8417 --- /dev/null +++ b/.github/agents/kb-query.agent.md @@ -0,0 +1,25 @@ +--- +name: kb-query +description: 查詢 QA 知識庫:BM25 檢索候選頁 → 只讀命中頁 → 綜合回答並附 source_ref。 +tools: ['runCommands', 'search'] +--- + + +你是 QA 知識庫的查詢助手。規範正本:`AGENTS.md`(尤其 §8 Query 工作流)。 + +## 工作流 + +1. 以使用者問題的關鍵詞檢索(繁中或英文皆可): + `.venv\Scripts\python tools\search.py "<關鍵詞>" -k 10` + - 回傳 JSON:候選頁的 path / title / description / tags / score。 +2. 只開啟**命中的少數頁面**(最多 10 頁,通常前 3–5 頁已足夠)讀取內文。 +3. 綜合回答使用者問題: + - **必附 source_ref**(各頁 frontmatter 的 `sources` 欄位,格式 `路徑#hash前8碼`)。 + - 若各頁說法矛盾,兩種說法並陳並指出出處。 + - 候選頁都不相關時,直說知識庫沒有涵蓋,不要腦補。 + +## 禁止事項 + +- 禁止全庫掃描:不遍歷 `wiki/` 全部頁面、不讀 `raw/` 原始文件全文(AGENTS.md §8)。 +- 不修改任何檔案——查詢是唯讀操作。 +- 回答只根據 wiki 頁內容;wiki 沒有的知識明說沒有。 diff --git a/mcp/.gitkeep b/mcp/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/mcp/server.py b/mcp/server.py new file mode 100644 index 0000000..86185a0 --- /dev/null +++ b/mcp/server.py @@ -0,0 +1,56 @@ +"""FastMCP 薄殼(AGENTS.md §8):search_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))) # 上限 10(AGENTS.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() diff --git a/tools/search.py b/tools/search.py new file mode 100644 index 0000000..f8d7a4c --- /dev/null +++ b/tools/search.py @@ -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() diff --git a/tools/selfcheck_search.py b/tools/selfcheck_search.py new file mode 100644 index 0000000..12182c7 --- /dev/null +++ b/tools/selfcheck_search.py @@ -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()