Files
CADx-4/tests/test_build_outputs.py
T
iomgaa 7e7f32a13d feat(review): 核对包改成医生读得懂;资料库超声一路只留给 agent 的参数
- ceus:灌注指标改为对病灶去暗区(roi_perf)和紫圈(roi_pan)两组算,roi_all 指标删除;
  D1–D4 仍要 roi_all、roi_perf 两条都过才定位(负责人 2026-10-17:医生要正常区与病灶对比看)
- build/library:tic.json 只出两组 11 个指标与 7 个槽位的视频时间;医生截图挪进
  ultrasound/keyframes/key.png,UltrasoundRoute 去掉 key_image、keyframes 多一个 key
- review:TIC参数.csv 取代 TIC结果.csv;问题清单按原因对照表写成大白话,对照表外的原因报错;
  程序原文进 snapshot/问题清单_原文.csv;说明.pdf 整份重写为合作者口吻
- 文档:结构.md、医生核对包、装配两个库、CEUS定量

任务书 04。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:22:15 -04:00

392 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""构建产物:可见性、泄露扫描、化名、解码开关、关键图、清单与读取、不覆盖、答案库内容。"""
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 (
LibraryCase,
list_aliases,
read_clinical,
read_mr,
read_ultrasound,
scan_for_leaks,
write_library,
)
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/keyframes/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).keyframes["key"]
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, None), REPO_ROOT, NOW)
def _assert_key_image_skipped(tmp_path: Path, reason: str) -> None:
"""002 这一例记 C14 跳过、涉及值是原因;001 照常进库。"""
(library_dir,) = list((tmp_path / "library").iterdir())
answers_dir = tmp_path / "answers" / library_dir.name
assert [r.case_no for r in read_aliases(answers_dir)] == ["001"]
assert len(list_aliases(library_dir)) == 1
skipped = _csv_rows(answers_dir / "skipped.csv")
assert [row[:4] for row in skipped[1:]] == [
["002", "C14", "关键图能原样重编码", "CEUS_agent/002/U002_key.png"]
]
assert json.loads(skipped[1][5]) == reason
def test_key_image_16_bit_source_skips_case(tmp_path: Path) -> None:
_build_with_key_image(tmp_path, Image.new("I;16", (4, 4)))
_assert_key_image_skipped(tmp_path, "位深 16、颜色类型 0 不能逐像素保真重编码")
def test_key_image_with_transparency_skips_case(tmp_path: Path) -> None:
image = Image.new("RGB", (4, 4))
image.info["transparency"] = (0, 0, 0)
_build_with_key_image(tmp_path, image)
_assert_key_image_skipped(tmp_path, "带 tRNS 透明色,重编码会丢透明")
def test_key_image_palette_source_skips_case(tmp_path: Path) -> None:
_build_with_key_image(tmp_path, Image.new("P", (4, 4)))
# 只有一种颜色的调色板图,Pillow 存成 1 位。
_assert_key_image_skipped(tmp_path, "位深 1、颜色类型 3 不能逐像素保真重编码")
def test_key_image_not_png_skips_case(tmp_path: Path) -> None:
root = make_delivery(2).write(tmp_path / "delivery")
Image.new("RGB", (4, 4)).save(root / "CEUS_agent" / "002" / "U002_key.png", format="JPEG")
run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
_assert_key_image_skipped(tmp_path, "不是 PNG")
def test_write_library_rejects_unchecked_key_image(tmp_path: Path) -> None:
"""build 漏了 C14 检查时,library 写图那一步报错停下。"""
source = tmp_path / "key.png"
Image.new("P", (4, 4)).save(source, format="PNG")
target = tmp_path / "out"
target.mkdir()
case = LibraryCase(
alias="abcd",
clinical_fields={},
ultrasound_report="",
key_image_source=source,
mr_report="",
ceus=None,
)
with pytest.raises(ValueError, match="check_key_image"):
write_library(target, "b", "c", True, {}, False, (), None, [case])
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"]).keyframes["key"]
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/finished.json",
"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"])