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
+24
View File
@@ -0,0 +1,24 @@
"""answers:标准答案库的目录布局、文件格式、写入和评价要用的读取。
拒绝读取名字以 `.partial` 结尾的目录;读时核对清单里的 SHA-256。
"""
from cadx.answers._files import (
AliasRow,
DiagnosisRow,
ReviewRow,
SkippedRow,
read_aliases,
read_diagnoses,
write_answers,
)
__all__ = [
"AliasRow",
"DiagnosisRow",
"ReviewRow",
"SkippedRow",
"read_aliases",
"read_diagnoses",
"write_answers",
]
+177
View File
@@ -0,0 +1,177 @@
"""标准答案库的目录布局、写入和读取。
```
<构建目录>/
manifest.json 构建号、跳过清单的说明、本目录其余全部文件的 SHA-256
snapshot/ 运行快照(由 runs 写,先于本模块写入)
aliases.csv 化名、病例号、视频号
diagnoses.csv 化名、病例号、三张表的诊断编码、诊断名
skipped.csv 没进资料库的病例和第一项没过的检查
review.csv 进库病例报告里含复核措辞的句子
```
CSV 用带 BOM 的 UTF-8,医生用 Excel 直接打开不乱码。
"""
import csv
import hashlib
import json
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
MANIFEST = "manifest.json"
_ENCODING = "utf-8-sig"
_JOIN = "; "
ALIASES_HEADER = ("化名", "病例号", "视频号")
DIAGNOSES_HEADER = ("化名", "病例号", "临床表诊断编码", "超声表诊断编码", "MR表诊断编码", "诊断名")
SKIPPED_HEADER = ("病例", "检查编号", "检查说明", "文件", "列", "涉及值")
REVIEW_HEADER = ("化名", "路", "命中的词", "所在句子")
@dataclass(frozen=True)
class AliasRow:
"""化名与真实病例号、视频号的对照。"""
alias: str
case_no: str
video_id: str
@dataclass(frozen=True)
class DiagnosisRow:
"""一例的金标准:三张表各自的诊断编码,解码后的诊断名(不解码时为 None)。"""
alias: str
case_no: str
clinical_code: int
ultrasound_code: int
mr_code: int
name: str | None
@dataclass(frozen=True)
class SkippedRow:
"""跳过清单的一行。`case` 是病例号,或认不出编号时的「文件 第 N 行」。"""
case: str
check: str
description: str
files: tuple[str, ...]
columns: tuple[str, ...]
value_json: str
@dataclass(frozen=True)
class ReviewRow:
"""复核清单的一行:`route` 是「超声」或「MR」。"""
alias: str
route: str
words: str
sentence: str
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _write_csv(path: Path, header: tuple[str, ...], rows: Sequence[Sequence[object]]) -> None:
with path.open("w", encoding=_ENCODING, newline="") as handle:
writer = csv.writer(handle)
writer.writerow(header)
writer.writerows(rows)
def write_answers(
answers_dir: Path,
build_id: str,
skipped_note: str,
aliases: Sequence[AliasRow],
diagnoses: Sequence[DiagnosisRow],
skipped: Sequence[SkippedRow],
reviews: Sequence[ReviewRow],
) -> None:
"""写四张表,再给目录里全部文件(含已写好的 snapshot/)算 SHA-256 写进清单。"""
_write_csv(
answers_dir / "aliases.csv",
ALIASES_HEADER,
[(r.alias, r.case_no, r.video_id) for r in aliases],
)
_write_csv(
answers_dir / "diagnoses.csv",
DIAGNOSES_HEADER,
[
(r.alias, r.case_no, r.clinical_code, r.ultrasound_code, r.mr_code, r.name or "")
for r in diagnoses
],
)
_write_csv(
answers_dir / "skipped.csv",
SKIPPED_HEADER,
[
(
r.case,
r.check,
r.description,
_JOIN.join(r.files),
_JOIN.join(r.columns),
r.value_json,
)
for r in skipped
],
)
_write_csv(
answers_dir / "review.csv",
REVIEW_HEADER,
[(r.alias, r.route, r.words, r.sentence) for r in reviews],
)
files = {
p.relative_to(answers_dir).as_posix(): _sha256(p)
for p in sorted(answers_dir.rglob("*"))
if p.is_file()
}
manifest = {"build_id": build_id, "skipped_note": skipped_note, "files": files}
(answers_dir / MANIFEST).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
def _read_csv(answers_dir: Path, name: str, header: tuple[str, ...]) -> list[list[str]]:
if answers_dir.name.endswith(".partial"):
raise ValueError(f"{answers_dir} 是没写完的构建(.partial),不能读")
manifest = cast(
dict[str, Any], json.loads((answers_dir / MANIFEST).read_text(encoding="utf-8"))
)
path = answers_dir / name
if _sha256(path) != manifest["files"][name]:
raise ValueError(f"{name} 的 SHA-256 与清单不一致,答案库被改动过")
with path.open(encoding=_ENCODING, newline="") as handle:
rows = list(csv.reader(handle))
if tuple(rows[0]) != header:
raise ValueError(f"{name} 的表头与约定不一致")
return rows[1:]
def read_aliases(answers_dir: Path) -> tuple[AliasRow, ...]:
"""读化名对照。"""
rows = _read_csv(answers_dir, "aliases.csv", ALIASES_HEADER)
return tuple(AliasRow(alias=a, case_no=c, video_id=v) for a, c, v in rows)
def read_diagnoses(answers_dir: Path) -> tuple[DiagnosisRow, ...]:
"""读金标准。诊断名一栏为空表示构建时没有解码。"""
rows = _read_csv(answers_dir, "diagnoses.csv", DIAGNOSES_HEADER)
return tuple(
DiagnosisRow(
alias=alias,
case_no=case_no,
clinical_code=int(clinical),
ultrasound_code=int(ultrasound),
mr_code=int(mr),
name=name or None,
)
for alias, case_no, clinical, ultrasound, mr, name in rows
)
+13
View File
@@ -0,0 +1,13 @@
"""build:装配两个库。可见性(每一路放什么)、化名、清单内容只在这里决定。"""
from cadx.build._assemble import BuildSummary, make_aliases, run_build
from cadx.build._config import BuildConfig, load_build_config, load_code_maps
__all__ = [
"BuildConfig",
"BuildSummary",
"load_build_config",
"load_code_maps",
"make_aliases",
"run_build",
]
+198
View File
@@ -0,0 +1,198 @@
"""装配一次构建:决定每一路放什么、化名怎么取,写出资料库和答案库。
全项目只有这里同时碰得到原始资料和答案。
"""
import hashlib
import random
import string
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import cast
from cadx.answers import AliasRow, DiagnosisRow, ReviewRow, SkippedRow, write_answers
from cadx.build._config import load_build_config, load_code_maps
from cadx.delivery import (
CODED_COLUMNS,
SKIPPED_NOTE,
CaseRecord,
CodeMaps,
read_delivery,
review_sentences,
)
from cadx.library import LibraryCase, write_library
from cadx.runs import make_build_id, read_git_state, write_snapshot
ALIAS_ALPHABET = string.ascii_lowercase + string.digits
ALIAS_LENGTH = 4
# 化名里出现亚型缩写会被泄露扫描(不分大小写)误报,抽到就重抽。
_ALIAS_BANNED = ("ipmn", "spn", "mcn", "scn")
@dataclass(frozen=True)
class BuildSummary:
"""一次构建的计数,供终端打印;不含病例号和资料内容。"""
build_id: str
library_dir: Path
answers_dir: Path
total_cases: int
passed_cases: int
skipped_cases: int
unrecognized_rows: int
skipped_by_check: dict[str, int]
review_rows: int
skipped_note: str
def make_aliases(case_nos: Sequence[str], seed: int) -> dict[str, str]:
"""按病例号排序后依次抽化名:同一个种子、同一批病例得到同样的化名。"""
rng = random.Random(seed) # noqa: S311 化名只需可复现,不需要密码学强度
aliases: dict[str, str] = {}
for case_no in sorted(case_nos):
while True:
alias = "".join(rng.choice(ALIAS_ALPHABET) for _ in range(ALIAS_LENGTH))
if alias not in aliases.values() and not any(b in alias for b in _ALIAS_BANNED):
break
aliases[case_no] = alias
return aliases
def _clinical_fields(
case: CaseRecord, code_maps: CodeMaps | None
) -> dict[str, str | int | float | None]:
"""临床一路的 22 列;解码时编码列写映射表里的含义,没测的化验保持 None。"""
if code_maps is None:
return dict(case.clinical)
return {
column: code_maps[column][cast(int, value)]
if column in CODED_COLUMNS and value is not None
else value
for column, value in case.clinical.items()
}
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _rename(partial: Path, final: Path) -> None:
# POSIX 的 rename 会悄悄覆盖空目录,所以改名前再查一次。
if final.exists():
raise FileExistsError(f"{final} 已存在,不覆盖")
partial.rename(final)
def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary:
"""读配置和交付包,装配一对同号的资料库与答案库。`now` 是本地时间,定构建号。"""
config = load_build_config(config_path, repo_root)
code_maps = load_code_maps(config.code_maps) if config.code_maps is not None else None
delivery = read_delivery(config.delivery_dir, code_maps)
git = read_git_state(repo_root)
build_id = make_build_id(now, git)
library_dir = config.library_root / build_id
answers_dir = config.answers_root / build_id
library_partial = config.library_root / f"{build_id}.partial"
answers_partial = config.answers_root / f"{build_id}.partial"
for final in (library_dir, answers_dir):
if final.exists():
raise FileExistsError(f"{final} 已存在,不覆盖")
for partial in (library_partial, answers_partial):
partial.mkdir(parents=True, exist_ok=False)
aliases = make_aliases([case.case_no for case in delivery.cases], config.alias_seed)
by_alias = sorted(delivery.cases, key=lambda case: aliases[case.case_no])
configs = {"build.yaml": config_path}
if config.code_maps is not None:
configs["code_maps.yaml"] = config.code_maps
write_library(
build_dir=library_partial,
build_id=build_id,
git_commit=git.commit,
git_clean=git.clean,
config_sha256={name: _sha256(path) for name, path in configs.items()},
codes_decoded=code_maps is not None,
uncompared=delivery.uncompared,
ceus_source=None,
cases=[
LibraryCase(
alias=aliases[case.case_no],
clinical_fields=_clinical_fields(case, code_maps),
ultrasound_report=case.ultrasound_report,
key_image_source=case.key_image,
mr_report=case.mr_report,
)
for case in by_alias
],
)
reviews = [
ReviewRow(alias=aliases[case.case_no], route=route, words=words, sentence=sentence)
for case in by_alias
for route, text in (("超声", case.ultrasound_report), ("MR", case.mr_report))
for words, sentence in review_sentences(text)
]
write_snapshot(
snapshot_dir=answers_partial / "snapshot",
build_id=build_id,
git=git,
started_at=now,
finished_at=datetime.now().astimezone(),
uv_lock=repo_root / "uv.lock",
configs=configs,
)
write_answers(
answers_dir=answers_partial,
build_id=build_id,
skipped_note=SKIPPED_NOTE,
aliases=[
AliasRow(alias=aliases[c.case_no], case_no=c.case_no, video_id=c.video_id)
for c in delivery.cases
],
diagnoses=[
DiagnosisRow(
alias=aliases[c.case_no],
case_no=c.case_no,
clinical_code=c.diagnosis.clinical,
ultrasound_code=c.diagnosis.ultrasound,
mr_code=c.diagnosis.mr,
name=None if code_maps is None else code_maps["Diagnosis"][c.diagnosis.clinical],
)
for c in delivery.cases
],
skipped=[
SkippedRow(
case=s.case,
check=s.check,
description=s.description,
files=s.files,
columns=s.columns,
value_json=s.value_json,
)
for s in delivery.skipped
],
reviews=reviews,
)
# 先改答案库,最后改资料库:资料库的 <构建号>/ 出现才算构建完成。
_rename(answers_partial, answers_dir)
_rename(library_partial, library_dir)
by_check = Counter(s.check for s in delivery.skipped)
return BuildSummary(
build_id=build_id,
library_dir=library_dir,
answers_dir=answers_dir,
total_cases=delivery.total_cases,
passed_cases=len(delivery.cases),
skipped_cases=len(delivery.skipped) - delivery.unrecognized_rows,
unrecognized_rows=delivery.unrecognized_rows,
skipped_by_check=dict(sorted(by_check.items())),
review_rows=len(reviews),
skipped_note=SKIPPED_NOTE,
)
+96
View File
@@ -0,0 +1,96 @@
"""`config/build.yaml` 与编码映射表的字段定义和校验(含 T5)。"""
from collections.abc import Hashable
from pathlib import Path
from typing import Annotated, Any, Self
import yaml
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, TypeAdapter, model_validator
from cadx.delivery import CODED_COLUMNS, CodeMaps
def _reject_empty_path(value: object) -> object:
# Path("") 会变成 "."(当前目录),空串在转换前就拦下。
if value == "":
raise ValueError("路径不能是空串")
return value
# YAML 里的路径是字符串;路径字段是唯一允许从字符串转换类型的地方。
type _PathField = Annotated[Path, Field(strict=False), BeforeValidator(_reject_empty_path)]
class _UniqueKeyLoader(yaml.SafeLoader):
"""同一层出现重复键就报错:PyYAML 默认让后一个悄悄覆盖前一个。"""
# 签名(含 deep 的默认值)照抄 PyYAML 的基类方法,覆盖时必须一致。
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Hashable, Any]:
seen: set[Hashable] = set()
for key_node, _ in node.value:
key = self.construct_object(key_node, deep=deep) # pyright: ignore[reportUnknownMemberType]
if key in seen:
raise ValueError(f"YAML 第 {key_node.start_mark.line + 1} 行重复的键:{key!r}")
seen.add(key)
return super().construct_mapping(node, deep=deep)
def _load_yaml(path: Path) -> object:
return yaml.load(path.read_text(encoding="utf-8"), Loader=_UniqueKeyLoader) # noqa: S506 _UniqueKeyLoader 继承 SafeLoader
SUBTYPES = frozenset({"IPMN", "SPN", "MCN", "SCN"})
SEXES = frozenset({"男", "女"})
class BuildConfig(BaseModel):
"""装配两个库的配置。每个字段必填;相对路径相对仓库根目录解析。"""
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
delivery_dir: _PathField
library_root: _PathField
answers_root: _PathField
alias_seed: int
decode_codes: bool
code_maps: Annotated[Path | None, Field(strict=False), BeforeValidator(_reject_empty_path)]
@model_validator(mode="after")
def _code_maps_match_switch(self) -> Self:
if self.decode_codes and self.code_maps is None:
raise ValueError("decode_codes 为 true 时 code_maps 必须是映射表路径")
if not self.decode_codes and self.code_maps is not None:
raise ValueError("decode_codes 为 false 时 code_maps 必须是 null")
return self
def load_build_config(path: Path, repo_root: Path) -> BuildConfig:
"""读并校验 `build.yaml`,把相对路径换成 `repo_root` 下的绝对路径。"""
config = BuildConfig.model_validate(_load_yaml(path))
return config.model_copy(
update={
"delivery_dir": repo_root / config.delivery_dir,
"library_root": repo_root / config.library_root,
"answers_root": repo_root / config.answers_root,
"code_maps": None if config.code_maps is None else repo_root / config.code_maps,
}
)
_CODE_MAPS = TypeAdapter(dict[str, dict[int, str]], config=ConfigDict(strict=True))
def load_code_maps(path: Path) -> CodeMaps:
"""读映射表并做 T5:恰好覆盖全部编码列,性别映射到「男」「女」,诊断映射到四个亚型缩写。"""
maps = _CODE_MAPS.validate_python(_load_yaml(path))
if set(maps) != set(CODED_COLUMNS):
extra = sorted(set(maps) - set(CODED_COLUMNS))
missing = sorted(set(CODED_COLUMNS) - set(maps))
raise ValueError(
f"T5 映射表应恰好覆盖 {len(CODED_COLUMNS)} 项编码列:多了 {extra},缺 {missing}"
)
for column, expected in (("Sex", SEXES), ("Diagnosis", SUBTYPES)):
values = list(maps[column].values())
if len(values) != len(expected) or frozenset(values) != expected:
raise ValueError(f"T5 映射表的 {column} 应恰好映射到 {sorted(expected)}")
return maps
+31
View File
@@ -0,0 +1,31 @@
"""delivery:读 40 例交付包、逐例检查。只读,不决定谁能看见什么。
整张表的问题(T1–T4)抛 `DeliveryError`,整次构建停下;单个病例的问题(C01–C13)
跳过整例,记一条 `SkipRecord`。
"""
from cadx.delivery._cases import (
SKIPPED_NOTE,
CaseRecord,
CodeMaps,
DeliveryResult,
DiagnosisCodes,
SkipRecord,
read_delivery,
)
from cadx.delivery._shape import CODED_COLUMNS
from cadx.delivery._tables import DeliveryError
from cadx.delivery._text import review_sentences
__all__ = [
"CODED_COLUMNS",
"SKIPPED_NOTE",
"CaseRecord",
"CodeMaps",
"DeliveryError",
"DeliveryResult",
"DiagnosisCodes",
"SkipRecord",
"read_delivery",
"review_sentences",
]
+412
View File
@@ -0,0 +1,412 @@
"""逐例检查 C01–C13,交出通过的病例和跳过记录。
一例遇到第一项不过的检查就跳过整例,后面的检查不再做(`AGENTS.md`「错误怎么处理」)。
类型按 openpyxl 读出的 Python 类型判断,不做转换:要求整数的格读出 `45.0` 也不过,
字符串不去首尾空格。
"""
import json
import re
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TypeGuard, cast
from cadx.delivery import _text
from cadx.delivery._shape import (
CASE_REPORT_SHEET,
CLINICAL,
CLINICAL_ROUTE_COLUMNS,
CODED_COLUMNS,
DIAGNOSIS_HEADER,
HISTORY_COLUMNS,
LAB_PAIRS,
MR,
SUMMARY_TABLES,
SYMPTOM_COLUMNS,
ULTRASOUND,
TableShape,
case_dir_path,
video_id,
)
from cadx.delivery._tables import Cell, Row, open_workbook, read_tables
type CodeMaps = Mapping[str, Mapping[int, str]]
CHECK_DESCRIPTIONS = {
"C01": "编号能规范化",
"C02": "同一张表里这个编号只出现一次",
"C03": "三张表和病例目录里都有这一例",
"C04": "病例目录恰好有关键图、视频、与视频名配对的单例报告表三个文件",
"C05": "超声表的 CEUS视频ID 与病例号一致",
"C06": "单例报告表与超声汇总表对应行一致",
"C07": "临床表取值合规",
"C08": "超声表取值合规",
"C09": "MR 表取值合规",
"C10": "跨表一致(诊断、年龄、性别)",
"C11": "报告文字里没有结论段标题",
"C12": "报告文字里没有亚型名",
"C13": "报告文字里没有人口学信息",
}
SKIPPED_NOTE = "每例只记第一项没过的检查:遇到它就跳过整例,后面的检查没有做过,不代表没有问题。"
UNCOMPARED_CLINICAL_SEX = "临床性别未比对"
_CLINICAL_ID = re.compile(r"P([0-9]{3})")
_REPORT_ID = re.compile(r"[0-9]+")
_REPORT_AGE = re.compile(r"[0-9]+岁")
@dataclass(frozen=True)
class DiagnosisCodes:
"""三张表各自的诊断编码(C10 保证三者相等)。"""
clinical: int
ultrasound: int
mr: int
@dataclass(frozen=True)
class CaseRecord:
"""一个检查全部通过的病例读到的内容。"""
case_no: str # 三位补零
video_id: str
clinical: Mapping[str, int | float | None] # 临床表除 Case_ID、Diagnosis 外的 22 列
ultrasound_report: str
mr_report: str
key_image: Path
diagnosis: DiagnosisCodes
@dataclass(frozen=True)
class SkipRecord:
"""一条跳过记录。`case` 是三位病例号,C01 认不出编号时是「文件 第 N 行」。"""
case: str
check: str
description: str
files: tuple[str, ...]
columns: tuple[str, ...]
value_json: str
@dataclass(frozen=True)
class DeliveryResult:
"""读一次交付包的结果。"""
cases: tuple[CaseRecord, ...] # 按病例号排序
skipped: tuple[SkipRecord, ...]
total_cases: int # 三张表和病例目录里认得出的病例号的并集大小
unrecognized_rows: int # C01 认不出编号的行数,不计入病例数
uncompared: tuple[str, ...] # 因为没有映射表而没比对的项
def _skip(
case: str, check: str, files: tuple[str, ...], columns: tuple[str, ...], value: object
) -> SkipRecord:
# 涉及值可能是 openpyxl 读出的任何类型(如日期),JSON 表示不了的按 repr 写,供医生核对。
return SkipRecord(
case=case,
check=check,
description=CHECK_DESCRIPTIONS[check],
files=files,
columns=columns,
value_json=json.dumps(value, ensure_ascii=False, default=repr),
)
def _is_int(value: Cell) -> TypeGuard[int]:
return type(value) is int # bool 是 int 的子类,要排除
def _is_number(value: Cell) -> TypeGuard[int | float]:
return type(value) in (int, float)
def _in_range(value: Cell, low: int, high: int) -> bool:
return _is_int(value) and low <= value <= high
def _normalize_id(table: TableShape, raw: Cell) -> str | None:
"""C01:认得出就返回三位病例号,认不出返回 None。"""
number: int | None = None
if table is CLINICAL:
if isinstance(raw, str) and (m := _CLINICAL_ID.fullmatch(raw)):
number = int(m.group(1))
elif isinstance(raw, str) and _REPORT_ID.fullmatch(raw):
number = int(raw)
elif _is_int(raw):
number = raw
if number is None or not 1 <= number <= 999:
return None
return f"{number:03d}"
def _index_rows(
rows: dict[TableShape, list[Row]], case_dirs: list[str]
) -> tuple[dict[TableShape, dict[str, list[Row]]], set[str], list[SkipRecord]]:
index: dict[TableShape, dict[str, list[Row]]] = {}
unrecognized: list[SkipRecord] = []
for table in SUMMARY_TABLES:
index[table] = {}
for row in rows[table]:
raw = row.cells[table.id_column]
case_no = _normalize_id(table, raw)
if case_no is None:
unrecognized.append(
_skip(
f"{table.path} 第 {row.number} 行",
"C01",
(table.path,),
(table.id_column,),
{"原始写法": raw, "行号": row.number},
)
)
continue
index[table].setdefault(case_no, []).append(row)
dirs: set[str] = set()
for name in case_dirs:
if not 1 <= int(name) <= 999:
unrecognized.append(
_skip(f"病例目录 {name}", "C01", (case_dir_path(name),), (), {"目录名": name})
)
continue
dirs.add(name)
return index, dirs, unrecognized
# 单例报告表的名字随视频名配对:带 roi2 标记的视频配带 roi2 的报告,反之亦然(负责人 2026-10-16)。
_VIDEO_REPORT_PAIRS = (("_roi2.mp4", "_roi2_report.xlsx"), (".mp4", "_report.xlsx"))
def _check_case_dir(case_no: str, folder: Path) -> str | SkipRecord:
"""C04:通过时返回这一例单例报告表的文件名。"""
u = video_id(case_no)
names = sorted(p.name for p in folder.iterdir())
for video, report in _VIDEO_REPORT_PAIRS:
if names == sorted((f"{u}_key.png", f"{u}{video}", f"{u}{report}")):
return f"{u}{report}"
return _skip(case_no, "C04", (case_dir_path(case_no),), (), {"文件": names})
def _check_case_report(case_no: str, path: Path, summary: Row, rel: str) -> SkipRecord | None:
"""C06:单例报告表与超声汇总表那一行逐格相等(类型也要相同)。"""
sheets = open_workbook(path)
if sheets.names != (CASE_REPORT_SHEET,):
return _skip(case_no, "C06", (rel,), (), {"工作表": list(sheets.names)})
if not sheets.rows or sheets.rows[0] != ULTRASOUND.header:
return _skip(case_no, "C06", (rel,), (), {"表头": "与超声汇总表不一致"})
if len(sheets.rows) != 2:
return _skip(case_no, "C06", (rel,), (), {"数据行数": len(sheets.rows) - 1})
diffs = {
column: {"单例报告表": mine, "超声汇总表": summary.cells[column]}
for column, mine in zip(ULTRASOUND.header, sheets.rows[1], strict=True)
if not (type(mine) is type(summary.cells[column]) and mine == summary.cells[column])
}
if diffs:
return _skip(case_no, "C06", (rel, ULTRASOUND.path), tuple(diffs), diffs)
return None
def _clinical_violations(cells: dict[str, Cell], code_maps: CodeMaps | None) -> dict[str, Cell]:
"""C07:返回不合规的列和值。"""
bad: dict[str, Cell] = {}
if not _in_range(cells["Sex"], 1, 2):
bad["Sex"] = cells["Sex"]
if not (_is_int(cells["Age"]) and cells["Age"] > 0):
bad["Age"] = cells["Age"]
if not _in_range(cells["Diagnosis"], 1, 4):
bad["Diagnosis"] = cells["Diagnosis"]
for column in (*SYMPTOM_COLUMNS, *HISTORY_COLUMNS):
if not _in_range(cells[column], 0, 1):
bad[column] = cells[column]
for raw_column, level_column in LAB_PAIRS:
raw, level = cells[raw_column], cells[level_column]
if raw is None and level is None:
continue
if not (_is_number(raw) and _in_range(level, 0, 1)):
bad[raw_column], bad[level_column] = raw, level
if code_maps is not None:
for column in CODED_COLUMNS:
value = cells[column]
if value is not None and not (_is_int(value) and value in code_maps[column]):
bad[column] = value
return bad
def _report_violations(
cells: dict[str, Cell], text_column: str, code_maps: CodeMaps | None
) -> dict[str, Cell]:
"""C08 / C09 共同的部分:性别、年龄、诊断、描述文字。"""
bad: dict[str, Cell] = {}
if cells["性别"] not in ("男", "女"):
bad["性别"] = cells["性别"]
age = cells["年龄"]
if not (isinstance(age, str) and _REPORT_AGE.fullmatch(age)):
bad["年龄"] = age
diagnosis = cells[DIAGNOSIS_HEADER]
if not _in_range(diagnosis, 1, 4) or (
code_maps is not None and diagnosis not in code_maps["Diagnosis"]
):
bad[DIAGNOSIS_HEADER] = diagnosis
text = cells[text_column]
if not (isinstance(text, str) and text):
bad[text_column] = text
return bad
def _report_age(cells: dict[str, Cell]) -> int:
return int(cast(str, cells["年龄"]).removesuffix("岁"))
def _cross_table(
clinical: dict[str, Cell],
ultrasound: dict[str, Cell],
mr: dict[str, Cell],
code_maps: CodeMaps | None,
) -> dict[str, dict[str, Cell]]:
"""C10:返回对不上的字段和各表的值。"""
bad: dict[str, dict[str, Cell]] = {}
diagnoses = (clinical["Diagnosis"], ultrasound[DIAGNOSIS_HEADER], mr[DIAGNOSIS_HEADER])
if len(set(diagnoses)) != 1:
bad["诊断"] = dict(zip(("临床表", "超声表", "MR 表"), diagnoses, strict=True))
ages = (clinical["Age"], _report_age(ultrasound), _report_age(mr))
if len(set(ages)) != 1:
bad["年龄"] = {"临床表": clinical["Age"], "超声表": ultrasound["年龄"], "MR 表": mr["年龄"]}
sexes = {"超声表": ultrasound["性别"], "MR 表": mr["性别"]}
if code_maps is not None:
sexes["临床表(解码后)"] = code_maps["Sex"][cast(int, clinical["Sex"])]
if len(set(sexes.values())) != 1:
bad["性别"] = sexes
return bad
def _text_hits(
case_no: str, check: str, reports: dict[TableShape, str], find: Callable[[str], list[object]]
) -> SkipRecord | None:
"""C11–C13 共同的部分:两份报告文字各查一遍,命中的都记下。"""
text_columns = {ULTRASOUND: "超声描述", MR: "MR描述"}
hits = {table: found for table, text in reports.items() if (found := find(text))}
if not hits:
return None
return _skip(
case_no,
check,
tuple(table.path for table in hits),
tuple(text_columns[table] for table in hits),
{text_columns[table]: found for table, found in hits.items()},
)
def _headings(text: str) -> list[object]:
return [{"关键词": k, "句子": s} for k, s in _text.conclusion_headings(text)]
def _subtypes(text: str) -> list[object]:
return list(_text.subtype_terms(text))
def _demographics(text: str) -> list[object]:
return list(_text.demographic_terms(text))
def _check_case(
case_no: str,
delivery_dir: Path,
index: dict[TableShape, dict[str, list[Row]]],
dirs: set[str],
code_maps: CodeMaps | None,
) -> CaseRecord | SkipRecord:
"""按 C02–C13 的顺序检查一例,遇到第一项不过就返回它的跳过记录。"""
for table in SUMMARY_TABLES:
found = index[table].get(case_no, [])
if len(found) > 1:
numbers = [row.number for row in found]
return _skip(case_no, "C02", (table.path,), (table.id_column,), {"行号": numbers})
missing = [table.path for table in SUMMARY_TABLES if case_no not in index[table]]
if case_no not in dirs:
missing.append(case_dir_path(case_no) + "/")
if missing:
return _skip(case_no, "C03", tuple(missing), (), {"缺在": missing})
clinical, ultrasound, mr = (index[table][case_no][0].cells for table in SUMMARY_TABLES)
folder = delivery_dir / case_dir_path(case_no)
u = video_id(case_no)
report_name = _check_case_dir(case_no, folder)
if isinstance(report_name, SkipRecord):
return report_name
if ultrasound["CEUS视频ID"] != u:
value = {"CEUS视频ID": ultrasound["CEUS视频ID"], "病例号": case_no}
return _skip(case_no, "C05", (ULTRASOUND.path,), ("CEUS视频ID",), value)
report_rel = f"{case_dir_path(case_no)}/{report_name}"
summary_row = index[ULTRASOUND][case_no][0]
report_path = delivery_dir / report_rel
if (skip := _check_case_report(case_no, report_path, summary_row, report_rel)) is not None:
return skip
if bad := _clinical_violations(clinical, code_maps):
return _skip(case_no, "C07", (CLINICAL.path,), tuple(bad), bad)
bad = _report_violations(ultrasound, "超声描述", code_maps)
bolus = ultrasound["推注开始时间s"]
if not (_is_int(bolus) and bolus >= 0):
bad["推注开始时间s"] = bolus
if bad:
return _skip(case_no, "C08", (ULTRASOUND.path,), tuple(bad), bad)
if bad := _report_violations(mr, "MR描述", code_maps):
return _skip(case_no, "C09", (MR.path,), tuple(bad), bad)
if mismatched := _cross_table(clinical, ultrasound, mr, code_maps):
files = tuple(table.path for table in SUMMARY_TABLES)
return _skip(case_no, "C10", files, tuple(mismatched), mismatched)
# C08、C09 已保证两份描述是非空字符串。
reports = {ULTRASOUND: cast(str, ultrasound["超声描述"]), MR: cast(str, mr["MR描述"])}
for check, find in (("C11", _headings), ("C12", _subtypes), ("C13", _demographics)):
if (skip := _text_hits(case_no, check, reports, find)) is not None:
return skip
# C07 已保证临床各列是整数、浮点数或空;C07–C10 已保证三个诊断是相等的整数。
return CaseRecord(
case_no=case_no,
video_id=u,
clinical={c: cast(int | float | None, clinical[c]) for c in CLINICAL_ROUTE_COLUMNS},
ultrasound_report=reports[ULTRASOUND],
mr_report=reports[MR],
key_image=folder / f"{u}_key.png",
diagnosis=DiagnosisCodes(
clinical=cast(int, clinical["Diagnosis"]),
ultrasound=cast(int, ultrasound[DIAGNOSIS_HEADER]),
mr=cast(int, mr[DIAGNOSIS_HEADER]),
),
)
def read_delivery(delivery_dir: Path, code_maps: CodeMaps | None) -> DeliveryResult:
"""读交付包并逐例检查。
`code_maps` 为 None 表示不解码:编码列只查取值范围,不查是否在映射表里,
临床表的性别不和报告表比对。整张表的问题抛 `DeliveryError`。
"""
rows, case_dirs = read_tables(delivery_dir)
index, dirs, skipped = _index_rows(rows, case_dirs)
unrecognized = len(skipped)
all_cases = sorted(dirs.union(*(index[table].keys() for table in SUMMARY_TABLES)))
cases: list[CaseRecord] = []
for case_no in all_cases:
outcome = _check_case(case_no, delivery_dir, index, dirs, code_maps)
if isinstance(outcome, SkipRecord):
skipped.append(outcome)
else:
cases.append(outcome)
return DeliveryResult(
cases=tuple(cases),
skipped=tuple(skipped),
total_cases=len(all_cases),
unrecognized_rows=unrecognized,
uncompared=() if code_maps is not None else (UNCOMPARED_CLINICAL_SEX,),
)
+104
View File
@@ -0,0 +1,104 @@
"""交付包的形状:目录、文件名、工作表名、表头、编码列名单。
全部按 40 例交付包(`data/交付包_20260908_40例/`)写死;交付方式变了就改这里,不做兼容。
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class TableShape:
"""一张汇总表的形状。"""
path: str # 相对交付包根目录
sheet: str
header: tuple[str, ...]
id_column: str
DIAGNOSIS_HEADER = "病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)"
SYMPTOM_COLUMNS = (
"Symptom_Pain",
"Symptom_Jaundice",
"Symptom_WeightLoss",
"Symptom_Fever",
"Symptom_Asymptomatic",
)
HISTORY_COLUMNS = (
"Hx_Pancreatitis",
"Hx_Alcohol",
"Hx_Gallstones",
"Hx_Malignancy",
"Hx_Family_PC",
)
# 化验原值列与档位列成对出现。
LAB_PAIRS = (
("CA199", "CA199_Level"),
("CEA", "CEA_Level"),
("Amylase", "Amylase_Level"),
("Lipase", "Lipase_Level"),
("TBil", "TBil_Level"),
)
CLINICAL = TableShape(
path="Clinical_agent/临床工作簿_40例.xlsx",
sheet="Sheet",
header=(
"Case_ID",
"Sex",
"Age",
"Diagnosis",
*SYMPTOM_COLUMNS,
*HISTORY_COLUMNS,
*(column for pair in LAB_PAIRS for column in pair),
),
id_column="Case_ID",
)
ULTRASOUND = TableShape(
path="CEUS_agent/超声报告文本_40例.xlsx",
sheet="超声报告",
header=(
"患者ID",
"CEUS视频ID",
"性别",
"年龄",
DIAGNOSIS_HEADER,
"推注开始时间s",
"超声描述",
),
id_column="患者ID",
)
MR = TableShape(
path="MR_agent/MR报告文本_40例.xlsx",
sheet="MR报告",
header=("患者ID", "性别", "年龄", DIAGNOSIS_HEADER, "MR描述"),
id_column="患者ID",
)
SUMMARY_TABLES = (CLINICAL, ULTRASOUND, MR)
# 病例目录里的单例报告表:工作表名固定,表头与超声汇总表相同。
CASE_REPORT_SHEET = "Sheet1"
# 临床一路拿到的列:临床表去掉编号和诊断。
CLINICAL_ROUTE_COLUMNS = tuple(c for c in CLINICAL.header if c not in ("Case_ID", "Diagnosis"))
# 用数字编码、需要映射表才知道含义的列。`Diagnosis` 同时管临床表的 `Diagnosis`
# 和两张报告表的诊断列,三列编码相同。
CODED_COLUMNS = (
"Sex",
"Diagnosis",
*SYMPTOM_COLUMNS,
*HISTORY_COLUMNS,
*(level for _, level in LAB_PAIRS),
)
def case_dir_path(case_no: str) -> str:
"""病例目录相对交付包根目录的路径。"""
return f"CEUS_agent/{case_no}"
def video_id(case_no: str) -> str:
"""病例号对应的视频号(`U` 加三位病例号)。"""
return f"U{case_no}"
+84
View File
@@ -0,0 +1,84 @@
"""读工作簿;整张表的检查 T1–T4,不过就整次构建停下。"""
import re
from dataclasses import dataclass
from pathlib import Path
import openpyxl
from cadx.delivery._shape import CLINICAL, MR, SUMMARY_TABLES, ULTRASOUND, TableShape
# 单元格按 openpyxl 读出的原样保留,不做类型转换;类型检查在各条 C 检查里做。
type Cell = object
CASE_DIR_NAME = re.compile(r"[0-9]{3}")
class DeliveryError(Exception):
"""整张表读不了:交付方式变了,一例也读不出来。消息以检查编号开头。"""
@dataclass(frozen=True)
class Row:
"""汇总表的一行:Excel 行号与「列名 → 单元格」。"""
number: int
cells: dict[str, Cell]
@dataclass(frozen=True)
class Sheets:
"""一个工作簿:全部工作表名,以及第一张表逐行的单元格(第 1 行是表头)。"""
names: tuple[str, ...]
rows: tuple[tuple[Cell, ...], ...]
def open_workbook(path: Path) -> Sheets:
"""读出工作簿的工作表名和第一张工作表的全部行。"""
workbook = openpyxl.load_workbook(path, read_only=True, data_only=True)
names = tuple(workbook.sheetnames)
rows = tuple(tuple(row) for row in workbook.worksheets[0].iter_rows(values_only=True))
workbook.close()
return Sheets(names=names, rows=rows)
def _read_summary(delivery_dir: Path, table: TableShape) -> list[Row]:
sheets = open_workbook(delivery_dir / table.path)
if sheets.names != (table.sheet,):
raise DeliveryError(
f"T2 {table.path} 的工作表应恰好是 [{table.sheet}],实际 {sheets.names}"
)
if not sheets.rows or sheets.rows[0] != table.header:
raise DeliveryError(f"T3 {table.path} 第 1 行表头与约定不一致")
return [
Row(number=number, cells=dict(zip(table.header, values, strict=True)))
for number, values in enumerate(sheets.rows[1:], start=2)
]
def _check_layout(delivery_dir: Path) -> list[str]:
"""T4:三个子目录里没有多余的东西。返回病例目录名(按名排序)。"""
for table in (CLINICAL, MR):
folder = (delivery_dir / table.path).parent
names = sorted(p.name for p in folder.iterdir())
if names != [Path(table.path).name]:
raise DeliveryError(f"T4 {folder.name}/ 下应只有 {Path(table.path).name},实际 {names}")
ceus = (delivery_dir / ULTRASOUND.path).parent
case_dirs: list[str] = []
for entry in sorted(ceus.iterdir()):
if entry.name == Path(ULTRASOUND.path).name:
continue
if not (entry.is_dir() and CASE_DIR_NAME.fullmatch(entry.name)):
raise DeliveryError(f"T4 {ceus.name}/ 下有不认识的条目 {entry.name}")
case_dirs.append(entry.name)
return case_dirs
def read_tables(delivery_dir: Path) -> tuple[dict[TableShape, list[Row]], list[str]]:
"""依次做 T1–T4,交出三张汇总表的数据行和病例目录名。"""
for table in SUMMARY_TABLES:
if not (delivery_dir / table.path).is_file():
raise DeliveryError(f"T1 缺汇总表 {table.path}")
rows = {table: _read_summary(delivery_dir, table) for table in SUMMARY_TABLES}
return rows, _check_layout(delivery_dir)
+102
View File
@@ -0,0 +1,102 @@
"""报告文字的规则:结论段标题、亚型名、人口学信息、复核用的措辞。
都先做 NFKC 归一(全角字母、数字、标点转半角),这样全角写法绕不过去。
"""
import re
import unicodedata
# 结论段标题的关键词。长的写在前面,正则按顺序尝试,命中「超声提示」时不会只报「提示」。
CONCLUSION_KEYWORDS = (
"超声提示",
"超声诊断",
"影像学诊断",
"检查结论",
"诊断意见",
"印象",
"结论",
"诊断",
"意见",
"提示",
)
# 段首 = 全文开头或换行之后;沿用旧代码,允许标题前有空格或制表符缩进。
# 换行认 str.splitlines 认的全部行边界(含 \x0b、\x0c、\x85、U+2028、U+2029),
# 冒号除 NFKC 后的「:」外也认比例号「∶」(U+2236,NFKC 不把它转成冒号)。
_LINE_BREAKS = "\r\n\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029"
_CONCLUSION_HEADING = re.compile(
rf"(?:^|(?<=[{_LINE_BREAKS}]))[ \t]*(" + "|".join(CONCLUSION_KEYWORDS) + r")\s*[:∶]"
)
# 归一并转大写后按子串查。「浆液性」「黏液性」单独出现是描述囊液的正常用语,不查。
# 词根覆盖了更长的写法(「SPT」覆盖「SPTP」,「导管内乳头」覆盖「导管内乳头状」),长的不再单列。
SUBTYPE_TERMS = (
"IPMN",
"IPMT",
"SPN",
"SPT",
"MCN",
"SCN",
"导管内乳头",
"实性假乳头",
"黏液性囊",
"粘液性囊",
"黏液囊性",
"粘液囊性",
"浆液性囊",
"浆液性微囊",
"囊腺瘤",
)
# 阿拉伯数字或中文数字,可带「周」,简繁「岁」都认。
_AGE = re.compile(r"[0-9一二三四五六七八九十百]+\s*周?[岁歲]")
SEX_CHARACTERS = ("男", "女")
# 不带冒号出现时不算检查不过,只列进复核清单。
REVIEW_WORDS = ("考虑", "可能", "倾向", "提示")
_SENTENCE = re.compile(r"[^。;]+")
def normalize(text: str) -> str:
"""NFKC 归一。"""
return unicodedata.normalize("NFKC", text)
def _sentence_at(normalized: str, position: int) -> str:
for match in _SENTENCE.finditer(normalized):
if match.start() <= position < match.end():
return match.group()
raise ValueError(f"位置 {position} 不在任何句子里(落在分隔符上)")
def conclusion_headings(text: str) -> list[tuple[str, str]]:
"""段首的结论段标题:每处命中给出(关键词,所在句子)。"""
normalized = normalize(text)
return [
(m.group(1), _sentence_at(normalized, m.start(1)))
for m in _CONCLUSION_HEADING.finditer(normalized)
]
def subtype_terms(text: str) -> list[str]:
"""报告文字里出现的亚型名。"""
upper = normalize(text).upper()
return [term for term in SUBTYPE_TERMS if term in upper]
def demographic_terms(text: str) -> list[str]:
"""报告文字里出现的「数字岁」和性别字。"""
normalized = normalize(text)
ages = [m.group() for m in _AGE.finditer(normalized)]
return ages + [ch for ch in SEX_CHARACTERS if ch in normalized]
def review_sentences(text: str) -> list[tuple[str, str]]:
"""含复核措辞的句子:每句一条(命中的词用「、」连接,句子)。按「。」「;」切句。"""
found: list[tuple[str, str]] = []
for match in _SENTENCE.finditer(normalize(text)):
sentence = match.group()
words = [w for w in REVIEW_WORDS if w in sentence]
if words:
found.append(("、".join(words), sentence))
return found
+30
View File
@@ -0,0 +1,30 @@
"""library:给专家的资料库的目录布局、文件格式、写入和按路读取。
拒绝读取名字以 `.partial` 结尾的目录;读每个文件时核对清单里的 SHA-256。
"""
from cadx.library._read import (
ClinicalRoute,
MrRoute,
UltrasoundRoute,
list_aliases,
read_clinical,
read_mr,
read_ultrasound,
)
from cadx.library._scan import LeakHit, scan_for_leaks
from cadx.library._write import LibraryCase, write_library
__all__ = [
"ClinicalRoute",
"LeakHit",
"LibraryCase",
"MrRoute",
"UltrasoundRoute",
"list_aliases",
"read_clinical",
"read_mr",
"read_ultrasound",
"scan_for_leaks",
"write_library",
]
+83
View File
@@ -0,0 +1,83 @@
"""按路读出资料库,读时核对清单里的 SHA-256。"""
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
from cadx.library._write import (
CLINICAL_JSON,
KEY_IMAGE,
MANIFEST,
MR_REPORT,
ULTRASOUND_REPORT,
case_file,
sha256_file,
)
@dataclass(frozen=True)
class ClinicalRoute:
"""临床一路:性别、年龄、症状、既往史、化验。"""
codes_decoded: bool
fields: dict[str, str | int | float | None]
@dataclass(frozen=True)
class UltrasoundRoute:
"""超声一路:报告「所见」原文和医生截的关键图(给路径)。"""
report: str
key_image: Path
@dataclass(frozen=True)
class MrRoute:
"""MR 一路:报告「所见」原文。"""
report: str
def _manifest(build_dir: Path) -> dict[str, Any]:
if build_dir.name.endswith(".partial"):
raise ValueError(f"{build_dir} 是没写完的构建(.partial),不能读")
return cast(dict[str, Any], json.loads((build_dir / MANIFEST).read_text(encoding="utf-8")))
def _verified(build_dir: Path, alias: str, relative: str) -> Path:
manifest = _manifest(build_dir)
if alias not in manifest["aliases"]:
raise ValueError(f"构建 {build_dir.name} 里没有化名 {alias}")
key = case_file(alias, relative)
path = build_dir / key
if sha256_file(path) != manifest["files"][key]:
raise ValueError(f"{key} 的 SHA-256 与清单不一致,资料库被改动过")
return path
def _text(path: Path) -> str:
# 按字节解码,不做换行转换,读出的就是写进去的原文。
return path.read_bytes().decode("utf-8")
def list_aliases(build_dir: Path) -> tuple[str, ...]:
"""一次构建的全部化名(按化名排序)。"""
return tuple(_manifest(build_dir)["aliases"])
def read_clinical(build_dir: Path, alias: str) -> ClinicalRoute:
"""读一例的临床一路。"""
content = json.loads(_text(_verified(build_dir, alias, CLINICAL_JSON)))
return ClinicalRoute(codes_decoded=content["codes_decoded"], fields=content["fields"])
def read_ultrasound(build_dir: Path, alias: str) -> UltrasoundRoute:
"""读一例的超声一路。"""
report = _text(_verified(build_dir, alias, ULTRASOUND_REPORT))
return UltrasoundRoute(report=report, key_image=_verified(build_dir, alias, KEY_IMAGE))
def read_mr(build_dir: Path, alias: str) -> MrRoute:
"""读一例的 MR 一路。"""
return MrRoute(report=_text(_verified(build_dir, alias, MR_REPORT)))
+68
View File
@@ -0,0 +1,68 @@
"""泄露扫描:在一次构建的文字文件里找不该出现的字符串和 JSON 键名。"""
import json
import unicodedata
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import cast
@dataclass(frozen=True)
class LeakHit:
"""一处命中。`kind` 是「字符串」或「键名」。"""
file: str # 相对构建目录
kind: str
term: str
def _fold(text: str) -> str:
"""NFKC 再 casefold:全角、大小写写法都按同一个词比。"""
return unicodedata.normalize("NFKC", text).casefold()
def _json_keys_and_strings(node: object, keys: list[str], strings: list[str]) -> None:
# json.loads 出来的对象:dict 的键都是字符串。
if isinstance(node, dict):
for key, value in cast(dict[str, object], node).items():
keys.append(key)
_json_keys_and_strings(value, keys, strings)
elif isinstance(node, list):
for item in cast(list[object], node):
_json_keys_and_strings(item, keys, strings)
elif isinstance(node, str):
strings.append(node)
def scan_for_leaks(
build_dir: Path, forbidden_strings: Sequence[str], forbidden_keys: Sequence[str]
) -> list[LeakHit]:
"""扫构建目录下全部 `.txt`、`.json`(含清单),不扫 PNG。
字符串做 NFKC 并 casefold 后按子串比较;JSON 文件除原文外还扫解析后的键和字符串值,
这样以 `\\uXXXX` 转义写出的中文也查得到。键名按整个键名、不分大小写比较。
"""
hits: list[LeakHit] = []
banned_keys = {key.casefold() for key in forbidden_keys}
paths = sorted(p for p in build_dir.rglob("*") if p.suffix in (".txt", ".json"))
for path in paths:
relative = path.relative_to(build_dir).as_posix()
text = path.read_bytes().decode("utf-8")
haystacks = [text]
if path.suffix == ".json":
keys: list[str] = []
_json_keys_and_strings(json.loads(text), keys, haystacks)
hits.extend(
LeakHit(relative, "键名", key)
for key in sorted(set(keys))
if key.casefold() in banned_keys
)
haystacks.extend(keys)
folded = [_fold(h) for h in haystacks]
hits.extend(
LeakHit(relative, "字符串", term)
for term in forbidden_strings
if any(_fold(term) in h for h in folded)
)
return hits
+119
View File
@@ -0,0 +1,119 @@
"""资料库的目录布局与写入。
```
<构建目录>/
manifest.json
cases/<化名>/clinical/clinical.json
cases/<化名>/ultrasound/report.txt
cases/<化名>/ultrasound/key.png
cases/<化名>/mr/report.txt
```
"""
import hashlib
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from PIL import Image
MANIFEST = "manifest.json"
CLINICAL_JSON = "clinical/clinical.json"
ULTRASOUND_REPORT = "ultrasound/report.txt"
KEY_IMAGE = "ultrasound/key.png"
MR_REPORT = "mr/report.txt"
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
# 能逐像素保真重编码的 (位深, PNG 颜色类型):8 位的灰度、RGB、灰度加透明、RGBA,以及 1 位灰度。
# 16 位图 Pillow 读 RGB 时会悄悄降成 8 位;调色板图按像素重编码会丢调色板。40 例都是 8 位 RGB。
_FAITHFUL = frozenset({(8, 0), (8, 2), (8, 4), (8, 6), (1, 0)})
@dataclass(frozen=True)
class LibraryCase:
"""要写进资料库的一例:三路各自的内容。"""
alias: str
clinical_fields: Mapping[str, str | int | float | None]
ultrasound_report: str
key_image_source: Path
mr_report: str
def case_file(alias: str, relative: str) -> str:
"""病例文件相对构建目录的路径。"""
return f"cases/{alias}/{relative}"
def sha256_file(path: Path) -> str:
"""文件内容的 SHA-256。"""
return hashlib.sha256(path.read_bytes()).hexdigest()
def _write_json(path: Path, content: object) -> None:
# ensure_ascii=False:中文原样写出,泄露扫描按子串才查得到。
path.write_text(json.dumps(content, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _write_clean_png(source: Path, target: Path) -> None:
"""只保留像素和颜色模式重新编码:文本块、EXIF、ICC 等附加数据都不带过去。
不能逐像素保真的图(非 PNG、位深或颜色类型不在 `_FAITHFUL` 里、带 tRNS 透明色)报错停下,
不悄悄降位或丢透明。
"""
header = source.read_bytes()[:26]
if header[:8] != _PNG_SIGNATURE or header[12:16] != b"IHDR":
raise ValueError(f"关键图不是 PNG:{source}")
bit_depth, color_type = header[24], header[25]
if (bit_depth, color_type) not in _FAITHFUL:
raise ValueError(
f"关键图位深 {bit_depth}、颜色类型 {color_type} 不能逐像素保真重编码:{source}"
)
with Image.open(source) as image:
if "transparency" in image.info:
raise ValueError(f"关键图带 tRNS 透明色,重编码会丢透明:{source}")
clean = Image.frombytes(image.mode, image.size, image.tobytes())
clean.save(target, format="PNG")
def write_library(
build_dir: Path,
build_id: str,
git_commit: str,
git_clean: bool,
config_sha256: Mapping[str, str],
codes_decoded: bool,
uncompared: Sequence[str],
ceus_source: str | None,
cases: Sequence[LibraryCase],
) -> None:
"""把一次构建写进已经存在的空目录 `build_dir`,最后写清单。"""
files: dict[str, str] = {}
for case in cases:
case_dir = build_dir / "cases" / case.alias
for relative in (CLINICAL_JSON, ULTRASOUND_REPORT, MR_REPORT):
(case_dir / relative).parent.mkdir(parents=True, exist_ok=True)
_write_json(
case_dir / CLINICAL_JSON,
{"codes_decoded": codes_decoded, "fields": dict(case.clinical_fields)},
)
(case_dir / ULTRASOUND_REPORT).write_text(case.ultrasound_report, encoding="utf-8")
_write_clean_png(case.key_image_source, case_dir / KEY_IMAGE)
(case_dir / MR_REPORT).write_text(case.mr_report, encoding="utf-8")
for relative in (CLINICAL_JSON, ULTRASOUND_REPORT, KEY_IMAGE, MR_REPORT):
files[case_file(case.alias, relative)] = sha256_file(case_dir / relative)
_write_json(
build_dir / MANIFEST,
{
"build_id": build_id,
"git_commit": git_commit,
"git_clean": git_clean,
"config_sha256": dict(config_sha256),
"codes_decoded": codes_decoded,
"uncompared": list(uncompared),
"ceus_source": ceus_source,
"aliases": sorted(case.alias for case in cases),
"files": dict(sorted(files.items())),
},
)
+5
View File
@@ -0,0 +1,5 @@
"""runs:构建号怎么取、运行快照记什么。会诊运行目录等会诊那一步再加。"""
from cadx.runs._snapshot import GitState, make_build_id, read_git_state, write_snapshot
__all__ = ["GitState", "make_build_id", "read_git_state", "write_snapshot"]
+81
View File
@@ -0,0 +1,81 @@
"""构建号与运行快照:一次构建用的是哪份代码、哪份配置、哪个环境。"""
import hashlib
import json
import shutil
import subprocess
import sys
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@dataclass(frozen=True)
class GitState:
"""仓库当前的提交和工作区状态。"""
commit: str
short_commit: str
clean: bool # `git status --porcelain` 为空(含未跟踪文件)
diff_sha256: str # `git diff HEAD` 输出的 SHA-256
def _git(repo_root: Path, *args: str) -> bytes:
git = shutil.which("git")
if git is None:
raise RuntimeError("找不到 git,无法记录构建用的代码版本")
return subprocess.run( # noqa: S603 参数是写死的 git 子命令,不含外部输入
[git, *args], cwd=repo_root, check=True, capture_output=True
).stdout
def read_git_state(repo_root: Path) -> GitState:
"""读出 `repo_root` 所在仓库的提交号、是否干净、未提交改动的哈希。"""
return GitState(
commit=_git(repo_root, "rev-parse", "HEAD").decode().strip(),
short_commit=_git(repo_root, "rev-parse", "--short", "HEAD").decode().strip(),
clean=_git(repo_root, "status", "--porcelain").strip() == b"",
diff_sha256=hashlib.sha256(_git(repo_root, "diff", "HEAD")).hexdigest(),
)
def make_build_id(now: datetime, git: GitState) -> str:
"""`YYYYMMDD-HHMMSS-<git 短哈希>`,工作区不干净时末尾加 `-dirty`。`now` 用本地时间。"""
build_id = f"{now:%Y%m%d-%H%M%S}-{git.short_commit}"
return build_id if git.clean else f"{build_id}-dirty"
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def write_snapshot(
snapshot_dir: Path,
build_id: str,
git: GitState,
started_at: datetime,
finished_at: datetime,
uv_lock: Path,
configs: Mapping[str, Path],
) -> None:
"""建 `snapshot_dir`,写 snapshot.json,并把 `configs`(快照里的文件名 → 原文件)原样复制进去。"""
snapshot_dir.mkdir(exist_ok=False)
copied: dict[str, str] = {}
for name, source in configs.items():
shutil.copyfile(source, snapshot_dir / name)
copied[name] = _sha256(snapshot_dir / name)
snapshot = {
"build_id": build_id,
"git_commit": git.commit,
"git_clean": git.clean,
"git_diff_head_sha256": git.diff_sha256,
"python_version": sys.version,
"uv_lock_sha256": _sha256(uv_lock),
"started_at": started_at.isoformat(),
"finished_at": finished_at.isoformat(),
"config_sha256": copied,
}
(snapshot_dir / "snapshot.json").write_text(
json.dumps(snapshot, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)