Day 24 建立了安全聯集門控(Safety Union Gate),把一般檢索、主訴、生命徵象與高風險保留候選合成同一個候選集合。那一步解決的是「哪些級數還有資格進入後續判斷」,不是「系統現在就能輸出哪一級」。
本系列的制度背景仍是韓國急診檢傷與急迫度分級量表(Korean Triage and Acuity Scale, KTAS)。本篇只使用可公開追溯的部分 KTAS 知識,不宣稱已重建完整官方演算法。
這個差異很重要。候選集合中出現第二級,只表示第二級沒有被前段管線排除;如果目前只有一般五級定義,卻缺少主訴專屬門檻與完整決策路徑,直接回答第二級仍是在補寫證據沒有提供的結論。
今天要替檢索增強生成(Retrieval-Augmented Generation, RAG)加上最後一道輸出契約:有直接證據才回答;必要輸入缺失、知識有缺口或證據衝突時,輸出可追蹤的拒答;兩次格式都不合法時,則明確記為技術失敗並轉人工。
本篇十筆案例與每一次 proposal_attempts 都是作者為工程測試撰寫的合成提案,不是大型語言模型(Large Language Model, LLM)的實際輸出,也不是病患資料、護理師登錄級數或專家重新判定級數。整個 Day 25 不呼叫模型或網路,只測試輸出控制層能否按照鎖定契約分流。
下圖用概念示意呈現同一批證據通過檢查後的三條出口。只有證據與契約同時成立才回答;其餘狀況必須留下可追蹤狀態並交由專業人員覆核。

上圖把「拒答」與「當機」分開。前者是系統根據可觀察條件做出的有效決策輸出;後者代表連輸出契約都沒有通過,不能偽裝成證據不足。
完成本篇後,你會得到六個可驗證產物:
本篇不回答「哪一個模型比較準」,也不估計真實覆蓋率或選擇性風險。今天的問題範圍只有一個:已知當前候選、取回證據與知識缺口後,輸出控制層能否拒絕越界內容並保留人工接手?
| 中文名稱 | 英文全名/縮寫 | 本篇用途 |
|---|---|---|
| 證據約束生成 | 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 只能是 answered 或 abstained。predicted_level 只能是 null 或第一至第五級。human_review_required 固定為 true。confidence 等額外欄位。固定 Schema 不知道「這一筆」到底取回哪些 rule_id,也不知道 Day 24 為這一筆產生哪些候選。若 evidence_rule_ids 是任意非空字串陣列,那麼不存在的引用依然可能通過;若 predicted_level 只限制在 1 到 5,候選集合明明只有 {1, 2},第五級仍可能通過。
JSON Schema 的 if、then、else 可以依欄位值套用不同條件。本篇用它保證 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 對十筆案例完全相同,只檢查:
answered 是否有級數和至少一筆引用。abstained 是否為 null 級數,並有理由和缺少資訊。程式接著複製通用 Schema,為每筆案例縮小三個範圍:
case_id = 當前案例的固定值
candidate_levels = 上游候選集合的固定值
evidence_rule_ids ⊆ 本次 retrieved_rule_ids
如果狀態是 answered,還要滿足:
predicted_level ∈ candidate_levels
假設本筆案例候選只有 {1, 2},取回證據只有 level-1 與 level-2 兩筆,動態 Schema 就不接受第五級,也不接受任何未取回 rule_id。下一筆案例會重新建立另一份 Schema,不共用上一筆的允許清單。
下圖比較兩層檢查能回答的不同問題。固定格式只知道欄位形狀,動態限制才知道本筆案例允許引用什麼。

上圖顯示通用格式首輪接受 8/10 筆,但其中包括一筆未取回引用與一筆候選越界。動態檢查共讓 4 筆進入第二次提案,3 筆修復成功,最後一筆仍失敗。這正是「格式正確不等於有根據」的可觀察反例。
動態 Schema 能檢查集合成員,卻不知道公開知識庫已登錄哪些缺口,也不應讓生成文字自行解決證據衝突。因此,本篇在動態 Schema 通過後,依固定優先序執行 apply_grounding_policy():
missing_required_input。conflicting_evidence。insufficient_evidence。前三項由程式產生標準拒答,不接受生成端自行判定「這次可以忽略」。例如主訴缺失時,即使提案寫了第二級,程式仍把 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,因為根本沒有一份有效的十欄輸出。若把格式錯誤、解析失敗或缺欄都統計成「模型知道自己不知道」,會高估拒答設計的能力,也讓工程故障無法單獨監控。
下圖把兩層驗證與一次修復放回實際執行順序。請特別注意「證據拒答」和「格式失敗」走不同出口。

上圖右側三個計數來自同一份公開結果:4 筆回答、5 筆拒答、1 筆技術失敗。後兩類共 6 筆需要人工接手;回答也固定標示需要專業覆核,但在本篇工作流中不計為「未完成而轉交」。
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 有已知缺口 | 證據不足拒答 |
| 缺少主訴 | 提案想靠保留候選回答 | 必要輸入缺失拒答 |
| 取回證據衝突 | 提案自行選較急迫一邊 | 證據衝突拒答 |
| 引用未取回規則 | 通用格式接受,動態限制拒絕 | 修復為引用越界拒答 |
| 回答超出候選 | 候選只有第一、二級卻答第五級 | 修復為候選越界拒答 |
| 重試後格式仍錯 | 兩次都缺少或多出欄位 | 技術失敗 |
下圖逐列保留十筆案例,不把不同拒答原因合併成一個模糊的「沒回答」。

上圖的五種拒答理由各出現一筆,這是測試覆蓋設計,不代表真實使用時五種原因等機率。最後一列也沒有被塞進任何拒答欄,避免技術故障污染證據統計。
公開摘要得到以下描述性計數:
| 項目 | 結果 |
|---|---|
| 合成案例總數 | 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 "資料夾已建立" 驗證單一資料夾。接著使用你熟悉的文字編輯器新增各檔案,把對應區塊完整貼入後儲存。
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 只描述刻意設計的十筆契約案例,不是模型覆蓋率,也沒有計算選擇性風險或信賴區間。",
"系統只能作為研究與教學用決策支援元件,所有回答、拒答與生成失敗都需要專業人員覆核。"
]
}
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
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、程式碼圍欄或額外說明。
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
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
}
}
]
}
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
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",
]
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
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