Ctrl K
ComunidadGuías…

scripts/action_recall.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
Action Recall — Active Recall Layer Phase 1.3

查詢來源:
  - scripts/*.py 腳本清單(比對名稱)
  - wiki/topics/*.md(找含 TODO / 下一步 / Phase / roadmap 的段落)
  - docs/plans/*.md(比對計畫文件)

適用於「規劃下一步、評估 implementation、拆任務」時,
主動提醒已有的腳本、元件或計畫可以復用。

Usage:
  python3 action_recall.py "active recall 自動觸發 hook"
  python3 action_recall.py "query" --json
"""
from __future__ import annotations

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

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

WORKSPACE = xkb_paths.WORKSPACE
_SKILL_DIR = xkb_paths.SKILL_DIR
SCRIPTS_DIR = xkb_paths.SCRIPTS_DIR
WIKI_TOPICS_DIR = xkb_paths.WIKI_TOPICS_DIR
DOCS_PLANS_DIR = WORKSPACE / "docs" / "plans"
DOCS_XKB_DIR = WORKSPACE / "docs" / "xkb"

STOPWORDS = {
    "的", "了", "是", "我", "你", "在", "有", "和", "也", "都", "要", "用",
    "this", "that", "with", "from", "have", "will", "the", "are",
}

# ── 可執行資產信號詞 ────────────────────────────────────────────────────────────
ACTION_SIGNALS = [
    r"TODO", r"下一步", r"下一個", r"接下來", r"Phase\s*\d", r"v\d\s*[::]",
    r"roadmap", r"計畫", r"可以直接", r"已有", r"現有", r"可復用", r"可接",
    r"建議先", r"優先", r"待辦",
]
ACTION_RE = re.compile("|".join(ACTION_SIGNALS), re.IGNORECASE)


class ActionResult(NamedTuple):
    asset_type: str     # script | wiki_section | plan
    name: str           # script filename / wiki title / plan filename
    path: str           # relative path
    description: str    # one-line description or excerpt
    score: float


def tokenize(text: str) -> list[str]:
    return xkb_text.tokenize(text, STOPWORDS)


def _score(tokens: list[str], text: str) -> float:
    if not tokens or not text:
        return 0.0
    text_lower = text.lower()
    hits = sum(1 for t in tokens if t in text_lower)
    phrase_bonus = sum(
        0.3 for i in range(len(tokens) - 1)
        if f"{tokens[i]} {tokens[i+1]}" in text_lower or tokens[i] + tokens[i+1] in text_lower
    )
    return hits / max(len(tokens), 1) + phrase_bonus


# ── Script search ──────────────────────────────────────────────────────────────

def _search_scripts(tokens: list[str]) -> list[ActionResult]:
    if not SCRIPTS_DIR.exists():
        return []
    results = []
    for path in SCRIPTS_DIR.glob("*.py"):
        stem = path.stem.replace("_", " ").replace("-", " ")
        score = _score(tokens, stem + " " + path.name)
        if score < 0.2:
            continue
        # Try to get one-line description from docstring
        try:
            content = path.read_text(encoding="utf-8")
            docstring_match = re.search(r'"""(.*?)"""', content, re.DOTALL)
            desc = ""
            if docstring_match:
                first_line = docstring_match.group(1).strip().splitlines()[0].strip()
                desc = first_line[:100]
        except Exception:
            desc = ""
        results.append(ActionResult(
            asset_type="script",
            name=path.name,
            path=f"scripts/{path.name}",
            description=desc or stem,
            score=round(score, 3),
        ))
    results.sort(key=lambda r: r.score, reverse=True)
    return results[:3]


# ── Wiki action sections ───────────────────────────────────────────────────────

def _split_sections(content: str) -> list[tuple[str, str]]:
    sections: list[tuple[str, str]] = []
    current_title = ""
    current_lines: list[str] = []
    for line in content.splitlines():
        if re.match(r"^#{1,3} .+", line):
            if current_lines:
                sections.append((current_title, "\n".join(current_lines)))
            current_title = re.sub(r"^#{1,3} ", "", line).strip()
            current_lines = []
        else:
            current_lines.append(line)
    if current_lines:
        sections.append((current_title, "\n".join(current_lines)))
    return sections


def _search_wiki_actions(tokens: list[str]) -> list[ActionResult]:
    if not WIKI_TOPICS_DIR.exists():
        return []
    results = []
    for path in WIKI_TOPICS_DIR.glob("*.md"):
        try:
            content = re.sub(r"^---\n.*?\n---\n", "", path.read_text(encoding="utf-8"), flags=re.DOTALL)
        except Exception:
            continue
        for title, body in _split_sections(content):
            # Only sections with action signals
            if not ACTION_RE.search(body) and not ACTION_RE.search(title):
                continue
            rel_score = _score(tokens, f"{title} {body}")
            if rel_score < 0.15:
                continue
            excerpt = (body.strip().splitlines()[0] or "")[:120]
            results.append(ActionResult(
                asset_type="wiki_section",
                name=f"{path.stem} § {title}",
                path=f"wiki/topics/{path.name}",
                description=excerpt,
                score=round(rel_score, 3),
            ))
    results.sort(key=lambda r: r.score, reverse=True)
    return results[:2]


# ── Plan / spec docs search ────────────────────────────────────────────────────

def _search_plans(tokens: list[str]) -> list[ActionResult]:
    results = []
    for dir_ in [DOCS_PLANS_DIR, DOCS_XKB_DIR]:
        if not dir_.exists():
            continue
        for path in dir_.glob("*.md"):
            stem = path.stem.replace("-", " ").replace("_", " ")
            score = _score(tokens, stem)
            if score < 0.2:
                continue
            try:
                first_line = path.read_text(encoding="utf-8").splitlines()[0].lstrip("#").strip()
            except Exception:
                first_line = stem
            results.append(ActionResult(
                asset_type="plan",
                name=path.name,
                path=str(path.relative_to(WORKSPACE)),
                description=first_line[:100],
                score=round(score, 3),
            ))
    results.sort(key=lambda r: r.score, reverse=True)
    return results[:2]


# ── Main recall ────────────────────────────────────────────────────────────────

def recall(query: str, top_k: int = 4) -> list[ActionResult]:
    tokens = tokenize(query)
    if not tokens:
        return []

    candidates: list[ActionResult] = []
    candidates.extend(_search_scripts(tokens))
    candidates.extend(_search_wiki_actions(tokens))
    candidates.extend(_search_plans(tokens))

    # De-dup by path, keep highest score
    seen: dict[str, ActionResult] = {}
    for r in candidates:
        if r.path not in seen or r.score > seen[r.path].score:
            seen[r.path] = r

    sorted_results = sorted(seen.values(), key=lambda r: r.score, reverse=True)
    return sorted_results[:top_k]


def format_hint(results: list[ActionResult]) -> str:
    if not results:
        return ""
    lines = ["【🔧 可復用資產】"]
    type_labels = {"script": "腳本", "wiki_section": "Wiki", "plan": "計畫文件"}
    for r in results:
        label = type_labels.get(r.asset_type, r.asset_type)
        lines.append(f"• [{label}] {r.name}")
        if r.description:
            lines.append(f"  {r.description}")
        lines.append(f"  路徑:{r.path}")
        lines.append("")
    return "\n".join(lines).strip()


def main() -> int:
    parser = argparse.ArgumentParser(description="Action Recall — finds existing scripts, wiki sections, and plans")
    parser.add_argument("query", nargs="?")
    parser.add_argument("--limit", type=int, default=4)
    parser.add_argument("--json", dest="as_json", action="store_true")
    args = parser.parse_args()

    query = args.query or sys.stdin.read().strip()
    if not query:
        print("Usage: action_recall.py <query>")
        return 1

    results = recall(query, top_k=args.limit)

    if args.as_json:
        print(json.dumps([r._asdict() for r in results], ensure_ascii=False, indent=2))
        return 0

    if not results:
        print("(沒找到相關的可執行資產)")
        return 0

    print(format_hint(results))
    return 0


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

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