前面用到的每一個工具,都是獨立跑的:改完清理規則,我自己去跑一次 Day 14 的腳本;要看表一,再手動跑一次 Day 16 的腳本。今天想把它們串成一條,資料一改,後面全部自動重跑。
動手寫這條流水線的時候,發現了兩件事——一件是「文件本身有沒串好的地方」,一件是「程式碼裡一個我完全沒想到的坑」。這篇把兩件事都寫實話。
把 30 天累積下來的工具攤開來看,資料真正的流向是這樣:
data/raw/(Google 表單原始匯出,唯讀)
↓
data/clean/01_renamed.csv(改欄名、刪重複與測試填答)
↓ Day 14:清理規則
data/clean/02_cleaned.csv(反向計分/補值/離群標記)
↓ Day 15 ↓ Day 16 ↓ Day 17
data/clean/03_for_spss.sav draft/table1.docx output/fig*.png
文獻那邊是另一條線:
literature/pdf/*.pdf
↓ Day 8:摘要卡
literature/cards/*.md(核對過的)
↓ Day 9:比較矩陣 ↓(尚未有獨立文章記錄這一步)
literature/matrix.md draft/references.md
↓ Day 10:查證 ↓ Day 11:APA 校對
literature/cite-check.md draft/references.apa.md
畫到這裡,我發現兩個空格。
data/raw/ 到 01_renamed.csv 這一步,從來沒有獨立寫過。 Day 14 開頭寫「昨天把 Google 表單匯出的原始檔改好欄名、刪掉重複和測試填答,287 份變成 279 份」,但「昨天」具體做了什麼、用什麼規則判斷是不是測試填答,這件事本身應該要有自己的規格檔和 skill,跟 Day 8 摘要卡、Day 14 清理規則是同一種東西,但這系列裡沒有單獨一篇講過它。
摘要卡跟比較矩陣,怎麼變成 draft/references.md 這份最終的參考文獻清單,這一步也沒寫過。 Day 10 查證、Day 11 校對,兩篇都是直接從「已經存在的 draft/references.md」開始講,但那份清單本身是怎麼從幾十張摘要卡篩選、彙整出來的,中間這道手續一直沒有被說清楚。
這兩個空格不是這條流水線「壞掉」了——每個工具各自都跑得動,我這週手動做這兩步也做得出來。是文件鏈本身有兩個環節,我一直跳過沒寫,因為跳過去也不影響單篇文章讀起來完不完整。 串起來的時候才會露餡,因為串流水線逼著我把每一個環節的輸入到底從哪裡來,老實交代一次。
這跟 Day 16 那句話是同一個教訓,只是這次不是資料錯了,是文件本身有缺口:規則設對了、單一步驟做對了,不代表整條鏈接起來的時候每一環都交代清楚了。 這兩個環節我會補回去,在那之前,PROGRESS.md 裡先誠實記一筆待補。
流水線的核心不是把四支腳本貼在一起跑,是任何一步失敗,後面的步驟不能拿舊資料繼續跑下去。這是 Day 4 「clean 可以整個刪掉重生」的原則,在流水線層級的版本——不只是單一檔案可以重生,是整條鏈只要有一環壞了,就該整條停下來,而不是讓後面的表格和圖表,安靜地用著上一次成功的舊資料,看起來一切正常。
analysis/run_pipeline.py:
import os, subprocess, sys
from pathlib import Path
CHILD_ENV = {**os.environ, "PYTHONIOENCODING": "utf-8"}
STEPS = [
("清理", "analysis/02_clean.py", "data/clean/02_cleaned.csv"),
("匯出 SPSS", "analysis/03_export.py", "data/clean/03_for_spss.sav"),
("表一", "analysis/04_table1.py", "draft/table1.docx"),
("圖表", "analysis/05_figure.py", "output/fig1_burnout_by_shift.png"),
]
def main():
if not Path("data/clean/01_renamed.csv").exists():
print("找不到 01_renamed.csv,流水線無法開始。")
sys.exit(1)
report = ["# 流水線執行報告\n"]
for name, script, expect in STEPS:
result = subprocess.run([sys.executable, script],
capture_output=True, text=True,
encoding="utf-8", env=CHILD_ENV)
ok = result.returncode == 0 and Path(expect).exists()
report.append(f"## {name}\n狀態:{'成功' if ok else '失敗'}\n")
if not ok:
report.append(f"錯誤:\n```\n{result.stderr}\n```\n")
Path("analysis/pipeline-report.md").write_text("\n".join(report), encoding="utf-8")
print(f"{name} 失敗,流水線停止,後面的步驟不會執行。")
sys.exit(1)
Path("analysis/pipeline-report.md").write_text("\n".join(report), encoding="utf-8")
if __name__ == "__main__":
main()
跟 Claude Code 說「跑一次流水線」,它就是執行這支腳本,把 Day 14 到 17 四支腳本依序串起來,任何一支失敗就停,不讓下一支拿舊檔案繼續跑。
我把這支腳本實際跑過。清理、匯出、做表、畫圖,四步全部成功,pipeline-report.md 老實記下每一步的輸出。順便把 Day 19 回顧時抓到的那個問題也修掉了——Day 17 的圖表這次正確排除了 bo_missing_flag 標記的 6 筆,班別的樣本數跟平均分數因此有小幅變動:
| 班別 | 修正前 n | 修正前 M | 修正後 n | 修正後 M |
|---|---|---|---|---|
| 白班 | 87 | 64.85 | 83 | 65.30 |
| 小夜班 | 90 | 65.83 | 90 | 65.78 |
| 大夜班 | 102 | 65.72 | 100 | 65.66 |
差異很小,但這才是對的數字——那 6 筆本來就不該被算進倦怠總分的平均,Day 19 發現的問題,今天真的補上了。
跑第一次的時候,還踩到一個完全沒料到的坑:整個流水線在終端機上炸出一整排看起來很嚇人的錯誤訊息,UnicodeDecodeError,每一步都報錯。我第一反應是流水線整個壞了。但檢查產出的檔案,02_cleaned.csv、table1.docx、圖檔,全部都正確生成了——流水線其實是成功的,炸掉的只是我用來「讀」子程式輸出文字的那一層。
原因是我的 Windows 中文系統,終端機預設編碼是 Big5(cp950),四支子腳本印出中文訊息時,是用系統這套編碼寫出去的;我在執行器裡卻指定用 UTF-8 去讀那些輸出,兩邊對不上,直接丟例外。解法是在啟動子程式之前,先設定一個環境變數 PYTHONIOENCODING=utf-8,強迫子程式改用 UTF-8 印出東西,這樣執行器用 UTF-8 讀,兩邊才對得起來。
這個坑最有意思的地方,是它示範了 Day 16、Day 19 反覆出現的那句話的另一種版本:「看起來失敗」跟「真的失敗」是兩件事,看起來對跟真的對也是兩件事。 這次剛好相反——不是「看起來成功,其實錯了」,是「看起來爆炸,其實成功了」。兩種情況要分辨的方法一樣:不要只看終端機上跳出來的文字,去檢查真正的產出檔案在不在、對不對。
串流水線這個動作本身,就是一種驗證機制,跟摘要卡的核對、查證的 🟢🟡🔴、圖表的灰階檢查是同一類東西——它逼著我把每一個「反正這步驟我自己知道」的地方,攤開來檢查一次是不是真的接得上。今天沒有抓到資料算錯的問題,抓到的是文件本身兩個沒寫清楚的環節、跟一個編碼層面的假警報。三種問題形狀都不一樣,但抓到它們的方法是同一個:把各自獨立、看起來都沒問題的東西,真的串起來跑一次。
Day 26:打包分享。讓實驗室同學也能直接用這整套東西——.mcp.json、CLAUDE.md、規則檔要怎麼包,才不會變成只有我自己看得懂的黑盒子。