# 代码结构 这份文件说明数据从医院原始资料到评价结果经过哪几段、代码分成哪些模块、每个模块藏住什么决定、模块之间怎么依赖、各模块预期多大,以及按什么顺序把它们写出来。各模块对外的函数和类型,写到哪个模块时再补进第八部分。 来源: - 负责人 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`),另有一个按顺序调用四段的总入口;读配置文件,把模块装配起来 | `debugs/t1_local_debug.py`、`debugs/tic_lognormal_replay.py` | | 组织 | build | 两个库怎么装配:每一路放哪些内容(可见性的唯一决定处)、化名怎么取(化名随机,资料库按化名排序,顺序就与交付顺序无关)、清单记什么。全项目只有它同时碰得到原始资料和答案 | `app/domain/input_readers.py`、`ingestion*.py` 的装配部分 | | 组织 | consult | 会诊怎么进行:几个专家、谁先说、后发言的看得到前面的什么、什么时候结束;「调一次模型」的统一入口(先存请求、记开始、调用、存回复、记结束)也在这里 | 没有 | | 组织 | evaluate | 评价口径:指标、统计方法、字段级对照怎么计分 | 没有(`core/eval` 已删) | | 格式 | 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 | 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 格式 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 | | 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 定量。** 医生标注工作簿里的时刻(如造影剂到达时间 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 在这一例上失败(在 `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`),会出现在这份清单里; - 将来加医生标注工作簿。 ### CEUS 定量的产物(`runs/ceus/` 与 `work/`) 每次运行的产物写进 `runs/ceus/<运行号>/`,只追加;第 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` | CEUS 定量的全部参数,每个参数用注释写明出处 | | `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/`**。下面只列契约实际强制的(共 8 条),没有的保护不写: - `cadx` 里只有 segment 引用 `torch`、`sam2`(契约逐个列出其余已落地的模块,新模块落地时加进去)。 - segment 不引用其他 `cadx` 模块。 - delivery、library、answers、runs 互不引用。 - delivery、library、answers、runs 不引用其他 `cadx` 模块(build 和规划中的各模块)。 - ceus 不引用其他 `cadx` 模块(含 segment:分割结果由 `scripts/ceus_quant.py` 传进来)。 - build 不引用 delivery、library、answers、runs 以外的 `cadx` 模块(ceus 落地时放行)。 - 只有 delivery 引用 `openpyxl`(交付包的 Excel 只有它碰)。 - 只有 library 引用 `PIL`(写资料库的图片时去掉元数据)。 第三部分依赖表的其余部分和下面几条外部库独占,随模块落地逐条写成契约(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(基本照搬旧算法,去掉只为产品层服务的部分) | — | `ceus_roi/` 除 `service.py` 7330,`video_decoder.py` 135 | | segment | 150–300 | 233 | `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 | 300 | `input_packages.py` 104 | | answers | 100–200 | 201 | — | | verdicts | 100–200 | — | — | | build | 300–500 | 307 | `input_readers.py` 329 的装配部分 | | llm | 200–450 | — | ClinDB `llm/` 415 | | ledger | 200–300 | — | ClinDB `ledger/` 274 | | runs | 100–250 | 86(只含快照部分) | — | | consult | 500–1000(会诊口径定后重估) | — | — | | evaluate | 300–600(评价口径定后重估) | — | 已删的 `core/eval` 622 | | scripts/ 每个 | ≤ 300 | `build.py` 37、`scan_library.py` 88、`ceus_quant.py` 293 | `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_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)`:评价用的读取,读时核对 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-`,工作区不干净时末尾加 `-dirty`。 - `write_snapshot(snapshot_dir, build_id, git, started_at, finished_at, uv_lock, configs)`:新建快照目录,复制配置原文并写 `snapshot.json`(构建号、git 状态、Python 版本、`uv.lock` 哈希、起止时间、各配置的哈希)。 ### ceus(`src/cadx/ceus/`) 对外两段接口,中间由 `scripts/ceus_quant.py` 调 segment 做第 3 步: - `prepare_segmentation(video_path, cfg)`:第 1–2 步,交出 `SegmentationInput`;视频打不开或读出的不合规(fps、尺寸、时间戳、帧数)抛 `VideoDecodeError`、找不到可信分屏线抛 `SplitDetectionError`(都是第 1 步),两半都有或都没有绿框抛 `ContrastSideError`(第 2 步)。 - `SegmentationInput`:视频的 fps、帧数、宽高、逐帧时间戳,分屏线,`contrast`(`ContrastSide`:哪一半是造影画面、造影视图和分割那一半视图 `target_view`、提示帧),`target_frames`(分割那一半的逐帧 BGR 图像),`prompt_masks`(提示帧帧号 → 绿框挪到 `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`。 - `CeusConfig` 及各段的配置类:`config/ceus.yaml` 里 `ceus` 段的字段定义(pydantic,字段必填、不许多写、读入后不可改);`paths` 段由 `scripts/ceus_quant.py` 定义。 - `CeusCaseError`:只落在这一例上的失败,带失败的步号和原因;`VideoDecodeError`、`SplitDetectionError`、`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 图像,`prompt_masks` 是提示帧帧号 → 同尺寸布尔掩码,交出 `(帧数, 高, 宽)` 布尔数组(即 `quantify_case` 的 `target_masks`)。 - `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`。 ### 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 位化名,同种子同病例得到同样的化名。