feat(review): 医生核对包入口;CEUS 运行目录的读取收进 runs

- 新模块 review(组织层,只依赖 library、answers、runs):把一次构建和它装入的
  CEUS 运行整理成医生可读的文件夹(CSV 带 BOM、说明与报告转 PDF、图片视频逐字节拷贝),
  入口 scripts/review_pack.py + config/review_pack.yaml。任务书 03。
- 读 CEUS 运行目录(failed.csv、结果目录、关键帧文件)从 build 挪进 runs,build 与
  review 都经它读;failed.csv 读取在边界上收严(步号须为整数、视频号不重复、列数固定)。
- library、answers 补只读函数;import-linter 增 review 契约。
- 文档:guides/医生核对包.md(过一轮硕士生阅读),结构.md 第三至八部分同步。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 06:04:19 -04:00
parent 14d6f8e153
commit c7373efde2
23 changed files with 1718 additions and 83 deletions
+458
View File
@@ -0,0 +1,458 @@
"""医生核对包:目录结构、CSV 规格、问题清单的每种来源、逐字节拷贝、TIC结果、没装 CEUS 的构建、PDF。
预期值照任务书 03 写:目录和文件名、列名、问题清单的「环节」与「问题」原文、关键帧文件名的秒数
(`video_time_s` 保留一位小数)都按任务书的文字手写,不从实现里推。
假数据(6 例):
- P001:进库;定位到 D1、D2、F1,其余 4 个槽位未取到;病灶去暗区曲线拟合失败
(parameter_on_boundary);紫圈不可用。
- P002:CEUS 第 2 步失败(C15 跳过)。P003:CEUS 运行里没有结果目录(C15 跳过)。
- P004:进库;7 个槽位都取到,D2、F1 的时间改成 5.04、12.46 秒(测一位小数);拟合都成功;紫圈可用。
- P005:MR 表诊断与另两张表不同(C10 跳过);CEUS 运行里却有它完整的结果目录(被跳过但定量成功)。
- P006:进库;7 个槽位都取到;病灶整体曲线拟合失败(credible_peak_not_found);紫圈可用。
"""
import csv
import hashlib
import json
import re
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
import pytest
import yaml
from cadx.answers import read_aliases
from cadx.build import BuildSummary, run_build
from cadx.library import read_ultrasound
from cadx.review import run_review_pack
from cadx.review._pdf import markdown_to_pdf, report_markdown
from fake_ceus_run import UNLOCATED_REASON, summary, write_run
from fake_delivery import DIAGNOSIS_COLUMN, REPO_ROOT, make_delivery, write_config
BUILD_NOW = datetime(2026, 10, 17, 9, 0, 0).astimezone()
PACK_NOW = datetime(2026, 10, 17, 10, 0, 0).astimezone()
RUN_ID = "20261017-080000-abcdef0"
ALL_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
SUMMARY_HEADER = [
"病例号",
"视频号",
"化名",
"是否进入资料库",
"没进的原因",
"CEUS 定量是否成功",
"CEUS 失败在第几步",
*ALL_SLOTS,
]
PROBLEM_HEADER = ["病例号", "视频号", "环节", "问题"]
ANSWER_HEADERS = {
"化名对照.csv": ["化名", "病例号", "视频号"],
"诊断.csv": ["化名", "病例号", "临床表诊断编码", "超声表诊断编码", "MR表诊断编码", "诊断名"],
"跳过病例.csv": ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"],
"报告复核句.csv": ["化名", "路", "命中的词", "所在句子"],
}
CASE_FILES = {"临床信息.csv", "超声报告.pdf", "MR报告.pdf", "关键图.png"}
CEUS_FILES = {"关键帧", "TIC曲线.png", "TIC结果.csv", "回放视频.mp4"}
def _rewrite_summary(run: Path, video_id: str, located: tuple[str, ...], edit: Any) -> None:
content = summary(video_id, located)
edit(content)
(run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8")
def _fail_fit(curve: str, code: str) -> Any:
def edit(content: dict[str, Any]) -> None:
content["fits"][curve].update(success=False, failure_reason=code)
return edit
def _p004(content: dict[str, Any]) -> None:
content["reference_valid"] = True
content["anchor_frames"]["D2"]["frame_time_video_s"] = 5.04
content["anchor_frames"]["F1"]["frame_time_video_s"] = 12.46
def _p006(content: dict[str, Any]) -> None:
_fail_fit("roi_all", "credible_peak_not_found")(content)
content["reference_valid"] = True
def _build_with_ceus(root: Path) -> tuple[BuildSummary, Path]:
delivery = make_delivery(6)
delivery.case(5).mr[DIAGNOSIS_COLUMN] = 2
delivery_dir = delivery.write(root / "delivery")
run = write_run(
root / "runs" / RUN_ID,
delivery_dir,
{"U001": ("D1", "D2", "F1"), "U004": ALL_SLOTS, "U005": ALL_SLOTS, "U006": ALL_SLOTS},
[("U002", 2, "两半都没有绿框")],
)
_rewrite_summary(
run, "U001", ("D1", "D2", "F1"), _fail_fit("roi_perf", "parameter_on_boundary")
)
_rewrite_summary(run, "U004", ALL_SLOTS, _p004)
_rewrite_summary(run, "U006", ALL_SLOTS, _p006)
return run_build(write_config(root, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW), run
def _pack_config(root: Path, library_dir: Path, answers_dir: Path) -> Path:
config = {
"library_dir": str(library_dir),
"answers_dir": str(answers_dir),
"output_root": str(root / "review_pack"),
}
path = root / "review_pack.yaml"
path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8")
return path
def _tree_hashes(folder: Path) -> dict[str, str]:
return {
p.relative_to(folder).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(folder.rglob("*"))
if p.is_file()
}
class Packed:
"""构建一次、出一次包,各条测试共用。"""
def __init__(self, root: Path) -> None:
self.build, self.run = _build_with_ceus(root)
self.library_before = _tree_hashes(self.build.library_dir)
self.answers_before = _tree_hashes(self.build.answers_dir)
config = _pack_config(root, self.build.library_dir, self.build.answers_dir)
self.config = config
self.result = run_review_pack(config, REPO_ROOT, PACK_NOW)
self.pack = self.result.pack_dir
self.alias = {r.case_no: r.alias for r in read_aliases(self.build.answers_dir)}
@pytest.fixture(scope="module")
def packed(tmp_path_factory: pytest.TempPathFactory) -> Packed:
return Packed(tmp_path_factory.mktemp("review"))
def _csv(path: Path) -> list[list[str]]:
raw = path.read_bytes()
assert raw.startswith(b"\xef\xbb\xbf"), path
with path.open(encoding="utf-8-sig", newline="") as handle:
return list(csv.reader(handle))
def test_directory_layout(packed: Packed) -> None:
pack = packed.pack
assert pack.name == f"医生核对包_{packed.build.build_id}"
assert sorted(p.name for p in packed.result.run_dir.iterdir()) == sorted(
["snapshot", pack.name]
)
assert {p.name for p in pack.iterdir()} == {
"说明.pdf",
"问题清单.csv",
"病例总表.csv",
"标准答案",
"病例",
}
assert {p.name for p in (pack / "标准答案").iterdir()} == set(ANSWER_HEADERS)
assert {p.name for p in (pack / "病例").iterdir()} == {"P001", "P004", "P006"}
for case in ("P001", "P004", "P006"):
assert {p.name for p in (pack / "病例" / case).iterdir()} == CASE_FILES | CEUS_FILES
assert {p.name for p in (pack / "病例" / "P001" / "关键帧").iterdir()} == {
"D1_第2.5秒.png",
"D2_第5.0秒.png",
"F1_第12.5秒.png",
}
assert {p.name for p in (pack / "病例" / "P004" / "关键帧").iterdir()} == {
"B0_第0.0秒.png",
"D1_第2.5秒.png",
"D2_第5.0秒.png",
"D3_第7.5秒.png",
"D4_第10.0秒.png",
"F1_第12.5秒.png",
"F2_第15.0秒.png",
}
def test_every_csv_has_bom_and_no_placeholder_for_missing(packed: Packed) -> None:
csvs = sorted(packed.pack.rglob("*.csv"))
assert len(csvs) == 2 + 4 + 3 * 2
for path in csvs:
for row in _csv(path):
assert not {"null", "None", "NaN", "nan"} & set(row), path
assert _csv(packed.pack / "病例总表.csv")[0] == SUMMARY_HEADER
assert _csv(packed.pack / "问题清单.csv")[0] == PROBLEM_HEADER
for name, header in ANSWER_HEADERS.items():
assert _csv(packed.pack / "标准答案" / name)[0] == header
clinical = _csv(packed.pack / "病例" / "P001" / "临床信息.csv")
assert len(clinical) == 2 and "Case_ID" not in clinical[0] and "Diagnosis" not in clinical[0]
assert clinical[0][:3] == ["Sex", "Age", "Symptom_Pain"]
# 假交付包里没测的化验是 None:留空。
assert clinical[1][clinical[0].index("Amylase")] == ""
def test_case_summary(packed: Packed) -> None:
rows = _csv(packed.pack / "病例总表.csv")[1:]
blank = [""] * 7
a = packed.alias
assert rows == [
[
"P001",
"U001",
a["001"],
"是",
"",
"是",
"",
"未取到",
"2.5",
"5.0",
"未取到",
"未取到",
"12.5",
"未取到",
],
["P002", "U002", "", "否", "C15 CEUS 定量结果齐全", "否", "2", *blank],
["P003", "U003", "", "否", "C15 CEUS 定量结果齐全", "", "", *blank],
[
"P004",
"U004",
a["004"],
"是",
"",
"是",
"",
"0.0",
"2.5",
"5.04",
"7.5",
"10.0",
"12.46",
"15.0",
],
["P005", "U005", "", "否", "C10 跨表一致(诊断、年龄、性别)", "是", "", *blank],
[
"P006",
"U006",
a["006"],
"是",
"",
"是",
"",
"0.0",
"2.5",
"5.0",
"7.5",
"10.0",
"12.5",
"15.0",
],
]
def test_problem_list_has_every_source(packed: Packed) -> None:
rows = _csv(packed.pack / "问题清单.csv")[1:]
skipped = {row[0]: row for row in _csv(packed.pack / "标准答案" / "跳过病例.csv")[1:]}
unlocated = [
["P001", "U001", "关键帧", f"{slot} 未取到:{UNLOCATED_REASON}"]
for slot in ("B0", "D3", "D4", "F2")
]
assert rows == [
["P001", "U001", "TIC 拟合", "病灶去暗区曲线未完成拟合(parameter_on_boundary)"],
*unlocated,
["P001", "U001", "紫圈", "紫圈曲线不可用"],
[
"P002",
"U002",
"装配资料库",
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/failed.csv;"
'涉及值:"第 2 步失败:两半都没有绿框"',
],
["P002", "U002", "CEUS 第 2 步", "第 2 步失败:两半都没有绿框"],
[
"P003",
"U003",
"装配资料库",
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/U003;"
'涉及值:"CEUS 运行目录里没有这一例的结果目录"',
],
[
"P005",
"U005",
"装配资料库",
f"C10 跨表一致(诊断、年龄、性别);文件:{skipped['005'][3]};"
f"列:{skipped['005'][4]};涉及值:{skipped['005'][5]}",
],
["P006", "U006", "TIC 拟合", "病灶整体曲线未完成拟合(credible_peak_not_found)"],
]
counts = {"装配资料库": 3, "CEUS 第 2 步": 1, "TIC 拟合": 2, "关键帧": 4, "紫圈": 1}
assert packed.result.problems_by_stage == dict(sorted(counts.items()))
def test_copies_are_byte_identical(packed: Packed) -> None:
for case_no, video_id in (("001", "U001"), ("004", "U004")):
case_dir = packed.pack / "病例" / f"P{case_no}"
route = read_ultrasound(packed.build.library_dir, packed.alias[case_no])
assert (case_dir / "关键图.png").read_bytes() == route.key_image.read_bytes()
assert route.tic is not None
copied = {p.name.split("_")[0]: p for p in (case_dir / "关键帧").iterdir()}
assert sorted(copied) == sorted(route.keyframes)
for slot, source in route.keyframes.items():
assert copied[slot].read_bytes() == source.read_bytes()
seconds = route.tic["keyframes"][slot]["video_time_s"]
assert copied[slot].name == f"{slot}_第{round(seconds, 1)}秒.png"
run_case = packed.run / video_id
assert (case_dir / "TIC曲线.png").read_bytes() == (run_case / "tic.png").read_bytes()
assert (case_dir / "回放视频.mp4").read_bytes() == (run_case / "overlay.mp4").read_bytes()
answers = packed.build.answers_dir
for source, name in (
("aliases.csv", "化名对照.csv"),
("diagnoses.csv", "诊断.csv"),
("skipped.csv", "跳过病例.csv"),
("review.csv", "报告复核句.csv"),
):
assert (packed.pack / "标准答案" / name).read_bytes() == (answers / source).read_bytes()
def _tool_output(tool: str, *args: str) -> str:
path = shutil.which(tool)
assert path is not None, f"找不到 {tool}"
return subprocess.run([path, *args], check=True, capture_output=True, text=True).stdout # noqa: S603
def _expected_cell(value: object) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
return value if isinstance(value, str) else repr(value)
def test_tic_results_match_tic_json(packed: Packed) -> None:
for case_no in ("001", "004", "006"):
tic = read_ultrasound(packed.build.library_dir, packed.alias[case_no]).tic
assert tic is not None
rows = _csv(packed.pack / "病例" / f"P{case_no}" / "TIC结果.csv")
assert rows[0] == ["项目", "值"]
# 4 条曲线 × 2 + 病灶 2 条 ×(3 个口径 + 11 个指标 + 3 个辅助时刻)+ 紫圈 1 + 7 槽位 × 3。
assert len(rows) - 1 == 8 + 34 + 1 + 21
for item, value in rows[1:]:
path = item[item.rindex("(") + 1 : -1]
node: Any = tic
for key in path.split("."):
node = None if node is None else node[key]
assert value == _expected_cell(node), (case_no, item)
rows = dict(_csv(packed.pack / "病例" / "P001" / "TIC结果.csv")[1:])
assert rows["病灶整体曲线 拟合成功(fits.roi_all.success)"] == "true"
assert rows["病灶去暗区曲线 拟合成功(fits.roi_perf.success)"] == "false"
assert rows["紫圈曲线可用(pan_curve_usable)"] == "false"
assert rows["关键帧 D4 未取到的原因(keyframes.D4.reason)"] == UNLOCATED_REASON
def test_pdfs_use_noto_cjk_and_carry_ids(packed: Packed) -> None:
pdfs = sorted(packed.pack.rglob("*.pdf"))
assert len(pdfs) == packed.result.pdfs == 1 + 3 * 2
for pdf in pdfs:
assert pdf.read_bytes().startswith(b"%PDF") and pdf.stat().st_size > 1000
readme = packed.pack / "说明.pdf"
fonts = _tool_output("pdffonts", str(readme))
assert "NotoSerifCJK" in fonts and "NotoSansCJK" in fonts
text = _tool_output("pdftotext", str(readme), "-")
assert packed.build.build_id in text and RUN_ID in text and "parameter_on_boundary" in text
report = _tool_output("pdftotext", str(packed.pack / "病例" / "P001" / "超声报告.pdf"), "-")
assert report.startswith("病例号 P001 超声描述")
def test_snapshot_and_no_writes_to_libraries(packed: Packed) -> None:
snapshot = packed.result.run_dir / "snapshot"
assert (snapshot / "review_pack.yaml").read_bytes() == packed.config.read_bytes()
record = json.loads((snapshot / "review.json").read_text(encoding="utf-8"))
assert record["build_id"] == packed.build.build_id and record["ceus_run_id"] == RUN_ID
assert record["pandoc"].startswith("pandoc ") and "LibreOffice" in record["soffice"]
assert (snapshot / "finished.json").is_file()
assert _tree_hashes(packed.build.library_dir) == packed.library_before
assert _tree_hashes(packed.build.answers_dir) == packed.answers_before
def test_existing_run_dir_stops(packed: Packed) -> None:
with pytest.raises(FileExistsError):
run_review_pack(packed.config, REPO_ROOT, PACK_NOW)
def test_build_without_ceus(tmp_path: Path) -> None:
delivery_dir = make_delivery(2).write(tmp_path / "delivery")
build = run_build(write_config(tmp_path, delivery_dir, 1, None, None), REPO_ROOT, BUILD_NOW)
result = run_review_pack(
_pack_config(tmp_path, build.library_dir, build.answers_dir), REPO_ROOT, PACK_NOW
)
for case in ("P001", "P002"):
assert {p.name for p in (result.pack_dir / "病例" / case).iterdir()} == CASE_FILES
alias = {r.case_no: r.alias for r in read_aliases(build.answers_dir)}
rows = _csv(result.pack_dir / "病例总表.csv")[1:]
assert [row[:5] for row in rows] == [
["P001", "U001", alias["001"], "是", ""],
["P002", "U002", alias["002"], "是", ""],
]
assert all(row[5:] == [""] * 9 for row in rows)
assert _csv(result.pack_dir / "问题清单.csv") == [PROBLEM_HEADER]
assert result.problems_by_stage == {} and result.video_bytes == 0
def test_build_ids_differ_stops(tmp_path: Path) -> None:
config = _pack_config(tmp_path, tmp_path / "library" / "a", tmp_path / "answers" / "b")
with pytest.raises(ValueError, match="不是同一次构建"):
run_review_pack(config, REPO_ROOT, PACK_NOW)
assert not (tmp_path / "review_pack").exists()
def test_report_pdf_keeps_markdown_characters(tmp_path: Path) -> None:
"""报告正文里的 Markdown 记号、引号、连字符、换行照抄,不被 pandoc 当成格式。
行首空格抽回文字时只剩一个(pdftotext 按版面估空格),所以每行比较去掉首尾空白后的内容。
"""
text = (
'1. 囊壁*增厚* #2 a_b_c <b>x</b> [链接](y) -- "引号" ... `码` | 竖线\n 第二行行首两个空格'
)
target = tmp_path / "报告.pdf"
markdown_to_pdf({target: report_markdown("病例号 P001 超声描述", text)})
lines = _tool_output("pdftotext", "-layout", str(target), "-").splitlines()
assert [line.strip() for line in lines if line.strip()][1:] == [
line.strip() for line in text.splitlines()
]
def _gap_after_ab(pdf: Path) -> float:
"""pdftotext -bbox 里词「ab」右边界到下一个词左边界的距离(点)。"""
boxes = re.findall(
r'xMin="([0-9.]+)"[^>]*xMax="([0-9.]+)"[^>]*>([^<]*)</word>',
_tool_output("pdftotext", "-bbox", str(pdf), "-"),
)
words = [(float(x0), float(x1), w) for x0, x1, w in boxes]
index = next(i for i, (_, _, w) in enumerate(words) if w == "ab")
return words[index + 1][0] - words[index][1]
def test_report_pdf_keeps_repeated_spaces(tmp_path: Path) -> None:
"""一行里连续三个空格不被 pandoc 并成一个。
pdftotext 抽文字时按间距估空格,三个空格也只吐出一个,所以量版面:「ab」与「cd」之间的间距是
单个空格那份 PDF 的约三倍。
"""
one, three = tmp_path / "一个空格.pdf", tmp_path / "三个空格.pdf"
markdown_to_pdf(
{
one: report_markdown("病例号 P001 超声描述", "ab cd"),
three: report_markdown("病例号 P001 超声描述", "ab cd"),
}
)
assert _gap_after_ab(three) / _gap_after_ab(one) == pytest.approx(3.0, abs=0.3)
def test_report_with_tab_stops() -> None:
with pytest.raises(ValueError, match="病例号 P001 超声描述:报告原文里有 Tab"):
report_markdown("病例号 P001 超声描述", "囊壁厚\t约2mm")