Files
pp-qa-km/tools/kb.py

187 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""共用模組config 載入驗證、本地 Ollama 呼叫(重試 + fallback
wiki 頁 frontmatter 解析/寫出、index.md 重建、log.md 追加。
供 ingest.py / lint.py / search.py / mcp/server.py 使用。
"""
import datetime
import json
import pathlib
import os
import re
import urllib.request
import yaml
ROOT = pathlib.Path(__file__).resolve().parents[1]
PAGE_DIRS = {"summary": "wiki/summaries", "entity": "wiki/entities", "concept": "wiki/concepts"}
PAGE_TYPES = set(PAGE_DIRS)
STATUSES = {"draft", "reviewed", "stale"}
REQUIRED_FM = ("type", "title", "description", "tags", "timestamp", "sources", "status")
# ---------- config ----------
def load_config(root=ROOT):
"""讀取 config/models.yaml。缺欄位報錯不使用隱含預設值AGENTS.md §1.2)。"""
path = root / "config" / "models.yaml"
cfg = yaml.safe_load(path.read_text(encoding="utf-8"))
try:
cfg["ollama"]["base_url"]
cfg["ollama"]["timeout_seconds"]
for task in ("ingest", "lint"):
for key in ("model", "temperature", "max_retries"):
cfg["tasks"][task][key]
cfg["tasks"]["fallback"]["model"]
cfg["limits"]["mcp_response_max_tokens"]
cfg["limits"]["ingest_chunk_max_chars"]
except (KeyError, TypeError) as e:
raise SystemExit(f"config/models.yaml 缺欄位:{e}")
for task, spec in cfg["tasks"].items():
if spec["model"] == "CHANGE_ME":
raise SystemExit(f"config/models.yamltasks.{task}.model 尚未設定")
if spec["model"].endswith(":cloud"):
raise SystemExit(f"tasks.{task}.model 為 :cloud 模型違反資料落地AGENTS.md §1.1")
# 環境變數是唯一允許的覆寫來源AGENTS.md §1.2
cfg["ollama"]["base_url"] = os.environ.get("OLLAMA_BASE_URL", cfg["ollama"]["base_url"])
return cfg
# ---------- Ollama ----------
def ollama_chat(cfg, model, prompt, temperature, json_format=False):
"""單次呼叫本地 Ollama /api/chatstdlib urllib不需額外依賴"""
body = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"temperature": temperature},
}
if json_format:
body["format"] = "json"
req = urllib.request.Request(
cfg["ollama"]["base_url"].rstrip("/") + "/api/chat",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=cfg["ollama"]["timeout_seconds"]) as r:
return json.loads(r.read())["message"]["content"]
def llm_json(cfg, task, prompt, validate):
"""要求 JSON 輸出的呼叫:主模型重試 max_retries 次,仍失敗切 fallback 模型。
本地模型輸出不穩定是預期情況AGENTS.md 前提),故每次失敗把錯誤附回
prompt 再試。validate(obj) 回傳錯誤訊息字串或 None。
回傳 (obj, 實際使用的 model)。全部失敗拋 RuntimeError。
"""
spec = cfg["tasks"][task]
attempts = []
for model in (spec["model"], cfg["tasks"]["fallback"]["model"]):
hint = ""
for _ in range(spec["max_retries"]):
try:
raw = ollama_chat(cfg, model, prompt + hint, spec["temperature"],
json_format=True)
obj = json.loads(raw)
err = validate(obj)
if err is None:
return obj, model
except Exception as e:
err = f"{type(e).__name__}: {e}"
attempts.append(f"{model}: {err}")
hint = f"\n\n注意:上次輸出無效({err})。請只輸出符合規格的 JSON不得有其他文字。"
raise RuntimeError("LLM 輸出驗證失敗(含 fallback" + " | ".join(attempts[-4:]))
# ---------- wiki 頁 ----------
def parse_page(text):
"""回傳 (frontmatter dict, body)。格式不符拋 ValueError。"""
m = re.match(r"^---\r?\n(.*?)\r?\n---\r?\n(.*)$", text, re.S)
if not m:
raise ValueError("缺少 YAML frontmatter")
meta = yaml.safe_load(m.group(1))
if not isinstance(meta, dict):
raise ValueError("frontmatter 不是 mapping")
return meta, m.group(2).strip()
def dump_page(meta, body):
fm = yaml.safe_dump(meta, allow_unicode=True, sort_keys=False)
return f"---\n{fm}---\n\n{body.strip()}\n"
def validate_meta(meta):
"""依 AGENTS.md §3.2 檢查 frontmatter回傳錯誤訊息列表。"""
errs = [f"缺欄位 {k}" for k in REQUIRED_FM if k not in meta]
if errs:
return errs
if meta["type"] not in PAGE_TYPES:
errs.append(f"type 不合法:{meta['type']}")
if meta["status"] not in STATUSES:
errs.append(f"status 不合法:{meta['status']}")
if not isinstance(meta["tags"], list) or not meta["tags"]:
errs.append("tags 須為非空列表")
if not isinstance(meta["sources"], list) or not meta["sources"]:
errs.append("sources 須為非空列表")
for s in meta.get("sources") or []:
if not re.match(r"^raw/(converted|originals)/.+#[0-9a-f]{8}$", str(s)):
errs.append(f"source_ref 格式不符:{s}")
if not re.match(r"^\d{4}-\d{2}-\d{2}$", str(meta["timestamp"])):
errs.append(f"timestamp 須為 YYYY-MM-DD{meta['timestamp']}")
return errs
def safe_slug(s, fallback):
s = re.sub(r"[^a-z0-9]+", "-", str(s).lower()).strip("-")[:60]
return s or fallback
def iter_pages(root=ROOT):
for sub in PAGE_DIRS.values():
d = root / sub
if d.is_dir():
yield from sorted(d.glob("*.md"))
# ---------- index.md / log.md ----------
def rebuild_index(root=ROOT):
"""由全部 wiki 頁 frontmatter 決定性重建 index.mdAGENTS.md §10"""
groups = {"summary": [], "entity": [], "concept": []}
for p in iter_pages(root):
meta, _ = parse_page(p.read_text(encoding="utf-8"))
rel = p.relative_to(root).as_posix()
tags = " ".join(f"#{t}" for t in meta["tags"])
groups[meta["type"]].append(
(str(meta["title"]), f"- [{meta['title']}]({rel}) — {meta['description']} {tags}"))
def section(key):
lines = [line for _, line in sorted(groups[key])]
return "\n".join(lines) if lines else "(尚無頁面)"
text = f"""# 知識庫索引
<!-- 目錄式導航AGENTS.md §10每頁一行由 ingest 管線自動重建,格式:
- [標題](路徑) — 一句摘要 #tag1 #tag2
查詢工作流一律先讀本檔定位候選頁(最多 10 頁)。 -->
## 摘要頁summaries
{section('summary')}
## 實體頁entities
{section('entity')}
## 概念頁concepts
{section('concept')}
"""
(root / "index.md").write_text(text, encoding="utf-8")
def append_log(root, op, title, lines):
entry = f"\n## [{datetime.date.today().isoformat()}] {op} | {title}\n\n"
entry += "".join(f"- {l}\n" for l in lines)
with open(root / "log.md", "a", encoding="utf-8") as f:
f.write(entry)