#!/usr/bin/env python3
"""
migrate_schema.py — XKB schema migration (Layer 1 + Layer 2)
Layer 1: search_index.json
- Backfill source_type (infer from path/filename)
- Backfill enriched: true for items with summary
Layer 2: card frontmatter
- Strip ... prefix from card content
- Normalize type: x-knowledge-card → knowledge-card
- Add source_type if missing
- Add sensitivity: public if missing
Usage:
python3 migrate_schema.py --dry-run
python3 migrate_schema.py
"""
from __future__ import annotations
import argparse
import json
import os
import re
from pathlib import Path
WORKSPACE = Path(os.getenv(
"OPENCLAW_WORKSPACE",
os.getenv("WORKSPACE_DIR", str(Path.home() / ".openclaw" / "workspace"))
))
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_paths
import xkb_index
CARDS_DIR = xkb_paths.CARDS_DIR
INDEX_FILE = xkb_paths.INDEX_FILE
def infer_source_type(stem: str) -> str:
if stem.startswith("local-pmc") or (stem.startswith("local-") and not stem.startswith("local-paper")):
return "local-paper"
if stem.startswith("github_fork-"):
return "github_fork"
if stem.startswith("github_star-"):
return "github_star"
if stem.startswith("youtube-"):
return "youtube"
if stem.startswith("legacy-"):
return "x-bookmark"
if re.fullmatch(r"\d{15,20}", stem):
return "x-bookmark"
return "local"
def strip_think(text: str) -> str:
"""Remove ... block from the start of a card."""
return re.sub(r"^\s*[\s\S]*?\s*", "", text).lstrip()
def fix_frontmatter(text: str, stem: str) -> tuple[str, list[str]]:
"""Normalize frontmatter. Returns (new_text, list_of_changes)."""
changes = []
# Strip prefix first
if re.match(r"\s*", text):
text = strip_think(text)
changes.append("stripped block")
# Check frontmatter exists
m = re.match(r"^---\n(.*?)\n---\s*\n?(.*)", text, re.DOTALL)
if not m:
# No valid frontmatter — can't fix automatically, skip
return text, ["SKIP: no valid frontmatter after stripping"]
fm_block = m.group(1)
body = m.group(2)
# Normalize type field
if "type: x-knowledge-card" in fm_block:
fm_block = fm_block.replace("type: x-knowledge-card", "type: knowledge-card")
changes.append("type: x-knowledge-card → knowledge-card")
# Add source_type if missing
if not re.search(r"^source_type:", fm_block, re.MULTILINE):
st = infer_source_type(stem)
# Try to insert after 'type:' line
fm_block = re.sub(
r"(^type:.*$)",
lambda m2: m2.group(1) + f"\nsource_type: {st}",
fm_block, count=1, flags=re.MULTILINE
)
changes.append(f"added source_type: {st}")
# Add sensitivity if missing
if not re.search(r"^sensitivity:", fm_block, re.MULTILINE):
fm_block = fm_block.rstrip() + "\nsensitivity: public"
changes.append("added sensitivity: public")
new_text = f"---\n{fm_block}\n---\n{body}"
return new_text, changes
def migrate_cards(dry_run: bool) -> dict:
stats = {"fixed": 0, "skipped": 0, "changes": {}}
for card_path in xkb_paths.card_files():
stem = card_path.stem
text = card_path.read_text(encoding="utf-8", errors="ignore")
new_text, changes = fix_frontmatter(text, stem)
real_changes = [c for c in changes if not c.startswith("SKIP")]
if not real_changes:
continue
stats["changes"][card_path.name] = real_changes
if not dry_run:
card_path.write_text(new_text, encoding="utf-8")
stats["fixed"] += 1
return stats
def migrate_index(dry_run: bool) -> dict:
if not INDEX_FILE.exists():
return {"error": "index not found"}
data = json.loads(INDEX_FILE.read_text(encoding="utf-8"))
items = data.get("items", [])
changed = 0
for item in items:
item_changed = False
# Infer source_type from relative_path or path
if not item.get("source_type"):
rp = item.get("relative_path", item.get("path", ""))
stem = Path(rp).stem
st = infer_source_type(stem)
item["source_type"] = st
item_changed = True
# Set enriched based on whether summary exists
if "enriched" not in item:
item["enriched"] = bool(item.get("summary", "").strip())
item_changed = True
if item_changed:
changed += 1
# Layer 1 原本補的 source_type / enriched,builder 現在直接從檔案解析,
# 所以這裡不需要也不應該自己寫索引。重建交給 main() 在修完卡片之後做一次:
# migrate_index 只動記憶體、不碰檔案,而增量重建只重解析 mtime 變過的檔案
# ——在這裡呼叫 rebuild() 是個什麼都不會發生的動作,而輸出照樣印 updated: N。
return {"total": len(items), "updated": changed}
def main():
parser = argparse.ArgumentParser(description="XKB schema migration")
parser.add_argument("--dry-run", action="store_true", help="Preview without writing")
args = parser.parse_args()
mode = "DRY RUN" if args.dry_run else "LIVE"
print(f"=== XKB Schema Migration [{mode}] ===\n")
print("Layer 1: search_index.json")
idx_result = migrate_index(args.dry_run)
print(f" total items : {idx_result.get('total', 0)}")
print(f" updated : {idx_result.get('updated', 0)}")
print("\nLayer 2: card frontmatter")
card_result = migrate_cards(args.dry_run)
print(f" cards fixed : {card_result['fixed']}")
# Show sample of changes
changes = card_result["changes"]
sample = list(changes.items())[:10]
for name, ch in sample:
print(f" {name}: {', '.join(ch)}")
if len(changes) > 10:
print(f" ... and {len(changes) - 10} more")
if args.dry_run:
print("\n[dry-run] Nothing was written. Run without --dry-run to apply.")
return 0
# 修完卡片才重建,而且要整份重建。
#
# 遷移改的是 frontmatter,而增量重建靠 mtime/size 判斷要不要重新解析——
# 照理說改過的檔案會被認出來,但 Layer 1 完全沒碰檔案,所以原本在
# migrate_index 裡呼叫的那次增量重建是個什麼都不會發生的動作,而輸出
# 照樣印 updated: N。順序也是錯的:那次重建發生在卡片被修好之前。
if card_result["fixed"] or idx_result.get("updated"):
print("\nRebuilding the search index from the migrated files...")
if not xkb_index.rebuild(incremental=False):
print(" ⚠️ 索引重建失敗——遷移已寫進卡片,但索引還是舊的。")
return 1
print("\nDone. Run build_vector_index.py --incremental to refresh embeddings.")
return 0
import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py
if __name__ == "__main__":
xkb_usage.record(__file__)
raise SystemExit(main())