Ctrl K
ComunidadGuías…

scripts/run_scan_worker.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
Scan-mode bookmark enrichment worker.

Instead of reading from tiege-queue.json, directly scans memory/bookmarks/
for files that don't yet have a corresponding enriched card in memory/cards/.

Handles:
  - Named files (01-clawpal.md) that have tweet_id inside frontmatter
  - Files with truncated/non-standard tweet IDs
  - Any bookmark without a matching memory/cards/ entry

Usage:
    python3 scripts/run_scan_worker.py --limit 20
    python3 scripts/run_scan_worker.py --dry-run
    python3 scripts/run_scan_worker.py --limit 50 --worker pipeline
    python3 scripts/run_scan_worker.py --category 01-openclaw-workflows --limit 5
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
from pathlib import Path

# ── Shared card prompt module ─────────────────────────────────────────────────
sys.path.insert(0, str(Path(__file__).parent))
from _card_prompt import build_prompt, find_related_context, llm_call as _llm_call, gbrain_put as _gbrain_put
from category_classifier import apply_category, classify_content

sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_paths
from gbrain_publish import PublicationBatch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))

import xkb_failures
from runtime_config import runtime_env

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


def _get_api_key() -> str:
    return runtime_env().get("LLM_API_KEY", "")


def _get_index_items() -> list[dict]:
    index_path = WORKSPACE / "memory" / "bookmarks" / "search_index.json"
    if not index_path.exists():
        return []
    try:
        data = json.loads(index_path.read_text(encoding="utf-8"))
        return data.get("items", []) if isinstance(data, dict) else []
    except Exception as err:
        # 索引壞掉會被當成「索引是空的」,於是整批重做或跳過去重。
        xkb_failures.note("search index", err, detail=str(index_path))
        return []


def _call_llm(api_key: str, content: str, card_id: str, source_url: str, category: str,
              related_context: str = "") -> str:
    prompt = build_prompt(
        content=f"Please process this bookmark. If content is low-value (login page/404/noise), output only: SKIPPED\n\n"
                f"--- Raw content ---\n{content[:4000]}\n---",
        card_id=card_id,
        source_type="x-bookmark",
        source_url=source_url,
        category=category,
        related_context=related_context or "(知識庫中尚無明顯相關的已存 card)",
    )
    return _llm_call(prompt, api_key, max_tokens=2500)


# ── Scan-specific helpers ──────────────────────────────────────────────────────

def _extract_frontmatter_value(text: str, key: str) -> str:
    m = re.search(rf'^{re.escape(key)}:\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 _build_legacy_card_id(filepath: Path) -> str:
    slug = re.sub(r"[^a-zA-Z0-9._-]+", "-", filepath.stem).strip("-").lower() or "untitled"
    return f"legacy-{slug}"


def _get_card_id(filepath: Path, content: str) -> str:
    """Determine a stable card ID for this bookmark file."""
    tweet_id = _extract_frontmatter_value(content, "tweet_id")
    if tweet_id and re.fullmatch(r"\d{15,20}", tweet_id):
        return tweet_id

    for field in ("source_url", "source"):
        url = _extract_frontmatter_value(content, field)
        status_id = _extract_status_id(url)
        if status_id:
            return status_id

    if re.fullmatch(r"\d{15,20}", filepath.stem):
        return filepath.stem

    m = re.match(r"^(\d{15,20})", filepath.stem)
    if m:
        return m.group(1)

    return _build_legacy_card_id(filepath)


def _get_source_url(content: str, card_id: str) -> str:
    for field in ("source_url", "source"):
        url = _extract_frontmatter_value(content, field)
        if url and url.startswith("http"):
            return url
    if re.fullmatch(r"\d{15,20}", card_id):
        return f"https://x.com/i/status/{card_id}"
    return ""


def _get_category(filepath: Path) -> str:
    try:
        parts = filepath.relative_to(BOOKMARKS_DIR).parts
        if len(parts) >= 2:
            return parts[0]
    except Exception:
        pass
    return ""



# Known markers that indicate the page failed to load (X login wall, etc.)
_JUNK_PATTERNS = [
    "Don't miss what's happening",
    "People on X are the first to know",
    "Log in to X",
    "Sign in to X",
    "JavaScript is not available",
]
# junk detection uses pattern matching only (no length threshold)

def _is_junk_content(content: str) -> bool:
    """Return True if bookmark content is too thin or is a known empty page."""
    # Strip frontmatter
    body = content.split("---", 2)[-1].strip() if content.startswith("---") else content.strip()
    for pat in _JUNK_PATTERNS:
        if pat in body:
            return True
    return False

def scan_missing(limit: int, category_filter: str = "") -> list[tuple[Path, str, str, str, str]]:
    """Return list of (filepath, content, card_id, source_url, category) for unenriched files."""
    CARDS_DIR.mkdir(parents=True, exist_ok=True)
    existing_card_ids = xkb_paths.card_ids()

    results = []
    skip_dirs = {"notebooklm_exports", "__pycache__", "youtube"}

    for md_file in sorted(BOOKMARKS_DIR.rglob("*.md")):
        if any(d in md_file.parts for d in skip_dirs):
            continue
        if md_file.name.startswith("."):
            continue

        content = md_file.read_text(encoding="utf-8", errors="ignore")
        card_id = _get_card_id(md_file, content)
        source_url = _get_source_url(content, card_id)
        category = _get_category(md_file)

        if category_filter and category_filter not in category:
            continue

        if card_id in existing_card_ids:
            continue
        if _is_junk_content(content):
            print(f"  🗑️  Junk (no card, empty content): {md_file.name}, deleting")
            md_file.unlink()
            continue

        results.append((md_file, content, card_id, source_url, category))
        if len(results) >= limit:
            break

    return results


def main() -> int:
    parser = argparse.ArgumentParser(description="Scan-mode bookmark enrichment worker")
    parser.add_argument("--limit",      type=int, default=20,    help="Max items to process (default: 20)")
    parser.add_argument("--worker",     default="scan-worker",   help="Worker name")
    parser.add_argument("--dry-run",    action="store_true",     help="Simulate without API calls")
    parser.add_argument("--local-only", action="store_true",     help="Skip LLM enrichment — scan and list unenriched bookmarks without sending content to any API")
    parser.add_argument("--category",   default="",              help="Filter by category slug")
    args = parser.parse_args()

    if args.local_only:
        args.dry_run = True  # local-only implies dry-run (no API calls)

    api_key = "" if args.dry_run else _get_api_key()
    if not api_key and not args.dry_run:
        print("❌ LLM_API_KEY not found. Set process env or XKB_ENV_FILE.")
        sys.exit(1)

    batch = PublicationBatch(_gbrain_put)
    if not args.dry_run:
        if not batch.ready():
            return 1
        batch.recover_saved((path, path.stem) for path in xkb_paths.card_files())
        if batch.blocked:
            batch.finish()
            return 1
    missing = scan_missing(args.limit, args.category)
    total_missing = len(scan_missing(9999, args.category))

    if not missing:
        print("✅ All bookmarks already enriched")
        return 0 if args.dry_run or batch.finish() else 1

    print(f"📋 Found {total_missing} unenriched bookmarks  |  Processing {len(missing)} [worker: {args.worker}]")
    if args.local_only:
        print("   (local-only mode — no content sent to external APIs)")
    elif args.dry_run:
        print("   (dry-run — no API calls)")
    else:
        print(f"   ⚠️  Bookmark content will be sent to LLM API (配置中的 model) for enrichment.")

    results = {"done": 0, "skipped": 0, "failed": 0}

    for filepath, content, card_id, source_url, category in missing:
        if batch.blocked:
            break
        label = str(filepath.relative_to(BOOKMARKS_DIR))
        print(f"  → {label}", end="  ", flush=True)

        if args.dry_run:
            print(f"[dry-run: {card_id}]")
            results["done"] += 1
            continue

        try:
            related_ctx = find_related_context(content, [])
            classified = classify_content(content, source_type="x-bookmark", current_category=category)
            text = _call_llm(api_key, content, card_id, source_url, classified["category"], related_ctx)
            if not text:
                results["failed"] += 1
                print("✗ empty response")
                continue
            if re.match(r"^SKIPPED", text.strip(), re.IGNORECASE):
                results["skipped"] += 1
                print("⏭ skipped")
                continue

            text = apply_category(text, classified["category"])
            card_path = CARDS_DIR / f"{card_id}.md"
            card_path.write_text(text, encoding="utf-8")
            if not batch.publish(card_path, card_id, generated=True):
                results["failed"] += 1
                continue
            results["done"] += 1
            print("✓ done")
        except Exception as exc:
            results["failed"] += 1
            print(f"✗ {exc}")

    if not args.dry_run and not batch.finish():
        return 1
    if results["failed"]:
        return 1
    remaining = len(scan_missing(9999, args.category))
    print(f"\n📊 done={results['done']}  skipped={results['skipped']}  failed={results['failed']}  remaining={remaining}")

    return 0


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

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