Day 15 已經把 1,267 筆病患紀錄切成固定的重複分層五折。那張分派表會讓後續所有方法在相同訓練與驗證紀錄上比較,但它只處理「用哪些病患資料評估模型」,還沒有回答另一個問題:模型可以檢索哪些外部知識?
這兩件事不能混在一起。
假設資料中有一筆呼吸困難案例,最後由專家標成第二級。我們可以用這筆紀錄評估模型是否答對,卻不能因為看到「呼吸困難+第二級」,就自行反推出「所有呼吸困難都屬於第二級」。同一個主訴還會受到第一印象、生命徵象、意識、疼痛與其他考量影響。單一標籤是某次判定的結果,不是完整規則。
今天要建立的知識庫,來源只能是可公開閱讀、可以定位,而且經過人工查證的韓國急診檢傷與急迫度分級量表(Korean Triage and Acuity Scale, KTAS)制度說明。Kaggle 病患資料、護理師級數 KTAS_RN 與專家標籤 KTAS_expert 都不會進入這條建置管線。
下圖是概念示意。請先看左側被框在獨立托盤中的病患資料,再看中央的人工作業與右側尚未拼完整的知識拼圖。

上圖傳達三件事。病患資料不能直接變成規則;公開網頁與研究也不能不經檢查就全部丟進系統;即使完成整理,公開內容仍然可能有缺口。因此,本篇使用「部分 KTAS 公開知識庫」這個名稱,不會把它稱為完整官方手冊。
本篇只會整理文字與執行一般 Python 程式,不會向線上模型送出資料,也不需要啟動 Ollama 或下載任何模型。
讀完並跟著操作後,你會完成以下六件事:
rule_id、來源定位、完整度與內容雜湊。本篇會使用多個資料治理名詞,先建立快速索引。後面的正文仍會用實際檔案逐一解釋。
| 中文名稱 | 英文全名/縮寫 | 本篇用途 |
|---|---|---|
| 檢索增強生成 | Retrieval-Augmented Generation, RAG | 先找出相關外部知識,再把知識交給生成模型回答;本篇準備其中的知識來源 |
| 知識庫 | Knowledge Base | 保存可供後續檢索的公開 KTAS 知識單元 |
| 知識單元 | Knowledge Unit | 可獨立理解、定位來源與判斷能否檢索的最小內容 |
| 中繼資料 | Metadata | 描述知識本身的資料,例如主題、級數、來源、日期與完整度 |
| 資料來源追溯 | Data Provenance | 回答一筆知識從哪裡來、由誰發布、查閱哪個位置與何時取得 |
| JSON Lines | JSON Lines, JSONL | 每一行保存一個完整 JSON 物件的文字格式,方便逐筆讀取知識單元 |
| 統一資源定位符 | Uniform Resource Locator, URL | 指向公開來源的網頁位置;設定檔中的欄位名稱是 url |
| 安全雜湊演算法 256 位元 | Secure Hash Algorithm 256-bit, SHA-256 | 為整理後內容建立固定長度摘要,用來偵測內容是否改變 |
| 創用 CC 姓名標示 4.0 國際 | Creative Commons Attribution 4.0 International, CC BY 4.0 | PLOS ONE 來源標示的開放授權,使用時仍須保留適當來源 |
先把本系列目前出現的三種資產放在一起比較:
| 資產 | 例子 | 能做什麼 | 不能做什麼 |
|---|---|---|---|
| 病患輸入 | 主訴、意識、疼痛、血壓、心率 | 成為模型在檢傷當下可以讀取的案例條件 | 不能單獨代表 KTAS 規則 |
| 人工參考標籤 | KTAS_expert |
評估模型預測是否接近專家重新判定 | 不能當模型輸入,也不能反推出完整決策表 |
| 公開制度知識 | 官方五級定義、公開流程與考量類型 | 成為 RAG 後續可以檢索的外部證據 | 不能假裝涵蓋未公開的完整主訴與門檻 |
這個區分也決定了 Day 15 與 Day 16 的檔案不能互相污染:
record_index × repeat_id × fold_id 分派表只決定某筆病患紀錄在哪一折接受驗證。record_index,也不讀取 KTAS_expert。來源不是越多越好。來源數量增加時,版本、權利、內容矛盾與重複敘述也會增加。Day 16 先使用兩個能清楚定位的第一手來源,把治理流程做對。
KTAS 官方介紹與五級分類標準把 KTAS 說明為以症狀為導向的分類工具。公開頁面描述的高階流程包含第一印象、感染相關詢問與檢查、主訴、主要考量及次要考量,也逐級列出第一級到第五級的公開定義與照護優先順序。
這個來源適合支持三類內容:
本次盤點沒有把整張網頁下載進專案,也沒有把韓文內容大量複製到知識庫。我們只保存自行撰寫的繁體中文短摘要、網址、段落定位與 2026 年 8 月 10 日的查閱日期。
Moon 等人在 2019 年公開的 Triage accuracy and causes of mistriage using the Korean Triage and Acuity Scale,於方法段落描述 KTAS 是五級、以症狀為導向的工具。研究列舉的主要考量包括生命徵象、疼痛分數、出血相關狀況與受傷機轉,次要考量則舉出血糖與脫水程度。該頁面也明確標示內容採用 CC BY 4.0。
這個來源能證明上述考量類型曾被公開描述,卻不能證明我們已取得所有主訴、完整門檻、例外與衝突處理規則。因此,主要考量與次要考量在覆蓋清單中只能標成「部分覆蓋」。
「瀏覽器打得開」只代表我們能閱讀,不自動代表可以把整頁內容鏡像、重新授權或包成資料集發布。本篇採取以下來源政策:
設定中的 capture.mode 因此固定為 metadata_only,也就是只保存中繼資料與摘要。capture.content_sha256 保持 null,表示我們沒有來源全文快照可供計算整頁雜湊。這個空值是誠實的能力邊界,不是漏做欄位。
下圖把兩個來源到兩個產物的流程畫出來。閱讀時請注意中央人工查證不是裝飾步驟,右側的知識單元與缺口報告也負責不同工作。

上圖右上方的 JSONL 才會成為 Day 17 之後的檢索候選;右下方的覆蓋報告則告訴我們哪些內容不能檢索。缺口不是一筆內容為空的規則,而是「目前沒有足夠公開證據可以建立規則」。兩者不能放進同一個向量索引。
知識單元(Knowledge Unit)是可以獨立理解、獨立追溯,而且能單獨判斷檢索資格的最小內容。它不等於固定字數的文字片段。
例如,「KTAS 使用五個急迫級」和「公開研究列出哪些主要考量類型」是兩個不同主題。如果把兩者和多個未驗證門檻塞進同一段,後續模型只想找生命徵象時,可能同時取回大量無關敘述。反過來,若把一句話切得只剩「生命徵象」,讀者與模型又不知道這個詞在 KTAS 流程中扮演什麼角色。
Day 16 先用人工定義的語意邊界建立知識單元。Day 17 才會在相同公開內容上比較固定字數、段落與規則導向切塊,避免今天同時改變來源和切塊方法。
每筆知識單元需要回答四組問題:
下圖使用「主要考量類型」這筆摘要呈現欄位分工。圖中的生命徵象、疼痛、出血與受傷機轉只是公開研究列出的類型,不包含數值門檻或套用順序。

上圖中央保留了「不含完整門檻與套用順序」這句限制。限制不能只寫在整篇文章結尾,因為知識單元到了後續檢索階段可能會被單獨取出。把限制跟內容保存在同一筆紀錄中,模型才不容易只看到類型名稱,就把它擴張成不存在的完整規則。
rule_id?rule_id 是知識單元的穩定識別碼。它不代表官方 KTAS 規則編號,而是本系列為了追蹤內容自行建立的識別碼。例如:
ktas-public-primary-considerations-001
這個識別碼可以拆成:
ktas-public:內容屬於 KTAS 公開知識範圍。primary-considerations:主題是主要考量。001:同一主題下的穩定序號。更新摘要措辭時,若知識主張仍是同一件事,rule_id 保持不變並提升 kb_version。若新增另一個不同主張,就建立新的 rule_id。若刪除一筆內容,也不能把舊識別碼立刻改配給另一項規則,否則過去保存的檢索紀錄會指向錯誤內容。
每筆輸出會計算 content_sha256。程式把以下四個欄位轉成鍵順序固定的 JSON,再計算 SHA-256:
rule_id
content
source_id
source_locator
只要摘要、來源或定位改變,content_sha256 就會跟著改變。這能幫助我們發現本專案中的知識內容是否被悄悄修改。
它不能證明以下事情:
因此,程式另外保存 accessed_at 和 capture_mode。雜湊負責內容一致性,日期負責說明查閱時間,人工審查負責語意正確性;三者不能互相取代。
如果只寫「目前覆蓋 50%」,讀者無法知道分母是五個級數、所有主訴、所有門檻,還是作者隨手列出的十個項目。本篇不計算模糊的完整率,而是先固定十項查核清單:
| 查核項目 | 狀態 | 目前能說到哪裡 |
|---|---|---|
| 制度目的與適用情境 | 已覆蓋 | 官方頁面直接描述制度目的與症狀導向定位 |
| 高階檢傷流程 | 已覆蓋 | 能說明第一印象、感染詢問、主訴、主要與次要考量的高階順序 |
| 五個級數的公開定義 | 已覆蓋 | 官方頁面逐級提供公開定義與優先順序 |
| 主要考量類型 | 部分覆蓋 | 公開研究列出數種類型,沒有完整門檻與例外 |
| 次要考量類型 | 部分覆蓋 | 公開研究只提供例子,不是逐主訴完整清單 |
| 成人完整主訴目錄 | 缺口 | 目前來源不足以重建完整目錄 |
| 兒童完整主訴目錄 | 缺口 | 目前來源不足以重建完整目錄 |
| 逐主訴數值門檻 | 缺口 | 目前來源不足以還原所有條件 |
| 完整決策路徑與衝突規則 | 缺口 | 無法確認所有分支、例外與衝突處理 |
| 官方版本與變更紀錄 | 缺口 | 無法用目前頁面鎖定整套規則版本 |
前三項的「已覆蓋」只表示我們已經為該項公開主張建立來源摘要,不表示官方手冊已經完整。全域 scope 仍固定為 public_partial_knowledge_base_not_official_manual。
下圖把十項清單排在同一張畫布。請把紅色卡片理解成「目前不能建立可檢索規則」,而不是「規則不存在」。

上圖顯示本版清單有三項已覆蓋、兩項部分覆蓋與五項缺口。缺口數量多並不代表建置失敗;相反地,能把不知道的地方明確留下,才不會讓後續 RAG 把零散公開資訊回答成完整制度規則。
開始操作前,先理解每個資料夾與檔案的責任。讀者不需要前往其他程式碼網站尋找內容,下一節會提供所有需要手動建立檔案的完整內容。
configs/knowledge/:保存人工決策configs/ 是保存可閱讀、可審查設定的資料夾;其中 knowledge/ 子目錄專門保存公開知識來源、摘要與覆蓋政策。
configs/knowledge/day-16-public-ktas-kb.json 是 JavaScript 物件表示法(JavaScript Object Notation, JSON)設定檔。它保存:
claim_kind 可以進入檢索。這份 JSON 是人工審查的輸入,不是網頁爬蟲輸出。
src/triage_rag/knowledge_base/:保存可測試的核心規則src/ 是 Python 套件原始碼資料夾;triage_rag/knowledge_base/ 子目錄集中知識庫驗證與建置邏輯。
__init__.py 建立子套件入口。builder.py 驗證來源、日期、網址、rule_id、KTAS 級數、檢索資格與覆蓋狀態,再建立穩定輸出。核心模組不決定檔案要寫到哪裡,也不使用病患資料。這讓測試可以直接傳入故意破壞的設定,確認契約真的會阻擋錯誤。
scripts/:保存讀者執行的入口scripts/ 是保存自動化 Python 程式的資料夾。scripts/build_day16_knowledge_base.py 會讀取設定、呼叫核心模組,並產生三類輸出:
data/knowledge/ktas-public-v1/knowledge-units.jsonl:九筆可檢索知識單元。results/public/day-16-knowledge-coverage.json:可以公開的覆蓋與缺口報告。results/runs/day-16/<run_id>/run-manifest.json:本次執行使用的輸入雜湊、輸出雜湊與版本紀錄。其中 data/knowledge/ 保存可由程式逐筆讀取的公開知識,不是 Day 12 的病患資料;results/public/ 保存不含病患紀錄的公開彙總;results/runs/ 則是每次執行的本機紀錄。
tests/:驗證防線真的存在tests/ 保存自動測試。tests/test_knowledge_base.py 會檢查合法設定能否穩定重建,也會故意製造四種錯誤:
rule_id。source_id。series_inference 作者推論標成可檢索。gap 的項目連到知識單元。測試的目的不是證明醫療內容正確,而是確認我們寫下的治理規則確實被程式執行。
本篇沿用 Day 13 建立的 Poetry 專案環境。Poetry 是 Python 的相依套件與虛擬環境管理工具;請先在專案根目錄執行:
poetry --version
poetry install
第一個命令應顯示 Poetry 版本,第二個命令會依 pyproject.toml 與 poetry.lock 安裝既有環境。本篇沒有新增第三方執行相依套件,因此不需要修改 pyproject.toml,也不需要下載模型。
若電腦顯示 poetry: command not found,代表 Poetry 尚未安裝或沒有加入終端機的 PATH。請先完成 Day 13 的 Poetry 安裝與專案初始化;不要把下方 poetry run 任意改成另一個環境的 python,否則讀者得到的套件與 Python 版本可能不同。
接下來不會要求你前往任何程式碼網站。請在自己的電腦開啟專案資料夾,依下列順序建立檔案;每個程式碼區塊都是該檔案的完整內容,不含省略號。
本篇沿用 Day 13 已安裝的 Poetry 環境與重現性工具;不讀取 Day 15 的病患資料或標籤。以下是 Day 16 新增的完整來源設定、核心模組、執行入口與測試。JSONL、覆蓋報告與 run manifest 都由執行入口自動產生,不需要手動建立。
先從專案根目錄建立需要的資料夾:
mkdir -p configs/knowledge src/triage_rag/knowledge_base scripts tests data/knowledge/ktas-public-v1 results/public
如果指令沒有印出訊息是正常的。可用 test -d 資料夾路徑 && echo "資料夾已建立" 驗證單一資料夾。接著使用你熟悉的文字編輯器新增各檔案,把對應區塊完整貼入後儲存。
configs/knowledge/day-16-public-ktas-kb.json保存兩個公開來源、九筆自行整理知識單元、十項覆蓋清單,以及來源、推論與檢索資格政策。
請在文字編輯器建立 configs/knowledge/day-16-public-ktas-kb.json,貼入以下完整內容並儲存:
{
"schema_version": 1,
"kb_id": "ktas-public-kb",
"kb_version": "2026-08-10.v1",
"scope": "public_partial_knowledge_base_not_official_manual",
"governance": {
"retrieval_may_use_claim_kinds": [
"direct_source_summary"
],
"source_text_policy": "只保存自行撰寫的繁體中文短摘要、來源定位與網址,不鏡像完整網頁或手冊。",
"inference_policy": "作者推論與知識缺口只能進入覆蓋報告,不得包裝成可檢索的官方規則。",
"refresh_policy": "更新來源時必須人工重新查證、更新 accessed_at、提升 kb_version 並重建全部產物。",
"clinical_use": "education_and_research_only_not_clinical_decision"
},
"sources": [
{
"source_id": "ktas-official-overview",
"title": "KTAS 官方介紹與五級分類標準",
"publisher": "Korean Triage and Acuity Scale(KTAS)",
"source_kind": "official_web_page",
"language": "ko",
"url": "https://www.ktas.org/about/ktas",
"published_at": null,
"accessed_at": "2026-08-10",
"license": "未在公開頁面標示開放授權;本專案只保存自行撰寫摘要與連結。",
"capture": {
"mode": "metadata_only",
"content_sha256": null,
"note": "未保存網頁全文,因此這裡沒有宣稱可驗證整頁內容雜湊。"
},
"supports": [
"system_purpose",
"high_level_workflow",
"five_level_definitions"
]
},
{
"source_id": "moon-2019-plos-one",
"title": "Triage accuracy and causes of mistriage using the Korean Triage and Acuity Scale",
"publisher": "PLOS ONE",
"source_kind": "peer_reviewed_open_access_article",
"language": "en",
"url": "https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0216972",
"doi": "10.1371/journal.pone.0216972",
"published_at": "2019-09-06",
"accessed_at": "2026-08-10",
"license": "Creative Commons Attribution 4.0 International(CC BY 4.0)",
"capture": {
"mode": "metadata_only",
"content_sha256": null,
"note": "未保存研究全文;知識單元只保留針對指定段落的自行整理摘要。"
},
"supports": [
"high_level_workflow",
"primary_consideration_categories",
"secondary_consideration_examples"
]
}
],
"knowledge_units": [
{
"rule_id": "ktas-public-system-purpose-001",
"title": "KTAS 的公開定位",
"topic": "system_purpose",
"ktas_levels": [1, 2, 3, 4, 5],
"content": "韓國急診檢傷與急迫度分級量表是一套以症狀為導向的五級分類工具,用來判斷急迫程度與安排照護優先順序。",
"source_id": "ktas-official-overview",
"source_locator": "「KTAS 란」中的目的、分類方法與運用效果",
"claim_kind": "direct_source_summary",
"completeness": "complete_for_stated_public_claim",
"retrieval_eligible": true,
"coverage_targets": ["system_purpose"]
},
{
"rule_id": "ktas-public-workflow-001",
"title": "公開介紹中的高階檢傷流程",
"topic": "high_level_workflow",
"ktas_levels": [1, 2, 3, 4, 5],
"content": "公開流程先觀察病患第一印象,再進行感染相關的基本詢問與檢查,接著依主訴套用共通的主要考量與主訴特定的次要考量,最後判斷嚴重度與急迫度。",
"source_id": "ktas-official-overview",
"source_locator": "「분류 방법」段落",
"claim_kind": "direct_source_summary",
"completeness": "complete_for_stated_public_claim",
"retrieval_eligible": true,
"coverage_targets": ["high_level_workflow"]
},
{
"rule_id": "ktas-public-level-1-001",
"title": "KTAS 第一級公開定義",
"topic": "level_definition",
"ktas_levels": [1],
"content": "第一級代表需要立即處置,且生命、肢體或病況惡化風險受到重大威脅的狀態,照護順序為最高優先。",
"source_id": "ktas-official-overview",
"source_locator": "「KTAS 분류 기준」表格的 KTAS 1",
"claim_kind": "direct_source_summary",
"completeness": "complete_for_stated_public_claim",
"retrieval_eligible": true,
"coverage_targets": ["five_level_definitions"]
},
{
"rule_id": "ktas-public-level-2-001",
"title": "KTAS 第二級公開定義",
"topic": "level_definition",
"ktas_levels": [2],
"content": "第二級代表生命、肢體或身體功能可能受到威脅,需要快速治療,照護順序僅次於第一級。",
"source_id": "ktas-official-overview",
"source_locator": "「KTAS 분류 기준」表格的 KTAS 2",
"claim_kind": "direct_source_summary",
"completeness": "complete_for_stated_public_claim",
"retrieval_eligible": true,
"coverage_targets": ["five_level_definitions"]
},
{
"rule_id": "ktas-public-level-3-001",
"title": "KTAS 第三級公開定義",
"topic": "level_definition",
"ktas_levels": [3],
"content": "第三級代表病況具有進展為需要治療狀態的可能性,照護優先順序低於第一、二級,高於第四、五級。",
"source_id": "ktas-official-overview",
"source_locator": "「KTAS 분류 기준」表格的 KTAS 3",
"claim_kind": "direct_source_summary",
"completeness": "complete_for_stated_public_claim",
"retrieval_eligible": true,
"coverage_targets": ["five_level_definitions"]
},
{
"rule_id": "ktas-public-level-4-001",
"title": "KTAS 第四級公開定義",
"topic": "level_definition",
"ktas_levels": [4],
"content": "第四級代表可依年齡、疼痛以及惡化或併發症可能性,在一至兩小時內接受處置或重新評估的狀態。",
"source_id": "ktas-official-overview",
"source_locator": "「KTAS 분류 기준」表格的 KTAS 4",
"claim_kind": "direct_source_summary",
"completeness": "complete_for_stated_public_claim",
"retrieval_eligible": true,
"coverage_targets": ["five_level_definitions"]
},
{
"rule_id": "ktas-public-level-5-001",
"title": "KTAS 第五級公開定義",
"topic": "level_definition",
"ktas_levels": [5],
"content": "第五級代表不屬於緊急狀態,常見於慢性問題或惡化可能性較低的狀況,照護優先順序最低。",
"source_id": "ktas-official-overview",
"source_locator": "「KTAS 분류 기준」表格的 KTAS 5",
"claim_kind": "direct_source_summary",
"completeness": "complete_for_stated_public_claim",
"retrieval_eligible": true,
"coverage_targets": ["five_level_definitions"]
},
{
"rule_id": "ktas-public-primary-considerations-001",
"title": "研究公開描述的主要考量類型",
"topic": "primary_consideration",
"ktas_levels": [1, 2, 3, 4, 5],
"content": "公開研究列舉的主要考量包含生命徵象、疼痛分數、出血相關狀況與受傷機轉;這份摘要只證明這些類型存在,不包含完整門檻或套用順序。",
"source_id": "moon-2019-plos-one",
"source_locator": "Materials and methods/KTAS algorithm",
"claim_kind": "direct_source_summary",
"completeness": "partial_public_description",
"retrieval_eligible": true,
"coverage_targets": ["primary_consideration_categories"]
},
{
"rule_id": "ktas-public-secondary-considerations-001",
"title": "研究公開描述的次要考量例子",
"topic": "secondary_consideration",
"ktas_levels": [1, 2, 3, 4, 5],
"content": "公開研究以血糖與脫水程度作為次要考量的例子;這不是完整次要考量清單,也不能直接重建特定主訴的級數判斷。",
"source_id": "moon-2019-plos-one",
"source_locator": "Materials and methods/KTAS algorithm",
"claim_kind": "direct_source_summary",
"completeness": "partial_public_description",
"retrieval_eligible": true,
"coverage_targets": ["secondary_consideration_examples"]
}
],
"coverage_targets": [
{
"coverage_id": "system_purpose",
"label": "制度目的與適用情境",
"status": "covered",
"reason": "官方公開頁面直接說明制度目的與以症狀為導向的定位。"
},
{
"coverage_id": "high_level_workflow",
"label": "高階檢傷流程",
"status": "covered",
"reason": "官方頁面公開第一印象、感染詢問、主訴及主要與次要考量的順序。"
},
{
"coverage_id": "five_level_definitions",
"label": "五個級數的公開定義",
"status": "covered",
"reason": "官方頁面逐級提供定義與照護優先順序。"
},
{
"coverage_id": "primary_consideration_categories",
"label": "主要考量類型",
"status": "partial",
"reason": "研究列出數種類型,但不足以證明完整類型、門檻與例外規則。"
},
{
"coverage_id": "secondary_consideration_examples",
"label": "次要考量類型",
"status": "partial",
"reason": "研究只提供例子,不是逐主訴的完整清單。"
},
{
"coverage_id": "adult_chief_complaint_catalog",
"label": "成人完整主訴目錄",
"status": "gap",
"reason": "本版公開來源沒有提供可直接重建的完整成人主訴目錄。"
},
{
"coverage_id": "pediatric_chief_complaint_catalog",
"label": "兒童完整主訴目錄",
"status": "gap",
"reason": "本版公開來源沒有提供可直接重建的完整兒童主訴目錄。"
},
{
"coverage_id": "complaint_specific_thresholds",
"label": "逐主訴數值門檻",
"status": "gap",
"reason": "公開摘要不足以還原每個主訴與生命徵象、疼痛等條件的完整門檻。"
},
{
"coverage_id": "complete_decision_paths",
"label": "完整決策路徑與衝突規則",
"status": "gap",
"reason": "無法從目前來源確認所有分支、例外與多個考量衝突時的正式處理。"
},
{
"coverage_id": "official_version_history",
"label": "官方版本與變更紀錄",
"status": "gap",
"reason": "目前公開頁面沒有提供足以鎖定整套規則版本的完整變更紀錄。"
}
],
"outputs": {
"knowledge_units_path": "data/knowledge/ktas-public-v1/knowledge-units.jsonl",
"coverage_report_path": "results/public/day-16-knowledge-coverage.json",
"run_output_root": "results/runs/day-16"
}
}
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
src/triage_rag/knowledge_base/__init__.py建立知識庫子套件入口,公開契約錯誤與建置函式。
請在文字編輯器建立 src/triage_rag/knowledge_base/__init__.py,貼入以下完整內容並儲存:
"""Public, traceable knowledge-base helpers for the KTAS side project."""
from .builder import KnowledgeBaseContractError, build_public_knowledge_base
__all__ = ["KnowledgeBaseContractError", "build_public_knowledge_base"]
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
src/triage_rag/knowledge_base/builder.py驗證來源、rule_id、級數、檢索資格與缺口,再建立穩定 JSONL 紀錄和覆蓋報告。
請在文字編輯器建立 src/triage_rag/knowledge_base/builder.py,貼入以下完整內容並儲存:
"""Validate and build the Day 16 public KTAS knowledge base."""
from __future__ import annotations
import json
import re
from collections import Counter
from datetime import date
from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Tuple
from urllib.parse import urlparse
from triage_rag.reproducibility import canonical_json_bytes, sha256_bytes
JsonObject = Dict[str, Any]
RULE_ID_PATTERN = re.compile(r"^ktas-public-[a-z0-9-]+-[0-9]{3}$")
ALLOWED_SOURCE_KINDS = {
"official_web_page",
"peer_reviewed_open_access_article",
}
ALLOWED_CLAIM_KINDS = {"direct_source_summary", "series_inference"}
ALLOWED_COMPLETENESS = {
"complete_for_stated_public_claim",
"partial_public_description",
}
ALLOWED_COVERAGE_STATUS = {"covered", "partial", "gap"}
class KnowledgeBaseContractError(ValueError):
"""Raised when a source, knowledge unit, or coverage record breaks policy."""
def _require_nonempty_text(value: Any, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise KnowledgeBaseContractError(f"{field} 必須是非空字串")
return value.strip()
def _require_unique(values: Iterable[str], label: str) -> None:
values = list(values)
duplicates = sorted(value for value, count in Counter(values).items() if count > 1)
if duplicates:
raise KnowledgeBaseContractError(f"{label} 必須唯一,重複值:{duplicates}")
def _validate_iso_date(value: Any, field: str, *, optional: bool = False) -> None:
if value is None and optional:
return
text = _require_nonempty_text(value, field)
try:
date.fromisoformat(text)
except ValueError as exc:
raise KnowledgeBaseContractError(f"{field} 必須是 YYYY-MM-DD:{text}") from exc
def _validate_https_url(value: Any, field: str) -> None:
url = _require_nonempty_text(value, field)
parsed = urlparse(url)
if parsed.scheme != "https" or not parsed.netloc:
raise KnowledgeBaseContractError(f"{field} 必須是完整 HTTPS 網址:{url}")
def _validate_sources(sources: Any) -> Dict[str, JsonObject]:
if not isinstance(sources, list) or not sources:
raise KnowledgeBaseContractError("sources 必須是非空陣列")
source_ids = [_require_nonempty_text(item.get("source_id"), "source_id") for item in sources]
_require_unique(source_ids, "source_id")
by_id: Dict[str, JsonObject] = {}
for item in sources:
source_id = item["source_id"]
_require_nonempty_text(item.get("title"), f"{source_id}.title")
_require_nonempty_text(item.get("publisher"), f"{source_id}.publisher")
source_kind = item.get("source_kind")
if source_kind not in ALLOWED_SOURCE_KINDS:
raise KnowledgeBaseContractError(
f"{source_id}.source_kind 不支援:{source_kind}"
)
_validate_https_url(item.get("url"), f"{source_id}.url")
_validate_iso_date(item.get("published_at"), f"{source_id}.published_at", optional=True)
_validate_iso_date(item.get("accessed_at"), f"{source_id}.accessed_at")
_require_nonempty_text(item.get("license"), f"{source_id}.license")
capture = item.get("capture")
if not isinstance(capture, dict) or capture.get("mode") != "metadata_only":
raise KnowledgeBaseContractError(
f"{source_id}.capture.mode 目前必須是 metadata_only"
)
if capture.get("content_sha256") is not None:
raise KnowledgeBaseContractError(
f"{source_id} 未保存全文時不得填入整頁 content_sha256"
)
_require_nonempty_text(capture.get("note"), f"{source_id}.capture.note")
by_id[source_id] = item
return by_id
def _validate_coverage_targets(targets: Any) -> Dict[str, JsonObject]:
if not isinstance(targets, list) or not targets:
raise KnowledgeBaseContractError("coverage_targets 必須是非空陣列")
coverage_ids = [
_require_nonempty_text(item.get("coverage_id"), "coverage_id")
for item in targets
]
_require_unique(coverage_ids, "coverage_id")
by_id: Dict[str, JsonObject] = {}
for item in targets:
coverage_id = item["coverage_id"]
_require_nonempty_text(item.get("label"), f"{coverage_id}.label")
status = item.get("status")
if status not in ALLOWED_COVERAGE_STATUS:
raise KnowledgeBaseContractError(
f"{coverage_id}.status 不支援:{status}"
)
_require_nonempty_text(item.get("reason"), f"{coverage_id}.reason")
by_id[coverage_id] = item
return by_id
def _validate_units(
units: Any,
sources_by_id: Mapping[str, JsonObject],
coverage_by_id: Mapping[str, JsonObject],
retrieval_claim_kinds: set[str],
) -> List[JsonObject]:
if not isinstance(units, list) or not units:
raise KnowledgeBaseContractError("knowledge_units 必須是非空陣列")
rule_ids = [_require_nonempty_text(item.get("rule_id"), "rule_id") for item in units]
_require_unique(rule_ids, "rule_id")
validated: List[JsonObject] = []
for item in units:
rule_id = item["rule_id"]
if not RULE_ID_PATTERN.fullmatch(rule_id):
raise KnowledgeBaseContractError(f"rule_id 格式錯誤:{rule_id}")
_require_nonempty_text(item.get("title"), f"{rule_id}.title")
_require_nonempty_text(item.get("topic"), f"{rule_id}.topic")
content = _require_nonempty_text(item.get("content"), f"{rule_id}.content")
if len(content) > 500:
raise KnowledgeBaseContractError(f"{rule_id}.content 超過 500 字")
levels = item.get("ktas_levels")
if not isinstance(levels, list) or not levels:
raise KnowledgeBaseContractError(f"{rule_id}.ktas_levels 必須是非空陣列")
if any(not isinstance(level, int) or level not in range(1, 6) for level in levels):
raise KnowledgeBaseContractError(f"{rule_id}.ktas_levels 只能包含整數 1 到 5")
if levels != sorted(set(levels)):
raise KnowledgeBaseContractError(f"{rule_id}.ktas_levels 必須排序且不可重複")
source_id = item.get("source_id")
if source_id not in sources_by_id:
raise KnowledgeBaseContractError(f"{rule_id} 引用未知 source_id:{source_id}")
_require_nonempty_text(item.get("source_locator"), f"{rule_id}.source_locator")
claim_kind = item.get("claim_kind")
if claim_kind not in ALLOWED_CLAIM_KINDS:
raise KnowledgeBaseContractError(f"{rule_id}.claim_kind 不支援:{claim_kind}")
completeness = item.get("completeness")
if completeness not in ALLOWED_COMPLETENESS:
raise KnowledgeBaseContractError(
f"{rule_id}.completeness 不支援:{completeness}"
)
retrieval_eligible = item.get("retrieval_eligible")
if not isinstance(retrieval_eligible, bool):
raise KnowledgeBaseContractError(
f"{rule_id}.retrieval_eligible 必須是布林值"
)
if retrieval_eligible and claim_kind not in retrieval_claim_kinds:
raise KnowledgeBaseContractError(
f"{rule_id} 的 {claim_kind} 不得進入檢索"
)
target_ids = item.get("coverage_targets")
if not isinstance(target_ids, list) or not target_ids:
raise KnowledgeBaseContractError(
f"{rule_id}.coverage_targets 必須是非空陣列"
)
unknown_targets = sorted(set(target_ids) - set(coverage_by_id))
if unknown_targets:
raise KnowledgeBaseContractError(
f"{rule_id} 引用未知 coverage target:{unknown_targets}"
)
gap_targets = [
target_id
for target_id in target_ids
if coverage_by_id[target_id]["status"] == "gap"
]
if gap_targets:
raise KnowledgeBaseContractError(
f"{rule_id} 不得指向仍標示為 gap 的項目:{gap_targets}"
)
validated.append(dict(item))
return validated
def validate_config(config: Mapping[str, Any]) -> Tuple[
Dict[str, JsonObject], Dict[str, JsonObject], List[JsonObject]
]:
if config.get("schema_version") != 1:
raise KnowledgeBaseContractError("目前只支援 schema_version=1")
_require_nonempty_text(config.get("kb_id"), "kb_id")
_require_nonempty_text(config.get("kb_version"), "kb_version")
if config.get("scope") != "public_partial_knowledge_base_not_official_manual":
raise KnowledgeBaseContractError("scope 必須明確標示為部分公開知識庫")
governance = config.get("governance")
if not isinstance(governance, dict):
raise KnowledgeBaseContractError("governance 必須是物件")
retrieval_claim_kinds = set(governance.get("retrieval_may_use_claim_kinds", []))
if not retrieval_claim_kinds or not retrieval_claim_kinds <= ALLOWED_CLAIM_KINDS:
raise KnowledgeBaseContractError("retrieval_may_use_claim_kinds 設定無效")
for field in (
"source_text_policy",
"inference_policy",
"refresh_policy",
"clinical_use",
):
_require_nonempty_text(governance.get(field), f"governance.{field}")
sources_by_id = _validate_sources(config.get("sources"))
coverage_by_id = _validate_coverage_targets(config.get("coverage_targets"))
units = _validate_units(
config.get("knowledge_units"),
sources_by_id,
coverage_by_id,
retrieval_claim_kinds,
)
linked_targets = {
target_id for item in units for target_id in item["coverage_targets"]
}
for coverage_id, target in coverage_by_id.items():
is_linked = coverage_id in linked_targets
if target["status"] == "gap" and is_linked:
raise KnowledgeBaseContractError(f"gap 項目不應連到知識單元:{coverage_id}")
if target["status"] != "gap" and not is_linked:
raise KnowledgeBaseContractError(
f"非 gap 項目至少要有一個知識單元:{coverage_id}"
)
return sources_by_id, coverage_by_id, units
def build_public_knowledge_base(
config: Mapping[str, Any]
) -> Tuple[List[JsonObject], JsonObject]:
"""Return deterministic JSONL records and a deterministic coverage report."""
sources_by_id, coverage_by_id, units = validate_config(config)
records: List[JsonObject] = []
for item in sorted(units, key=lambda value: value["rule_id"]):
source = sources_by_id[item["source_id"]]
record = {
"schema_version": 1,
"kb_id": config["kb_id"],
"kb_version": config["kb_version"],
"scope": config["scope"],
"rule_id": item["rule_id"],
"title": item["title"],
"topic": item["topic"],
"ktas_levels": item["ktas_levels"],
"content": item["content"],
"claim_kind": item["claim_kind"],
"completeness": item["completeness"],
"retrieval_eligible": item["retrieval_eligible"],
"coverage_targets": item["coverage_targets"],
"source": {
"source_id": source["source_id"],
"title": source["title"],
"publisher": source["publisher"],
"url": source["url"],
"source_locator": item["source_locator"],
"published_at": source.get("published_at"),
"accessed_at": source["accessed_at"],
"license": source["license"],
"capture_mode": source["capture"]["mode"],
},
}
record["content_sha256"] = sha256_bytes(
canonical_json_bytes(
{
"rule_id": record["rule_id"],
"content": record["content"],
"source_id": record["source"]["source_id"],
"source_locator": record["source"]["source_locator"],
}
)
)
records.append(record)
rule_ids_by_coverage: Dict[str, List[str]] = {
coverage_id: [] for coverage_id in coverage_by_id
}
for record in records:
for coverage_id in record["coverage_targets"]:
rule_ids_by_coverage[coverage_id].append(record["rule_id"])
coverage_items = []
for target in config["coverage_targets"]:
coverage_items.append(
{
**target,
"rule_ids": sorted(rule_ids_by_coverage[target["coverage_id"]]),
}
)
status_counts = Counter(item["status"] for item in coverage_items)
report = {
"schema_version": 1,
"kb_id": config["kb_id"],
"kb_version": config["kb_version"],
"scope": config["scope"],
"source_count": len(sources_by_id),
"knowledge_unit_count": len(records),
"retrieval_eligible_count": sum(
record["retrieval_eligible"] for record in records
),
"claim_kind_counts": dict(
sorted(Counter(record["claim_kind"] for record in records).items())
),
"coverage": {
"checklist_item_count": len(coverage_items),
"status_counts": {
status: status_counts.get(status, 0)
for status in ("covered", "partial", "gap")
},
"items": coverage_items,
"interpretation": "這是本系列自行定義清單的覆蓋狀態,不是 KTAS 官方規則完整率。",
},
"source_capture": {
"metadata_only_count": sum(
source["capture"]["mode"] == "metadata_only"
for source in sources_by_id.values()
),
"full_page_snapshots_stored": 0,
},
"checks": {
"rule_ids_unique": True,
"source_ids_resolved": True,
"retrieval_excludes_series_inference": True,
"gap_items_have_no_rule_ids": all(
not item["rule_ids"]
for item in coverage_items
if item["status"] == "gap"
),
"scope_marks_partial_public_kb": True,
},
"warning": "本產物不是完整 KTAS 官方手冊,不得直接用於臨床自動決策。",
}
report["knowledge_records_sha256"] = sha256_bytes(
b"".join(canonical_json_bytes(record) + b"\n" for record in records)
)
return records, report
def write_jsonl(path: Path, records: Iterable[Mapping[str, Any]]) -> None:
"""Write one canonical JSON object per line."""
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("wb") as handle:
for record in records:
handle.write(canonical_json_bytes(record))
handle.write(b"\n")
def read_jsonl(path: Path) -> List[JsonObject]:
"""Read JSON Lines records for verification and tests."""
with path.open("r", encoding="utf-8") as handle:
return [json.loads(line) for line in handle if line.strip()]
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
scripts/build_day16_knowledge_base.py讀取設定、呼叫核心模組、寫出知識單元與覆蓋報告,並保存可追溯的執行紀錄。
請在文字編輯器建立 scripts/build_day16_knowledge_base.py,貼入以下完整內容並儲存:
#!/usr/bin/env python3
"""Build the traceable, partial public KTAS knowledge base for Day 16."""
from __future__ import annotations
import argparse
import sys
from datetime import datetime, timezone
from pathlib import Path
from triage_rag.knowledge_base import build_public_knowledge_base
from triage_rag.knowledge_base.builder import read_jsonl, write_jsonl
from triage_rag.reproducibility import (
file_record,
git_state,
load_json,
resolve_project_path,
sha256_file,
write_json,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONFIG = "configs/knowledge/day-16-public-ktas-kb.json"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="建立 Day 16 可追溯的部分 KTAS 公開知識庫。"
)
parser.add_argument("--config", default=DEFAULT_CONFIG)
return parser.parse_args()
def main() -> int:
args = parse_args()
config_path = resolve_project_path(PROJECT_ROOT, args.config)
config = load_json(config_path)
records, report = build_public_knowledge_base(config)
output_path = resolve_project_path(
PROJECT_ROOT, config["outputs"]["knowledge_units_path"]
)
report_path = resolve_project_path(
PROJECT_ROOT, config["outputs"]["coverage_report_path"]
)
write_jsonl(output_path, records)
write_json(report_path, report)
written_records = read_jsonl(output_path)
if written_records != records:
raise RuntimeError("JSONL 寫入後內容與記憶體中的知識單元不同")
if sha256_file(output_path) != report["knowledge_records_sha256"]:
raise RuntimeError("JSONL 的 SHA-256 與覆蓋報告不同")
started_at = datetime.now(timezone.utc)
run_id = (
f"{started_at.strftime('%Y%m%dT%H%M%S%fZ')}-"
f"{report['knowledge_records_sha256'][:8]}"
)
run_root = resolve_project_path(
PROJECT_ROOT, config["outputs"]["run_output_root"]
)
run_directory = run_root / run_id
run_directory.mkdir(parents=True, exist_ok=False)
manifest = {
"manifest_schema_version": 1,
"run_id": run_id,
"started_at_utc": started_at.isoformat().replace("+00:00", "Z"),
"command": [sys.executable, *sys.argv],
"documented_command": [
"poetry",
"run",
"python",
"scripts/build_day16_knowledge_base.py",
],
"git": git_state(PROJECT_ROOT),
"inputs": [file_record(PROJECT_ROOT, args.config)],
"outputs": [
file_record(PROJECT_ROOT, str(output_path.relative_to(PROJECT_ROOT))),
file_record(PROJECT_ROOT, str(report_path.relative_to(PROJECT_ROOT))),
],
"parameters": {
"kb_id": config["kb_id"],
"kb_version": config["kb_version"],
"scope": config["scope"],
},
"privacy": "只保存公開來源的自行撰寫摘要,不讀取或輸出病患資料。",
}
manifest_path = run_directory / "run-manifest.json"
write_json(manifest_path, manifest)
counts = report["coverage"]["status_counts"]
print("Day 16 公開知識庫:通過")
print(f"來源:{report['source_count']} 個")
print(f"知識單元:{report['knowledge_unit_count']} 筆")
print(
"覆蓋清單:"
f"完整 {counts['covered']}、部分 {counts['partial']}、缺口 {counts['gap']}"
)
print(f"知識單元:{output_path.relative_to(PROJECT_ROOT)}")
print(f"覆蓋報告:{report_path.relative_to(PROJECT_ROOT)}")
print(f"執行紀錄:{manifest_path.relative_to(PROJECT_ROOT)}")
print("注意:這是部分公開知識庫,不是完整 KTAS 官方手冊。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
tests/test_knowledge_base.py驗證建置可重現,並確保重複識別碼、未知來源、推論進入檢索與缺口偽裝成規則都會失敗。
請在文字編輯器建立 tests/test_knowledge_base.py,貼入以下完整內容並儲存:
from __future__ import annotations
import copy
import unittest
from pathlib import Path
from triage_rag.knowledge_base import (
KnowledgeBaseContractError,
build_public_knowledge_base,
)
from triage_rag.reproducibility import load_json
ROOT = Path(__file__).resolve().parents[1]
CONFIG_PATH = ROOT / "configs" / "knowledge" / "day-16-public-ktas-kb.json"
class Day16KnowledgeBaseTests(unittest.TestCase):
def setUp(self) -> None:
self.config = load_json(CONFIG_PATH)
def test_build_is_deterministic_and_scope_is_explicit(self) -> None:
first_records, first_report = build_public_knowledge_base(self.config)
second_records, second_report = build_public_knowledge_base(self.config)
self.assertEqual(first_records, second_records)
self.assertEqual(first_report, second_report)
self.assertEqual(len(first_records), 9)
self.assertEqual(first_report["source_count"], 2)
self.assertEqual(
first_report["scope"],
"public_partial_knowledge_base_not_official_manual",
)
self.assertEqual(
first_report["coverage"]["status_counts"],
{"covered": 3, "partial": 2, "gap": 5},
)
def test_every_retrieval_record_has_traceable_source(self) -> None:
records, _ = build_public_knowledge_base(self.config)
for record in records:
self.assertTrue(record["retrieval_eligible"])
self.assertEqual(record["claim_kind"], "direct_source_summary")
self.assertTrue(record["source"]["url"].startswith("https://"))
self.assertTrue(record["source"]["source_locator"])
self.assertEqual(len(record["content_sha256"]), 64)
def test_duplicate_rule_id_is_rejected(self) -> None:
invalid = copy.deepcopy(self.config)
invalid["knowledge_units"][1]["rule_id"] = invalid["knowledge_units"][0][
"rule_id"
]
with self.assertRaisesRegex(KnowledgeBaseContractError, "rule_id 必須唯一"):
build_public_knowledge_base(invalid)
def test_unknown_source_is_rejected(self) -> None:
invalid = copy.deepcopy(self.config)
invalid["knowledge_units"][0]["source_id"] = "missing-source"
with self.assertRaisesRegex(KnowledgeBaseContractError, "未知 source_id"):
build_public_knowledge_base(invalid)
def test_series_inference_cannot_enter_retrieval(self) -> None:
invalid = copy.deepcopy(self.config)
invalid["knowledge_units"][0]["claim_kind"] = "series_inference"
with self.assertRaisesRegex(KnowledgeBaseContractError, "不得進入檢索"):
build_public_knowledge_base(invalid)
def test_gap_target_cannot_have_knowledge_unit(self) -> None:
invalid = copy.deepcopy(self.config)
invalid["knowledge_units"][0]["coverage_targets"] = [
"complete_decision_paths"
]
with self.assertRaisesRegex(KnowledgeBaseContractError, "仍標示為 gap"):
build_public_knowledge_base(invalid)
if __name__ == "__main__":
unittest.main()
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
以下命令會在需要時產生套件鎖定檔,接著檢查設定格式與 Python 語法;它們不會啟動模型,也不會把病患資料送到網路:
poetry run python -m json.tool configs/knowledge/day-16-public-ktas-kb.json
poetry run python -m py_compile src/triage_rag/knowledge_base/__init__.py src/triage_rag/knowledge_base/builder.py scripts/build_day16_knowledge_base.py tests/test_knowledge_base.py
poetry run python -m unittest tests.test_knowledge_base
每個命令都應正常結束。若 JSON 顯示行號,先檢查貼上時是否遺漏逗號、引號或括號;若 py_compile 報錯,先依行號修正縮排或漏貼內容。靜態檢查通過後,再執行本文後面的正式步驟。
完整檔案建立並通過靜態檢查後,請保持工作目錄在專案根目錄,依序執行以下步驟。
目的:確認合法設定可重現,也確認四種故意破壞的知識紀錄會被拒絕。
輸入是 configs/knowledge/day-16-public-ktas-kb.json 與 tests/test_knowledge_base.py,操作命令是:
poetry run python -m unittest tests.test_knowledge_base
成功時會看到類似以下結果:
......
----------------------------------------------------------------------
Ran 6 tests in ...s
OK
六個點代表六個測試方法都已執行;OK 表示所有預期成立。這一步不會建立正式知識庫檔案,只驗證核心邏輯。
若顯示 ModuleNotFoundError: No module named 'triage_rag',先確認目前位於專案根目錄,接著重新執行 poetry install。若某個測試顯示 FAIL 或 ERROR,不要跳過;先檢查完整檔案是否漏貼、縮排是否改變,以及設定中的識別碼是否被手動修改。
目的:把人工審查的 JSON 設定轉成後續檢索可以逐筆讀取的 JSONL,同時留下缺口與執行紀錄。
輸入是 Day 16 設定檔;輸出是九筆知識單元、公開覆蓋報告與一個新的本機執行目錄。執行:
poetry run python scripts/build_day16_knowledge_base.py
成功時會看到:
Day 16 公開知識庫:通過
來源:2 個
知識單元:9 筆
覆蓋清單:完整 3、部分 2、缺口 5
知識單元:data/knowledge/ktas-public-v1/knowledge-units.jsonl
覆蓋報告:results/public/day-16-knowledge-coverage.json
執行紀錄:results/runs/day-16/<本次 run_id>/run-manifest.json
注意:這是部分公開知識庫,不是完整 KTAS 官方手冊。
run_id 由執行時間與知識內容雜湊前八碼組成,所以每次執行的資料夾名稱不同。這是預期行為;不能為了得到相同資料夾名稱而覆寫舊紀錄。
目的:確認輸出的數量、檢查結果與部分公開範圍都已寫入檔案,而不是只在終端機顯示一次。
執行:
poetry run python -m json.tool results/public/day-16-knowledge-coverage.json
輸出內容應包含:
source_count 為 2。knowledge_unit_count 為 9。retrieval_eligible_count 為 9。status_counts 為 covered: 3、partial: 2、gap: 5。full_page_snapshots_stored 為 0。checks 都是 true。scope 是 public_partial_knowledge_base_not_official_manual。若數量不同,先不要修改報告;報告是自動產物,真正需要檢查的是輸入設定與建置程式。手動修報告只會讓產物和來源失去一致性。
目的:確認 JSONL 每一行都是完整 JSON 物件,而且第一筆包含可追溯來源與內容雜湊。
先計算行數:
poetry run python -c "from pathlib import Path; p=Path('data/knowledge/ktas-public-v1/knowledge-units.jsonl'); print(sum(1 for line in p.open(encoding='utf-8') if line.strip()))"
預期輸出是:
9
接著讀取第一筆並只印出本篇要檢查的欄位:
poetry run python -c "import json; from pathlib import Path; first=json.loads(Path('data/knowledge/ktas-public-v1/knowledge-units.jsonl').read_text(encoding='utf-8').splitlines()[0]); print({key: first[key] for key in ['rule_id', 'claim_kind', 'retrieval_eligible', 'content_sha256']}); print(first['source'])"
你應該看到一個 ktas-public-... 開頭的 rule_id、direct_source_summary、True、64 個十六進位字元組成的 content_sha256,以及包含網址、段落定位、查閱日期和權利說明的 source 物件。
這一步只印出第一筆供人工查看。真正的建置程式在寫入後已重新讀取全部九行,並核對整份 JSONL 的 SHA-256;若任何一行無法解析或寫入內容不同,程式會直接失敗。
builder.py 不只是把 JSON 換成 JSONL。它會依序檢查:
schema_version 與全域 scope 是否正確。YYYY-MM-DD。metadata_only 與空的整頁雜湊。rule_id 是否符合固定格式且不重複。ktas_levels 是否只含排序後、不重複的 1 到 5。claim_kind 能把 retrieval_eligible 設為 true。gap 的項目是否完全沒有知識單元。這些都是工程契約,不是臨床有效性證明。程式能發現「引用不存在來源」這類結構錯誤,卻無法自行判斷人工摘要是否誤解韓文或研究內容,因此來源整理仍需要人工複核。
本篇實際完成的是一個可以重跑、可以追溯、會公開承認缺口的知識資料層:
本篇尚未完成以下工作:
把這些「尚未完成」寫清楚很重要。今天的九筆知識只證明來源治理與資料格式可以運作,不代表檢索品質已經通過,也不代表臨床安全。
KTAS_expert 加進知識內容KTAS_expert 是評估標籤。若把它寫進知識單元,後續模型可能在檢索時直接看到與測試答案高度相關的資訊,造成標籤洩漏。Day 16 的設定與程式不讀取任何 Day 15 CSV,就是為了維持這條邊界。
如果我們根據兩個來源推論「公開資料不完整」,這個判斷可以寫進覆蓋報告;它不能改名成 direct_source_summary 再送進檢索。測試會阻止 series_inference 在 retrieval_eligible=true 時通過。
complete_for_stated_public_claim 看成完整手冊這個值只表示「對目前這句公開主張而言,摘要已保留必要意思」。例如五級公開定義可以各自成為完整摘要,但整套 KTAS 的主訴、門檻與例外仍然缺失。判斷全域範圍時一定要看 scope 與覆蓋報告,不能只看單筆 completeness。
重新查閱來源並修改摘要時,至少要同步更新 accessed_at、提升 kb_version、重新執行測試與建置,並保留新的 run manifest。不要只改 JSONL 輸出,因為下一次建置會覆蓋手動修改,而且無法追溯修改理由。
空白內容或「規則未知」不應進入檢索索引。缺口應保存在 coverage item 中,狀態設為 gap 且 rule_ids 為空。這樣後續檢索不到規則時,系統才能誠實拒答或要求人工確認,而不是取回一筆看似有效但沒有內容的紀錄。
今天完成了 RAG 的知識來源邊界,但還沒有開始做檢索。
最重要的不是九筆這個數字,而是每一筆都能回答:內容是什麼、來自哪裡、查閱哪個位置、完整到哪裡,以及能不能進入檢索。與此同時,病患資料和 KTAS_expert 標籤完全留在另一條資料管線,不會被用來反推規則。
目前結果是三項已覆蓋、兩項部分覆蓋與五項缺口。這些數字描述本系列自訂查核清單,不是 KTAS 官方規則完整率;整體產物仍然是研究與教學用途的部分公開知識庫,也不取代護理師或醫師的專業判斷。
Day 17 會使用今天產生的九筆知識單元,正式處理切塊(Chunking)與中繼資料設計。
我們會比較固定字數、段落與規則導向切塊,並建立平面與階層知識結構。選擇切塊方法時,只使用獨立的檢索測試案例,不會偷看病患測試標籤或用最終分類分數反向調整知識庫。