iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Modern Web

教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard系列 第 16

Day 16|對話式紀錄:把「今天 12K 有點喘」變成一筆訓練紀錄

  • 分享至 

  • xImage
  •  

昨天結束在一個沒解決的問題上,

8/25 那場課表開 1000m×5~6,我跑到第三趟覺得撐不太下去,後三趟自己改成 800m,而 sessions.json 裡那筆紀錄長這樣:趟數 5、配速 5/5 全在範圍內—兩個數字都漂亮,但自己當下決定改課表這件事,沒有地方記錄到。

尤其是紀錄的備註把它寫成:

縮短單組距離(1600m → 1000/800m)換來快 6–7 秒/400m 的配速,是合理的區塊推進

聽起來像計畫中的安排。那是從數字反推出來的—而數字裡本來就沒有「當下狀態」的表示。

前面十幾天做的事,本質上都是把結構化的資料搬來搬去:Garmin 的 JSON、體重機的 13 bytes、Notion 的巢狀 block。它們格式各異,但至少都是機器產生的、有固定形狀的東西。

今天處理的是完全不同的輸入:

今天 12K 有點喘

這句話沒有欄位、沒有格式,甚至沒有明確說是哪一天、什麼配速、喘到什麼程度。

而這種訊息,才是實際上最常發生的紀錄方式—跑完站在路邊,掏出手機打一行字補充狀態資訊。


傳統做法為什麼行不通

如果要用程式解析這句話,得處理的變化太多了:「12K」可能寫成「12 公里」「12km」「十二 K」;「有點喘」可能是「還好」「快往生」「腿很重」;還有其他很難預期的說法。

寫正則表達式去窮舉?寫到第二十條規則就會發現,每多支援一種說法,就多一分寫錯的風險,而且永遠有沒想到的講法。

這正是 LLM 適合的地方—它不需要你先定義好所有可能的句型。


架構上的關鍵決定:bot 自己不寫檔案

這個設計最反直覺的地方在這裡。

一般會想像的流程是:bot 收到訊息 → 呼叫 LLM 把它解析成 JSON → bot 拿到 JSON → bot 寫進檔案。LLM 在中間當一個「文字轉結構」的翻譯器。

但實際上的做法不是這樣,bot 沒有解析、也沒有寫檔案,它做的事只有一件:把訊息原封不動交出去。

async def on_message(update, ctx):
    if not _authorized(update):
        return
    # ...快捷按鈕的處理...
    await _drive_claude(update, ctx, update.message.text)

最後那行就是全部了。使用者打的字直接送進 _drive_claude,由它啟動一個 claude CLI 的行程,在訓練紀錄那個資料夾裡執行

proc = await asyncio.create_subprocess_exec(
    *cmd,
    cwd=str(COACH_DIR),          # 工作目錄 = 助教的資料夾
    stdin=asyncio.subprocess.PIPE,
    ...
)

CLI 啟動時會讀取那個資料夾裡的 CLAUDE.md—也就是 Day 14 那份助教人設與紀錄規則—然後自己決定要不要寫、寫成什麼樣子、寫到哪個檔案

換句話說,「怎麼記錄一筆訓練」這個邏輯,不是寫在 Python 裡,是寫在一份 markdown 文件裡。


這樣做的好處與代價

好處是改規則不用改程式。 想調整紀錄格式,多記一個欄位及改變判斷標準,修改 CLAUDE.md 就行,一行 Python 都不用動。

代價是輸出不保證。 傳統程式寫入資料庫,格式一定正確。這裡的最後一哩路是自然語言,理論上每次的結果可能不一樣,也可能寫錯地方。

實務上是靠 CLAUDE.md 把規則寫得夠明確來收斂—但這是「引導」而不是「保證」,這個區別要誠實面對。

還有一個實際的安全考量。CLI 是用這個權限模式啟動的:

PERMISSION_MODE = os.environ.get("PERMISSION_MODE", "acceptEdits")

acceptEdits 代表自動允許讀寫檔案,但不會執行任意 Bash 指令。這是刻意選的:紀錄訓練需要寫檔案,但不需要跑系統指令。而且那個工作目錄裡刻意不放 .env 和程式碼—助教碰得到紀錄,碰不到鑰匙


記憶:讓它記得剛剛講過什麼

如果每則訊息都開一個全新的對話,會很難用—你說「今天 12K 有點喘」,它記錄完;接著你補一句「配速大概 5:30」,它會完全不知道你在講哪一次。

所以每個對話都維持一條連續的 session:

sid = _sessions.get(chat_id) if resume else None
if sid:
    cmd += ["--resume", sid]      # 續上既有對話
else:
    sid = str(uuid.uuid4())       # 新對話
    cmd += ["--session-id", sid]

但對話不能無限延長—session 越長,每次帶的上下文越多,token 燒得越兇。所以加了閒置自動重開:

SESSION_IDLE_MINUTES = float(os.environ.get("SESSION_IDLE_MINUTES", "30"))

超過三十分鐘沒講話,下一則訊息就自動開一條新的。這個數字的邏輯是:訓練紀錄的對話通常是一陣一陣的—跑完步那幾分鐘會連續講好幾句,講完就沒事了。三十分鐘後再開口,通常已經是不相干的另一件事。

也有 /new 指令可以手動清空重來。


兩個實作上的細節

逾時要收屍。 CLI 有可能卡住,所以設了逾時,但光是 kill 不夠:

except asyncio.TimeoutError:
    proc.kill()
    await proc.wait()   # 收屍,避免 zombie process 累積
    raise RuntimeError(...)

少了 await proc.wait(),被移除掉的行程會變成殭屍留在系統裡,久了會累積。

訊息要切段。 Telegram 單則訊息上限 4096 字,助教有時候會回一長串分析:

for i in range(0, len(reply), 4000):
    await update.message.reply_text(reply[i : i + 4000])

但話進來了,欄位還是沒有

管線通了,訊息會變成 markdown 紀錄。可是昨天那個問題還沒解掉。

因為 parse_logs.py 抽的是這幾個欄位:

{date, kind, summary, moves, reps, plan}

距離、配速、心率都有欄位,「吃不消」沒有。 話進到 markdown 的散文裡,結構化那層依然看不到它—跟 Day 15 提到的問題是一模一樣的:人看得到,程式看不到。

所以今天多做一件事:CLAUDE.md 加一條規則,把使用者講的話拉成獨立一行。

自述:課表開 1000m×5~6,跑到第三趟覺得吃不消,後三趟自己改成 800m。

規則裡最重要的是兩條不准

- **不要改寫成分析。** 備註那一段是你事後推論出來的,自述是他當下的理由 —
  兩者不能混在一起。
- **不要自己編。** 使用者沒講,就沒有這一行。從數字反推出來的原因寫進備註,
  不是自述。

第二條是針對開頭那個「合理的區塊推進」。助教很擅長從數字編出一個說得通的理由,而那個理由聽起來跟真的一樣,所以規則要明講,沒有加進去就不要多提。

解析端只認行首:

# 只認行首,免得備註裡提到這兩個字也被抓走。
_SELF = re.compile(r"^\s*自述[::]\s*(.+?)\s*$", re.MULTILINE)

畫面上這句擺在分組表上面,不是下面:

自述 課表開 1000m×5~6,跑到第三趟覺得吃不消,後三趟自己改成 800m。

擺下面就沒用了—因為會先看到「800m、800m、800m」那三格,很容易就把它當成計畫中的安排。


明天:把疲勞算出來

有了紀錄與資料,但還缺一個東西:判斷

一週練了五次,是進步還是過頭?明天要用訓練負荷模型來回答這件事。



上一篇
Day 15|課表達成率的兩把尺:教練的課表,和當天跑的情況
下一篇
Day 17|把疲勞算出來:TRIMP → ATL / CTL / TSB
系列文
教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言