Day 9 做自動排程的時候,我們已經發現教練課表是唯一還要人工搬運的一段。
其他來源都有自己的接口—Garmin 有 API、體重機會廣播、重訓紀錄在 Notion。只有課表,教練發在 LINE 記事本,我得自己截圖、轉傳到 Telegram。
今天要處理這一段。而第一個決定是:不用 OCR。
OCR(Optical Character Recognition,光學文字辨識)就是「把圖片裡的字讀出來」的技術—掃描文件、手機的圖片轉文字都是它。既然課表是圖片,第一個念頭當然是先用 OCR 轉成文字,再寫程式解析。
但這條路不太適合我們。
教練的課表不是一段自由書寫的訊息,是一張有結構的表:
第一、二組 4區1000m*3~4(組休180秒)
拆開來是:
| 部分 | 意思 |
|---|---|
第一、二組 |
分組(我在第一組) |
4區 |
四區配速 |
1000m |
每趟距離 |
*3~4 |
跑 3 到 4 趟 |
(組休180秒) |
每趟之間休息 180 秒 |
而「幾區」對應的配速是另一張固定的表:
| 區 | 代號 | 配速 | 每 400m |
|---|---|---|---|
| 一區 | E | 5:10/km ±10 秒 | 2:04 |
| 四區 | A | 4:00/km ±5 秒 | 1:36 |
| 六區 | R | 3:20/km ±5 秒 | 1:20 |
看到那個 1:36/400m 了嗎—那正是 Day 10 那張間歇表裡,Garmin 活動名稱標的目標配速。兩邊本來就對得起來,只是一邊在圖片裡,一邊在 FIT 檔裡。
這是最直覺的反應,而且 Day 8 才剛用同樣的思路處理過重訓紀錄—找出格式,寫正則。
但課表跟那些不一樣的地方在於它是圖片。要先把圖變成文字,才輪得到正則。
問題不在認字,OCR 認字功能完善,4區1000m*3~4(組休180秒) 這幾個字元它讀得出來,
但認字之後,OCR 回給你的是一堆文字方塊,每個帶著座標,但課表是一張表格—哪一格屬於星期幾、哪一格跨了兩行、哪一段是備註,全部要自己從座標推回來。
換句話說,OCR 幫你跨過的是最簡單的那一步,剩下的表格重建才是麻煩的地方。
但有另一條方式可以完全跳過。
這條個方式在之前測試 Telegram bot 的時候,就加了圖片處理—當時只是想截圖訓練結果,完全沒想到後來會拿來讀課表:
def _photo_prompt(paths: list[Path], caption: str) -> str:
"""把圖片路徑組成給 claude 的 prompt,請它用 Read 工具看圖。"""
listing = "\n".join(str(p) for p in paths)
head = caption.strip() + "\n\n" if caption.strip() else ""
return (
f"{head}[使用者傳了 {len(paths)} 張圖片到本機,請先用 Read 工具逐張查看"
f"再依 CLAUDE.md 規則處理]\n{listing}"
)
流程是:圖片存到 coach/incoming/ → 告訴 Claude 路徑 → 它用 Read 工具看圖 → 依 CLAUDE.md 的規則處理。
昨天講過 CLI 內建了 Read 工具,而 Read 讀得了圖片。所以「把課表截圖交給 LLM」這件事,不需要任何新程式碼。
僅是寫了一份規則。
圖片能成功傳送給Claude 讀到,但它不知道我是第幾組、紅字雜訊要跳過,也不知道四區是配速是多少,這些每次都要在對話裡重講一遍。
所以寫進 coach/CLAUDE.md:
## 只看第一組、只看黑字
- **只讀「第一組」**:每格第一行會標「第一、二組」之類,取第一組的量。
- **只讀黑字**:紅字是除了台北馬外的賽事專屬資訊,
我的目標是 **12/20 臺北馬**,紅字那些不適用,直接略過。
加了說明和那張配速對照表。
這就是今天的全部產出。 沒有解析器、沒有新的 handler,只有一份寫給 AI 看的說明文件。
這個做法之後還會再出現一次,Day 16 要做對話式紀錄—「今天 12K 有點喘」自動變成結構化資料,用的也是同一方式:規則寫在 markdown,不寫在 Python。
判準是:規則會不會變、以及變的時候誰要處理。
課表版面或寫法可能會更動改,也可能會換組。這些變動要是寫在正則裡,每次都得改程式碼,重新測試和部署,但寫在 CLAUDE.md 裡的話,改一行字就好。
代價是它還不穩定,資訊需要再做確認。
規則寫完,傳兩張課表截圖進去,一次讀出四週。跟實際紀錄一比,趟數全部對得上:
| 課表 | 實際做的 | |
|---|---|---|
| 8/10 週 · 四區 1000m ×3~4 | 8/11 跑 4 趟 | ✅ |
| 8/10 週 · 三區 1600m ×2~3 | 8/13 跑 3 趟 | ✅ |
| 8/17 週 · 三區 1600m ×3~4 | 8/20 跑 4 趟 | ✅ |
| 8/24 週 · 四區 1000m ×5~6 | 8/25 跑 5 趟 | ✅ |
而且配速也對得上—四區是 1:36/400m,8/25 那場前四組跑 1:37.6~1:37.9,貼著目標下緣,達成率終於算得出來了。
但有一列明顯不對:
| 日 | 長跑 | 1 | 80~100km(週量) | - | - |
每週 80 到 100 公里。 我的實際最長單次是 16K,週量差得遠。
去對原圖才發現,那格寫的是 80~100 分鐘,被讀成了公里。
有意思的是這種錯不會讓程式壞掉。80~100 和 km 都是還算合理的數字跟單位,寫進 markdown 不會馬上被發現。
所以規則裡補了一段:
**課表裡時間與距離混用,讀圖時先確認單位**
不確定的時候寧可回報「這格看不清楚」,不要瞎猜。
這也是這個做法的真實成本—LLM 讀圖不是不會出錯,需要人工審閱。 趟數錯了能馬上會發現,因為它跟我實際跑的對不上;單位錯了則是因為那個數字太離譜才被抓到。要是它把 3~4 讀成 3~5,就很難發現了。
解讀完的課表寫進 logs/plans.md,跟訓練紀錄分開存:
## 2026-08-24 ~ 2026-08-30
| 日 | 內容 | 區 | 距離 | 趟數 | 組休 |
|---|---|---|---|---|---|
| 二 | 間歇 | 4 | 1000m | 3~4 | 180s |
分開的理由很簡單:課表是教練開的,訓練紀錄是實際做的。
Day 10 那個 planned 欄位的問題就是這麼來的—在規劃版面的時候假設會有課表可以比對,結果沒有,卡片只好從「本週課表達成」改名成「本週訓練」。
現在課表有地方放了,那張卡片之後就有機會改回去。
課表成功進到系統,Garmin、體重、重訓紀錄也都有了,資料終於比較完整。
但資料齊了,不代表 AI 能有 sense 理解。Day 1 有提及這個系列不做 AI 教練—教練是真人,AI 是中間那層助教,既然是助教,怎麼讓他好好運行就是明天的事啦。
明天要講 NotebookLM 的部分—餵了哪些來源、怎麼驗證它沒有亂講,明天見啦!