Files
CADx-4/research-wiki/explanation/结构.md
T
2026-10-17 06:08:42 -04:00

439 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 代码结构
这份文件说明数据从医院原始资料到评价结果经过哪几段、代码分成哪些模块、每个模块藏住什么决定、模块之间怎么依赖、各模块预期多大,以及按什么顺序把它们写出来。各模块对外的函数和类型,写到哪个模块时再补进第八部分。
来源:
- 负责人 2026-10-15 定:旧项目不再做成工程产品,在本仓库按研究原型重写。原话:「我们后面发现其其实不需要(至少目前不需要)是一个工程产品」(design 0001)。
- 负责人 2026-10-15 定:协作环境和文档体系参考 ClinDB-ReviewBench 与 dissect3;检查强度取 ClinDB 那一档;一个 uv 环境,MedSAM2 与 torch 装在里面,靠导入边界隔离(design 0001 第 4、7 条)。
- 负责人 2026-10-16 定:数据流是「医院原始资料->中间文件(可以有可以没有)->「给专家的资料库」(给agent的东西都在里面)&「标准答案库」(我们自己用来评估的金标准的标准答案等)」;两个库和中间文件都用文件夹,原话:「两个库和缓存都用文件夹」;调模型用实验室的 PolyGateway;CEUS 代码「直接一个个迁移」;数据格式和口径以 40 例交付包为准(design 0002)。
- 负责人 2026-10-16 定:配置「每段一份」;编码列的解码是一个可选的开关,映射表是独立的配置文件;交付资料的检查,一个病例只要有一项没过就整例跳过并记录,原话:「都是遇到整个病例跳过,让医生核对去吧」。
- 其余内容(模块名单、边界、依赖、规模、推进顺序)是 Agent 2026-10-16 的提议;规模在对应模块动手前由负责人逐个确认。
---
## 一、切分原则
不按流水线的步骤切,也不按技术层(接口、数据库、服务)切,按「以后可能会改的设计决定」切,一个模块藏住一个决定。换掉某个决定时,只改这一个模块。例如「用哪个分割模型」是一个可能会改的决定:把它藏进 segment,CEUS 的其余算法只接收「每帧一张病灶掩码」,不知道背后是 MedSAM2 还是别的模型;以后换模型只改 segment。这个原则来自 Parnas 1972 年的信息隐藏论文,ClinDB-ReviewBench 用的也是这个分法。
旧项目按技术层切(`app/api`、`app/persistence`、`app/workers`、`app/providers`、`app/domain`)。按层切的问题是同一个决定散在几层里:
- 「专家能看见什么」散在四处:Excel 列白名单在 `app/providers/clinical.py`、`modalities.py`,输入包的形状在 `app/domain/input_packages.py`,按病例拼装在 `app/domain/input_readers.py`,入库分区在 `app/domain/ingestion*.py`。
- CEUS 处理有两条互相分叉的路径:产品编排 `app/domain/ceus_roi/service.py`,以及实际跑出 40 例结果的 `debugs/` 脚本。两者在裁黑边、识别分屏用的帧、关键帧在哪一步算这几处行为不同(design 0002 决定 4)。
判断一刀切得对不对,问这 7 个问题:
1. 这个模块藏住的是哪一个决定?说不出来,或者说出来的是「第几步」,就是切错了。
2. 换掉这个决定,要改几个模块?应该只改 1 个。
3. 对外露出的函数和类型,是否比内部实现简单得多?
4. 依赖有没有绕成圈?下层能不能离开上层单独测试?
5. 纯计算的模块里,有没有读写文件、网络或模型?
6. 每样外部的东西(交付包的 Excel、MedSAM2、模型服务、账本的 SQLite),是不是只有一个模块直接碰它?
7. 同一份知识(哪一路能看见哪些列、诊断编码、关键帧的时刻定义)是不是只在一处定义?
---
## 二、项目的完整范围
项目做胰腺囊性肿瘤的鉴别诊断:三个 AI 专家各看一路资料,模拟多学科会诊,判断病灶属于 IPMN、SPN、MCN、SCN 四类中的哪一类(全称见 `AGENTS.md`),结论对病理金标准评价;同时评价专家从资料里抽取字段的可靠性。模块要覆盖全部四段,不只是旧项目已经做的部分。
| 段 | 做什么 | 输入 → 输出 | 旧项目状态 |
| --- | --- | --- | --- |
| CEUS 定量 | 从超声造影视频里追踪病灶、画出亮度曲线、拟合、挑关键帧、算灌注指标 | `data/` 的视频 → `runs/ceus/`(分割缓存在 `work/`) | 算法约 7.3k 行,40 例跑通 34 例(旧项目远程结果清单);两条路径行为不一致,映射锚点按 1856 宽素材写死(design 0002 决定 4) |
| 装配两个库 | 读交付包,按白名单把每路允许的内容写进给专家的资料库,把答案写进标准答案库 | `data/` + `work/` → `library/`、`answers/` | 列白名单和编号规范化有,但按旧样本 `1.2-TEST` 写死了工作表名和列名,读不了 40 例交付包 |
| 会诊 | 三个专家各读自己那一路,按会诊流程给出判断 | `library/` → `runs/` | 没有,只有三版设计文档 |
| 评价 | 会诊结论对金标准;专家抽取的字段对医生标注 | `runs/` + `answers/` → `runs/<运行号>/eval/` | 没有;写过一套评估骨架,因缺陷和无人使用而删除 |
整体框架:每段一个入口脚本、一份配置,段与段之间只通过磁盘上的目录交接。双线以下的会诊碰不到标准答案库,评价才把会诊结果和答案放在一起。
```
data/交付包_20260908_40例/ 医院原始资料(只读)
│ 视频 │ 三张表、关键图
▼ │
① CEUS 定量 │ config/ceus.yaml
scripts/ceus_quant.py │
ceus + segment(显卡) │
│ │
▼ ▼
runs/ceus/<运行号>/ ────► ② 装配两个库 config/build.yaml
(work/ 放分割缓存) scripts/build.py config/code_maps.yaml
delivery → build
┌─────────┴──────────┐
▼ ▼
library/<构建号>/ answers/<构建号>/
给专家的资料库 标准答案库
化名、三路分开 金标准、化名与真实病例号对照、跳过清单
│ │
══════════════════════╪════════════════════╪═══ 会诊读不到右侧
▼ │
③ 会诊 scripts/consult.py │ config/consult.yaml
consult → llm(PolyGateway) │
│ │
▼ │
runs/<运行号>/ │
各路意见、会诊结论、账本 │
│ │
└─────────┬──────────┘
▼
④ 评价 scripts/evaluate.py config/evaluate.yaml
▼
runs/<运行号>/eval/
```
`data/`、`work/`、`library/`、`answers/`、`runs/` 都不进 git(第五部分)。每段的配置怎么写见第五部分「配置」。
几个贯穿全文的词:
- **给专家的资料库**(`library/`):AI 专家能看到的一切。每次装配是一个新目录,叫一次**构建**。
- **标准答案库**(`answers/`):病理诊断、化名与真实病例号的对照、跳过清单(没进资料库的病例及原因),将来还有医生标注工作簿。只给评价用。
- **CEUS 运行**(`runs/ceus/<运行号>/`):一次 CEUS 定量的产物(轮廓、曲线、关键帧、指标),会装进资料库、可能写进论文,所以和会诊运行一样只追加不删(`AGENTS.md` 硬性约束 5)。
- **中间文件**(`work/`):可以删掉重算的东西,目前只有 MedSAM2 分割结果的缓存。
- **运行**:一次会诊或评价,产物放在 `runs/` 下一个新目录里,只追加不删。
- **账本**:一次会诊运行里每次调用模型的记录(请求、回复、用量、结局),存为 SQLite,只追加。
- **关键帧**:从 CEUS 视频里挑出交给超声专家的 7 张图,定义见第四部分。
---
## 三、模块
| 层 | 模块 | 藏住的决定 | 旧项目里的对应内容 |
| --- | --- | --- | --- |
| 入口 | `scripts/`(不在包内) | 人怎么操作:每段一个脚本(`ceus_quant`、`build`、`consult`、`evaluate`),另有一个按顺序调用四段的总入口,以及出医生核对包的 `review_pack`;读配置文件,把模块装配起来 | `debugs/t1_local_debug.py`、`debugs/tic_lognormal_replay.py` |
| 组织 | build | 两个库怎么装配:每一路放哪些内容(可见性的唯一决定处)、化名怎么取(化名随机,资料库按化名排序,顺序就与交付顺序无关)、清单记什么。全项目只有它同时碰得到原始资料和答案 | `app/domain/input_readers.py`、`ingestion*.py` 的装配部分 |
| 组织 | consult | 会诊怎么进行:几个专家、谁先说、后发言的看得到前面的什么、什么时候结束;「调一次模型」的统一入口(先存请求、记开始、调用、存回复、记结束)也在这里 | 没有 |
| 组织 | evaluate | 评价口径:指标、统计方法、字段级对照怎么计分 | 没有(`core/eval` 已删) |
| 组织 | review | 给医生的核对包长什么样:目录布局、每个文件的格式(表格 CSV、文字 PDF、图和视频原样拷贝)、问题清单的来源与写法、说明模板。只读已构建好的两个库和那次 CEUS 运行,不写它们、不碰交付包 | 没有 |
| 格式 | library | 给专家的资料库的目录布局、文件格式和读取;按路分别读出,一路一个对象 | `app/domain/input_packages.py` 的数据形状 |
| 格式 | answers | 标准答案库的目录布局、文件格式和读取 | 旧库里的金标准表 |
| 格式 | verdicts | 会诊产物(各路意见、会诊结论)的文件格式和读取;consult 写、evaluate 读 | 没有 |
| 外部 | delivery | 交付包长什么样:目录和文件名、工作表与列名、病例编号的几种写法和规范化、哪些列是数字编码、报告「印象 / 结论」段怎么认、跨表一致性怎么核对。只读,不决定谁能看见什么 | `app/providers/clinical.py`、`modalities.py`、`workbook.py`、`delivery.py` |
| 外部 | segment | 用哪个分割模型、权重在哪、怎么在显卡上把提示传播成逐帧掩码 | `debugs/medsam2_local_track.py`、`roi_inference/` 的后端 |
| 外部 | llm | 经 PolyGateway(实验室的模型调用库,管外部错误的重试和超时)调模型;交出服务商返回的精确版本号、响应编号、用量、结束原因。只管调用,不写账本 | `app/providers/model_gateway_config.py`(只借思路);ClinDB 的 `llm/` |
| 外部 | ledger | 账本怎么存:SQLite 只追加、大块内容按哈希存成文件、同一目录同时只许一个进程写、找出开始了没结束的调用 | 没有;ClinDB 的 `ledger/` |
| 外部 | runs | 运行目录怎么命名、运行快照记什么(git 提交号、配置、所用构建、提示词哈希、模型版本、随机种子);一次 CEUS 定量运行目录(`runs/ceus/<运行号>/`)的布局和读取,build 和 review 都经它读 | 没有 |
| 纯计算 | ceus | CEUS 定量的全部算法和参数:分屏、绿框、坐标映射、多边形、暗区、TIC 采样、Lognormal 拟合、关键帧、灌注指标、出图;视频解码也在这里 | `app/domain/ceus_roi/`(除 `service.py`)、`app/providers/video_decoder.py` |
暂不设共用词汇模块。一样东西要被两个互相不能引用的模块用到,才另立一处放它;目前跨模块的类型各有归属(资料库格式归 library、答案格式归 answers、会诊产物格式归 verdicts)。配置的字段定义和校验归使用它的模块:CEUS 参数归 ceus(旧 `research_config.py`),会诊的实验配置归 consult;每个字段必填,不补默认值(`AGENTS.md`「不设默认值」)。
依赖只能往下,同一行的模块之间不互相依赖:
```
入口 scripts/
组织 build consult evaluate review
格式 library answers verdicts
外部 delivery segment llm ledger runs
纯计算 ceus
```
谁具体能依赖谁:
| 模块 | 可以依赖 |
| --- | --- |
| ceus、delivery、segment、llm、ledger、runs | 不依赖其他 `cadx` 模块 |
| library、answers、verdicts | 不依赖其他 `cadx` 模块 |
| build | delivery、ceus(读 CEUS 结果的类型)、library、answers、runs |
| consult | library、verdicts、llm、ledger、runs |
| evaluate | answers、verdicts、runs |
| review | library、answers、runs |
| scripts/ | 任何模块(不在导入检查范围内,见第五部分) |
### 几条边界
- **专家能看见什么,只在 build 一处决定。** build 按白名单把每路允许的内容写进 `library/`,此外任何模块都不挑选给专家的材料。consult 只拿到某一次构建的路径,经 library 按路读出;它不依赖 delivery、answers、build,读原始资料和答案的代码对它来说根本不存在。报告的「印象 / 结论」段(医生写的诊断倾向)由 delivery 认出、由 build 剔除,`library/` 里只有「所见」描述。
- **交付资料的检查不过,整例跳过。** delivery 逐例检查(编号、文件、取值、跨表是否一致;检查项 C01–C14 的速查表见 `../guides/装配两个库.md`,规则以 `src/cadx/delivery/_cases.py`、`_text.py` 为准),build 把没过的病例整例跳过:资料库和答案库的病理诊断里都没有它,答案库的跳过清单写明它没过哪一项、涉及哪个文件哪一列。一例遇到第一项不过就不再往下查,清单上每例只记一项。整张表读不了时整次构建停下。规则见 `AGENTS.md`「错误怎么处理」。
- **三路互相隔开。** 每个专家的提示词只用自己那一路的对象拼。consult 里拼某一路提示词的函数只接收那一路的对象,不接收整个病例。这是研究设计本身:模拟各科医生各看各的检查结果,再在会诊时交换意见。会诊时一路能看到另一路的什么,由 consult 的会诊流程决定,在会诊的任务书里定。
- **我们自己调模型只有一个入口:consult 的「调一次模型」。** 它按固定顺序做五步:把完整请求存进账本的大块存储、写「调用开始」、经 llm 调用、把完整回复存进大块存储、写「调用结束」。llm 只管调用,不写账本,所以任何一次模型调用都绕不开登记。中断后重启时,从账本找出开始了没结束的调用,记为「状态未知」,不重做已有结果的调用。做重复运行(同一病例、同一专家调多次,测一致性)时,每一次都恰好登记一次。
- **CEUS 算法不知道分割模型是谁,也不管中间文件放哪。** ceus 接收视频路径、参数和分割结果,交出曲线、拟合、关键帧和指标;中间文件写在哪、按什么复用,由 `scripts/ceus_quant.py` 决定(目前只有它一个使用者,出现第二个使用者再收进模块)。segment 只管「给提示、出逐帧掩码」,不认识分屏和绿框。
- **CEUS 运行目录只有 runs 认得。** `failed.csv`、`summary.json`、关键帧、TIC 曲线图、回放视频各在运行目录的哪里,由 runs 读出;build 和 review 都经它读,不各自拼路径(任务书 03:出现第二个使用者,就把读取收进模块)。`summary.json` 里挑哪些字段给专家仍只在 build 决定。
- **核对包只读不写。** review 读某次构建的 `library/`、`answers/` 和资料库记的那次 CEUS 运行,输出只写进 `runs/review_pack/<运行号>/`;不写两个库、不读交付包。核对包里有答案和真实病例号,是给医生的,不是给会诊的。
- **医生的判断不进 CEUS 定量。** 医生标注工作簿里的时刻(如造影剂到达时间 AT)属于标准答案库,ceus 和 build 都不读它;关键帧全部由程序自己算或按推注时间取(design 0002 决定 6)。交付视频里医生画的绿框是 CEUS 定量的输入,它能否作为输入、在最终病灶轮廓里占多大分量,记在 `../待定问题/01-医生画的绿框能否作为输入.md`。
- **评价只读文件,不重新调模型。** evaluate 读 `runs/` 里的会诊产物(经 verdicts)和 `answers/`,改指标或统计方法不需要重跑会诊。
---
## 四、两个库与 CEUS 定量产物的内容
### 给专家的资料库(`library/<构建号>/`)
```
library/<构建号>/
manifest.json 构建清单:每个文件的 SHA-256、构建时的 git 提交、配置哈希、所用 CEUS 运行号(没装为 null)
cases/<化名>/
clinical/clinical.json 临床那一路
ultrasound/ 超声那一路:report.txt、key.png;装了 CEUS 结果时另有
keyframes/<槽位>.png 和 tic.json
mr/report.txt MR 那一路
```
`build.yaml` 的 `ceus_run` 指向某次 CEUS 定量的运行目录时,build 才往超声一路装 CEUS 结果,为 null 时不装。装的时候:
- `keyframes/<槽位>.png`:下文关键帧表里定位到的那几张(槽位即 B0、D1 等),和 `key.png` 一样去掉附加数据重新编码;每例最多 8 张图(7 张关键帧加 `key.png`)。
- `tic.json`:从这一例的 `summary.json` 里逐字段挑出,此外什么都不写:四条曲线(`roi_all` 病灶整体、`roi_perf` 病灶去暗区、`roi_pan` 紫圈即医生圈的一块正常胰腺、`background_circle` 背景圆)各自的拟合是否成功(`success`)和拟合优度 R²(`r2`);病灶两条曲线的灌注指标(`perfusion_metrics`,拟合没过质量门为 null);紫圈曲线是否可用(`pan_curve_usable`);7 个槽位各自是否定位到(`located`)、定位到的视频时间(`video_time_s`,秒)、没定位的原因(`reason`)。视频号、文件路径、配置、帧号、拟合参数都不写。挑哪些字段只在 `src/cadx/build/_ceus.py` 里列。
- 核对这次 CEUS 运行是在这份交付包上跑的:运行快照里 `ceus.yaml` 的 `paths.delivery_dir` 与 `build.yaml` 的 `delivery_dir` 规范化后不是同一路径;或逐例比,交付包里这段视频文件的 SHA-256 与 `summary.json` 的 `video_sha256` 不同,或交付包的推注时间与 `summary.json` 的 `injection_offset_s` 不同,整次停下报错。这两个字段不进 `tic.json`。
- CEUS 在这一例上失败(在 `failed.csv` 里)或运行目录里没有这一例的结果,这一例整例跳过,记 C15。失败的病例要不要改成「超声不可评价、仍参与会诊」等 `../待定问题/03-病例排除标准.md` 定。
每一路能看见什么:
| 内容 | 临床 | 超声 | MR |
| --- | --- | --- | --- |
| 性别、年龄 | 看得见 | 看不见 | 看不见 |
| 5 个症状、5 项既往史、5 项化验(原值与是否异常) | 看得见 | 看不见 | 看不见 |
| 超声报告「所见」段、医生截的关键图 | 看不见 | 看得见 | 看不见 |
| CEUS 关键帧、TIC 灌注指标 | 看不见 | 看得见(给哪些指标、几张图由会诊任务书定) | 看不见 |
| MR 报告「所见」段 | 看不见 | 看不见 | 看得见 |
| 病理诊断、报告「印象 / 结论」段、医生标注工作簿、真实病例号、Excel 行序、姓名 | 看不见 | 看不见 | 看不见 |
性别、年龄在资料库里只放临床一路:医院在两份报告工作簿里也复制了这两列,但影像两路的第一轮判断必须只凭影像证据,先看到它们会被人口学先验带偏(旧项目 design 0026 决定三)。这张表管的是资料库里每一路放什么。会诊时,影像两路在第一轮判断之后,需要时可以向临床专家提问,临床专家的回答可以含性别、年龄;这属于会诊流程,细节由会诊任务书定(负责人 2026-10-16)。
病例用化名,目录和清单按化名排序:交付文件名里带病例号(如 `U001_roi2.mp4`),行序可能和诊断有关;化名是按种子随机抽的 4 位小写字母加数字,按它排序后的顺序与交付顺序无关。图片写入时去掉文件元数据。构建完不再修改;要改就做一次新构建。
### 标准答案库(`answers/<构建号>/`)
与同一次构建的资料库一一对应(同一个构建号),放:
- 病理诊断:三张表的诊断列各存一份,用哪一份算分由评价决定;
- 化名对照:化名、真实病例号、CEUS 视频号。会诊产物只用化名,评价靠这张表接回真实病例;
- 跳过清单:没进资料库的病例、没过的那一项检查、涉及的文件和列,以及对不上的各表原值,供医生核对。40 例里已知有 6 例临床表与超声表对不上(`../待定问题/04-交付数据的跨表不一致.md`),会出现在这份清单里;
- 将来加医生标注工作簿。
### 医生核对包(`runs/review_pack/<运行号>/`)
把一次构建的两个库和它装入的那次 CEUS 运行,整理成医生不用写代码就能看的一个文件夹(表格 CSV、文字 PDF、图和视频逐字节拷贝),由负责人打包交给临床方核对我们的处理结果。病例目录用医生的病例号,问题清单只写程序观察到的现象;包里有真实病例号、报告原文和诊断,只在临床方与我们之间传递。它不是流水线的一段:不产出任何后续段要读的东西,医生每交一版数据、跑完 CEUS 定量和装配之后出一次。包内布局和每个文件的格式见 `../任务书/03-医生核对包.md` 和包里的 `说明.pdf`,怎么跑见 `../guides/医生核对包.md`。
### CEUS 定量的产物(`runs/ceus/` 与 `work/`)
每次运行的产物写进 `runs/ceus/<运行号>/`,只追加;全量运行之前的配准目视核对写进 `runs/ceus_align/<运行号>/`(图、`key.csv`、判读、`review.csv`),ceus_quant 按 `review.csv` 拦下核对没通过的病例;第 3 步的分割结果按下表的键缓存在 `work/`,可以删掉重算。具体布局见 `../任务书/02-CEUS定量.md`。
CEUS 定量按下面的阶段进行(旧项目的阶段划分,迁移时逐模块核对):
| 阶段 | 做什么 | 跨运行复用 |
| --- | --- | --- |
| 1 解码与分屏 | 读视频;超声机录造影时屏幕左右分两半,一半造影画面、一半普通灰阶画面,找出分界;再配准两半,估出两半局部坐标之间的平移(扇形在两半里的位置不同,每段视频单独估) | 否 |
| 2 提示帧 | 在造影画面上找医生画的绿框,挑出绿框位置有变化的帧,换算到另一半画面上 | 否 |
| 3 分割 | segment 用 MedSAM2 把提示传播成逐帧病灶掩码;需要显卡,最贵 | **是**,按「视频内容哈希 + 阶段 1–3 的参数 + 模型权重哈希」存 |
| 4 多边形 | 掩码转成平滑轮廓,与绿框融合 | 否 |
| 5 暗区 | 找出病灶里不进造影剂、发黑的囊液部分 | 否 |
| 6 TIC 采样 | 逐帧取病灶(去不去暗区两种)平均亮度,得到 TIC(时间-强度曲线) | 否 |
| 7 拟合 | 用 Lognormal(对数正态)曲线拟合 TIC,过质量门 | 否 |
| 8 关键帧 | 按下面的定义挑 7 张 | 否 |
| 9 灌注指标 | 从拟合曲线算 11 个指标(峰值强度、达峰时间、洗入速率等,口径仿照商用造影定量软件 VueBOX) | 否 |
只有阶段 3 跨运行复用,其余每次重算:阶段 3 要显卡,最贵;其余阶段在 CPU 上算。改了后段参数不必重跑显卡。
关键帧:
| 帧 | 含义 | 怎么取 |
| --- | --- | --- |
| B0 | 刚开始变亮之前 | 从拟合曲线算出到达时刻,取其前 |
| D1 | 开始变亮 | 拟合曲线上的到达时刻 |
| D2 | 洗入拐点(变亮最快处) | 拟合曲线 |
| D3 | 最亮 | 拟合曲线 |
| D4 | 洗出拐点(变暗最快处) | 拟合曲线 |
| F1 | 推注后第 20 秒 | 视频第 20 − X 秒 |
| F2 | 推注后第 60 秒 | 视频第 60 − X 秒 |
X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0 秒时已经推注了多少秒(design 0002 决定 5)。某个时刻落在视频之外,或附近没有可用帧,该帧记为未定位并写明原因,不拿别的帧补。各时刻的容差窗口和出处(哪些来自和临床方约定的口径,哪些是旧项目工程侧定的)在迁移关键帧模块时逐个核对,写进 ceus 的参数文件注释。
---
## 五、目录与检查
| 位置 | 内容 | 进 git |
| --- | --- | --- |
| `src/cadx/` | 一个 Python 包,每个模块一个子包;别的模块只能经子包的 `__init__.py` 引用它,内部文件加 `_` 前缀 | 是 |
| `scripts/` | 入口脚本 | 是 |
| `tests/` | 不花钱、不要显卡的测试;需要显卡的标 `gpu`,花钱的标 `live`,两者不进 `make check` | 是 |
| `config/` | 每段一份配置,另有编码映射表(见下文「配置」);密钥只写环境变量名 | 是 |
| `prompts/` | 版本化的提示词文件;每次运行记下所用提示词的哈希 | 是 |
| `data/` | 医院交付的原始资料 | 否 |
| `library/`、`answers/` | 两个库,每次构建一个目录 | 否 |
| `work/` | 可以删掉重算的中间文件,目前只有分割缓存 | 否 |
| `runs/` | CEUS 定量、医生核对包、会诊与评价的运行,只追加不删 | 否 |
| `models/` | 模型权重 | 否 |
| `reference/`、`reference-pdf/` | 旧项目副本、外部代码、文献原文 | 否 |
不进 git 的目录由 `.gitignore` 和 pre-commit 的 `local-only-paths` 两道拦截,`git add -f` 也提交不了。
### 配置
所有参数都写在 `config/` 下的 YAML 文件里,每段一份:
| 文件 | 给谁用 | 内容 |
| --- | --- | --- |
| `build.yaml` | `scripts/build.py` | `delivery_dir`(交付包位置)、`library_root`、`answers_root`(两个库的上级目录)、`alias_seed`(生成化名的随机种子)、`decode_codes`(编码解码开关)、`code_maps`(映射表位置或 null)、`ceus_run`(CEUS 运行目录或 null);字段定义和校验在 `src/cadx/build/_config.py`,逐项说明见 `../guides/装配两个库.md` |
| `code_maps.yaml` | build(经 `build.yaml` 引用) | 编码映射表:交付包里每个用数字编码的列,每个数字代表什么(如性别的 1、2)。还没向临床方拿到,尚未建 |
| `ceus.yaml` | `scripts/ceus_quant.py`、`scripts/ceus_align_review.py` | CEUS 定量的全部参数和配准目视核对的出图设置,每个参数用注释写明出处 |
| `review_pack.yaml` | `scripts/review_pack.py` | `library_dir`、`answers_dir`(同一次构建的两个库,构建号必须相同)、`output_root`(核对包运行目录的上级);用哪次 CEUS 运行从资料库清单读,不另配;字段定义和校验在 `src/cadx/review/_config.py`,逐项说明见 `../guides/医生核对包.md` |
| `consult.yaml` | `scripts/consult.py` | 会诊的实验配置与模型用途 |
| `evaluate.yaml` | `scripts/evaluate.py` | 评价的指标与统计设置 |
每段一份,是因为四段分开运行,各自的快照只记自己的配置;改会诊参数不会改变装配配置的哈希,不会让人误以为资料库要重建。哪份文件在哪个模块落地时建,不提前写空文件。
规则:
- **入口脚本只接收配置文件路径**,不能在命令行单独改某个参数,「这次用了什么参数」只看文件就知道。
- **字段定义归使用它的模块**(第三部分),用 pydantic 写:每个字段必填,不设默认值;可以没有的值显式写 null;多写或拼错的字段名报错;读入后不能修改。
- **路径、随机种子、模型名、服务地址都写进配置**,代码里不写死。
- **每次运行把所用配置的原文复制进快照**,并记下 SHA-256 哈希;build 的快照同时复制编码映射表。
- **编码解码是一个开关。** `build.yaml` 的 `decode_codes` 必填,取 true 或 false。取 false 时,映射表位置写 null,编码列按原始数字写进资料库,并注明「编码含义未解码」;取 true 时,映射表位置必填,映射表必须覆盖交付包的全部编码列,缺一列就整次构建停下,某一例出现映射表里没有的值就跳过这一例。编码的含义要向临床方要映射表;到位之前用 false。
文件格式:数组用 npz(读取时 `allow_pickle=False`,pickle 读取时会执行任意代码),每例的标量和摘要用 json,跨病例的表用 csv,图用 png。缺失值写 null,不写 NaN 或 0:0 是合法的测量值,NaN 在 json 里不合法。
导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的(共 9 条),没有的保护不写:
- `cadx` 里只有 segment 引用 `torch`、`sam2`(契约逐个列出其余已落地的模块,新模块落地时加进去)。
- segment 不引用其他 `cadx` 模块。
- delivery、library、answers、runs 互不引用。
- delivery、library、answers、runs 不引用其他 `cadx` 模块(build、review 和规划中的各模块)。
- ceus 不引用其他 `cadx` 模块(含 segment:分割结果由 `scripts/ceus_quant.py` 传进来)。
- build 不引用 delivery、library、answers、runs 以外的 `cadx` 模块(ceus 落地时放行)。
- review 不引用 library、answers、runs 以外的 `cadx` 模块(不碰交付包,不依赖 build 和 ceus)。
- 只有 delivery 引用 `openpyxl`(交付包的 Excel 只有它碰)。
- 只有 library 引用 `PIL`(写资料库的图片时去掉元数据;review 拷图片只按字节拷,不打开)。
第三部分依赖表的其余部分和下面几条外部库独占,随模块落地逐条写成契约(import-linter 只能检查已经存在的模块):只有 llm 引用 `polygateway`;只有 ledger 引用 `sqlite3`。
契约管不到的,不要当成机器守住了:
- `scripts/` 不在检查范围内,一个脚本可以同时读 `answers/` 和调会诊。consult 在启动时核对:传进来的路径必须在 `library/` 下的某次构建里,否则报错。会诊落地时另写一条泄露扫描测试,检查发给模型的请求全文里没有答案和真实病例号。
- 三路互相隔开靠 consult 的函数签名(拼某一路提示词的函数只接收那一路的对象),import-linter 管不到同一模块内部,靠审查和上面的泄露扫描。
- ceus 是纯计算,只有「不引用其他 `cadx` 模块」能写成契约;它要读视频文件,`open()` 之类内置函数拦不住。
Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
---
## 六、规模
下表是粗估,参考了旧项目对应部分的行数。**计数口径**:一个模块的行数是 `cat src/cadx/<模块>/*.py | wc -l`,不含测试和提示词文件;旧项目的数按同样口径数(2026-10-16)。动手写某个模块之前,由负责人逐个确认;写的过程中超过预期一倍,就停下来检查是需求本身复杂,还是专门路径在变多。
| 模块 | 预期行数 | 实际行数 | 旧项目对应部分的行数 |
| --- | --- | --- | --- |
| ceus | 6000–7500(基本照搬旧算法,去掉只为产品层服务的部分) | 7103 | `ceus_roi/` 除 `service.py` 7330,`video_decoder.py` 135 |
| segment | 150–300 | 300 | `medsam2_local_track.py` 240 |
| delivery | 400–700 | 739 | `clinical.py` 266、`modalities.py` 522、`workbook.py` 99、`delivery.py` 134,共 1021,其中入库与数据库部分不迁 |
| library | 150–300 | 377 | `input_packages.py` 104 |
| answers | 100–200 | 227 | — |
| verdicts | 100–200 | — | — |
| build | 300–500 | 537(含装入 CEUS 结果的 `_ceus.py`;读 CEUS 运行目录已挪进 runs) | `input_readers.py` 329 的装配部分 |
| llm | 200–450 | — | ClinDB `llm/` 415 |
| ledger | 200–300 | — | ClinDB `ledger/` 274 |
| runs | 100–250 | 201(快照 `_snapshot.py` 98、读 CEUS 运行的 `_ceus_run.py` 82、`__init__.py` 21) | — |
| consult | 500–1000(会诊口径定后重估) | — | — |
| evaluate | 300–600(评价口径定后重估) | — | 已删的 `core/eval` 622 |
| review | 300–500 | 610(`_pack.py` 407、`_pdf.py` 129、`_config.py` 64、`__init__.py` 10;不含说明模板 `readme_template.md` 202 行) | — |
| scripts/ 每个 | ≤ 300 | `build.py` 37、`scan_library.py` 88、`ceus_quant.py` 403(超出,含目视核对表的读取与核对、开跑即写快照)、`ceus_align_review.py` 243、`review_pack.py` 37 | `t1_local_debug.py` 762、`tic_lognormal_replay.py` 627 |
---
## 七、推进顺序
1. **骨架**(已完成):协作规则、文档体系、工具链。
2. **装配两个库(先不含 CEUS 结果):delivery → library、answers → runs 的快照部分 → build。** 先做它,是因为它最轻,做完就能用文字资料试会诊;而且隔离(专家看不到答案)是整个研究成立的前提,越早有代码和测试守住越好。顺带产出跳过清单,交给医生核对,也给 `待定问题/04` 提供证据。参数系统(第五部分「配置」)随这一步落地。
3. **CEUS 定量:ceus 按旧模块逐个迁移(带上旧的算法单测)→ segment → `scripts/ceus_quant.py` → build 加入 CEUS 结果。** 旧算法单测约 274 个,不依赖数据库,随模块带过来,作为迁移正确性的主要依据(design 0002 决定 4)。已知问题(映射锚点写死、两条旧路径的行为差别、造影画面在右半边)在任务书里逐条定。
4. **会诊:ledger → llm → consult 的「调一次模型」→ verdicts → consult 的会诊流程。** 动手前先专门讨论定下会诊口径:后发言的看得到什么、怎样算两路一致、超声一路给哪些指标和图、模型与重复次数等(旧项目留下的未裁决问题)。
5. **评价:evaluate。** 动手前先定指标、统计方法,以及 `待定问题/02`(参数何时冻结、开发集与测试集怎么分)、`待定问题/03`(病例排除标准)。
第 2 步和第 3 步互不依赖,可以换顺序;第 3 步完成后,build 要重新构建一次才把 CEUS 结果装进资料库。
---
## 八、各模块对外的函数与类型
写到哪个模块时,再把它对外的函数和类型补在这里。以各子包 `__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 描述、关键图路径、CEUS 视频路径(`video`,C04 已检查恰好一段)、推注时间 X(`bolus_start_s`,超声表「推注开始时间s」,C08 已检查是非负整数)、三张表的诊断编码。
- `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)`:把一次构建的各例三路内容写进一个已存在的空目录,关键图和关键帧去掉附加数据重新编码,有 CEUS 结果时写 `tic.json`,最后写 `manifest.json`(`ceus_source` 是 CEUS 运行号或 None,与每例的 `ceus` 是否为 None 必须配套)。图不能保真重编码时报错停下(说明 build 漏了 C14)。
- `check_key_image(path)`:关键图能逐像素保真重编码时返回 None,不能时(不是 PNG、位深或颜色类型不行、带 tRNS 透明色)返回原因。
- `LibraryCase`:要写进资料库的一例:化名、临床一路的字段、超声描述、关键图源文件、MR 描述、CEUS 部分(`LibraryCeus` 或 None)。
- `LibraryCeus`:一例超声一路的 CEUS 部分:build 挑好的 tic.json 内容、槽位 → 关键帧源图。
- `list_aliases(build_dir)`:一次构建的全部化名(按化名排序)。
- `read_build_info(build_dir)`:读构建清单的概况,交出 `BuildInfo`:构建号、编码列是否解码、装入的 CEUS 运行号(没装为 None)。
- `read_clinical(build_dir, alias)`、`read_ultrasound(build_dir, alias)`、`read_mr(build_dir, alias)`:按路读出一例,读时核对 SHA-256,拒绝 `.partial` 目录。
- `ClinicalRoute`:临床一路:是否已解码、22 列字段。
- `UltrasoundRoute`:超声一路:报告「所见」原文、关键图路径、tic.json 的内容(没装 CEUS 时为 None)、槽位 → 关键帧路径(没装时为空)。
- `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)`、`read_skipped(answers_dir)`:读化名对照、诊断、跳过清单,读时核对 SHA-256 和表头,拒绝 `.partial` 目录;跳过清单的文件和列两栏拆回元组。
- `verified_file(answers_dir, name)`:答案库里某个文件(如 `snapshot/build.yaml`)的路径,先核对它与清单的 SHA-256 一致;给需要原样拷贝或读快照的使用者(review)用。
### 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, uv_lock, configs)`:开跑时调。新建快照目录,复制配置原文并写 `snapshot.json`(构建号、git 状态、Python 版本、`uv.lock` 哈希、开始时间、各配置的哈希)。运行要跑很久时(CEUS 定量约一小时)在开跑时写,期间改了配置文件,快照里仍是这次用的那份。
- `finish_snapshot(snapshot_dir, finished_at, record)`:收尾时调。在快照目录里新建 `finished.json`:结束时间加 `record`(运行结束才知道的值,如显存峰值、缓存命中数);已存在就报错,不改开跑时写的文件。
- `open_ceus_run(run_dir)`:打开一次已完成的 CEUS 定量运行:核对目录存在、不是 `.partial`、有 `snapshot/snapshot.json`,读快照里的 `ceus.yaml` 和 `failed.csv`(表头、步号、视频号不重复),交出 `CeusRun`。
- `CeusRun`:运行号、目录、快照里的整份 `ceus.yaml`、`failed`(视频号 → `CeusFailure`);按视频号取文件:`has_result`(有没有结果目录)、`summary`(`summary.json`,核对里面记的视频号与目录一致)、`keyframes`(槽位 → 关键帧图路径)、`tic_png`、`overlay`。
- `CeusFailure`:`failed.csv` 的一行:失败在第几步、原因原文。
### ceus(`src/cadx/ceus/`)
对外两段接口,中间由 `scripts/ceus_quant.py` 调 segment 做第 3 步:
- `prepare_segmentation(video_path, cfg)`:第 1–2 步,交出 `SegmentationInput`(逐帧解码几遍,不把整段视频留在内存里,只留分割那一半的画面);视频打不开或读出的不合规(fps、尺寸、时间戳、帧数)抛 `VideoDecodeError`、找不到可信分屏线抛 `SplitDetectionError`、两半配准失败(找不到扇形区、可用模板匹配太少,或两种方法估出的平移相差超过 `registration.max_disagreement_px`)抛 `RegistrationError`(都是第 1 步),两半都有或都没有绿框抛 `ContrastSideError`(第 2 步)。
- `register_halves(frames, left_view, right_view, cfg.registration, cfg.prompt_source, cfg.pan_source)`:第 1 步的两半配准,交出 `HalfRegistration`:左半局部坐标 → 右半局部坐标的平移,`correlation`(两半扇形区边缘图的归一化互相关,采用它)、`template`(左半扇形内多块模板逐帧匹配后取中位数,只用来核对)、各次匹配明细、两者各分量之差的最大值;`contrast_to_target(side)` 换成造影那一半 → 另一半的平移。
- `SegmentationInput`:视频的 fps、帧数、宽高、逐帧时间戳,分屏线,`registration`(`HalfRegistration`),`offset`(造影那一半局部坐标 + `offset` = `target_view` 局部坐标),`contrast`(`ContrastSide`:哪一半是造影画面、造影视图和分割那一半视图 `target_view`、提示帧),`target_frames()`(重新解码,逐帧交出分割那一半的 BGR 图像;不存整段),`prompt_masks`(提示帧帧号 → 绿框按 `offset` 挪到 `target_view` 局部坐标的掩膜),造影那一半的第一帧。
- `quantify_case(prepared, target_masks, injection_offset_s, cfg)`:第 4–9 步。`target_masks` 是分割在 `target_view` 局部坐标上的 `(帧数, 高, 宽)` 布尔数组,`injection_offset_s` 是推注时间 X;分割一帧有效轮廓都没有抛 `NoValidContourError`(第 4 步)。拟合没过质量门不抛错。
- `QuantResult`:`trajectory`(`Trajectory`)、`tic`(`TicResult`)、`curves`(`TicCurves`)、推注时间、病灶两条曲线拟合是否都过质量门、`anchors`(7 个槽位的 `AnchorFrameLocation`)、`perfusion`(`roi_all`、`roi_perf` 各自的 `LognormalPerfusionMetrics`,拟合没过为 None)、紫圈检出率。
- `AnchorFrameLocation`:一个关键帧槽位的帧号、视频时间、推注后时刻、状态(`ok` 或未定位的类别)、未定位的原因、锚点时刻。
- 写产物用:`pack_trajectory(trajectory)`(trajectory.npz 字节)、`pack_tic_curves(curves)`(tic_curves.npz 字节)、`render_tic_png(tic, markers=tic_plot_markers(anchors))`(tic.png,标出 D1–D4)、`render_case_overlay(prepared, result)`(overlay.mp4 字节)、`keyframe_images(prepared, anchors)`(定位到的关键帧整幅原图,槽位 → 图像)、`quant_summary(result)`(拟合、灌注指标、紫圈检出率、7 个关键帧定位,可直接写成 JSON);读回用 `unpack_trajectory`、`unpack_tic_curves`。
- `fan_mask(std, cfg.registration)`:由一半画面的逐像素时间标准差求扇形区掩膜;`color_marks(frames, prompt_cfg, pan_cfg, dilate)`:任一帧里落在绿框或紫圈颜色范围内的像素。两者配准用,目视核对出图也用。
- `CeusConfig` 及各段的配置类:`config/ceus.yaml` 里 `ceus` 段的字段定义(pydantic,字段必填、不许多写、读入后不可改);`paths`、`align_check` 两段由 `scripts/ceus_quant.py` 定义。
- `CeusCaseError`:只落在这一例上的失败,带失败的步号和原因;`VideoDecodeError`、`SplitDetectionError`、`RegistrationError`、`ContrastSideError`、`NoValidContourError` 是它的子类。脚本据此把这一例记进 `failed.csv`、接着跑下一例。
- `VideoFile(path)`:按路径用 cv2 读视频:`probe()` 给 fps、帧数、宽高;`iter_frames()` 每次从第 0 帧逐帧读;`frame_times_s()` 给逐帧显示时间戳。
- 其余导出(几何函数、各步的算法函数与结果类型,如 `split_views`、`locate_contrast_side`、`masks_to_smoothed_polygons`、`extract_dark_region_polygons`、`select_reference_region`、`compute_tic_for_video`、`fit_lognormal_curve`、`locate_anchor_frames`、`compute_lognormal_perfusion_metrics`、`render_overlay`)是两段接口的组成部分,供测试用。
### segment(`src/cadx/segment/`)
- `segment_video(frames, prompt_masks, cfg)`:把提示帧的病灶掩码用 MedSAM2 传播到每一帧;`frames` 是分割那一半的逐帧 BGR 图像(只遍历一遍,边读边写成 jpg,可以是逐帧解码的生成器),`prompt_masks` 是提示帧帧号 → 同尺寸布尔掩码,交出 `(帧数, 高, 宽)` 布尔数组(即 `quantify_case` 的 `target_masks`)。MedSAM2 的 `init_state` 读帧时换成本模块的读法,帧张量直接建在显卡上,不先在 CPU 上建整段(结果逐位相同)。
- `SegmentConfig`:`config/ceus.yaml` 里 `segment` 段的字段定义(MedSAM2 代码目录、权重、模型配置名、设备、两个 offload 开关;pydantic,字段必填、不许多写、读入后不可改)。
- `medsam2_commit(medsam2_dir)`:读 MedSAM2 代码目录 `.git` 的 HEAD 提交号,给快照和分割缓存键用。
- `cuda_peak_memory_bytes(device)`:本进程在该设备上分配过的显存峰值(字节),给运行快照用;非 CUDA 设备为 None,本进程没初始化过 CUDA(分割全读了缓存)为 0。放在 segment 里,是因为 `cadx` 里只有它引用 `torch`。
### CEUS 的两个脚本(`scripts/`)
- `ceus_quant.py --config config/ceus.yaml`:逐例跑第 1–9 步;第 1 步后用 `check_review` 按视频 SHA-256 查 `paths.align_review`(`read_review` 读入):没通过的病例第 1 步失败,表里没有这段视频或配准结果与表里不同就整次停下。
- `ceus_align_review.py render --config config/ceus.yaml`:只做第 1 步,为每例和随机挑出的阴性对照出棋盘拼图和边缘叠加图,按打乱的编号写进 `runs/ceus_align/<运行号>/images/`,对应关系写 `key.csv`。
- `ceus_align_review.py score --run-dir <运行目录>`:对照 `verdicts_a.csv`、`verdicts_b.csv` 计分;有对照没被两人都判「没对齐」就作废(退出码 1,不写表),否则写 `review.csv`。
### build(`src/cadx/build/`)
- `run_build(config_path, repo_root, now)`:完整做一次构建:读配置和映射表、读交付包、对每例做 C14(`check_key_image` 返回原因的整例跳过,记进跳过清单);`ceus_run` 不为 null 时核对运行目录(不合规整次停下),对每例做 C15(CEUS 失败或没有结果的整例跳过)、关键帧也过 C14,按白名单构造 tic.json;取化名、先写进 `.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 位化名,同种子同病例得到同样的化名。
### review(`src/cadx/review/`)
- `run_review_pack(config_path, repo_root, now)`:出一次医生核对包:读配置,经 library、answers 读这次构建,经 runs 打开资料库记的那次 CEUS 运行(路径取自答案库快照里的 `build.yaml`,运行号须与资料库清单一致),先写进 `<运行号>.partial/` 再改名,交出 `PackSummary`。`now` 是本地时间,定运行号。
- `PackSummary`:供终端打印的计数:运行目录、核对包目录、病例数(进库、跳过)、按环节分的问题清单行数、PDF 份数、拷贝的视频总字节数;不含病例号。
- `ReviewPackConfig`:`review_pack.yaml` 的字段定义(pydantic,字段必填、不许多写、读入后不可改)。
- `load_review_config(path, repo_root)`:读并校验 `review_pack.yaml`,相对路径按仓库根目录解析;两个库的目录名(构建号)不同就报错。