From bceb01e8faf12e55c5abe4136fcd9c920d17f4c0 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Fri, 16 Oct 2026 03:00:20 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A3=85=E9=85=8D=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E5=BA=93=EF=BC=88=E4=B8=8D=E5=90=AB=20CEUS=20=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按任务书 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 --- WORK_LOG.md | 9 +- config/.gitkeep | 0 config/build.yaml | 10 + pyproject.toml | 57 +++- research-wiki/explanation/结构.md | 84 +++++- research-wiki/guides/装配两个库.md | 212 +++++++++++++++ scripts/.gitkeep | 0 scripts/build.py | 37 +++ scripts/scan_library.py | 88 ++++++ src/cadx/answers/__init__.py | 24 ++ src/cadx/answers/_files.py | 177 ++++++++++++ src/cadx/build/__init__.py | 13 + src/cadx/build/_assemble.py | 198 ++++++++++++++ src/cadx/build/_config.py | 96 +++++++ src/cadx/delivery/__init__.py | 31 +++ src/cadx/delivery/_cases.py | 412 ++++++++++++++++++++++++++++ src/cadx/delivery/_shape.py | 104 +++++++ src/cadx/delivery/_tables.py | 84 ++++++ src/cadx/delivery/_text.py | 102 +++++++ src/cadx/library/__init__.py | 30 +++ src/cadx/library/_read.py | 83 ++++++ src/cadx/library/_scan.py | 68 +++++ src/cadx/library/_write.py | 119 ++++++++ src/cadx/runs/__init__.py | 5 + src/cadx/runs/_snapshot.py | 81 ++++++ tests/fake_delivery.py | 267 ++++++++++++++++++ tests/test_build_outputs.py | 352 ++++++++++++++++++++++++ tests/test_build_stops.py | 187 +++++++++++++ tests/test_delivery_cases.py | 420 +++++++++++++++++++++++++++++ uv.lock | 268 ++++++++++++++++++ 30 files changed, 3600 insertions(+), 18 deletions(-) delete mode 100644 config/.gitkeep create mode 100644 config/build.yaml create mode 100644 research-wiki/guides/装配两个库.md delete mode 100644 scripts/.gitkeep create mode 100644 scripts/build.py create mode 100644 scripts/scan_library.py create mode 100644 src/cadx/answers/__init__.py create mode 100644 src/cadx/answers/_files.py create mode 100644 src/cadx/build/__init__.py create mode 100644 src/cadx/build/_assemble.py create mode 100644 src/cadx/build/_config.py create mode 100644 src/cadx/delivery/__init__.py create mode 100644 src/cadx/delivery/_cases.py create mode 100644 src/cadx/delivery/_shape.py create mode 100644 src/cadx/delivery/_tables.py create mode 100644 src/cadx/delivery/_text.py create mode 100644 src/cadx/library/__init__.py create mode 100644 src/cadx/library/_read.py create mode 100644 src/cadx/library/_scan.py create mode 100644 src/cadx/library/_write.py create mode 100644 src/cadx/runs/__init__.py create mode 100644 src/cadx/runs/_snapshot.py create mode 100644 tests/fake_delivery.py create mode 100644 tests/test_build_outputs.py create mode 100644 tests/test_build_stops.py create mode 100644 tests/test_delivery_cases.py diff --git a/WORK_LOG.md b/WORK_LOG.md index f7672fb..f4a9534 100644 --- a/WORK_LOG.md +++ b/WORK_LOG.md @@ -4,7 +4,7 @@ ## 当前目标 -骨架已完成;整体架构已和负责人讨论定稿(`research-wiki/design/0002`、`explanation/结构.md`)。下一步是写第 2 步「装配两个库(不含 CEUS)」的任务书 `research-wiki/任务书/01-装配两个库.md`。 +第 2 步「装配两个库(不含 CEUS)」已按任务书 01 实现,等监督者验收。之后是第 3 步 CEUS 定量或会诊口径讨论(`结构.md` 第七部分)。 ## 已完成 @@ -15,6 +15,8 @@ - 2026-10-15/16 深读旧项目三条线(CEUS 算法、资料与答案隔离、会诊与评价),两位中立评审独立评了存储与迁移方案;结论落成 design 0002 与重写的 `结构.md`。待定的研究问题记在 `research-wiki/待定问题/`。 +- 2026-10-16 任务书 01 装配两个库:delivery、library、answers、runs(快照)、build 五个模块,`scripts/build.py`、`scripts/scan_library.py`、`config/build.yaml`,91 条测试,`make check` 通过(6 条导入契约)。两轮独立审阅的会悄悄漏答案的意见已修(C11–C13 加严、泄露扫描 NFKC、关键图只收能保真重编码的 PNG、YAML 拒绝重复键)。真实 40 例:40 例中 34 例进库,跳过 6 例全为 C10(跨表不一致,与 `待定问题/04` 一致),复核清单 2 条,泄露扫描命中 0;构建号与提交号见提交说明和本次汇报。使用说明 `guides/装配两个库.md`。 + ## 未完成 - `结构.md` 按负责人要求对照 ClinDB 的 `结构.md` 重写后,重写版还没再过一轮「硕士生阅读」(design 0002 与上一版结构文档过过)。 @@ -29,9 +31,12 @@ - 负责人 2026-10-16:先做第 2 步装配两个库;配置每段一份(`config/<段>.yaml`),编码解码是可选开关、映射表单独一份配置;交付资料检查一例有一项不过就整例跳过并记录,「都是遇到整个病例跳过,让医生核对去吧」;化名对照存进答案库。已写进 `结构.md`(框架图、第五部分「配置」)和 `AGENTS.md`「错误怎么处理」。 +- 负责人 2026-10-16 定 C04:报告表名随视频名配对(`U<号>_roi2.mp4` 配 `U<号>_roi2_report.xlsx`,`U<号>.mp4` 配 `U<号>_report.xlsx`),绿框版那一例因此进库。任务书 01 原文只认前一种,未改任务书。 + ## 下一步 -- 任务书 01「装配两个库」已写好(`research-wiki/任务书/01-装配两个库.md`),交负责人另开会话执行,本会话验收。 +- 监督者验收任务书 01。 +- 待负责人定:关键图格式不合要求时整次停下还是整例跳过;化名种子在 git 里,拿到种子可反推进库病例的病例号先后(专家拿不到种子);含「造影提示:」的那份超声描述所在病例被 C10 跳过,故不在复核清单里。 - 向临床方要编码映射表(性别、诊断、症状、既往史、化验档位)。 - 把 `待定问题/04`(6 例跨表不一致)、`待定问题/06`(1 份超声描述中段的「造影提示:」)转给医生;06 定之前的构建不做正式会诊。 - 等临床方交付 40 例的医生标注工作簿(含造影剂到达时间和注射时间)。 diff --git a/config/.gitkeep b/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/config/build.yaml b/config/build.yaml new file mode 100644 index 0000000..4c42338 --- /dev/null +++ b/config/build.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index b341baa..5aae5d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,12 @@ name = "cadx" version = "0.0.0" description = "胰腺囊性肿瘤多模态会诊诊断的研究代码" requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "openpyxl>=3.1.5", + "pillow>=12.3.0", + "pydantic>=2.14.1", + "pyyaml>=6.0.3", +] [dependency-groups] dev = [ @@ -12,6 +17,8 @@ dev = [ "pytest>=8.3", "import-linter>=2.1", "pre-commit>=4.0", + "types-pyyaml>=6.0.12.20260906", + "types-openpyxl>=3.1.5.20260827", ] [build-system] @@ -41,9 +48,12 @@ pythonVersion = "3.12" typeCheckingMode = "strict" venvPath = "." venv = ".venv" +extraPaths = ["tests"] [tool.pytest.ini_options] testpaths = ["tests"] +# 测试辅助(假交付包生成器)放在 tests/ 下,按普通模块导入。 +pythonpath = ["tests"] addopts = "--strict-markers --import-mode=importlib" markers = [ "live: 真实模型调用,花钱,需要 .env 里的密钥", @@ -51,7 +61,7 @@ markers = [ ] # 导入边界。改契约先改 research-wiki/explanation/结构.md,再改这里; -# 不靠 ignore_imports 让违规通过。规划中的契约见结构.md 第四节,模块落地时随任务书加入。 +# 不靠 ignore_imports 让违规通过。规划中的契约见结构.md 第五部分,模块落地时随任务书加入。 [tool.importlinter] root_packages = ["cadx"] include_external_packages = true @@ -62,3 +72,46 @@ name = "torch 与 sam2 不进 cadx" type = "forbidden" source_modules = ["cadx"] forbidden_modules = ["torch", "sam2"] + +[[tool.importlinter.contracts]] +# 结构.md 第三部分依赖表:外部层与格式层的模块不依赖其他 cadx 模块。 +name = "delivery、library、answers、runs 互不依赖" +type = "independence" +modules = ["cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs"] + +[[tool.importlinter.contracts]] +# 同上,依赖表的另一半:它们也不引用上层和其余规划中的模块(独立契约只管它们之间)。 +name = "delivery、library、answers、runs 不引用其他 cadx 模块" +type = "forbidden" +source_modules = ["cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs"] +forbidden_modules = [ + "cadx.build", "cadx.ceus", "cadx.segment", "cadx.llm", "cadx.ledger", + "cadx.consult", "cadx.evaluate", "cadx.verdicts", +] + +[[tool.importlinter.contracts]] +# 结构.md 第三部分依赖表:build 只依赖 delivery、library、answers、runs(ceus 落地时加入)。 +name = "build 只引用 delivery、library、answers、runs" +type = "forbidden" +source_modules = ["cadx.build"] +forbidden_modules = [ + "cadx.ceus", "cadx.segment", "cadx.llm", "cadx.ledger", + "cadx.consult", "cadx.evaluate", "cadx.verdicts", +] +allow_indirect_imports = true + +[[tool.importlinter.contracts]] +# 结构.md 第五部分:交付包的 Excel 只有 delivery 碰。 +name = "只有 delivery 引用 openpyxl" +type = "forbidden" +source_modules = ["cadx.library", "cadx.answers", "cadx.runs", "cadx.build"] +forbidden_modules = ["openpyxl"] +allow_indirect_imports = true + +[[tool.importlinter.contracts]] +# 结构.md 第五部分:写资料库的图片(去掉元数据)只有 library 碰。 +name = "只有 library 引用 PIL" +type = "forbidden" +source_modules = ["cadx.delivery", "cadx.answers", "cadx.runs", "cadx.build"] +forbidden_modules = ["PIL"] +allow_indirect_imports = true diff --git a/research-wiki/explanation/结构.md b/research-wiki/explanation/结构.md index 1a28f35..29f9c48 100644 --- a/research-wiki/explanation/结构.md +++ b/research-wiki/explanation/结构.md @@ -98,7 +98,7 @@ work/ 中间文件 ─────────► ② 装配两个库 | 层 | 模块 | 藏住的决定 | 旧项目里的对应内容 | | --- | --- | --- | --- | | 入口 | `scripts/`(不在包内) | 人怎么操作:每段一个脚本(`ceus_quant`、`build`、`consult`、`evaluate`),另有一个按顺序调用四段的总入口;读配置文件,把模块装配起来 | `debugs/t1_local_debug.py`、`debugs/tic_lognormal_replay.py` | -| 组织 | build | 两个库怎么装配:每一路放哪些内容(可见性的唯一决定处)、化名怎么取、病例顺序怎么打乱、清单记什么。全项目只有它同时碰得到原始资料和答案 | `app/domain/input_readers.py`、`ingestion*.py` 的装配部分 | +| 组织 | build | 两个库怎么装配:每一路放哪些内容(可见性的唯一决定处)、化名怎么取(化名随机,资料库按化名排序,顺序就与交付顺序无关)、清单记什么。全项目只有它同时碰得到原始资料和答案 | `app/domain/input_readers.py`、`ingestion*.py` 的装配部分 | | 组织 | consult | 会诊怎么进行:几个专家、谁先说、后发言的看得到前面的什么、什么时候结束;「调一次模型」的统一入口(先存请求、记开始、调用、存回复、记结束)也在这里 | 没有 | | 组织 | evaluate | 评价口径:指标、统计方法、字段级对照怎么计分 | 没有(`core/eval` 已删) | | 格式 | library | 给专家的资料库的目录布局、文件格式和读取;按路分别读出,一路一个对象 | `app/domain/input_packages.py` 的数据形状 | @@ -137,7 +137,7 @@ work/ 中间文件 ─────────► ② 装配两个库 ### 几条边界 - **专家能看见什么,只在 build 一处决定。** build 按白名单把每路允许的内容写进 `library/`,此外任何模块都不挑选给专家的材料。consult 只拿到某一次构建的路径,经 library 按路读出;它不依赖 delivery、answers、build,读原始资料和答案的代码对它来说根本不存在。报告的「印象 / 结论」段(医生写的诊断倾向)由 delivery 认出、由 build 剔除,`library/` 里只有「所见」描述。 -- **交付资料的检查不过,整例跳过。** delivery 逐例检查(编号、文件、取值、跨表是否一致,具体检查项在装配两个库的任务书里列),build 把没过的病例整例跳过:资料库和答案库的病理诊断里都没有它,答案库的跳过清单写明它没过哪一项、涉及哪个文件哪一列。一例遇到第一项不过就不再往下查,清单上每例只记一项。整张表读不了时整次构建停下。规则见 `AGENTS.md`「错误怎么处理」。 +- **交付资料的检查不过,整例跳过。** delivery 逐例检查(编号、文件、取值、跨表是否一致;检查项 C01–C13 的速查表见 `../guides/装配两个库.md`,规则以 `src/cadx/delivery/_cases.py`、`_text.py` 为准),build 把没过的病例整例跳过:资料库和答案库的病理诊断里都没有它,答案库的跳过清单写明它没过哪一项、涉及哪个文件哪一列。一例遇到第一项不过就不再往下查,清单上每例只记一项。整张表读不了时整次构建停下。规则见 `AGENTS.md`「错误怎么处理」。 - **三路互相隔开。** 每个专家的提示词只用自己那一路的对象拼。consult 里拼某一路提示词的函数只接收那一路的对象,不接收整个病例。这是研究设计本身:模拟各科医生各看各的检查结果,再在会诊时交换意见。会诊时一路能看到另一路的什么,由 consult 的会诊流程决定,在会诊的任务书里定。 - **我们自己调模型只有一个入口:consult 的「调一次模型」。** 它按固定顺序做五步:把完整请求存进账本的大块存储、写「调用开始」、经 llm 调用、把完整回复存进大块存储、写「调用结束」。llm 只管调用,不写账本,所以任何一次模型调用都绕不开登记。中断后重启时,从账本找出开始了没结束的调用,记为「状态未知」,不重做已有结果的调用。做重复运行(同一病例、同一专家调多次,测一致性)时,每一次都恰好登记一次。 - **CEUS 算法不知道分割模型是谁,也不管中间文件放哪。** ceus 接收视频路径、参数和分割结果,交出曲线、拟合、关键帧和指标;中间文件写在哪、按什么复用,由 `scripts/ceus_quant.py` 决定(目前只有它一个使用者,出现第二个使用者再收进模块)。segment 只管「给提示、出逐帧掩码」,不认识分屏和绿框。 @@ -156,6 +156,7 @@ library/<构建号>/ cases/<化名>/ clinical/clinical.json 临床那一路 ultrasound/ 超声那一路:report.txt、key.png、keyframes/*.png、tic.json + (后两项等 CEUS 定量落地后由 build 加入,目前只有前两项) mr/report.txt MR 那一路 ``` @@ -172,7 +173,7 @@ library/<构建号>/ 性别、年龄只给临床一路:医院在两份报告工作簿里也复制了这两列,但影像两路先看到它们会被人口学先验带偏(旧项目 design 0026 决定三)。 -病例用化名,目录顺序打乱:交付文件名里带病例号(如 `U001_roi2.mp4`),行序可能和诊断有关。图片写入时去掉文件元数据。构建完不再修改;要改就做一次新构建。 +病例用化名,目录和清单按化名排序:交付文件名里带病例号(如 `U001_roi2.mp4`),行序可能和诊断有关;化名是按种子随机抽的 4 位小写字母加数字,按它排序后的顺序与交付顺序无关。图片写入时去掉文件元数据。构建完不再修改;要改就做一次新构建。 ### 标准答案库(`answers/<构建号>/`) @@ -241,8 +242,8 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0 | 文件 | 给谁用 | 内容 | | --- | --- | --- | -| `build.yaml` | `scripts/build.py` | 交付包位置、两个库的输出位置、化名与打乱顺序的随机种子、编码解码开关与映射表位置 | -| `code_maps.yaml` | build(经 `build.yaml` 引用) | 编码映射表:交付包里每个用数字编码的列,每个数字代表什么(如性别的 1、2) | +| `build.yaml` | `scripts/build.py` | `delivery_dir`(交付包位置)、`library_root`、`answers_root`(两个库的上级目录)、`alias_seed`(生成化名的随机种子)、`decode_codes`(编码解码开关)、`code_maps`(映射表位置或 null);字段定义和校验在 `src/cadx/build/_config.py`,逐项说明见 `../guides/装配两个库.md` | +| `code_maps.yaml` | build(经 `build.yaml` 引用) | 编码映射表:交付包里每个用数字编码的列,每个数字代表什么(如性别的 1、2)。还没向临床方拿到,尚未建 | | `ceus.yaml` | `scripts/ceus_quant.py` | CEUS 定量的全部参数,每个参数用注释写明出处 | | `consult.yaml` | `scripts/consult.py` | 会诊的实验配置与模型用途 | | `evaluate.yaml` | `scripts/evaluate.py` | 评价的指标与统计设置 | @@ -259,11 +260,16 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0 文件格式:数组用 npz(读取时 `allow_pickle=False`,pickle 读取时会执行任意代码),每例的标量和摘要用 json,跨病例的表用 csv,图用 png。缺失值写 null,不写 NaN 或 0:0 是合法的测量值,NaN 在 json 里不合法。 -导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的,没有的保护不写: +导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的(共 6 条),没有的保护不写: - `cadx` 不引用 `torch`、`sam2`。 +- delivery、library、answers、runs 互不引用。 +- delivery、library、answers、runs 不引用其他 `cadx` 模块(build 和规划中的各模块)。 +- build 不引用 delivery、library、answers、runs 以外的 `cadx` 模块(ceus 落地时放行)。 +- 只有 delivery 引用 `openpyxl`(交付包的 Excel 只有它碰)。 +- 只有 library 引用 `PIL`(写资料库的图片时去掉元数据)。 -第三部分的依赖表和下面几条外部库独占,随模块落地逐条写成契约(import-linter 只能检查已经存在的模块):只有 delivery 引用 `openpyxl`;只有 library 引用 `PIL`(写资料库的图片时去掉元数据);只有 segment 引用 `torch`、`sam2`;只有 llm 引用 `polygateway`;只有 ledger 引用 `sqlite3`。 +第三部分依赖表的其余部分和下面几条外部库独占,随模块落地逐条写成契约(import-linter 只能检查已经存在的模块):只有 segment 引用 `torch`、`sam2`(落地时把上面第一条改成放行 segment);只有 llm 引用 `polygateway`;只有 ledger 引用 `sqlite3`。 契约管不到的,不要当成机器守住了: @@ -283,17 +289,17 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。 | --- | --- | --- | --- | | ceus | 6000–7500(基本照搬旧算法,去掉只为产品层服务的部分) | — | `ceus_roi/` 除 `service.py` 7330,`video_decoder.py` 135 | | segment | 150–300 | — | `medsam2_local_track.py` 240 | -| delivery | 400–700 | — | `clinical.py` 266、`modalities.py` 522、`workbook.py` 99、`delivery.py` 134,共 1021,其中入库与数据库部分不迁 | -| library | 150–300 | — | `input_packages.py` 104 | -| answers | 100–200 | — | — | +| delivery | 400–700 | 733 | `clinical.py` 266、`modalities.py` 522、`workbook.py` 99、`delivery.py` 134,共 1021,其中入库与数据库部分不迁 | +| library | 150–300 | 300 | `input_packages.py` 104 | +| answers | 100–200 | 201 | — | | verdicts | 100–200 | — | — | -| build | 300–500 | — | `input_readers.py` 329 的装配部分 | +| build | 300–500 | 307 | `input_readers.py` 329 的装配部分 | | llm | 200–450 | — | ClinDB `llm/` 415 | | ledger | 200–300 | — | ClinDB `ledger/` 274 | -| runs | 100–250 | — | — | +| runs | 100–250 | 86(只含快照部分) | — | | consult | 500–1000(会诊口径定后重估) | — | — | | evaluate | 300–600(评价口径定后重估) | — | 已删的 `core/eval` 622 | -| scripts/ 每个 | ≤ 300 | — | `t1_local_debug.py` 762、`tic_lognormal_replay.py` 627 | +| scripts/ 每个 | ≤ 300 | `build.py` 37、`scan_library.py` 88 | `t1_local_debug.py` 762、`tic_lognormal_replay.py` 627 | --- @@ -311,4 +317,54 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。 ## 八、各模块对外的函数与类型 -写到哪个模块时,再把它对外的函数和类型补在这里。 +写到哪个模块时,再把它对外的函数和类型补在这里。以各子包 `__init__.py` 导出的为准;参数和字段的细节看代码的 docstring。 + +### delivery(`src/cadx/delivery/`) + +- `read_delivery(delivery_dir, code_maps)`:读交付包,做整张表的检查 T1–T4 和逐例检查 C01–C13,交出 `DeliveryResult`;`code_maps` 为 None 表示不解码。 +- `DeliveryResult`:一次读取的结果:通过的病例(按病例号排序)、跳过记录、病例总数、认不出编号的行数、因没有映射表而没比对的项。 +- `CaseRecord`:一个检查全部通过的病例:三位病例号、视频号、临床一路的 22 列、超声描述、MR 描述、关键图路径、三张表的诊断编码。 +- `DiagnosisCodes`:一例在临床表、超声表、MR 表里各自的诊断编码(C10 保证三者相等)。 +- `SkipRecord`:一条跳过记录:病例号(认不出编号时是「文件 第 N 行」或「病例目录 名字」)、检查编号、检查说明、涉及的文件和列、涉及值的 JSON 字符串。 +- `DeliveryError`:整张表读不了(T1–T4)时抛出,消息以检查编号开头,整次构建停下。 +- `CODED_COLUMNS`:用数字编码、需要映射表才知道含义的 17 项列名,映射表必须恰好覆盖它们。 +- `CodeMaps`:映射表的类型,「列名 → {数字 → 含义}」。 +- `SKIPPED_NOTE`:跳过清单的说明句(每例只记第一项没过的检查),写进答案库清单并在终端打印。 +- `review_sentences(text)`:报告文字里含「考虑」「可能」「倾向」「提示」的句子,每句一条(命中的词,句子),用来生成复核清单。 + +### library(`src/cadx/library/`) + +- `write_library(build_dir, build_id, git_commit, git_clean, config_sha256, codes_decoded, uncompared, ceus_source, cases)`:把一次构建的各例三路内容写进一个已存在的空目录,关键图去掉附加数据重新编码,最后写 `manifest.json`。 +- `LibraryCase`:要写进资料库的一例:化名、临床一路的字段、超声描述、关键图源文件、MR 描述。 +- `list_aliases(build_dir)`:一次构建的全部化名(按化名排序)。 +- `read_clinical(build_dir, alias)`、`read_ultrasound(build_dir, alias)`、`read_mr(build_dir, alias)`:按路读出一例,读时核对 SHA-256,拒绝 `.partial` 目录。 +- `ClinicalRoute`:临床一路:是否已解码、22 列字段。 +- `UltrasoundRoute`:超声一路:报告「所见」原文、关键图路径。 +- `MrRoute`:MR 一路:报告「所见」原文。 +- `scan_for_leaks(build_dir, forbidden_strings, forbidden_keys)`:在构建目录的全部 `.txt`、`.json` 里找禁止的字符串(NFKC 归一、不分大小写、按子串)和禁止的 JSON 键名,返回命中列表。 +- `LeakHit`:一处命中:文件(相对构建目录)、类别(「字符串」或「键名」)、命中的词。 + +### answers(`src/cadx/answers/`) + +- `write_answers(answers_dir, build_id, skipped_note, aliases, diagnoses, skipped, reviews)`:写 `aliases.csv`、`diagnoses.csv`、`skipped.csv`、`review.csv`,再给目录里全部文件(含 `snapshot/`)算 SHA-256 写进 `manifest.json`。 +- `AliasRow`:化名、三位病例号、视频号。 +- `DiagnosisRow`:化名、病例号、三张表的诊断编码、诊断名(不解码时为 None)。 +- `SkippedRow`:跳过清单的一行,字段同 `SkipRecord`。 +- `ReviewRow`:复核清单的一行:化名、路(「超声」或「MR」)、命中的词、所在句子。 +- `read_aliases(answers_dir)`、`read_diagnoses(answers_dir)`:评价用的读取,读时核对 SHA-256 和表头,拒绝 `.partial` 目录。 + +### runs(`src/cadx/runs/`,目前只有快照部分) + +- `read_git_state(repo_root)`:读出完整和短提交号、工作区是否干净(含未跟踪文件)、`git diff HEAD` 输出的 SHA-256。 +- `GitState`:上面这四项。 +- `make_build_id(now, git)`:构建号 `YYYYMMDD-HHMMSS-`,工作区不干净时末尾加 `-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 位化名,同种子同病例得到同样的化名。 diff --git a/research-wiki/guides/装配两个库.md b/research-wiki/guides/装配两个库.md new file mode 100644 index 0000000..cd40a71 --- /dev/null +++ b/research-wiki/guides/装配两个库.md @@ -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-`,时间是本地时间(代码在 `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`「两档标准」:运行产物只追加)。 diff --git a/scripts/.gitkeep b/scripts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..e0b8770 --- /dev/null +++ b/scripts/build.py @@ -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() diff --git a/scripts/scan_library.py b/scripts/scan_library.py new file mode 100644 index 0000000..618ff75 --- /dev/null +++ b/scripts/scan_library.py @@ -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() diff --git a/src/cadx/answers/__init__.py b/src/cadx/answers/__init__.py new file mode 100644 index 0000000..04d3d6c --- /dev/null +++ b/src/cadx/answers/__init__.py @@ -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", +] diff --git a/src/cadx/answers/_files.py b/src/cadx/answers/_files.py new file mode 100644 index 0000000..95c2651 --- /dev/null +++ b/src/cadx/answers/_files.py @@ -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 + ) diff --git a/src/cadx/build/__init__.py b/src/cadx/build/__init__.py new file mode 100644 index 0000000..4d0f581 --- /dev/null +++ b/src/cadx/build/__init__.py @@ -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", +] diff --git a/src/cadx/build/_assemble.py b/src/cadx/build/_assemble.py new file mode 100644 index 0000000..d95dac7 --- /dev/null +++ b/src/cadx/build/_assemble.py @@ -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, + ) diff --git a/src/cadx/build/_config.py b/src/cadx/build/_config.py new file mode 100644 index 0000000..8feab47 --- /dev/null +++ b/src/cadx/build/_config.py @@ -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 diff --git a/src/cadx/delivery/__init__.py b/src/cadx/delivery/__init__.py new file mode 100644 index 0000000..a97509c --- /dev/null +++ b/src/cadx/delivery/__init__.py @@ -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", +] diff --git a/src/cadx/delivery/_cases.py b/src/cadx/delivery/_cases.py new file mode 100644 index 0000000..065a89e --- /dev/null +++ b/src/cadx/delivery/_cases.py @@ -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,), + ) diff --git a/src/cadx/delivery/_shape.py b/src/cadx/delivery/_shape.py new file mode 100644 index 0000000..c018d25 --- /dev/null +++ b/src/cadx/delivery/_shape.py @@ -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}" diff --git a/src/cadx/delivery/_tables.py b/src/cadx/delivery/_tables.py new file mode 100644 index 0000000..ede257b --- /dev/null +++ b/src/cadx/delivery/_tables.py @@ -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) diff --git a/src/cadx/delivery/_text.py b/src/cadx/delivery/_text.py new file mode 100644 index 0000000..3b69a7d --- /dev/null +++ b/src/cadx/delivery/_text.py @@ -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 diff --git a/src/cadx/library/__init__.py b/src/cadx/library/__init__.py new file mode 100644 index 0000000..1d796f8 --- /dev/null +++ b/src/cadx/library/__init__.py @@ -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", +] diff --git a/src/cadx/library/_read.py b/src/cadx/library/_read.py new file mode 100644 index 0000000..f594a31 --- /dev/null +++ b/src/cadx/library/_read.py @@ -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))) diff --git a/src/cadx/library/_scan.py b/src/cadx/library/_scan.py new file mode 100644 index 0000000..a746f18 --- /dev/null +++ b/src/cadx/library/_scan.py @@ -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 diff --git a/src/cadx/library/_write.py b/src/cadx/library/_write.py new file mode 100644 index 0000000..b6420b8 --- /dev/null +++ b/src/cadx/library/_write.py @@ -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())), + }, + ) diff --git a/src/cadx/runs/__init__.py b/src/cadx/runs/__init__.py new file mode 100644 index 0000000..ca68e75 --- /dev/null +++ b/src/cadx/runs/__init__.py @@ -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"] diff --git a/src/cadx/runs/_snapshot.py b/src/cadx/runs/_snapshot.py new file mode 100644 index 0000000..6a37e0c --- /dev/null +++ b/src/cadx/runs/_snapshot.py @@ -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-`,工作区不干净时末尾加 `-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" + ) diff --git a/tests/fake_delivery.py b/tests/fake_delivery.py new file mode 100644 index 0000000..9a33d99 --- /dev/null +++ b/tests/fake_delivery.py @@ -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) diff --git a/tests/test_build_outputs.py b/tests/test_build_outputs.py new file mode 100644 index 0000000..3eee3a8 --- /dev/null +++ b/tests/test_build_outputs.py @@ -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"]) diff --git a/tests/test_build_stops.py b/tests/test_build_stops.py new file mode 100644 index 0000000..4eea030 --- /dev/null +++ b/tests/test_build_stops.py @@ -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) diff --git a/tests/test_delivery_cases.py b/tests/test_delivery_cases.py new file mode 100644 index 0000000..8652e2b --- /dev/null +++ b/tests/test_delivery_cases.py @@ -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}' diff --git a/uv.lock b/uv.lock index d1f885d..5c1cf1e 100644 --- a/uv.lock +++ b/uv.lock @@ -2,10 +2,25 @@ version = 1 revision = 2 requires-python = ">=3.12" +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "cadx" version = "0.0.0" source = { editable = "." } +dependencies = [ + { name = "openpyxl" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "pyyaml" }, +] [package.dev-dependencies] dev = [ @@ -14,9 +29,17 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "ruff" }, + { name = "types-openpyxl" }, + { name = "types-pyyaml" }, ] [package.metadata] +requires-dist = [ + { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "pillow", specifier = ">=12.3.0" }, + { name = "pydantic", specifier = ">=2.14.1" }, + { name = "pyyaml", specifier = ">=6.0.3" }, +] [package.metadata.requires-dev] dev = [ @@ -25,6 +48,8 @@ dev = [ { name = "pyright", specifier = ">=1.1.380" }, { name = "pytest", specifier = ">=8.3" }, { name = "ruff", specifier = ">=0.6" }, + { name = "types-openpyxl", specifier = ">=3.1.5.20260827" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20260906" }, ] [[package]] @@ -63,6 +88,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "filelock" version = "4.2.0" @@ -215,6 +249,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -224,6 +270,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "platformdirs" version = "4.13.3" @@ -258,6 +375,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/83/ce0a196e4f7b20b11ce3aa1fc72fb973f64b7628d51c1b8aebad37fb7f81/pre_commit-4.7.0-py2.py3-none-any.whl", hash = "sha256:2e229038ad3656081b70c27913157e4b636ef306f643f024a8a8e3f0f7b2f877", size = 226261, upload-time = "2026-10-12T20:45:38.858Z" }, ] +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -392,6 +630,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, ] +[[package]] +name = "types-openpyxl" +version = "3.1.5.20260827" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/6b/ce650ce7754a2bce3ca1dfbce7f7441df092e2dc6047d00e04f840c2b56e/types_openpyxl-3.1.5.20260827.tar.gz", hash = "sha256:be8b605fb99cfd7d5f5576d4a508e8ec44be2dd15b85157c559080de6384be34", size = 101985, upload-time = "2026-08-27T12:06:18.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/23/9708c0895d237205ab2b31c06f97d72294f69789ff9923ff7f4aa2126d6b/types_openpyxl-3.1.5.20260827-py3-none-any.whl", hash = "sha256:94e176d871d12e3cbc34f8fb03dc14db2a4245a6690791daf16fc7b08fd67869", size = 165885, upload-time = "2026-08-27T12:06:17.88Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -401,6 +657,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "virtualenv" version = "21.14.8"