轉錄完成的逐字稿,是怎麼經過 term_fix.TERM_TABLE 與 GOOGLE_API_KEY,最後變成一張張可回溯的規則卡?這兩者其實是前後兩站、互不相干。
影片線為例;課程線同一套,教材線沒有逐字稿所以不走校正
關鍵只有一句:校正只在記憶體裡做,餵給 LLM 與拿來定位引文的是同一份校正後文字,硬碟上的原始逐字稿永遠不動。金鑰只在第三站用到,其餘五站都是本機確定性運算。
綠色虛線是整條線的靈魂:餵入與定位必須是同一份校正稿,否則含「權證」的引文會對不上原稿裡的「權陣」而被當成幻覺丟掉。
whisper 把「買盤竭盡」聽成「捷徑」127 次,語意完全相反
2026-08-28 抽查 14 份課程逐字稿,「買賣盤竭盡」這個核心轉折概念被整批轉成「捷徑」127 次、正確拼寫 0 次。重轉沒用:whisper 的 initial_prompt 只影響開頭 30 秒,改 prompt 重轉一支 46 分鐘影片,輸出與舊版逐位元組相同。所以校正放在消費端,原始檔不動。
2026-10-01 補充:確定性解碼(temperature=0)一旦陷入幻覺迴圈,連換切點重轉也逃不掉。處置神器第 3 支的開頭 20 分鐘,整片、20 分鐘分段、5 分鐘小塊三次都只剩 85 字;改以溫度回退(壓縮比過高就以較高溫度重解)後,同一段轉出 985 字。現在課程線的分段轉錄遇到單段字數低於 25 字/分,會自動以溫度回退重轉一次;這只補救整段掉字,辨音錯字仍靠校正表。
indicators 撈出 53 個受控術語TERM_GUARDS 前字排除:「全正」前面是「完/安」不改(完全正確、安全正常),「頭信」前面是「多/空/龍」不改(多頭信心)——後者是就地校正卡片時實際誤改過一次才補上的fix_with_report() 回報改了哪幾條、幾次;筆記與卡片都標「已校正」整個模組只有一張 dict 和一個迴圈。值得看的是每條後面的命中數:那是證據,不是裝飾。
# {錯誤形式: (正確形式, 實際命中次數)}——命中數是對 14 份課程逐字稿的實測值
TERM_TABLE: dict[str, tuple[str, int]] = {
# 核心概念(語意完全相反,最嚴重)——綁定「買盤/賣盤/脈盤」前綴
"買盤捷徑": ("買盤竭盡", 64),
"賣盤捷徑": ("賣盤竭盡", 50),
"脈盤捷徑": ("賣盤竭盡", 3), # 「賣盤」也被聽成「脈盤」
# 技術指標
"軍線": ("均線", 10),
"歸離": ("乖離", 3), # 2026-08-30 重萃時漏進 quote 才補
# 權證工具:「權陣大戶/大出權陣/用權陣」8 次全指權證
"權陣": ("權證", 8),
…共 115 條(2026-09-25 補「全政/全正/全鎮/全證→權證」「頭信→投信」「科轉債→可轉債」等 14 條,再補逐字稿原文欄露出的「軍量→均量」「存碼→籌碼」「贈雨→贈與」4 條)
…2026-10-01 再補處置策略用語 57 條(累犯/處置/位階/葛蘭碧/個股名等),見下一張卡
…2026-10-08/10-13 再補 PPA 審閱用語 18 條(停牌→聽牌、格子沖→隔日沖、自行商→自營商、開步林→開布林、魏華科→蔚華科、文貌→穩懋等),見再下一張卡
}
def fix_with_report(text):
changes = {}
# 長的先替換:避免短規則吃掉長規則的一部分
for wrong in sorted(TERM_TABLE, key=len, reverse=True):
right, _ = TERM_TABLE[wrong]
n = text.count(wrong)
if n:
text = text.replace(wrong, right)
changes[f"{wrong}→{right}"] = n
return text, changes
處置神器課程(雙刀處置策略實戰班)的核心名詞整批被聽錯,而且舊逐字稿也大量中標:處置神器以外的 71 份裡,「儲值」(=處置)28 處、「格蘭幣」(=葛蘭碧)13 處。做法同上——對 74 份逐字稿(影片 57+課程 17)反掃,每條逐筆看過上下文才收。57 條分六組:初犯/累犯、處置、位階/均值/浪子回頭/找買點空點、葛蘭碧/通道、被關/停損/尾盤等、個股名(雙刀案例的主角,用畫面上的名稱對過)。
累販→累犯:「累販跌的機率非常大」(初犯也有「初販/初飯/粗範」)儲值→處置:「台股最大bug就是儲值股」「儲值神器」君子以上→均值以上:「君子以上做空 君子以下做舵」=均值以上做空、以下做多格蘭幣→葛蘭碧:「格蘭幣八大法則」聯軍 用 TERM_GUARDS 排除前字「國」,「君子」只綁「以上/以下」,「通告」只綁「上/下/布林」PPA 訂閱線(付費專欄影片逐字稿,走同一份校正)試跑與 PM 審閱規則卡時,逐條抓出的辨音錯字;命中數都是對影片線+課程+PPA 逐字稿轉繁體後實測、逐筆看過上下文才收。專名同音(股名、券商分點)另由 name_fix 比對籌碼湖名冊處理,不進這張表。
停牌→聽牌(8 處)、格子沖→隔日沖(11 處):76 份逐字稿裡全是此義,沒有真的暫停交易自行商→自營商(43 處/14 檔)、出價單→觸價單(7 處)、真頂→臻鼎(6 處)開步林/開步零/開埠零→開布林、價籌倍率→價籌背離、魏華科→蔚華科、文貌→穩懋(20 處,「3105文貌」)備關→被關 用 TERM_GUARDS 排除「準備關門」「設備關機」金鑰怎麼解析、額度怎麼守、LLM 說的話怎麼驗
一集逐字稿進 extract_for_episode() 之後的六步。LLM 只負責「找出規則」,事實一律由我們填或推導。
逐字稿在記憶體裡套 TERM_TABLE,這份校正稿同時給第 2 步與第 5 步用。
取前 40,000 字,附指標詞彙表、五類 category 白名單、provenance 與「個股名不得進 condition/action」的硬規則,以及既有 rule_id 目錄(上限 1000,重萃時排除本集舊卡)讓跨集命名對齊;並要求逐條列出、名詞解釋做成知識卡。
每集呼叫兩輪:第二輪把第一輪的卡列進 prompt、只補漏掉的,兩輪合併時同 id 去重(extract_episode_two_pass)。每輪都解析 GOOGLE_API_KEY(環境變數 → 專案根 .env → 缺就 fail fast),經 call_with_rate_limit_retry 先扣今日額度,再用 google-genai 打 gemini-3.8-flash;被伺服器每分鐘上限(429)擋下會退回額度、等一下再試。
抽 JSON 陣列。「明確的 []」是合法空結果(整集都是行情點評);截斷或畸形的回應一律報錯、不寫檔,留在佇列下次重跑(2026-09-27 影片線跟進課程線)。
在校正稿裡模糊比對 quote。相似度=2×相同字數÷兩邊總長;比對前兩邊統一成繁體、略過 ## MM:SS 標題與截圖行、去標點空白;引文含「...」就拆段各自比對、依字數加權;在逐字稿上逐字滑動取最高分。判定:≥0.8 已驗證;0.5–0.8 且命中處有連續 ≥8 字與逐字稿相同(錨點)保留標未驗證;其餘整卡丟(幻覺)。影片卡驗證率 97%(2026-10-26 實測 769 張中 746 張;2026-09-25 改良前 61%)。命中處另截出逐字稿原文存成 quote_source,手冊在與引文用字不同時並列。timestamp 由命中位置往前找最近的 ## MM:SS 推導、覆寫 LLM 自報;截圖取最近且確實存在的幀。
category 白名單、condition/action 非空、個股名不得進規則本文、provenance 必備。「有產出但全被丟棄」會 raise 退回佇列重跑,不會把空結果固化成已完成。
這段是整條線最容易做錯的地方。註解裡寫的「實測 3 張卡」與「定位失敗被當幻覺丟掉」都是真的踩過。
# 術語校正:餵入與 quote 定位**都用 fix 後的文字**(同一空間)。餵原稿會讓
# 「權陣」這類辨音錯誤原樣穿透進 condition/quote(實測 3 張卡);餵校正後
# 卻對原稿定位,含「權證」的 quote 會定位失敗被當幻覺丟掉。
transcript = term_fix.fix(transcript)
prompt = build_prompt(transcript, meta, rule_catalog=rule_catalog)
raw = complete_fn(prompt) # 測試注入假函式;正式跑 _gemini_complete
cards = parse_llm_response(raw)
for card in cards:
prov = card.get("provenance") or {}
match = match_quote(prov.get("quote", ""), transcript)
status = classify_quote(match.similarity, match.anchor) # 0.8/0.5+連續 8 字
position = match.position
if status == "rejected": # 幻覺風險,整卡丟
quote_rejected += 1
continue
prov["video_id"] = video_id # 事實由我們填,不採信 LLM
prov["episode_title"] = episode_title
prov["date"] = date
prov["quote_verified"] = status == "verified" # ≥0.8
prov["term_fixed"] = True # quote 是 fix 後空間的文字
set_quote_source(prov, transcript, match) # 逐字稿截出的原文
# timestamp 一律用「quote 定位 → 往前找最近標題」推導出的值覆蓋 LLM 給的值
derived = derive_timestamp_from_quote(transcript, position)
if derived: prov["timestamp"] = derived
else: prov.pop("timestamp", None) # 寧可缺席交給護欄丟,不留猜測值
金鑰在整條線只出現在這裡。三件設計:金鑰解析與 groq 那把口徑一致;額度在呼叫之前扣,寧可少打不可超打;被伺服器每分鐘上限擋下時退回額度、等一下再試。
def _resolve_google_api_key() -> str:
key = os.environ.get("GOOGLE_API_KEY") # 1. 環境變數優先
if not key:
env = PROJECT_ROOT / ".env" # 2. 其次專案根 .env 的那一行
if env.exists():
for line in env.read_text(encoding="utf-8").splitlines():
if line.startswith("GOOGLE_API_KEY="):
key = line.split("=", 1)[1].strip().strip('"').strip("'")
break
if not key:
raise RuntimeError("缺少 GOOGLE_API_KEY:…") # 3. 缺就 fail fast,不靜默
return key
def _gemini_complete(prompt: str) -> str:
api_key = _resolve_google_api_key()
return llm_config.call_with_rate_limit_retry( # 每次嘗試前預約今日額度(上限 100)
lambda: llm_config.gemini_generate(prompt, api_key, temperature=llm_config.TEXT_TEMPERATURE))
# call_with_rate_limit_retry:reserve_call(已滿就 raise DailyQuotaExceeded)→ 呼叫 →
# 被 429 擋下:退回額度、等 20/40/60 秒(伺服器有建議就照它)再試;到底仍被擋 raise RateLimited
# gemini_generate:google-genai 打一次、回傳文字;單次逾時 600 秒,SDK 只重試 503(429 留給上面那層)
DailyQuotaExceeded 刻意不繼承 RuntimeError,且 main() 的 except 必須排在 except Exception 之前並 break。否則額度用盡會被逐集容錯接走,對剩下每一集各記一次失敗、燒光重試計數。這個缺陷模式本專案一天內在四個階段各踩過一次。每分鐘限流重試到底丟的 RateLimited 是它的子類,同一個 except 一併接住。| 站 | 在哪 | 花錢? | 產物 |
|---|---|---|---|
| ① 術語校正 | term_fix.fix | 否,確定性 | 記憶體裡的校正稿;檔案不動 |
| ② 組 prompt | build_prompt | 否 | 前 4 萬字+詞彙表+rule_id 目錄 |
| ③ Gemini | _gemini_complete | 是,每日 100 次 | LLM 原始文字 |
| ④ 解析 | parse_llm_response | 否 | 卡片清單(可能為空) |
| ⑤ 引文驗證 | match_quote | 否 | quote_verified、quote_source(逐字稿原文)、推導的 timestamp、frame |
| ⑥ 護欄 | filter_cards | 否 | rule_cards/<vid>.json,manifest 標 extracted |
--force 重萃受影響的那支,或比照 cea44b2 就地校正卡片文字。加短鍵前先數它的前一個字:「全正」「頭信」都是一般中文會出現的組合,要用 TERM_GUARDS 排除,並逐條看替換前後文。.env。寫在別處會直到真的呼叫 LLM 才爆認證錯誤。groq 那把 GROQ_API_KEY 同一套解析。except DailyQuotaExceeded 要排在 except Exception 之前並 break,不可呼叫 _save_failed。這是 CLAUDE.md 上寫的硬規則。zh_convert 只在簡報線與手機隨身讀轉引文顯示,不進萃取,與 term_fix 無關。