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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 03:00:20 -04:00
parent bcd5932a66
commit bceb01e8fa
30 changed files with 3600 additions and 18 deletions
View File
+37
View File
@@ -0,0 +1,37 @@
"""装配两个库:读交付包,写出一对同号的资料库与答案库。
用法:uv run python scripts/build.py --config config/build.yaml
终端只打印计数,不打印病例号和资料内容。
"""
import argparse
from datetime import datetime
from pathlib import Path
from cadx.build import run_build
REPO_ROOT = Path(__file__).resolve().parents[1]
def main() -> None:
parser = argparse.ArgumentParser(description="装配给专家的资料库和标准答案库")
parser.add_argument("--config", type=Path, required=True, help="build.yaml 的路径")
config: Path = parser.parse_args().config
summary = run_build(config, REPO_ROOT, datetime.now().astimezone())
print(f"构建号:{summary.build_id}")
print(f"资料库:{summary.library_dir}")
print(f"答案库:{summary.answers_dir}")
print(f"病例总数:{summary.total_cases}")
print(f"进库病例:{summary.passed_cases}")
print(f"跳过病例:{summary.skipped_cases}")
print(f"认不出编号的行:{summary.unrecognized_rows}")
by_check = ",".join(f"{check} {n}" for check, n in summary.skipped_by_check.items())
print(f"按检查编号分的跳过数:{by_check or '无'}")
print(f"复核清单条数:{summary.review_rows}")
print(f"跳过清单说明:{summary.skipped_note}")
if __name__ == "__main__":
main()
+88
View File
@@ -0,0 +1,88 @@
"""泄露扫描:查一次构建的资料库里有没有真实病例号、交付文件名、亚型名、诊断列名。
用法:uv run python scripts/scan_library.py --library library/<构建号> --answers answers/<构建号>
病例号和视频号从答案库读;亚型名、列名、文件名的词表在本脚本里独立写,不取自 delivery。
只输出命中的条数和类别,不输出命中的内容。这个脚本同时读两个库,只能放在 scripts/。
"""
import argparse
from collections import Counter
from pathlib import Path
from cadx.answers import read_aliases
from cadx.library import scan_for_leaks
SUBTYPE_NAMES = (
"IPMN",
"IPMT",
"SPN",
"SPT",
"SPTP",
"MCN",
"SCN",
"导管内乳头状黏液性肿瘤",
"导管内乳头状",
"导管内乳头",
"实性假乳头状",
"实性假乳头",
"黏液性囊性肿瘤",
"黏液性囊",
"粘液性囊",
"黏液囊性",
"粘液囊性",
"浆液性囊性肿瘤",
"浆液性囊",
"浆液性微囊",
"囊腺瘤",
)
COLUMN_NAMES = (
"Case_ID",
"Diagnosis",
"病理类型",
"病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)",
"患者ID",
"CEUS视频ID",
"推注开始时间",
)
DELIVERY_FILE_NAMES = (
"临床工作簿_40例.xlsx",
"超声报告文本_40例.xlsx",
"MR报告文本_40例.xlsx",
"Clinical_agent",
"CEUS_agent",
"MR_agent",
)
FORBIDDEN_KEYS = ("diagnosis", "label", "gold", "pathology", "name", "row")
def main() -> None:
parser = argparse.ArgumentParser(description="扫描资料库里的泄露")
parser.add_argument("--library", type=Path, required=True, help="资料库的构建目录")
parser.add_argument("--answers", type=Path, required=True, help="同一构建号的答案库目录")
args = parser.parse_args()
library: Path = args.library
answers: Path = args.answers
if library.name != answers.name:
raise SystemExit(f"两个目录的构建号不同:{library.name} 与 {answers.name}")
categories: dict[str, str] = {}
for row in read_aliases(answers):
categories[f"P{row.case_no}"] = "病例号"
categories[row.video_id] = "视频号"
for suffix in ("_key.png", "_roi2.mp4", ".mp4", "_roi2_report.xlsx", "_report.xlsx"):
categories[f"{row.video_id}{suffix}"] = "交付文件名"
categories.update(dict.fromkeys(DELIVERY_FILE_NAMES, "交付文件名"))
categories.update(dict.fromkeys(SUBTYPE_NAMES, "亚型名"))
categories.update(dict.fromkeys(COLUMN_NAMES, "列名"))
hits = scan_for_leaks(library, list(categories), FORBIDDEN_KEYS)
counts = Counter("键名" if hit.kind == "键名" else categories[hit.term] for hit in hits)
print(f"扫描的病例数:{len(read_aliases(answers))}")
print(f"禁止的字符串:{len(categories)} 个;禁止的键名:{len(FORBIDDEN_KEYS)} 个")
print(f"命中总数:{len(hits)}")
for category in ("病例号", "视频号", "交付文件名", "亚型名", "列名", "键名"):
print(f" {category}:{counts[category]}")
if __name__ == "__main__":
main()