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:
+7
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前目标
|
||||
|
||||
骨架已完成;整体架构已和负责人讨论定稿(`research-wiki/design/0002`、`explanation/结构.md`)。下一步是写第 2 步「装配两个库(不含 CEUS)」的任务书 `research-wiki/任务书/01-装配两个库.md`。
|
||||
第 2 步「装配两个库(不含 CEUS)」已按任务书 01 实现,等监督者验收。之后是第 3 步 CEUS 定量或会诊口径讨论(`结构.md` 第七部分)。
|
||||
|
||||
## 已完成
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
- 2026-10-15/16 深读旧项目三条线(CEUS 算法、资料与答案隔离、会诊与评价),两位中立评审独立评了存储与迁移方案;结论落成 design 0002 与重写的 `结构.md`。待定的研究问题记在 `research-wiki/待定问题/`。
|
||||
|
||||
- 2026-10-16 任务书 01 装配两个库:delivery、library、answers、runs(快照)、build 五个模块,`scripts/build.py`、`scripts/scan_library.py`、`config/build.yaml`,91 条测试,`make check` 通过(6 条导入契约)。两轮独立审阅的会悄悄漏答案的意见已修(C11–C13 加严、泄露扫描 NFKC、关键图只收能保真重编码的 PNG、YAML 拒绝重复键)。真实 40 例:40 例中 34 例进库,跳过 6 例全为 C10(跨表不一致,与 `待定问题/04` 一致),复核清单 2 条,泄露扫描命中 0;构建号与提交号见提交说明和本次汇报。使用说明 `guides/装配两个库.md`。
|
||||
|
||||
## 未完成
|
||||
|
||||
- `结构.md` 按负责人要求对照 ClinDB 的 `结构.md` 重写后,重写版还没再过一轮「硕士生阅读」(design 0002 与上一版结构文档过过)。
|
||||
@@ -29,9 +31,12 @@
|
||||
|
||||
- 负责人 2026-10-16:先做第 2 步装配两个库;配置每段一份(`config/<段>.yaml`),编码解码是可选开关、映射表单独一份配置;交付资料检查一例有一项不过就整例跳过并记录,「都是遇到整个病例跳过,让医生核对去吧」;化名对照存进答案库。已写进 `结构.md`(框架图、第五部分「配置」)和 `AGENTS.md`「错误怎么处理」。
|
||||
|
||||
- 负责人 2026-10-16 定 C04:报告表名随视频名配对(`U<号>_roi2.mp4` 配 `U<号>_roi2_report.xlsx`,`U<号>.mp4` 配 `U<号>_report.xlsx`),绿框版那一例因此进库。任务书 01 原文只认前一种,未改任务书。
|
||||
|
||||
## 下一步
|
||||
|
||||
- 任务书 01「装配两个库」已写好(`research-wiki/任务书/01-装配两个库.md`),交负责人另开会话执行,本会话验收。
|
||||
- 监督者验收任务书 01。
|
||||
- 待负责人定:关键图格式不合要求时整次停下还是整例跳过;化名种子在 git 里,拿到种子可反推进库病例的病例号先后(专家拿不到种子);含「造影提示:」的那份超声描述所在病例被 C10 跳过,故不在复核清单里。
|
||||
- 向临床方要编码映射表(性别、诊断、症状、既往史、化验档位)。
|
||||
- 把 `待定问题/04`(6 例跨表不一致)、`待定问题/06`(1 份超声描述中段的「造影提示:」)转给医生;06 定之前的构建不做正式会诊。
|
||||
- 等临床方交付 40 例的医生标注工作簿(含造影剂到达时间和注射时间)。
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# 装配两个库(scripts/build.py)的配置。字段定义与校验见 src/cadx/build/_config.py,
|
||||
# 每个字段必填;相对路径相对仓库根目录解析。
|
||||
delivery_dir: data/交付包_20260908_40例
|
||||
library_root: library
|
||||
answers_root: answers
|
||||
# 生成化名的随机种子。
|
||||
alias_seed: 20261016
|
||||
# 编码列是否按映射表解码。映射表还没向临床方拿到,先不解码,code_maps 必须是 null。
|
||||
decode_codes: false
|
||||
code_maps: null
|
||||
+55
-2
@@ -3,7 +3,12 @@ name = "cadx"
|
||||
version = "0.0.0"
|
||||
description = "胰腺囊性肿瘤多模态会诊诊断的研究代码"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"openpyxl>=3.1.5",
|
||||
"pillow>=12.3.0",
|
||||
"pydantic>=2.14.1",
|
||||
"pyyaml>=6.0.3",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -12,6 +17,8 @@ dev = [
|
||||
"pytest>=8.3",
|
||||
"import-linter>=2.1",
|
||||
"pre-commit>=4.0",
|
||||
"types-pyyaml>=6.0.12.20260906",
|
||||
"types-openpyxl>=3.1.5.20260827",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
@@ -41,9 +48,12 @@ pythonVersion = "3.12"
|
||||
typeCheckingMode = "strict"
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
extraPaths = ["tests"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
# 测试辅助(假交付包生成器)放在 tests/ 下,按普通模块导入。
|
||||
pythonpath = ["tests"]
|
||||
addopts = "--strict-markers --import-mode=importlib"
|
||||
markers = [
|
||||
"live: 真实模型调用,花钱,需要 .env 里的密钥",
|
||||
@@ -51,7 +61,7 @@ markers = [
|
||||
]
|
||||
|
||||
# 导入边界。改契约先改 research-wiki/explanation/结构.md,再改这里;
|
||||
# 不靠 ignore_imports 让违规通过。规划中的契约见结构.md 第四节,模块落地时随任务书加入。
|
||||
# 不靠 ignore_imports 让违规通过。规划中的契约见结构.md 第五部分,模块落地时随任务书加入。
|
||||
[tool.importlinter]
|
||||
root_packages = ["cadx"]
|
||||
include_external_packages = true
|
||||
@@ -62,3 +72,46 @@ name = "torch 与 sam2 不进 cadx"
|
||||
type = "forbidden"
|
||||
source_modules = ["cadx"]
|
||||
forbidden_modules = ["torch", "sam2"]
|
||||
|
||||
[[tool.importlinter.contracts]]
|
||||
# 结构.md 第三部分依赖表:外部层与格式层的模块不依赖其他 cadx 模块。
|
||||
name = "delivery、library、answers、runs 互不依赖"
|
||||
type = "independence"
|
||||
modules = ["cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs"]
|
||||
|
||||
[[tool.importlinter.contracts]]
|
||||
# 同上,依赖表的另一半:它们也不引用上层和其余规划中的模块(独立契约只管它们之间)。
|
||||
name = "delivery、library、answers、runs 不引用其他 cadx 模块"
|
||||
type = "forbidden"
|
||||
source_modules = ["cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs"]
|
||||
forbidden_modules = [
|
||||
"cadx.build", "cadx.ceus", "cadx.segment", "cadx.llm", "cadx.ledger",
|
||||
"cadx.consult", "cadx.evaluate", "cadx.verdicts",
|
||||
]
|
||||
|
||||
[[tool.importlinter.contracts]]
|
||||
# 结构.md 第三部分依赖表:build 只依赖 delivery、library、answers、runs(ceus 落地时加入)。
|
||||
name = "build 只引用 delivery、library、answers、runs"
|
||||
type = "forbidden"
|
||||
source_modules = ["cadx.build"]
|
||||
forbidden_modules = [
|
||||
"cadx.ceus", "cadx.segment", "cadx.llm", "cadx.ledger",
|
||||
"cadx.consult", "cadx.evaluate", "cadx.verdicts",
|
||||
]
|
||||
allow_indirect_imports = true
|
||||
|
||||
[[tool.importlinter.contracts]]
|
||||
# 结构.md 第五部分:交付包的 Excel 只有 delivery 碰。
|
||||
name = "只有 delivery 引用 openpyxl"
|
||||
type = "forbidden"
|
||||
source_modules = ["cadx.library", "cadx.answers", "cadx.runs", "cadx.build"]
|
||||
forbidden_modules = ["openpyxl"]
|
||||
allow_indirect_imports = true
|
||||
|
||||
[[tool.importlinter.contracts]]
|
||||
# 结构.md 第五部分:写资料库的图片(去掉元数据)只有 library 碰。
|
||||
name = "只有 library 引用 PIL"
|
||||
type = "forbidden"
|
||||
source_modules = ["cadx.delivery", "cadx.answers", "cadx.runs", "cadx.build"]
|
||||
forbidden_modules = ["PIL"]
|
||||
allow_indirect_imports = true
|
||||
|
||||
@@ -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 位化名,同种子同病例得到同样的化名。
|
||||
|
||||
@@ -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`「两档标准」:运行产物只追加)。
|
||||
@@ -0,0 +1,37 @@
|
||||
"""装配两个库:读交付包,写出一对同号的资料库与答案库。
|
||||
|
||||
用法:uv run python scripts/build.py --config config/build.yaml
|
||||
终端只打印计数,不打印病例号和资料内容。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from cadx.build import run_build
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="装配给专家的资料库和标准答案库")
|
||||
parser.add_argument("--config", type=Path, required=True, help="build.yaml 的路径")
|
||||
config: Path = parser.parse_args().config
|
||||
|
||||
summary = run_build(config, REPO_ROOT, datetime.now().astimezone())
|
||||
|
||||
print(f"构建号:{summary.build_id}")
|
||||
print(f"资料库:{summary.library_dir}")
|
||||
print(f"答案库:{summary.answers_dir}")
|
||||
print(f"病例总数:{summary.total_cases}")
|
||||
print(f"进库病例:{summary.passed_cases}")
|
||||
print(f"跳过病例:{summary.skipped_cases}")
|
||||
print(f"认不出编号的行:{summary.unrecognized_rows}")
|
||||
by_check = ",".join(f"{check} {n}" for check, n in summary.skipped_by_check.items())
|
||||
print(f"按检查编号分的跳过数:{by_check or '无'}")
|
||||
print(f"复核清单条数:{summary.review_rows}")
|
||||
print(f"跳过清单说明:{summary.skipped_note}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""泄露扫描:查一次构建的资料库里有没有真实病例号、交付文件名、亚型名、诊断列名。
|
||||
|
||||
用法:uv run python scripts/scan_library.py --library library/<构建号> --answers answers/<构建号>
|
||||
病例号和视频号从答案库读;亚型名、列名、文件名的词表在本脚本里独立写,不取自 delivery。
|
||||
只输出命中的条数和类别,不输出命中的内容。这个脚本同时读两个库,只能放在 scripts/。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from cadx.answers import read_aliases
|
||||
from cadx.library import scan_for_leaks
|
||||
|
||||
SUBTYPE_NAMES = (
|
||||
"IPMN",
|
||||
"IPMT",
|
||||
"SPN",
|
||||
"SPT",
|
||||
"SPTP",
|
||||
"MCN",
|
||||
"SCN",
|
||||
"导管内乳头状黏液性肿瘤",
|
||||
"导管内乳头状",
|
||||
"导管内乳头",
|
||||
"实性假乳头状",
|
||||
"实性假乳头",
|
||||
"黏液性囊性肿瘤",
|
||||
"黏液性囊",
|
||||
"粘液性囊",
|
||||
"黏液囊性",
|
||||
"粘液囊性",
|
||||
"浆液性囊性肿瘤",
|
||||
"浆液性囊",
|
||||
"浆液性微囊",
|
||||
"囊腺瘤",
|
||||
)
|
||||
COLUMN_NAMES = (
|
||||
"Case_ID",
|
||||
"Diagnosis",
|
||||
"病理类型",
|
||||
"病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)",
|
||||
"患者ID",
|
||||
"CEUS视频ID",
|
||||
"推注开始时间",
|
||||
)
|
||||
DELIVERY_FILE_NAMES = (
|
||||
"临床工作簿_40例.xlsx",
|
||||
"超声报告文本_40例.xlsx",
|
||||
"MR报告文本_40例.xlsx",
|
||||
"Clinical_agent",
|
||||
"CEUS_agent",
|
||||
"MR_agent",
|
||||
)
|
||||
FORBIDDEN_KEYS = ("diagnosis", "label", "gold", "pathology", "name", "row")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="扫描资料库里的泄露")
|
||||
parser.add_argument("--library", type=Path, required=True, help="资料库的构建目录")
|
||||
parser.add_argument("--answers", type=Path, required=True, help="同一构建号的答案库目录")
|
||||
args = parser.parse_args()
|
||||
library: Path = args.library
|
||||
answers: Path = args.answers
|
||||
if library.name != answers.name:
|
||||
raise SystemExit(f"两个目录的构建号不同:{library.name} 与 {answers.name}")
|
||||
|
||||
categories: dict[str, str] = {}
|
||||
for row in read_aliases(answers):
|
||||
categories[f"P{row.case_no}"] = "病例号"
|
||||
categories[row.video_id] = "视频号"
|
||||
for suffix in ("_key.png", "_roi2.mp4", ".mp4", "_roi2_report.xlsx", "_report.xlsx"):
|
||||
categories[f"{row.video_id}{suffix}"] = "交付文件名"
|
||||
categories.update(dict.fromkeys(DELIVERY_FILE_NAMES, "交付文件名"))
|
||||
categories.update(dict.fromkeys(SUBTYPE_NAMES, "亚型名"))
|
||||
categories.update(dict.fromkeys(COLUMN_NAMES, "列名"))
|
||||
|
||||
hits = scan_for_leaks(library, list(categories), FORBIDDEN_KEYS)
|
||||
counts = Counter("键名" if hit.kind == "键名" else categories[hit.term] for hit in hits)
|
||||
print(f"扫描的病例数:{len(read_aliases(answers))}")
|
||||
print(f"禁止的字符串:{len(categories)} 个;禁止的键名:{len(FORBIDDEN_KEYS)} 个")
|
||||
print(f"命中总数:{len(hits)}")
|
||||
for category in ("病例号", "视频号", "交付文件名", "亚型名", "列名", "键名"):
|
||||
print(f" {category}:{counts[category]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""answers:标准答案库的目录布局、文件格式、写入和评价要用的读取。
|
||||
|
||||
拒绝读取名字以 `.partial` 结尾的目录;读时核对清单里的 SHA-256。
|
||||
"""
|
||||
|
||||
from cadx.answers._files import (
|
||||
AliasRow,
|
||||
DiagnosisRow,
|
||||
ReviewRow,
|
||||
SkippedRow,
|
||||
read_aliases,
|
||||
read_diagnoses,
|
||||
write_answers,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AliasRow",
|
||||
"DiagnosisRow",
|
||||
"ReviewRow",
|
||||
"SkippedRow",
|
||||
"read_aliases",
|
||||
"read_diagnoses",
|
||||
"write_answers",
|
||||
]
|
||||
@@ -0,0 +1,177 @@
|
||||
"""标准答案库的目录布局、写入和读取。
|
||||
|
||||
```
|
||||
<构建目录>/
|
||||
manifest.json 构建号、跳过清单的说明、本目录其余全部文件的 SHA-256
|
||||
snapshot/ 运行快照(由 runs 写,先于本模块写入)
|
||||
aliases.csv 化名、病例号、视频号
|
||||
diagnoses.csv 化名、病例号、三张表的诊断编码、诊断名
|
||||
skipped.csv 没进资料库的病例和第一项没过的检查
|
||||
review.csv 进库病例报告里含复核措辞的句子
|
||||
```
|
||||
|
||||
CSV 用带 BOM 的 UTF-8,医生用 Excel 直接打开不乱码。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
MANIFEST = "manifest.json"
|
||||
_ENCODING = "utf-8-sig"
|
||||
_JOIN = "; "
|
||||
|
||||
ALIASES_HEADER = ("化名", "病例号", "视频号")
|
||||
DIAGNOSES_HEADER = ("化名", "病例号", "临床表诊断编码", "超声表诊断编码", "MR表诊断编码", "诊断名")
|
||||
SKIPPED_HEADER = ("病例", "检查编号", "检查说明", "文件", "列", "涉及值")
|
||||
REVIEW_HEADER = ("化名", "路", "命中的词", "所在句子")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AliasRow:
|
||||
"""化名与真实病例号、视频号的对照。"""
|
||||
|
||||
alias: str
|
||||
case_no: str
|
||||
video_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiagnosisRow:
|
||||
"""一例的金标准:三张表各自的诊断编码,解码后的诊断名(不解码时为 None)。"""
|
||||
|
||||
alias: str
|
||||
case_no: str
|
||||
clinical_code: int
|
||||
ultrasound_code: int
|
||||
mr_code: int
|
||||
name: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkippedRow:
|
||||
"""跳过清单的一行。`case` 是病例号,或认不出编号时的「文件 第 N 行」。"""
|
||||
|
||||
case: str
|
||||
check: str
|
||||
description: str
|
||||
files: tuple[str, ...]
|
||||
columns: tuple[str, ...]
|
||||
value_json: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewRow:
|
||||
"""复核清单的一行:`route` 是「超声」或「MR」。"""
|
||||
|
||||
alias: str
|
||||
route: str
|
||||
words: str
|
||||
sentence: str
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _write_csv(path: Path, header: tuple[str, ...], rows: Sequence[Sequence[object]]) -> None:
|
||||
with path.open("w", encoding=_ENCODING, newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(header)
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def write_answers(
|
||||
answers_dir: Path,
|
||||
build_id: str,
|
||||
skipped_note: str,
|
||||
aliases: Sequence[AliasRow],
|
||||
diagnoses: Sequence[DiagnosisRow],
|
||||
skipped: Sequence[SkippedRow],
|
||||
reviews: Sequence[ReviewRow],
|
||||
) -> None:
|
||||
"""写四张表,再给目录里全部文件(含已写好的 snapshot/)算 SHA-256 写进清单。"""
|
||||
_write_csv(
|
||||
answers_dir / "aliases.csv",
|
||||
ALIASES_HEADER,
|
||||
[(r.alias, r.case_no, r.video_id) for r in aliases],
|
||||
)
|
||||
_write_csv(
|
||||
answers_dir / "diagnoses.csv",
|
||||
DIAGNOSES_HEADER,
|
||||
[
|
||||
(r.alias, r.case_no, r.clinical_code, r.ultrasound_code, r.mr_code, r.name or "")
|
||||
for r in diagnoses
|
||||
],
|
||||
)
|
||||
_write_csv(
|
||||
answers_dir / "skipped.csv",
|
||||
SKIPPED_HEADER,
|
||||
[
|
||||
(
|
||||
r.case,
|
||||
r.check,
|
||||
r.description,
|
||||
_JOIN.join(r.files),
|
||||
_JOIN.join(r.columns),
|
||||
r.value_json,
|
||||
)
|
||||
for r in skipped
|
||||
],
|
||||
)
|
||||
_write_csv(
|
||||
answers_dir / "review.csv",
|
||||
REVIEW_HEADER,
|
||||
[(r.alias, r.route, r.words, r.sentence) for r in reviews],
|
||||
)
|
||||
files = {
|
||||
p.relative_to(answers_dir).as_posix(): _sha256(p)
|
||||
for p in sorted(answers_dir.rglob("*"))
|
||||
if p.is_file()
|
||||
}
|
||||
manifest = {"build_id": build_id, "skipped_note": skipped_note, "files": files}
|
||||
(answers_dir / MANIFEST).write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _read_csv(answers_dir: Path, name: str, header: tuple[str, ...]) -> list[list[str]]:
|
||||
if answers_dir.name.endswith(".partial"):
|
||||
raise ValueError(f"{answers_dir} 是没写完的构建(.partial),不能读")
|
||||
manifest = cast(
|
||||
dict[str, Any], json.loads((answers_dir / MANIFEST).read_text(encoding="utf-8"))
|
||||
)
|
||||
path = answers_dir / name
|
||||
if _sha256(path) != manifest["files"][name]:
|
||||
raise ValueError(f"{name} 的 SHA-256 与清单不一致,答案库被改动过")
|
||||
with path.open(encoding=_ENCODING, newline="") as handle:
|
||||
rows = list(csv.reader(handle))
|
||||
if tuple(rows[0]) != header:
|
||||
raise ValueError(f"{name} 的表头与约定不一致")
|
||||
return rows[1:]
|
||||
|
||||
|
||||
def read_aliases(answers_dir: Path) -> tuple[AliasRow, ...]:
|
||||
"""读化名对照。"""
|
||||
rows = _read_csv(answers_dir, "aliases.csv", ALIASES_HEADER)
|
||||
return tuple(AliasRow(alias=a, case_no=c, video_id=v) for a, c, v in rows)
|
||||
|
||||
|
||||
def read_diagnoses(answers_dir: Path) -> tuple[DiagnosisRow, ...]:
|
||||
"""读金标准。诊断名一栏为空表示构建时没有解码。"""
|
||||
rows = _read_csv(answers_dir, "diagnoses.csv", DIAGNOSES_HEADER)
|
||||
return tuple(
|
||||
DiagnosisRow(
|
||||
alias=alias,
|
||||
case_no=case_no,
|
||||
clinical_code=int(clinical),
|
||||
ultrasound_code=int(ultrasound),
|
||||
mr_code=int(mr),
|
||||
name=name or None,
|
||||
)
|
||||
for alias, case_no, clinical, ultrasound, mr, name in rows
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""build:装配两个库。可见性(每一路放什么)、化名、清单内容只在这里决定。"""
|
||||
|
||||
from cadx.build._assemble import BuildSummary, make_aliases, run_build
|
||||
from cadx.build._config import BuildConfig, load_build_config, load_code_maps
|
||||
|
||||
__all__ = [
|
||||
"BuildConfig",
|
||||
"BuildSummary",
|
||||
"load_build_config",
|
||||
"load_code_maps",
|
||||
"make_aliases",
|
||||
"run_build",
|
||||
]
|
||||
@@ -0,0 +1,198 @@
|
||||
"""装配一次构建:决定每一路放什么、化名怎么取,写出资料库和答案库。
|
||||
|
||||
全项目只有这里同时碰得到原始资料和答案。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
from collections import Counter
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from cadx.answers import AliasRow, DiagnosisRow, ReviewRow, SkippedRow, write_answers
|
||||
from cadx.build._config import load_build_config, load_code_maps
|
||||
from cadx.delivery import (
|
||||
CODED_COLUMNS,
|
||||
SKIPPED_NOTE,
|
||||
CaseRecord,
|
||||
CodeMaps,
|
||||
read_delivery,
|
||||
review_sentences,
|
||||
)
|
||||
from cadx.library import LibraryCase, write_library
|
||||
from cadx.runs import make_build_id, read_git_state, write_snapshot
|
||||
|
||||
ALIAS_ALPHABET = string.ascii_lowercase + string.digits
|
||||
ALIAS_LENGTH = 4
|
||||
# 化名里出现亚型缩写会被泄露扫描(不分大小写)误报,抽到就重抽。
|
||||
_ALIAS_BANNED = ("ipmn", "spn", "mcn", "scn")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildSummary:
|
||||
"""一次构建的计数,供终端打印;不含病例号和资料内容。"""
|
||||
|
||||
build_id: str
|
||||
library_dir: Path
|
||||
answers_dir: Path
|
||||
total_cases: int
|
||||
passed_cases: int
|
||||
skipped_cases: int
|
||||
unrecognized_rows: int
|
||||
skipped_by_check: dict[str, int]
|
||||
review_rows: int
|
||||
skipped_note: str
|
||||
|
||||
|
||||
def make_aliases(case_nos: Sequence[str], seed: int) -> dict[str, str]:
|
||||
"""按病例号排序后依次抽化名:同一个种子、同一批病例得到同样的化名。"""
|
||||
rng = random.Random(seed) # noqa: S311 化名只需可复现,不需要密码学强度
|
||||
aliases: dict[str, str] = {}
|
||||
for case_no in sorted(case_nos):
|
||||
while True:
|
||||
alias = "".join(rng.choice(ALIAS_ALPHABET) for _ in range(ALIAS_LENGTH))
|
||||
if alias not in aliases.values() and not any(b in alias for b in _ALIAS_BANNED):
|
||||
break
|
||||
aliases[case_no] = alias
|
||||
return aliases
|
||||
|
||||
|
||||
def _clinical_fields(
|
||||
case: CaseRecord, code_maps: CodeMaps | None
|
||||
) -> dict[str, str | int | float | None]:
|
||||
"""临床一路的 22 列;解码时编码列写映射表里的含义,没测的化验保持 None。"""
|
||||
if code_maps is None:
|
||||
return dict(case.clinical)
|
||||
return {
|
||||
column: code_maps[column][cast(int, value)]
|
||||
if column in CODED_COLUMNS and value is not None
|
||||
else value
|
||||
for column, value in case.clinical.items()
|
||||
}
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _rename(partial: Path, final: Path) -> None:
|
||||
# POSIX 的 rename 会悄悄覆盖空目录,所以改名前再查一次。
|
||||
if final.exists():
|
||||
raise FileExistsError(f"{final} 已存在,不覆盖")
|
||||
partial.rename(final)
|
||||
|
||||
|
||||
def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary:
|
||||
"""读配置和交付包,装配一对同号的资料库与答案库。`now` 是本地时间,定构建号。"""
|
||||
config = load_build_config(config_path, repo_root)
|
||||
code_maps = load_code_maps(config.code_maps) if config.code_maps is not None else None
|
||||
delivery = read_delivery(config.delivery_dir, code_maps)
|
||||
|
||||
git = read_git_state(repo_root)
|
||||
build_id = make_build_id(now, git)
|
||||
library_dir = config.library_root / build_id
|
||||
answers_dir = config.answers_root / build_id
|
||||
library_partial = config.library_root / f"{build_id}.partial"
|
||||
answers_partial = config.answers_root / f"{build_id}.partial"
|
||||
for final in (library_dir, answers_dir):
|
||||
if final.exists():
|
||||
raise FileExistsError(f"{final} 已存在,不覆盖")
|
||||
for partial in (library_partial, answers_partial):
|
||||
partial.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
aliases = make_aliases([case.case_no for case in delivery.cases], config.alias_seed)
|
||||
by_alias = sorted(delivery.cases, key=lambda case: aliases[case.case_no])
|
||||
|
||||
configs = {"build.yaml": config_path}
|
||||
if config.code_maps is not None:
|
||||
configs["code_maps.yaml"] = config.code_maps
|
||||
write_library(
|
||||
build_dir=library_partial,
|
||||
build_id=build_id,
|
||||
git_commit=git.commit,
|
||||
git_clean=git.clean,
|
||||
config_sha256={name: _sha256(path) for name, path in configs.items()},
|
||||
codes_decoded=code_maps is not None,
|
||||
uncompared=delivery.uncompared,
|
||||
ceus_source=None,
|
||||
cases=[
|
||||
LibraryCase(
|
||||
alias=aliases[case.case_no],
|
||||
clinical_fields=_clinical_fields(case, code_maps),
|
||||
ultrasound_report=case.ultrasound_report,
|
||||
key_image_source=case.key_image,
|
||||
mr_report=case.mr_report,
|
||||
)
|
||||
for case in by_alias
|
||||
],
|
||||
)
|
||||
|
||||
reviews = [
|
||||
ReviewRow(alias=aliases[case.case_no], route=route, words=words, sentence=sentence)
|
||||
for case in by_alias
|
||||
for route, text in (("超声", case.ultrasound_report), ("MR", case.mr_report))
|
||||
for words, sentence in review_sentences(text)
|
||||
]
|
||||
write_snapshot(
|
||||
snapshot_dir=answers_partial / "snapshot",
|
||||
build_id=build_id,
|
||||
git=git,
|
||||
started_at=now,
|
||||
finished_at=datetime.now().astimezone(),
|
||||
uv_lock=repo_root / "uv.lock",
|
||||
configs=configs,
|
||||
)
|
||||
write_answers(
|
||||
answers_dir=answers_partial,
|
||||
build_id=build_id,
|
||||
skipped_note=SKIPPED_NOTE,
|
||||
aliases=[
|
||||
AliasRow(alias=aliases[c.case_no], case_no=c.case_no, video_id=c.video_id)
|
||||
for c in delivery.cases
|
||||
],
|
||||
diagnoses=[
|
||||
DiagnosisRow(
|
||||
alias=aliases[c.case_no],
|
||||
case_no=c.case_no,
|
||||
clinical_code=c.diagnosis.clinical,
|
||||
ultrasound_code=c.diagnosis.ultrasound,
|
||||
mr_code=c.diagnosis.mr,
|
||||
name=None if code_maps is None else code_maps["Diagnosis"][c.diagnosis.clinical],
|
||||
)
|
||||
for c in delivery.cases
|
||||
],
|
||||
skipped=[
|
||||
SkippedRow(
|
||||
case=s.case,
|
||||
check=s.check,
|
||||
description=s.description,
|
||||
files=s.files,
|
||||
columns=s.columns,
|
||||
value_json=s.value_json,
|
||||
)
|
||||
for s in delivery.skipped
|
||||
],
|
||||
reviews=reviews,
|
||||
)
|
||||
|
||||
# 先改答案库,最后改资料库:资料库的 <构建号>/ 出现才算构建完成。
|
||||
_rename(answers_partial, answers_dir)
|
||||
_rename(library_partial, library_dir)
|
||||
|
||||
by_check = Counter(s.check for s in delivery.skipped)
|
||||
return BuildSummary(
|
||||
build_id=build_id,
|
||||
library_dir=library_dir,
|
||||
answers_dir=answers_dir,
|
||||
total_cases=delivery.total_cases,
|
||||
passed_cases=len(delivery.cases),
|
||||
skipped_cases=len(delivery.skipped) - delivery.unrecognized_rows,
|
||||
unrecognized_rows=delivery.unrecognized_rows,
|
||||
skipped_by_check=dict(sorted(by_check.items())),
|
||||
review_rows=len(reviews),
|
||||
skipped_note=SKIPPED_NOTE,
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""`config/build.yaml` 与编码映射表的字段定义和校验(含 T5)。"""
|
||||
|
||||
from collections.abc import Hashable
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Self
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, TypeAdapter, model_validator
|
||||
|
||||
from cadx.delivery import CODED_COLUMNS, CodeMaps
|
||||
|
||||
|
||||
def _reject_empty_path(value: object) -> object:
|
||||
# Path("") 会变成 "."(当前目录),空串在转换前就拦下。
|
||||
if value == "":
|
||||
raise ValueError("路径不能是空串")
|
||||
return value
|
||||
|
||||
|
||||
# YAML 里的路径是字符串;路径字段是唯一允许从字符串转换类型的地方。
|
||||
type _PathField = Annotated[Path, Field(strict=False), BeforeValidator(_reject_empty_path)]
|
||||
|
||||
|
||||
class _UniqueKeyLoader(yaml.SafeLoader):
|
||||
"""同一层出现重复键就报错:PyYAML 默认让后一个悄悄覆盖前一个。"""
|
||||
|
||||
# 签名(含 deep 的默认值)照抄 PyYAML 的基类方法,覆盖时必须一致。
|
||||
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Hashable, Any]:
|
||||
seen: set[Hashable] = set()
|
||||
for key_node, _ in node.value:
|
||||
key = self.construct_object(key_node, deep=deep) # pyright: ignore[reportUnknownMemberType]
|
||||
if key in seen:
|
||||
raise ValueError(f"YAML 第 {key_node.start_mark.line + 1} 行重复的键:{key!r}")
|
||||
seen.add(key)
|
||||
return super().construct_mapping(node, deep=deep)
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> object:
|
||||
return yaml.load(path.read_text(encoding="utf-8"), Loader=_UniqueKeyLoader) # noqa: S506 _UniqueKeyLoader 继承 SafeLoader
|
||||
|
||||
|
||||
SUBTYPES = frozenset({"IPMN", "SPN", "MCN", "SCN"})
|
||||
SEXES = frozenset({"男", "女"})
|
||||
|
||||
|
||||
class BuildConfig(BaseModel):
|
||||
"""装配两个库的配置。每个字段必填;相对路径相对仓库根目录解析。"""
|
||||
|
||||
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
|
||||
|
||||
delivery_dir: _PathField
|
||||
library_root: _PathField
|
||||
answers_root: _PathField
|
||||
alias_seed: int
|
||||
decode_codes: bool
|
||||
code_maps: Annotated[Path | None, Field(strict=False), BeforeValidator(_reject_empty_path)]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _code_maps_match_switch(self) -> Self:
|
||||
if self.decode_codes and self.code_maps is None:
|
||||
raise ValueError("decode_codes 为 true 时 code_maps 必须是映射表路径")
|
||||
if not self.decode_codes and self.code_maps is not None:
|
||||
raise ValueError("decode_codes 为 false 时 code_maps 必须是 null")
|
||||
return self
|
||||
|
||||
|
||||
def load_build_config(path: Path, repo_root: Path) -> BuildConfig:
|
||||
"""读并校验 `build.yaml`,把相对路径换成 `repo_root` 下的绝对路径。"""
|
||||
config = BuildConfig.model_validate(_load_yaml(path))
|
||||
return config.model_copy(
|
||||
update={
|
||||
"delivery_dir": repo_root / config.delivery_dir,
|
||||
"library_root": repo_root / config.library_root,
|
||||
"answers_root": repo_root / config.answers_root,
|
||||
"code_maps": None if config.code_maps is None else repo_root / config.code_maps,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_CODE_MAPS = TypeAdapter(dict[str, dict[int, str]], config=ConfigDict(strict=True))
|
||||
|
||||
|
||||
def load_code_maps(path: Path) -> CodeMaps:
|
||||
"""读映射表并做 T5:恰好覆盖全部编码列,性别映射到「男」「女」,诊断映射到四个亚型缩写。"""
|
||||
maps = _CODE_MAPS.validate_python(_load_yaml(path))
|
||||
if set(maps) != set(CODED_COLUMNS):
|
||||
extra = sorted(set(maps) - set(CODED_COLUMNS))
|
||||
missing = sorted(set(CODED_COLUMNS) - set(maps))
|
||||
raise ValueError(
|
||||
f"T5 映射表应恰好覆盖 {len(CODED_COLUMNS)} 项编码列:多了 {extra},缺 {missing}"
|
||||
)
|
||||
for column, expected in (("Sex", SEXES), ("Diagnosis", SUBTYPES)):
|
||||
values = list(maps[column].values())
|
||||
if len(values) != len(expected) or frozenset(values) != expected:
|
||||
raise ValueError(f"T5 映射表的 {column} 应恰好映射到 {sorted(expected)}")
|
||||
return maps
|
||||
@@ -0,0 +1,31 @@
|
||||
"""delivery:读 40 例交付包、逐例检查。只读,不决定谁能看见什么。
|
||||
|
||||
整张表的问题(T1–T4)抛 `DeliveryError`,整次构建停下;单个病例的问题(C01–C13)
|
||||
跳过整例,记一条 `SkipRecord`。
|
||||
"""
|
||||
|
||||
from cadx.delivery._cases import (
|
||||
SKIPPED_NOTE,
|
||||
CaseRecord,
|
||||
CodeMaps,
|
||||
DeliveryResult,
|
||||
DiagnosisCodes,
|
||||
SkipRecord,
|
||||
read_delivery,
|
||||
)
|
||||
from cadx.delivery._shape import CODED_COLUMNS
|
||||
from cadx.delivery._tables import DeliveryError
|
||||
from cadx.delivery._text import review_sentences
|
||||
|
||||
__all__ = [
|
||||
"CODED_COLUMNS",
|
||||
"SKIPPED_NOTE",
|
||||
"CaseRecord",
|
||||
"CodeMaps",
|
||||
"DeliveryError",
|
||||
"DeliveryResult",
|
||||
"DiagnosisCodes",
|
||||
"SkipRecord",
|
||||
"read_delivery",
|
||||
"review_sentences",
|
||||
]
|
||||
@@ -0,0 +1,412 @@
|
||||
"""逐例检查 C01–C13,交出通过的病例和跳过记录。
|
||||
|
||||
一例遇到第一项不过的检查就跳过整例,后面的检查不再做(`AGENTS.md`「错误怎么处理」)。
|
||||
类型按 openpyxl 读出的 Python 类型判断,不做转换:要求整数的格读出 `45.0` 也不过,
|
||||
字符串不去首尾空格。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypeGuard, cast
|
||||
|
||||
from cadx.delivery import _text
|
||||
from cadx.delivery._shape import (
|
||||
CASE_REPORT_SHEET,
|
||||
CLINICAL,
|
||||
CLINICAL_ROUTE_COLUMNS,
|
||||
CODED_COLUMNS,
|
||||
DIAGNOSIS_HEADER,
|
||||
HISTORY_COLUMNS,
|
||||
LAB_PAIRS,
|
||||
MR,
|
||||
SUMMARY_TABLES,
|
||||
SYMPTOM_COLUMNS,
|
||||
ULTRASOUND,
|
||||
TableShape,
|
||||
case_dir_path,
|
||||
video_id,
|
||||
)
|
||||
from cadx.delivery._tables import Cell, Row, open_workbook, read_tables
|
||||
|
||||
type CodeMaps = Mapping[str, Mapping[int, str]]
|
||||
|
||||
CHECK_DESCRIPTIONS = {
|
||||
"C01": "编号能规范化",
|
||||
"C02": "同一张表里这个编号只出现一次",
|
||||
"C03": "三张表和病例目录里都有这一例",
|
||||
"C04": "病例目录恰好有关键图、视频、与视频名配对的单例报告表三个文件",
|
||||
"C05": "超声表的 CEUS视频ID 与病例号一致",
|
||||
"C06": "单例报告表与超声汇总表对应行一致",
|
||||
"C07": "临床表取值合规",
|
||||
"C08": "超声表取值合规",
|
||||
"C09": "MR 表取值合规",
|
||||
"C10": "跨表一致(诊断、年龄、性别)",
|
||||
"C11": "报告文字里没有结论段标题",
|
||||
"C12": "报告文字里没有亚型名",
|
||||
"C13": "报告文字里没有人口学信息",
|
||||
}
|
||||
|
||||
SKIPPED_NOTE = "每例只记第一项没过的检查:遇到它就跳过整例,后面的检查没有做过,不代表没有问题。"
|
||||
|
||||
UNCOMPARED_CLINICAL_SEX = "临床性别未比对"
|
||||
|
||||
_CLINICAL_ID = re.compile(r"P([0-9]{3})")
|
||||
_REPORT_ID = re.compile(r"[0-9]+")
|
||||
_REPORT_AGE = re.compile(r"[0-9]+岁")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiagnosisCodes:
|
||||
"""三张表各自的诊断编码(C10 保证三者相等)。"""
|
||||
|
||||
clinical: int
|
||||
ultrasound: int
|
||||
mr: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaseRecord:
|
||||
"""一个检查全部通过的病例读到的内容。"""
|
||||
|
||||
case_no: str # 三位补零
|
||||
video_id: str
|
||||
clinical: Mapping[str, int | float | None] # 临床表除 Case_ID、Diagnosis 外的 22 列
|
||||
ultrasound_report: str
|
||||
mr_report: str
|
||||
key_image: Path
|
||||
diagnosis: DiagnosisCodes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkipRecord:
|
||||
"""一条跳过记录。`case` 是三位病例号,C01 认不出编号时是「文件 第 N 行」。"""
|
||||
|
||||
case: str
|
||||
check: str
|
||||
description: str
|
||||
files: tuple[str, ...]
|
||||
columns: tuple[str, ...]
|
||||
value_json: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryResult:
|
||||
"""读一次交付包的结果。"""
|
||||
|
||||
cases: tuple[CaseRecord, ...] # 按病例号排序
|
||||
skipped: tuple[SkipRecord, ...]
|
||||
total_cases: int # 三张表和病例目录里认得出的病例号的并集大小
|
||||
unrecognized_rows: int # C01 认不出编号的行数,不计入病例数
|
||||
uncompared: tuple[str, ...] # 因为没有映射表而没比对的项
|
||||
|
||||
|
||||
def _skip(
|
||||
case: str, check: str, files: tuple[str, ...], columns: tuple[str, ...], value: object
|
||||
) -> SkipRecord:
|
||||
# 涉及值可能是 openpyxl 读出的任何类型(如日期),JSON 表示不了的按 repr 写,供医生核对。
|
||||
return SkipRecord(
|
||||
case=case,
|
||||
check=check,
|
||||
description=CHECK_DESCRIPTIONS[check],
|
||||
files=files,
|
||||
columns=columns,
|
||||
value_json=json.dumps(value, ensure_ascii=False, default=repr),
|
||||
)
|
||||
|
||||
|
||||
def _is_int(value: Cell) -> TypeGuard[int]:
|
||||
return type(value) is int # bool 是 int 的子类,要排除
|
||||
|
||||
|
||||
def _is_number(value: Cell) -> TypeGuard[int | float]:
|
||||
return type(value) in (int, float)
|
||||
|
||||
|
||||
def _in_range(value: Cell, low: int, high: int) -> bool:
|
||||
return _is_int(value) and low <= value <= high
|
||||
|
||||
|
||||
def _normalize_id(table: TableShape, raw: Cell) -> str | None:
|
||||
"""C01:认得出就返回三位病例号,认不出返回 None。"""
|
||||
number: int | None = None
|
||||
if table is CLINICAL:
|
||||
if isinstance(raw, str) and (m := _CLINICAL_ID.fullmatch(raw)):
|
||||
number = int(m.group(1))
|
||||
elif isinstance(raw, str) and _REPORT_ID.fullmatch(raw):
|
||||
number = int(raw)
|
||||
elif _is_int(raw):
|
||||
number = raw
|
||||
if number is None or not 1 <= number <= 999:
|
||||
return None
|
||||
return f"{number:03d}"
|
||||
|
||||
|
||||
def _index_rows(
|
||||
rows: dict[TableShape, list[Row]], case_dirs: list[str]
|
||||
) -> tuple[dict[TableShape, dict[str, list[Row]]], set[str], list[SkipRecord]]:
|
||||
index: dict[TableShape, dict[str, list[Row]]] = {}
|
||||
unrecognized: list[SkipRecord] = []
|
||||
for table in SUMMARY_TABLES:
|
||||
index[table] = {}
|
||||
for row in rows[table]:
|
||||
raw = row.cells[table.id_column]
|
||||
case_no = _normalize_id(table, raw)
|
||||
if case_no is None:
|
||||
unrecognized.append(
|
||||
_skip(
|
||||
f"{table.path} 第 {row.number} 行",
|
||||
"C01",
|
||||
(table.path,),
|
||||
(table.id_column,),
|
||||
{"原始写法": raw, "行号": row.number},
|
||||
)
|
||||
)
|
||||
continue
|
||||
index[table].setdefault(case_no, []).append(row)
|
||||
dirs: set[str] = set()
|
||||
for name in case_dirs:
|
||||
if not 1 <= int(name) <= 999:
|
||||
unrecognized.append(
|
||||
_skip(f"病例目录 {name}", "C01", (case_dir_path(name),), (), {"目录名": name})
|
||||
)
|
||||
continue
|
||||
dirs.add(name)
|
||||
return index, dirs, unrecognized
|
||||
|
||||
|
||||
# 单例报告表的名字随视频名配对:带 roi2 标记的视频配带 roi2 的报告,反之亦然(负责人 2026-10-16)。
|
||||
_VIDEO_REPORT_PAIRS = (("_roi2.mp4", "_roi2_report.xlsx"), (".mp4", "_report.xlsx"))
|
||||
|
||||
|
||||
def _check_case_dir(case_no: str, folder: Path) -> str | SkipRecord:
|
||||
"""C04:通过时返回这一例单例报告表的文件名。"""
|
||||
u = video_id(case_no)
|
||||
names = sorted(p.name for p in folder.iterdir())
|
||||
for video, report in _VIDEO_REPORT_PAIRS:
|
||||
if names == sorted((f"{u}_key.png", f"{u}{video}", f"{u}{report}")):
|
||||
return f"{u}{report}"
|
||||
return _skip(case_no, "C04", (case_dir_path(case_no),), (), {"文件": names})
|
||||
|
||||
|
||||
def _check_case_report(case_no: str, path: Path, summary: Row, rel: str) -> SkipRecord | None:
|
||||
"""C06:单例报告表与超声汇总表那一行逐格相等(类型也要相同)。"""
|
||||
sheets = open_workbook(path)
|
||||
if sheets.names != (CASE_REPORT_SHEET,):
|
||||
return _skip(case_no, "C06", (rel,), (), {"工作表": list(sheets.names)})
|
||||
if not sheets.rows or sheets.rows[0] != ULTRASOUND.header:
|
||||
return _skip(case_no, "C06", (rel,), (), {"表头": "与超声汇总表不一致"})
|
||||
if len(sheets.rows) != 2:
|
||||
return _skip(case_no, "C06", (rel,), (), {"数据行数": len(sheets.rows) - 1})
|
||||
diffs = {
|
||||
column: {"单例报告表": mine, "超声汇总表": summary.cells[column]}
|
||||
for column, mine in zip(ULTRASOUND.header, sheets.rows[1], strict=True)
|
||||
if not (type(mine) is type(summary.cells[column]) and mine == summary.cells[column])
|
||||
}
|
||||
if diffs:
|
||||
return _skip(case_no, "C06", (rel, ULTRASOUND.path), tuple(diffs), diffs)
|
||||
return None
|
||||
|
||||
|
||||
def _clinical_violations(cells: dict[str, Cell], code_maps: CodeMaps | None) -> dict[str, Cell]:
|
||||
"""C07:返回不合规的列和值。"""
|
||||
bad: dict[str, Cell] = {}
|
||||
if not _in_range(cells["Sex"], 1, 2):
|
||||
bad["Sex"] = cells["Sex"]
|
||||
if not (_is_int(cells["Age"]) and cells["Age"] > 0):
|
||||
bad["Age"] = cells["Age"]
|
||||
if not _in_range(cells["Diagnosis"], 1, 4):
|
||||
bad["Diagnosis"] = cells["Diagnosis"]
|
||||
for column in (*SYMPTOM_COLUMNS, *HISTORY_COLUMNS):
|
||||
if not _in_range(cells[column], 0, 1):
|
||||
bad[column] = cells[column]
|
||||
for raw_column, level_column in LAB_PAIRS:
|
||||
raw, level = cells[raw_column], cells[level_column]
|
||||
if raw is None and level is None:
|
||||
continue
|
||||
if not (_is_number(raw) and _in_range(level, 0, 1)):
|
||||
bad[raw_column], bad[level_column] = raw, level
|
||||
if code_maps is not None:
|
||||
for column in CODED_COLUMNS:
|
||||
value = cells[column]
|
||||
if value is not None and not (_is_int(value) and value in code_maps[column]):
|
||||
bad[column] = value
|
||||
return bad
|
||||
|
||||
|
||||
def _report_violations(
|
||||
cells: dict[str, Cell], text_column: str, code_maps: CodeMaps | None
|
||||
) -> dict[str, Cell]:
|
||||
"""C08 / C09 共同的部分:性别、年龄、诊断、描述文字。"""
|
||||
bad: dict[str, Cell] = {}
|
||||
if cells["性别"] not in ("男", "女"):
|
||||
bad["性别"] = cells["性别"]
|
||||
age = cells["年龄"]
|
||||
if not (isinstance(age, str) and _REPORT_AGE.fullmatch(age)):
|
||||
bad["年龄"] = age
|
||||
diagnosis = cells[DIAGNOSIS_HEADER]
|
||||
if not _in_range(diagnosis, 1, 4) or (
|
||||
code_maps is not None and diagnosis not in code_maps["Diagnosis"]
|
||||
):
|
||||
bad[DIAGNOSIS_HEADER] = diagnosis
|
||||
text = cells[text_column]
|
||||
if not (isinstance(text, str) and text):
|
||||
bad[text_column] = text
|
||||
return bad
|
||||
|
||||
|
||||
def _report_age(cells: dict[str, Cell]) -> int:
|
||||
return int(cast(str, cells["年龄"]).removesuffix("岁"))
|
||||
|
||||
|
||||
def _cross_table(
|
||||
clinical: dict[str, Cell],
|
||||
ultrasound: dict[str, Cell],
|
||||
mr: dict[str, Cell],
|
||||
code_maps: CodeMaps | None,
|
||||
) -> dict[str, dict[str, Cell]]:
|
||||
"""C10:返回对不上的字段和各表的值。"""
|
||||
bad: dict[str, dict[str, Cell]] = {}
|
||||
diagnoses = (clinical["Diagnosis"], ultrasound[DIAGNOSIS_HEADER], mr[DIAGNOSIS_HEADER])
|
||||
if len(set(diagnoses)) != 1:
|
||||
bad["诊断"] = dict(zip(("临床表", "超声表", "MR 表"), diagnoses, strict=True))
|
||||
ages = (clinical["Age"], _report_age(ultrasound), _report_age(mr))
|
||||
if len(set(ages)) != 1:
|
||||
bad["年龄"] = {"临床表": clinical["Age"], "超声表": ultrasound["年龄"], "MR 表": mr["年龄"]}
|
||||
sexes = {"超声表": ultrasound["性别"], "MR 表": mr["性别"]}
|
||||
if code_maps is not None:
|
||||
sexes["临床表(解码后)"] = code_maps["Sex"][cast(int, clinical["Sex"])]
|
||||
if len(set(sexes.values())) != 1:
|
||||
bad["性别"] = sexes
|
||||
return bad
|
||||
|
||||
|
||||
def _text_hits(
|
||||
case_no: str, check: str, reports: dict[TableShape, str], find: Callable[[str], list[object]]
|
||||
) -> SkipRecord | None:
|
||||
"""C11–C13 共同的部分:两份报告文字各查一遍,命中的都记下。"""
|
||||
text_columns = {ULTRASOUND: "超声描述", MR: "MR描述"}
|
||||
hits = {table: found for table, text in reports.items() if (found := find(text))}
|
||||
if not hits:
|
||||
return None
|
||||
return _skip(
|
||||
case_no,
|
||||
check,
|
||||
tuple(table.path for table in hits),
|
||||
tuple(text_columns[table] for table in hits),
|
||||
{text_columns[table]: found for table, found in hits.items()},
|
||||
)
|
||||
|
||||
|
||||
def _headings(text: str) -> list[object]:
|
||||
return [{"关键词": k, "句子": s} for k, s in _text.conclusion_headings(text)]
|
||||
|
||||
|
||||
def _subtypes(text: str) -> list[object]:
|
||||
return list(_text.subtype_terms(text))
|
||||
|
||||
|
||||
def _demographics(text: str) -> list[object]:
|
||||
return list(_text.demographic_terms(text))
|
||||
|
||||
|
||||
def _check_case(
|
||||
case_no: str,
|
||||
delivery_dir: Path,
|
||||
index: dict[TableShape, dict[str, list[Row]]],
|
||||
dirs: set[str],
|
||||
code_maps: CodeMaps | None,
|
||||
) -> CaseRecord | SkipRecord:
|
||||
"""按 C02–C13 的顺序检查一例,遇到第一项不过就返回它的跳过记录。"""
|
||||
for table in SUMMARY_TABLES:
|
||||
found = index[table].get(case_no, [])
|
||||
if len(found) > 1:
|
||||
numbers = [row.number for row in found]
|
||||
return _skip(case_no, "C02", (table.path,), (table.id_column,), {"行号": numbers})
|
||||
|
||||
missing = [table.path for table in SUMMARY_TABLES if case_no not in index[table]]
|
||||
if case_no not in dirs:
|
||||
missing.append(case_dir_path(case_no) + "/")
|
||||
if missing:
|
||||
return _skip(case_no, "C03", tuple(missing), (), {"缺在": missing})
|
||||
|
||||
clinical, ultrasound, mr = (index[table][case_no][0].cells for table in SUMMARY_TABLES)
|
||||
folder = delivery_dir / case_dir_path(case_no)
|
||||
u = video_id(case_no)
|
||||
|
||||
report_name = _check_case_dir(case_no, folder)
|
||||
if isinstance(report_name, SkipRecord):
|
||||
return report_name
|
||||
|
||||
if ultrasound["CEUS视频ID"] != u:
|
||||
value = {"CEUS视频ID": ultrasound["CEUS视频ID"], "病例号": case_no}
|
||||
return _skip(case_no, "C05", (ULTRASOUND.path,), ("CEUS视频ID",), value)
|
||||
|
||||
report_rel = f"{case_dir_path(case_no)}/{report_name}"
|
||||
summary_row = index[ULTRASOUND][case_no][0]
|
||||
report_path = delivery_dir / report_rel
|
||||
if (skip := _check_case_report(case_no, report_path, summary_row, report_rel)) is not None:
|
||||
return skip
|
||||
|
||||
if bad := _clinical_violations(clinical, code_maps):
|
||||
return _skip(case_no, "C07", (CLINICAL.path,), tuple(bad), bad)
|
||||
bad = _report_violations(ultrasound, "超声描述", code_maps)
|
||||
bolus = ultrasound["推注开始时间s"]
|
||||
if not (_is_int(bolus) and bolus >= 0):
|
||||
bad["推注开始时间s"] = bolus
|
||||
if bad:
|
||||
return _skip(case_no, "C08", (ULTRASOUND.path,), tuple(bad), bad)
|
||||
if bad := _report_violations(mr, "MR描述", code_maps):
|
||||
return _skip(case_no, "C09", (MR.path,), tuple(bad), bad)
|
||||
if mismatched := _cross_table(clinical, ultrasound, mr, code_maps):
|
||||
files = tuple(table.path for table in SUMMARY_TABLES)
|
||||
return _skip(case_no, "C10", files, tuple(mismatched), mismatched)
|
||||
|
||||
# C08、C09 已保证两份描述是非空字符串。
|
||||
reports = {ULTRASOUND: cast(str, ultrasound["超声描述"]), MR: cast(str, mr["MR描述"])}
|
||||
for check, find in (("C11", _headings), ("C12", _subtypes), ("C13", _demographics)):
|
||||
if (skip := _text_hits(case_no, check, reports, find)) is not None:
|
||||
return skip
|
||||
|
||||
# C07 已保证临床各列是整数、浮点数或空;C07–C10 已保证三个诊断是相等的整数。
|
||||
return CaseRecord(
|
||||
case_no=case_no,
|
||||
video_id=u,
|
||||
clinical={c: cast(int | float | None, clinical[c]) for c in CLINICAL_ROUTE_COLUMNS},
|
||||
ultrasound_report=reports[ULTRASOUND],
|
||||
mr_report=reports[MR],
|
||||
key_image=folder / f"{u}_key.png",
|
||||
diagnosis=DiagnosisCodes(
|
||||
clinical=cast(int, clinical["Diagnosis"]),
|
||||
ultrasound=cast(int, ultrasound[DIAGNOSIS_HEADER]),
|
||||
mr=cast(int, mr[DIAGNOSIS_HEADER]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def read_delivery(delivery_dir: Path, code_maps: CodeMaps | None) -> DeliveryResult:
|
||||
"""读交付包并逐例检查。
|
||||
|
||||
`code_maps` 为 None 表示不解码:编码列只查取值范围,不查是否在映射表里,
|
||||
临床表的性别不和报告表比对。整张表的问题抛 `DeliveryError`。
|
||||
"""
|
||||
rows, case_dirs = read_tables(delivery_dir)
|
||||
index, dirs, skipped = _index_rows(rows, case_dirs)
|
||||
unrecognized = len(skipped)
|
||||
all_cases = sorted(dirs.union(*(index[table].keys() for table in SUMMARY_TABLES)))
|
||||
cases: list[CaseRecord] = []
|
||||
for case_no in all_cases:
|
||||
outcome = _check_case(case_no, delivery_dir, index, dirs, code_maps)
|
||||
if isinstance(outcome, SkipRecord):
|
||||
skipped.append(outcome)
|
||||
else:
|
||||
cases.append(outcome)
|
||||
return DeliveryResult(
|
||||
cases=tuple(cases),
|
||||
skipped=tuple(skipped),
|
||||
total_cases=len(all_cases),
|
||||
unrecognized_rows=unrecognized,
|
||||
uncompared=() if code_maps is not None else (UNCOMPARED_CLINICAL_SEX,),
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""交付包的形状:目录、文件名、工作表名、表头、编码列名单。
|
||||
|
||||
全部按 40 例交付包(`data/交付包_20260908_40例/`)写死;交付方式变了就改这里,不做兼容。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TableShape:
|
||||
"""一张汇总表的形状。"""
|
||||
|
||||
path: str # 相对交付包根目录
|
||||
sheet: str
|
||||
header: tuple[str, ...]
|
||||
id_column: str
|
||||
|
||||
|
||||
DIAGNOSIS_HEADER = "病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)"
|
||||
|
||||
SYMPTOM_COLUMNS = (
|
||||
"Symptom_Pain",
|
||||
"Symptom_Jaundice",
|
||||
"Symptom_WeightLoss",
|
||||
"Symptom_Fever",
|
||||
"Symptom_Asymptomatic",
|
||||
)
|
||||
HISTORY_COLUMNS = (
|
||||
"Hx_Pancreatitis",
|
||||
"Hx_Alcohol",
|
||||
"Hx_Gallstones",
|
||||
"Hx_Malignancy",
|
||||
"Hx_Family_PC",
|
||||
)
|
||||
# 化验原值列与档位列成对出现。
|
||||
LAB_PAIRS = (
|
||||
("CA199", "CA199_Level"),
|
||||
("CEA", "CEA_Level"),
|
||||
("Amylase", "Amylase_Level"),
|
||||
("Lipase", "Lipase_Level"),
|
||||
("TBil", "TBil_Level"),
|
||||
)
|
||||
|
||||
CLINICAL = TableShape(
|
||||
path="Clinical_agent/临床工作簿_40例.xlsx",
|
||||
sheet="Sheet",
|
||||
header=(
|
||||
"Case_ID",
|
||||
"Sex",
|
||||
"Age",
|
||||
"Diagnosis",
|
||||
*SYMPTOM_COLUMNS,
|
||||
*HISTORY_COLUMNS,
|
||||
*(column for pair in LAB_PAIRS for column in pair),
|
||||
),
|
||||
id_column="Case_ID",
|
||||
)
|
||||
ULTRASOUND = TableShape(
|
||||
path="CEUS_agent/超声报告文本_40例.xlsx",
|
||||
sheet="超声报告",
|
||||
header=(
|
||||
"患者ID",
|
||||
"CEUS视频ID",
|
||||
"性别",
|
||||
"年龄",
|
||||
DIAGNOSIS_HEADER,
|
||||
"推注开始时间s",
|
||||
"超声描述",
|
||||
),
|
||||
id_column="患者ID",
|
||||
)
|
||||
MR = TableShape(
|
||||
path="MR_agent/MR报告文本_40例.xlsx",
|
||||
sheet="MR报告",
|
||||
header=("患者ID", "性别", "年龄", DIAGNOSIS_HEADER, "MR描述"),
|
||||
id_column="患者ID",
|
||||
)
|
||||
SUMMARY_TABLES = (CLINICAL, ULTRASOUND, MR)
|
||||
|
||||
# 病例目录里的单例报告表:工作表名固定,表头与超声汇总表相同。
|
||||
CASE_REPORT_SHEET = "Sheet1"
|
||||
|
||||
# 临床一路拿到的列:临床表去掉编号和诊断。
|
||||
CLINICAL_ROUTE_COLUMNS = tuple(c for c in CLINICAL.header if c not in ("Case_ID", "Diagnosis"))
|
||||
|
||||
# 用数字编码、需要映射表才知道含义的列。`Diagnosis` 同时管临床表的 `Diagnosis`
|
||||
# 和两张报告表的诊断列,三列编码相同。
|
||||
CODED_COLUMNS = (
|
||||
"Sex",
|
||||
"Diagnosis",
|
||||
*SYMPTOM_COLUMNS,
|
||||
*HISTORY_COLUMNS,
|
||||
*(level for _, level in LAB_PAIRS),
|
||||
)
|
||||
|
||||
|
||||
def case_dir_path(case_no: str) -> str:
|
||||
"""病例目录相对交付包根目录的路径。"""
|
||||
return f"CEUS_agent/{case_no}"
|
||||
|
||||
|
||||
def video_id(case_no: str) -> str:
|
||||
"""病例号对应的视频号(`U` 加三位病例号)。"""
|
||||
return f"U{case_no}"
|
||||
@@ -0,0 +1,84 @@
|
||||
"""读工作簿;整张表的检查 T1–T4,不过就整次构建停下。"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
|
||||
from cadx.delivery._shape import CLINICAL, MR, SUMMARY_TABLES, ULTRASOUND, TableShape
|
||||
|
||||
# 单元格按 openpyxl 读出的原样保留,不做类型转换;类型检查在各条 C 检查里做。
|
||||
type Cell = object
|
||||
|
||||
CASE_DIR_NAME = re.compile(r"[0-9]{3}")
|
||||
|
||||
|
||||
class DeliveryError(Exception):
|
||||
"""整张表读不了:交付方式变了,一例也读不出来。消息以检查编号开头。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Row:
|
||||
"""汇总表的一行:Excel 行号与「列名 → 单元格」。"""
|
||||
|
||||
number: int
|
||||
cells: dict[str, Cell]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Sheets:
|
||||
"""一个工作簿:全部工作表名,以及第一张表逐行的单元格(第 1 行是表头)。"""
|
||||
|
||||
names: tuple[str, ...]
|
||||
rows: tuple[tuple[Cell, ...], ...]
|
||||
|
||||
|
||||
def open_workbook(path: Path) -> Sheets:
|
||||
"""读出工作簿的工作表名和第一张工作表的全部行。"""
|
||||
workbook = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
names = tuple(workbook.sheetnames)
|
||||
rows = tuple(tuple(row) for row in workbook.worksheets[0].iter_rows(values_only=True))
|
||||
workbook.close()
|
||||
return Sheets(names=names, rows=rows)
|
||||
|
||||
|
||||
def _read_summary(delivery_dir: Path, table: TableShape) -> list[Row]:
|
||||
sheets = open_workbook(delivery_dir / table.path)
|
||||
if sheets.names != (table.sheet,):
|
||||
raise DeliveryError(
|
||||
f"T2 {table.path} 的工作表应恰好是 [{table.sheet}],实际 {sheets.names}"
|
||||
)
|
||||
if not sheets.rows or sheets.rows[0] != table.header:
|
||||
raise DeliveryError(f"T3 {table.path} 第 1 行表头与约定不一致")
|
||||
return [
|
||||
Row(number=number, cells=dict(zip(table.header, values, strict=True)))
|
||||
for number, values in enumerate(sheets.rows[1:], start=2)
|
||||
]
|
||||
|
||||
|
||||
def _check_layout(delivery_dir: Path) -> list[str]:
|
||||
"""T4:三个子目录里没有多余的东西。返回病例目录名(按名排序)。"""
|
||||
for table in (CLINICAL, MR):
|
||||
folder = (delivery_dir / table.path).parent
|
||||
names = sorted(p.name for p in folder.iterdir())
|
||||
if names != [Path(table.path).name]:
|
||||
raise DeliveryError(f"T4 {folder.name}/ 下应只有 {Path(table.path).name},实际 {names}")
|
||||
ceus = (delivery_dir / ULTRASOUND.path).parent
|
||||
case_dirs: list[str] = []
|
||||
for entry in sorted(ceus.iterdir()):
|
||||
if entry.name == Path(ULTRASOUND.path).name:
|
||||
continue
|
||||
if not (entry.is_dir() and CASE_DIR_NAME.fullmatch(entry.name)):
|
||||
raise DeliveryError(f"T4 {ceus.name}/ 下有不认识的条目 {entry.name}")
|
||||
case_dirs.append(entry.name)
|
||||
return case_dirs
|
||||
|
||||
|
||||
def read_tables(delivery_dir: Path) -> tuple[dict[TableShape, list[Row]], list[str]]:
|
||||
"""依次做 T1–T4,交出三张汇总表的数据行和病例目录名。"""
|
||||
for table in SUMMARY_TABLES:
|
||||
if not (delivery_dir / table.path).is_file():
|
||||
raise DeliveryError(f"T1 缺汇总表 {table.path}")
|
||||
rows = {table: _read_summary(delivery_dir, table) for table in SUMMARY_TABLES}
|
||||
return rows, _check_layout(delivery_dir)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""报告文字的规则:结论段标题、亚型名、人口学信息、复核用的措辞。
|
||||
|
||||
都先做 NFKC 归一(全角字母、数字、标点转半角),这样全角写法绕不过去。
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# 结论段标题的关键词。长的写在前面,正则按顺序尝试,命中「超声提示」时不会只报「提示」。
|
||||
CONCLUSION_KEYWORDS = (
|
||||
"超声提示",
|
||||
"超声诊断",
|
||||
"影像学诊断",
|
||||
"检查结论",
|
||||
"诊断意见",
|
||||
"印象",
|
||||
"结论",
|
||||
"诊断",
|
||||
"意见",
|
||||
"提示",
|
||||
)
|
||||
# 段首 = 全文开头或换行之后;沿用旧代码,允许标题前有空格或制表符缩进。
|
||||
# 换行认 str.splitlines 认的全部行边界(含 \x0b、\x0c、\x85、U+2028、U+2029),
|
||||
# 冒号除 NFKC 后的「:」外也认比例号「∶」(U+2236,NFKC 不把它转成冒号)。
|
||||
_LINE_BREAKS = "\r\n\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029"
|
||||
_CONCLUSION_HEADING = re.compile(
|
||||
rf"(?:^|(?<=[{_LINE_BREAKS}]))[ \t]*(" + "|".join(CONCLUSION_KEYWORDS) + r")\s*[:∶]"
|
||||
)
|
||||
|
||||
# 归一并转大写后按子串查。「浆液性」「黏液性」单独出现是描述囊液的正常用语,不查。
|
||||
# 词根覆盖了更长的写法(「SPT」覆盖「SPTP」,「导管内乳头」覆盖「导管内乳头状」),长的不再单列。
|
||||
SUBTYPE_TERMS = (
|
||||
"IPMN",
|
||||
"IPMT",
|
||||
"SPN",
|
||||
"SPT",
|
||||
"MCN",
|
||||
"SCN",
|
||||
"导管内乳头",
|
||||
"实性假乳头",
|
||||
"黏液性囊",
|
||||
"粘液性囊",
|
||||
"黏液囊性",
|
||||
"粘液囊性",
|
||||
"浆液性囊",
|
||||
"浆液性微囊",
|
||||
"囊腺瘤",
|
||||
)
|
||||
|
||||
# 阿拉伯数字或中文数字,可带「周」,简繁「岁」都认。
|
||||
_AGE = re.compile(r"[0-9一二三四五六七八九十百]+\s*周?[岁歲]")
|
||||
SEX_CHARACTERS = ("男", "女")
|
||||
|
||||
# 不带冒号出现时不算检查不过,只列进复核清单。
|
||||
REVIEW_WORDS = ("考虑", "可能", "倾向", "提示")
|
||||
|
||||
_SENTENCE = re.compile(r"[^。;]+")
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""NFKC 归一。"""
|
||||
return unicodedata.normalize("NFKC", text)
|
||||
|
||||
|
||||
def _sentence_at(normalized: str, position: int) -> str:
|
||||
for match in _SENTENCE.finditer(normalized):
|
||||
if match.start() <= position < match.end():
|
||||
return match.group()
|
||||
raise ValueError(f"位置 {position} 不在任何句子里(落在分隔符上)")
|
||||
|
||||
|
||||
def conclusion_headings(text: str) -> list[tuple[str, str]]:
|
||||
"""段首的结论段标题:每处命中给出(关键词,所在句子)。"""
|
||||
normalized = normalize(text)
|
||||
return [
|
||||
(m.group(1), _sentence_at(normalized, m.start(1)))
|
||||
for m in _CONCLUSION_HEADING.finditer(normalized)
|
||||
]
|
||||
|
||||
|
||||
def subtype_terms(text: str) -> list[str]:
|
||||
"""报告文字里出现的亚型名。"""
|
||||
upper = normalize(text).upper()
|
||||
return [term for term in SUBTYPE_TERMS if term in upper]
|
||||
|
||||
|
||||
def demographic_terms(text: str) -> list[str]:
|
||||
"""报告文字里出现的「数字岁」和性别字。"""
|
||||
normalized = normalize(text)
|
||||
ages = [m.group() for m in _AGE.finditer(normalized)]
|
||||
return ages + [ch for ch in SEX_CHARACTERS if ch in normalized]
|
||||
|
||||
|
||||
def review_sentences(text: str) -> list[tuple[str, str]]:
|
||||
"""含复核措辞的句子:每句一条(命中的词用「、」连接,句子)。按「。」「;」切句。"""
|
||||
found: list[tuple[str, str]] = []
|
||||
for match in _SENTENCE.finditer(normalize(text)):
|
||||
sentence = match.group()
|
||||
words = [w for w in REVIEW_WORDS if w in sentence]
|
||||
if words:
|
||||
found.append(("、".join(words), sentence))
|
||||
return found
|
||||
@@ -0,0 +1,30 @@
|
||||
"""library:给专家的资料库的目录布局、文件格式、写入和按路读取。
|
||||
|
||||
拒绝读取名字以 `.partial` 结尾的目录;读每个文件时核对清单里的 SHA-256。
|
||||
"""
|
||||
|
||||
from cadx.library._read import (
|
||||
ClinicalRoute,
|
||||
MrRoute,
|
||||
UltrasoundRoute,
|
||||
list_aliases,
|
||||
read_clinical,
|
||||
read_mr,
|
||||
read_ultrasound,
|
||||
)
|
||||
from cadx.library._scan import LeakHit, scan_for_leaks
|
||||
from cadx.library._write import LibraryCase, write_library
|
||||
|
||||
__all__ = [
|
||||
"ClinicalRoute",
|
||||
"LeakHit",
|
||||
"LibraryCase",
|
||||
"MrRoute",
|
||||
"UltrasoundRoute",
|
||||
"list_aliases",
|
||||
"read_clinical",
|
||||
"read_mr",
|
||||
"read_ultrasound",
|
||||
"scan_for_leaks",
|
||||
"write_library",
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""按路读出资料库,读时核对清单里的 SHA-256。"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from cadx.library._write import (
|
||||
CLINICAL_JSON,
|
||||
KEY_IMAGE,
|
||||
MANIFEST,
|
||||
MR_REPORT,
|
||||
ULTRASOUND_REPORT,
|
||||
case_file,
|
||||
sha256_file,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClinicalRoute:
|
||||
"""临床一路:性别、年龄、症状、既往史、化验。"""
|
||||
|
||||
codes_decoded: bool
|
||||
fields: dict[str, str | int | float | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UltrasoundRoute:
|
||||
"""超声一路:报告「所见」原文和医生截的关键图(给路径)。"""
|
||||
|
||||
report: str
|
||||
key_image: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MrRoute:
|
||||
"""MR 一路:报告「所见」原文。"""
|
||||
|
||||
report: str
|
||||
|
||||
|
||||
def _manifest(build_dir: Path) -> dict[str, Any]:
|
||||
if build_dir.name.endswith(".partial"):
|
||||
raise ValueError(f"{build_dir} 是没写完的构建(.partial),不能读")
|
||||
return cast(dict[str, Any], json.loads((build_dir / MANIFEST).read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def _verified(build_dir: Path, alias: str, relative: str) -> Path:
|
||||
manifest = _manifest(build_dir)
|
||||
if alias not in manifest["aliases"]:
|
||||
raise ValueError(f"构建 {build_dir.name} 里没有化名 {alias}")
|
||||
key = case_file(alias, relative)
|
||||
path = build_dir / key
|
||||
if sha256_file(path) != manifest["files"][key]:
|
||||
raise ValueError(f"{key} 的 SHA-256 与清单不一致,资料库被改动过")
|
||||
return path
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
# 按字节解码,不做换行转换,读出的就是写进去的原文。
|
||||
return path.read_bytes().decode("utf-8")
|
||||
|
||||
|
||||
def list_aliases(build_dir: Path) -> tuple[str, ...]:
|
||||
"""一次构建的全部化名(按化名排序)。"""
|
||||
return tuple(_manifest(build_dir)["aliases"])
|
||||
|
||||
|
||||
def read_clinical(build_dir: Path, alias: str) -> ClinicalRoute:
|
||||
"""读一例的临床一路。"""
|
||||
content = json.loads(_text(_verified(build_dir, alias, CLINICAL_JSON)))
|
||||
return ClinicalRoute(codes_decoded=content["codes_decoded"], fields=content["fields"])
|
||||
|
||||
|
||||
def read_ultrasound(build_dir: Path, alias: str) -> UltrasoundRoute:
|
||||
"""读一例的超声一路。"""
|
||||
report = _text(_verified(build_dir, alias, ULTRASOUND_REPORT))
|
||||
return UltrasoundRoute(report=report, key_image=_verified(build_dir, alias, KEY_IMAGE))
|
||||
|
||||
|
||||
def read_mr(build_dir: Path, alias: str) -> MrRoute:
|
||||
"""读一例的 MR 一路。"""
|
||||
return MrRoute(report=_text(_verified(build_dir, alias, MR_REPORT)))
|
||||
@@ -0,0 +1,68 @@
|
||||
"""泄露扫描:在一次构建的文字文件里找不该出现的字符串和 JSON 键名。"""
|
||||
|
||||
import json
|
||||
import unicodedata
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LeakHit:
|
||||
"""一处命中。`kind` 是「字符串」或「键名」。"""
|
||||
|
||||
file: str # 相对构建目录
|
||||
kind: str
|
||||
term: str
|
||||
|
||||
|
||||
def _fold(text: str) -> str:
|
||||
"""NFKC 再 casefold:全角、大小写写法都按同一个词比。"""
|
||||
return unicodedata.normalize("NFKC", text).casefold()
|
||||
|
||||
|
||||
def _json_keys_and_strings(node: object, keys: list[str], strings: list[str]) -> None:
|
||||
# json.loads 出来的对象:dict 的键都是字符串。
|
||||
if isinstance(node, dict):
|
||||
for key, value in cast(dict[str, object], node).items():
|
||||
keys.append(key)
|
||||
_json_keys_and_strings(value, keys, strings)
|
||||
elif isinstance(node, list):
|
||||
for item in cast(list[object], node):
|
||||
_json_keys_and_strings(item, keys, strings)
|
||||
elif isinstance(node, str):
|
||||
strings.append(node)
|
||||
|
||||
|
||||
def scan_for_leaks(
|
||||
build_dir: Path, forbidden_strings: Sequence[str], forbidden_keys: Sequence[str]
|
||||
) -> list[LeakHit]:
|
||||
"""扫构建目录下全部 `.txt`、`.json`(含清单),不扫 PNG。
|
||||
|
||||
字符串做 NFKC 并 casefold 后按子串比较;JSON 文件除原文外还扫解析后的键和字符串值,
|
||||
这样以 `\\uXXXX` 转义写出的中文也查得到。键名按整个键名、不分大小写比较。
|
||||
"""
|
||||
hits: list[LeakHit] = []
|
||||
banned_keys = {key.casefold() for key in forbidden_keys}
|
||||
paths = sorted(p for p in build_dir.rglob("*") if p.suffix in (".txt", ".json"))
|
||||
for path in paths:
|
||||
relative = path.relative_to(build_dir).as_posix()
|
||||
text = path.read_bytes().decode("utf-8")
|
||||
haystacks = [text]
|
||||
if path.suffix == ".json":
|
||||
keys: list[str] = []
|
||||
_json_keys_and_strings(json.loads(text), keys, haystacks)
|
||||
hits.extend(
|
||||
LeakHit(relative, "键名", key)
|
||||
for key in sorted(set(keys))
|
||||
if key.casefold() in banned_keys
|
||||
)
|
||||
haystacks.extend(keys)
|
||||
folded = [_fold(h) for h in haystacks]
|
||||
hits.extend(
|
||||
LeakHit(relative, "字符串", term)
|
||||
for term in forbidden_strings
|
||||
if any(_fold(term) in h for h in folded)
|
||||
)
|
||||
return hits
|
||||
@@ -0,0 +1,119 @@
|
||||
"""资料库的目录布局与写入。
|
||||
|
||||
```
|
||||
<构建目录>/
|
||||
manifest.json
|
||||
cases/<化名>/clinical/clinical.json
|
||||
cases/<化名>/ultrasound/report.txt
|
||||
cases/<化名>/ultrasound/key.png
|
||||
cases/<化名>/mr/report.txt
|
||||
```
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
MANIFEST = "manifest.json"
|
||||
CLINICAL_JSON = "clinical/clinical.json"
|
||||
ULTRASOUND_REPORT = "ultrasound/report.txt"
|
||||
KEY_IMAGE = "ultrasound/key.png"
|
||||
MR_REPORT = "mr/report.txt"
|
||||
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
# 能逐像素保真重编码的 (位深, PNG 颜色类型):8 位的灰度、RGB、灰度加透明、RGBA,以及 1 位灰度。
|
||||
# 16 位图 Pillow 读 RGB 时会悄悄降成 8 位;调色板图按像素重编码会丢调色板。40 例都是 8 位 RGB。
|
||||
_FAITHFUL = frozenset({(8, 0), (8, 2), (8, 4), (8, 6), (1, 0)})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LibraryCase:
|
||||
"""要写进资料库的一例:三路各自的内容。"""
|
||||
|
||||
alias: str
|
||||
clinical_fields: Mapping[str, str | int | float | None]
|
||||
ultrasound_report: str
|
||||
key_image_source: Path
|
||||
mr_report: str
|
||||
|
||||
|
||||
def case_file(alias: str, relative: str) -> str:
|
||||
"""病例文件相对构建目录的路径。"""
|
||||
return f"cases/{alias}/{relative}"
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
"""文件内容的 SHA-256。"""
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _write_json(path: Path, content: object) -> None:
|
||||
# ensure_ascii=False:中文原样写出,泄露扫描按子串才查得到。
|
||||
path.write_text(json.dumps(content, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _write_clean_png(source: Path, target: Path) -> None:
|
||||
"""只保留像素和颜色模式重新编码:文本块、EXIF、ICC 等附加数据都不带过去。
|
||||
|
||||
不能逐像素保真的图(非 PNG、位深或颜色类型不在 `_FAITHFUL` 里、带 tRNS 透明色)报错停下,
|
||||
不悄悄降位或丢透明。
|
||||
"""
|
||||
header = source.read_bytes()[:26]
|
||||
if header[:8] != _PNG_SIGNATURE or header[12:16] != b"IHDR":
|
||||
raise ValueError(f"关键图不是 PNG:{source}")
|
||||
bit_depth, color_type = header[24], header[25]
|
||||
if (bit_depth, color_type) not in _FAITHFUL:
|
||||
raise ValueError(
|
||||
f"关键图位深 {bit_depth}、颜色类型 {color_type} 不能逐像素保真重编码:{source}"
|
||||
)
|
||||
with Image.open(source) as image:
|
||||
if "transparency" in image.info:
|
||||
raise ValueError(f"关键图带 tRNS 透明色,重编码会丢透明:{source}")
|
||||
clean = Image.frombytes(image.mode, image.size, image.tobytes())
|
||||
clean.save(target, format="PNG")
|
||||
|
||||
|
||||
def write_library(
|
||||
build_dir: Path,
|
||||
build_id: str,
|
||||
git_commit: str,
|
||||
git_clean: bool,
|
||||
config_sha256: Mapping[str, str],
|
||||
codes_decoded: bool,
|
||||
uncompared: Sequence[str],
|
||||
ceus_source: str | None,
|
||||
cases: Sequence[LibraryCase],
|
||||
) -> None:
|
||||
"""把一次构建写进已经存在的空目录 `build_dir`,最后写清单。"""
|
||||
files: dict[str, str] = {}
|
||||
for case in cases:
|
||||
case_dir = build_dir / "cases" / case.alias
|
||||
for relative in (CLINICAL_JSON, ULTRASOUND_REPORT, MR_REPORT):
|
||||
(case_dir / relative).parent.mkdir(parents=True, exist_ok=True)
|
||||
_write_json(
|
||||
case_dir / CLINICAL_JSON,
|
||||
{"codes_decoded": codes_decoded, "fields": dict(case.clinical_fields)},
|
||||
)
|
||||
(case_dir / ULTRASOUND_REPORT).write_text(case.ultrasound_report, encoding="utf-8")
|
||||
_write_clean_png(case.key_image_source, case_dir / KEY_IMAGE)
|
||||
(case_dir / MR_REPORT).write_text(case.mr_report, encoding="utf-8")
|
||||
for relative in (CLINICAL_JSON, ULTRASOUND_REPORT, KEY_IMAGE, MR_REPORT):
|
||||
files[case_file(case.alias, relative)] = sha256_file(case_dir / relative)
|
||||
_write_json(
|
||||
build_dir / MANIFEST,
|
||||
{
|
||||
"build_id": build_id,
|
||||
"git_commit": git_commit,
|
||||
"git_clean": git_clean,
|
||||
"config_sha256": dict(config_sha256),
|
||||
"codes_decoded": codes_decoded,
|
||||
"uncompared": list(uncompared),
|
||||
"ceus_source": ceus_source,
|
||||
"aliases": sorted(case.alias for case in cases),
|
||||
"files": dict(sorted(files.items())),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""runs:构建号怎么取、运行快照记什么。会诊运行目录等会诊那一步再加。"""
|
||||
|
||||
from cadx.runs._snapshot import GitState, make_build_id, read_git_state, write_snapshot
|
||||
|
||||
__all__ = ["GitState", "make_build_id", "read_git_state", "write_snapshot"]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""构建号与运行快照:一次构建用的是哪份代码、哪份配置、哪个环境。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitState:
|
||||
"""仓库当前的提交和工作区状态。"""
|
||||
|
||||
commit: str
|
||||
short_commit: str
|
||||
clean: bool # `git status --porcelain` 为空(含未跟踪文件)
|
||||
diff_sha256: str # `git diff HEAD` 输出的 SHA-256
|
||||
|
||||
|
||||
def _git(repo_root: Path, *args: str) -> bytes:
|
||||
git = shutil.which("git")
|
||||
if git is None:
|
||||
raise RuntimeError("找不到 git,无法记录构建用的代码版本")
|
||||
return subprocess.run( # noqa: S603 参数是写死的 git 子命令,不含外部输入
|
||||
[git, *args], cwd=repo_root, check=True, capture_output=True
|
||||
).stdout
|
||||
|
||||
|
||||
def read_git_state(repo_root: Path) -> GitState:
|
||||
"""读出 `repo_root` 所在仓库的提交号、是否干净、未提交改动的哈希。"""
|
||||
return GitState(
|
||||
commit=_git(repo_root, "rev-parse", "HEAD").decode().strip(),
|
||||
short_commit=_git(repo_root, "rev-parse", "--short", "HEAD").decode().strip(),
|
||||
clean=_git(repo_root, "status", "--porcelain").strip() == b"",
|
||||
diff_sha256=hashlib.sha256(_git(repo_root, "diff", "HEAD")).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def make_build_id(now: datetime, git: GitState) -> str:
|
||||
"""`YYYYMMDD-HHMMSS-<git 短哈希>`,工作区不干净时末尾加 `-dirty`。`now` 用本地时间。"""
|
||||
build_id = f"{now:%Y%m%d-%H%M%S}-{git.short_commit}"
|
||||
return build_id if git.clean else f"{build_id}-dirty"
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def write_snapshot(
|
||||
snapshot_dir: Path,
|
||||
build_id: str,
|
||||
git: GitState,
|
||||
started_at: datetime,
|
||||
finished_at: datetime,
|
||||
uv_lock: Path,
|
||||
configs: Mapping[str, Path],
|
||||
) -> None:
|
||||
"""建 `snapshot_dir`,写 snapshot.json,并把 `configs`(快照里的文件名 → 原文件)原样复制进去。"""
|
||||
snapshot_dir.mkdir(exist_ok=False)
|
||||
copied: dict[str, str] = {}
|
||||
for name, source in configs.items():
|
||||
shutil.copyfile(source, snapshot_dir / name)
|
||||
copied[name] = _sha256(snapshot_dir / name)
|
||||
snapshot = {
|
||||
"build_id": build_id,
|
||||
"git_commit": git.commit,
|
||||
"git_clean": git.clean,
|
||||
"git_diff_head_sha256": git.diff_sha256,
|
||||
"python_version": sys.version,
|
||||
"uv_lock_sha256": _sha256(uv_lock),
|
||||
"started_at": started_at.isoformat(),
|
||||
"finished_at": finished_at.isoformat(),
|
||||
"config_sha256": copied,
|
||||
}
|
||||
(snapshot_dir / "snapshot.json").write_text(
|
||||
json.dumps(snapshot, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
@@ -0,0 +1,267 @@
|
||||
"""测试用假交付包:形状照 40 例交付包写(表头、工作表名、目录结构),内容全部编造。
|
||||
|
||||
这里的表头和文件名是按任务书 01 第二部分的表格独立写的,不取自 delivery 的常量,
|
||||
这样 delivery 的常量写错时测试能发现。各条测试在 `FakeDelivery` 上改出要测的情况,再 `write`。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import openpyxl
|
||||
import yaml
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
from cadx.build import BuildSummary, run_build
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
DIAGNOSIS_COLUMN = "病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)"
|
||||
CLINICAL_HEADER = [
|
||||
"Case_ID",
|
||||
"Sex",
|
||||
"Age",
|
||||
"Diagnosis",
|
||||
"Symptom_Pain",
|
||||
"Symptom_Jaundice",
|
||||
"Symptom_WeightLoss",
|
||||
"Symptom_Fever",
|
||||
"Symptom_Asymptomatic",
|
||||
"Hx_Pancreatitis",
|
||||
"Hx_Alcohol",
|
||||
"Hx_Gallstones",
|
||||
"Hx_Malignancy",
|
||||
"Hx_Family_PC",
|
||||
"CA199",
|
||||
"CA199_Level",
|
||||
"CEA",
|
||||
"CEA_Level",
|
||||
"Amylase",
|
||||
"Amylase_Level",
|
||||
"Lipase",
|
||||
"Lipase_Level",
|
||||
"TBil",
|
||||
"TBil_Level",
|
||||
]
|
||||
ULTRASOUND_HEADER = [
|
||||
"患者ID",
|
||||
"CEUS视频ID",
|
||||
"性别",
|
||||
"年龄",
|
||||
DIAGNOSIS_COLUMN,
|
||||
"推注开始时间s",
|
||||
"超声描述",
|
||||
]
|
||||
MR_HEADER = ["患者ID", "性别", "年龄", DIAGNOSIS_COLUMN, "MR描述"]
|
||||
|
||||
CLINICAL_PATH = "Clinical_agent/临床工作簿_40例.xlsx"
|
||||
ULTRASOUND_PATH = "CEUS_agent/超声报告文本_40例.xlsx"
|
||||
MR_PATH = "MR_agent/MR报告文本_40例.xlsx"
|
||||
|
||||
# 编造的报告文字:不含亚型名、结论段标题、性别字、「数字岁」和复核措辞。
|
||||
ULTRASOUND_TEXT = "胰体部见一无回声区,边界清晰,壁薄。造影后囊壁呈等增强,内部未见增强。"
|
||||
MR_TEXT = "胰体部见一类圆形长T2信号灶,边界清楚,内见细分隔;增强后分隔轻度强化。"
|
||||
|
||||
# 测试用的样例映射表:含义是编的,只用来验证解码路径。
|
||||
SAMPLE_CODE_MAPS: dict[str, dict[int, str]] = {
|
||||
"Sex": {1: "男", 2: "女"},
|
||||
"Diagnosis": {1: "IPMN", 2: "SPN", 3: "MCN", 4: "SCN"},
|
||||
**{
|
||||
column: {0: "否", 1: "是"}
|
||||
for column in (
|
||||
"Symptom_Pain",
|
||||
"Symptom_Jaundice",
|
||||
"Symptom_WeightLoss",
|
||||
"Symptom_Fever",
|
||||
"Symptom_Asymptomatic",
|
||||
"Hx_Pancreatitis",
|
||||
"Hx_Alcohol",
|
||||
"Hx_Gallstones",
|
||||
"Hx_Malignancy",
|
||||
"Hx_Family_PC",
|
||||
)
|
||||
},
|
||||
**{
|
||||
column: {0: "正常", 1: "异常"}
|
||||
for column in ("CA199_Level", "CEA_Level", "Amylase_Level", "Lipase_Level", "TBil_Level")
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeCase:
|
||||
"""一例:三张表各一行,病例目录里的文件名和单例报告表的行。"""
|
||||
|
||||
number: int
|
||||
clinical: dict[str, object]
|
||||
ultrasound: dict[str, object]
|
||||
mr: dict[str, object]
|
||||
dir_files: list[str]
|
||||
# None 表示单例报告表照抄超声汇总表那一行。
|
||||
case_report_rows: list[list[object]] | None = None
|
||||
case_report_sheet: str = "Sheet1"
|
||||
|
||||
@property
|
||||
def no(self) -> str:
|
||||
return f"{self.number:03d}"
|
||||
|
||||
|
||||
def make_case(number: int) -> FakeCase:
|
||||
"""一例全部检查都能通过的病例。诊断按病例号轮转 1–4。"""
|
||||
no = f"{number:03d}"
|
||||
sex = 1 + number % 2
|
||||
age = 30 + number
|
||||
diagnosis = 1 + (number - 1) % 4
|
||||
clinical: dict[str, object] = dict.fromkeys(CLINICAL_HEADER, 0)
|
||||
clinical.update(
|
||||
{
|
||||
"Case_ID": f"P{no}",
|
||||
"Sex": sex,
|
||||
"Age": age,
|
||||
"Diagnosis": diagnosis,
|
||||
"Symptom_Pain": 1,
|
||||
"CA199": 12.5,
|
||||
"CA199_Level": 0,
|
||||
"CEA": 3,
|
||||
"CEA_Level": 1,
|
||||
"Amylase": None,
|
||||
"Amylase_Level": None,
|
||||
"Lipase": None,
|
||||
"Lipase_Level": None,
|
||||
"TBil": 10.2,
|
||||
"TBil_Level": 0,
|
||||
}
|
||||
)
|
||||
sex_text = {1: "男", 2: "女"}[sex]
|
||||
ultrasound: dict[str, object] = {
|
||||
"患者ID": str(number),
|
||||
"CEUS视频ID": f"U{no}",
|
||||
"性别": sex_text,
|
||||
"年龄": f"{age}岁",
|
||||
DIAGNOSIS_COLUMN: diagnosis,
|
||||
"推注开始时间s": 5,
|
||||
"超声描述": ULTRASOUND_TEXT,
|
||||
}
|
||||
mr: dict[str, object] = {
|
||||
"患者ID": str(number),
|
||||
"性别": sex_text,
|
||||
"年龄": f"{age}岁",
|
||||
DIAGNOSIS_COLUMN: diagnosis,
|
||||
"MR描述": MR_TEXT,
|
||||
}
|
||||
files = [f"U{no}_key.png", f"U{no}_roi2.mp4", f"U{no}_roi2_report.xlsx"]
|
||||
return FakeCase(number, clinical, ultrasound, mr, files)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeDelivery:
|
||||
"""一个假交付包。`extra_rows` 是附加在汇总表末尾的整行(测试编号认不出的行)。"""
|
||||
|
||||
cases: list[FakeCase]
|
||||
sheet_names: dict[str, str] = field(
|
||||
default_factory=lambda: {
|
||||
CLINICAL_PATH: "Sheet",
|
||||
ULTRASOUND_PATH: "超声报告",
|
||||
MR_PATH: "MR报告",
|
||||
}
|
||||
)
|
||||
headers: dict[str, list[str]] = field(
|
||||
default_factory=lambda: {
|
||||
CLINICAL_PATH: list(CLINICAL_HEADER),
|
||||
ULTRASOUND_PATH: list(ULTRASOUND_HEADER),
|
||||
MR_PATH: list(MR_HEADER),
|
||||
}
|
||||
)
|
||||
extra_rows: dict[str, list[list[object]]] = field(
|
||||
default_factory=lambda: {CLINICAL_PATH: [], ULTRASOUND_PATH: [], MR_PATH: []}
|
||||
)
|
||||
|
||||
def case(self, number: int) -> FakeCase:
|
||||
return next(c for c in self.cases if c.number == number)
|
||||
|
||||
def write(self, root: Path) -> Path:
|
||||
tables = {
|
||||
CLINICAL_PATH: [c.clinical for c in self.cases],
|
||||
ULTRASOUND_PATH: [c.ultrasound for c in self.cases],
|
||||
MR_PATH: [c.mr for c in self.cases],
|
||||
}
|
||||
for path, rows in tables.items():
|
||||
header = self.headers[path]
|
||||
values = [[row.get(h) for h in header] for row in rows]
|
||||
_write_workbook(
|
||||
root / path, self.sheet_names[path], [header, *values, *self.extra_rows[path]]
|
||||
)
|
||||
for case in self.cases:
|
||||
folder = root / "CEUS_agent" / case.no
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
for name in case.dir_files:
|
||||
if name.endswith(".png"):
|
||||
_write_png(folder / name, case.number)
|
||||
elif name.endswith(".xlsx"):
|
||||
row = [case.ultrasound.get(h) for h in ULTRASOUND_HEADER]
|
||||
rows = case.case_report_rows or [list(ULTRASOUND_HEADER), row]
|
||||
_write_workbook(folder / name, case.case_report_sheet, rows)
|
||||
else:
|
||||
(folder / name).write_bytes(b"not a real video")
|
||||
(root / "交付说明_40例_20260908.md").write_text("说明不读", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def make_delivery(count: int) -> FakeDelivery:
|
||||
"""病例号 1..count、全部能通过检查的假交付包。"""
|
||||
return FakeDelivery(cases=[make_case(n) for n in range(1, count + 1)])
|
||||
|
||||
|
||||
def _write_workbook(path: Path, sheet: str, rows: Sequence[Sequence[object]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
workbook = openpyxl.Workbook()
|
||||
worksheet = workbook.active
|
||||
assert worksheet is not None
|
||||
worksheet.title = sheet
|
||||
for row in rows:
|
||||
# 假数据里的值都是 openpyxl 认得的单元格类型(字符串、数字、None)。
|
||||
worksheet.append(cast("list[Any]", list(row)))
|
||||
workbook.save(path)
|
||||
|
||||
|
||||
def _write_png(path: Path, number: int) -> None:
|
||||
pixels = bytes(v for i in range(24) for v in (number * 10 % 256, i * 10, 255 - i * 10))
|
||||
image = Image.frombytes("RGB", (6, 4), pixels)
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("PatientName", "should-not-survive")
|
||||
image.save(path, format="PNG", pnginfo=info)
|
||||
|
||||
|
||||
def write_config(
|
||||
tmp_path: Path, delivery_dir: Path, seed: int, code_maps: dict[str, dict[int, str]] | None
|
||||
) -> Path:
|
||||
"""在 tmp_path 写一份 build.yaml(路径都写绝对路径);给了映射表就同时写 code_maps.yaml。"""
|
||||
maps_path = None
|
||||
if code_maps is not None:
|
||||
maps_path = tmp_path / "code_maps.yaml"
|
||||
maps_path.write_text(yaml.safe_dump(code_maps, allow_unicode=True), encoding="utf-8")
|
||||
config = {
|
||||
"delivery_dir": str(delivery_dir),
|
||||
"library_root": str(tmp_path / "library"),
|
||||
"answers_root": str(tmp_path / "answers"),
|
||||
"alias_seed": seed,
|
||||
"decode_codes": code_maps is not None,
|
||||
"code_maps": None if maps_path is None else str(maps_path),
|
||||
}
|
||||
path = tmp_path / "build.yaml"
|
||||
path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def build(
|
||||
tmp_path: Path,
|
||||
delivery: FakeDelivery,
|
||||
seed: int,
|
||||
code_maps: dict[str, dict[int, str]] | None,
|
||||
now: datetime,
|
||||
) -> BuildSummary:
|
||||
"""写出假交付包和配置,跑一次构建。"""
|
||||
delivery_dir = delivery.write(tmp_path / "delivery")
|
||||
return run_build(write_config(tmp_path, delivery_dir, seed, code_maps), REPO_ROOT, now)
|
||||
@@ -0,0 +1,352 @@
|
||||
"""构建产物:可见性、泄露扫描、化名、解码开关、关键图、清单与读取、不覆盖、答案库内容。"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from cadx.answers import read_aliases, read_diagnoses
|
||||
from cadx.build import make_aliases, run_build
|
||||
from cadx.library import (
|
||||
list_aliases,
|
||||
read_clinical,
|
||||
read_mr,
|
||||
read_ultrasound,
|
||||
scan_for_leaks,
|
||||
)
|
||||
from fake_delivery import (
|
||||
CLINICAL_HEADER,
|
||||
MR_TEXT,
|
||||
REPO_ROOT,
|
||||
SAMPLE_CODE_MAPS,
|
||||
ULTRASOUND_TEXT,
|
||||
build,
|
||||
make_delivery,
|
||||
write_config,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 10, 16, 9, 30, 0).astimezone()
|
||||
|
||||
# 独立写的泄露词表,不取自 delivery 的常量。
|
||||
SUBTYPES = (
|
||||
"IPMN",
|
||||
"SPN",
|
||||
"MCN",
|
||||
"SCN",
|
||||
"导管内乳头状黏液性肿瘤",
|
||||
"实性假乳头状瘤",
|
||||
"黏液性囊性肿瘤",
|
||||
"浆液性囊性肿瘤",
|
||||
"浆液性囊腺瘤",
|
||||
)
|
||||
DIAGNOSIS_COLUMNS = ("病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)", "病理类型", "Diagnosis", "Case_ID")
|
||||
DELIVERY_FILE_NAMES = ("临床工作簿_40例.xlsx", "超声报告文本_40例.xlsx", "MR报告文本_40例.xlsx")
|
||||
FORBIDDEN_KEYS = ("diagnosis", "label", "gold", "pathology", "name", "row")
|
||||
|
||||
|
||||
def _forbidden_strings(case_nos: list[str]) -> list[str]:
|
||||
per_case = [
|
||||
term
|
||||
for no in case_nos
|
||||
for term in (
|
||||
f"P{no}",
|
||||
f"U{no}",
|
||||
f"U{no}_key.png",
|
||||
f"U{no}_roi2.mp4",
|
||||
f"U{no}_roi2_report.xlsx",
|
||||
)
|
||||
]
|
||||
return [*per_case, *DELIVERY_FILE_NAMES, *SUBTYPES, *DIAGNOSIS_COLUMNS]
|
||||
|
||||
|
||||
def _case_dirs(library_dir: Path) -> list[Path]:
|
||||
return sorted((library_dir / "cases").iterdir())
|
||||
|
||||
|
||||
def test_visibility_sex_and_age_only_in_clinical_route(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
summary = build(tmp_path, delivery, 1, SAMPLE_CODE_MAPS, NOW)
|
||||
clinical_names = [*CLINICAL_HEADER, "男", "女", "岁"]
|
||||
for case_dir in _case_dirs(summary.library_dir):
|
||||
files = sorted(p.relative_to(case_dir).as_posix() for p in case_dir.rglob("*.*"))
|
||||
assert files == [
|
||||
"clinical/clinical.json",
|
||||
"mr/report.txt",
|
||||
"ultrasound/key.png",
|
||||
"ultrasound/report.txt",
|
||||
]
|
||||
clinical = json.loads((case_dir / "clinical/clinical.json").read_text(encoding="utf-8"))
|
||||
assert set(clinical["fields"]) == set(CLINICAL_HEADER) - {"Case_ID", "Diagnosis"}
|
||||
for report in ("ultrasound/report.txt", "mr/report.txt"):
|
||||
text = (case_dir / report).read_text(encoding="utf-8")
|
||||
assert [name for name in clinical_names if name in text] == []
|
||||
ages = {f"{30 + n}" for n in range(1, 5)}
|
||||
for alias in list_aliases(summary.library_dir):
|
||||
assert str(read_clinical(summary.library_dir, alias).fields["Age"]) in ages
|
||||
assert read_ultrasound(summary.library_dir, alias).report == ULTRASOUND_TEXT
|
||||
assert read_mr(summary.library_dir, alias).report == MR_TEXT
|
||||
|
||||
|
||||
def test_leak_scan_finds_nothing_in_clean_build(tmp_path: Path) -> None:
|
||||
summary = build(tmp_path, make_delivery(4), 1, None, NOW)
|
||||
case_nos = [f"{n:03d}" for n in range(1, 5)]
|
||||
hits = scan_for_leaks(summary.library_dir, _forbidden_strings(case_nos), FORBIDDEN_KEYS)
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_leak_scan_detects_planted_leaks(tmp_path: Path) -> None:
|
||||
summary = build(tmp_path, make_delivery(3), 1, None, NOW)
|
||||
case_dir = _case_dirs(summary.library_dir)[0]
|
||||
(case_dir / "mr/report.txt").write_text(MR_TEXT + "见 P002 。", encoding="utf-8")
|
||||
(case_dir / "ultrasound/note.json").write_text('{"Label": "x"}', encoding="utf-8")
|
||||
(case_dir / "clinical/extra.json").write_text(
|
||||
'{"k": "\\u6d46\\u6db2\\u6027\\u56ca\\u6027\\u80bf\\u7624"}', encoding="utf-8"
|
||||
)
|
||||
hits = scan_for_leaks(summary.library_dir, _forbidden_strings(["002"]), FORBIDDEN_KEYS)
|
||||
found = {(Path(h.file).name, h.kind, h.term) for h in hits}
|
||||
assert found == {
|
||||
("report.txt", "字符串", "P002"),
|
||||
("note.json", "键名", "Label"),
|
||||
("extra.json", "字符串", "浆液性囊性肿瘤"),
|
||||
}
|
||||
|
||||
|
||||
def test_leak_scan_normalizes_fullwidth_and_case(tmp_path: Path) -> None:
|
||||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||||
case_dir = _case_dirs(summary.library_dir)[0]
|
||||
leaked = MR_TEXT + "形态似ipmn,见p002。"
|
||||
(case_dir / "mr/report.txt").write_text(leaked, encoding="utf-8")
|
||||
hits = scan_for_leaks(summary.library_dir, ["IPMN", "P002", "U002"], FORBIDDEN_KEYS)
|
||||
assert {(Path(h.file).name, h.term) for h in hits} == {
|
||||
("report.txt", "IPMN"),
|
||||
("report.txt", "P002"),
|
||||
}
|
||||
(case_dir / "mr/report.txt").write_text(MR_TEXT + "U002", encoding="utf-8")
|
||||
hits = scan_for_leaks(summary.library_dir, ["U002"], FORBIDDEN_KEYS)
|
||||
assert [(Path(h.file).name, h.term) for h in hits] == [("report.txt", "U002")]
|
||||
|
||||
|
||||
def test_aliases_are_reproducible_and_well_formed(tmp_path: Path) -> None:
|
||||
first = build(tmp_path / "a", make_delivery(5), 11, None, NOW)
|
||||
second = build(tmp_path / "b", make_delivery(5), 11, None, NOW)
|
||||
other = build(tmp_path / "c", make_delivery(5), 12, None, NOW)
|
||||
aliases = list_aliases(first.library_dir)
|
||||
assert aliases == list_aliases(second.library_dir)
|
||||
assert aliases != list_aliases(other.library_dir)
|
||||
assert len(set(aliases)) == 5
|
||||
assert all(re.fullmatch(r"[a-z0-9]{4}", a) for a in aliases)
|
||||
|
||||
|
||||
def test_aliases_avoid_subtype_substrings_and_repeats() -> None:
|
||||
case_nos = [f"{n:03d}" for n in range(1, 1000)]
|
||||
aliases = make_aliases(case_nos, 3)
|
||||
assert len(set(aliases.values())) == 999
|
||||
banned = ("ipmn", "spn", "mcn", "scn")
|
||||
assert [a for a in aliases.values() if any(b in a for b in banned)] == []
|
||||
|
||||
|
||||
def test_alias_order_does_not_depend_on_input_order() -> None:
|
||||
case_nos = ["003", "001", "002"]
|
||||
assert make_aliases(case_nos, 5) == make_aliases(sorted(case_nos), 5)
|
||||
|
||||
|
||||
def test_decode_off_keeps_raw_numbers(tmp_path: Path) -> None:
|
||||
summary = build(tmp_path, make_delivery(3), 1, None, NOW)
|
||||
for alias in list_aliases(summary.library_dir):
|
||||
clinical = read_clinical(summary.library_dir, alias)
|
||||
assert clinical.codes_decoded is False
|
||||
assert clinical.fields["Sex"] in (1, 2)
|
||||
assert clinical.fields["Symptom_Pain"] == 1
|
||||
assert clinical.fields["CEA_Level"] == 1
|
||||
assert [d.name for d in read_diagnoses(summary.answers_dir)] == [None, None, None]
|
||||
manifest = json.loads((summary.library_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["codes_decoded"] is False
|
||||
assert manifest["uncompared"] == ["临床性别未比对"]
|
||||
|
||||
|
||||
def test_decode_on_uses_code_maps(tmp_path: Path) -> None:
|
||||
summary = build(tmp_path, make_delivery(3), 1, SAMPLE_CODE_MAPS, NOW)
|
||||
by_case = {row.case_no: row.alias for row in read_aliases(summary.answers_dir)}
|
||||
# 假交付包:病例 n 的性别编码是 1 + n % 2,诊断编码是 1 + (n - 1) % 4。
|
||||
expected_sex = {"001": "女", "002": "男", "003": "女"}
|
||||
for case_no, alias in by_case.items():
|
||||
clinical = read_clinical(summary.library_dir, alias)
|
||||
assert clinical.codes_decoded is True
|
||||
assert clinical.fields["Sex"] == expected_sex[case_no]
|
||||
assert clinical.fields["Symptom_Pain"] == "是"
|
||||
assert clinical.fields["CEA_Level"] == "异常"
|
||||
assert clinical.fields["Amylase_Level"] is None
|
||||
assert clinical.fields["Age"] == 30 + int(case_no)
|
||||
names = {d.case_no: d.name for d in read_diagnoses(summary.answers_dir)}
|
||||
assert names == {"001": "IPMN", "002": "SPN", "003": "MCN"}
|
||||
|
||||
|
||||
def test_key_image_pixels_kept_and_text_chunk_dropped(tmp_path: Path) -> None:
|
||||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||||
by_case = {row.case_no: row.alias for row in read_aliases(summary.answers_dir)}
|
||||
for case_no, alias in by_case.items():
|
||||
source = tmp_path / "delivery" / "CEUS_agent" / case_no / f"U{case_no}_key.png"
|
||||
with Image.open(source) as original:
|
||||
assert original.info.get("PatientName") == "should-not-survive"
|
||||
original_mode, original_pixels = original.mode, original.tobytes()
|
||||
copied = read_ultrasound(summary.library_dir, alias).key_image
|
||||
with Image.open(copied) as image:
|
||||
assert image.mode == original_mode
|
||||
assert image.tobytes() == original_pixels
|
||||
assert "PatientName" not in image.info
|
||||
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
|
||||
|
||||
|
||||
def _png_chunks(data: bytes) -> list[str]:
|
||||
"""按 PNG 格式逐块读出块类型(8 字节签名后:4 字节长度、4 字节类型、数据、4 字节 CRC)。"""
|
||||
chunks: list[str] = []
|
||||
offset = 8
|
||||
while offset < len(data):
|
||||
length = int.from_bytes(data[offset : offset + 4], "big")
|
||||
chunks.append(data[offset + 4 : offset + 8].decode("ascii"))
|
||||
offset += 12 + length
|
||||
return chunks
|
||||
|
||||
|
||||
def _build_with_key_image(tmp_path: Path, image: Image.Image) -> None:
|
||||
root = make_delivery(2).write(tmp_path / "delivery")
|
||||
image.save(root / "CEUS_agent" / "002" / "U002_key.png", format="PNG")
|
||||
run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW)
|
||||
|
||||
|
||||
def test_key_image_16_bit_source_stops_build(tmp_path: Path) -> None:
|
||||
image = Image.new("I;16", (4, 4))
|
||||
with pytest.raises(ValueError, match=r"U002_key\.png"):
|
||||
_build_with_key_image(tmp_path, image)
|
||||
assert not (tmp_path / "library" / f"{NOW:%Y%m%d-%H%M%S}").exists()
|
||||
assert [p for p in (tmp_path / "library").iterdir() if not p.name.endswith(".partial")] == []
|
||||
|
||||
|
||||
def test_key_image_with_transparency_stops_build(tmp_path: Path) -> None:
|
||||
image = Image.new("RGB", (4, 4))
|
||||
image.info["transparency"] = (0, 0, 0)
|
||||
with pytest.raises(ValueError, match=r"U002_key\.png"):
|
||||
_build_with_key_image(tmp_path, image)
|
||||
|
||||
|
||||
def test_key_image_palette_source_stops_build(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValueError, match=r"U002_key\.png"):
|
||||
_build_with_key_image(tmp_path, Image.new("P", (4, 4)))
|
||||
|
||||
|
||||
def test_key_image_one_bit_kept_as_is(tmp_path: Path) -> None:
|
||||
source = Image.frombytes("1", (8, 2), bytes([0b10110010, 0b01001101]))
|
||||
_build_with_key_image(tmp_path, source)
|
||||
library_root = tmp_path / "library"
|
||||
(library_dir,) = list(library_root.iterdir())
|
||||
by_case = {r.case_no: r.alias for r in read_aliases(tmp_path / "answers" / library_dir.name)}
|
||||
copied = read_ultrasound(library_dir, by_case["002"]).key_image
|
||||
with Image.open(copied) as image:
|
||||
assert image.mode == "1"
|
||||
assert image.tobytes() == source.tobytes()
|
||||
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
|
||||
|
||||
|
||||
def test_library_read_rejects_modified_file(tmp_path: Path) -> None:
|
||||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||||
alias = list_aliases(summary.library_dir)[0]
|
||||
report = summary.library_dir / "cases" / alias / "mr" / "report.txt"
|
||||
report.write_text(MR_TEXT + "改", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="SHA-256"):
|
||||
read_mr(summary.library_dir, alias)
|
||||
|
||||
|
||||
def test_library_read_rejects_partial_directory(tmp_path: Path) -> None:
|
||||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||||
partial = summary.library_dir.with_name(summary.library_dir.name + ".partial")
|
||||
summary.library_dir.rename(partial)
|
||||
with pytest.raises(ValueError, match="partial"):
|
||||
list_aliases(partial)
|
||||
|
||||
|
||||
def test_answers_read_rejects_modified_file(tmp_path: Path) -> None:
|
||||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||||
path = summary.answers_dir / "diagnoses.csv"
|
||||
path.write_text(path.read_text(encoding="utf-8-sig") + "x\n", encoding="utf-8-sig")
|
||||
with pytest.raises(ValueError, match="SHA-256"):
|
||||
read_diagnoses(summary.answers_dir)
|
||||
|
||||
|
||||
def test_existing_build_directory_is_not_overwritten(tmp_path: Path) -> None:
|
||||
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
|
||||
manifest = (summary.library_dir / "manifest.json").read_bytes()
|
||||
with pytest.raises(FileExistsError):
|
||||
build(tmp_path, make_delivery(3), 1, None, NOW) # 同一时刻、同一提交 → 同一构建号
|
||||
assert (summary.library_dir / "manifest.json").read_bytes() == manifest
|
||||
assert len(list_aliases(summary.library_dir)) == 2
|
||||
|
||||
|
||||
def _csv_rows(path: Path) -> list[list[str]]:
|
||||
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||
return list(csv.reader(handle))
|
||||
|
||||
|
||||
def test_answers_contents(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(3).mr["年龄"] = "99岁" # C10
|
||||
delivery.case(1).ultrasound["超声描述"] = "胰体部见无回声区。考虑囊性病灶;边界清。"
|
||||
summary = build(tmp_path, delivery, 1, None, NOW)
|
||||
|
||||
assert summary.total_cases == 4
|
||||
assert summary.passed_cases == 3
|
||||
assert summary.skipped_cases == 1
|
||||
assert summary.skipped_by_check == {"C10": 1}
|
||||
assert summary.review_rows == 1
|
||||
|
||||
skipped = _csv_rows(summary.answers_dir / "skipped.csv")
|
||||
assert skipped[0] == ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"]
|
||||
assert [row[:2] for row in skipped[1:]] == [["003", "C10"]]
|
||||
assert json.loads(skipped[1][5])["年龄"]["MR 表"] == "99岁"
|
||||
|
||||
review = _csv_rows(summary.answers_dir / "review.csv")
|
||||
alias_of_1 = {r.case_no: r.alias for r in read_aliases(summary.answers_dir)}["001"]
|
||||
assert review[1:] == [[alias_of_1, "超声", "考虑", "考虑囊性病灶"]]
|
||||
|
||||
assert [r.case_no for r in read_aliases(summary.answers_dir)] == ["001", "002", "004"]
|
||||
assert [r.video_id for r in read_aliases(summary.answers_dir)] == ["U001", "U002", "U004"]
|
||||
codes = [
|
||||
(d.clinical_code, d.ultrasound_code, d.mr_code) for d in read_diagnoses(summary.answers_dir)
|
||||
]
|
||||
assert codes == [(1, 1, 1), (2, 2, 2), (4, 4, 4)]
|
||||
|
||||
manifest = json.loads((summary.answers_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["build_id"] == summary.build_id
|
||||
assert "不代表没有问题" in manifest["skipped_note"]
|
||||
assert sorted(manifest["files"]) == [
|
||||
"aliases.csv",
|
||||
"diagnoses.csv",
|
||||
"review.csv",
|
||||
"skipped.csv",
|
||||
"snapshot/build.yaml",
|
||||
"snapshot/snapshot.json",
|
||||
]
|
||||
|
||||
|
||||
def test_snapshot_records_config_and_git(tmp_path: Path) -> None:
|
||||
summary = build(tmp_path, make_delivery(2), 1, SAMPLE_CODE_MAPS, NOW)
|
||||
snapshot_dir = summary.answers_dir / "snapshot"
|
||||
assert (snapshot_dir / "build.yaml").read_bytes() == (tmp_path / "build.yaml").read_bytes()
|
||||
assert (snapshot_dir / "code_maps.yaml").read_bytes() == (
|
||||
tmp_path / "code_maps.yaml"
|
||||
).read_bytes()
|
||||
snapshot = json.loads((snapshot_dir / "snapshot.json").read_text(encoding="utf-8"))
|
||||
assert snapshot["build_id"] == summary.build_id
|
||||
assert re.fullmatch(r"[0-9a-f]{40}", snapshot["git_commit"])
|
||||
assert summary.build_id.startswith("20261016-093000-" + snapshot["git_commit"][:7])
|
||||
assert summary.build_id.endswith("-dirty") == (snapshot["git_clean"] is False)
|
||||
assert set(snapshot["config_sha256"]) == {"build.yaml", "code_maps.yaml"}
|
||||
library_manifest = json.loads(
|
||||
(summary.library_dir / "manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert library_manifest["config_sha256"] == snapshot["config_sha256"]
|
||||
assert library_manifest["ceus_source"] is None
|
||||
assert library_manifest["aliases"] == sorted(library_manifest["aliases"])
|
||||
@@ -0,0 +1,187 @@
|
||||
"""整张表的问题 T1–T5 与配置错误:整次构建停下,两个库下不留完成的构建目录。"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cadx.build import load_build_config, load_code_maps, run_build
|
||||
from cadx.delivery import DeliveryError
|
||||
from fake_delivery import (
|
||||
CLINICAL_PATH,
|
||||
MR_PATH,
|
||||
REPO_ROOT,
|
||||
SAMPLE_CODE_MAPS,
|
||||
ULTRASOUND_PATH,
|
||||
make_delivery,
|
||||
write_config,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 10, 16, 9, 0, 0).astimezone()
|
||||
|
||||
|
||||
def _assert_no_finished_build(tmp_path: Path) -> None:
|
||||
for root in (tmp_path / "library", tmp_path / "answers"):
|
||||
if root.exists():
|
||||
assert all(p.name.endswith(".partial") for p in root.iterdir())
|
||||
|
||||
|
||||
def _run(tmp_path: Path, root: Path, code_maps: dict[str, dict[int, str]] | None) -> None:
|
||||
run_build(write_config(tmp_path, root, 7, code_maps), REPO_ROOT, NOW)
|
||||
|
||||
|
||||
def test_t1_missing_summary_table(tmp_path: Path) -> None:
|
||||
root = make_delivery(3).write(tmp_path / "delivery")
|
||||
(root / MR_PATH).unlink()
|
||||
with pytest.raises(DeliveryError, match=r"^T1"):
|
||||
_run(tmp_path, root, None)
|
||||
_assert_no_finished_build(tmp_path)
|
||||
|
||||
|
||||
def test_t2_wrong_sheet_name(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.sheet_names[ULTRASOUND_PATH] = "Sheet1"
|
||||
root = delivery.write(tmp_path / "delivery")
|
||||
with pytest.raises(DeliveryError, match=r"^T2"):
|
||||
_run(tmp_path, root, None)
|
||||
_assert_no_finished_build(tmp_path)
|
||||
|
||||
|
||||
def test_t3_header_differs(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.headers[MR_PATH][3] = "病理类型" # 诊断列名里写的编码含义丢了
|
||||
root = delivery.write(tmp_path / "delivery")
|
||||
with pytest.raises(DeliveryError, match=r"^T3"):
|
||||
_run(tmp_path, root, None)
|
||||
_assert_no_finished_build(tmp_path)
|
||||
|
||||
|
||||
def test_t4_extra_file_in_clinical_folder(tmp_path: Path) -> None:
|
||||
root = make_delivery(3).write(tmp_path / "delivery")
|
||||
(root / CLINICAL_PATH).with_name("备注.txt").write_text("x", encoding="utf-8")
|
||||
with pytest.raises(DeliveryError, match=r"^T4"):
|
||||
_run(tmp_path, root, None)
|
||||
_assert_no_finished_build(tmp_path)
|
||||
|
||||
|
||||
def test_t4_unexpected_entry_in_ceus_folder(tmp_path: Path) -> None:
|
||||
root = make_delivery(3).write(tmp_path / "delivery")
|
||||
(root / "CEUS_agent" / "41").mkdir() # 不是三位数字
|
||||
with pytest.raises(DeliveryError, match=r"^T4"):
|
||||
_run(tmp_path, root, None)
|
||||
_assert_no_finished_build(tmp_path)
|
||||
|
||||
|
||||
def test_t5_code_maps_missing_a_column(tmp_path: Path) -> None:
|
||||
root = make_delivery(3).write(tmp_path / "delivery")
|
||||
maps = {k: v for k, v in SAMPLE_CODE_MAPS.items() if k != "TBil_Level"}
|
||||
with pytest.raises(ValueError, match=r"^T5"):
|
||||
_run(tmp_path, root, maps)
|
||||
_assert_no_finished_build(tmp_path)
|
||||
|
||||
|
||||
def test_t5_code_maps_extra_column(tmp_path: Path) -> None:
|
||||
root = make_delivery(3).write(tmp_path / "delivery")
|
||||
maps = {**SAMPLE_CODE_MAPS, "Age": {1: "一"}}
|
||||
with pytest.raises(ValueError, match=r"^T5"):
|
||||
_run(tmp_path, root, maps)
|
||||
_assert_no_finished_build(tmp_path)
|
||||
|
||||
|
||||
def test_t5_sex_must_map_to_chinese_characters(tmp_path: Path) -> None:
|
||||
root = make_delivery(3).write(tmp_path / "delivery")
|
||||
maps = {**SAMPLE_CODE_MAPS, "Sex": {1: "M", 2: "F"}}
|
||||
with pytest.raises(ValueError, match=r"^T5"):
|
||||
_run(tmp_path, root, maps)
|
||||
_assert_no_finished_build(tmp_path)
|
||||
|
||||
|
||||
def test_t5_diagnosis_must_map_to_four_subtypes(tmp_path: Path) -> None:
|
||||
root = make_delivery(3).write(tmp_path / "delivery")
|
||||
maps = {**SAMPLE_CODE_MAPS, "Diagnosis": {1: "IPMN", 2: "SPN", 3: "MCN", 4: "MCN"}}
|
||||
with pytest.raises(ValueError, match=r"^T5"):
|
||||
_run(tmp_path, root, maps)
|
||||
_assert_no_finished_build(tmp_path)
|
||||
|
||||
|
||||
def _write_yaml(tmp_path: Path, body: str) -> Path:
|
||||
path = tmp_path / "build.yaml"
|
||||
path.write_text(body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
_BASE = "delivery_dir: d\nlibrary_root: l\nanswers_root: a\nalias_seed: 1\n"
|
||||
|
||||
|
||||
def test_config_decode_true_needs_code_maps(tmp_path: Path) -> None:
|
||||
path = _write_yaml(tmp_path, _BASE + "decode_codes: true\ncode_maps: null\n")
|
||||
with pytest.raises(ValidationError):
|
||||
load_build_config(path, tmp_path)
|
||||
|
||||
|
||||
def test_config_decode_false_needs_null_code_maps(tmp_path: Path) -> None:
|
||||
path = _write_yaml(tmp_path, _BASE + "decode_codes: false\ncode_maps: maps.yaml\n")
|
||||
with pytest.raises(ValidationError):
|
||||
load_build_config(path, tmp_path)
|
||||
|
||||
|
||||
def test_config_field_missing(tmp_path: Path) -> None:
|
||||
path = _write_yaml(tmp_path, _BASE + "decode_codes: false\n")
|
||||
with pytest.raises(ValidationError):
|
||||
load_build_config(path, tmp_path)
|
||||
|
||||
|
||||
def test_config_extra_field(tmp_path: Path) -> None:
|
||||
path = _write_yaml(tmp_path, _BASE + "decode_codes: false\ncode_maps: null\nseed: 2\n")
|
||||
with pytest.raises(ValidationError):
|
||||
load_build_config(path, tmp_path)
|
||||
|
||||
|
||||
def test_config_seed_must_be_int_not_string(tmp_path: Path) -> None:
|
||||
body = _BASE.replace("alias_seed: 1", "alias_seed: '1'")
|
||||
path = _write_yaml(tmp_path, body + "decode_codes: false\ncode_maps: null\n")
|
||||
with pytest.raises(ValidationError):
|
||||
load_build_config(path, tmp_path)
|
||||
|
||||
|
||||
def test_config_relative_paths_resolve_against_repo_root(tmp_path: Path) -> None:
|
||||
path = _write_yaml(tmp_path, _BASE + "decode_codes: false\ncode_maps: null\n")
|
||||
config = load_build_config(path, Path("/repo"))
|
||||
assert config.delivery_dir == Path("/repo/d")
|
||||
assert config.library_root == Path("/repo/l")
|
||||
assert config.answers_root == Path("/repo/a")
|
||||
assert config.code_maps is None
|
||||
|
||||
|
||||
def test_committed_config_is_valid() -> None:
|
||||
config = load_build_config(REPO_ROOT / "config" / "build.yaml", REPO_ROOT)
|
||||
assert config.decode_codes is False
|
||||
assert config.code_maps is None
|
||||
|
||||
|
||||
def test_config_duplicate_key(tmp_path: Path) -> None:
|
||||
body = _BASE + "decode_codes: false\ncode_maps: null\nalias_seed: 2\n"
|
||||
path = _write_yaml(tmp_path, body)
|
||||
with pytest.raises(ValueError, match="重复的键"):
|
||||
load_build_config(path, tmp_path)
|
||||
|
||||
|
||||
def test_config_empty_path(tmp_path: Path) -> None:
|
||||
body = _BASE.replace("delivery_dir: d", "delivery_dir: ''")
|
||||
path = _write_yaml(tmp_path, body + "decode_codes: false\ncode_maps: null\n")
|
||||
with pytest.raises(ValidationError, match="空串"):
|
||||
load_build_config(path, tmp_path)
|
||||
|
||||
|
||||
def test_config_empty_code_maps_path(tmp_path: Path) -> None:
|
||||
path = _write_yaml(tmp_path, _BASE + "decode_codes: true\ncode_maps: ''\n")
|
||||
with pytest.raises(ValidationError, match="空串"):
|
||||
load_build_config(path, tmp_path)
|
||||
|
||||
|
||||
def test_code_maps_duplicate_key(tmp_path: Path) -> None:
|
||||
path = tmp_path / "code_maps.yaml"
|
||||
path.write_text("Sex: {1: 男, 1: 女}\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="重复的键"):
|
||||
load_code_maps(path)
|
||||
@@ -0,0 +1,420 @@
|
||||
"""逐例检查 C01–C13:只让一例违反一项,这一例跳过、记对检查编号,其余病例照常通过。
|
||||
|
||||
预期值照任务书 01 第二部分「单个病例的问题」的文字写。
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from cadx.delivery import DeliveryResult, read_delivery
|
||||
from cadx.delivery._text import conclusion_headings
|
||||
from fake_delivery import (
|
||||
CLINICAL_PATH,
|
||||
DIAGNOSIS_COLUMN,
|
||||
MR_PATH,
|
||||
MR_TEXT,
|
||||
SAMPLE_CODE_MAPS,
|
||||
ULTRASOUND_HEADER,
|
||||
ULTRASOUND_PATH,
|
||||
ULTRASOUND_TEXT,
|
||||
FakeDelivery,
|
||||
make_delivery,
|
||||
)
|
||||
|
||||
|
||||
def _read(
|
||||
tmp_path: Path, delivery: FakeDelivery, code_maps: dict[str, dict[int, str]] | None
|
||||
) -> DeliveryResult:
|
||||
return read_delivery(delivery.write(tmp_path / "delivery"), code_maps)
|
||||
|
||||
|
||||
def _assert_only_skipped(result: DeliveryResult, case: str, check: str, total: int) -> None:
|
||||
assert [(s.case, s.check) for s in result.skipped] == [(case, check)]
|
||||
passed = [f"{n:03d}" for n in range(1, total + 1) if f"{n:03d}" != case]
|
||||
assert [c.case_no for c in result.cases] == passed
|
||||
assert result.total_cases == total
|
||||
|
||||
|
||||
def test_all_pass(tmp_path: Path) -> None:
|
||||
result = _read(tmp_path, make_delivery(4), None)
|
||||
assert [c.case_no for c in result.cases] == ["001", "002", "003", "004"]
|
||||
assert result.skipped == ()
|
||||
assert result.unrecognized_rows == 0
|
||||
|
||||
|
||||
def test_c01_unrecognized_clinical_id_is_its_own_row_and_case_missing(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(2).clinical["Case_ID"] = "P2" # 临床表要求 P 加三位数字
|
||||
result = _read(tmp_path, delivery, None)
|
||||
# 第 1 行是表头,病例 2 在第 3 行;同一个病人因此在 C03(缺在临床表)再记一条。
|
||||
assert [(s.case, s.check) for s in result.skipped] == [
|
||||
(f"{CLINICAL_PATH} 第 3 行", "C01"),
|
||||
("002", "C03"),
|
||||
]
|
||||
assert result.unrecognized_rows == 1
|
||||
assert result.total_cases == 4
|
||||
assert [c.case_no for c in result.cases] == ["001", "003", "004"]
|
||||
|
||||
|
||||
def test_c01_float_report_id_is_unrecognized(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(3).mr["患者ID"] = 3.5 # 浮点不是认得出的写法
|
||||
result = _read(tmp_path, delivery, None)
|
||||
assert [(s.case, s.check) for s in result.skipped] == [
|
||||
(f"{MR_PATH} 第 4 行", "C01"),
|
||||
("003", "C03"),
|
||||
]
|
||||
|
||||
|
||||
def test_c01_report_ids_accept_int_and_zero_padded_strings(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(1).ultrasound["患者ID"] = 1
|
||||
delivery.case(2).mr["患者ID"] = "002"
|
||||
delivery.case(2).ultrasound["患者ID"] = "02"
|
||||
result = _read(tmp_path, delivery, None)
|
||||
assert result.skipped == ()
|
||||
assert len(result.cases) == 3
|
||||
|
||||
|
||||
def test_c02_duplicate_id_in_one_table(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
duplicate = delivery.case(2).mr
|
||||
delivery.extra_rows[MR_PATH].append([duplicate[h] for h in delivery.headers[MR_PATH]])
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C02", 4)
|
||||
|
||||
|
||||
def test_c03_case_directory_missing(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
root = delivery.write(tmp_path / "delivery")
|
||||
shutil.rmtree(root / "CEUS_agent" / "003")
|
||||
_assert_only_skipped(read_delivery(root, None), "003", "C03", 4)
|
||||
|
||||
|
||||
def test_c04_missing_key_image(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(4).dir_files.remove("U004_key.png")
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "004", "C04", 4)
|
||||
|
||||
|
||||
def test_c04_extra_file(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(1).dir_files.append("U001.mp4") # 两个视频共四个文件
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C04", 3)
|
||||
|
||||
|
||||
def test_c04_roi2_video_with_roi2_report_passes(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
assert delivery.case(2).dir_files == ["U002_key.png", "U002_roi2.mp4", "U002_roi2_report.xlsx"]
|
||||
assert _read(tmp_path, delivery, None).skipped == ()
|
||||
|
||||
|
||||
def test_c04_plain_video_with_plain_report_passes(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(3).dir_files = ["U003_key.png", "U003.mp4", "U003_report.xlsx"]
|
||||
result = _read(tmp_path, delivery, None)
|
||||
assert result.skipped == ()
|
||||
assert len(result.cases) == 3
|
||||
|
||||
|
||||
def test_c04_roi2_video_with_plain_report_is_skipped(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
# 文件名被 gitleaks 的通用密钥规则误报,不是密钥。
|
||||
delivery.case(1).dir_files = [
|
||||
"U001_key.png",
|
||||
"U001_roi2.mp4", # gitleaks:allow
|
||||
"U001_report.xlsx",
|
||||
]
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C04", 3)
|
||||
|
||||
|
||||
def test_c04_plain_video_with_roi2_report_is_skipped(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(2).dir_files = ["U002_key.png", "U002.mp4", "U002_roi2_report.xlsx"]
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C04", 3)
|
||||
|
||||
|
||||
def test_c06_reads_plain_report_when_paired_with_plain_video(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
case = delivery.case(3)
|
||||
case.dir_files = ["U003_key.png", "U003.mp4", "U003_report.xlsx"]
|
||||
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
|
||||
row[-1] = ULTRASOUND_TEXT + "补充一句。"
|
||||
case.case_report_rows = [list(ULTRASOUND_HEADER), row]
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C06", 3)
|
||||
|
||||
|
||||
def test_c05_video_id_mismatch(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(2).ultrasound["CEUS视频ID"] = "U003"
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C05", 4)
|
||||
|
||||
|
||||
def test_c06_case_report_cell_differs(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
case = delivery.case(3)
|
||||
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
|
||||
row[-1] = ULTRASOUND_TEXT + "补充一句。"
|
||||
case.case_report_rows = [list(ULTRASOUND_HEADER), row]
|
||||
result = _read(tmp_path, delivery, None)
|
||||
_assert_only_skipped(result, "003", "C06", 4)
|
||||
assert result.skipped[0].columns == ("超声描述",)
|
||||
|
||||
|
||||
def test_c06_case_report_cell_type_differs(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
case = delivery.case(1)
|
||||
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
|
||||
row[0] = 1 # 汇总表里是字符串 "1"
|
||||
case.case_report_rows = [list(ULTRASOUND_HEADER), row]
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C06", 3)
|
||||
|
||||
|
||||
def test_c06_case_report_wrong_sheet(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(2).case_report_sheet = "Sheet2"
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C06", 3)
|
||||
|
||||
|
||||
def test_c06_case_report_two_data_rows(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
case = delivery.case(2)
|
||||
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
|
||||
case.case_report_rows = [list(ULTRASOUND_HEADER), row, row]
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C06", 3)
|
||||
|
||||
|
||||
def test_c07_bool_sex_is_not_an_int(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(1).clinical["Sex"] = True # bool 是 int 的子类,不能当作 1
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C07", 3)
|
||||
|
||||
|
||||
def test_c01_bool_report_id_is_unrecognized(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(1).ultrasound["患者ID"] = True
|
||||
result = _read(tmp_path, delivery, None)
|
||||
assert [(s.case, s.check) for s in result.skipped] == [
|
||||
(f"{ULTRASOUND_PATH} 第 2 行", "C01"),
|
||||
("001", "C03"),
|
||||
]
|
||||
|
||||
|
||||
def test_c07_sex_out_of_range(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(1).clinical["Sex"] = 3
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C07", 4)
|
||||
|
||||
|
||||
def test_c07_non_integer_age(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(2).clinical["Age"] = 32.5
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C07", 3)
|
||||
|
||||
|
||||
def test_c07_symptom_empty(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(2).clinical["Symptom_Fever"] = None
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C07", 3)
|
||||
|
||||
|
||||
def test_c07_lab_only_one_column_empty(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(2).clinical["CA199"] = None # 档位列仍是 0
|
||||
result = _read(tmp_path, delivery, None)
|
||||
_assert_only_skipped(result, "002", "C07", 4)
|
||||
assert set(result.skipped[0].columns) == {"CA199", "CA199_Level"}
|
||||
|
||||
|
||||
def test_c07_lab_both_empty_is_fine(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(1).clinical["TBil"] = None
|
||||
delivery.case(1).clinical["TBil_Level"] = None
|
||||
assert _read(tmp_path, delivery, None).skipped == ()
|
||||
|
||||
|
||||
def test_c07_value_missing_from_code_map(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
maps = {**SAMPLE_CODE_MAPS, "Hx_Alcohol": {0: "否"}} # 映射表里没有 1
|
||||
delivery.case(2).clinical["Hx_Alcohol"] = 1
|
||||
_assert_only_skipped(_read(tmp_path, delivery, maps), "002", "C07", 3)
|
||||
|
||||
|
||||
def test_c08_ultrasound_sex_invalid(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(4).ultrasound["性别"] = "M"
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "004", "C08", 4)
|
||||
|
||||
|
||||
def test_c08_negative_bolus_time(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(3).ultrasound["推注开始时间s"] = -1
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C08", 3)
|
||||
|
||||
|
||||
def test_c08_empty_description(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(1).ultrasound["超声描述"] = None
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C08", 3)
|
||||
|
||||
|
||||
def test_c09_mr_age_not_number_plus_sui(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(3).mr["年龄"] = "33"
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C09", 4)
|
||||
|
||||
|
||||
def test_c09_mr_diagnosis_out_of_range(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(2).mr[DIAGNOSIS_COLUMN] = 5
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C09", 3)
|
||||
|
||||
|
||||
def test_c10_diagnosis_mismatch(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(1).clinical["Diagnosis"] = 2 # 两张报告表仍是 1
|
||||
result = _read(tmp_path, delivery, None)
|
||||
_assert_only_skipped(result, "001", "C10", 4)
|
||||
assert result.skipped[0].columns == ("诊断",)
|
||||
|
||||
|
||||
def test_c10_age_mismatch(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(2).mr["年龄"] = "99岁"
|
||||
result = _read(tmp_path, delivery, None)
|
||||
_assert_only_skipped(result, "002", "C10", 4)
|
||||
assert result.skipped[0].columns == ("年龄",)
|
||||
|
||||
|
||||
def test_c10_sex_mismatch_between_reports(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(3).mr["性别"] = "男" if delivery.case(3).ultrasound["性别"] == "女" else "女"
|
||||
result = _read(tmp_path, delivery, None)
|
||||
_assert_only_skipped(result, "003", "C10", 4)
|
||||
assert result.skipped[0].columns == ("性别",)
|
||||
|
||||
|
||||
def test_c10_clinical_sex_compared_only_when_decoding(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
case = delivery.case(2)
|
||||
case.clinical["Sex"] = 1 if case.clinical["Sex"] == 2 else 2 # 报告表的性别不变
|
||||
undecoded = _read(tmp_path / "a", delivery, None)
|
||||
assert undecoded.skipped == ()
|
||||
assert undecoded.uncompared == ("临床性别未比对",)
|
||||
decoded = _read(tmp_path / "b", delivery, SAMPLE_CODE_MAPS)
|
||||
_assert_only_skipped(decoded, "002", "C10", 3)
|
||||
assert decoded.uncompared == ()
|
||||
|
||||
|
||||
def test_c11_heading_at_start_with_fullwidth_colon(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(2).ultrasound["超声描述"] = "结论:胰体部囊性病灶。" + ULTRASOUND_TEXT
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C11", 4)
|
||||
|
||||
|
||||
def test_c11_heading_after_newline(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(3).mr["MR描述"] = MR_TEXT + "\n印象 : 胰体部囊性病灶。"
|
||||
result = _read(tmp_path, delivery, None)
|
||||
_assert_only_skipped(result, "003", "C11", 4)
|
||||
assert result.skipped[0].columns == ("MR描述",)
|
||||
|
||||
|
||||
def test_c11_heading_mid_text_is_not_a_failure(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(1).ultrasound["超声描述"] = "胰体部见一无回声区。造影提示:囊壁呈等增强。"
|
||||
result = _read(tmp_path, delivery, None)
|
||||
assert result.skipped == ()
|
||||
assert len(result.cases) == 3
|
||||
|
||||
|
||||
def test_c12_subtype_name_fullwidth_lowercase(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(4).mr["MR描述"] = MR_TEXT + "形态类似ipmn。"
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "004", "C12", 4)
|
||||
|
||||
|
||||
def test_c12_chinese_subtype_name(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(1).ultrasound["超声描述"] = ULTRASOUND_TEXT + "符合浆液性囊腺瘤表现。"
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C12", 3)
|
||||
|
||||
|
||||
def test_c11_heading_after_vertical_tab_or_form_feed() -> None:
|
||||
# xlsx(XML)里写不进 \x0b、\x0c,openpyxl 拒绝生成,所以直接测规则函数。
|
||||
for separator in ("\x0b", "\x0c"):
|
||||
text = MR_TEXT + separator + "诊断:胰体部囊性病灶。"
|
||||
assert [k for k, _ in conclusion_headings(text)] == ["诊断"]
|
||||
|
||||
|
||||
def test_c11_heading_after_unicode_line_separator(tmp_path: Path) -> None:
|
||||
for i, separator in enumerate(("\x85", "\u2028", "\u2029")):
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(2).mr["MR描述"] = MR_TEXT + separator + "诊断:胰体部囊性病灶。"
|
||||
_assert_only_skipped(_read(tmp_path / str(i), delivery, None), "002", "C11", 3)
|
||||
|
||||
|
||||
def test_c11_ratio_sign_as_colon(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(1).ultrasound["超声描述"] = "超声提示∶胰体部囊性病灶。" + ULTRASOUND_TEXT
|
||||
result = _read(tmp_path, delivery, None)
|
||||
_assert_only_skipped(result, "001", "C11", 3)
|
||||
assert '"关键词": "超声提示"' in result.skipped[0].value_json
|
||||
|
||||
|
||||
def test_c12_new_subtype_terms(tmp_path: Path) -> None:
|
||||
terms = (
|
||||
"SPT",
|
||||
"sptp",
|
||||
"IPMT",
|
||||
"导管内乳头",
|
||||
"实性假乳头",
|
||||
"黏液囊性",
|
||||
"粘液囊性",
|
||||
"囊腺瘤",
|
||||
"浆液性微囊",
|
||||
)
|
||||
for i, term in enumerate(terms):
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(3).ultrasound["超声描述"] = ULTRASOUND_TEXT + f"形态与{term}相似。"
|
||||
_assert_only_skipped(_read(tmp_path / str(i), delivery, None), "003", "C12", 3)
|
||||
|
||||
|
||||
def test_c12_serous_alone_is_fine(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(1).ultrasound["超声描述"] = ULTRASOUND_TEXT + "囊液呈浆液性。"
|
||||
assert _read(tmp_path, delivery, None).skipped == ()
|
||||
|
||||
|
||||
def test_c13_age_in_report(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(4)
|
||||
delivery.case(1).ultrasound["超声描述"] = "患者45岁。" + ULTRASOUND_TEXT
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C13", 4)
|
||||
|
||||
|
||||
def test_c13_age_variants(tmp_path: Path) -> None:
|
||||
for i, age in enumerate(("四十五岁", "45 岁", "6周岁", "三十歲", "12周岁")):
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(2).mr["MR描述"] = f"患者{age}。" + MR_TEXT
|
||||
_assert_only_skipped(_read(tmp_path / str(i), delivery, None), "002", "C13", 3)
|
||||
|
||||
|
||||
def test_c13_sex_character_in_report(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(3).mr["MR描述"] = "女性患者。" + MR_TEXT
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C13", 3)
|
||||
|
||||
|
||||
def test_only_first_failing_check_is_recorded(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(2).ultrasound["性别"] = "M" # C08
|
||||
delivery.case(2).mr["年龄"] = "99岁" # C10
|
||||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C08", 3)
|
||||
|
||||
|
||||
def test_skip_record_names_file_and_column(tmp_path: Path) -> None:
|
||||
delivery = make_delivery(3)
|
||||
delivery.case(2).ultrasound["推注开始时间s"] = 2.5
|
||||
record = _read(tmp_path, delivery, None).skipped[0]
|
||||
assert record.files == (ULTRASOUND_PATH,)
|
||||
assert record.columns == ("推注开始时间s",)
|
||||
assert record.value_json == '{"推注开始时间s": 2.5}'
|
||||
@@ -2,10 +2,25 @@ version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadx"
|
||||
version = "0.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "openpyxl" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
@@ -14,9 +29,17 @@ dev = [
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "types-openpyxl" },
|
||||
{ name = "types-pyyaml" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||
{ name = "pillow", specifier = ">=12.3.0" },
|
||||
{ name = "pydantic", specifier = ">=2.14.1" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -25,6 +48,8 @@ dev = [
|
||||
{ name = "pyright", specifier = ">=1.1.380" },
|
||||
{ name = "pytest", specifier = ">=8.3" },
|
||||
{ name = "ruff", specifier = ">=0.6" },
|
||||
{ name = "types-openpyxl", specifier = ">=3.1.5.20260827" },
|
||||
{ name = "types-pyyaml", specifier = ">=6.0.12.20260906" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -63,6 +88,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "et-xmlfile"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "4.2.0"
|
||||
@@ -215,6 +249,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
version = "3.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "et-xmlfile" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
@@ -224,6 +270,77 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.13.3"
|
||||
@@ -258,6 +375,127 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/83/ce0a196e4f7b20b11ce3aa1fc72fb973f64b7628d51c1b8aebad37fb7f81/pre_commit-4.7.0-py2.py3-none-any.whl", hash = "sha256:2e229038ad3656081b70c27913157e4b636ef306f643f024a8a8e3f0f7b2f877", size = 226261, upload-time = "2026-10-12T20:45:38.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.50.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
@@ -392,6 +630,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-openpyxl"
|
||||
version = "3.1.5.20260827"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/6b/ce650ce7754a2bce3ca1dfbce7f7441df092e2dc6047d00e04f840c2b56e/types_openpyxl-3.1.5.20260827.tar.gz", hash = "sha256:be8b605fb99cfd7d5f5576d4a508e8ec44be2dd15b85157c559080de6384be34", size = 101985, upload-time = "2026-08-27T12:06:18.927Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/23/9708c0895d237205ab2b31c06f97d72294f69789ff9923ff7f4aa2126d6b/types_openpyxl-3.1.5.20260827-py3-none-any.whl", hash = "sha256:94e176d871d12e3cbc34f8fb03dc14db2a4245a6690791daf16fc7b08fd67869", size = 165885, upload-time = "2026-08-27T12:06:17.88Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pyyaml"
|
||||
version = "6.0.12.20260906"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
@@ -401,6 +657,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "21.14.8"
|
||||
|
||||
Reference in New Issue
Block a user