scripts/xkb_synthesize_topic.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""把主題頁裡累積的碎片寫成結論——真正的「消化」步驟。
為什麼需要這支:
吸收閘門做的是「附加」,不是「消化」。它把卡片摘要與連結一條條接在
頁尾,所以經過它的頁面有 48~75% 的條列只是連結:
ai-video-workflows 75% 純連結
video-prompt-patterns 68%
openclaw-agent-workflows 49%
而真正好讀的頁面(gpt-image2-seedance-workflow、learning-base)
恰恰是它沒碰過、由人寫出來的那幾個。
再吸收更多卡片,只會得到更多連結,不會得到更多知識。
這支做的是另一件事:把已經累積在頁面裡的條列,交給模型寫成「結論」,
並保留出處。輸出到審閱檔,**不直接覆寫**主題頁。
消化不出結論的條列會原封不動留在頁面上,不會被結論取代掉——模型給不出
結論不代表那幾條筆記沒有價值,下一次消化會再試一次。
--apply 併回去的,就是你剛剛看過的那一份審閱稿。它不會重跑模型——重跑會
產出不一樣的結論,那樣審閱的稿子跟寫進 wiki 的稿子就不是同一份,審閱也就
沒有意義。頁面在你審閱之後又變動了的話,它會停下來說明,而不是默默重產。
用法:
python3 scripts/xkb_synthesize_topic.py --list
python3 scripts/xkb_synthesize_topic.py --topic ai-video-workflows
python3 scripts/xkb_synthesize_topic.py --topic ai-video-workflows --apply
"""
from __future__ import annotations
import argparse
import hashlib
import pathlib
import re
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_paths
REVIEW_DIR = xkb_paths.WIKI_DIR / "_synthesis"
LINK_ONLY = re.compile(r"^\s*-\s*\[[^\]]+\]\(https?://")
BULLET = re.compile(r"^\s*-\s+\S")
# 一次餵給模型的條列數。太多會讓它只能寫出泛泛的摘要,
# 反而失去「結論」該有的具體性。
CHUNK = 40
# 每批最多產出幾條結論。
#
# 原本這裡還有一個 MIN_COMPRESSION 門檻,低於它就拒絕併回。那個指標拿掉了:
# 它會因為丟掉內容而變好看,所以它獎勵的正是要防的行為。現在筆記全部保留,
# 不需要用比例去猜有沒有真的消化。
PER_CHUNK = 8
def split_page(text: str) -> tuple[str, list[str], list[str]]:
"""拆成 (前言/人寫的部分, 敘述型條列, 純連結)。
純連結另外收著。它們是出處,不是內容——把它們丟給模型當素材,
只會得到「有很多相關連結」這種沒有資訊的句子。
"""
lines = text.splitlines()
prose: list[str] = []
bullets: list[str] = []
links: list[str] = []
for line in lines:
if LINK_ONLY.match(line):
links.append(line.strip())
elif BULLET.match(line) and len(line.strip()) > 60:
bullets.append(line.strip())
else:
prose.append(line)
return "\n".join(prose), bullets, links
CONCLUSIONS_HEADING = "## 結論(消化自累積筆記)"
UNDIGESTED_HEADING = "## 尚未消化"
# 消化過的原始筆記搬到這裡,不刪除。
#
# 2026-09-11 改的:原本 --apply 是用結論「取代」筆記,所以消化是不可逆的,而
# 壓縮比會因為丟掉內容而變好看(實測有一次 16.2x,其實是整批不見了)。一個會
# 因為弄壞事情而變好看的指標,不該留在會被自動執行的路徑上。
#
# 現在筆記全部留著。代價是頁面變長,而那個代價很小:語意召回撈的是段落,不是
# 整頁。換來的是「消化丟掉東西」在結構上不可能發生——不必用任何數字去追它,
# 也讓這件事終於可以安全地排程。
DIGESTED_HEADING = "## 已消化的筆記(原文保留)"
SOURCES_HEADING = "## 出處"
GENERATED_HEADINGS = (CONCLUSIONS_HEADING, UNDIGESTED_HEADING,
DIGESTED_HEADING, SOURCES_HEADING)
def _owned_sections(text: str) -> tuple[str, dict[str, list[str]]]:
"""Only synthesis-specific headings establish ownership; preserve foreign text."""
human: list[str] = []
blocks: dict[str, list[str]] = {}
current = None
owned = False
for line in text.splitlines():
heading = line.strip()
if heading in GENERATED_HEADINGS and (heading != SOURCES_HEADING or owned):
owned = True
current = heading
blocks.setdefault(current, []).append("")
elif line.startswith("#") and not line.startswith("####"):
current = None
human.append(line)
elif current is None:
human.append(line)
else:
blocks[current].append(line)
return "\n".join(human), blocks
def _note_blocks(lines: list[str]) -> list[str]:
"""Keep complete notes and their continuation lines, including non-bullet prose."""
blocks: list[list[str]] = []
for line in lines:
if line.startswith("- ") or not blocks:
blocks.append([])
blocks[-1].append(line)
return ["\n".join(block).strip("\n") for block in blocks if "\n".join(block).strip()]
def split_generated(text: str) -> tuple[str, str, list[str], list[str]]:
human, blocks = _owned_sections(text)
links, extra = [], []
for line in blocks.get(SOURCES_HEADING, []):
if LINK_ONLY.match(line):
links.append(line.strip())
else:
extra.append(line)
if any(line.strip() for line in extra):
human += "\n\n" + SOURCES_HEADING + "\n" + "\n".join(extra)
return (human, "\n".join(blocks.get(CONCLUSIONS_HEADING, [])).strip("\n"),
_note_blocks(blocks.get(UNDIGESTED_HEADING, [])), links)
def prior_digested(text: str) -> list[str]:
"""Read archived notes with the same section ownership rules as ingestion."""
_, blocks = _owned_sections(text)
return _note_blocks(blocks.get(DIGESTED_HEADING, []))
def undigested(text: str) -> tuple[str, list[str], list[str], str]:
"""(前言, 待消化的條列, 出處, 既有結論) —— 給每個想知道「還剩多少」的人。
「還沒消化的有幾條」在三個地方各自被算過一次,其中兩種算法會把結論也
算進去。這是唯一的定義。
"""
human, conclusions, waiting, prior_links = split_generated(text)
prose, bullets, links = split_page(human)
for line in prior_links:
if line not in links:
links.append(line)
# 已消化的筆記不在這裡面——它們留在頁面上是為了可追溯,不是為了再消化一次。
# 再交出去的話,每次 --apply 都會把同一批筆記重新消化,而結論會一層層疊加。
return prose, bullets + waiting, links, conclusions
def take_bullets(markdown: str, limit: int) -> list[str]:
"""只留前 limit 條。
「最多 8 條」寫在提示詞裡只是請求,模型可以無視——實測 117 條進去、
86 條出來,等於沒有消化。上限必須由程式執行,不能靠模型自律。
"""
kept: list[str] = []
for line in markdown.splitlines():
if line.strip().startswith(("- ", "* ")):
if len(kept) >= limit:
break
kept.append(line.rstrip())
elif kept and line.strip():
kept[-1] += " " + line.strip() # 條列的續行
return kept
# 條列結尾的出處標記,例如 *(self-derived · memory/2026-09-10.md)* 或一個網址。
_PROVENANCE_RE = re.compile(r"\*\(([^)]{4,200})\)\*\s*$")
_URL_RE = re.compile(r"https?://\S+")
def provenance_of(bullet: str) -> str:
"""一條筆記的出處字串;沒有就回空字串。
用來對帳「這條筆記有沒有被某條結論代表」。比對的是出處而不是內容,因為
結論本來就會改寫措辭——改寫之後比字串只會得到「全都沒消化」。
"""
match = _PROVENANCE_RE.search((bullet or "").rstrip())
if match:
return match.group(1).strip()
url = _URL_RE.search(bullet or "")
return url.group(0).rstrip(").,") if url else ""
def _is_covered(bullet: str, conclusions: str) -> bool:
source = provenance_of(bullet)
if not source:
# 本來就沒有出處的筆記無法對帳。保守起見當成沒消化——留著比丟掉安全,
# 而且它會在頁面上顯示出來,讓「這條沒有出處」這件事被看見。
return False
return source in conclusions
def synthesise(topic: str, prose: str, bullets: list[str],
per_chunk: int) -> tuple[str, list[str]]:
"""回傳 (結論, 沒能消化的原始條列)。
第二個值是「這一輪沒能消化的」:整批沒產出,或出處沒有被任何結論引用到。
它們留在「尚未消化」區,下一次再試。
--apply 不會刪掉任何筆記(見 DIGESTED_HEADING),所以這個值的意義是
「下次要不要再試」,不再是「會不會消失」。
"""
from _llm import call as llm_call
system = (
"You turn accumulated research notes into a short, decisive knowledge page. "
"Write in Traditional Chinese (Taiwan). No emoji."
)
out: list[str] = []
lost: list[str] = []
for start in range(0, len(bullets), CHUNK):
batch = bullets[start:start + CHUNK]
user = (
f"主題:{topic}\n\n"
f"這一頁目前已經寫好的部分(作為脈絡,不要重複它):\n{prose[:1500]}\n\n"
f"以下是累積下來、尚未消化的筆記共 {len(batch)} 條:\n"
+ "\n".join(batch)
+ "\n\n"
"請把它們寫成「結論」而不是摘要。要求:\n"
"1. 合併重複的說法,只留下站得住腳的那一版\n"
"2. 有衝突就明講衝突,不要假裝一致\n"
"3. 每一條結論要具體到可以照著做,不要寫『可以提升效率』這種空話\n"
f"4. 用 Markdown 條列,**最多 {per_chunk} 條**。超過的部分會被直接截掉,\n"
" 所以請自己挑最重要的,不要把每條筆記都改寫一遍\n"
"5. 沒有把握的地方標註『(待查證)』,不要編\n"
"6. **每一條結論的結尾,要把它合併到的那幾條筆記的出處標記原樣抄上**\n"
" (每條筆記結尾那個 *(…)* 的部分,多條就並排寫)。\n"
" 出處沒有被抄到的筆記會被當成沒有消化,原樣留在頁面上。\n"
"只輸出條列本身,不要開場白。"
)
# 一次重試。空回應多半是暫時的;連兩次都空,才算這一批真的消化不出來。
kept: list[str] = []
for attempt in range(2):
raw = llm_call(system, user)
cleaned = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
kept = take_bullets(cleaned, per_chunk) if cleaned else []
if kept:
break
if attempt == 0:
print(f" 第 {start // CHUNK + 1} 批沒有產出結論,重試一次", flush=True)
if kept:
out.append("\n".join(kept))
# 沒有被任何一條結論引用到出處的筆記,算沒有消化,原樣留著。
#
# 提示詞本身就在要求丟東西(「最多 N 條,不要把每條筆記都改寫一遍」),
# 而 lost 原本只接住「整批都沒產出」。30 條進去、8 條出來的時候,另外
# 22 條是無聲消失的——壓縮比好看正是因為內容被丟掉。
#
# 現在改成由出處對帳。模型不配合抄出處的話,結果是「什麼都沒消化」
# 而不是「安靜丟掉一批」——失敗的方向是安全的那一邊。
covered = "\n".join(kept)
lost.extend(b for b in batch if not _is_covered(b, covered))
else:
lost.extend(batch)
print(f" 已消化 {min(start + CHUNK, len(bullets))}/{len(bullets)} 條", flush=True)
return "\n\n".join(out), lost
DIGEST_LINE = re.compile(r"^<!-- source-digest: ([0-9a-f]{16}) -->$", re.M)
LOST_LINE = re.compile(r"^<!-- lost-bullets: (\d+) -->$", re.M)
def digest(bullets: list[str]) -> str:
"""指紋,用來確認審閱稿跟現在的頁面是同一批條列。"""
joined = "\n".join(bullets).encode("utf-8")
return hashlib.sha256(joined).hexdigest()[:16]
def read_draft(review: pathlib.Path, bullets: list[str], source_text: str | None = None) -> str | None:
"""讀回審閱稿的結論;指紋對不上就回 None。
對不上代表頁面在你審閱之後又長了新東西,那份稿子已經不是這一頁的
消化結果了。
"""
if not review.exists():
return None
text = review.read_text(encoding="utf-8", errors="replace")
if source_text is not None:
page_digest = re.search(r"<!-- source-page-digest: ([0-9a-f]+) -->", text)
if not page_digest or page_digest.group(1) != digest([source_text]):
return None
found = DIGEST_LINE.search(text)
if not found or found.group(1) != digest(bullets):
return None
body = text.split("\n## 結論\n", 1)
if len(body) != 2:
return None
# 到下一個標題為止。原本寫死找「## 出處」,於是後來插進來的
# 「## 尚未消化」整段都被當成結論讀了回來。
return body[1].split("\n## ", 1)[0].strip("\n")
def draft_lost(review: pathlib.Path) -> list[str]:
"""審閱稿裡那些消化不出結論的原始條列。
存的是條列本身而不是數量,因為它們要原封不動跟著併回頁面——沒有消化
出結論不代表可以丟掉。
"""
if not review.exists():
return []
text = review.read_text(encoding="utf-8", errors="replace")
if UNDIGESTED_HEADING + "\n" not in text:
return []
section = text.split(UNDIGESTED_HEADING + "\n", 1)[1].split("\n## ", 1)[0]
return [block for block in _note_blocks(section.splitlines()) if block.startswith("- ")]
def render(topic: str, synthesis: str, links: list[str], bullets_text: list[str],
lost: list[str] | None = None, source_text: str | None = None) -> str:
stamp = datetime.now(timezone.utc).isoformat()
return "\n".join([
f"# {topic} — 消化後",
"",
f"> 由 {len(bullets_text)} 條累積筆記消化而成,{stamp}。",
"> 這是審閱稿:確認無誤後再用 --apply 併回主題頁。",
f"<!-- source-digest: {digest(bullets_text)} -->",
*([f"<!-- source-page-digest: {digest([source_text])} -->"] if source_text is not None else []),
f"<!-- lost-bullets: {len(lost or [])} -->",
"",
"## 結論",
"",
synthesis,
"",
*((
UNDIGESTED_HEADING,
"",
"> 這幾條模型給不出結論(已重試一次)。它們會原封不動留在主題頁上,",
"> 下次消化會再試一次——沒有消化出結論,不代表可以丟掉。",
"",
*lost,
"",
) if lost else ()),
"## 出處",
"",
*(links or ["(無)"]),
"",
])
def cmd_list() -> int:
print(f"{'主題':34}{'敘述條列':>8}{'純連結':>8}{'值得消化':>10}")
for path in sorted(xkb_paths.WIKI_TOPICS_DIR.glob("*.md")):
_, bullets, links, _ = undigested(path.read_text(encoding="utf-8", errors="replace"))
worth = "是" if len(bullets) >= 10 else ""
print(f"{path.stem[:32]:34}{len(bullets):>8}{len(links):>8}{worth:>10}")
return 0
def cmd_topic(topic: str, apply: bool, regenerate: bool = False) -> int:
path = xkb_paths.WIKI_TOPICS_DIR / f"{topic}.md"
if not path.exists():
print(f"找不到主題頁:{path}", file=sys.stderr)
return 1
text = path.read_text(encoding="utf-8", errors="replace")
prose, bullets, links, existing = undigested(text)
per_chunk = PER_CHUNK
if not bullets:
print("這一頁沒有累積的條列可以消化。")
return 0
print(f" {topic}:{len(bullets)} 條敘述、{len(links)} 條連結")
review = REVIEW_DIR / f"{topic}-synthesis.md"
synthesis = None if regenerate else read_draft(review, bullets, text)
lost: list[str] = draft_lost(review) if synthesis else []
if synthesis:
print(f" 沿用既有審閱稿:{review.name}")
else:
if apply and not review.exists() and not regenerate:
# 沒有審閱稿就 --apply,等於產生完直接寫進 wiki——而兩段式流程
# 存在的理由就是中間要有人看過。這比舊稿對不上更值得擋。
print(" 這一頁還沒有審閱稿。先不加 --apply 跑一次,看過再併回;", file=sys.stderr)
print(" 確定不需要審閱的話,加 --regenerate 明確表示。", file=sys.stderr)
return 5
if apply and review.exists() and not regenerate:
print(" 審閱稿是針對舊版頁面產生的,頁面之後又有變動。", file=sys.stderr)
print(" 請先重跑一次消化、看過新稿,再 --apply;"
"或加 --regenerate 直接重產。", file=sys.stderr)
return 3
synthesis, lost = synthesise(topic, prose, bullets, per_chunk)
if not synthesis:
print("模型沒有產出內容,未寫入任何檔案。", file=sys.stderr)
return 2
# 壓縮比這個指標拿掉了。
#
# 它是「有沒有真的消化」的直接指標,但它同時是丟掉內容的誘因:把 30 條筆記
# 換成 8 條結論、另外 22 條不見,壓縮比會很好看。實測 16.2x 那次就是整批不
# 見了。一個會因為弄壞事情而變好看的指標,不該留在會被自動執行的路徑上。
#
# 取而代之的保證是結構性的:--apply 不再取代筆記。結論是附加上去的,原始
# 條列搬到「已消化的筆記」區留著。所以「消化丟掉東西」在結構上不可能發生,
# 不需要用一個數字去追它。
produced = len([b for b in synthesis.splitlines() if b.strip().startswith("- ")])
print(f" 產出 {produced} 條結論({len(bullets)} 條筆記全部保留)")
if lost:
print(f" 其中 {len(lost)} 條沒有產出結論(已重試一次),"
f"留在「尚未消化」區,下次再試。")
REVIEW_DIR.mkdir(parents=True, exist_ok=True)
review.write_text(render(topic, synthesis, links, bullets, lost, text),
encoding="utf-8")
print(f" 審閱稿:{review}")
if not apply:
print(" 確認後再加 --apply 併回主題頁。")
return 0
# 備份還是留著。現在消化不會刪掉筆記,但它仍然會重排整頁,而「重排一頁
# 我手寫的東西」值得有一份退路。
backup = path.with_suffix(f".md.before-synthesis-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}")
if path.read_text(encoding="utf-8") != text:
print("Topic changed during synthesis; draft retained, topic left untouched.", file=sys.stderr)
return 3
shutil.copy2(path, backup)
# 這次併回去的是「結論 + 原始筆記」,不是「結論取代原始筆記」。
#
# 原本是取代,所以任何沒被寫進結論的筆記就此消失——而提示詞本身就在要求
# 模型挑重點不要全改寫。結果是消化會安靜丟掉內容,而壓縮比會因此好看。
#
# 筆記搬到 DIGESTED_HEADING 之下:頁面上看得到、召回查得到、下一次不會被
# 當成素材再消化一次。唯一的代價是頁面變長。
digested = [b for b in bullets if b not in lost]
for line in prior_digested(text):
if line not in digested:
digested.append(line)
conclusions = f"{existing}\n\n{synthesis}" if existing else synthesis
merged = prose.rstrip() + "\n\n" + CONCLUSIONS_HEADING + "\n\n" + conclusions
if lost:
merged += ("\n\n" + UNDIGESTED_HEADING + "\n\n"
+ "\n".join(lost))
if digested:
merged += ("\n\n" + DIGESTED_HEADING + "\n\n"
+ "\n".join(digested))
merged += "\n\n" + SOURCES_HEADING + "\n\n" + "\n".join(links) + "\n"
path.write_text(merged, encoding="utf-8")
kept = len(digested)
print(f" 已併回 {path.name}:{produced} 條結論,"
f"{kept} 條筆記原文保留(備份:{backup.name})")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--list", action="store_true", help="列出哪些主題頁累積了未消化的條列")
mode.add_argument("--topic", help="消化這個主題頁")
parser.add_argument("--apply", action="store_true", help="把結論併回主題頁(預設只產生審閱稿)")
parser.add_argument("--regenerate", action="store_true",
help="就算已有審閱稿也重新消化一次")
args = parser.parse_args(argv)
if args.list:
return cmd_list()
return cmd_topic(args.topic, args.apply, args.regenerate)
import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py
if __name__ == "__main__":
xkb_usage.record(__file__)
raise SystemExit(main())