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

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

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

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

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