影片裡的知識是線性的、口語的、散落的。要把它變成能查、能比對、能追溯原話與畫面的規則庫,難的不是呼叫 LLM——是如何不相信 LLM,以及如何讓一個項目失敗時不連坐整批。
本頁只講影片線。全專案併入教材、課程、PPA 與外部筆記後,目前共 1,024 條規則,見 知識來源與產出物盤點。
五個階段、一份狀態正本、一條平行支線
整條管線的骨架只有一句話:每一集影片獨立地穿過五個階段,狀態全部記在同一份 manifest.json 裡。階段之間不傳遞記憶體物件、不共用進程——中斷後重跑,未完成的項目自己接續。
最容易誤解的一點在圖的下半部:擷圖不在狀態鏈上。它只寫 frames_dir 欄位,刻意與轉錄同時進行,因為兩者一個吃網路頻寬、一個吃 GPU,串起來跑會白白浪費一半的時間。
每個階段都只跟 manifest 對話,彼此不直接耦合——這是「中斷後能續跑」的來源,也是雙 worker 能同時跑的前提
| 產出 | 數量 | 說明 |
|---|---|---|
| 已萃取集數 | 57 / 57 | 其中 17 集是 YouTube 頻道會員限定,2026-09-25 以會員 cookie 補齊(沒有 cookie 時標 members_only) |
| 規則卡 | 769 張 | 逐集萃取,每張含條件、動作、理由、原話、時間戳、截圖 |
| 唯一規則 | 637 條 | 同一 rule_id 跨集合併後的結果(只算影片線;併入教材、課程、外部筆記後的主手冊是 764 條) |
| 跨集重複 | 84 條 | 最高一條講過 6 集——這就是重要度訊號 |
| 幀圖 | 2,628 張 | 每 30 秒一張,供規則卡回溯當下畫面 |
| quote 驗證通過 | 97% | 引文與逐字稿相似度 ≥ 0.8 才算已驗證(2026-09-25 改良比對方法後由 60% 提升) |
雙 worker 平行,鎖只包住毫秒級操作
轉錄有兩條引擎:雲端快但有限速、地端慢但免費且無限。兩者同時跑、共用同一個佇列,誰先空下來誰認領下一集。這使得限速不再是阻塞——雲端撞限速時該集退回佇列,地端自然接手。
retry_count——不是這集的錯tf_3.0 環境的 python五個階段的職責與產出:
下載音訊 → Whisper → 帶 ## MM:SS 時間戳標題的 Markdown。字數少於「分鐘數 × 100」視為空殼,直接判失敗。
下載影片 → 每 30 秒擷一張(取區間中點 15 秒處,避開轉場)。與轉錄平行,不等它。
把幀圖依時間戳插進逐字稿對應位置。冪等——重跑不會重複插入。
整份圖文逐字稿餵給 Gemini,回傳規則卡陣列。四道護欄過濾 + 引文模糊比對驗證。
依 rule_id 跨集合併、累計提及次數、依重要度排序,輸出五篇 Markdown。不呼叫 LLM,同樣輸入必得同樣輸出。
雙 worker 同時寫一份 JSON,最直覺的寫法是「整個處理流程包在鎖裡」。那會壞得很隱蔽:鎖有 60 秒過期機制,而轉錄要跑好幾分鐘——鎖會在轉錄途中被另一個 worker 判定過期而搶走,變成兩邊都以為自己持鎖。
# 轉錄呼叫留在鎖外執行(數分鐘等級),讓雙 worker 真正平行
outcome = run_transcription(vid, args.worker, _real_transcribe)
# 回寫走加鎖入口:鎖內重新 load 最新版 → 套用結果 → save,
# 避免整份覆寫舊快照蓋掉另一個 worker 同時完成的別集成果
try:
final_data = mf.update_episode_and_save(
MANIFEST, vid,
lambda d, v: apply_outcome(d, v, args.worker, outcome))
except mf.LockTimeoutError:
# 轉錄已經跑完(錢已經花了/GPU 已經算完)——結果絕不能憑空遺失
_save_pending_outcome(vid, args.worker, outcome)
run_transcription 在鎖外(慢、可能失敗),apply_outcome 是純函式在鎖內(快、只動記憶體)。另外注意 except LockTimeoutError 那段:錢都花完了才拿不到鎖,結果先落地成備援檔,寧可留下一個要人工處理的檔案,也不讓花掉的成本憑空蒸發。這支函式簽名上有個刻意的缺席:它不接受呼叫端手上的 data,只收一個 mutate_fn。這不是設計潔癖,是修過的 race。
def update_episode_and_save(path, vid, mutate_fn, lock_timeout=30.0,
stale_seconds=60.0):
lock_path = path.with_name(path.name + ".lock")
with file_lock(lock_path, timeout=lock_timeout, stale_seconds=stale_seconds):
data = load(path) # ← 鎖內重新讀,不用呼叫端的舊快照
mutate_fn(data, vid) # ← 只動這一集,純記憶體操作
save(path, data)
return data
mf.save(MANIFEST, ...) 這種整份覆寫寫法。哪些事實不能問 LLM,以及單點失敗如何被擋在原地
每張規則卡都附「出處」:原話、時間戳、畫面截圖。這些欄位只有原話是 LLM 給的,其餘全部由我們自己算——因為 LLM 對時間的自報從根本上不可靠,它沒有能力知道某句話出現在影片第幾分鐘。
實際踩過的事故:LLM 宣稱某句話在 29:30,但它在逐字稿中實際落在 ## 30:00 標題底下,影片裡真正的時點是 30:13——卡片因此指向一張不存在的截圖。
唯一向 LLM 索取的是「哪句原話」;時間與畫面都由我們從既有事實推導,因此可被任何人重新驗證
逐字稿在轉錄階段就寫入了 ## MM:SS 標題,那是機器產生的既有事實。既然如此,時間戳就沒有理由去問 LLM。
def derive_timestamp_from_quote(transcript, position):
# position 為 None(quote 完全定位不到)→ 回傳 None,
# 呼叫端不得用 LLM 的猜測值頂替,讓該卡被護欄丟棄
if position is None:
return None
seconds = None
for offset, total_seconds in _iter_heading_seconds(transcript):
if offset <= position:
seconds = total_seconds # 持續往後推進到最接近的標題
else:
break
if seconds is None:
return None
return f"{seconds // 60:02d}:{seconds % 60:02d}"
return None——它們代表「寧可沒有時間戳,也不要一個看起來合理但錯誤的時間戳」。缺 timestamp 的卡會被護欄丟棄,這是刻意的:一張指向錯誤畫面的卡片,比一張不存在的卡片更有害,因為使用者會相信它。這是本專案一天內在四個不同階段各犯一次的錯誤模式:某個共通原因讓每一項都失敗,而逐項容錯的迴圈忠實地把每一項都記成失敗、燒光重試額度。下面這段的設計重點不在「擋下」,而在擋下的方式。
class DailyQuotaExceeded(Exception):
"""刻意不繼承 RuntimeError——呼叫端的逐集容錯通常寫
except Exception/except RuntimeError,額度用盡必須能被
單獨攔在前面,乾淨結束整批。"""
# ── extract_rules.main() 的迴圈裡 ──
except llm_config.DailyQuotaExceeded as e:
# 額度用盡不是這一集的錯——絕不可落到下面的逐集容錯
print(f"[規則卡萃取] {e}")
break # ← 本集維持原狀,明日原地續跑
except Exception as e: # noqa: BLE001 — 逐集容錯
_save_failed(vid, f"規則卡萃取失敗:{e}") # 燒 retry_count
except 的順序就是防線本身,位置寫反等於防線不存在。反向驗證確認過:把上面那條分流拿掉,測試中 5 集全部被標成失敗。同型防線還有三道——環境故障連續失敗即停機、不為永遠拿不到的集數下載 200MB 影片、尾段餘數不足 3 秒不補幀(曾因最後一秒取不到畫面,害同集已成功的 35 張全部作廢)。存在 ≠ 完整是貫穿全專案的判準:問「處理過了嗎」一律看內容,不看旗標或檔案存在,讓空的項目下次自動重來。
| 檢查點 | 判準 | 擋下過什麼 |
|---|---|---|
| 逐字稿 | 字數 ≥ 分鐘數 × 100 | 40 分鐘影片只轉出 129 字的空殼 |
| 幀圖 | 逐槽驗存在且非 0 byte | 殘檔被當成已完成 |
| 萃取 | 有產出但全被丟棄 → 退回重跑 | 0 張卡被標成「已完成」而永久消失 |
| 手冊 | 規則數縮水 >10% 即擋下 | 資料源出錯時靜默產出縮水手冊 |
萃取那條特別微妙:「LLM 回空陣列」是合法的(整集都是行情點評,本來就沒有規則可萃),但「LLM 有產出卻全被護欄丟棄」代表這次輸出劣化,必須重跑。實測中後者發生過一次,重跑後同一集 39 張卡全數通過。
D:/Anaconda_202412/envs/tf_3.0/python.exe(只有它有 GPU whisper),其餘一律用專案 .venv。地端 worker 還要把 tf_3.0/Scripts 加進 PATH——yt-dlp 在那裡,只用絕對路徑呼叫 python 會找不到,症狀是每一集都以「找不到 yt-dlp」失敗。.tools/ffmpeg.exe:那是 imageio 的靜態 binary。conda 環境自帶的 ffmpeg 跨環境呼叫會 DLL 崩潰——判別特徵是 returncode 0xC0000139/0xC0000135 且 stderr 全空。看到這個組合直接換靜態 binary,不要再調 PATH 試錯。Start-Process。Claude Code 的 Bash 背景任務有 10 分鐘上限,地端轉錄一集就要 5 分鐘,會被半途砍掉。GROQ_API_KEY(轉錄)與 GOOGLE_API_KEY(萃取)都是環境變數優先 → 專案根 .env → 缺就 fail fast。搬遷初期 Gemini 那把沒有 .env 後備,寫進 .env 會被靜默忽略,已修。members_only/failed)。gemini-2.5-flash 產的,2026-08-02 起改用 gemini-3.1-flash-lite,2026-09-30 起改用 gemini-3.8-flash 並要求逐條列出、名詞解釋做成知識卡。日後重跑既有集數做比較時,要意識到這條分界——品質特性不同,數字不能直接對照。wc:CJK 字元與欄位內嵌換行都會讓 wc 給出假數字。核對資料完整性時同時比檔數與總位元組——只比檔數對「檔案還在但內容被清空」完全盲目。