#!/usr/bin/env python3 """把分類在正式清單外的知識重新分類。預設只報告,--apply 才寫。 2026-09-12 實測 1,706 筆索引裡有 68 筆(4%)的 category 不在 taxonomy() 裡: ai-tools 25、tech 13、inbox 11、general 7、空 4、02-ai-tools 2, 以及 design / developer-tools / test / 05-knowledge-management 等零星值 分類不影響召回(沒有過濾也沒有加權),但它決定 wiki 路由:sync_cards_to_wiki 的 make_card 看到空的 category 就回 None,那張卡從此不會進任何 topic。清單外的 值則會被當成一個沒有 topic 映射的分類,效果跟落在角落一樣。 **為什麼不是順手對齊資料夾。** 書籤原始檔的資料夾有 650 筆跟自己的 frontmatter 不一致,但那跟分類品質無關:索引是從 memory/cards/(平的)建的,資料夾只是書籤 的進料位址,沒有任何程式把它當分類讀(見 tests/test_the_folder_is_not_the_category)。 搬那 650 個檔會改掉 relative_path,而那是向量索引與 knowledge_usage 的鍵——會 把使用統計變成孤兒,換不到任何東西。 **新分類走候診,不是禁止。** 分類器看到真的沒有現成分類可用的內容時會提議一個新 名字,但一票不開:同一個名字累積到 category_classifier.PROMOTE_AFTER 才真的開(跟 wiki topic 那一層同一個數字)。沒達門檻的卡片先歸到既有分類,身上留著 `proposed_category`;門檻到了 gather_promoted() 會把它們搬過去。報告會列出候診中的 提案與票數,所以「差幾票」看得見。 **壞掉的卡片不在這支的守備範圍。** 有幾筆的 frontmatter 寫著 ``、 標題是 ``——那是 LLM 的模板原樣漏進檔案,是內容壞了,不是分類錯了。 幫它分類只會讓一張壞卡片看起來正常。這支把它們單獨列出來,不動它們。 用法: python3 scripts/xkb_recategorize.py # 只報告 python3 scripts/xkb_recategorize.py --apply # 寫入並重建索引 python3 scripts/xkb_recategorize.py --limit 10 --apply """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import category_classifier import xkb_console import xkb_frontmatter import xkb_index import xkb_paths xkb_console.use_utf8() # LLM 的 prompt 模板漏進檔案留下的痕跡,以及抓取失敗留下的空殼。 # 這些是內容壞了,不是分類錯了——分類它們等於把壞卡片粉刷一遍。 BROKEN_MARKERS = ( re.compile(r""), re.compile(r""), # 其他沒列舉到的佔位符。要求裡面有空格——`` 這類模板都有,而 # `
` / `
` / `` 這些正常標記沒有。不要求空格的話,內容裡夾
    # 一個 HTML 標記的好卡片會被當成壞卡片跳過。
    re.compile(r"<[a-z]+ [a-z ]{2,30}>"),
    re.compile(r"擷取失敗|無法擷取內容"),
)


def load_items() -> list[dict]:
    try:
        raw = json.loads(xkb_paths.INDEX_FILE.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as err:
        print(f"讀不到索引 {xkb_paths.INDEX_FILE}:{err}", file=sys.stderr)
        return []
    items = raw if isinstance(raw, list) else raw.get("items", [])
    return [i for i in items if isinstance(i, dict)]


def off_taxonomy(items: list[dict]) -> list[dict]:
    """分類不在 taxonomy() 裡的。

    判準取自 category_classifier.taxonomy(),不是自己抄一份清單——它會把
    runtime 註冊的新分類算進來,而那是使用者的知識結構,不是這支的常數。
    """
    valid = set(category_classifier.taxonomy())
    return [i for i in items if (i.get("category") or "").strip() not in valid]


def broken_reason(text: str) -> str | None:
    """這筆是不是「內容壞了」而不是「分類錯了」。"""
    head = text[:1500]
    for pattern in BROKEN_MARKERS:
        m = pattern.search(head)
        if m:
            return m.group(0)
    return None


def decide(item: dict, text: str) -> tuple[str, str, str]:
    """這筆該歸到哪一類、理由,以及(如果有)它提議的新分類名。

    交給既有的 category_classifier——它是 LLM 判斷、帶關鍵字 fallback、而且認得
    同一份 taxonomy。這支不自己做判斷:多一份判斷就多一份會跟它不一致的東西。

    **新分類走候診,不是禁止。** 規則是「同一個名字被提議到門檻才開」
    (category_classifier.PROMOTE_AFTER,跟 wiki topic 那一層同一個數字),所以這裡
    不傳 allow_new=False。我一度那樣寫,那會把提案整個丟掉——跟「一票就開」是相反
    方向的同一種錯。沒達門檻時 classifier 回既有分類外加 proposed_category,由這支
    寫到卡片上,門檻到了才撈得回來。

    回傳的理由裡一定說明 LLM 有沒有真的跑。classify_content 在 LLM 失敗時會安靜地
    退回關鍵字,只把 `llm: False` 放在結果裡——不把它印出來的話,整批都是關鍵字猜的
    也會被當成「分類器判斷」。
    """
    current = (item.get("category") or "").strip()
    record_id = str(item.get("relative_path") or item.get("path") or "")
    result = category_classifier.classify_content(
        text, source_type=str(item.get("source_type") or ""),
        current_category=current, record_id=record_id)
    category = str(result.get("category") or "").strip()
    confidence = str(result.get("confidence") or "?")
    proposed = str(result.get("proposed_category") or "")
    how = "LLM" if result.get("llm") else "關鍵字 fallback"
    why = f"{how}·信心 {confidence}"
    if proposed:
        why += (f"·提議新分類 {proposed}"
                f"(第 {result.get('proposal_count')}/{category_classifier.PROMOTE_AFTER} 票)")
    if category not in set(category_classifier.taxonomy()):
        return "99-general", f"{why}·回了清單外的 {category!r},退回預設", proposed
    return category, why, proposed


def apply_to(files: list[Path], category: str, proposed: str = "") -> list[Path]:
    """把分類寫進這一組的每一個檔。

    要寫整組,不是只寫索引那一列指向的檔:索引會依 source_url 去重,同一份知識
    在磁碟上可能有卡片與書籤各一份。2026-09-10 品質排除只標了贏的那一列,重建
    之後去重挑了沒被標的那一份,結果「已標記」等於沒發生。
    """
    written = []
    for path in files:
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        if not xkb_frontmatter.has_frontmatter(text):
            continue
        updated = xkb_frontmatter.set_field(text, "category", category)
        if proposed:
            # 把提議的名字留在卡片上。門檻到了之後 gather_promoted() 靠它撈回來——
            # 少了這一步,提案達標也沒有任何卡片會跟著搬過去。
            updated = xkb_frontmatter.set_field(updated, "proposed_category", proposed)
        if updated != text:
            path.write_text(updated, encoding="utf-8")
            written.append(path)
    return written


def gather_promoted(items: list[dict]) -> list[tuple[str, list[Path]]]:
    """提案達標、分類已經開了之後,把在候診的卡片搬過去。

    這是整條規則的最後一段,也是最容易漏的一段:提案累積、門檻、開分類都做了,
    但沒有人回頭處理那些先被歸到既有分類、身上帶著 proposed_category 的卡片。
    漏掉它的話,門檻到了也只是多一個空分類。

    wiki topic 那邊是同一件事——導向 general 的條目保留 proposed_topic,就是為了
    日後撈得回來。
    """
    valid = set(category_classifier.taxonomy())
    out = []
    for item in items:
        files = xkb_index.files_for_item(item)
        if not files:
            continue
        try:
            text = files[0].read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        proposed = (xkb_frontmatter.get(text, "proposed_category") or "").strip()
        if proposed and proposed in valid and (item.get("category") or "") != proposed:
            out.append((proposed, files))
    return out


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--apply", action="store_true",
                        help="真的寫入並重建索引(預設只報告)")
    parser.add_argument("--limit", type=int, default=0, help="只處理前 N 筆")
    args = parser.parse_args()

    items = load_items()
    if not items:
        return 1
    targets = off_taxonomy(items)
    print(f"索引 {len(items)} 筆,分類在正式清單外的 {len(targets)} 筆")
    print(f"  正式清單:{', '.join(category_classifier.taxonomy())}")
    print()
    if not targets:
        print("  沒有要處理的。")
        return 0
    if args.limit:
        targets = targets[: args.limit]

    broken, planned, unreadable, unwritable = [], [], [], []
    for item in targets:
        files = xkb_index.files_for_item(item)
        if not files:
            unreadable.append(item)
            continue
        try:
            text = files[0].read_text(encoding="utf-8", errors="replace")
        except OSError:
            unreadable.append(item)
            continue
        reason = broken_reason(text)
        if reason:
            broken.append((item, reason))
            continue
        if not any(xkb_frontmatter.has_frontmatter(
                f.read_text(encoding="utf-8", errors="replace")) for f in files):
            # 一組裡沒有一個檔有 YAML frontmatter,就沒有地方寫分類。
            # 照樣排進計畫的話,報告會每次都說「要重新分類」然後什麼都沒發生——
            # 這個專案把「做不到」寫成「待辦」的次數已經夠多了。
            unwritable.append((item, files))
            continue
        category, why, proposed = decide(item, text)
        planned.append((item, files, category, why, proposed))

    if planned:
        print(f"  要重新分類 {len(planned)} 筆:")
        for item, files, category, why, _proposed in planned:
            print(f"    {(item.get('category') or '空'):>22} -> {category:<22}"
                  f" {(item.get('title') or '')[:32]}  ({len(files)} 個檔·{why})")
        print()

    waiting = category_classifier.proposals()
    if waiting:
        print(f"  候診中的新分類提案({category_classifier.PROMOTE_AFTER} 票才開):")
        for slug, entry in sorted(waiting.items(),
                                  key=lambda kv: -int(kv[1].get("count") or 0)):
            count = int(entry.get("count") or 0)
            state = "已達門檻" if count >= category_classifier.PROMOTE_AFTER else "等票"
            print(f"    {slug:<26} {count:>2}/{category_classifier.PROMOTE_AFTER}"
                  f"  {state}  {str(entry.get('reason') or '')[:40]}")
        print("    沒達門檻的卡片先歸到既有分類,身上留著提議的名字——門檻到了會被撈回來。")
        print()

    promoted = gather_promoted(items)
    if promoted:
        print(f"  提案已達門檻、要把 {len(promoted)} 筆候診的卡片搬過去:")
        for slug, files in promoted:
            print(f"    -> {slug:<26} {len(files)} 個檔")
        print()
    if broken:
        print(f"  ⚠ {len(broken)} 筆是內容壞了,不是分類錯了——沒有動它們:")
        for item, reason in broken:
            print(f"    {reason!r}  {(item.get('title') or '')[:40]}")
        print("    這些要修的是卡片本身(重新產生或排除),分類只是症狀。")
        print()
    if unwritable:
        print(f"  ⚠ {len(unwritable)} 筆的檔案沒有 YAML frontmatter,分類無處可寫:")
        for item, files in unwritable:
            print(f"    {(item.get('title') or '(無標題)')[:34]}"
                  f"  {str(files[0])[-44:]}")
        print("    索引因此套用預設值。要先讓這些檔有 frontmatter(重新產生卡片,")
        print("    或讓寫出它們的攝取腳本補上),才談得上分類。")
        print()
    if unreadable:
        print(f"  ⚠ {len(unreadable)} 筆在磁碟上找不到檔案,沒有動:")
        for item in unreadable[:10]:
            print(f"    {(item.get('relative_path') or item.get('path') or '?')}")
        print()

    if not args.apply:
        print("  這是預覽。加 --apply 才會寫入並重建索引。")
        return 0

    touched = 0
    for _item, files, category, _why, proposed in planned:
        touched += len(apply_to(files, category, proposed))
    for slug, files in promoted:
        # 搬過去之後清掉提議欄位——它的用途已經結束,留著會讓下一輪再搬一次。
        touched += len(apply_to(files, slug))
        for path in files:
            try:
                text = path.read_text(encoding="utf-8", errors="replace")
            except OSError:
                continue
            if xkb_frontmatter.get(text, "proposed_category"):
                path.write_text(
                    xkb_frontmatter.set_field(text, "proposed_category", ""),
                    encoding="utf-8")
    print(f"  已寫入 {touched} 個檔。重建索引……")
    # 索引是衍生物,只有這一個寫入者;改了 frontmatter 一定要整建,
    # 增量重建不會重讀沒動到 mtime 判斷的那些列。
    ok = xkb_index.rebuild(incremental=False)
    print("  索引重建完成。" if ok else "  索引重建失敗——分類已寫入,索引還是舊的。")
    return 0 if ok else 1


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

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