#!/usr/bin/env python3 """Generate and apply compact knowledge-base patches. The pipeline is deliberately conservative: - the agent generates compact `.patch.md` files only; - this script applies only `更新动作: 新增` patches; - each applied patch creates three core files in one run directory: `*.patch.md`, `*.backup.md.bak`, and `*.applied.md`; - stable knowledge files are backed up before being modified. """ from __future__ import annotations import argparse import json import os import re import shutil import sys import time from dataclasses import dataclass from datetime import datetime from pathlib import Path BACKEND_ROOT = Path(__file__).resolve().parents[3] KNOWLEDGE_PARENT = Path(__file__).resolve().parents[2] HARNESS_ROOT = BACKEND_ROOT / "packages" / "harness" for import_root in (BACKEND_ROOT, HARNESS_ROOT): import_root_str = str(import_root) if import_root_str not in sys.path: sys.path.insert(0, import_root_str) KNOWLEDGE_ROOT = KNOWLEDGE_PARENT / "knowledge_base" MAINTENANCE_ROOT = KNOWLEDGE_PARENT / "knowledge_generate" / "maintenance" RUNS_ROOT = MAINTENANCE_ROOT / "runs" DEFAULT_MODEL = "GLM-5" DEFAULT_SKILLS = {"policy-knowledge", "update-knowledge"} DEFAULT_CONFIG_PATH = BACKEND_ROOT / "config.yaml" class PatchError(RuntimeError): pass @dataclass class ParsedPatch: patch_path: Path target_rel: Path action: str anchor_heading: str merge_content: str source_basis: str merge_judgment: str uncertain_items: str def slug_from_target(target_rel: Path) -> str: return "__".join(target_rel.with_suffix("").parts) def section(text: str, heading: str) -> str: pattern = re.compile( rf"^## {re.escape(heading)}\s*\n(?P.*?)(?=^## |\Z)", re.M | re.S, ) match = pattern.search(text) if not match: raise PatchError(f"missing section: ## {heading}") return match.group("body").strip() def first_backtick_path(text: str) -> str: match = re.search(r"`([^`]+)`", text) if not match: raise PatchError("target file section does not contain a backtick path") return match.group(1).strip() def normalize_target_path(raw: str) -> Path: raw_path = Path(raw) parts = raw_path.parts if parts and parts[0] in {"entities", "narratives", "events", "primitives", "policies", "playbooks", "reference", "analysis", "data_pointers", "routing"}: return raw_path if parts and parts[0] == "knowledge_base": return Path(*parts[1:]) if parts and parts[0] == "/mnt" and len(parts) >= 3 and parts[1] == "knowledge": return Path(*parts[2:]) if "knowledge_base" in parts: idx = parts.index("knowledge_base") return Path(*parts[idx + 1 :]) raise PatchError(f"target path must point inside knowledge_base: {raw}") def extract_merge_block(text: str) -> str: body = section(text, "可直接合并内容") match = re.search(r"```(?:markdown)?\s*\n(?P.*?)\n```", body, re.S) if not match: raise PatchError("merge content section must contain a fenced markdown block") content = match.group("content").strip() if not content: raise PatchError("merge content block is empty") return content def extract_anchor(text: str) -> str: body = section(text, "建议插入位置") headings = re.findall(r"`(#{2,6}\s+[^`]+)`", body) if not headings: raise PatchError("insert position must contain an existing heading in backticks") return headings[0].strip() def parse_patch(patch_path: Path) -> ParsedPatch: text = patch_path.read_text(encoding="utf-8") target_rel = normalize_target_path(first_backtick_path(section(text, "目标文件"))) action = section(text, "更新动作").splitlines()[0].strip(" 。.") if action != "新增": raise PatchError(f"only 新增 patches are supported, got: {action}") merge_judgment = section(text, "合并判断") if "暂不合并" in merge_judgment: raise PatchError("patch merge judgment says 暂不合并") return ParsedPatch( patch_path=patch_path, target_rel=target_rel, action=action, anchor_heading=extract_anchor(text), merge_content=extract_merge_block(text), source_basis=section(text, "来源依据"), merge_judgment=merge_judgment, uncertain_items=section(text, "不确定项"), ) def heading_level(heading: str) -> int: return len(heading) - len(heading.lstrip("#")) def insert_content(target_text: str, anchor_heading: str, merge_content: str) -> str: if merge_content in target_text: raise PatchError("merge content already exists in target") anchor = re.search(rf"^{re.escape(anchor_heading)}\s*$", target_text, re.M) if not anchor: raise PatchError(f"anchor heading not found: {anchor_heading}") level = heading_level(anchor_heading) next_heading = re.search(rf"^#{{1,{level}}}\s+", target_text[anchor.end() :], re.M) insert_at = anchor.end() + next_heading.start() if next_heading else len(target_text) before = target_text[:insert_at].rstrip() after = target_text[insert_at:].lstrip("\n") return f"{before}\n\n{merge_content.strip()}\n\n{after}" def write_applied_record(parsed: ParsedPatch, target_path: Path, backup_path: Path, record_path: Path) -> None: now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") record_path.write_text( "\n".join( [ "# 知识库自动更新说明", "", "## 更新时间", "", now, "", "## Patch 文件", "", f"`{parsed.patch_path}`", "", "## 目标文件", "", f"`{target_path}`", "", "## 备份文件", "", f"`{backup_path}`", "", "## 更新动作", "", parsed.action, "", "## 插入位置", "", f"`{parsed.anchor_heading}` 章节内、下一个同级标题之前。", "", "## 来源依据", "", parsed.source_basis, "", "## 合并判断", "", parsed.merge_judgment, "", "## 不确定项", "", parsed.uncertain_items, "", "## 状态", "", "已自动合并,待人工通过 git diff 审核。", "", ] ), encoding="utf-8", ) def apply_patch_file(patch_path: Path, run_dir: Path, dry_run: bool = False) -> tuple[Path, Path | None, Path | None]: parsed = parse_patch(patch_path) target_path = KNOWLEDGE_ROOT / parsed.target_rel if not target_path.exists(): raise PatchError(f"target file does not exist: {target_path}") original = target_path.read_text(encoding="utf-8") updated = insert_content(original, parsed.anchor_heading, parsed.merge_content) slug = slug_from_target(parsed.target_rel) backup_path = run_dir / f"{slug}.backup.md.bak" record_path = run_dir / f"{slug}.applied.md" if dry_run: return target_path, None, None backup_path.write_text(original, encoding="utf-8") target_path.write_text(updated, encoding="utf-8") write_applied_record(parsed, target_path, backup_path, record_path) return target_path, backup_path, record_path def build_prompt(live_date: str, workspace_rel_dir: str, limit: int, topic_hint: str | None) -> str: topic_line = f"\n优先主题/目标:{topic_hint}\n" if topic_hint else "" return f""" 你是知识库维护 agent。请使用 update-knowledge skill,并可结合 policy-knowledge skill 的知识库读取规则。 任务:基于 `/mnt/knowledge/live/{live_date}.txt` 判断是否需要生成可自动合并的知识库 patch 文件。{topic_line} 严格要求: 1. 先读取 `/mnt/skills/custom/update-knowledge/SKILL.md`。 2. 读取 `/mnt/knowledge/INDEX.md`、`/mnt/knowledge/live/_MANIFEST.md`、`/mnt/knowledge/live/{live_date}.txt`。 3. 根据 live 条目识别需要更新的稳定知识文件,并读取必要目标文件。 4. 不要修改 `/mnt/knowledge` 下任何稳定知识正文。 5. 使用 `write_file` 将 patch 写入 `/mnt/user-data/workspace/{workspace_rel_dir}/`。 6. 根据当天 live 内容自主判断需要更新多少个稳定知识文件;允许生成 0 到 {limit} 个 `.patch.md` 文件。 7. `{limit}` 是上限,不是必须生成数量;不要为了凑数量而更新。 8. 如果当天内容与稳定知识库无关、只是短期噪声、或信息不稳定,请不要生成 patch。 9. 不要生成 `.update.md` 长篇报告。 10. 每个 patch 总长度控制在 800-1500 字。 11. 每个 patch 必须只服务一个目标文件。 12. patch 文件必须使用以下固定格式: ```markdown # 知识库更新补丁候选 ## 目标文件 `knowledge_base/.../...md` ## 更新动作 新增。 ## 建议插入位置 建议插入在 `## 已存在的二级标题` 章节内、下一个同级标题之前。 ## 可直接合并内容 ```markdown 可直接进入 knowledge_base 正文的内容。这里不要写维护说明,不要写“建议”“我认为”。 ``` ## 来源依据 - `live/{live_date}.txt`:条目编号 ## 合并判断 建议人工审核后合并。 ## 不确定项 - 需要继续核验的事项;没有则写“无”。 ``` 硬性约束: - `## 可直接合并内容` 是唯一允许写入稳定知识库的正文。 - 如果信息不稳定,不要生成 patch。 - 不要写长篇背景介绍、完整事件复述、主题分类表。 - `建议插入位置` 中第一个反引号标题必须是目标文件中已经存在的标题。 - 如果判断无需更新,最终回复说明“本日未生成 patch”即可,不要调用 write_file。 完成后,在最终回复中列出实际写入的 patch 文件。 """ def event_to_jsonable(event) -> dict: return {"type": event.type, "data": dict(event.data or {})} def resolve_secret(value: str | None) -> str | None: """Resolve direct values or env references. Supported forms: - ``sk-xxx``: returned as-is - ``$ENV_NAME``: read from environment - ``env:ENV_NAME``: read from environment """ if not value: return None value = value.strip() if value.startswith("env:"): return os.getenv(value[4:]) if value.startswith("$"): return os.getenv(value[1:]) return value def secret_config_value(value: str) -> str: """Return the value to write into temp config without expanding env refs.""" value = value.strip() if value.startswith("env:"): return f"${value[4:]}" return value def parse_header_values(values: list[str] | None) -> dict[str, str]: """Parse repeated ``--header Key=Value`` options.""" headers: dict[str, str] = {} for raw in values or []: if "=" not in raw: raise ValueError(f"--header must use Key=Value format, got: {raw}") key, value = raw.split("=", 1) key = key.strip() if not key: raise ValueError(f"--header key cannot be empty: {raw}") headers[key] = value.strip() return headers def parse_headers_json(raw: str | None) -> dict[str, str]: if not raw: return {} data = json.loads(raw) if not isinstance(data, dict): raise ValueError("--headers-json must be a JSON object") return {str(k): str(v) for k, v in data.items()} def build_runtime_config( *, source_config: Path, run_dir: Path, config_model_name: str, provider_model_name: str | None, api_key: str | None, base_url: str | None, headers: dict[str, str], ) -> Path | None: """Create a temporary config.yaml with an injected model when overrides are provided. Returns ``None`` when no override is needed, so callers can use the normal DeerFlow config resolution. """ if not any([provider_model_name, api_key, base_url, headers]): return None try: import yaml except ImportError as exc: raise RuntimeError("PyYAML is required for model override config generation") from exc config = yaml.safe_load(source_config.read_text(encoding="utf-8")) or {} models = list(config.get("models") or []) if not models: raise RuntimeError(f"source config has no models: {source_config}") base_model = dict(models[0]) for model in models: if model.get("name") == config_model_name: base_model = dict(model) break base_model["name"] = config_model_name base_model["display_name"] = config_model_name base_model["use"] = "langchain_openai:ChatOpenAI" if provider_model_name: base_model["model"] = provider_model_name if api_key: resolved_api_key = resolve_secret(api_key) if not resolved_api_key: raise RuntimeError(f"api key is empty after env resolution: {api_key}") base_model["api_key"] = secret_config_value(api_key) if base_url: base_model["base_url"] = base_url if headers: # LangChain ChatOpenAI / OpenAI client uses default_headers. existing = base_model.get("default_headers") or {} if not isinstance(existing, dict): existing = {} base_model["default_headers"] = {**existing, **headers} base_model["supports_thinking"] = False base_model["supports_vision"] = bool(base_model.get("supports_vision", False)) replaced = False new_models = [] for model in models: if model.get("name") == config_model_name: new_models.append(base_model) replaced = True else: new_models.append(model) if not replaced: new_models.insert(0, base_model) config["models"] = new_models runtime_config = run_dir / "_runtime_config.yaml" runtime_config.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False), encoding="utf-8") return runtime_config def generate_patches( *, live_date: str, run_dir: Path, model: str, config_path: Path | None, limit: int, topic_hint: str | None, keep_log: bool, ) -> list[Path]: from deerflow.client import DeerFlowClient from deerflow.config.paths import get_paths from deerflow.runtime.user_context import get_effective_user_id thread_id = f"knowledge-update-{live_date}-{int(time.time())}" workspace_rel_dir = f"knowledge_update_runs/{run_dir.name}" prompt = build_prompt(live_date, workspace_rel_dir, limit, topic_hint) client = DeerFlowClient( config_path=str(config_path) if config_path else None, model_name=model, thinking_enabled=False, subagent_enabled=False, plan_mode=False, available_skills=DEFAULT_SKILLS, ) chunks: dict[str, list[str]] = {} last_ai_id = "" log_file = (run_dir / "_agent_run.jsonl").open("w", encoding="utf-8") if keep_log else None started = time.time() try: for event in client.stream(prompt, thread_id=thread_id, recursion_limit=160): if log_file: log_file.write(json.dumps(event_to_jsonable(event), ensure_ascii=False) + "\n") log_file.flush() if event.type == "messages-tuple": data = event.data if data.get("type") == "ai": msg_id = data.get("id") or "" content = data.get("content") or "" if content: chunks.setdefault(msg_id, []).append(content) last_ai_id = msg_id if data.get("tool_calls"): names = [tc.get("name") for tc in data.get("tool_calls", []) if tc.get("name")] if names: print(f"[tool_call] {names}", flush=True) elif data.get("type") == "tool": name = data.get("name") content = (data.get("content") or "").replace("\n", " ") print(f"[tool_result] {name}: {content[:200]}", flush=True) elif event.type == "end": print(f"[end] usage={event.data.get('usage')} elapsed={time.time() - started:.1f}s", flush=True) finally: if log_file: log_file.close() if keep_log: (run_dir / "_agent_final.md").write_text("".join(chunks.get(last_ai_id, [])), encoding="utf-8") workspace_dir = get_paths().sandbox_work_dir(thread_id, user_id=get_effective_user_id()) / workspace_rel_dir if not workspace_dir.exists(): raise RuntimeError(f"agent workspace output not found: {workspace_dir}") copied: list[Path] = [] for source in sorted(workspace_dir.glob("*.patch.md")): dest = run_dir / source.name shutil.copy2(source, dest) copied.append(dest) return copied def apply_all_patches(run_dir: Path, dry_run: bool) -> list[tuple[Path, Path, Path | None, Path | None]]: results = [] for patch_path in sorted(run_dir.glob("*.patch.md")): target, backup, record = apply_patch_file(patch_path, run_dir, dry_run=dry_run) results.append((patch_path, target, backup, record)) return results def create_run_dir(live_date: str, run_name: str | None) -> Path: name = run_name or f"{live_date}_glm5_{datetime.now().strftime('%H%M%S')}" run_dir = RUNS_ROOT / name run_dir.mkdir(parents=True, exist_ok=False) return run_dir def main() -> int: parser = argparse.ArgumentParser(description="Generate and apply knowledge patches.") parser.add_argument("--live-date", default="2026-05-18", help="live file date, e.g. 2026-05-18") parser.add_argument("--model", default=DEFAULT_MODEL, help="DeerFlow runtime model config name") parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_PATH, help="source config.yaml used to build runtime config") parser.add_argument("--api-key", help="override API key; supports direct value, $ENV_NAME, or env:ENV_NAME") parser.add_argument("--base-url", help="override OpenAI-compatible base URL") parser.add_argument("--model-name", help="override provider model name, e.g. Pro/zai-org/GLM-5.1") parser.add_argument("--header", action="append", help="extra HTTP header, repeatable, format Key=Value") parser.add_argument("--headers-json", help='extra HTTP headers as JSON object, e.g. \'{"X-Org":"abc"}\'') parser.add_argument("--limit", type=int, default=5, help="maximum number of patches to generate; agent may generate fewer, including 0") parser.add_argument("--topic", help="optional topic/target hint for the agent") parser.add_argument("--run-name", help="run directory name under maintenance/runs") parser.add_argument("--patch", type=Path, help="apply an existing patch instead of generating") parser.add_argument("--dry-run", action="store_true", help="validate but do not modify knowledge_base") parser.add_argument("--keep-log", action="store_true", help="keep agent stream log/final text in run dir") args = parser.parse_args() run_dir = create_run_dir(args.live_date, args.run_name) try: headers = { **parse_headers_json(args.headers_json), **parse_header_values(args.header), } runtime_config = build_runtime_config( source_config=args.config, run_dir=run_dir, config_model_name=args.model, provider_model_name=args.model_name, api_key=args.api_key, base_url=args.base_url, headers=headers, ) if args.patch: patch_dest = run_dir / args.patch.name shutil.copy2(args.patch, patch_dest) patches = [patch_dest] else: patches = generate_patches( live_date=args.live_date, run_dir=run_dir, model=args.model, config_path=runtime_config, limit=args.limit, topic_hint=args.topic, keep_log=args.keep_log, ) results = apply_all_patches(run_dir, dry_run=args.dry_run) except Exception: if not any(run_dir.iterdir()): run_dir.rmdir() raise print(f"run_dir: {run_dir}") print(f"patches: {len(patches)}") if not patches: print("status: no patches generated; no knowledge_base files changed") return 0 for patch_path, target, backup, record in results: print(f"patch: {patch_path}") print(f"target: {target}") if args.dry_run: print("status: dry-run ok") else: print(f"backup: {backup}") print(f"applied: {record}") print("status: applied") return 0 if __name__ == "__main__": sys.exit(main())