bceb01e8fa
按任务书 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>
268 lines
8.9 KiB
Python
268 lines
8.9 KiB
Python
"""测试用假交付包:形状照 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)
|