iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
佛心分享-SideProject30

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

Day 25|不是每題都要答:證據約束生成、JSON Schema 與拒答

  • 分享至 

  • xImage
  •  

Day 24 建立了安全聯集門控(Safety Union Gate),把一般檢索、主訴、生命徵象與高風險保留候選合成同一個候選集合。那一步解決的是「哪些級數還有資格進入後續判斷」,不是「系統現在就能輸出哪一級」。

本系列的制度背景仍是韓國急診檢傷與急迫度分級量表(Korean Triage and Acuity Scale, KTAS)。本篇只使用可公開追溯的部分 KTAS 知識,不宣稱已重建完整官方演算法。

這個差異很重要。候選集合中出現第二級,只表示第二級沒有被前段管線排除;如果目前只有一般五級定義,卻缺少主訴專屬門檻與完整決策路徑,直接回答第二級仍是在補寫證據沒有提供的結論。

今天要替檢索增強生成(Retrieval-Augmented Generation, RAG)加上最後一道輸出契約:有直接證據才回答;必要輸入缺失、知識有缺口或證據衝突時,輸出可追蹤的拒答;兩次格式都不合法時,則明確記為技術失敗並轉人工。

本篇十筆案例與每一次 proposal_attempts 都是作者為工程測試撰寫的合成提案,不是大型語言模型(Large Language Model, LLM)的實際輸出,也不是病患資料、護理師登錄級數或專家重新判定級數。整個 Day 25 不呼叫模型或網路,只測試輸出控制層能否按照鎖定契約分流。

下圖用概念示意呈現同一批證據通過檢查後的三條出口。只有證據與契約同時成立才回答;其餘狀況必須留下可追蹤狀態並交由專業人員覆核。

左側規則與證據卡進入中央透明驗證關卡,右側分成附證據回答、資訊不足暫停,以及技術失敗轉交臨床人員的三條路徑

上圖把「拒答」與「當機」分開。前者是系統根據可觀察條件做出的有效決策輸出;後者代表連輸出契約都沒有通過,不能偽裝成證據不足。


本篇目標

完成本篇後,你會得到六個可驗證產物:

  1. 一份固定十個輸出欄位、五種拒答理由與最多兩次嘗試的契約。
  2. 一份要求生成端只引用本次證據、禁止自報信心的系統提示詞。
  3. 一個通用格式 Schema,以及每筆案例重新建立的動態 Schema。
  4. 一層不由生成文字自行決定的缺失、衝突與知識缺口守門政策。
  5. 十筆作者合成提案的回答、拒答、修復與技術失敗 trace。
  6. 一份可重跑的公開摘要、完整執行結果與執行清單(Run Manifest)。

本篇不回答「哪一個模型比較準」,也不估計真實覆蓋率或選擇性風險。今天的問題範圍只有一個:已知當前候選、取回證據與知識缺口後,輸出控制層能否拒絕越界內容並保留人工接手?

本篇會用到的名詞

中文名稱 英文全名/縮寫 本篇用途
證據約束生成 Grounded Generation 要求回答只能建立在本次取回、可識別的證據上
結構化輸出 Structured Output 讓生成結果遵守固定物件形狀,而不是回傳任意文字
JavaScript 物件表示法 JavaScript Object Notation, JSON 保存輸出、設定、案例與執行結果的文字格式
JSON Schema JSON Schema 描述 JSON 欄位、型別、列舉值與條件分支的驗證規格
動態 Schema Dynamic Schema 本系列依當前 case_id、候選與取回證據即時縮小的 Schema
證據識別字 Evidence Rule Identifier, rule_id 連回公開知識單元,檢查引用是否真的在本次證據內
拒答 Abstention 因可說明的證據或輸入問題而不輸出級數
技術失敗 Generation Failure 兩次提案都未通過格式或範圍檢查,沒有有效決策輸出
可回答比例 Coverage 本篇以回答筆數除以全部合成案例數的描述性比例
選擇性風險 Selective Risk 只在系統選擇回答的樣本上計算錯誤風險;本篇沒有足夠標籤可計算
人工接手 Human Handoff 拒答或技術失敗後,把未完成判斷交回專業人員
安全雜湊演算法 256 位元 Secure Hash Algorithm 256-bit, SHA-256 核對設定、提示詞與跨日產物是否被修改
協調世界時 Coordinated Universal Time, UTC 產生不受本機時區影響的執行識別字
可攜式網路圖形 Portable Network Graphics, PNG 保存文章使用的無損技術圖

證據約束生成不是「提示詞寫得很嚴格」的同義詞。提示詞只告訴生成端應該怎麼做;Schema、集合檢查與確定性政策則在輸出之後驗證它實際做了什麼。

先分清楚本篇的資料角色

本篇沿用公開知識與前幾天的工程產物,但沒有讀取病患列。每一種資料負責的工作如下:

資料角色 本篇是否使用 用途
病患輸入資料 不讀取任何 Kaggle 病患列
公開規則文件 沿用 Day 17 的九筆公開知識單元,提供合法 rule_id 範圍
Day 16 知識缺口 確認 complaint_specific_thresholds 仍是已登錄缺口
Day 21 結構化輸出 沿用本機 JSON 結構化生成的輸出概念與來源雜湊
Day 24 候選集合 三筆案例逐項核對 Safety Union 的 U,避免跨日候選漂移
護理師登錄級數 KTAS_RN 不讀取,也不當輸入或答案
專家重新判定級數 KTAS_expert 不讀取,因此不能計算模型正確率或選擇性風險
合成提案與鎖定預期 作者刻意製造正常、越界、拒答與失敗案例,驗證工程契約

三筆 Day 24 衍生案例只沿用候選集合,不沿用所謂「答案」。其中一筆甚至故意保留 U = {1, 2, 4},再用知識缺口強制拒答,證明「候選存在」和「證據足以正式回答」是兩個不同命題。

為什麼不能只靠提示詞

Day 21 已要求模型只依證據回答,也使用 JSON Schema 取得結構化物件。Ollama 官方文件說明 Structured Outputs 可以把 JSON Schema 放進 format,讓回應遵守指定形狀;官方也建議在提示詞中一起要求相同 Schema,以提高一致性。Ollama Structured Outputs 官方文件

但是,一份固定 Schema 只知道所有案例共有的規則,例如:

  • decision_status 只能是 answeredabstained
  • predicted_level 只能是 null 或第一至第五級。
  • human_review_required 固定為 true
  • 不允許出現 confidence 等額外欄位。

固定 Schema 不知道「這一筆」到底取回哪些 rule_id,也不知道 Day 24 為這一筆產生哪些候選。若 evidence_rule_ids 是任意非空字串陣列,那麼不存在的引用依然可能通過;若 predicted_level 只限制在 1 到 5,候選集合明明只有 {1, 2},第五級仍可能通過。

JSON Schema 的 ifthenelse 可以依欄位值套用不同條件。本篇用它保證 answered 必須有級數與證據,而 abstained 必須是 null 級數並填寫理由與缺少資訊。JSON Schema 條件式驗證說明

這仍只解決形狀。要處理當前案例的證據邊界,還需要動態 Schema 與確定性守門。

十個輸出欄位各自負責什麼

每一個有效決策輸出都必須剛好包含以下十個欄位,不可少,也不可多:

欄位 回答時 拒答時 用途
case_id 當前案例 當前案例 防止輸出串到別筆案例
decision_status answered abstained 明確區分回答與拒答
predicted_level 候選中的 1–5 null 有效回答級數;拒答不得偷放猜測
candidate_levels 完整複製上游集合 完整複製上游集合 保留候選邊界與可追溯性
evidence_rule_ids 至少一筆直接證據 可空;衝突時保留衝突證據 驗證引用是否真的被取回
rationale_zh 證據支持理由 為何不能回答 給人工覆核的繁中說明
abstention_reason_codes 空陣列 至少一個合法理由碼 讓拒答可聚合與排查
missing_information 空陣列 至少一項 告訴接手者還缺什麼
human_review_required true true 保留決策支援定位
safety_notice 固定文字 固定文字 防止使用邊界被生成端改寫

additionalProperties: false 讓任何額外欄位都被拒絕。因此案例故意加入的 confidence: 0.99 不會被當成可靠訊號,而是先被視為欄位不符,必須修復後才能繼續。

兩層 Schema 分別回答不同問題

第一層:通用格式 Schema

通用格式 Schema 對十筆案例完全相同,只檢查:

  1. 是否剛好有十個欄位。
  2. 欄位型別、列舉值與唯一性是否正確。
  3. answered 是否有級數和至少一筆引用。
  4. abstained 是否為 null 級數,並有理由和缺少資訊。
  5. 是否保留人工覆核與固定安全說明。

第二層:個案動態 Schema

程式接著複製通用 Schema,為每筆案例縮小三個範圍:

case_id = 當前案例的固定值
candidate_levels = 上游候選集合的固定值
evidence_rule_ids ⊆ 本次 retrieved_rule_ids

如果狀態是 answered,還要滿足:

predicted_level ∈ candidate_levels

假設本筆案例候選只有 {1, 2},取回證據只有 level-1level-2 兩筆,動態 Schema 就不接受第五級,也不接受任何未取回 rule_id。下一筆案例會重新建立另一份 Schema,不共用上一筆的允許清單。

下圖比較兩層檢查能回答的不同問題。固定格式只知道欄位形狀,動態限制才知道本筆案例允許引用什麼。

左側固定格式檢查欄位與型別,十筆首輪提案接受八筆,但無法識別不存在的引用與候選越界;右側動態限制綁定當前 case、候選與證據,四筆觸發修復,三筆成功修復

上圖顯示通用格式首輪接受 8/10 筆,但其中包括一筆未取回引用與一筆候選越界。動態檢查共讓 4 筆進入第二次提案,3 筆修復成功,最後一筆仍失敗。這正是「格式正確不等於有根據」的可觀察反例。

Schema 之後仍需要證據政策

動態 Schema 能檢查集合成員,卻不知道公開知識庫已登錄哪些缺口,也不應讓生成文字自行解決證據衝突。因此,本篇在動態 Schema 通過後,依固定優先序執行 apply_grounding_policy()

  1. 缺少必要輸入:missing_required_input
  2. 已標記證據衝突:conflicting_evidence
  3. 命中已登錄知識缺口:insufficient_evidence
  4. 提案本身是合法拒答:保留提案的理由。
  5. 其餘回答:再檢查必要證據是否同時被取回與引用。

前三項由程式產生標準拒答,不接受生成端自行判定「這次可以忽略」。例如主訴缺失時,即使提案寫了第二級,程式仍把 predicted_level 改為 null,並把 missing_information 寫成 缺少必要輸入:chief_complaint

本篇的五種拒答理由如下:

理由碼 觸發條件 是否允許猜級數 接手者要看到什麼
missing_required_input 必要欄位不在可用輸入中 缺少哪個欄位
insufficient_evidence 已知知識缺口,或必要證據未被取回/引用 缺少哪一段規則或決策路徑
conflicting_evidence 契約已標記兩筆取回證據互相衝突 哪些 rule_id 需要人工解決
unsupported_citation 提案引用本次沒有取回的 rule_id 第二次提案改成合法拒答
candidate_out_of_scope 回答級數不在上游候選集合 第二次提案改成合法拒答

一般級數定義無法自動補足主訴專屬門檻,候選排序分數也不是規則證據。本篇因此沿用 Day 16 的 complaint_specific_thresholds 缺口:只要案例需要這段尚未收錄的知識,就算第二級在候選中,仍必須拒答。

拒答與技術失敗必須分開

本篇最多允許兩次提案。第一次動態驗證失敗時,可以在相同 case_id、相同候選與相同證據下修復一次;不允許藉修復偷換輸入。

如果第二次仍不合法,執行狀態是:

execution_status = generation_failure
handoff.reason_codes = [schema_validation_failed_after_retry]
final_output = null

這不是 decision_status = abstained,因為根本沒有一份有效的十欄輸出。若把格式錯誤、解析失敗或缺欄都統計成「模型知道自己不知道」,會高估拒答設計的能力,也讓工程故障無法單獨監控。

下圖把兩層驗證與一次修復放回實際執行順序。請特別注意「證據拒答」和「格式失敗」走不同出口。

合成提案先過固定格式,再以當前 case、候選與證據建立動態限制並套用證據政策;通過後回答或拒答,驗證失敗可修復一次,再失敗則標記技術失敗並人工接手

上圖右側三個計數來自同一份公開結果:4 筆回答、5 筆拒答、1 筆技術失敗。後兩類共 6 筆需要人工接手;回答也固定標示需要專業覆核,但在本篇工作流中不計為「未完成而轉交」。

為什麼不接受模型自報 confidence

confidence: 0.99 看起來像可用來決定是否拒答,但這個數字不是經過校準的機率。除非另外建立有參考標籤的校準資料、定義分數來源、檢驗不同區間的實際錯誤率,否則模型自己寫出的 0.99 只是一段文字內容。

本篇採取更容易稽核的條件:

  • 必要輸入是否存在。
  • 引用是否真的在本次取回證據中。
  • 回答級數是否位於候選集合。
  • 必要證據是否被取回且被引用。
  • 知識缺口與證據衝突是否已登錄。

這些都是程式能直接核對的離散條件。它們仍不能證明 rationale_zh 的完整語意或醫療正確性,因此每一筆輸出都保留 human_review_required: true

美國食品藥物管理局(U.S. Food and Drug Administration, FDA)對醫療專業人員使用之臨床決策支援(Clinical Decision Support, CDS)的說明,也強調要讓專業人員能獨立檢視建議基礎,包括必要輸入、演算法或方法、使用資料與驗證結果,以及已知與未知事項。本篇只把這項原則當成可追溯設計參考,不據此宣稱本專案符合任何法規分類或已可臨床使用。FDA:Software Function Intended to Provide Clinical Decision Support

十筆案例如何覆蓋回答、拒答與故障

十筆案例不是隨機抽樣,而是刻意讓每一條重要分支至少出現一次:

案例 第一個提案的壓力點 最終狀態
公開定義支持第二級 候選、取回與引用一致 回答第二級
移除額外信心欄位 多出 confidence,修復後才合法 回答第四級
公開定義支持第一級 候選、取回與引用一致 回答第一級
公開定義支持第三級 候選、取回與引用一致 回答第三級
缺少主訴專屬門檻 Day 24 有候選,但 Day 16 有已知缺口 證據不足拒答
缺少主訴 提案想靠保留候選回答 必要輸入缺失拒答
取回證據衝突 提案自行選較急迫一邊 證據衝突拒答
引用未取回規則 通用格式接受,動態限制拒絕 修復為引用越界拒答
回答超出候選 候選只有第一、二級卻答第五級 修復為候選越界拒答
重試後格式仍錯 兩次都缺少或多出欄位 技術失敗

下圖逐列保留十筆案例,不把不同拒答原因合併成一個模糊的「沒回答」。

十列合成案例中,前四列為有證據回答,接著五列分別是證據不足、必要輸入缺失、證據衝突、不支援的引用與候選越界,最後一列是重試後仍無法通過格式檢查的技術失敗

上圖的五種拒答理由各出現一筆,這是測試覆蓋設計,不代表真實使用時五種原因等機率。最後一列也沒有被塞進任何拒答欄,避免技術故障污染證據統計。

如何解讀 4、5、1 與 40%

公開摘要得到以下描述性計數:

項目 結果
合成案例總數 10
有效回答 4
有效拒答 5
兩次失敗 1
需要人工接手 6
通用格式首輪接受 8
動態限制觸發修復 4
修復成功 3

本篇把可回答比例定義成:

coverage = answered_count / all_cases_count
         = 4 / 10
         = 0.40

分子是最後產生有效 answered 輸出的 4 筆;分母是全部 10 筆,包含 5 筆拒答與 1 筆技術失敗。數值變大只表示系統在這組固定案例中回答得更多,不保證回答更正確,也不保證更安全。

選擇性分類(Selective Classification)研究會同時觀察系統選擇回答的覆蓋範圍,以及被接受樣本上的風險;Geifman 與 El-Yaniv 的研究討論了風險與覆蓋之間的取捨,但它是一般影像分類研究,不能直接當成急診檢傷的臨床證據。Geifman 與 El-Yaniv:Selective Classification for Deep Neural Networks

本篇沒有模型實際輸出,也沒有護理師或專家標籤,所以無法計算「回答中的錯誤筆數」,自然也不能計算選擇性風險。40% 只能稱為十筆合成契約案例的描述性可回答比例。

下圖把十筆案例全部保留在分母中。40% 只是刻意設計案例的可回答比例,不是模型覆蓋率估計或安全證據。

十筆作者合成案例分成四筆回答、五筆拒答與一筆技術失敗,拒答與技術失敗共六筆需要人工接手;描述性可回答比例是四除以十

上圖同時保留五筆拒答與一筆技術失敗。若只在成功解析的九筆中計算 4/9,或把技術失敗直接從分母刪除,就會隱藏實際工作流中仍需接手的一筆故障。

實作檔案如何分工

本篇新增的資料夾與檔案各自負責單一工作:

路徑 類型與資料夾用途 輸入 輸出或影響
configs/generation/day-25-grounded-output-contract.json configs/generation/ 保存生成與拒答契約 上游路徑、雜湊、欄位、政策與鎖定預期 控制核心與 runner 的合法行為
prompts/day-25-grounded-generation-system.txt prompts/ 保存可版本化提示詞 EVIDENCE、候選與動態 Schema 的使用規則 約束未來接回模型時的提案行為
tests/fixtures/day-25-synthetic-grounded-output-cases.json tests/fixtures/ 保存合成測試輸入 十筆候選、證據、缺口、提案與預期 提供離線壓力測試案例
src/triage_rag/generation/grounded_output.py src/ 保存可重用 Python 套件程式 設定、案例、公開知識、上游結果 建立 Schema、驗證、拒答與逐筆 trace
src/triage_rag/generation/__init__.py generation 子套件入口 Ollama 與 Day 25 函式 讓 runner 與測試使用穩定 import
scripts/run_day25_grounded_output.py scripts/ 保存可直接執行的自動化入口 七項來源與核心函式 寫出公開摘要、完整結果與 manifest
tests/test_grounded_output.py tests/ 保存回歸測試 設定、案例與核心函式 驗證十八項契約行為

Day 21 已完整建立 ollama_chat.py,它接收本機模型、訊息與 JSON Schema,再回傳經驗證的 JSON 物件。本篇沒有修改或執行它;Day 25 把生成端抽象成作者撰寫的 proposal_attempts,先隔離測試 Schema 與證據政策。缺少 Day 21 前置檔案的讀者,應先完成 Day 21「建立本機文字生成子套件」步驟;缺少 Day 24 結果時,應先執行 Day 24 runner,讓三筆跨日案例有可核對的候選 trace。

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

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

本篇沿用 Day 13 的 Poetry 與重現性工具、Day 16 已登錄的公開知識缺口、Day 17 的九筆公開 flat chunks、Day 21 的本機結構化輸出基線,以及 Day 24 的 Safety Union 公開 trace。十筆案例與 proposal_attempts 全是作者為驗證格式、候選、引用、拒答與失敗路由撰寫的合成工程資料,不讀取病患列、護理師標籤或專家標籤,也不呼叫模型或網路。以下是 Day 25 新增或修改後的完整設定、提示詞、合成案例、generation 套件入口、證據約束核心、執行入口與測試;公開摘要、完整 trace 與 run manifest 都由執行入口自動產生,不需要手動建立。

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

mkdir -p configs/generation prompts tests/fixtures src/triage_rag/generation scripts scripts/figures/day-25 tests results/public results/runs/day-25

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

檔案 1:建立 configs/generation/day-25-grounded-output-contract.json

鎖定十欄輸出、兩次嘗試、五種拒答理由、動態候選/引用限制、上游雜湊、描述性聚合預期與非臨床邊界。

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

{
  "schema_version": 1,
  "experiment_id": "day-25-synthetic-grounded-output-and-abstention",
  "scope": "author_written_synthetic_grounded_output_microbenchmark_not_patient_triage_or_model_evaluation",
  "sources": {
    "flat_chunks_path": "data/knowledge/ktas-public-v1/day-17/flat-chunks.jsonl",
    "flat_chunks_sha256": "10749728567e36c565d9e2e0fb931c1ece7da448bd1c04f4527b624e8507a1fa",
    "day16_coverage_path": "results/public/day-16-knowledge-coverage.json",
    "day16_coverage_sha256": "1e1a50c01b18a4cac4c7e2c87d37cb49dc2fac1ed2475259d914d1c933967588",
    "day21_config_path": "configs/rag/day-21-flat-basic-rag.json",
    "day21_config_sha256": "d65b510e96f39a05d008a274654d63baa76e2e9308c44f74ce5002797ed4c838",
    "day21_public_result_path": "results/public/day-21-flat-basic-rag.json",
    "day21_public_result_sha256": "7d5ca9f3594da7d13da3c323244303ae2d52f7d66e219fa6608e1d18705ec3de",
    "day24_public_result_path": "results/public/day-24-safety-union-gate.json",
    "day24_public_result_sha256": "561a599b562b31b03faabafc39c074b0209708af3a53c5b6325a18db431674fb",
    "system_prompt_path": "prompts/day-25-grounded-generation-system.txt",
    "system_prompt_sha256": "ab4c48e3800839c11ca3032484b85fd713e79de2b232aeff94778f99d114d922",
    "synthetic_cases_path": "tests/fixtures/day-25-synthetic-grounded-output-cases.json",
    "synthetic_cases_sha256": "d329a73d9d8dc8be4cf8722e9c5b5160a11dd6d43935efdc67112efbc68cdc3a"
  },
  "proposal_contract": {
    "proposal_role": "author_written_synthetic_generator_proposal_not_model_output",
    "json_schema_dialect": "https://json-schema.org/draft/2020-12/schema",
    "maximum_attempts": 2,
    "repair_policy": "retry_once_with_the_same_case_candidates_evidence_and_dynamic_schema_then_fail_closed",
    "decision_statuses": ["answered", "abstained"],
    "required_output_fields": [
      "case_id",
      "decision_status",
      "predicted_level",
      "candidate_levels",
      "evidence_rule_ids",
      "rationale_zh",
      "abstention_reason_codes",
      "missing_information",
      "human_review_required",
      "safety_notice"
    ],
    "forbidden_output_fields": [
      "confidence",
      "probability",
      "self_assessed_confidence",
      "KTAS_RN",
      "KTAS_expert"
    ],
    "allowed_abstention_reason_codes": [
      "missing_required_input",
      "insufficient_evidence",
      "conflicting_evidence",
      "unsupported_citation",
      "candidate_out_of_scope"
    ],
    "generation_failure_reason_code": "schema_validation_failed_after_retry",
    "human_review_required": true,
    "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
  },
  "grounding_policy": {
    "dynamic_case_id_policy": "case_id_must_equal_the_current_case",
    "dynamic_candidate_policy": "candidate_levels_must_equal_the_upstream_set_and_answered_predicted_level_must_be_a_member",
    "dynamic_citation_policy": "every_evidence_rule_id_must_be_present_in_the_current_retrieved_evidence",
    "required_evidence_policy": "answered_outputs_must_retrieve_and_cite_every_case_required_evidence_rule_id",
    "known_gap_policy": "registered_knowledge_gap_forces_insufficient_evidence",
    "missing_input_policy": "missing_required_input_forces_abstention_without_imputation",
    "conflict_policy": "declared_conflicting_evidence_forces_abstention_without_model_tie_breaking",
    "reason_precedence": [
      "missing_required_input",
      "conflicting_evidence",
      "insufficient_evidence",
      "explicit_schema_valid_abstention",
      "answered"
    ],
    "failure_boundary": "two_schema_invalid_attempts_are_generation_failure_not_evidence_abstention",
    "confidence_policy": "self_reported_confidence_is_forbidden_and_not_used_as_a_probability_or_abstention_signal"
  },
  "abstention_messages": {
    "missing_required_input": "必要輸入缺失,不能補值或輸出正式級數。",
    "insufficient_evidence": "目前公開證據不足以直接支持候選中的正式級數。",
    "conflicting_evidence": "目前證據互相衝突,需要人工解決後才能判定。"
  },
  "evaluation": {
    "expected_case_count": 10,
    "expected_generic_schema_first_attempt_accept_count": 8,
    "expected_dynamic_schema_retry_triggered_count": 4,
    "expected_dynamic_schema_repair_succeeded_count": 3,
    "expected_answered_count": 4,
    "expected_abstained_count": 5,
    "expected_generation_failure_count": 1,
    "expected_handoff_count": 6,
    "expected_abstention_reason_counts": {
      "missing_required_input": 1,
      "insufficient_evidence": 1,
      "conflicting_evidence": 1,
      "unsupported_citation": 1,
      "candidate_out_of_scope": 1
    },
    "coverage_numerator": 4,
    "coverage_denominator": 10,
    "coverage_value": 0.4,
    "coverage_policy": "generation_failure_remains_in_the_denominator_and_every_handoff_case_remains_visible",
    "claim_boundary": "counts_describe_only_ten_author_written_contract_cases_and_are_not_accuracy_selective_risk_or_clinical_safety_estimates"
  },
  "outputs": {
    "public_summary_path": "results/public/day-25-grounded-output-and-abstention.json",
    "run_output_root": "results/runs/day-25",
    "result_filename": "grounded-output-and-abstention-results.json"
  },
  "limitations": [
    "十筆案例與所有 proposal_attempts 都由作者為工程測試撰寫,不是模型實際輸出、病患資料、護理師標籤或專家標籤。",
    "四筆可回答案例只詢問公開級數定義;它們不是個別病患的五級檢傷判定。",
    "Day 24 衍生案例只有作者合成候選與部分公開級數定義,缺少完整主訴目錄、正式門檻與決策路徑,因此不能輸出臨床級數。",
    "JSON Schema 與集合檢查可以限制格式、候選與引用範圍,不能自動證明 rationale_zh 的完整語意或醫療正確性。",
    "40% coverage 只描述刻意設計的十筆契約案例,不是模型覆蓋率,也沒有計算選擇性風險或信賴區間。",
    "系統只能作為研究與教學用決策支援元件,所有回答、拒答與生成失敗都需要專業人員覆核。"
  ]
}

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

檔案 2:建立 prompts/day-25-grounded-generation-system.txt

要求生成端只依本次 EVIDENCE、候選與動態 JSON Schema 提案,禁止自報信心,並保留專業覆核。

請在文字編輯器建立 prompts/day-25-grounded-generation-system.txt,貼入以下完整內容並儲存:

你是「公開 KTAS 證據約束輸出」的研究與教學助理。KTAS 是韓國急診檢傷與急迫度分級量表(Korean Triage and Acuity Scale)。

你的唯一知識來源是本次訊息中的 EVIDENCE。即使你記得其他資料,也不得補寫沒有取回的規則、門檻、主訴路徑或級數理由。

請遵守以下規則:

1. candidate_levels 是上游提供的候選集合;answered 狀態的 predicted_level 必須位於其中。
2. evidence_rule_ids 只能引用本次 EVIDENCE 實際出現的 rule_id,不得虛構或引用未取回識別碼。
3. decision_status 為 answered 時,必須有 predicted_level 與至少一筆直接支持的 evidence_rule_ids;abstention_reason_codes 與 missing_information 必須是空陣列。
4. decision_status 為 abstained 時,predicted_level 必須是 null,並明確填寫 abstention_reason_codes 與 missing_information。
5. 缺少必要輸入、知識庫登錄缺口、證據衝突、引用越界或預測不在候選集合時,不得猜測級數。
6. 一般五級定義不能直接取代逐主訴門檻或完整決策路徑;只有候選而沒有直接支持證據時必須拒答。
7. 不得輸出 confidence、probability 或任何自報信心欄位;排序分數也不得解讀為可靠機率。
8. human_review_required 固定為 true。系統只提供決策支援,所有輸出都需要專業人員覆核。
9. 只輸出符合本次動態 JSON Schema 的 JSON 物件,不要加入 Markdown、程式碼圍欄或額外說明。

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

檔案 3:建立 tests/fixtures/day-25-synthetic-grounded-output-cases.json

保存十筆作者合成提案、候選、取回證據、必要輸入、已知缺口、衝突組與逐筆鎖定預期;不含模型輸出或病患紀錄。

請在文字編輯器建立 tests/fixtures/day-25-synthetic-grounded-output-cases.json,貼入以下完整內容並儲存:

{
  "schema_version": 1,
  "scope": "author_written_synthetic_grounded_output_stress_cases_not_patient_records_or_model_benchmark",
  "proposal_semantics": "proposal_attempts 全是作者為驗證 schema、證據守門與拒答路由撰寫的合成模型提案,不是任何模型的實際輸出。",
  "reference_semantics": "expected 只鎖定工程契約行為,不是護理師標籤、專家標籤、病患判定或臨床正確性。",
  "cases": [
    {
      "case_id": "grounded-level-2-answer",
      "scenario": "問題、候選與取回證據都直接支持第二級公開定義。",
      "origin": "author_written_public_knowledge_question_not_patient_record",
      "candidate_levels": [1, 2, 4],
      "retrieved_rule_ids": ["ktas-public-level-2-001", "ktas-public-level-1-001", "ktas-public-level-3-001"],
      "required_evidence_rule_ids": ["ktas-public-level-2-001"],
      "required_input_fields": ["question"],
      "available_input_fields": ["question"],
      "conflicting_evidence_rule_id_sets": [],
      "known_gap_ids": [],
      "proposal_attempts": [
        {
          "case_id": "grounded-level-2-answer",
          "decision_status": "answered",
          "predicted_level": 2,
          "candidate_levels": [1, 2, 4],
          "evidence_rule_ids": ["ktas-public-level-2-001"],
          "rationale_zh": "公開定義直接支持第二級。",
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        }
      ],
      "expected": {
        "generic_schema_first_attempt_accepted": true,
        "first_dynamic_attempt_accepted": true,
        "attempt_count": 1,
        "execution_status": "completed",
        "final_decision_status": "answered",
        "final_predicted_level": 2,
        "final_reason_codes": [],
        "handoff_required": false
      }
    },
    {
      "case_id": "grounded-level-4-repair-confidence",
      "scenario": "第一個提案偷偷加入自報 confidence;第二次移除額外欄位後才通過。",
      "origin": "author_written_public_knowledge_question_not_patient_record",
      "candidate_levels": [1, 2, 4],
      "retrieved_rule_ids": ["ktas-public-level-4-001", "ktas-public-level-1-001", "ktas-public-system-purpose-001"],
      "required_evidence_rule_ids": ["ktas-public-level-4-001"],
      "required_input_fields": ["question"],
      "available_input_fields": ["question"],
      "conflicting_evidence_rule_id_sets": [],
      "known_gap_ids": [],
      "proposal_attempts": [
        {
          "case_id": "grounded-level-4-repair-confidence",
          "decision_status": "answered",
          "predicted_level": 4,
          "candidate_levels": [1, 2, 4],
          "evidence_rule_ids": ["ktas-public-level-4-001"],
          "rationale_zh": "公開定義直接支持第四級。",
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。",
          "confidence": 0.99
        },
        {
          "case_id": "grounded-level-4-repair-confidence",
          "decision_status": "answered",
          "predicted_level": 4,
          "candidate_levels": [1, 2, 4],
          "evidence_rule_ids": ["ktas-public-level-4-001"],
          "rationale_zh": "公開定義直接支持第四級。",
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        }
      ],
      "expected": {
        "generic_schema_first_attempt_accepted": false,
        "first_dynamic_attempt_accepted": false,
        "attempt_count": 2,
        "execution_status": "completed",
        "final_decision_status": "answered",
        "final_predicted_level": 4,
        "final_reason_codes": [],
        "handoff_required": false
      }
    },
    {
      "case_id": "grounded-level-1-answer",
      "scenario": "第一級位於候選集合,且取回與引用同一筆第一級公開定義。",
      "origin": "author_written_public_knowledge_question_not_patient_record",
      "candidate_levels": [1, 2],
      "retrieved_rule_ids": ["ktas-public-level-1-001", "ktas-public-level-2-001"],
      "required_evidence_rule_ids": ["ktas-public-level-1-001"],
      "required_input_fields": ["question"],
      "available_input_fields": ["question"],
      "conflicting_evidence_rule_id_sets": [],
      "known_gap_ids": [],
      "proposal_attempts": [
        {
          "case_id": "grounded-level-1-answer",
          "decision_status": "answered",
          "predicted_level": 1,
          "candidate_levels": [1, 2],
          "evidence_rule_ids": ["ktas-public-level-1-001"],
          "rationale_zh": "公開定義直接支持第一級。",
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        }
      ],
      "expected": {
        "generic_schema_first_attempt_accepted": true,
        "first_dynamic_attempt_accepted": true,
        "attempt_count": 1,
        "execution_status": "completed",
        "final_decision_status": "answered",
        "final_predicted_level": 1,
        "final_reason_codes": [],
        "handoff_required": false
      }
    },
    {
      "case_id": "grounded-level-3-answer",
      "scenario": "第三級位於候選集合,且取回與引用同一筆第三級公開定義。",
      "origin": "author_written_public_knowledge_question_not_patient_record",
      "candidate_levels": [2, 3, 4],
      "retrieved_rule_ids": ["ktas-public-level-3-001", "ktas-public-level-2-001", "ktas-public-level-4-001"],
      "required_evidence_rule_ids": ["ktas-public-level-3-001"],
      "required_input_fields": ["question"],
      "available_input_fields": ["question"],
      "conflicting_evidence_rule_id_sets": [],
      "known_gap_ids": [],
      "proposal_attempts": [
        {
          "case_id": "grounded-level-3-answer",
          "decision_status": "answered",
          "predicted_level": 3,
          "candidate_levels": [2, 3, 4],
          "evidence_rule_ids": ["ktas-public-level-3-001"],
          "rationale_zh": "公開定義直接支持第三級。",
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        }
      ],
      "expected": {
        "generic_schema_first_attempt_accepted": true,
        "first_dynamic_attempt_accepted": true,
        "attempt_count": 1,
        "execution_status": "completed",
        "final_decision_status": "answered",
        "final_predicted_level": 3,
        "final_reason_codes": [],
        "handoff_required": false
      }
    },
    {
      "case_id": "day24-candidates-lack-specific-thresholds",
      "scenario": "Day 24 已產生候選,但公開知識缺少主訴特定門檻,不能把級數定義直接套到合成案例。",
      "origin": "author_written_from_day24_trace_not_patient_record",
      "upstream_day24_case_id": "alarm-low-saturation-rescue",
      "candidate_levels": [1, 2, 4],
      "retrieved_rule_ids": ["ktas-public-level-2-001", "ktas-public-level-1-001"],
      "required_evidence_rule_ids": [],
      "required_input_fields": ["chief_complaint", "vital_signs"],
      "available_input_fields": ["chief_complaint", "vital_signs"],
      "conflicting_evidence_rule_id_sets": [],
      "known_gap_ids": ["complaint_specific_thresholds"],
      "gap_missing_information": ["正式主訴目錄", "主訴特定數值門檻與套用順序"],
      "proposal_attempts": [
        {
          "case_id": "day24-candidates-lack-specific-thresholds",
          "decision_status": "answered",
          "predicted_level": 2,
          "candidate_levels": [1, 2, 4],
          "evidence_rule_ids": ["ktas-public-level-2-001"],
          "rationale_zh": "候選中有第二級,因此選第二級。",
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        }
      ],
      "expected": {
        "generic_schema_first_attempt_accepted": true,
        "first_dynamic_attempt_accepted": true,
        "attempt_count": 1,
        "execution_status": "completed",
        "final_decision_status": "abstained",
        "final_predicted_level": null,
        "final_reason_codes": ["insufficient_evidence"],
        "handoff_required": true
      }
    },
    {
      "case_id": "day24-missing-chief-complaint",
      "scenario": "Day 24 主訴缺失案例不能只靠生命徵象與固定保留集合輸出正式級數。",
      "origin": "author_written_from_day24_trace_not_patient_record",
      "upstream_day24_case_id": "missing-complaint-reserve-overtriage",
      "candidate_levels": [1, 2, 4],
      "retrieved_rule_ids": ["ktas-public-level-2-001", "ktas-public-level-4-001"],
      "required_evidence_rule_ids": [],
      "required_input_fields": ["chief_complaint", "vital_signs"],
      "available_input_fields": ["vital_signs"],
      "conflicting_evidence_rule_id_sets": [],
      "known_gap_ids": [],
      "proposal_attempts": [
        {
          "case_id": "day24-missing-chief-complaint",
          "decision_status": "answered",
          "predicted_level": 2,
          "candidate_levels": [1, 2, 4],
          "evidence_rule_ids": ["ktas-public-level-2-001"],
          "rationale_zh": "高風險保留集合含第二級。",
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        }
      ],
      "expected": {
        "generic_schema_first_attempt_accepted": true,
        "first_dynamic_attempt_accepted": true,
        "attempt_count": 1,
        "execution_status": "completed",
        "final_decision_status": "abstained",
        "final_predicted_level": null,
        "final_reason_codes": ["missing_required_input"],
        "handoff_required": true
      }
    },
    {
      "case_id": "conflicting-retrieved-evidence",
      "scenario": "兩筆取回證據被契約標記為衝突,不能由生成文字自行選邊。",
      "origin": "author_written_from_day24_trace_not_patient_record",
      "upstream_day24_case_id": "boundary-alarm-removes-reference",
      "candidate_levels": [1, 2, 3],
      "retrieved_rule_ids": ["ktas-public-level-2-001", "ktas-public-level-3-001"],
      "required_evidence_rule_ids": [],
      "required_input_fields": ["question"],
      "available_input_fields": ["question"],
      "conflicting_evidence_rule_id_sets": [["ktas-public-level-2-001", "ktas-public-level-3-001"]],
      "known_gap_ids": [],
      "proposal_attempts": [
        {
          "case_id": "conflicting-retrieved-evidence",
          "decision_status": "answered",
          "predicted_level": 2,
          "candidate_levels": [1, 2, 3],
          "evidence_rule_ids": ["ktas-public-level-2-001"],
          "rationale_zh": "選擇其中較急的第二級。",
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        }
      ],
      "expected": {
        "generic_schema_first_attempt_accepted": true,
        "first_dynamic_attempt_accepted": true,
        "attempt_count": 1,
        "execution_status": "completed",
        "final_decision_status": "abstained",
        "final_predicted_level": null,
        "final_reason_codes": ["conflicting_evidence"],
        "handoff_required": true
      }
    },
    {
      "case_id": "unsupported-citation-repaired-to-abstain",
      "scenario": "第一個提案引用未取回的第五級;第二次改為明確拒答。",
      "origin": "author_written_contract_stress_case_not_patient_record",
      "candidate_levels": [1, 2],
      "retrieved_rule_ids": ["ktas-public-level-2-001"],
      "required_evidence_rule_ids": ["ktas-public-level-2-001"],
      "required_input_fields": ["question"],
      "available_input_fields": ["question"],
      "conflicting_evidence_rule_id_sets": [],
      "known_gap_ids": [],
      "proposal_attempts": [
        {
          "case_id": "unsupported-citation-repaired-to-abstain",
          "decision_status": "answered",
          "predicted_level": 2,
          "candidate_levels": [1, 2],
          "evidence_rule_ids": ["ktas-public-level-5-001"],
          "rationale_zh": "引用第五級後回答第二級。",
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        },
        {
          "case_id": "unsupported-citation-repaired-to-abstain",
          "decision_status": "abstained",
          "predicted_level": null,
          "candidate_levels": [1, 2],
          "evidence_rule_ids": [],
          "rationale_zh": "前一個提案引用未取回證據,因此不輸出級數。",
          "abstention_reason_codes": ["unsupported_citation"],
          "missing_information": ["需要重新產生只引用本次 EVIDENCE 的回答"],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        }
      ],
      "expected": {
        "generic_schema_first_attempt_accepted": true,
        "first_dynamic_attempt_accepted": false,
        "attempt_count": 2,
        "execution_status": "completed",
        "final_decision_status": "abstained",
        "final_predicted_level": null,
        "final_reason_codes": ["unsupported_citation"],
        "handoff_required": true
      }
    },
    {
      "case_id": "candidate-out-of-scope-repaired-to-abstain",
      "scenario": "第一個提案輸出不在候選集合的第四級;第二次改為拒答。",
      "origin": "author_written_contract_stress_case_not_patient_record",
      "candidate_levels": [1, 2],
      "retrieved_rule_ids": ["ktas-public-level-4-001"],
      "required_evidence_rule_ids": [],
      "required_input_fields": ["question"],
      "available_input_fields": ["question"],
      "conflicting_evidence_rule_id_sets": [],
      "known_gap_ids": [],
      "proposal_attempts": [
        {
          "case_id": "candidate-out-of-scope-repaired-to-abstain",
          "decision_status": "answered",
          "predicted_level": 4,
          "candidate_levels": [1, 2],
          "evidence_rule_ids": ["ktas-public-level-4-001"],
          "rationale_zh": "證據提到第四級,因此輸出第四級。",
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        },
        {
          "case_id": "candidate-out-of-scope-repaired-to-abstain",
          "decision_status": "abstained",
          "predicted_level": null,
          "candidate_levels": [1, 2],
          "evidence_rule_ids": ["ktas-public-level-4-001"],
          "rationale_zh": "證據對應級數不在本次候選集合,因此不輸出級數。",
          "abstention_reason_codes": ["candidate_out_of_scope"],
          "missing_information": ["需要重新建立候選集合或取得候選內的直接支持證據"],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        }
      ],
      "expected": {
        "generic_schema_first_attempt_accepted": true,
        "first_dynamic_attempt_accepted": false,
        "attempt_count": 2,
        "execution_status": "completed",
        "final_decision_status": "abstained",
        "final_predicted_level": null,
        "final_reason_codes": ["candidate_out_of_scope"],
        "handoff_required": true
      }
    },
    {
      "case_id": "schema-invalid-after-retry",
      "scenario": "兩次提案都違反固定欄位,應記為生成失敗而不是偽裝成有證據的拒答。",
      "origin": "author_written_contract_stress_case_not_patient_record",
      "candidate_levels": [1, 2, 3],
      "retrieved_rule_ids": ["ktas-public-level-3-001"],
      "required_evidence_rule_ids": ["ktas-public-level-3-001"],
      "required_input_fields": ["question"],
      "available_input_fields": ["question"],
      "conflicting_evidence_rule_id_sets": [],
      "known_gap_ids": [],
      "proposal_attempts": [
        {
          "case_id": "schema-invalid-after-retry",
          "decision_status": "answered",
          "predicted_level": 3,
          "candidate_levels": [1, 2, 3],
          "evidence_rule_ids": ["ktas-public-level-3-001"],
          "rationale_zh": "公開定義直接支持第三級。",
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。",
          "confidence": 0.8
        },
        {
          "case_id": "schema-invalid-after-retry",
          "decision_status": "answered",
          "predicted_level": 3,
          "candidate_levels": [1, 2, 3],
          "evidence_rule_ids": ["ktas-public-level-3-001"],
          "abstention_reason_codes": [],
          "missing_information": [],
          "human_review_required": true,
          "safety_notice": "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        }
      ],
      "expected": {
        "generic_schema_first_attempt_accepted": false,
        "first_dynamic_attempt_accepted": false,
        "attempt_count": 2,
        "execution_status": "generation_failure",
        "final_decision_status": null,
        "final_predicted_level": null,
        "final_reason_codes": ["schema_validation_failed_after_retry"],
        "handoff_required": true
      }
    }
  ]
}

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

檔案 4:建立 src/triage_rag/generation/__init__.py

更新 generation 子套件入口,保留 Day 21 Ollama 用戶端並公開 Day 25 的契約、Schema、驗證、證據政策與聚合函式。

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

"""Local text generation clients used by the triage RAG experiments."""

from triage_rag.generation.ollama_chat import (
    OllamaChatError,
    build_response_schema,
    chat_structured,
    get_chat_runtime_metadata,
)
from triage_rag.generation.grounded_output import (
    GroundedOutputContractError,
    apply_grounding_policy,
    build_dynamic_output_schema,
    build_generic_output_schema,
    run_grounded_output_case,
    summarize_grounded_output_results,
    validate_dynamic_output,
    validate_generic_output,
    validate_grounded_output_contract,
)

__all__ = [
    "OllamaChatError",
    "build_response_schema",
    "chat_structured",
    "get_chat_runtime_metadata",
    "GroundedOutputContractError",
    "apply_grounding_policy",
    "build_dynamic_output_schema",
    "build_generic_output_schema",
    "run_grounded_output_case",
    "summarize_grounded_output_results",
    "validate_dynamic_output",
    "validate_generic_output",
    "validate_grounded_output_contract",
]

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

檔案 5:建立 src/triage_rag/generation/grounded_output.py

驗證跨日來源與合成範圍,建立通用/動態 Schema,執行一次修復、缺失/衝突/缺口守門,並把技術失敗與證據拒答分開記錄。

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

"""Grounded structured output and abstention contract for Day 25."""

from __future__ import annotations

import copy
from collections import Counter
from typing import Any, Dict, Iterable, List, Mapping, Sequence


JsonObject = Dict[str, Any]
VALID_LEVELS = (1, 2, 3, 4, 5)
EXPECTED_SCOPE = (
    "author_written_synthetic_grounded_output_microbenchmark_"
    "not_patient_triage_or_model_evaluation"
)
EXPECTED_FIXTURE_SCOPE = (
    "author_written_synthetic_grounded_output_stress_cases_"
    "not_patient_records_or_model_benchmark"
)
PATIENT_OR_LABEL_FIELDS = {
    "record_index",
    "KTAS_RN",
    "KTAS_expert",
    "Group",
    "diagnosis",
    "disposition",
    "length_of_stay",
    "Error_group",
    "mistriage",
}


class GroundedOutputContractError(ValueError):
    """Raised when the Day 25 evidence, schema, or abstention contract drifts."""


def _all_keys(value: Any) -> Iterable[str]:
    if isinstance(value, Mapping):
        for key, child in value.items():
            yield str(key)
            yield from _all_keys(child)
    elif isinstance(value, list):
        for child in value:
            yield from _all_keys(child)


def _nonempty_unique_strings(value: Any) -> bool:
    return (
        isinstance(value, list)
        and len(value) == len(set(value))
        and all(isinstance(item, str) and item.strip() for item in value)
    )


def _valid_level_list(value: Any) -> bool:
    return (
        isinstance(value, list)
        and bool(value)
        and value == sorted(value)
        and len(value) == len(set(value))
        and all(level in VALID_LEVELS for level in value)
    )


def _gap_ids(coverage_report: Mapping[str, Any]) -> set[str]:
    items = coverage_report.get("coverage", {}).get("items", [])
    return {
        str(item["coverage_id"])
        for item in items
        if item.get("status") == "gap"
    }


def validate_grounded_output_contract(
    config: Mapping[str, Any],
    fixture: Mapping[str, Any],
    documents: Sequence[Mapping[str, Any]],
    coverage_report: Mapping[str, Any],
    day21_config: Mapping[str, Any],
    day21_public_result: Mapping[str, Any],
    day24_public_result: Mapping[str, Any],
    system_prompt: str,
) -> JsonObject:
    """Validate scope, sources, cases, prompt, and locked comparison values."""

    if config.get("scope") != EXPECTED_SCOPE:
        raise GroundedOutputContractError("Day 25 scope 不允許病患或模型評估")
    if fixture.get("scope") != EXPECTED_FIXTURE_SCOPE:
        raise GroundedOutputContractError("Day 25 fixture 必須明示為作者合成提案")
    if fixture.get("proposal_semantics") != (
        "proposal_attempts 全是作者為驗證 schema、證據守門與拒答路由撰寫的"
        "合成模型提案,不是任何模型的實際輸出。"
    ):
        raise GroundedOutputContractError("合成提案角色不可漂移成模型實測")

    proposal = config.get("proposal_contract")
    grounding = config.get("grounding_policy")
    if not isinstance(proposal, Mapping) or not isinstance(grounding, Mapping):
        raise GroundedOutputContractError("缺少 proposal_contract 或 grounding_policy")
    required_proposal = {
        "proposal_role": "author_written_synthetic_generator_proposal_not_model_output",
        "json_schema_dialect": "https://json-schema.org/draft/2020-12/schema",
        "maximum_attempts": 2,
        "repair_policy": "retry_once_with_the_same_case_candidates_evidence_and_dynamic_schema_then_fail_closed",
        "decision_statuses": ["answered", "abstained"],
        "required_output_fields": [
            "case_id",
            "decision_status",
            "predicted_level",
            "candidate_levels",
            "evidence_rule_ids",
            "rationale_zh",
            "abstention_reason_codes",
            "missing_information",
            "human_review_required",
            "safety_notice",
        ],
        "forbidden_output_fields": [
            "confidence",
            "probability",
            "self_assessed_confidence",
            "KTAS_RN",
            "KTAS_expert",
        ],
        "allowed_abstention_reason_codes": [
            "missing_required_input",
            "insufficient_evidence",
            "conflicting_evidence",
            "unsupported_citation",
            "candidate_out_of_scope",
        ],
        "generation_failure_reason_code": "schema_validation_failed_after_retry",
        "human_review_required": True,
        "safety_notice": (
            "研究與教學用途的決策支援示範;所有輸出都需由專業人員覆核。"
        ),
    }
    drift = {
        key: {"actual": proposal.get(key), "expected": expected}
        for key, expected in required_proposal.items()
        if proposal.get(key) != expected
    }
    if drift:
        raise GroundedOutputContractError(f"輸出契約漂移:{drift}")

    required_grounding = {
        "dynamic_case_id_policy": "case_id_must_equal_the_current_case",
        "dynamic_candidate_policy": "candidate_levels_must_equal_the_upstream_set_and_answered_predicted_level_must_be_a_member",
        "dynamic_citation_policy": "every_evidence_rule_id_must_be_present_in_the_current_retrieved_evidence",
        "required_evidence_policy": "answered_outputs_must_retrieve_and_cite_every_case_required_evidence_rule_id",
        "known_gap_policy": "registered_knowledge_gap_forces_insufficient_evidence",
        "missing_input_policy": "missing_required_input_forces_abstention_without_imputation",
        "conflict_policy": "declared_conflicting_evidence_forces_abstention_without_model_tie_breaking",
        "reason_precedence": [
            "missing_required_input",
            "conflicting_evidence",
            "insufficient_evidence",
            "explicit_schema_valid_abstention",
            "answered",
        ],
        "failure_boundary": "two_schema_invalid_attempts_are_generation_failure_not_evidence_abstention",
        "confidence_policy": "self_reported_confidence_is_forbidden_and_not_used_as_a_probability_or_abstention_signal",
    }
    grounding_drift = {
        key: {"actual": grounding.get(key), "expected": expected}
        for key, expected in required_grounding.items()
        if grounding.get(key) != expected
    }
    if grounding_drift:
        raise GroundedOutputContractError(
            f"證據與拒答契約漂移:{grounding_drift}"
        )

    if day21_config.get("scope") != (
        "author_written_public_knowledge_rag_integration_"
        "not_patient_triage_or_clinical_evaluation"
    ):
        raise GroundedOutputContractError("Day 21 公開知識契約已漂移")
    if day21_public_result.get("experiment_id") != day21_config.get("experiment_id"):
        raise GroundedOutputContractError("Day 21 設定與公開結果不一致")
    if day24_public_result.get("checks", {}).get("statistics_eligible") is not False:
        raise GroundedOutputContractError("Day 24 合成結果不得變成統計估計")
    if not day24_public_result.get("checks", {}).get(
        "safety_union_preserves_every_base_candidate"
    ):
        raise GroundedOutputContractError("Day 24 Safety Union 不變量未通過")

    prompt_requirements = (
        "唯一知識來源",
        "candidate_levels",
        "evidence_rule_ids",
        "decision_status",
        "abstained",
        "confidence",
        "human_review_required",
        "JSON Schema",
    )
    missing_prompt_items = [
        item for item in prompt_requirements if item not in system_prompt
    ]
    if missing_prompt_items:
        raise GroundedOutputContractError(
            f"Day 25 system prompt 缺少必要條款:{missing_prompt_items}"
        )

    document_rule_ids = {
        str(document.get("metadata", {}).get("rule_id")) for document in documents
    }
    if len(documents) != 9 or len(document_rule_ids) != 9 or "None" in document_rule_ids:
        raise GroundedOutputContractError("Day 25 必須沿用九筆唯一公開知識")
    registered_gaps = _gap_ids(coverage_report)
    if "complaint_specific_thresholds" not in registered_gaps:
        raise GroundedOutputContractError("主訴特定門檻缺口不可消失")
    day24_by_id = {
        str(case["case_id"]): case
        for case in day24_public_result.get("case_results", [])
    }

    cases = fixture.get("cases")
    expected_count = int(config["evaluation"]["expected_case_count"])
    if not isinstance(cases, list) or len(cases) != expected_count:
        raise GroundedOutputContractError(
            f"Day 25 應有 {expected_count} 筆合成案例"
        )
    forbidden_present = sorted(PATIENT_OR_LABEL_FIELDS.intersection(_all_keys(cases)))
    if forbidden_present:
        raise GroundedOutputContractError(
            f"Day 25 合成案例出現禁止欄位:{forbidden_present}"
        )

    case_ids: set[str] = set()
    for case in cases:
        case_id = case.get("case_id")
        if not isinstance(case_id, str) or not case_id.strip():
            raise GroundedOutputContractError("每筆案例都需要 case_id")
        if case_id in case_ids:
            raise GroundedOutputContractError(f"case_id 重複:{case_id}")
        case_ids.add(case_id)
        origin = case.get("origin")
        if not isinstance(origin, str) or "not_patient_record" not in origin:
            raise GroundedOutputContractError(f"{case_id}.origin 必須標示非病患紀錄")
        if not _valid_level_list(case.get("candidate_levels")):
            raise GroundedOutputContractError(f"{case_id}.candidate_levels 非法")
        retrieved = case.get("retrieved_rule_ids")
        if not _nonempty_unique_strings(retrieved):
            raise GroundedOutputContractError(f"{case_id}.retrieved_rule_ids 非法")
        unknown_retrieved = sorted(set(retrieved) - document_rule_ids)
        if unknown_retrieved:
            raise GroundedOutputContractError(
                f"{case_id} 取回未知規則:{unknown_retrieved}"
            )
        required = case.get("required_evidence_rule_ids")
        if not isinstance(required, list) or len(required) != len(set(required)):
            raise GroundedOutputContractError(f"{case_id}.required_evidence_rule_ids 非法")
        unknown_required = sorted(set(required) - document_rule_ids)
        if unknown_required:
            raise GroundedOutputContractError(
                f"{case_id} 要求未知規則:{unknown_required}"
            )
        for field in ("required_input_fields", "available_input_fields"):
            values = case.get(field)
            if not _nonempty_unique_strings(values):
                raise GroundedOutputContractError(f"{case_id}.{field} 非法")
        conflicts = case.get("conflicting_evidence_rule_id_sets")
        if not isinstance(conflicts, list):
            raise GroundedOutputContractError(f"{case_id}.conflicts 必須是陣列")
        for pair in conflicts:
            if (
                not isinstance(pair, list)
                or len(pair) != 2
                or len(set(pair)) != 2
                or not set(pair).issubset(retrieved)
            ):
                raise GroundedOutputContractError(
                    f"{case_id} 每組衝突必須是兩筆已取回規則"
                )
        gaps = case.get("known_gap_ids")
        if not isinstance(gaps, list) or len(gaps) != len(set(gaps)):
            raise GroundedOutputContractError(f"{case_id}.known_gap_ids 非法")
        unknown_gaps = sorted(set(gaps) - registered_gaps)
        if unknown_gaps:
            raise GroundedOutputContractError(
                f"{case_id} 使用未登錄缺口:{unknown_gaps}"
            )
        attempts = case.get("proposal_attempts")
        if not isinstance(attempts, list) or not 1 <= len(attempts) <= 2:
            raise GroundedOutputContractError(f"{case_id} 合成提案只能有一或兩次")
        if not isinstance(case.get("expected"), Mapping):
            raise GroundedOutputContractError(f"{case_id} 缺少鎖定預期")

        upstream_id = case.get("upstream_day24_case_id")
        if upstream_id is not None:
            upstream = day24_by_id.get(str(upstream_id))
            if upstream is None:
                raise Groun

上一篇
Day 24|Safety Union Gate:不靠刪除保護高急迫候選
系列文
30 天打造公開資料版急診檢傷系統:Side Project 與實驗計畫25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言