"""共用模組: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.yaml:tasks.{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/chat(stdlib 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.md(AGENTS.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"""# 知識庫索引 ## 摘要頁(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)