Ctrl K
ComunidadGuías…

scripts/sync_enriched_index.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
Sync enriched cards from memory/cards/ back into search_index.json.

For each enriched card found in memory/cards/:
- Update path to point to memory/cards/[id].md
- Keep relative_path stable and truly relative
- Extract and update summary from "## 1. 核心摘要" section
- Extract and update tags from frontmatter
- Extract and update title from "# Title" line
- Set enriched: true
- Only add orphan cards when they have valid, stable metadata

Usage:
    python3 scripts/sync_enriched_index.py
    python3 scripts/sync_enriched_index.py --dry-run
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
from pathlib import Path
from urllib.parse import urlparse

sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_paths
import xkb_index
import xkb_frontmatter

WORKSPACE = xkb_paths.WORKSPACE
BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR
CARDS_DIR = xkb_paths.CARDS_DIR
INDEX_FILE = xkb_paths.INDEX_FILE


def _extract_summary(text: str) -> str:
    # ── New format: ## 7. 雙語摘要(搜尋索引用)with ZH:/EN: labels ──────────
    bilingual = re.search(r"##\s+7\.\s*雙語摘要[^\n]*\n(.+?)(?=\n##|\Z)", text, re.DOTALL)
    if bilingual:
        block = bilingual.group(1)
        zh_m = re.search(r"^(?:ZH|中文)[::]\s*(.+)$", block, re.MULTILINE)
        en_m = re.search(r"^(?:EN|英文)[::]\s*(.+)$", block, re.MULTILINE)
        parts = [m.group(1).strip() for m in [zh_m, en_m] if m and m.group(1).strip()]
        if parts:
            return " | ".join(parts)

    # ── Legacy formats (old worker cards, github/local ingest cards) ──────────
    patterns = [
        r"##\s+1\.\s*核心摘要\s*\n(.+?)(?=\n##|\Z)",
        r"##\s+1\.\s*English Summary\s*\n(.+?)(?=\n##|\Z)",
        r"##\s*📝\s*English Summary\s*\n(.+?)(?=\n##|\Z)",
    ]
    parts = []
    for pat in patterns:
        m = re.search(pat, text, re.DOTALL)
        if m:
            lines = [l.strip().lstrip("-").strip() for l in m.group(1).strip().splitlines() if l.strip()]
            parts.append(" ".join(lines)[:300])
    return " | ".join(parts) if parts else ""


def _extract_tags(text: str) -> list[str]:
    m = re.search(r"^tags:\s*\[(.+?)\]", text, re.MULTILINE)
    if m:
        return [t.strip().strip('"').strip("'") for t in m.group(1).split(",") if t.strip()]
    return []


def _extract_title(text: str) -> str:
    m = re.search(r"^# (.+)$", text, re.MULTILINE)
    return m.group(1).strip() if m else ""


def _extract_frontmatter_value(text: str, key: str) -> str:
    m = re.search(r"^" + re.escape(key) + r":\s*[\"']?([^\"'\n]+)[\"']?\s*$", text, re.MULTILINE)
    return m.group(1).strip() if m else ""


def _extract_status_id(value: str) -> str:
    if not value:
        return ""
    m = re.search(r"/status/(\d{15,20})", value)
    return m.group(1) if m else ""


def _looks_like_url(value: str) -> bool:
    if not value:
        return False
    try:
        parsed = urlparse(value)
        return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
    except Exception:
        return False


def _placeholder_text(value: str) -> bool:
    v = (value or "").strip().lower()
    return v in {"", "待整理", "待補充", "tbd", "todo", "n/a", "untitled", "(untitled)"}


def _relative_from_workspace(path: Path) -> str:
    return str(path.relative_to(WORKSPACE))


def _resolve_item_id(item: dict) -> str:
    source_url = item.get("source_url", "")
    source_id = _extract_status_id(source_url)
    if source_id:
        return source_id

    for key in ("relative_path", "path"):
        stem = Path(item.get(key, "")).stem
        if re.fullmatch(r"\d{15,20}", stem):
            return stem
        m = re.match(r"^(\d{15,20})", stem)
        if m:
            return m.group(1)
        # Non-numeric stems (github_star-xxx, github_fork-xxx, local cards) — use full stem as id
        if stem:
            return stem
    return ""


def _resolve_card_id(text: str, stem: str) -> str:
    # Non-numeric stems (github/local cards) — stem itself is the stable id
    if stem and not re.match(r"\d", stem):
        return stem
    if re.fullmatch(r"\d{15,20}", stem):
        return stem
    m = re.match(r"^(\d{15,20})", stem)
    if m:
        return m.group(1)
    for key in ("id", "tweet_id", "source_url"):
        value = _extract_frontmatter_value(text, key)
        resolved = _extract_status_id(value) if key == "source_url" else (value if re.fullmatch(r"\d{15,20}", value) else "")
        if resolved:
            return resolved
    m = re.search(r"/status/(\d{15,20})", text)
    if m:
        return m.group(1)
    return ""


def _is_valid_orphan_card(text: str, card_id: str, source_url: str, title: str, summary: str) -> bool:
    _cleaned = re.sub(r"<think>[\s\S]*?</think>", "", text).strip()
    if not _cleaned.startswith("---"):
        return False
    card_type = _extract_frontmatter_value(text, "type")
    if card_type not in {"x-knowledge-card", "knowledge-card"}:
        return False
    source_type = _extract_frontmatter_value(text, "source_type")
    if source_type not in {"x-bookmark", "youtube", "github_fork", "github_star", "local-paper", "pubmed"}:
        return False
    if not _looks_like_url(source_url):
        return False
    if source_type == "x-bookmark" and not _extract_status_id(source_url):
        return False
    if source_type in {"youtube", "github_fork", "github_star", "local-paper", "pubmed"} and not card_id:
        return False
    if _placeholder_text(title) and _placeholder_text(summary):
        return False
    return True


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()

    if not CARDS_DIR.exists():
        print(f"❌ Cards dir not found: {CARDS_DIR}")
        sys.exit(1)
    if not INDEX_FILE.exists():
        print(f"❌ Index not found: {INDEX_FILE}")
        sys.exit(1)

    raw = json.loads(INDEX_FILE.read_text(encoding="utf-8"))
    items = raw.get("items", raw) if isinstance(raw, dict) else raw
    is_dict = isinstance(raw, dict)

    id_to_idx: dict[str, int] = {}
    for idx, item in enumerate(items):
        item_id = _resolve_item_id(item)
        if item_id:
            id_to_idx[item_id] = idx

    card_files = xkb_paths.card_files()
    print(f"📁 {len(card_files)} enriched cards  |  {len(id_to_idx)} indexed items")

    updated = 0
    unexcluded = 0
    not_found_ids: list[str] = []

    for card_path in card_files:
        raw_card_id = card_path.stem
        text = card_path.read_text(encoding="utf-8", errors="ignore")
        resolved_id = _resolve_card_id(text, raw_card_id)
        if not resolved_id or resolved_id not in id_to_idx:
            not_found_ids.append(raw_card_id)
            continue

        idx = id_to_idx[resolved_id]
        item = items[idx]

        summary = _extract_summary(text)
        tags = _extract_tags(text)
        title = _extract_title(text)
        category = _extract_frontmatter_value(text, "category")
        new_path = str(card_path)
        new_relative_path = _relative_from_workspace(card_path)

        changes: dict = {}
        if item.get("path") != new_path:
            changes["path"] = new_path
        if item.get("relative_path") != new_relative_path:
            changes["relative_path"] = new_relative_path
        if summary and item.get("summary") != summary:
            changes["summary"] = summary
        if tags and item.get("tags") != tags:
            changes["tags"] = tags
        if title and item.get("title") != title:
            changes["title"] = title
        if category and item.get("category") != category:
            changes["category"] = category
        if not item.get("enriched"):
            changes["enriched"] = True
        # 充實之後要把過期的排除標記撤掉——而且要撤在檔案上。
        #
        # 這一段原本只改索引列。索引現在是衍生物,所以那是死碼:下一次重建會
        # 從卡片檔案把 excluded: true 原封不動讀回來。結果是排除變成單行道——
        # 一張被誤排除的卡(例如被那個「空欄位吞掉下一行」的解析 bug 害到的那
        # 兩張),即使後來補上了標題與摘要,也沒有任何管線步驟能把它救回來,
        # 只能手動改檔案。原本反而是安全的:那時候下一次重建就洗掉了。
        #
        # 只撤品質類的理由。duplicate_source_url 不是品質問題,撤掉它會把一份
        # 重複的副本放回召回裡。
        if item.get("excluded") and title and summary and not title.startswith("Tweet "):
            reason = str(item.get("excluded_reason") or "")
            if "duplicate_source_url" not in reason:
                for path in xkb_index.files_for_item(item):
                    if xkb_frontmatter.unmark_excluded(path, dry_run=args.dry_run):
                        unexcluded += 1

        if changes:
            if not args.dry_run:
                item.update(changes)
            updated += 1
            if args.dry_run:
                print(f"  [dry-run] {raw_card_id}: {list(changes.keys())}")

    print(f"✅ Updated: {updated}  |  Not in index: {len(not_found_ids)}")
    if unexcluded:
        print(f"↩️  撤掉 {unexcluded} 個過期的排除標記(卡片已補上標題與摘要)")
    if not_found_ids:
        print(f"   (first 5 not found: {not_found_ids[:5]})")

    added = 0
    existing_ids = {_resolve_item_id(item) for item in items}
    existing_urls = {(item.get("source_url") or "").strip() for item in items if (item.get("source_url") or "").strip()}
    existing_paths = {(item.get("relative_path") or item.get("path") or "").strip() for item in items}
    for raw_card_id in not_found_ids:
        card_path = CARDS_DIR / f"{raw_card_id}.md"
        if not card_path.exists():
            continue
        text = card_path.read_text(encoding="utf-8", errors="ignore")
        title = _extract_title(text)
        summary = _extract_summary(text)
        tags = _extract_tags(text)
        source_url = _extract_frontmatter_value(text, "source_url")
        category = _extract_frontmatter_value(text, "category")
        source_type = _extract_frontmatter_value(text, "source_type")
        resolved_id = _resolve_card_id(text, raw_card_id) or raw_card_id

        if not _is_valid_orphan_card(text, resolved_id, source_url, title, summary):
            continue

        card_abs = str(card_path)
        card_rel = _relative_from_workspace(card_path)
        if resolved_id in existing_ids or source_url in existing_urls or card_rel in existing_paths or card_abs in existing_paths:
            continue
        new_entry = {
            "id": resolved_id,
            "path": card_abs,
            "relative_path": card_rel,
            "title": title,
            "summary": summary,
            "tags": tags,
            "source_url": source_url,
            "category": category,
            "source_type": source_type,
            "enriched": True,
        }
        if not args.dry_run:
            items.append(new_entry)
        added += 1
        if args.dry_run:
            print(f"  [dry-run] ADD {resolved_id}: {title[:60]}")

    if added:
        print(f"➕ Added {added} new index entries from orphaned cards")

    # 這支腳本不再寫索引。
    #
    # 它做的事——把卡片的標題/摘要/標籤同步進索引列、把孤兒卡補進去——
    # build_search_index.sh 從檔案重算時本來就全做了,而且做得更全:實測
    # 一次乾淨重建找回三張這支腳本漏掉的卡(它只走 X 書籤 ID 那條路,
    # github_star-* 與 legacy-* 那類卡片它掃不到)。
    #
    # 保留這個入口是為了不打斷既有的呼叫與習慣;真正做事的是 builder。
    if not args.dry_run and (updated > 0 or added > 0 or not_found_ids):
        xkb_index.rebuild()

import xkb_usage  # noqa: E402  — 量測誰在跑,見 scripts/xkb_usage.py

if __name__ == "__main__":
    xkb_usage.record(__file__)
    main()