scripts/absorb_gate_semantic.py
Rama main · réplica de lectura del repositorio original.
#!/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())