#!/usr/bin/env python3 """ 用向量做吸收閘門 — 不需要對話模型 閘門要回答的問題是「這張卡對這個主題頁**多出**了什麼」。原本靠對話 LLM 判斷, 但 LLM 是最容易掛的東西(2026-07-29 當天四個模型全部 503), 而 embedding 是另一條路、當時仍然正常。 判準兩道,量的是不同的東西: 主題契合度 = 與該頁段落的最高相似度。太低表示根本不是在講這個主題。 重複度 = 與**已經收錄進該頁的卡片**的最高相似度。太高表示已經有人講過。 這兩個不能共用一個數字。第一版拿「與頁面很像」當重複,實測是錯的: ai-video-workflows 這頁滿是 Seedance 內容,於是每張講 Seedance 的卡都跟它很像, 0.86 與 0.798 講的是同一類東西,差別只是深淺。 跟頁面像代表主題對,跟已收錄的卡片像才代表重複。 每個主題再設上限。1,202 張候選攤到 14 個主題是每頁 85 張, 那不是消化,是傾倒;visual-ai-private-arsenal 已經 680K 就是這樣來的。 Usage: python3 scripts/absorb_gate_semantic.py --review python3 scripts/absorb_gate_semantic.py --review --topic ai-video-workflows python3 scripts/absorb_gate_semantic.py --write-decisions # 寫入人工決策清單 """ from __future__ import annotations import argparse import json import os import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_paths import xkb_relevance import continuity_recall as cr import sync_cards_to_wiki as sync MIN_RELEVANCE = xkb_relevance.min_similarity() # 卡片之間的相似度。0.92 以上才算「同一件事被講兩次」—— # 同主題的兩張卡本來就會有 0.7~0.8,那不是重複。 MAX_REDUNDANCY = float(os.getenv("XKB_ABSORB_MAX_REDUNDANCY", "0.92")) PER_TOPIC_CAP = int(os.getenv("XKB_ABSORB_PER_TOPIC_CAP", "25")) def topic_section_vectors(vectors: dict) -> dict[str, list[list[float]]]: """把 wiki 段落向量依主題分組。""" grouped: dict[str, list[list[float]]] = {} prefix = "wiki/topics/" for key, vec in vectors.items(): if not key.startswith(prefix): continue stem = key[len(prefix):].split("#", 1)[0] grouped.setdefault(Path(stem).stem, []).append(vec) return grouped def is_absorbable(topic: str) -> bool: """redirect 頁與沒有 frontmatter 的頁面不是吸收目標。 gpt-image-2-private-taxonomy 與 visual-ai-workflow-node-catalog 都是 指向 visual-ai-private-arsenal 的轉址頁;往裡面寫等於寫進一個沒人會讀的殼, 而且 sync_cards_to_wiki 解析 frontmatter 時會直接拋錯中斷整輪。 """ path = xkb_paths.WIKI_TOPICS_DIR / f"{topic}.md" try: head = path.read_text(encoding="utf-8")[:200] except OSError: return False return head.startswith("---") and "Redirect:" not in head def main() -> int: parser = argparse.ArgumentParser(description="Semantic absorb gate") parser.add_argument("--review", action="store_true", help="只顯示結果") parser.add_argument("--write-decisions", action="store_true", help="把判斷寫進 review-decisions.json 的人工清單") parser.add_argument("--topic", help="只處理某個主題") parser.add_argument("--cap", type=int, default=PER_TOPIC_CAP, help="每個主題最多收幾張") args = parser.parse_args() vectors = cr._load_semantic_vectors() by_topic = topic_section_vectors(vectors) if not by_topic: print("找不到 wiki 段落向量——先跑 build_vector_index.py", file=sys.stderr) return 1 items = sync.load_search_items() url_to_path = {i.get("source_url"): (i.get("relative_path") or i.get("path") or "") for i in items if i.get("source_url")} existing_by_topic = sync.collect_topic_existing_urls() grouped = sync.iter_mapped_cards( items, sync.load_topic_map(), args.topic, existing_by_topic, sync.load_review_decisions(), ) allow: dict[str, list[str]] = {} skip: dict[str, list[str]] = {} summary: list[tuple[str, int, int, int, int]] = [] for topic, cards in sorted(grouped.items()): sections = by_topic.get(topic) if not sections: print(f" {topic}: 沒有段落向量,略過") continue if not is_absorbable(topic): print(f" {topic}: 是 redirect 或缺 frontmatter 的頁面,不吸收") continue # 已經收錄進這一頁的卡片——重複度要跟它們比,不是跟頁面文字比。 # 頁面記的是網址,向量索引的鍵是檔案路徑,中間要換一次。 absorbed_paths = [url_to_path[u] for u in existing_by_topic.get(topic, set()) if u in url_to_path] # 重複度只拿卡片級向量比。MAX_REDUNDANCY = 0.92 是照卡片級校準的 # (同主題的兩張卡本來就有 0.7~0.8),而論點是短句、彼此天生就近; # 兩邊都放寬成論點對論點,只要共用一個論點就會衝破 0.92,然後以 # 「重複」永久寫進跳過清單。 # 註:我上一版用巢狀迴圈「保持一張卡一組」,那在 max 底下等於沒做—— # 分組不可能在取最大值之後還存在。 absorbed = [vec for rows in cr.lookup_card_vectors( absorbed_paths, card_level_only=True, exact=True).values() for vec in rows] scored: list[tuple[float, sync.Card]] = [] unjudged: list[str] = [] off_topic = redundant = 0 for card in cards: # 相關度走原本的查法,別把兩件事合成一次查詢。_find_card_rows 的 # 優先順序是「這張卡自己的 #kpN」勝過「檔名相同的另一張卡」, # 先問卡片級再拿它當 rows 會把這個順序倒過來:跨目錄撞檔名時, # 會拿別張卡的向量來量這張卡,判離題之後永久寫進 skip。 # 少一次字典查詢不值得換這個。 # relative_path 才是向量索引的鍵。原本用 path(絕對路徑), # 一次都對不上,全靠檔名後備命中——我上一輪把那層關掉,於是 # 重複度檢查整個停擺而且沒有任何訊息。 key_ = card.relative_path or card.path or card.url card_rows = cr.lookup_card_vectors([key_]) # 取第一條會是靜默的錯誤:卡片級鍵不在時(重建索引或改名之後 # 很常見),rows 是這張卡的論點向量,第一條只是字典順序的第一 # 個。用它去比 0.55 的硬門檻,第三點正好命中的卡片會被判離題, # 而且以 skip 永久寫進人工決策清單。 rows = next((r for r in card_rows.values() if r), None) if rows is None: # 沒有向量就判斷不了。要明確擋掉—— # 不放進任何清單的話,--no-llm 會把它當成未表態而直接放行。 skip.setdefault(topic, []).append(card.url) continue relevance = max(cr._cosine(v, s) for v in rows for s in sections) if relevance < MIN_RELEVANCE: off_topic += 1 skip.setdefault(topic, []).append(card.url) continue # 重複度才需要卡片級向量:0.92 是照卡片級校準的,沒有的話寧可 # 不判,也不要用不同粒度去比一個沒為它校準過的門檻。 own = next(iter(cr.lookup_card_vectors( [key_], card_level_only=True, exact=True).values()), None) if absorbed and not own: # 查不到卡片級向量就判不了重複。這件事必須說出來: # 不說的話,表格照樣印「重複 0」,跟閘門整個停擺長得一模一樣。 # 我上一輪在 continuity_recall 的註解裡寫「見呼叫端」, # 然後忘了讓呼叫端真的講話。 unjudged.append(card.url) if absorbed and own and max( cr._cosine(own[0], a) for a in absorbed) > MAX_REDUNDANCY: redundant += 1 skip.setdefault(topic, []).append(card.url) continue scored.append((relevance, card)) # 同一區間內優先收最相關的 scored.sort(key=lambda kv: kv[0], reverse=True) chosen = scored[: args.cap] # 超過上限的不寫進 skip。skip 是持久的人工決策清單, # check_manual_decision 把它當成「這張卡不要收」的永久判定——而 cap # 的意思只是「這一輪先收這麼多」。把批次大小記成對卡片的判決, # 等於第 21 張從此再也不會被考慮。 overflow = len(scored) - len(chosen) if overflow: print(f" {topic}:超過上限 {args.cap},{overflow} 張留待下次(未列入 skip)") allow[topic] = [c.url for _, c in chosen] if unjudged: # 這一行不能省。判不出重複跟「沒有重複」在表格上長得一模一樣, # 而這個專案最貴的失敗模式就是「壞掉的東西看起來很正常」。 print(f" {topic}:{len(unjudged)} 張查不到卡片級向量,重複度沒有判(可能要重建索引)") summary.append((topic, len(cards), off_topic, redundant, len(chosen))) print(f"{'主題':34}{'候選':>6}{'離題':>6}{'重複':>6}{'收錄':>6}") for topic, total, off, red, kept in summary: print(f"{topic:34}{total:>6}{off:>6}{red:>6}{kept:>6}") print(f"{'合計':34}{sum(s[1] for s in summary):>6}{sum(s[2] for s in summary):>6}" f"{sum(s[3] for s in summary):>6}{sum(s[4] for s in summary):>6}") # --write-decisions 原本宣告了卻沒有人讀,寫入只看 --review, # 於是「跑一下看看」就覆寫了決策檔。宣告了的旗標要真的管事。 if args.review or not args.write_decisions: return 0 path = sync.REVIEW_DECISIONS_PATH data = sync.load_review_file() topics = data.setdefault("topics", {}) for topic in set(allow) | set(skip): entry = topics.setdefault(topic, {}) entry["allow"] = sorted(set(entry.get("allow", [])) | set(allow.get(topic, []))) entry["skip"] = sorted((set(entry.get("skip", [])) | set(skip.get(topic, []))) - set(entry["allow"])) path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") print(f"\n已寫入 {path}。接著跑:python3 scripts/sync_cards_to_wiki.py --apply --no-llm") return 0 import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) raise SystemExit(main())