9d82210f31
负责人 2026-10-17:跳过病例.csv 不放进医生目录,只留在 snapshot/,病例总表和问题清单已用大白话说了同样的内容;review 行数超预期一倍,原因对照表本来就该这么长,接受。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
692 lines
30 KiB
Python
692 lines
30 KiB
Python
"""医生核对包:目录结构、CSV 规格、TIC参数、问题清单的医生写法、程序原文快照、逐字节拷贝、PDF。
|
||
|
||
预期值照任务书 03、04 写:目录和文件名、列名、问题清单的「环节」与「问题」、TIC参数.csv 的中文名、
|
||
单位和小数位都按任务书的文字手写,不从实现里推。原因对照表的逐条预期在 `test_review_reasons.py`。
|
||
|
||
假数据(8 例):
|
||
- P001:进库;病灶去暗区曲线拟合失败(parameter_on_boundary),B0、D1–D4 因此未取到;F2 落在视频
|
||
之后;F1 取到;紫圈不可用。TIC参数两组都空。
|
||
- P002:CEUS 第 2 步失败(两半都没有绿框,C15 跳过)。P003:CEUS 运行里没有结果目录(C15 跳过)。
|
||
- P004:进库;7 个槽位都取到,D2、F1 的时间改成 5.04、12.46 秒;拟合都成功;紫圈可用;两组指标都有,
|
||
病灶一组观测不够的 4 项为 null。
|
||
- P005:MR 表诊断、临床表年龄与另两张表不同(C10 跳过);CEUS 运行里却有它完整的结果目录。
|
||
- P006:进库;病灶整体曲线拟合失败(credible_peak_not_found),B0、D1–D4 未取到;紫圈可用但拟合失败
|
||
(fit_quality_failed)。TIC参数只有病灶一组。
|
||
- P007:MR 表性别与超声表不同(C10 跳过);CEUS 第 1 步失败(两半配准不一致)。
|
||
- P008:进库;拟合都成功;B0 基线是估计的、D2 和 D3 的候选帧被占、D4 落在视频之后、
|
||
F2 附近只有重复帧。
|
||
"""
|
||
|
||
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 perfusion, 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")
|
||
DYNAMIC = ("B0", "D1", "D2", "D3", "D4")
|
||
CONS = ",程序没法处理这段视频"
|
||
SEX_NOTE = "(临床表的性别是数字编码,还没有编码对照表,这次没有拿来比)"
|
||
LESION = ";B0、D1–D4 要病灶整体和病灶去暗区两条曲线都拟合上才取,所以这 5 个关键帧没有取"
|
||
BOUNDARY = (
|
||
"试着贴出来的曲线在峰高、峰出现的时间、曲线宽窄、开始变亮的时刻或增强前水平上,"
|
||
"有一项卡在了程序允许范围的边上"
|
||
)
|
||
FIT_FAILED = "病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门"
|
||
F2_AFTER = (
|
||
"推注后第 60 秒在视频第 55.00 秒(推注时间 X=5 秒),落在视频有效帧范围 [0.00, 40.20] 秒之外"
|
||
)
|
||
STEP2 = "左右两半都没有找到绿框,选不出提示帧"
|
||
STEP1 = "两半配准不一致:互相关估计 (dx, dy) = (3, 4),模板匹配估计 (9, 4),相差 6 像素,超过门槛 4"
|
||
|
||
SUMMARY_HEADER = [
|
||
"病例号",
|
||
"视频号",
|
||
"化名",
|
||
"是否进入资料库",
|
||
"没进的原因",
|
||
"CEUS 定量是否成功",
|
||
"CEUS 失败在第几步",
|
||
*ALL_SLOTS,
|
||
]
|
||
PROBLEM_HEADER = ["病例号", "视频号", "环节", "问题"]
|
||
TIC_HEADER = ["指标", "单位", "胰腺实质(紫圈)", "病灶(去暗区)"]
|
||
METRIC_NAMES = [
|
||
["峰值增强 PE", "灰度"],
|
||
["达峰时间 TTP", "秒"],
|
||
["最大洗入斜率 WiR", "灰度/秒"],
|
||
["上升时间 RT", "秒"],
|
||
["洗入曲线下面积 WiAUC", "灰度·秒"],
|
||
["洗入灌注指数 WiPI", "灰度"],
|
||
["最大洗出斜率 WoR", "灰度/秒"],
|
||
["下降时间 FT", "秒"],
|
||
["洗出曲线下面积 WoAUC", "灰度·秒"],
|
||
["洗入洗出曲线下面积 WiWoAUC", "灰度·秒"],
|
||
["平均渡越时间 mTTl", "秒"],
|
||
]
|
||
# fake_ceus_run.perfusion(1.0) 的 11 个值按 TIC参数.csv 的行序、按小数位写出:
|
||
# PE 1、WiAUC 2、RT 3、mTTl 4、TTP 5、WiR 6、WiPI 7、WoAUC 8、WiWoAUC 9、FT 10、WoR −11。
|
||
SCALE1 = ["1.00", "5.0", "6.00", "3.0", "2.00", "7.00", "-11.00", "10.0", "8.00", "9.00", "4.0"]
|
||
BLANK = [""] * 11
|
||
ANSWER_HEADERS = {
|
||
"化名对照.csv": ["化名", "病例号", "视频号"],
|
||
"诊断.csv": ["化名", "病例号", "临床表诊断编码", "超声表诊断编码", "MR表诊断编码", "诊断名"],
|
||
"报告复核句.csv": ["化名", "路", "命中的词", "所在句子"],
|
||
}
|
||
CASE_FILES = {"临床信息.csv", "超声报告.pdf", "MR报告.pdf", "关键帧"}
|
||
CEUS_FILES = {"TIC曲线.png", "TIC参数.csv", "回放视频.mp4"}
|
||
# 任务书 04 验收第 3 条:问题清单里不许出现的东西。
|
||
FORBIDDEN = re.compile(r"C[0-9]|roi_|_|\{|\[|band|[0-9]{8}-[0-9]{6}")
|
||
|
||
|
||
def _rewrite_summary(run: Path, video_id: str, located: tuple[str, ...], edit: Any) -> None:
|
||
content = summary(video_id, located)
|
||
edit(content)
|
||
# 最大洗出斜率 WoR 在真实运行里是负数,假数据也照此写。
|
||
for group in content["perfusion_metrics"].values():
|
||
if group is not None and group["values"]["WoR_au_per_s"] is not None:
|
||
group["values"]["WoR_au_per_s"] = -abs(group["values"]["WoR_au_per_s"])
|
||
(run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
||
|
||
|
||
def _unlocated(content: dict[str, Any], reasons: dict[str, str]) -> None:
|
||
for slot, reason in reasons.items():
|
||
content["anchor_frames"][slot]["reason"] = reason
|
||
|
||
|
||
def _fail(content: dict[str, Any], curve: str, code: str) -> None:
|
||
content["fits"][curve].update(success=False, failure_reason=code)
|
||
if curve in content["perfusion_metrics"]:
|
||
content["perfusion_metrics"][curve] = None
|
||
|
||
|
||
def _pan_ok(content: dict[str, Any]) -> None:
|
||
content["reference_valid"] = True
|
||
content["fits"]["roi_pan"].update(success=True, failure_reason=None)
|
||
content["perfusion_metrics"]["roi_pan"] = perfusion(1.0)
|
||
|
||
|
||
def _p001(content: dict[str, Any]) -> None:
|
||
_fail(content, "roi_perf", "parameter_on_boundary")
|
||
content["fits"]["roi_pan"]["failure_reason"] = "insufficient_valid_samples"
|
||
_unlocated(content, {**dict.fromkeys(DYNAMIC, FIT_FAILED), "F2": F2_AFTER})
|
||
|
||
|
||
def _p004(content: dict[str, Any]) -> None:
|
||
_pan_ok(content)
|
||
content["anchor_frames"]["D2"]["frame_time_video_s"] = 5.04
|
||
content["anchor_frames"]["F1"]["frame_time_video_s"] = 12.46
|
||
values = content["perfusion_metrics"]["roi_perf"]["values"]
|
||
values.update(
|
||
PE_au=21.456,
|
||
TTP_s=13.04,
|
||
WiR_au_per_s=3.2,
|
||
RT_s=6.26,
|
||
WiAUC_au_s=80,
|
||
WiPI_au=12.777,
|
||
WoR_au_per_s=-1.234,
|
||
FT_s=None,
|
||
WoAUC_au_s=None,
|
||
WiWoAUC_au_s=None,
|
||
mTTl_s=None,
|
||
)
|
||
|
||
|
||
def _p006(content: dict[str, Any]) -> None:
|
||
_fail(content, "roi_all", "credible_peak_not_found")
|
||
content["reference_valid"] = True
|
||
content["fits"]["roi_pan"]["failure_reason"] = "fit_quality_failed"
|
||
_unlocated(content, dict.fromkeys(DYNAMIC, FIT_FAILED))
|
||
|
||
|
||
def _p008(content: dict[str, Any]) -> None:
|
||
_pan_ok(content)
|
||
taken = "{} 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序"
|
||
_unlocated(
|
||
content,
|
||
{
|
||
"B0": "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义",
|
||
"D2": taken.format("D2"),
|
||
"D3": taken.format("D3"),
|
||
"D4": "D4 时刻 41.50 秒落在视频有效帧范围 [0.00, 40.20] 秒 之外",
|
||
"F2": "视频第 55.00 秒 ±2 秒内没有不重复的帧",
|
||
},
|
||
)
|
||
|
||
|
||
LOCATED: dict[str, tuple[str, ...]] = {
|
||
"U001": ("F1",),
|
||
"U004": ALL_SLOTS,
|
||
"U005": ALL_SLOTS,
|
||
"U006": ("F1", "F2"),
|
||
"U008": ("D1", "F1"),
|
||
}
|
||
|
||
|
||
def _build_with_ceus(root: Path) -> tuple[BuildSummary, Path]:
|
||
delivery = make_delivery(8)
|
||
delivery.case(5).mr[DIAGNOSIS_COLUMN] = 2
|
||
delivery.case(5).clinical["Age"] = 28
|
||
delivery.case(7).mr["性别"] = "男"
|
||
delivery_dir = delivery.write(root / "delivery")
|
||
run = write_run(
|
||
root / "runs" / RUN_ID, delivery_dir, LOCATED, [("U002", 2, STEP2), ("U007", 1, STEP1)]
|
||
)
|
||
for video_id, edit in (("U001", _p001), ("U004", _p004), ("U006", _p006), ("U008", _p008)):
|
||
_rewrite_summary(run, video_id, LOCATED[video_id], edit)
|
||
_rewrite_summary(run, "U005", ALL_SLOTS, _pan_ok)
|
||
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", "P008"}
|
||
for case in ("P001", "P004", "P006", "P008"):
|
||
assert {p.name for p in (pack / "病例" / case).iterdir()} == CASE_FILES | CEUS_FILES
|
||
assert not [p for p in pack.rglob("*") if p.name in ("TIC结果.csv", "关键图.png")]
|
||
keyframes = {
|
||
"P001": {"医生截图.png", "F1_第12.5秒.png"},
|
||
"P004": {
|
||
"医生截图.png",
|
||
"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",
|
||
},
|
||
"P006": {"医生截图.png", "F1_第12.5秒.png", "F2_第15.0秒.png"},
|
||
"P008": {"医生截图.png", "D1_第2.5秒.png", "F1_第12.5秒.png"},
|
||
}
|
||
for case, names in keyframes.items():
|
||
assert {p.name for p in (pack / "病例" / case / "关键帧").iterdir()} == names
|
||
|
||
|
||
def test_every_csv_has_bom_and_no_placeholder_for_missing(packed: Packed) -> None:
|
||
csvs = sorted(packed.pack.rglob("*.csv"))
|
||
assert len(csvs) == 2 + 3 + 4 * 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
|
||
no = "未取到"
|
||
assert rows == [
|
||
["P001", "U001", a["001"], "是", "", "是", "", no, no, no, no, no, "12.5", no],
|
||
["P002", "U002", "", "否", f"视频里找不到画好的绿框{CONS}", "否", "2", *blank],
|
||
["P003", "U003", "", "否", "造影视频没有处理结果", "", "", *blank],
|
||
[
|
||
"P004",
|
||
"U004",
|
||
a["004"],
|
||
"是",
|
||
"",
|
||
"是",
|
||
"",
|
||
"0.0",
|
||
"2.5",
|
||
"5.04",
|
||
"7.5",
|
||
"10.0",
|
||
"12.46",
|
||
"15.0",
|
||
],
|
||
[
|
||
"P005",
|
||
"U005",
|
||
"",
|
||
"否",
|
||
"三张表的病理诊断编码对不上:临床表写 1,超声表写 1,MR 表写 2;"
|
||
"三张表的年龄对不上:临床表 28 岁,超声表 35 岁,MR 表 35 岁",
|
||
"是",
|
||
"",
|
||
*blank,
|
||
],
|
||
["P006", "U006", a["006"], "是", "", "是", "", no, no, no, no, no, "12.5", "15.0"],
|
||
[
|
||
"P007",
|
||
"U007",
|
||
"",
|
||
"否",
|
||
f"超声表和 MR 表的性别对不上:超声表 女,MR 表 男{SEX_NOTE}",
|
||
"否",
|
||
"1",
|
||
*blank,
|
||
],
|
||
["P008", "U008", a["008"], "是", "", "是", "", no, "2.5", no, no, no, "12.5", no],
|
||
]
|
||
|
||
|
||
EXPECTED_PROBLEMS = [
|
||
[
|
||
"P001",
|
||
"U001",
|
||
"TIC 曲线",
|
||
f"病灶去暗区那条曲线没能拟合({BOUNDARY}){LESION},TIC 参数里病灶一组也空着",
|
||
],
|
||
[
|
||
"P001",
|
||
"U001",
|
||
"关键帧",
|
||
"F2 没有取:换算到视频里的时刻不在录到的范围内"
|
||
"(F2 是推注后第 60 秒,应在视频第 55.0 秒,视频只录到第 40.2 秒)",
|
||
],
|
||
[
|
||
"P001",
|
||
"U001",
|
||
"紫圈",
|
||
"视频里大部分时间认不出紫圈,紫圈那条曲线不能用,TIC 参数里胰腺实质一组空着",
|
||
],
|
||
["P002", "U002", "造影视频处理", f"视频里找不到画好的绿框{CONS},这一例没进资料库"],
|
||
["P003", "U003", "造影视频处理", "造影视频没有处理结果,这一例没进资料库"],
|
||
[
|
||
"P005",
|
||
"U005",
|
||
"资料核对",
|
||
"三张表的病理诊断编码对不上:临床表写 1,超声表写 1,MR 表写 2;"
|
||
"三张表的年龄对不上:临床表 28 岁,超声表 35 岁,MR 表 35 岁,这一例没进资料库",
|
||
],
|
||
[
|
||
"P006",
|
||
"U006",
|
||
"TIC 曲线",
|
||
f"病灶整体那条曲线没能拟合(曲线太平,没有明显的峰){LESION}",
|
||
],
|
||
[
|
||
"P006",
|
||
"U006",
|
||
"TIC 曲线",
|
||
"紫圈那条曲线没能拟合(曲线起伏杂乱,平滑曲线和它差得太多),TIC 参数里胰腺实质一组空着",
|
||
],
|
||
[
|
||
"P007",
|
||
"U007",
|
||
"资料核对",
|
||
f"超声表和 MR 表的性别对不上:超声表 女,MR 表 男{SEX_NOTE},这一例没进资料库",
|
||
],
|
||
["P007", "U007", "造影视频处理", f"视频左右两屏的位置对不齐{CONS}"],
|
||
[
|
||
"P008",
|
||
"U008",
|
||
"关键帧",
|
||
"B0 没有取:视频一开始病灶就已经在变亮,没录到增强之前的那一段",
|
||
],
|
||
[
|
||
"P008",
|
||
"U008",
|
||
"关键帧",
|
||
"D2、D3 没有取:这个时刻附近能用的画面都不晚于前一个关键帧取的那一帧"
|
||
"(D1–D4 要按时间先后各取一帧,后一个必须比前一个晚)",
|
||
],
|
||
[
|
||
"P008",
|
||
"U008",
|
||
"关键帧",
|
||
"D4 没有取:算出的时刻不在视频能用的画面范围里"
|
||
"(D4 在视频第 41.5 秒,能用的画面是第 0.0–40.2 秒)",
|
||
],
|
||
["P008", "U008", "关键帧", "F2 没有取:这个时刻前后没有能用的画面(都是重复帧)"],
|
||
]
|
||
|
||
|
||
def test_problem_list_in_plain_words(packed: Packed) -> None:
|
||
assert _csv(packed.pack / "问题清单.csv")[1:] == EXPECTED_PROBLEMS
|
||
counts = {"资料核对": 2, "造影视频处理": 3, "TIC 曲线": 3, "关键帧": 5, "紫圈": 1}
|
||
assert packed.result.problems_by_stage == dict(sorted(counts.items()))
|
||
|
||
|
||
def test_problem_list_has_no_program_words(packed: Packed) -> None:
|
||
"""任务书 04 验收第 3 条:问题清单全文不含检查编号、字段名、下划线、括号、band、运行号。"""
|
||
text = (packed.pack / "问题清单.csv").read_text(encoding="utf-8-sig")
|
||
assert FORBIDDEN.findall(text) == []
|
||
|
||
|
||
def test_raw_problem_list_in_snapshot(packed: Packed) -> None:
|
||
"""程序原文照任务书 03 的写法留在快照里,不进医生的目录。"""
|
||
assert not (packed.pack / "问题清单_原文.csv").exists()
|
||
rows = _csv(packed.result.run_dir / "snapshot" / "问题清单_原文.csv")
|
||
skipped = {row[0]: row for row in _csv(packed.result.run_dir / "snapshot" / "跳过病例.csv")[1:]}
|
||
fit_failed = [f"{slot} 未取到:{FIT_FAILED}" for slot in DYNAMIC]
|
||
p008 = {
|
||
"B0": "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义",
|
||
"D2": "D2 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序",
|
||
"D3": "D3 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序",
|
||
"D4": "D4 时刻 41.50 秒落在视频有效帧范围 [0.00, 40.20] 秒 之外",
|
||
"F2": "视频第 55.00 秒 ±2 秒内没有不重复的帧",
|
||
}
|
||
|
||
def c10(no: str) -> list[str]:
|
||
row = skipped[no]
|
||
return [
|
||
f"P{no}",
|
||
f"U{no}",
|
||
"装配资料库",
|
||
f"C10 跨表一致(诊断、年龄、性别);文件:{row[3]};列:{row[4]};涉及值:{row[5]}",
|
||
]
|
||
|
||
assert rows == [
|
||
PROBLEM_HEADER,
|
||
["P001", "U001", "TIC 拟合", "病灶去暗区曲线未完成拟合(parameter_on_boundary)"],
|
||
*[["P001", "U001", "关键帧", text] for text in fit_failed],
|
||
["P001", "U001", "关键帧", f"F2 未取到:{F2_AFTER}"],
|
||
["P001", "U001", "紫圈", "紫圈曲线不可用"],
|
||
[
|
||
"P002",
|
||
"U002",
|
||
"装配资料库",
|
||
f'C15 CEUS 定量结果齐全;文件:{RUN_ID}/failed.csv;涉及值:"第 2 步失败:{STEP2}"',
|
||
],
|
||
["P002", "U002", "CEUS 第 2 步", f"第 2 步失败:{STEP2}"],
|
||
[
|
||
"P003",
|
||
"U003",
|
||
"装配资料库",
|
||
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/U003;"
|
||
'涉及值:"CEUS 运行目录里没有这一例的结果目录"',
|
||
],
|
||
c10("005"),
|
||
["P006", "U006", "TIC 拟合", "病灶整体曲线未完成拟合(credible_peak_not_found)"],
|
||
*[["P006", "U006", "关键帧", text] for text in fit_failed],
|
||
c10("007"),
|
||
["P007", "U007", "CEUS 第 1 步", f"第 1 步失败:{STEP1}"],
|
||
*[["P008", "U008", "关键帧", f"{slot} 未取到:{text}"] for slot, text in p008.items()],
|
||
]
|
||
|
||
|
||
def test_tic_params(packed: Packed) -> None:
|
||
def table(case: str) -> list[list[str]]:
|
||
return _csv(packed.pack / "病例" / case / "TIC参数.csv")
|
||
|
||
def rows(pan: list[str], perf: list[str]) -> list[list[str]]:
|
||
return [[*m, a, b] for m, a, b in zip(METRIC_NAMES, pan, perf, strict=True)]
|
||
|
||
p004_perf = ["21.46", "13.0", "3.20", "6.3", "80.00", "12.78", "-1.23", "", "", "", ""]
|
||
assert table("P004") == [TIC_HEADER, *rows(SCALE1, p004_perf)]
|
||
assert table("P008") == [TIC_HEADER, *rows(SCALE1, SCALE1)]
|
||
assert table("P006") == [
|
||
TIC_HEADER,
|
||
*rows(BLANK, SCALE1),
|
||
["没算出的原因", "", "曲线起伏杂乱,平滑曲线和它差得太多,没能拟合", ""],
|
||
]
|
||
assert table("P001") == [
|
||
TIC_HEADER,
|
||
*rows(BLANK, BLANK),
|
||
[
|
||
"没算出的原因",
|
||
"",
|
||
"视频里大部分时间认不出紫圈",
|
||
f"{BOUNDARY},没能拟合",
|
||
],
|
||
]
|
||
|
||
|
||
def test_tic_params_match_library(packed: Packed) -> None:
|
||
"""TIC参数.csv 的每一格都是资料库 tic.json 里那个数按小数位写出(null 为空)。"""
|
||
keys = ["PE_au", "TTP_s", "WiR_au_per_s", "RT_s", "WiAUC_au_s", "WiPI_au"]
|
||
keys += ["WoR_au_per_s", "FT_s", "WoAUC_au_s", "WiWoAUC_au_s", "mTTl_s"]
|
||
for case_no in ("001", "004", "006", "008"):
|
||
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")[1:12]
|
||
for row, key in zip(rows, keys, strict=True):
|
||
for cell, group in zip(row[2:], ("roi_pan", "roi_perf"), strict=True):
|
||
values = tic["perfusion_metrics"][group]
|
||
value = None if values is None else values[key]
|
||
digits = 1 if key in ("TTP_s", "RT_s", "FT_s", "mTTl_s") else 2
|
||
assert cell == ("" if value is None else f"{round(value, digits):.{digits}f}")
|
||
|
||
|
||
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])
|
||
key = (case_dir / "关键帧" / "医生截图.png").read_bytes()
|
||
assert key == route.keyframes["key"].read_bytes()
|
||
assert route.tic is not None
|
||
copied = {
|
||
p.name.split("_")[0]: p
|
||
for p in (case_dir / "关键帧").iterdir()
|
||
if p.name != "医生截图.png"
|
||
}
|
||
slots = {slot: path for slot, path in route.keyframes.items() if slot != "key"}
|
||
assert sorted(copied) == sorted(slots)
|
||
for slot, source in slots.items():
|
||
assert copied[slot].read_bytes() == source.read_bytes()
|
||
seconds = route.tic["keyframe_times_s"][slot]
|
||
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"),
|
||
("review.csv", "报告复核句.csv"),
|
||
):
|
||
assert (packed.pack / "标准答案" / name).read_bytes() == (answers / source).read_bytes()
|
||
|
||
|
||
def test_skipped_table_only_in_snapshot(packed: Packed) -> None:
|
||
"""跳过清单是程序原文:不进医生目录,原样拷进快照(负责人 2026-10-17)。"""
|
||
assert not [p for p in packed.pack.rglob("*") if p.name == "跳过病例.csv"]
|
||
copied = packed.result.run_dir / "snapshot" / "跳过病例.csv"
|
||
assert copied.read_bytes() == (packed.build.answers_dir / "skipped.csv").read_bytes()
|
||
assert _csv(copied)[0] == ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"]
|
||
|
||
|
||
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 test_pdfs_use_noto_cjk_and_carry_ids(packed: Packed) -> None:
|
||
pdfs = sorted(packed.pack.rglob("*.pdf"))
|
||
assert len(pdfs) == packed.result.pdfs == 1 + 4 * 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
|
||
pages = re.search(r"^Pages:\s+([0-9]+)$", _tool_output("pdfinfo", str(readme)), re.M)
|
||
assert pages is not None and int(pages.group(1)) <= 4
|
||
text = _tool_output("pdftotext", str(readme), "-")
|
||
assert packed.build.build_id in text and RUN_ID in text
|
||
# 说明里不写检查编号、原因码、字段名。
|
||
for word in ("C15", "parameter_on_boundary", "perfusion_metrics", "贵方", "MedSAM"):
|
||
assert word not 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_unknown_reason_stops(tmp_path: Path) -> None:
|
||
"""关键帧未取到的原因不在对照表里(假运行的默认原因是编造的):报错停下,不出包。"""
|
||
delivery_dir = make_delivery(1).write(tmp_path / "delivery")
|
||
run = write_run(tmp_path / "runs" / RUN_ID, delivery_dir, {"U001": ("D1",)}, [])
|
||
build = run_build(write_config(tmp_path, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW)
|
||
config = _pack_config(tmp_path, build.library_dir, build.answers_dir)
|
||
with pytest.raises(ValueError, match="不在对照表里"):
|
||
run_review_pack(config, REPO_ROOT, PACK_NOW)
|
||
assert not [p for p in (tmp_path / "review_pack").iterdir() if not p.name.endswith(".partial")]
|
||
|
||
|
||
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"):
|
||
case_dir = result.pack_dir / "病例" / case
|
||
assert {p.name for p in case_dir.iterdir()} == CASE_FILES
|
||
assert {p.name for p in (case_dir / "关键帧").iterdir()} == {"医生截图.png"}
|
||
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")
|