feat: 装配两个库(不含 CEUS 结果)

按任务书 01 实现 delivery、library、answers、runs(快照部分)、build 五个模块,
scripts/build.py、scripts/scan_library.py 与 config/build.yaml(decode_codes: false)。
交付包整表检查 T1–T4、映射表检查 T5、逐例检查 C01–C13,一例遇到第一项不过即整例跳过。

与任务书原文的出入:
- C04 报告表名随视频名配对(负责人 2026-10-16 定)。
- 按两轮独立审阅加严 C11(更多换行符、比例号冒号)、C12(同义词与词根)、
  C13(空格、周岁、中文数字、繁体);泄露扫描做 NFKC;关键图只接受能保真重编码的 PNG;
  YAML 配置拒绝重复键和空路径。
- import-linter 第 1 条拆成互不依赖与不引用其他 cadx 模块两条。

文档:结构.md 第五、六、八部分按实际更新;新增 guides/装配两个库.md;WORK_LOG 更新。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 03:00:20 -04:00
parent bcd5932a66
commit bceb01e8fa
30 changed files with 3600 additions and 18 deletions
+70 -14
View File
@@ -98,7 +98,7 @@ work/ 中间文件 ─────────► ② 装配两个库
| 层 | 模块 | 藏住的决定 | 旧项目里的对应内容 |
| --- | --- | --- | --- |
| 入口 | `scripts/`(不在包内) | 人怎么操作:每段一个脚本(`ceus_quant`、`build`、`consult`、`evaluate`),另有一个按顺序调用四段的总入口;读配置文件,把模块装配起来 | `debugs/t1_local_debug.py`、`debugs/tic_lognormal_replay.py` |
| 组织 | build | 两个库怎么装配:每一路放哪些内容(可见性的唯一决定处)、化名怎么取、病例顺序怎么打乱、清单记什么。全项目只有它同时碰得到原始资料和答案 | `app/domain/input_readers.py`、`ingestion*.py` 的装配部分 |
| 组织 | build | 两个库怎么装配:每一路放哪些内容(可见性的唯一决定处)、化名怎么取(化名随机,资料库按化名排序,顺序就与交付顺序无关)、清单记什么。全项目只有它同时碰得到原始资料和答案 | `app/domain/input_readers.py`、`ingestion*.py` 的装配部分 |
| 组织 | consult | 会诊怎么进行:几个专家、谁先说、后发言的看得到前面的什么、什么时候结束;「调一次模型」的统一入口(先存请求、记开始、调用、存回复、记结束)也在这里 | 没有 |
| 组织 | evaluate | 评价口径:指标、统计方法、字段级对照怎么计分 | 没有(`core/eval` 已删) |
| 格式 | library | 给专家的资料库的目录布局、文件格式和读取;按路分别读出,一路一个对象 | `app/domain/input_packages.py` 的数据形状 |
@@ -137,7 +137,7 @@ work/ 中间文件 ─────────► ② 装配两个库
### 几条边界
- **专家能看见什么,只在 build 一处决定。** build 按白名单把每路允许的内容写进 `library/`,此外任何模块都不挑选给专家的材料。consult 只拿到某一次构建的路径,经 library 按路读出;它不依赖 delivery、answers、build,读原始资料和答案的代码对它来说根本不存在。报告的「印象 / 结论」段(医生写的诊断倾向)由 delivery 认出、由 build 剔除,`library/` 里只有「所见」描述。
- **交付资料的检查不过,整例跳过。** delivery 逐例检查(编号、文件、取值、跨表是否一致,具体检查项在装配两个库的任务书里列),build 把没过的病例整例跳过:资料库和答案库的病理诊断里都没有它,答案库的跳过清单写明它没过哪一项、涉及哪个文件哪一列。一例遇到第一项不过就不再往下查,清单上每例只记一项。整张表读不了时整次构建停下。规则见 `AGENTS.md`「错误怎么处理」。
- **交付资料的检查不过,整例跳过。** delivery 逐例检查(编号、文件、取值、跨表是否一致;检查项 C01–C13 的速查表见 `../guides/装配两个库.md`,规则以 `src/cadx/delivery/_cases.py`、`_text.py` 为准),build 把没过的病例整例跳过:资料库和答案库的病理诊断里都没有它,答案库的跳过清单写明它没过哪一项、涉及哪个文件哪一列。一例遇到第一项不过就不再往下查,清单上每例只记一项。整张表读不了时整次构建停下。规则见 `AGENTS.md`「错误怎么处理」。
- **三路互相隔开。** 每个专家的提示词只用自己那一路的对象拼。consult 里拼某一路提示词的函数只接收那一路的对象,不接收整个病例。这是研究设计本身:模拟各科医生各看各的检查结果,再在会诊时交换意见。会诊时一路能看到另一路的什么,由 consult 的会诊流程决定,在会诊的任务书里定。
- **我们自己调模型只有一个入口:consult 的「调一次模型」。** 它按固定顺序做五步:把完整请求存进账本的大块存储、写「调用开始」、经 llm 调用、把完整回复存进大块存储、写「调用结束」。llm 只管调用,不写账本,所以任何一次模型调用都绕不开登记。中断后重启时,从账本找出开始了没结束的调用,记为「状态未知」,不重做已有结果的调用。做重复运行(同一病例、同一专家调多次,测一致性)时,每一次都恰好登记一次。
- **CEUS 算法不知道分割模型是谁,也不管中间文件放哪。** ceus 接收视频路径、参数和分割结果,交出曲线、拟合、关键帧和指标;中间文件写在哪、按什么复用,由 `scripts/ceus_quant.py` 决定(目前只有它一个使用者,出现第二个使用者再收进模块)。segment 只管「给提示、出逐帧掩码」,不认识分屏和绿框。
@@ -156,6 +156,7 @@ library/<构建号>/
cases/<化名>/
clinical/clinical.json 临床那一路
ultrasound/ 超声那一路:report.txt、key.png、keyframes/*.png、tic.json
(后两项等 CEUS 定量落地后由 build 加入,目前只有前两项)
mr/report.txt MR 那一路
```
@@ -172,7 +173,7 @@ library/<构建号>/
性别、年龄只给临床一路:医院在两份报告工作簿里也复制了这两列,但影像两路先看到它们会被人口学先验带偏(旧项目 design 0026 决定三)。
病例用化名,目录顺序打乱:交付文件名里带病例号(如 `U001_roi2.mp4`),行序可能和诊断有关。图片写入时去掉文件元数据。构建完不再修改;要改就做一次新构建。
病例用化名,目录和清单按化名排序:交付文件名里带病例号(如 `U001_roi2.mp4`),行序可能和诊断有关;化名是按种子随机抽的 4 位小写字母加数字,按它排序后的顺序与交付顺序无关。图片写入时去掉文件元数据。构建完不再修改;要改就做一次新构建。
### 标准答案库(`answers/<构建号>/`)
@@ -241,8 +242,8 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0
| 文件 | 给谁用 | 内容 |
| --- | --- | --- |
| `build.yaml` | `scripts/build.py` | 交付包位置、两个库的输出位置、化名与打乱顺序的随机种子、编码解码开关与映射表位置 |
| `code_maps.yaml` | build(经 `build.yaml` 引用) | 编码映射表:交付包里每个用数字编码的列,每个数字代表什么(如性别的 1、2) |
| `build.yaml` | `scripts/build.py` | `delivery_dir`(交付包位置)、`library_root`、`answers_root`(两个库的上级目录)、`alias_seed`(生成化名的随机种子)、`decode_codes`(编码解码开关)、`code_maps`(映射表位置或 null);字段定义和校验在 `src/cadx/build/_config.py`,逐项说明见 `../guides/装配两个库.md` |
| `code_maps.yaml` | build(经 `build.yaml` 引用) | 编码映射表:交付包里每个用数字编码的列,每个数字代表什么(如性别的 1、2)。还没向临床方拿到,尚未建 |
| `ceus.yaml` | `scripts/ceus_quant.py` | CEUS 定量的全部参数,每个参数用注释写明出处 |
| `consult.yaml` | `scripts/consult.py` | 会诊的实验配置与模型用途 |
| `evaluate.yaml` | `scripts/evaluate.py` | 评价的指标与统计设置 |
@@ -259,11 +260,16 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0
文件格式:数组用 npz(读取时 `allow_pickle=False`,pickle 读取时会执行任意代码),每例的标量和摘要用 json,跨病例的表用 csv,图用 png。缺失值写 null,不写 NaN 或 0:0 是合法的测量值,NaN 在 json 里不合法。
导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的,没有的保护不写:
导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的(共 6 条),没有的保护不写:
- `cadx` 不引用 `torch`、`sam2`。
- delivery、library、answers、runs 互不引用。
- delivery、library、answers、runs 不引用其他 `cadx` 模块(build 和规划中的各模块)。
- build 不引用 delivery、library、answers、runs 以外的 `cadx` 模块(ceus 落地时放行)。
- 只有 delivery 引用 `openpyxl`(交付包的 Excel 只有它碰)。
- 只有 library 引用 `PIL`(写资料库的图片时去掉元数据)。
第三部分的依赖表和下面几条外部库独占,随模块落地逐条写成契约(import-linter 只能检查已经存在的模块):只有 delivery 引用 `openpyxl`;只有 library 引用 `PIL`(写资料库的图片时去掉元数据);只有 segment 引用 `torch`、`sam2`;只有 llm 引用 `polygateway`;只有 ledger 引用 `sqlite3`。
第三部分依赖表的其余部分和下面几条外部库独占,随模块落地逐条写成契约(import-linter 只能检查已经存在的模块):只有 segment 引用 `torch`、`sam2`(落地时把上面第一条改成放行 segment);只有 llm 引用 `polygateway`;只有 ledger 引用 `sqlite3`。
契约管不到的,不要当成机器守住了:
@@ -283,17 +289,17 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
| --- | --- | --- | --- |
| ceus | 6000–7500(基本照搬旧算法,去掉只为产品层服务的部分) | — | `ceus_roi/` 除 `service.py` 7330,`video_decoder.py` 135 |
| segment | 150–300 | — | `medsam2_local_track.py` 240 |
| delivery | 400–700 | — | `clinical.py` 266、`modalities.py` 522、`workbook.py` 99、`delivery.py` 134,共 1021,其中入库与数据库部分不迁 |
| library | 150–300 | — | `input_packages.py` 104 |
| answers | 100–200 | — | — |
| delivery | 400–700 | 733 | `clinical.py` 266、`modalities.py` 522、`workbook.py` 99、`delivery.py` 134,共 1021,其中入库与数据库部分不迁 |
| library | 150–300 | 300 | `input_packages.py` 104 |
| answers | 100–200 | 201 | — |
| verdicts | 100–200 | — | — |
| build | 300–500 | — | `input_readers.py` 329 的装配部分 |
| build | 300–500 | 307 | `input_readers.py` 329 的装配部分 |
| llm | 200–450 | — | ClinDB `llm/` 415 |
| ledger | 200–300 | — | ClinDB `ledger/` 274 |
| runs | 100–250 | — | — |
| runs | 100–250 | 86(只含快照部分) | — |
| consult | 500–1000(会诊口径定后重估) | — | — |
| evaluate | 300–600(评价口径定后重估) | — | 已删的 `core/eval` 622 |
| scripts/ 每个 | ≤ 300 | — | `t1_local_debug.py` 762、`tic_lognormal_replay.py` 627 |
| scripts/ 每个 | ≤ 300 | `build.py` 37、`scan_library.py` 88 | `t1_local_debug.py` 762、`tic_lognormal_replay.py` 627 |
---
@@ -311,4 +317,54 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
## 八、各模块对外的函数与类型
写到哪个模块时,再把它对外的函数和类型补在这里。
写到哪个模块时,再把它对外的函数和类型补在这里。以各子包 `__init__.py` 导出的为准;参数和字段的细节看代码的 docstring。
### delivery(`src/cadx/delivery/`)
- `read_delivery(delivery_dir, code_maps)`:读交付包,做整张表的检查 T1–T4 和逐例检查 C01–C13,交出 `DeliveryResult`;`code_maps` 为 None 表示不解码。
- `DeliveryResult`:一次读取的结果:通过的病例(按病例号排序)、跳过记录、病例总数、认不出编号的行数、因没有映射表而没比对的项。
- `CaseRecord`:一个检查全部通过的病例:三位病例号、视频号、临床一路的 22 列、超声描述、MR 描述、关键图路径、三张表的诊断编码。
- `DiagnosisCodes`:一例在临床表、超声表、MR 表里各自的诊断编码(C10 保证三者相等)。
- `SkipRecord`:一条跳过记录:病例号(认不出编号时是「文件 第 N 行」或「病例目录 名字」)、检查编号、检查说明、涉及的文件和列、涉及值的 JSON 字符串。
- `DeliveryError`:整张表读不了(T1–T4)时抛出,消息以检查编号开头,整次构建停下。
- `CODED_COLUMNS`:用数字编码、需要映射表才知道含义的 17 项列名,映射表必须恰好覆盖它们。
- `CodeMaps`:映射表的类型,「列名 → {数字 → 含义}」。
- `SKIPPED_NOTE`:跳过清单的说明句(每例只记第一项没过的检查),写进答案库清单并在终端打印。
- `review_sentences(text)`:报告文字里含「考虑」「可能」「倾向」「提示」的句子,每句一条(命中的词,句子),用来生成复核清单。
### library(`src/cadx/library/`)
- `write_library(build_dir, build_id, git_commit, git_clean, config_sha256, codes_decoded, uncompared, ceus_source, cases)`:把一次构建的各例三路内容写进一个已存在的空目录,关键图去掉附加数据重新编码,最后写 `manifest.json`。
- `LibraryCase`:要写进资料库的一例:化名、临床一路的字段、超声描述、关键图源文件、MR 描述。
- `list_aliases(build_dir)`:一次构建的全部化名(按化名排序)。
- `read_clinical(build_dir, alias)`、`read_ultrasound(build_dir, alias)`、`read_mr(build_dir, alias)`:按路读出一例,读时核对 SHA-256,拒绝 `.partial` 目录。
- `ClinicalRoute`:临床一路:是否已解码、22 列字段。
- `UltrasoundRoute`:超声一路:报告「所见」原文、关键图路径。
- `MrRoute`:MR 一路:报告「所见」原文。
- `scan_for_leaks(build_dir, forbidden_strings, forbidden_keys)`:在构建目录的全部 `.txt`、`.json` 里找禁止的字符串(NFKC 归一、不分大小写、按子串)和禁止的 JSON 键名,返回命中列表。
- `LeakHit`:一处命中:文件(相对构建目录)、类别(「字符串」或「键名」)、命中的词。
### answers(`src/cadx/answers/`)
- `write_answers(answers_dir, build_id, skipped_note, aliases, diagnoses, skipped, reviews)`:写 `aliases.csv`、`diagnoses.csv`、`skipped.csv`、`review.csv`,再给目录里全部文件(含 `snapshot/`)算 SHA-256 写进 `manifest.json`。
- `AliasRow`:化名、三位病例号、视频号。
- `DiagnosisRow`:化名、病例号、三张表的诊断编码、诊断名(不解码时为 None)。
- `SkippedRow`:跳过清单的一行,字段同 `SkipRecord`。
- `ReviewRow`:复核清单的一行:化名、路(「超声」或「MR」)、命中的词、所在句子。
- `read_aliases(answers_dir)`、`read_diagnoses(answers_dir)`:评价用的读取,读时核对 SHA-256 和表头,拒绝 `.partial` 目录。
### runs(`src/cadx/runs/`,目前只有快照部分)
- `read_git_state(repo_root)`:读出完整和短提交号、工作区是否干净(含未跟踪文件)、`git diff HEAD` 输出的 SHA-256。
- `GitState`:上面这四项。
- `make_build_id(now, git)`:构建号 `YYYYMMDD-HHMMSS-<git 短哈希>`,工作区不干净时末尾加 `-dirty`。
- `write_snapshot(snapshot_dir, build_id, git, started_at, finished_at, uv_lock, configs)`:新建快照目录,复制配置原文并写 `snapshot.json`(构建号、git 状态、Python 版本、`uv.lock` 哈希、起止时间、各配置的哈希)。
### build(`src/cadx/build/`)
- `run_build(config_path, repo_root, now)`:完整做一次构建:读配置和映射表、读交付包、取化名、先写进 `.partial` 目录再改名,交出 `BuildSummary`。
- `BuildSummary`:供终端打印的计数:构建号、两个库的路径、病例总数、进库数、跳过数、认不出编号的行数、按检查编号分的跳过数、复核清单条数、跳过清单说明句。
- `BuildConfig`:`build.yaml` 的字段定义(pydantic,字段必填、不许多写、读入后不可改)。
- `load_build_config(path, repo_root)`:读并校验 `build.yaml`,相对路径按仓库根目录解析。
- `load_code_maps(path)`:读映射表并做 T5(恰好覆盖 17 项,性别映射到「男」「女」,诊断映射到四个亚型缩写)。
- `make_aliases(case_nos, seed)`:病例号排序后依次抽 4 位化名,同种子同病例得到同样的化名。
+212
View File
@@ -0,0 +1,212 @@
# 装配两个库
读者:要跑一次「装配两个库」、或者要把跳过清单交给医生的人。这一步读入医院的交付包,逐例检查,把检查全部通过的病例写成两个库:给 AI 专家看的**资料库**(`library/`)和只给评价用的**标准答案库**(`answers/`)。它在整条流水线里的位置见 `../explanation/结构.md` 第二部分;为什么这样设计见 `../任务书/01-装配两个库.md` 和 `../design/0002-四段流水线与两个库.md`。
## 先认识几个词
- **四类胰腺囊性肿瘤**:IPMN(导管内乳头状黏液性肿瘤)、SPN(实性假乳头状肿瘤)、MCN(黏液性囊性肿瘤)、SCN(浆液性囊性肿瘤)。项目要专家判断病灶属于哪一类,所以资料库里出现这些名字就等于泄露答案。
- **金标准**:术后或穿刺的病理结论,交付表里叫「病理类型」或 `Diagnosis`,用 1–4 的数字记。
- **CEUS、MR**:CEUS 是超声造影视频(往静脉打造影剂后录的超声);MR 是磁共振检查,本项目只有它的文字报告。这一步不处理 CEUS 视频,只检查它在不在。
- **交付包**:临床方交来的一批原始资料,目前是 `data/交付包_20260908_40例/`:三张汇总表(临床工作簿、超声报告、MR 报告)和每例一个病例目录(关键图、CEUS 视频、单例报告表)。
- **单例报告表**:每个病例目录里的一份 Excel,只有一行,列与超声汇总表相同(7 列)。它和汇总表重复,用来核对两份是否一致。
- **所见、结论段**:一份影像报告通常分「所见」(描述看到了什么)和「印象 / 结论」(医生的诊断倾向)。交付包的报告文字只有所见,这一步检查里面没有混进结论段(C11)。
- **`roi2`**:交付视频文件名里的版本标记,表示视频上带医生画的病灶框(ROI,感兴趣区)。
- **推注开始时间**:超声表的一列,单位秒,表示视频第 0 秒时造影剂已经推注(注射)了多少秒。以后 CEUS 定量(从视频里算亮度曲线、挑关键帧、算灌注指标的那一步)要用,这一步只检查它是非负整数,不放进资料库。
- **档位**:临床表每项化验除原值外还有一个 `*_Level` 列,取 0/1,看起来是正常/异常,未经临床方确认。
- **三路**:临床、超声、MR。每个 AI 专家只看自己那一路(`AGENTS.md` 硬性约束 2)。
- **构建**:跑一次 `scripts/build.py`,产出一对同号的 `library/<构建号>/` 和 `answers/<构建号>/`。构建完不再改,要改就重新构建。
- **化名**:资料库里代替病例号的 4 位随机名字(小写字母加数字;36 的 4 次方约 168 万种,几十例足够,抽到重复的或含亚型缩写的就重抽)。真实病例号会暴露交付顺序,而交付表是按诊断分组录入的,顺序本身带着答案(`AGENTS.md` 硬性约束 1)。
- **编码列**:交付包里用数字表示类别的列,如性别 1/2、症状 0/1。数字代表什么交付包没写,要向临床方要**映射表**。
- **结论段**:报告里医生写诊断倾向的那一段(「印象:」「提示:」之类),接近答案,不能给专家。
- **NFKC**:Unicode 的一种归一化,把全角字母、数字、标点转成半角。文字检查都先做它,全角写法就绕不过去。
## 一、改配置
配置是 `config/build.yaml`,进 git。字段定义和校验在 `src/cadx/build/_config.py` 的 `BuildConfig`:每个字段必填,没有默认值;多写一个字段、拼错字段名、同一层出现重复的键都直接报错;除路径以外不做类型转换(例如 `alias_seed` 写成 `"7"` 会报错)。这样配置写错时构建停在第一步,而不是用一个没人知道的值跑完(`AGENTS.md`「不设默认值」)。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `delivery_dir` | 路径 | 交付包目录 |
| `library_root` | 路径 | 资料库的上级目录,每次构建在它下面新建 `<构建号>/` |
| `answers_root` | 路径 | 答案库的上级目录,同上 |
| `alias_seed` | 整数 | 生成化名的随机种子。同一个种子、同一批通过的病例,得到同样的化名 |
| `decode_codes` | true / false | 编码列是否按映射表解码 |
| `code_maps` | 路径或 null | 映射表位置 |
相对路径**相对仓库根目录**解析,不相对配置文件所在目录,也不相对你敲命令时所在的目录。路径不能写空串(空串会变成当前目录)。
### decode_codes 与 code_maps 要配套
- `decode_codes: false` 时 `code_maps` 必须是 `null`。编码列按原始数字写进资料库,`clinical.json` 里 `codes_decoded` 为 false;答案库 `diagnoses.csv` 的「诊断名」一栏留空;临床表的性别(1/2)没法和报告表的「男」「女」比对,资料库清单的 `uncompared` 里写明「临床性别未比对」。
- `decode_codes: true` 时 `code_maps` 必须是映射表的路径。
- 配错(true 配 null,或 false 配路径)读配置时就报错。
为什么做成开关:映射表还没从临床方拿到,不能编造含义;先用 false 跑通,映射表到位后改成 true 重新构建一次(负责人 2026-10-16:解码做成可选开关,映射表是独立的配置文件)。诊断编码和其他编码列一样归这个开关管;诊断不进资料库,所以开关对诊断只影响答案库。
### 映射表的格式
映射表目前**还没有**(`config/code_maps.yaml` 尚未建)。拿到后按下面的格式写:每个编码列一项,列名映射到「数字 → 含义」。
```yaml
Sex: {1: ..., 2: ...}
Symptom_Pain: {0: ..., 1: ...}
```
- 键必须是整数(不加引号),含义必须是字符串。YAML 会把不加引号的 `yes`、`no`、`on`、`off` 读成布尔值,含义一律加引号最稳;读成布尔值时会报错,不会悄悄通过。
- 需要映射的 17 项列名是 `src/cadx/delivery/_shape.py` 里的 `CODED_COLUMNS`(性别、诊断、5 个症状、5 项既往史、5 项化验的档位列)。其中 `Diagnosis` 一项同时管临床表的 `Diagnosis` 列和两张报告表的「病理类型」列,三列编码相同。名单放在 delivery,是因为「哪些列是编码列」是交付包形状的一部分。
- 映射表在构建开始时检查(检查编号 T5,代码在 `src/cadx/build/_config.py` 的 `load_code_maps`):必须恰好覆盖这 17 项,多一项少一项都整次构建停下;`Sex` 的含义必须恰好是「男」「女」,才能和报告表的性别比对;`Diagnosis` 的含义必须恰好是 `IPMN`、`SPN`、`MCN`、`SCN`,评价要拿它和专家的结论比。
- 解码时,某一例出现映射表里没有的值,这一例按 C07(临床表)或 C08、C09(报告表的诊断)跳过。
## 二、怎么跑
```bash
uv run python scripts/build.py --config config/build.yaml
```
脚本只接收 `--config` 一个参数,不能在命令行单独改某个参数,这次用了什么参数只看配置文件就知道(`结构.md` 第五部分「配置」)。
终端只打印计数:构建号、两个库的路径、病例总数、进库病例数、跳过病例数、认不出编号的行数、按检查编号分的跳过数、复核清单条数、跳过清单的说明句。不打印病例号和任何资料内容,终端输出可以贴进汇报。
跑完建议再做一次泄露扫描:
```bash
uv run python scripts/scan_library.py --library library/<构建号> --answers answers/<构建号>
```
它从答案库读出这次构建的真实病例号和视频号,加上脚本里独立写的亚型名、诊断列名、交付文件名词表,扫资料库的全部 `.txt`、`.json`(不扫 PNG:PNG 的附加数据在写入时已经去掉,像素上烧录的信息栏按 `AGENTS.md` 硬性约束 3 不遮蔽,文字扫描也查不了像素),并检查 JSON 里有没有 `diagnosis`、`label`、`gold`、`pathology`、`name`、`row` 这类键名。只输出命中的条数和类别,不输出命中的内容;全部为 0 才说明资料库里没有这些东西。两个目录的构建号不同时直接报错。词表在脚本里另写一份、不取自 delivery 的常量,是为了让扫描不依赖被检查的代码本身:delivery 漏了一个词,扫描还能查出来。这个脚本同时读两个库,所以只能放在 `scripts/`,不能进 `src/cadx`(会诊代码不许碰答案库)。
## 三、产物在哪
### 构建号
`YYYYMMDD-HHMMSS-<git 短哈希>`,时间是本地时间(代码在 `src/cadx/runs/_snapshot.py` 的 `make_build_id`)。工作区有未提交的改动(含未跟踪的文件)时末尾加 `-dirty`。不干净的工作区照样允许构建,但如实记下:带 `-dirty` 的构建,代码不能只凭提交号复现,快照里另记了 `git diff HEAD` 输出的哈希。要写进论文的构建,应在干净的工作区上跑。
### 资料库 `library/<构建号>/`
```
manifest.json
cases/<化名>/
clinical/clinical.json
ultrasound/report.txt
ultrasound/key.png
mr/report.txt
```
| 文件 | 内容 |
| --- | --- |
| `manifest.json` | 构建号、git 提交号、工作区是否干净、配置文件(解码时含映射表)的 SHA-256、`codes_decoded`、`uncompared`(没比对的项)、`ceus_source`(所用 CEUS 结果的来源,这一步是 null)、化名列表(按化名排序)、每个文件的相对路径和 SHA-256。不含真实病例号、交付文件名和诊断 |
| `clinical.json` | `{"codes_decoded": ..., "fields": {列名: 值}}`。字段是临床表去掉编号和诊断后的 22 列:性别、年龄、5 个症状、5 项既往史、5 项化验的原值和档位。没测的化验写 null |
| `ultrasound/report.txt` | 超声报告的「所见」原文,UTF-8,不加标题 |
| `ultrasound/key.png` | 医生截的超声关键图,只保留像素和颜色模式重新编码,不带文本块、EXIF、ICC 等附加数据(附加数据里可能有文件名、时间等线索)。超声帧上烧录的信息栏不遮蔽(`AGENTS.md` 硬性约束 3) |
| `mr/report.txt` | MR 报告的「所见」原文 |
性别、年龄只在临床一路:两份报告表里医院也复制了这两列,但影像两路先看到会被人口学先验带偏(`结构.md` 第四部分)。以后 CEUS 定量落地,超声一路再加关键帧和灌注指标。
读资料库要经 `cadx.library` 的 `read_clinical`、`read_ultrasound`、`read_mr`:读时逐个核对文件的 SHA-256 与清单一致,被改过就报错。
### 答案库 `answers/<构建号>/`
```
manifest.json
snapshot/
snapshot.json
build.yaml
code_maps.yaml (只在 decode_codes 为 true 时有)
aliases.csv
diagnoses.csv
skipped.csv
review.csv
```
| 文件 | 内容 |
| --- | --- |
| `manifest.json` | 构建号、跳过清单的说明句(`skipped_note`)、本目录其余全部文件(含 `snapshot/` 下的)的 SHA-256 |
| `snapshot/snapshot.json` | 构建号、完整 git 提交号、工作区是否干净、`git diff HEAD` 的 SHA-256、Python 版本、`uv.lock` 的 SHA-256、开始和结束时间、各配置文件的 SHA-256 |
| `snapshot/build.yaml` 等 | 这次所用配置的原文副本。配置文件以后改了,这次构建用的是什么仍然查得到 |
| `aliases.csv` | 化名、三位病例号、视频号(`U` 加病例号)。会诊产物只用化名,评价靠这张表接回真实病例;CEUS 定量的结果按视频号接回 |
| `diagnoses.csv` | 化名、病例号、临床表、超声表、MR 表各自的诊断编码、诊断名(不解码时留空)。三张表各存一份,用哪一份算分由评价决定(进库病例已过 C10,三份相同;分开存是为了保留各表原值,评价口径不必在装配时定) |
| `skipped.csv` | 跳过清单,见第四节 |
| `review.csv` | 复核清单,见第五节 |
CSV 用带 BOM 的 UTF-8(文件开头有一个不可见的标记),医生用 Excel 直接打开不会乱码。
### `.partial` 是什么
两个库先写进 `<根目录>/<构建号>.partial/`,全部写完、清单算完后再改名成 `<构建号>/`:先改答案库,最后改资料库。所以:
- **资料库的 `<构建号>/` 出现,才算这次构建完成。** 会诊只读资料库,读取函数也拒绝名字以 `.partial` 结尾的目录,没写完的构建不会被用上。
- 看到 `.partial` 目录,说明那次构建中途出错。目前已知的一种是某张关键图的格式(位深、颜色类型、透明色)不能逐像素保真重编码:这本是只落在一例上的问题,但现在的代码让整次构建停下,而不是跳过这一例。它不会被自动删除,留着给人查原因;查完由人决定删不删。
- 只有答案库的 `<构建号>/`、没有同号的资料库,同样是没完成的构建。
- 配置、映射表或整张表的检查(T1–T5)不过时,构建在建目录之前就停下,不留任何目录。
- 目标目录已经存在时报错,不覆盖。构建号带到秒,正常不会重名。
## 四、跳过清单怎么看
### 规则
交付资料里的问题分两种(`AGENTS.md`「错误怎么处理」):
- **整张表的问题**(T1–T5):一例也读不出来,整次构建停下,报错信息以检查编号开头,写明哪个文件哪一项不符。
- **只落在某一个病例上的问题**(C01–C13):跳过这一整例,接着处理下一例,在 `skipped.csv` 里记一行。这一例不进资料库,也不进答案库的 `aliases.csv`、`diagnoses.csv`。
先对三张表的每一行和每个病例目录做 C01(编号认不认得出);认得出的,再逐例按 C02 到 C13 的顺序检查,**遇到第一项没过就停下**,清单上每例只记这一项(C01 记的是认不出的那一行,不算某一例,见下文)。后面的检查没做过,不代表没问题:医生改好这一项后,重新构建时它可能卡在后面某一项。这句说明写在答案库 `manifest.json` 的 `skipped_note` 里,终端也会打印;CSV 文件开头不能加注释行,所以没写在 `skipped.csv` 里。为什么整例跳过而不是停下整次构建:负责人 2026-10-16「都是遇到整个病例跳过,让医生核对去吧」。评价时要报告跳过了几例、各因为什么。
### skipped.csv 的列
| 列 | 含义 |
| --- | --- |
| 病例 | 三位病例号。C01(编号认不出)的行不知道是哪个病人,写成「表的路径 第 N 行」(N 是 Excel 行号,第 1 行是表头);病例目录名不在 1–999 之间时写成「病例目录 目录名」 |
| 检查编号 | C01–C13 |
| 检查说明 | 这项检查的一句话说明 |
| 文件 | 涉及的文件,相对交付包根目录,多个用「; 」隔开 |
| 列 | 涉及的列,多个用「; 」隔开;不涉及具体列(如目录里的文件不对)时为空 |
| 涉及值 | JSON 字符串:不合规的原值、对不上的各表的值、命中的词和所在句子等,供医生核对 |
C01 的行不计入病例数,也不计入终端的「跳过病例」,单独算在「认不出编号的行」里;「按检查编号分的跳过数」里的 C01 就是这些行。同一个病人如果在某张表里编号写坏了,他在 C01 记一条(那一行),又会因为「缺在那张表」在 C03 再记一条,这是预期的。
### 检查编号速查
规则细节以代码为准:T1–T4 在 `src/cadx/delivery/_tables.py`,T5 在 `src/cadx/build/_config.py`,C01–C10 在 `src/cadx/delivery/_cases.py`,C11–C13 的词表和正则在 `src/cadx/delivery/_text.py`,表名、工作表名、表头在 `src/cadx/delivery/_shape.py`。每条规则为什么这样定见任务书 `../任务书/01-装配两个库.md` 第二部分。
| 编号 | 查什么 | 不过时 |
| --- | --- | --- |
| T1 | 三张汇总表都存在 | 整次构建停下 |
| T2 | 每张汇总表恰好有约定的那一个工作表 | 整次构建停下 |
| T3 | 表头与约定一字不差(含诊断列名里写的编码含义) | 整次构建停下 |
| T4 | 三个子目录里没有多余的文件:临床、MR 目录只有自己那张汇总表;超声目录只有超声汇总表和三位数字命名的病例目录 | 整次构建停下 |
| T5 | 解码时映射表恰好覆盖 17 项,性别、诊断的含义合规(见第一节) | 整次构建停下 |
| C01 | 编号认得出:临床表是 `P` 加三位数字,两张报告表是补零或不补零的纯数字,病例目录是三位数字;规范化后在 1–999 之间 | 记一行,不计入病例数 |
| C02 | 同一张表里这个编号只出现一次 | 跳过这一例 |
| C03 | 三张表和病例目录里都有这一例 | 跳过这一例 |
| C04 | 病例目录恰好三个文件:`U<号>_key.png`,加一对配套的视频和单例报告表:`U<号>_roi2.mp4` 配 `U<号>_roi2_report.xlsx`,或 `U<号>.mp4` 配 `U<号>_report.xlsx`;混搭不过(负责人 2026-10-16 定):单例报告表的名字随视频名配对,不配套时说不清这份报告属于哪个版本的视频,交医生确认 | 跳过这一例 |
| C05 | 超声表的「CEUS视频ID」是 `U<号>`,与病例号一致 | 跳过这一例 |
| C06 | 单例报告表的工作表、表头正确,恰好 1 行数据,7 格与超声汇总表对应行逐格相等(类型也要相同) | 跳过这一例 |
| C07 | 临床表取值:性别、年龄、诊断、症状、既往史的取值范围;化验原值和档位要么都空(没测),要么都有值;解码时编码列的值在映射表里 | 跳过这一例 |
| C08 | 超声表取值:性别「男」或「女」、年龄形如「数字岁」、诊断 1–4(解码时还要在映射表里)、推注开始时间是非负整数、超声描述非空 | 跳过这一例 |
| C09 | MR 表取值:性别、年龄、诊断同 C08,MR 描述非空 | 跳过这一例 |
| C10 | 跨表一致:诊断三张表两两相等;年龄三张表相等(报告表去掉「岁」后和临床表的数字比);性别超声表与 MR 表相等,解码时临床表的性别也要相等 | 跳过这一例 |
| C11 | 报告文字里没有结论段标题:段首(全文开头或换行之后)出现「印象」「结论」「提示」等关键词加冒号 | 跳过这一例 |
| C12 | 报告文字里没有亚型名(缩写、中文名及其常见变体) | 跳过这一例 |
| C13 | 报告文字里没有人口学信息:没有「数字岁」,没有「男」「女」两个字 | 跳过这一例 |
几点说明:
- 类型按 Excel 读出的原样判断,不做转换:要求整数的格读出 `45.0` 也不过,字符串不去首尾空格。录入时的这类小毛病可能对应真的录错,不替医生猜。
- C10 是负责人点名要的:同一个人在不同表里本应一样的值对不上,大概率是录入错误,要拿去找医生改。`待定问题/04` 记了 40 例里已知的跨表不一致。
- C11 只认段首的标题。正文中段出现「提示:」这类写法不算不过,这一例照常进库,但它会出现在复核清单里;这种中段的提示该不该给专家,等 `../待定问题/06-超声描述中段的造影提示.md` 定。在那之前,不能用构建做正式会诊。
- C12 不查单独出现的「浆液性」「黏液性」:它们在描述囊液性状时是正常用语。
- C13 禁单字「男」「女」:描述病灶用不到这两个字,出现了多半是写了患者性别,影像两路不该看到。
## 五、review.csv 是什么
复核清单:进库病例的超声描述、MR 描述里,含「考虑」「可能」「倾向」「提示」任一词的句子,每句一行(化名、路、命中的词、所在句子)。句子按「。」和「;」切(先做 NFKC,全角分号变成半角)。只扫进库的病例,跳过的病例不在这里。
这些词不带冒号时不算检查不过,因为它们也常用来描述影像所见;但它们可能引出医生的诊断倾向,所以列出来由负责人过目,决定要不要改规则或问医生。这里用化名而不是病例号,过目时如需对回病例,查同目录的 `aliases.csv`。
## 六、交给医生之前
- **`answers/` 里有真实病例号和报告原句**(`skipped.csv` 的涉及值、`review.csv` 的句子、`aliases.csv`、`diagnoses.csv`),只在本机,不进 git(`.gitignore` 和 pre-commit 两道拦截)。不能贴进文档、提交说明、Linear 评论或发给外部模型服务(`AGENTS.md` 硬性约束 3)。交给医生核对要走负责人认可的渠道,由负责人转交。
- 交给医生的通常只有 `skipped.csv`,说明里带上 `skipped_note` 那句话:每例只记第一项没过的检查。
- 不要把 `diagnoses.csv` 和资料库放在同一个地方给任何会诊代码或模型读(`AGENTS.md` 硬性约束 1)。
- 汇报、终端输出、日志里只写计数和检查编号,不写病例号和资料内容。
- 医生改好交付资料后,重新构建一次,得到新的构建号;旧的构建目录不删、不改(`AGENTS.md`「两档标准」:运行产物只追加)。