iT邦幫忙

2026 iThome 鐵人賽

DAY 30
0
佛心分享-SideProject30

30 天打造公開資料版急診檢傷系統:Side Project 與實驗計畫系列 第 30

Day 30|完成可重現 Side Project:打包成果、限制與下一站

  • 分享至 

  • xImage
  •  

Day 29 已把第 1 次重複的折外預測拆成錯誤方向、切片與固定私人案例,並用七筆作者合成案例建立證據追蹤紀錄(Evidence Trace)的分類規則。最重要的結論不是找到一個漂亮案例,而是確認病患版完整檢索增強生成(Retrieval-Augmented Generation, RAG)沒有保存檢索查詢(query)、候選、門控、生成脈絡(context)、提示詞(prompt)、生成輸出與引用,所以元件歸因必須維持 null

到了最後一天,很容易再犯一次相同錯誤:看到 30 篇文章、程式與圖片都在,就把「專案完成」寫成「系統完成」。但一個可以公開檢查的程式碼儲存庫,不等於一個已經回答所有研究問題的模型,更不等於可以進入急診現場的醫療系統。

下圖是概念示意。左到右三個工作台依序整理資料、公開知識、檢索、安全檢查與測試,最後把可公開產物封裝成版本化盒子;臨床人員仍在終點覆核,盒子後方也保留一條尚未走完的路。圖中沒有真實病患、正式醫療規則或本篇精確數字。

三段透明工作台把資料、知識、模型與稽核模組組裝成公開封存盒,最後由臨床人員覆核的概念示意

上圖刻意沒有勝利講台。今天完成的是一份可重跑、可追溯、可以指出未完成證據的公開研究工程釋出;不是宣告模型勝過臨床人員,也不是取得部署資格。

本篇會完成五件事:

  1. 回顧 Day 01–30 實際建立的資料、知識、檢索、門控、生成、基準與評估產物。
  2. 用證據帳本逐題回答五個研究問題目前到哪裡,避免把元件測試拼成病患效果。
  3. 建立 Day 30 發布契約,自動檢查 30 篇文章、圖片、公開結果、完整程式碼、隱私排除與禁止宣稱。
  4. 把「repository 可以發布」和「臨床證據仍未完成」同時寫進機器可讀結果。
  5. 把下一站拆成病患 RAG、確認性與外部驗證、前瞻性靜默評估、受控臨床影響研究四個階段。

Day 30 不重新訓練模型、不呼叫外部服務,也不讀取逐筆病患資料。它只稽核 repository 中預定公開的檔案,以及 Day 15–29 已公開的聚合或合成工程結果。


本篇會用到的名詞

中文名稱 英文全名/縮寫 本篇用途
韓國急診檢傷與急迫度分級量表 Korean Triage and Acuity Scale, KTAS 本系列病患資料的五級參考制度;第一級最急迫、第五級最不急迫
檢索增強生成 Retrieval-Augmented Generation, RAG 先檢索外部知識,再讓生成模型依取回內容形成回答的方法
大型語言模型 Large Language Model, LLM 根據文字脈絡產生輸出的模型;流暢回答不等於有證據支持
折外預測 Out-of-Fold Prediction, OOF Prediction 每筆紀錄由沒有用該筆資料擬合的模型產生預測
研究問題 Research Question, RQ 把本系列要回答的問題編為 RQ1–RQ5,逐題維護證據狀態
程式碼儲存庫 Repository,本文簡稱 repo 保存文章、設定、程式、測試與可公開產物的專案根目錄
發布契約 Release Contract 用機器可讀規則定義釋出前必須存在、必須通過與必須排除的內容
證據帳本 Evidence Ledger 逐一記錄研究問題、目前證據、允許宣稱與下一份必要證據
執行產物清單 Run Manifest 保存一次執行使用的設定、輸入、版本、參數與輸出雜湊
安全雜湊演算法 256 位元 Secure Hash Algorithm 256-bit, SHA-256 為檔案或穩定結果產生可比較的內容摘要
JavaScript 物件表示法 JavaScript Object Notation, JSON 保存機器可讀的發布契約、公開摘要與 run manifest
逗號分隔值 Comma-Separated Values, CSV 保存逐列資料的表格文字格式;逐筆病患 CSV 不公開
應用程式介面 Application Programming Interface, API 程式與外部服務溝通的介面;API 金鑰只留本機
病患層級折外比較 Patient-level OOF Comparison 在相同 folds 上比較不同病患預測方法,並保存逐筆配對結果
外部驗證 External Validation 在不同時間、醫院或族群資料上檢查模型表現是否可延伸
前瞻性靜默評估 Prospective Silent Evaluation 在真實流程中收集模型輸出,但不讓輸出影響照護,先觀察失效與工作流程問題
預定用途 Intended Use 明確定義誰在什麼情境,為了哪一個決策,以什麼方式使用系統
台灣急診檢傷與急迫度分級量表 Taiwan Triage and Acuity Scale, TTAS 與 KTAS 不同的五級制度;本系列結果不能直接改稱 TTAS 結果
急診嚴重度指數 Emergency Severity Index, ESI 另一套五級急診分級制度;不能和 KTAS 標籤直接互換

B0B1B2 是 Day 26 的三個簡單基準識別碼。P0 是本系列規劃中的完整方法識別碼,不是已完成模型:它預計結合雙重數值表示、混合檢索、安全候選聯集、重排序與證據約束輸出,但病患 OOF 尚未執行。

先決定「完成」指的是哪一層

本篇把完成拆成三層,因為三層需要的證據完全不同。

第一層:公開工程釋出完成

第三方應能看到 30 篇文章、程式、設定、測試、公開知識、聚合結果與圖片,並依文章建立檔案、執行命令、核對輸出。逐筆資料、金鑰、模型權重與私人 run 不需要公開,但取得方式、雜湊與排除規則要寫清楚。

Day 30 runner 會檢查的就是這一層。通過後可以說「公開 repository 符合本系列發布契約」。

第二層:研究問題得到足夠回答

要回答「RAG 是否改善病患五級分類」,至少要在同一份 folds、相同生成模型與固定評估契約下,完成無檢索、Flat RAG、Hierarchical RAG 與完整 P0 的病患 OOF。元件級公開知識題與作者合成案例不能取代這份比較。

目前第二層沒有完成。公開工程可以發布,不代表五個研究問題都已有完整效果答案。

第三層:臨床使用準備完成

臨床使用還需要外部資料、前瞻性評估、人因與工作流程、失效處理、持續監測、版本治理、資安、倫理及法規等證據。人工智慧驅動決策支援系統的發展與探索性臨床研究報告指引(Developmental and Exploratory Clinical Investigations of DEcision support systems driven by Artificial Intelligence, DECIDE-AI)特別強調,在真實環境中要檢查小規模臨床效用、安全與人因,離線效能不能自行取代這些問題。DECIDE-AI:早期臨床 AI 決策支援評估

本系列沒有進入第三層,因此不能寫「可部署」「可取代檢傷」或「已證明臨床效益」。

30 天實際建立了什麼

30 天不是連續替同一個模型加功能,而是逐步建立問題、資料、知識、方法與驗證契約。

階段 建立內容 可觀察產物 不能跨越的界線
Day 01–10 五級檢傷、大型語言模型限制、RAG、三種架構、指標、控制比較、研究問題與資料角色 文章、研究契約、資料選擇與禁止欄位 尚未執行模型,不能報效果
Day 11–15 Kaggle KTAS 取得、欄位政策、資料管線、可重現 repo、資料契約、缺失與 folds 資料卡、SHA-256、處理程式、1,267 筆品質聚合與重複五折 原始與逐筆處理資料留本機;沒有病患識別碼
Day 16–20 部分公開 KTAS 知識庫、切塊、數值表示、稠密檢索(Dense Retrieval)、最佳匹配 25(Best Matching 25, BM25)、混合檢索(Hybrid Retrieval)與 Cohere 重排序 九筆公開知識單元、檢索 probes、排名、服務請求雜湊 公開規則不等於完整官方手冊;小型 probes 不是病患效能
Day 21–25 Flat RAG、Hierarchical RAG、Hard Gate、安全候選聯集(Safety Union Gate)、證據約束輸出與拒答 公開知識題、合成門控 trace、合成輸出驗證 工程案例不能稱為五級分類率或臨床安全率
Day 26–30 B0/B1/B2、消融就緒、配對統計、錯誤切片、Evidence Trace、發布稽核 6,335 列本機 OOF、公開聚合、私人固定案例、證據帳本 B3–P0 病患 OOF、外部驗證與前瞻性評估尚未完成

下圖把三個十天階段接在同一條路徑。請先看每一段建立的責任,再看底部的公開產物數;數字由 Day 30 稽核結果讀取,不是手動抄寫。

Day 01 到 10、11 到 20、21 到 30 三階段的問題契約、資料檢索、RAG 安全與發布成果總覽

上圖的最後一段包含兩種性質不同的成果。Day 21–25 是公開知識題或作者合成工程案例;Day 26–29 才使用真實 KTAS 紀錄的基準 OOF 與事後探索性分析。兩者可以放在同一個 repo,不能放進同一個效能排行榜。

三個簡單基準、Safety Union 與 P0 的關係

Day 26 的三個基準各自回答不同問題:

識別碼 方法 已完成證據 角色
B0 每折訓練資料多數類別 五次重複五折 OOF 不看輸入特徵的最低分類地板
B1 部分公開規則,缺口即拒答 五項規則缺口使覆蓋率為 0 檢查知識不足時能否誠實停止
B2 只用檢傷當下結構化欄位的線性序位 Ridge 迴歸(Ridge Regression) 五次重複五折 OOF 不用主訴文字或 RAG 的簡單預測基準

B2 的平均準確率高於 B0,但嚴重檢傷不足率仍高;Day 28 也觀察到平衡準確率與嚴重不足方向改善的同時,整體檢傷不足率增加。因此 B2 是有用的比較基準,不是安全模型。

Day 24 的安全候選聯集(Safety Union Gate)把 Base、主訴、生命徵象與高風險保留候選合併後再排序。八筆作者合成案例顯示它能保留 8/8 筆 Base,這只驗證候選集合契約;它沒有接上 1,267 筆病患 OOF,不能和 B2 比較病患效能。

P0 預計把 Safety Union 和數值雙重表示、混合檢索、重排序、證據約束輸出接成完整方法。Day 27 已建立 P0 與六個單一元件移除版本,但所有病患 metrics 都是 nullnull 不是零分,而是尚未執行。

五個研究問題目前回答到哪裡

證據帳本的用途,是阻止「某個模組能跑」被改寫成「整個研究問題已回答」。

研究問題 目前狀態 現有證據 下一份必要證據
RQ1:公開 KTAS 知識檢索是否改善病患分類? 病患 OOF 未回答 Day 21–22 六筆公開知識題;Day 26 只有 B0–B2 相同 folds 完成 B4、B5、P0,保存逐筆 Evidence Trace
RQ2:三種數值表示如何影響檢索與分類? 只有工程冒煙測試 Day 18 一筆合成檢索診斷 可追溯數值規則、固定標註與病患 OOF 控制比較
RQ3:平面與階層結構是否有差異? 只有元件配對 六題中 Flat 6/6、Hierarchical 5/6 固定其他元件後的病患 OOF 配對
RQ4:不同門控如何影響錯誤方向? 只有合成工程案例 Day 23–24 六到八筆門控 trace 臨床審查規則、高急迫級病患 OOF 與配對安全分析
RQ5:完整方法是否對切分與門檻穩健? 規劃,未執行 B0/B2 共用五次重複五折;P0 仍為 null 事前鎖定門檻、未碰觸資料與 P0 敏感度分析

因此,Day 30 的誠實答案是:五個研究問題都有對應的工程準備或局部觀察,但完整病患 P0 證據的回答數是 0/5。這不是專案失敗,而是把完成範圍寫準確。

三層釋出:公開、本機與未來證據

不是所有可重現內容都必須直接提交 Git。資料授權、隱私、檔案大小與外部服務會決定不同產物的保存位置。

可直接公開與檢查

  • 30 篇系列文章與文章圖片。
  • 設定檔、核心模組、執行入口與測試。
  • 部分公開 KTAS 知識庫、提示詞與作者合成 fixtures。
  • Day 15–30 不含逐筆病患資料的公開聚合或工程結果。
  • 每張 Python 技術圖的生成程式,以及 Image 2 概念圖的提示詞摘要。

需依授權在本機重跑

  • Kaggle 原始 ZIP、data.csv、中介表與處理後逐筆資料。
  • Day 26 的 6,335 列 OOF 與 Day 29 的 20 筆私人固定案例。
  • 本機 Ollama 模型權重、完整向量與逐次 run manifest。
  • Cohere API Key、回應快取與逐次網路狀態。

data/raw/data/interim/data/processed/results/runs/ 都由 .gitignore 排除。Day 30 只檢查排除規則與公開候選路徑,不會為了證明隱私而重新打開私人檔案。

尚未完成,不能由現有產物替代

  • B3P0 的同 folds 病患 OOF。
  • 病患 RAG 的完整 query、候選、門控、context、prompt、輸出與引用 trace。
  • 事前確認性方案、病患層級相依處理與外部資料。
  • 前瞻性靜默評估、人因、臨床影響與持續監測。

這種分層符合醫療人工智慧開發的全生命週期思維。美國食品藥物管理局(U.S. Food and Drug Administration, FDA)整理的良好機器學習實務(Good Machine Learning Practice, GMLP)原則強調多領域專業、具代表性的資料、訓練與測試獨立、人機互動與部署後監測;這些原則不是本篇 runner 可以自動驗證完的清單。FDA:Good Machine Learning Practice Guiding Principles

發布契約如何決定通過

Day 30 把四項檢查寫成布林值。設第 (j) 項檢查為 (C_j),通過時 (C_j=1),失敗時 (C_j=0)。repository 發布狀態定義為:

[
ReleaseReady=\prod_{j=1}^{4} C_j
]

四項檢查分別是:

  1. 發布契約、證據帳本與禁止宣稱沒有漂移。
  2. Day 01–30 文章完整,每篇至少三張圖片,所有資產可解析且為允許的 16:9 尺寸。
  3. 設定、測試、執行入口、公開知識與 Day 15–29 上游公開結果達到契約數量。
  4. 私人資料、run 產物、金鑰與 .DS_Store 不在版本控制候選,公開 JSON 也沒有逐筆主訴或人工標籤欄位。

例如四項都通過時,(1\times1\times1\times1=1),repository 可以發布。只要一項失敗,乘積就是 0;runner 會停止,不會寫出「大致通過」。

這個公式沒有第五項「模型準確率達標」。原因不是效能不重要,而是目前沒有事前鎖定的 P0 病患比較與臨床門檻。把未定義的門檻塞進發布稽核,反而會把工程完整度與臨床效益混成同一件事。

下圖左側是四項公開 repository 檢查,右側是必須維持「未完成」的臨床證據。閱讀時要同時看兩欄,不能只截取左側的綠色通過標記。

左欄四項公開 repository 發布檢查通過,右欄 P0 病患 OOF、外部驗證、前瞻性評估與部署皆未完成

上圖說明一個看似矛盾、其實必要的結果:repository_release_contract_passed=true,同一份 JSON 也固定保存 clinical_deployment_ready=false。公開工作做完與臨床證據未完成可以同時成立。

最終 repo 裡每種檔案負責什麼

Day 30 新增四個主要檔案與一個圖片生成程式。第一次出現路徑時先說明它們的責任:

路徑 檔案類型與資料夾責任 輸入 輸出或影響
configs/release/day-30-release-contract.json configs/release/ 保存發布規則的 JSON 設定 預期文章、圖片、公開結果、隱私排除、證據帳本 決定 runner 要檢查什麼,以及哪些宣稱被禁止
src/triage_rag/release_audit.py src/ 保存可重複呼叫與測試的 Python 核心 repo 根目錄、發布契約、版本控制候選路徑 建立穩定的公開稽核物件,不執行模型
scripts/run_day30_release_audit.py scripts/ 保存讀者可執行的命令入口 設定、文章、圖片、Day 15–29 公開 JSON 與 Git 候選清單 寫出公開摘要、本機完整結果與 run manifest
tests/test_release_audit.py tests/ 保存自動化檢查 真實 repo 與刻意破壞的契約副本 驗證 30 篇文章、16:9、隱私排除與臨床邊界
scripts/figures/day-30/generate_day30_figures.py scripts/figures/day-30/ 保存可重建技術圖的 Python 程式 Day 30 公開稽核 JSON;首次預覽可用同一契約與函式 產生三張 1920×1080 繁體中文技術圖

results/public/day-30-release-audit.json 是可提交的公開摘要;它只保存檔案數、檢查狀態、證據帳本與限制。results/runs/day-30/<run_id>/ 則保存每次完整稽核與 run manifest,由 Git 忽略。

可重現性不代表每個人都會在不同硬體上得到位元完全相同的所有模型輸出,但至少要讓人知道使用哪份資料、設定、程式、模型與結果,並能辨識哪些部分預期穩定。機器學習可重現性檢查也強調公開程式、資料取得、相依環境與實驗細節的重要性。Pineau 等人:Improving Reproducibility in Machine Learning Research

先在自己的專案資料夾建立本篇完整檔案

接下來不會要求你前往任何程式碼網站。請在自己的電腦開啟專案資料夾,依下列順序建立檔案;每個程式碼區塊都是該檔案的完整內容,不含省略號。

本篇沿用 Day 13 的 Poetry 與 run manifest 工具、Day 15–29 的十五份公開結果、Day 29 已完成的文章與私人資料邊界,以及 Day 01–29 的圖片資產。以下提供修改後的完整 .gitignore、Day 30 發布契約、release audit 核心、runner、十項測試與三張技術圖生成程式。公開稽核與 run manifest 由 runner 自動產生;Image 2 開場圖是文章資產,不是發布稽核的病患或模型輸入。

先從專案根目錄建立需要的資料夾:

mkdir -p configs/release src/triage_rag scripts scripts/figures/day-30 tests results/public results/runs/day-30 articles/assets/day-30

如果指令沒有印出訊息是正常的。可用 test -d 資料夾路徑 && echo "資料夾已建立" 驗證單一資料夾。接著使用你熟悉的文字編輯器新增各檔案,把對應區塊完整貼入後儲存。

檔案 1:建立 .gitignore

保留原始資料、逐筆處理資料、run 產物、虛擬環境與金鑰排除,並新增 macOS .DS_Store 排除。

請在文字編輯器建立 .gitignore,貼入以下完整內容並儲存:

# 本地參考文件與受限制資料
*.pdf
data/raw/
data/interim/
data/processed/

# 本地執行產物
results/runs/
.venv/
poetry.toml
__pycache__/
*.pyc
*.egg-info/
.DS_Store

# 本機 API 金鑰與環境變數
.env
.env.*
!.env.example

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 2:建立 configs/release/day-30-release-contract.json

鎖定 Day 01–30 文章、至少三張 16:9 圖片、公開庫存、隱私排除、五題證據帳本、禁止宣稱與四階段下一步。

請在文字編輯器建立 configs/release/day-30-release-contract.json,貼入以下完整內容並儲存:

{
  "schema_version": 1,
  "experiment_id": "day-30-reproducible-release-audit",
  "scope": "offline_repository_release_audit_not_clinical_validation",
  "article_contract": {
    "article_glob": "articles/day-*.md",
    "first_day": 1,
    "last_day": 30,
    "minimum_images_per_article": 3,
    "raw_asset_url_prefix": "https://raw.githubusercontent.com/nickchen1998/ithelp-2026-sideproject30/main/",
    "complete_code_days": [
      8,
      9,
      11,
      12,
      13,
      14,
      15,
      16,
      17,
      18,
      19,
      20,
      21,
      22,
      23,
      24,
      25,
      26,
      27,
      28,
      29,
      30
    ]
  },
  "image_contract": {
    "asset_glob": "articles/assets/day-*/*.png",
    "allowed_dimensions": [
      [1920, 1080],
      [1600, 900],
      [1280, 720]
    ],
    "aspect_ratio": [16, 9],
    "minimum_total_assets": 120,
    "all_assets_must_be_referenced": true
  },
  "inventory_contract": {
    "required_paths": [
      ".python-version",
      ".gitignore",
      "pyproject.toml",
      "poetry.lock",
      "README.md",
      "AGENTS.md",
      "data/README.md",
      "results/README.md",
      "prompts/day-21-flat-basic-rag-system.txt",
      "prompts/day-25-grounded-generation-system.txt",
      "configs/evaluation/day-29-error-audit.json",
      "results/public/day-29-error-audit.json"
    ],
    "patterns": {
      "config_json": "configs/**/*.json",
      "public_upstream_results": "results/public/day-*.json",
      "test_modules": "tests/test_*.py",
      "day_run_scripts": "scripts/run_day*.py",
      "figure_generators": "scripts/figures/day-*/generate*.py",
      "prompt_files": "prompts/*",
      "public_knowledge_files": "data/knowledge/**/*"
    },
    "minimum_counts": {
      "config_json": 25,
      "public_upstream_results": 15,
      "test_modules": 18,
      "day_run_scripts": 13,
      "figure_generators": 30,
      "prompt_files": 2,
      "public_knowledge_files": 6
    },
    "upstream_public_result_paths": [
      "results/public/day-15-data-quality-and-splits.json",
      "results/public/day-16-knowledge-coverage.json",
      "results/public/day-17-chunking-and-metadata.json",
      "results/public/day-18-numeric-embedding-smoke.json",
      "results/public/day-19-retrieval-benchmark.json",
      "results/public/day-20-cohere-rerank-benchmark.json",
      "results/public/day-21-flat-basic-rag.json",
      "results/public/day-22-hierarchical-rag.json",
      "results/public/day-23-vital-hard-gate.json",
      "results/public/day-24-safety-union-gate.json",
      "results/public/day-25-grounded-output-and-abstention.json",
      "results/public/day-26-simple-baselines.json",
      "results/public/day-27-ablation-readiness.json",
      "results/public/day-28-safety-statistics.json",
      "results/public/day-29-error-audit.json"
    ]
  },
  "privacy_contract": {
    "required_gitignore_patterns": [
      "data/raw",
      "data/interim",
      "data/processed",
      "results/runs",
      ".venv",
      ".env",
      ".env.*",
      ".DS_Store"
    ],
    "forbidden_version_control_prefixes": [
      "data/raw/",
      "data/interim/",
      "data/processed/",
      "results/runs/"
    ],
    "forbidden_version_control_basenames": [
      ".env",
      ".DS_Store"
    ],
    "forbidden_public_json_keys": [
      "Chief_complain",
      "chief_complaint",
      "KTAS_RN",
      "KTAS_expert"
    ]
  },
  "evidence_ledger": [
    {
      "research_question_id": "RQ1",
      "question": "加入公開 KTAS 知識檢索,是否比相同模型的無檢索版本改善五級分類?",
      "status": "not_answered_on_patient_oof",
      "available_evidence": "Day 21–22 只有六筆公開知識題的工程比較;Day 26 的病患 OOF 只有 B0、B1、B2。",
      "allowed_claim": "可以說明 Flat 與 Hierarchical RAG 工程管線已可執行,不能宣稱 RAG 改善病患五級分類。",
      "next_required_evidence": "在完全相同 folds 與生成模型下完成 B4、B5 與 P0 的病患 OOF,並保存逐筆 Evidence Trace。"
    },
    {
      "research_question_id": "RQ2",
      "question": "原始、語意化與雙重數值表示如何影響檢索與分類?",
      "status": "engineering_smoke_only",
      "available_evidence": "Day 18 只有一筆合成檢索診斷,三種表示都能產生向量。",
      "allowed_claim": "可以說明表示轉換與 embedding 契約可重跑,不能排名表示法或推論分類效果。",
      "next_required_evidence": "先取得可追溯數值規則與固定檢索標註,再完成表示 A 的病患 OOF 控制比較。"
    },
    {
      "research_question_id": "RQ3",
      "question": "平面與階層知識結構是否造成可測量差異?",
      "status": "component_pair_only",
      "available_evidence": "Day 22 在六筆公開知識題上做同次配對,Flat 通過 6/6、Hierarchical 通過 5/6。",
      "allowed_claim": "可以描述 Parent Top-2 在一題漏掉必要父層,不能外推病患分類或一般架構優劣。",
      "next_required_evidence": "固定其他元件,在相同病患 folds 完成 Flat 與 Hierarchical OOF 配對。"
    },
    {
      "research_question_id": "RQ4",
      "question": "生命徵象硬門控、軟門控與安全候選聯集如何影響錯誤方向?",
      "status": "synthetic_engineering_only",
      "available_evidence": "Day 23–24 使用六到八筆作者合成案例驗證候選排除、保留與排序契約。",
      "allowed_claim": "可以說明 Hard Gate 有不可逆排除風險,Safety Union 會保留 Base;不能把合成方向計數稱為安全率。",
      "next_required_evidence": "完成臨床審查過的規則、病患 OOF 與高急迫級配對安全分析。"
    },
    {
      "research_question_id": "RQ5",
      "question": "結論對資料切分、缺失值、主訴正規化與拒答門檻是否穩健?",
      "status": "planned_not_executed",
      "available_evidence": "B0/B2 已共用五次重複五折;P0 敏感度與 risk–coverage 病患結果仍是 null。",
      "allowed_claim": "可以說明簡單基準在固定 repeats 上的探索性變化,不能宣稱完整方法穩健。",
      "next_required_evidence": "在未碰觸的內層驗證或外部資料上事前鎖定門檻,完成 P0 敏感度分析。"
    }
  ],
  "release_tiers": [
    {
      "tier": "public_now",
      "name": "可直接公開與檢查",
      "contents": [
        "30 篇系列文章與 16:9 圖片",
        "設定、核心程式、執行入口與自動化測試",
        "部分公開 KTAS 知識庫、提示詞與合成 fixtures",
        "Day 15–30 不含逐筆病患資料的公開結果"
      ]
    },
    {
      "tier": "local_reproduction_required",
      "name": "需依授權在本機重跑",
      "contents": [
        "Kaggle 原始資料與處理後逐筆資料",
        "Day 26 的 6,335 列 OOF 與 Day 29 私人固定案例",
        "Ollama 模型權重、完整向量與逐次 run manifest",
        "Cohere API Key、回應快取與逐次網路狀態"
      ]
    },
    {
      "tier": "future_evidence",
      "name": "尚未完成,不能由現有產物替代",
      "contents": [
        "B3 到 P0 的同 folds 病患 OOF 與完整 Evidence Trace",
        "確認性分析方案、外部資料與病患層級相依性處理",
        "前瞻性靜默驗證、人因與工作流程評估",
        "臨床影響研究、持續監測與變更治理"
      ]
    }
  ],
  "claim_policy": {
    "allowed": [
      "本系列建立了一條可重跑、可追溯且明示隱私邊界的研究工程路徑。",
      "B0 與 B2 在同一份 1,267 筆紀錄上完成探索性配對 OOF;結果只適用於這份資料與鎖定流程。",
      "Day 18–25 的元件結果只代表其各自的公開知識題或合成工程契約。"
    ],
    "forbidden": [
      "RAG 已改善 KTAS 病患五級分類。",
      "P0 已比簡單基準更安全或已具臨床效益。",
      "本系統可以取代護理師、醫師或正式檢傷流程。",
      "同一資料的交叉驗證等同外部驗證或臨床部署證據。",
      "KTAS 結果可直接改稱 TTAS、ESI 或其他制度結果。"
    ]
  },
  "next_step_roadmap": [
    {
      "stage": 1,
      "name": "補齊病患 RAG 比較",
      "entry_condition": "凍結公開知識版本、prompt、候選、模型與 folds。",
      "exit_evidence": "B3–P0 配對 OOF、逐筆證據追蹤紀錄與失敗即關閉測試。"
    },
    {
      "stage": 2,
      "name": "鎖定確認性與外部驗證",
      "entry_condition": "不再用 Day 26 已觀察 OOF 調整閾值或挑模型。",
      "exit_evidence": "事前方案、病患層級或群組相依處理、跨時間或跨院資料。"
    },
    {
      "stage": 3,
      "name": "前瞻性靜默與人因評估",
      "entry_condition": "離線安全護欄與資料治理先通過臨床、資安與倫理審查。",
      "exit_evidence": "不影響照護的靜默結果、警示負擔、人工接手與失效模式。"
    },
    {
      "stage": 4,
      "name": "受控臨床影響研究",
      "entry_condition": "明確定義 intended use、使用者、版本、停用條件與監測責任。",
      "exit_evidence": "病患與流程結果、安全事件、人機互動與版本治理的前瞻性證據。"
    }
  ],
  "outputs": {
    "public_summary_path": "results/public/day-30-release-audit.json",
    "run_output_root": "results/runs/day-30",
    "full_result_filename": "day-30-release-audit.json"
  }
}

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 3:建立 src/triage_rag/release_audit.py

使用標準函式庫稽核文章、Markdown 圖片、PNG 尺寸、完整程式碼標記、公開 JSON、gitignore 與證據邊界。

請在文字編輯器建立 src/triage_rag/release_audit.py,貼入以下完整內容並儲存:

"""Audit the public Day 30 repository release without running clinical models."""

from __future__ import annotations

import json
import re
import struct
from pathlib import Path
from typing import Any, Iterable, Mapping
from urllib.parse import unquote, urlparse


JsonObject = dict[str, Any]
ARTICLE_PATTERN = re.compile(r"^day-(\d{2})-.*\.md$")
IMAGE_PATTERN = re.compile(r"!\[([^\]]+)\]\(([^)]+)\)")
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"


class ReleaseAuditError(ValueError):
    """Raised when the Day 30 release contract is internally inconsistent."""


def _require(condition: bool, message: str) -> None:
    if not condition:
        raise ReleaseAuditError(message)


def _read_json(path: Path) -> Any:
    with path.open("r", encoding="utf-8") as handle:
        return json.load(handle)


def _png_dimensions(path: Path) -> tuple[int, int]:
    with path.open("rb") as handle:
        header = handle.read(24)
    if len(header) != 24 or header[:8] != PNG_SIGNATURE or header[12:16] != b"IHDR":
        raise ReleaseAuditError(f"不是可辨識的 PNG:{path}")
    return struct.unpack(">II", header[16:24])


def _normalise_ignore_pattern(value: str) -> str:
    return value.strip().rstrip("/")


def _json_key_paths(value: Any, prefix: str = "") -> Iterable[str]:
    if isinstance(value, dict):
        for key, child in value.items():
            current = f"{prefix}.{key}" if prefix else str(key)
            yield current
            yield from _json_key_paths(child, current)
    elif isinstance(value, list):
        for index, child in enumerate(value):
            yield from _json_key_paths(child, f"{prefix}[{index}]")


def validate_release_contract(contract: Mapping[str, Any]) -> JsonObject:
    """Validate the fixed release, privacy, evidence, and claim boundaries."""

    _require(contract.get("schema_version") == 1, "只支援 schema_version=1")
    _require(
        contract.get("scope")
        == "offline_repository_release_audit_not_clinical_validation",
        "scope 不得把 repository audit 寫成臨床驗證",
    )
    article = contract.get("article_contract", {})
    first_day = int(article.get("first_day", 0))
    last_day = int(article.get("last_day", 0))
    _require(first_day == 1 and last_day == 30, "文章範圍必須固定為 Day 01–30")
    _require(
        int(article.get("minimum_images_per_article", 0)) >= 3,
        "每篇文章至少需要三張圖片",
    )
    image = contract.get("image_contract", {})
    _require(image.get("aspect_ratio") == [16, 9], "圖片比例必須固定為 16:9")
    allowed = [tuple(item) for item in image.get("allowed_dimensions", [])]
    _require(bool(allowed), "必須列出允許的圖片尺寸")
    _require(
        all(width * 9 == height * 16 for width, height in allowed),
        "所有允許尺寸都必須是 16:9",
    )
    upstream = contract.get("inventory_contract", {}).get(
        "upstream_public_result_paths", []
    )
    _require(len(upstream) == 15, "Day 15–29 應有十五份上游公開結果")
    _require(len(set(upstream)) == len(upstream), "上游公開結果路徑不可重複")
    ledger = list(contract.get("evidence_ledger", []))
    _require(
        [item.get("research_question_id") for item in ledger]
        == ["RQ1", "RQ2", "RQ3", "RQ4", "RQ5"],
        "evidence ledger 必須依序保留 RQ1–RQ5",
    )
    _require(
        all(item.get("status") != "answered" for item in ledger),
        "目前沒有研究問題可標成已由病患 P0 完整回答",
    )
    tiers = [item.get("tier") for item in contract.get("release_tiers", [])]
    _require(
        tiers == ["public_now", "local_reproduction_required", "future_evidence"],
        "release tiers 必須分成公開、本機與未來證據三層",
    )
    forbidden_claims = contract.get("claim_policy", {}).get("forbidden", [])
    _require(len(forbidden_claims) >= 5, "必須明列至少五項禁止宣稱")
    return {
        "schema_and_scope_valid": True,
        "day_range_locked_to_01_30": True,
        "image_policy_locked_to_16_9": True,
        "five_research_questions_preserved": True,
        "release_tiers_separated": True,
        "clinical_overclaim_blocked": True,
    }


def _resolve_article_asset(
    root: Path,
    article_path: Path,
    link: str,
    raw_prefix: str,
) -> Path | None:
    if link.startswith(raw_prefix):
        relative = unquote(link[len(raw_prefix) :])
        candidate = root / relative
    elif urlparse(link).scheme:
        return None
    else:
        candidate = article_path.parent / unquote(link)
    candidate = candidate.resolve()
    resolved_root = root.resolve()
    if candidate != resolved_root and resolved_root not in candidate.parents:
        return None
    return candidate


def _article_and_image_audit(root: Path, contract: Mapping[str, Any]) -> JsonObject:
    article_contract = contract["article_contract"]
    image_contract = contract["image_contract"]
    article_paths = sorted(root.glob(str(article_contract["article_glob"])))
    expected_days = list(
        range(
            int(article_contract["first_day"]),
            int(article_contract["last_day"]) + 1,
        )
    )
    article_by_day: dict[int, Path] = {}
    issues: list[str] = []
    referenced_assets: set[Path] = set()
    image_reference_count = 0
    per_day: list[JsonObject] = []

    for path in article_paths:
        match = ARTICLE_PATTERN.match(path.name)
        if not match:
            issues.append(f"無法解析文章日次:{path.relative_to(root)}")
            continue
        day = int(match.group(1))
        if day in article_by_day:
            issues.append(f"Day {day:02d} 有重複文章")
        article_by_day[day] = path

    observed_days = sorted(article_by_day)
    if observed_days != expected_days:
        issues.append(f"文章日次不完整:{observed_days}")

    raw_prefix = str(article_contract["raw_asset_url_prefix"])
    minimum_images = int(article_contract["minimum_images_per_article"])
    complete_code_days = set(int(day) for day in article_contract["complete_code_days"])
    for day in expected_days:
        path = article_by_day.get(day)
        if path is None:
            continue
        text = path.read_text(encoding="utf-8")
        images = IMAGE_PATTERN.findall(text)
        image_reference_count += len(images)
        day_issues: list[str] = []
        if len(images) < minimum_images:
            day_issues.append(f"圖片只有 {len(images)} 張")
        for alt, link in images:
            if not alt.strip():
                day_issues.append("圖片缺少替代文字")
            asset = _resolve_article_asset(root, path, link.strip(), raw_prefix)
            if asset is None:
                day_issues.append(f"圖片不是可對應的 repo 資產:{link}")
                continue
            expected_prefix = (root / f"articles/assets/day-{day:02d}").resolve()
            if expected_prefix not in asset.parents:
                day_issues.append(f"圖片不在本日資產目錄:{link}")
            if not asset.is_file():
                day_issues.append(f"找不到圖片:{asset.relative_to(root)}")
            else:
                referenced_assets.add(asset)
        if day in complete_code_days:
            begin = f"<!-- BEGIN COMPLETE CODE DAY-{day:02d} -->"
            end = f"<!-- END COMPLETE CODE DAY-{day:02d} -->"
            if text.count(begin) != 1 or text.count(end) != 1:
                day_issues.append("完整程式碼同步標記缺失或重複")
        issues.extend(f"Day {day:02d}:{item}" for item in day_issues)
        per_day.append(
            {
                "day": day,
                "article_path": str(path.relative_to(root)),
                "image_reference_count": len(images),
                "passed": not day_issues,
                "issues": day_issues,
            }
        )

    asset_paths = sorted(path.resolve() for path in root.glob(image_contract["asset_glob"]))
    allowed_dimensions = {
        tuple(item) for item in image_contract["allowed_dimensions"]
    }
    invalid_dimensions: list[JsonObject] = []
    for path in asset_paths:
        width, height = _png_dimensions(path)
        if (width, height) not in allowed_dimensions or width * 9 != height * 16:
            invalid_dimensions.append(
                {
                    "path": str(path.relative_to(root)),
                    "width": width,
                    "height": height,
                }
            )
    if invalid_dimensions:
        issues.append(f"有 {len(invalid_dimensions)} 張圖片尺寸不符")
    missing_references = sorted(
        str(path.relative_to(root)) for path in set(asset_paths) - referenced_assets
    )
    if image_contract.get("all_assets_must_be_referenced") and missing_references:
        issues.append(f"有 {len(missing_references)} 張資產未被文章引用")
    if len(asset_paths) < int(image_contract["minimum_total_assets"]):
        issues.append(
            f"圖片資產只有 {len(asset_paths)} 張,少於 "
            f"{image_contract['minimum_total_assets']} 張"
        )

    return {
        "passed": not issues,
        "article_count": len(article_paths),
        "observed_days": observed_days,
        "image_reference_count": image_reference_count,
        "image_asset_count": len(asset_paths),
        "referenced_asset_count": len(referenced_assets),
        "unreferenced_assets": missing_references,
        "invalid_image_dimensions": invalid_dimensions,
        "per_day": per_day,
        "issues": issues,
    }


def _inventory_audit(root: Path, contract: Mapping[str, Any]) -> JsonObject:
    inventory = contract["inventory_contract"]
    missing_required = [
        path for path in inventory["required_paths"] if not (root / path).is_file()
    ]
    missing_upstream = [
        path
        for path in inventory["upstream_public_result_paths"]
        if not (root / path).is_file()
    ]
    invalid_json: list[str] = []
    for path in inventory["upstream_public_result_paths"]:
        candidate = root / path
        if candidate.is_file():
            try:
                _read_json(candidate)
            except (OSError, json.JSONDecodeError):
                invalid_json.append(path)
    counts: JsonObject = {}
    below_minimum: JsonObject = {}
    for name, pattern in inventory["patterns"].items():
        if name == "public_upstream_results":
            count = sum(
                1
                for relative_path in inventory["upstream_public_result_paths"]
                if (root / relative_path).is_file()
            )
        else:
            count = sum(1 for path in root.glob(pattern) if path.is_file())
        counts[name] = count
        minimum = int(inventory["minimum_counts"][name])
        if count < minimum:
            below_minimum[name] = {"observed": count, "minimum": minimum}
    return {
        "passed": not missing_required
        and not missing_upstream
        and not invalid_json
        and not below_minimum,
        "required_path_count": len(inventory["required_paths"]),
        "upstream_public_result_count": len(
            inventory["upstream_public_result_paths"]
        ),
        "counts": counts,
        "missing_required_paths": missing_required,
        "missing_upstream_results": missing_upstream,
        "invalid_upstream_json": invalid_json,
        "below_minimum": below_minimum,
    }


def _privacy_audit(
    root: Path,
    contract: Mapping[str, Any],
    version_control_candidates: Iterable[str],
) -> JsonObject:
    privacy = contract["privacy_contract"]
    gitignore_path = root / ".gitignore"
    observed_patterns: set[str] = set()
    if gitignore_path.is_file():
        observed_patterns = {
            _normalise_ignore_pattern(line)
            for line in gitignore_path.read_text(encoding="utf-8").splitlines()
            if line.strip() and not line.lstrip().startswith("#")
        }
    required_patterns = {
        _normalise_ignore_pattern(value)
        for value in privacy["required_gitignore_patterns"]
    }
    missing_ignore_patterns = sorted(required_patterns - observed_patterns)

    candidates = sorted(set(str(path).lstrip("./") for path in version_control_candidates))
    forbidden_candidates: list[str] = []
    prefixes = tuple(privacy["forbidden_version_control_prefixes"])
    basenames = set(privacy["forbidden_version_control_basenames"])
    for path in candidates:
        if path.startswith(prefixes) or Path(path).name in basenames:
            forbidden_candidates.append(path)

    forbidden_keys = set(privacy["forbidden_public_json_keys"])
    public_key_violations: list[JsonObject] = []
    for relative_path in contract["inventory_contract"]["upstream_public_result_paths"]:
        path = root / relative_path
        if not path.is_file():
            continue
        payload = _read_json(path)
        found = sorted(
            key_path
            for key_path in _json_key_paths(payload)
            if key_path.rsplit(".", maxsplit=1)[-1].split("[", maxsplit=1)[0]
            in forbidden_keys
        )
        if found:
            public_key_violations.append({"path": relative_path, "key_paths": found})
    return {
        "passed": not missing_ignore_patterns
        and not forbidden_candidates
        and not public_key_violations,
        "version_control_candidate_count": len(candidates),
        "missing_gitignore_patterns": missing_ignore_patterns,
        "forbidden_version_control_candidates": forbidden_candidates,
        "public_json_forbidden_key_violations": public_key_violations,
        "patient_rows_read": False,
        "record_level_results_published": False,
    }


def build_release_report(
    project_root: Path,
    contract: Mapping[str, Any],
    version_control_candidates: Iterable[str],
) -> JsonObject:
    """Build a deterministic public audit of files, boundaries, and claims."""

    root = project_root.resolve()
    contract_checks = validate_release_contract(contract)
    article_images = _article_and_image_audit(root, contract)
    inventory = _inventory_audit(root, contract)
    privacy = _privacy_audit(root, contract, version_control_candidates)
    checks = [
        {
            "check_id": "contract",
            "name": "發布契約與證據邊界",
            "passed": all(contract_checks.values()),
        },
        {
            "check_id": "articles_and_images",
            "name": "30 篇文章、圖片引用與 16:9 尺寸",
            "passed": article_images["passed"],
        },
        {
            "check_id": "repository_inventory",
            "name": "設定、測試、入口、公開結果與知識檔案",
            "passed": inventory["passed"],
        },
        {
            "check_id": "privacy_boundary",
            "name": "逐筆資料、run 產物、金鑰與系統檔排除",
            "passed": privacy["passed"],
        },
    ]
    repository_release_ready = all(item["passed"] for item in checks)
    return {
        "schema_version": 1,
        "experiment_id": contract["experiment_id"],
        "scope": contract["scope"],
        "release_status": {
            "repository_release_contract_passed": repository_release_ready,
            "public_artifact_release_ready": repository_release_ready,
            "p0_patient_oof_complete": False,
            "external_validation_complete": False,
            "prospective_clinical_evaluation_complete": False,
            "clinical_deployment_ready": False,
        },
        "checks": checks,
        "contract_checks": contract_checks,
        "article_and_image_audit": article_images,
        "inventory_audit": inventory,
        "privacy_audit": privacy,
        "evidence_ledger": contract["evidence_ledger"],
        "release_tiers": contract["release_tiers"],
        "claim_policy": contract["claim_policy"],
        "next_step_roadmap": contract["next_step_roadmap"],
        "limitations": [
            "這份稽核只驗證 repository 公開產物、路徑、圖片、隱私排除與宣稱邊界,不重新訓練模型。",
            "Day 15–29 公開 JSON 是聚合或合成工程結果;逐筆病患資料、完整 OOF 與私人案例不在公開釋出內。",
            "B3 到 P0 尚未完成同 folds 病患 OOF;五個研究問題都不能寫成已由完整方法回答。",
            "同一份 1,267 筆紀錄的交叉驗證沒有外部效度,也無法排除同一病患重複就醫造成的相依性。",
            "系統定位為離線研究與決策支援候選,不取代臨床專業判斷,也不是部署、法規或臨床效益證據。",
        ],
    }


__all__ = [
    "ReleaseAuditError",
    "build_release_report",
    "validate_release_contract",
]

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 4:建立 scripts/run_day30_release_audit.py

取得 Git 公開候選路徑,建立穩定公開摘要、本機完整稽核與 run manifest;不執行模型或讀取私人逐筆資料。

請在文字編輯器建立 scripts/run_day30_release_audit.py,貼入以下完整內容並儲存:

#!/usr/bin/env python3
"""Run the Day 30 deterministic repository release audit."""

from __future__ import annotations

import argparse
import platform
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from triage_rag.release_audit import build_release_report
from triage_rag.reproducibility import (
    canonical_json_bytes,
    file_record,
    git_state,
    load_json,
    sha256_bytes,
    write_json,
)


PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONTRACT = "configs/release/day-30-release-contract.json"


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="稽核 Day 30 公開產物、圖片、隱私與宣稱邊界,不執行病患模型。"
    )
    parser.add_argument("--contract", default=DEFAULT_CONTRACT)
    return parser.parse_args()


def _version_control_candidates(excluded_path: str) -> list[str]:
    completed = subprocess.run(
        ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
        cwd=PROJECT_ROOT,
        check=True,
        capture_output=True,
        text=True,
    )
    return sorted(
        path
        for path in completed.stdout.splitlines()
        if path and path != excluded_path
    )


def _input_paths(contract_path: str, contract: dict[str, Any]) -> list[str]:
    paths = [
        contract_path,
        ".gitignore",
        *contract["inventory_contract"]["required_paths"],
        *contract["inventory_contract"]["upstream_public_result_paths"],
    ]
    paths.extend(
        str(path.relative_to(PROJECT_ROOT))
        for path in sorted(PROJECT_ROOT.glob(contract["article_contract"]["article_glob"]))
        if path.is_file()
    )
    paths.extend(
        str(path.relative_to(PROJECT_ROOT))
        for path in sorted(PROJECT_ROOT.glob(contract["image_contract"]["asset_glob"]))
        if path.is_file()
    )
    return list(dict.fromkeys(paths))


def _failure_message(report: dict[str, Any]) -> str:
    messages: list[str] = []
    messages.extend(report["article_and_image_audit"]["issues"])
    inventory = report["inventory_audit"]
    if inventory["missing_required_paths"]:
        messages.append(f"缺少必要檔:{inventory['missing_required_paths']}")
    if inventory["missing_upstream_results"]:
        messages.append(f"缺少上游結果:{inventory['missing_upstream_results']}")
    if inventory["below_minimum"]:
        messages.append(f"庫存低於契約:{inventory['below_minimum']}")
    privacy = report["privacy_audit"]
    if privacy["missing_gitignore_patterns"]:
        messages.append(f".gitignore 缺少:{privacy['missing_gitignore_patterns']}")
    if privacy["forbidden_version_control_candidates"]:
        messages.append(
            "版本控制候選含禁止檔:"
            f"{privacy['forbidden_version_control_candidates']}"
        )
    if privacy["public_json_forbidden_key_violations"]:
        messages.append(
            "公開 JSON 出現禁止逐筆欄位:"
            f"{privacy['public_json_forbidden_key_violations']}"
        )
    return ";".join(messages) or "未知發布契約錯誤"


def main() -> int:
    args = parse_args()
    contract_path = PROJECT_ROOT / args.contract
    contract = load_json(contract_path)
    public_relative = contract["outputs"]["public_summary_path"]
    candidates = _version_control_candidates(public_relative)
    report = build_release_report(PROJECT_ROOT, contract, candidates)
    if not report["release_status"]["repository_release_contract_passed"]:
        raise ValueError(f"Day 30 發布契約未通過:{_failure_message(report)}")

    stable_sha256 = sha256_bytes(canonical_json_bytes(report))
    public_path = PROJECT_ROOT / public_relative
    write_json(public_path, report)

    started_at = datetime.now(timezone.utc)
    run_id = f"{started_at.strftime('%Y%m%dT%H%M%S%fZ')}-{stable_sha256[:8]}"
    run_directory = PROJECT_ROOT / contract["outputs"]["run_output_root"] / run_id
    run_directory.mkdir(parents=True, exist_ok=False)
    full_path = run_directory / contract["outputs"]["full_result_filename"]
    write_json(full_path, report)

    input_paths = _input_paths(args.contract, contract)
    candidate_digest = sha256_bytes(
        ("\n".join(candidates) + "\n").encode("utf-8")
    )
    manifest = {
        "manifest_schema_version": 1,
        "run_id": run_id,
        "experiment_id": contract["experiment_id"],
        "started_at_utc": started_at.isoformat().replace("+00:00", "Z"),
        "command": [sys.executable, *sys.argv],
        "documented_command": [
            "poetry",
            "run",
            "python",
            "scripts/run_day30_release_audit.py",
        ],
        "git": git_state(PROJECT_ROOT),
        "runtime": {"python": platform.python_version()},
        "parameters": {
            "article_day_range": [1, 30],
            "minimum_images_per_article": contract["article_contract"][
                "minimum_images_per_article"
            ],
            "version_control_candidate_count": len(candidates),
            "version_control_candidate_path_sha256": candidate_digest,
            "patient_rows_read": False,
            "models_executed": False,
            "network_requests_made": False,
        },
        "inputs": [file_record(PROJECT_ROOT, path) for path in input_paths],
        "outputs": [
            file_record(PROJECT_ROOT, public_relative),
            file_record(PROJECT_ROOT, str(full_path.relative_to(PROJECT_ROOT))),
        ],
        "scope": contract["scope"],
        "privacy": "只稽核 repo 公開候選路徑與 Day 15–29 公開聚合;不讀取 data/interim、data/processed 或 results/runs 的既有逐筆內容。",
    }
    write_json(run_directory / "run-manifest.json", manifest)

    article = report["article_and_image_audit"]
    inventory = report["inventory_audit"]
    print("Day 30 可重現發布稽核:通過")
    print(
        f"文章:{article['article_count']} 篇;"
        f"圖片引用:{article['image_reference_count']};"
        f"16:9 資產:{article['image_asset_count']}"
    )
    print(
        "上游公開結果:"
        f"{inventory['upstream_public_result_count']} 份;"
        f"研究問題完整回答:0/{len(report['evidence_ledger'])}"
    )
    print(f"公開摘要:{public_relative}")
    print(f"執行目錄:{run_directory.relative_to(PROJECT_ROOT)}")
    print(f"穩定摘要:{stable_sha256}")
    print("臨床邊界:P0 病患 OOF、外部驗證與前瞻性評估皆未完成。")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 5:建立 tests/test_release_audit.py

以十項測試驗證離線 scope、三圖下限、16:9、30 篇文章、公開庫存、隱私排除、五題狀態與臨床未就緒。

請在文字編輯器建立 tests/test_release_audit.py,貼入以下完整內容並儲存:

from __future__ import annotations

import copy
import json
import subprocess
import unittest
from pathlib import Path

from triage_rag.release_audit import (
    ReleaseAuditError,
    build_release_report,
    validate_release_contract,
)


PROJECT_ROOT = Path(__file__).resolve().parents[1]
CONTRACT_PATH = PROJECT_ROOT / "configs/release/day-30-release-contract.json"


class ReleaseAuditTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls) -> None:
        cls.contract = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
        output_path = cls.contract["outputs"]["public_summary_path"]
        completed = subprocess.run(
            ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
            cwd=PROJECT_ROOT,
            check=True,
            capture_output=True,
            text=True,
        )
        cls.candidates = [
            path
            for path in completed.stdout.splitlines()
            if path and path != output_path
        ]
        cls.report = build_release_report(
            PROJECT_ROOT, cls.contract, cls.candidates
        )

    def test_release_contract_keeps_offline_scope(self) -> None:
        checks = validate_release_contract(self.contract)
        self.assertTrue(all(checks.values()))

    def test_release_contract_rejects_clinical_scope(self) -> None:
        contract = copy.deepcopy(self.contract)
        contract["scope"] = "clinical_validation"
        with self.assertRaises(ReleaseAuditError):
            validate_release_contract(contract)

    def test_release_contract_requires_three_images_per_article(self) -> None:
        contract = copy.deepcopy(self.contract)
        contract["article_contract"]["minimum_images_per_article"] = 2
        with self.assertRaises(ReleaseAuditError):
            validate_release_contract(contract)

    def test_release_contract_rejects_non_widescreen_dimensions(self) -> None:
        contract = copy.deepcopy(self.contract)
        contract["image_contract"]["allowed_dimensions"].append([1000, 1000])
        with self.assertRaises(ReleaseAuditError):
            validate_release_contract(contract)

    def test_all_thirty_articles_and_images_pass(self) -> None:
        audit = self.report["article_and_image_audit"]
        self.assertTrue(audit["passed"], audit["issues"])
        self.assertEqual(audit["article_count"], 30)
        self.assertGreaterEqual(audit["image_reference_count"], 120)
        self.assertEqual(audit["image_asset_count"], audit["referenced_asset_count"])

    def test_inventory_and_upstream_public_results_pass(self) -> None:
        audit = self.report["inventory_audit"]
        self.assertTrue(audit["passed"], audit)
        self.assertEqual(audit["upstream_public_result_count"], 15)
        self.assertGreaterEqual(audit["counts"]["test_modules"], 18)

    def test_publication_privacy_boundary_passes(self) -> None:
        audit = self.report["privacy_audit"]
        self.assertTrue(audit["passed"], audit)
        self.assertFalse(audit["patient_rows_read"])
        self.assertFalse(audit["record_level_results_published"])

    def test_privacy_audit_rejects_sensitive_candidate(self) -> None:
        report = build_release_report(
            PROJECT_ROOT,
            self.contract,
            [*self.candidates, "results/runs/day-29/private.json"],
        )
        self.assertFalse(report["privacy_audit"]["passed"])
        self.assertIn(
            "results/runs/day-29/private.json",
            report["privacy_audit"]["forbidden_version_control_candidates"],
        )

    def test_all_research_questions_keep_limited_status(self) -> None:
        statuses = {
            item["research_question_id"]: item["status"]
            for item in self.report["evidence_ledger"]
        }
        self.assertEqual(set(statuses), {"RQ1", "RQ2", "RQ3", "RQ4", "RQ5"})
        self.assertNotIn("answered", statuses.values())

    def test_repository_release_does_not_imply_clinical_readiness(self) -> None:
        status = self.report["release_status"]
        self.assertTrue(status["repository_release_contract_passed"])
        self.assertFalse(status["p0_patient_oof_complete"])
        self.assertFalse(status["external_validation_complete"])
        self.assertFalse(status["prospective_clinical_evaluation_complete"])
        self.assertFalse(status["clinical_deployment_ready"])


if __name__ == "__main__":
    unittest.main()

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 6:建立 scripts/figures/day-30/generate_day30_figures.py

讀取 Day 30 公開稽核,重建三階段成果、發布檢查與下一步路線三張 1920×1080 繁中技術圖。

請在文字編輯器建立 scripts/figures/day-30/generate_day30_figures.py,貼入以下完整內容並儲存:

#!/usr/bin/env python3
"""Generate Day 30 release figures from the public audit or its source contract."""

from __future__ import annotations

import json
import subprocess
from pathlib import Path
from typing import Any

from PIL import Image, ImageDraw, ImageFont

from triage_rag.release_audit import build_release_report


ROOT = Path(__file__).resolve().parents[3]
RESULT_PATH = ROOT / "results/public/day-30-release-audit.json"
CONTRACT_PATH = ROOT / "configs/release/day-30-release-contract.json"
OUTPUT_DIRECTORY = ROOT / "articles/assets/day-30"
FONT_PATH = Path("/System/Library/Fonts/STHeiti Medium.ttc")

WIDTH, HEIGHT = 1920, 1080
NAVY = "#102A43"
BLUE = "#2869AE"
TEAL = "#168C8C"
ORANGE = "#EA8A2F"
RED = "#CC5157"
YELLOW = "#E9B949"
GRAY = "#66788A"
LIGHT = "#F6F9FC"
WHITE = "#FFFFFF"
INK = "#25384A"
MUTED = "#607386"
GRID = "#CCD9E5"
PALE_BLUE = "#EAF2FA"
PALE_TEAL = "#E7F5F3"
PALE_ORANGE = "#FFF1DF"
PALE_RED = "#FBEAEC"


def load_json(path: Path) -> dict[str, Any]:
    with path.open(encoding="utf-8") as handle:
        return json.load(handle)


def font(size: int) -> ImageFont.FreeTypeFont:
    return ImageFont.truetype(str(FONT_PATH), size=size)


def canvas() -> tuple[Image.Image, ImageDraw.ImageDraw]:
    image = Image.new("RGB", (WIDTH, HEIGHT), LIGHT)
    return image, ImageDraw.Draw(image)


def header(draw: ImageDraw.ImageDraw, heading: str, subtitle: str) -> None:
    draw.text((90, 55), heading, fill=NAVY, font=font(54))
    draw.text((92, 128), subtitle, fill=MUTED, font=font(27))
    draw.rounded_rectangle((90, 185, 1830, 192), radius=4, fill=TEAL)


def rounded_box(
    draw: ImageDraw.ImageDraw,
    box: tuple[int, int, int, int],
    *,
    fill: str = WHITE,
    outline: str = GRID,
    width: int = 3,
    radius: int = 24,
) -> None:
    draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)


def centered(
    draw: ImageDraw.ImageDraw,
    box: tuple[int, int, int, int],
    text: str,
    *,
    size: int,
    fill: str = INK,
) -> None:
    bounds = draw.textbbox((0, 0), text, font=font(size))
    x = box[0] + (box[2] - box[0] - (bounds[2] - bounds[0])) / 2
    y = box[1] + (box[3] - box[1] - (bounds[3] - bounds[1])) / 2
    draw.text((x, y), text, fill=fill, font=font(size))


def wrap_text(
    draw: ImageDraw.ImageDraw, text: str, max_width: int, size: int
) -> list[str]:
    lines: list[str] = []
    current = ""
    for character in text:
        candidate = current + character
        bounds = draw.textbbox((0, 0), candidate, font=font(size))
        if current and bounds[2] - bounds[0] > max_width:
            lines.append(current)
            current = character
        else:
            current = candidate
    if current:
        lines.append(current)
    return lines


def arrow(
    draw: ImageDraw.ImageDraw,
    start: tuple[int, int],
    end: tuple[int, int],
    color: str,
) -> None:
    draw.line((*start, *end), fill=color, width=8)
    draw.polygon(
        [
            (end[0], end[1]),
            (end[0] - 22, end[1] - 14),
            (end[0] - 22, end[1] + 14),
        ],
        fill=color,
    )


def _release_candidates(output_path: str) -> list[str]:
    completed = subprocess.run(
        ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
        cwd=ROOT,
        check=True,
        capture_output=True,
        text=True,
    )
    return [
        path
        for path in completed.stdout.splitlines()
        if path and path != output_path
    ]


def release_report() -> dict[str, Any]:
    if RESULT_PATH.is_file():
        return load_json(RESULT_PATH)
    contract = load_json(CONTRACT_PATH)
    candidates = _release_candidates(contract["outputs"]["public_summary_path"])
    return build_release_report(ROOT, contract, candidates)


def draw_deliverables(result: dict[str, Any]) -> Image.Image:
    image, draw = canvas()
    article = result["article_and_image_audit"]
    inventory = result["inventory_audit"]
    header(
        draw,
        "30 天不是一個模型,而是一條可追溯的研究路徑",
        "每一段都有可公開產物,也都有不能跨越的證據邊界",
    )
    cards = [
        (
            (90, 260, 570, 820),
            "Day 01–10",
            "問題與研究契約",
            BLUE,
            ["五級檢傷與非對稱風險", "RAG 基礎與三種架構", "研究問題、假設與資料角色"],
        ),
        (
            (720, 260, 1200, 820),
            "Day 11–20",
            "資料、知識與檢索",
            TEAL,
            ["資料契約、缺失與固定 folds", "部分公開 KTAS 知識庫", "表示、混合檢索與重排序"],
        ),
        (
            (1350, 260, 1830, 820),
            "Day 21–30",
            "RAG、安全與發布",
            ORANGE,
            ["Flat、Hierarchical 與門控", "基準、統計與錯誤分析", "發布稽核與下一步證據"],
        ),
    ]
    for box, day_range, title, color, bullets in cards:
        rounded_box(draw, box, fill=WHITE, outline=color, width=4)
        draw.rounded_rectangle((box[0], box[1], box[2], box[1] + 100),

上一篇
# Day 29|模型為什麼錯?用 Evidence Trace 寫出可信的結果與限制
系列文
30 天打造公開資料版急診檢傷系統:Side Project 與實驗計畫30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言