Import sanitized HIS processing tools

This commit is contained in:
2026-05-26 08:51:10 +08:00
commit 7e329f1d85
46 changed files with 19175 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
{
"说明": "每个具体子科室只归属一个大科室aliases 用于把图片文件夹名归一到标准子科室名。",
"大科室列表": [
{
"大科室": "肝胆外科及肝移植相关",
"子科室": [
"肝胆外科1",
"肝胆外科2",
"肝胆外科3",
"肝胆外科4",
"肝胆外科5D",
"肝胆外科手术室",
"肝胆特种病区L",
"肝移植内",
"肝移植内N"
]
},
{
"大科室": "普通外科及腹部外科",
"子科室": [
"普外科1",
"普外科2",
"普外科3",
"普外科4D",
"腹部外科L",
"普通腺体外科",
"普通胃肠外科"
]
},
{
"大科室": "急诊医学科",
"子科室": [
"急诊中心",
"急诊中心L",
"急诊重症医学科L"
]
},
{
"大科室": "重症医学科",
"子科室": [
"※重症医学1病区",
"重症医学2N病区",
"重症医学3病区",
"重症医学4D病区",
"外科ICU",
"肝胆ICU",
"肿瘤外科重症",
"感染重症"
]
},
{
"大科室": "骨科",
"子科室": [
"骨科",
"骨科1",
"骨科2"
]
},
{
"大科室": "泌尿外科",
"子科室": [
"泌尿外1",
"泌尿外2"
]
},
{
"大科室": "呼吸内科",
"子科室": [
"呼吸内科3病区"
]
},
{
"大科室": "耳鼻喉头颈外科",
"子科室": [
"耳鼻喉D",
"耳鼻喉头颈外科",
"耳鼻喉头颈外科L",
"耳鼻喉2病区"
]
},
{
"大科室": "日间诊疗中心",
"子科室": [
"日间手术中心",
"日间中心病房L"
]
},
{
"大科室": "乳腺外科",
"子科室": [
"乳腺外科1N",
"乳腺外科2N",
"乳腺外科2D"
]
},
{
"大科室": "胸外科",
"子科室": [
"胸外1",
"胸外2",
"胸外3",
"胸外4D"
]
},
{
"大科室": "肝病内科",
"子科室": [
"肝病内科"
]
},
{
"大科室": "感染科",
"子科室": [
"感染1",
"感染2",
"感染3"
]
},
{
"大科室": "肿瘤放疗科",
"子科室": [
"肿瘤放疗1",
"肿瘤放疗2",
"肿瘤放疗3",
"肿瘤放疗病区L",
"肿瘤放疗中心L【无人】",
"肿瘤放疗日间【无人】"
]
},
{
"大科室": "特需/涉外病房",
"子科室": [
"健苑五涉外病房"
]
},
{
"大科室": "老年外科",
"子科室": [
"老年外科"
]
}
],
"aliases": {
"呼吸内科3": "呼吸内科3病区",
"外科ICU": "外科ICU",
"急诊中心病房": "急诊中心",
"急诊中心": "急诊中心",
"急诊中心病房L": "急诊中心L",
"急诊中心L": "急诊中心L",
"急诊重症医学L": "急诊重症医学科L",
"急诊重症医学": "急诊重症医学科L",
"急诊重症L": "急诊重症医学科L",
"急诊重症医学科L": "急诊重症医学科L",
"感染科1": "感染1",
"感染科2": "感染2",
"感染科3": "感染3",
"感染1": "感染1",
"感染2": "感染2",
"感染3": "感染3",
"感染科重症": "感染重症",
"感染重症": "感染重症",
"肝病内科": "肝病内科",
"肝病内科N": "肝病内科",
"肝胆外科1": "肝胆外科1",
"肝胆外科2": "肝胆外科2",
"肝胆外科3": "肝胆外科3",
"肝胆外科4": "肝胆外科4",
"肝胆1": "肝胆外科1",
"肝胆2": "肝胆外科2",
"肝胆3": "肝胆外科3",
"肝胆4": "肝胆外科4",
"肝胆5": "肝胆外科5D",
"肝胆_5": "肝胆外科5D",
"肝胆5D": "肝胆外科5D",
"肝胆外科5病房D": "肝胆外科5D",
"肝胆特种L": "肝胆特种病区L",
"肝胆特种病区L": "肝胆特种病区L",
"肝胆移植内": "肝移植内",
"肝移植内": "肝移植内",
"肝移植内科": "肝移植内",
"肝移植内N": "肝移植内N",
"肝移植内科N": "肝移植内N",
"肿瘤放疗1": "肿瘤放疗1",
"肿瘤放疗2": "肿瘤放疗2",
"肿瘤放疗3": "肿瘤放疗3",
"肿放1": "肿瘤放疗1",
"肿放2": "肿瘤放疗2",
"肿放3": "肿瘤放疗3",
"肿瘤放疗L": "肿瘤放疗病区L",
"肿放L": "肿瘤放疗病区L",
"重症医学1": "※重症医学1病区",
"重症医学科1": "※重症医学1病区",
"重症1": "※重症医学1病区",
"重症医学2": "重症医学2N病区",
"重症医学科2": "重症医学2N病区",
"重症2": "重症医学2N病区",
"重症2N": "重症医学2N病区",
"重症医学3": "重症医学3病区",
"重症医学科3": "重症医学3病区",
"重症3": "重症医学3病区",
"重症医学4": "重症医学4D病区",
"重症医学科4": "重症医学4D病区",
"重症4": "重症医学4D病区",
"重症4D": "重症医学4D病区",
"胸外1": "胸外1",
"胸外2": "胸外2",
"胸外3": "胸外3",
"胸外4D": "胸外4D",
"普外1": "普外科1",
"普外2": "普外科2",
"普外3": "普外科3",
"普外4D": "普外科4D",
"普通外科1": "普外科1",
"普通外科2": "普外科2",
"普通外科3": "普外科3",
"普通外科4": "普外科4D",
"普通外科4D": "普外科4D",
"普通腺体外科": "普通腺体外科",
"普通胃肠": "普通胃肠外科",
"普通胃肠外科": "普通胃肠外科",
"腹部外科L": "腹部外科L",
"肝胆ICU": "肝胆ICU",
"日间手术": "日间手术中心",
"日间手术中心": "日间手术中心",
"日间手术L": "日间中心病房L",
"日间中心L": "日间中心病房L",
"日间中心病房L": "日间中心病房L",
"耳鼻喉": "耳鼻喉头颈外科",
"耳鼻喉D": "耳鼻喉D",
"耳鼻喉L": "耳鼻喉头颈外科L",
"耳鼻喉头颈外科": "耳鼻喉头颈外科",
"耳鼻喉头颈外科L": "耳鼻喉头颈外科L",
"耳鼻咽喉": "耳鼻喉头颈外科",
"耳鼻咽喉头颈外科": "耳鼻喉头颈外科",
"耳鼻喉2": "耳鼻喉2病区",
"耳鼻喉2D": "耳鼻喉2病区",
"耳鼻喉头颈2": "耳鼻喉2病区",
"乳腺外科": "乳腺外科1N",
"乳腺外科1": "乳腺外科1N",
"乳腺外科1N": "乳腺外科1N",
"乳腺外科2": "乳腺外科2N",
"乳腺外科2N": "乳腺外科2N",
"乳腺外科2D": "乳腺外科2D",
"乳腺1N": "乳腺外科1N",
"乳腺2N": "乳腺外科2N",
"乳腺2D": "乳腺外科2D",
"骨科": "骨科",
"骨科1": "骨科1",
"骨科2": "骨科2",
"骨科_1": "骨科1",
"骨科_2": "骨科2",
"泌尿1": "泌尿外1",
"泌尿2": "泌尿外2",
"健苑五涉外": "健苑五涉外病房",
"健苑五涉外病房": "健苑五涉外病房",
"老年外科": "老年外科"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,247 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Consolidate patient archive result folders."""
from __future__ import annotations
import csv
import json
from collections import Counter
from pathlib import Path
from typing import Any
ROOT = Path("数据处理结果区")
RESULT_SUFFIX = "-列表归档结果"
INFO_DIR_NAME = "信息记录"
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def write_jsonl(path: Path, records: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as file:
for record in records:
file.write(json.dumps(record, ensure_ascii=False) + "\n")
def flatten_record(batch_name: str, record: dict[str, Any]) -> dict[str, Any]:
patient = record["患者信息"]
image = record["图片信息"]
review = record["复核"]
return {
"处理批次": batch_name,
"大科室": record["大科室"],
"子科室": record["子科室"],
"来源文件夹": record["来源文件夹"],
"图片路径": image["图片路径"],
"图片名": image["图片名"],
"图片内行号": image["图片内行号"],
"拼接组序号": image["拼接组序号"],
"OCR请求ID": image.get("OCR请求ID", ""),
"姓名": patient["姓名"],
"性别": patient["性别"],
"年龄": patient["年龄"],
"住院号": patient["住院号"],
"诊断": patient["诊断"],
"入院时间": patient["入院时间"],
"最后书写时间": patient["最后书写时间"],
"住院天数": patient["住院天数"],
"出院时间": patient["出院时间"],
"手术后天数": patient["手术后天数"],
"复核状态": review["状态"],
"复核提示": review_note_text(review),
"人工修正": bool(review.get("人工修正")),
}
def review_note_text(review: dict[str, Any]) -> str:
notes = [str(item) for item in review.get("提示", []) if str(item) and str(item) != "缺少出院时间"]
if review.get("人工备注"):
notes.append(f"人工备注: {review['人工备注']}")
return "".join(notes)
def record_quality_rank(record: dict[str, Any]) -> tuple[int, int, int, int]:
patient = record["患者信息"]
review = record["复核"]
review_ok = 0 if review.get("状态") == "需人工复核" else 1
manual_corrected = 1 if review.get("人工修正") else 0
date_count = int(bool(patient.get("入院时间"))) + int(bool(patient.get("出院时间")))
filled_count = sum(1 for value in patient.values() if value not in ("", None))
return (review_ok, manual_corrected, date_count, filled_count)
def summarize_record_for_duplicate(record: dict[str, Any]) -> dict[str, Any]:
patient = record["患者信息"]
image = record["图片信息"]
review = record["复核"]
return {
"处理批次": record["处理批次"],
"大科室": record["大科室"],
"子科室": record["子科室"],
"来源文件夹": record["来源文件夹"],
"图片路径": image["图片路径"],
"图片名": image["图片名"],
"图片内行号": image["图片内行号"],
"姓名": patient.get("姓名", ""),
"住院号": patient.get("住院号", ""),
"入院时间": patient.get("入院时间", ""),
"出院时间": patient.get("出院时间", ""),
"复核状态": review.get("状态", ""),
}
def deduplicate_records_by_inpatient_no(
records: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
kept_by_no: dict[str, dict[str, Any]] = {}
kept_order: list[str] = []
duplicates: list[dict[str, Any]] = []
missing_inpatient_no_records: list[dict[str, Any]] = []
for record in records:
inpatient_no = str(record["患者信息"].get("住院号", "")).strip()
if not inpatient_no:
missing_inpatient_no_records.append(
{
"剔除记录": summarize_record_for_duplicate(record),
"规则": "住院号为空,未纳入合并总表和数据库",
}
)
continue
if inpatient_no not in kept_by_no:
kept_order.append(inpatient_no)
else:
previous = kept_by_no[inpatient_no]
duplicates.append(
{
"住院号": inpatient_no,
"保留记录": summarize_record_for_duplicate(record),
"剔除记录": summarize_record_for_duplicate(previous),
"规则": "住院号重复,后出现记录覆盖先出现记录",
}
)
kept_by_no[inpatient_no] = record
return [kept_by_no[inpatient_no] for inpatient_no in kept_order], duplicates, missing_inpatient_no_records
def slim_department_summary(batch_name: str, item: dict[str, Any]) -> dict[str, Any]:
raw_tips = item.get("提示", [])
if isinstance(raw_tips, list):
tips = "".join(str(tip) for tip in raw_tips)
else:
tips = str(raw_tips)
return {
"处理批次": batch_name,
"来源文件夹": item["来源文件夹"],
"大科室": item["大科室"],
"子科室": item["子科室"],
"图片数": item["图片数"],
"记录数": item["记录数"],
"拼接组数": item["拼接组数"],
"提示": tips,
}
def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
if not rows:
return
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(file, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
def main() -> None:
ROOT.mkdir(exist_ok=True)
raw_records: list[dict[str, Any]] = []
batch_summaries: list[dict[str, Any]] = []
for result_dir in sorted(ROOT.rglob(f"*{RESULT_SUFFIX}")):
archive_path = result_dir / "患者列表_结构化.json"
review_path = result_dir / "复核报告.json"
if not archive_path.exists():
continue
batch_name = result_dir.name[: -len(RESULT_SUFFIX)]
archive = json.loads(archive_path.read_text(encoding="utf-8"))
review = json.loads(review_path.read_text(encoding="utf-8")) if review_path.exists() else {}
department_summaries = [
slim_department_summary(batch_name, item) for item in archive.get("科室汇总", [])
]
summary = {
"处理批次": batch_name,
"结果目录": str(result_dir),
**archive.get("汇总", {}),
"科室归类": archive.get("科室归类", []),
"科室汇总": department_summaries,
"需人工复核记录数": len(review.get("需人工复核记录", [])),
"人工修正记录数": len(review.get("人工修正记录", [])),
}
batch_summaries.append(summary)
summary_dir = result_dir / INFO_DIR_NAME
write_json(summary_dir / "汇总.json", {**summary, "科室汇总": department_summaries})
write_csv(summary_dir / "科室汇总.csv", department_summaries)
for record in archive.get("患者记录", []):
enriched = {"处理批次": batch_name, **record}
raw_records.append(enriched)
all_records, duplicate_records, missing_inpatient_no_records = deduplicate_records_by_inpatient_no(raw_records)
flat_records = [flatten_record(record["处理批次"], record) for record in all_records]
kept_by_batch = Counter(record["处理批次"] for record in all_records)
dropped_by_batch = Counter(item["剔除记录"]["处理批次"] for item in duplicate_records)
missing_by_batch = Counter(item["剔除记录"]["处理批次"] for item in missing_inpatient_no_records)
batch_level_duplicate_count = sum(int(item.get("重复住院号剔除记录数", 0)) for item in batch_summaries)
batch_level_raw_count = sum(
int(item.get("去重前患者记录数", int(item.get("患者记录数", 0)) + int(item.get("重复住院号剔除记录数", 0))))
for item in batch_summaries
)
for summary in batch_summaries:
batch_name = summary["处理批次"]
original_count = int(summary.get("患者记录数", 0))
summary["原始患者记录数"] = original_count
summary["合并阶段重复住院号剔除记录数"] = int(dropped_by_batch.get(batch_name, 0))
summary["合并阶段缺少住院号剔除记录数"] = int(missing_by_batch.get(batch_name, 0))
summary["患者记录数"] = int(kept_by_batch.get(batch_name, 0))
write_json(Path(summary["结果目录"]) / INFO_DIR_NAME / "汇总.json", summary)
global_summary = {
"批次数": len(batch_summaries),
"总图片数": sum(int(item.get("图片数", 0)) for item in batch_summaries),
"批次内去重前患者记录数": batch_level_raw_count,
"批次内重复住院号剔除记录数": batch_level_duplicate_count,
"合并前患者记录数": len(raw_records),
"总患者记录数": len(all_records),
"合并阶段重复住院号剔除记录数": len(duplicate_records),
"合并阶段缺少住院号剔除记录数": len(missing_inpatient_no_records),
"重复住院号剔除记录数": batch_level_duplicate_count + len(duplicate_records),
"需人工复核记录数": sum(int(item.get("需人工复核记录数", 0)) for item in batch_summaries),
"人工修正记录数": sum(int(item.get("人工修正记录数", 0)) for item in batch_summaries),
"批次汇总": batch_summaries,
}
write_json(ROOT / INFO_DIR_NAME / "全局汇总.json", global_summary)
write_csv(ROOT / INFO_DIR_NAME / "批次汇总.csv", batch_summaries)
write_json(
ROOT / "合并_患者列表_结构化.json",
{
"汇总": global_summary,
"重复住院号剔除记录": duplicate_records,
"缺少住院号剔除记录": missing_inpatient_no_records,
"患者记录": all_records,
},
)
write_jsonl(ROOT / "合并_患者列表_记录.jsonl", all_records)
write_csv(ROOT / "合并_患者列表_记录.csv", flat_records)
write_json(ROOT / INFO_DIR_NAME / "重复住院号报告.json", duplicate_records)
write_json(ROOT / INFO_DIR_NAME / "缺少住院号报告.json", missing_inpatient_no_records)
print(json.dumps(global_summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,233 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Sync consolidated HIS patient records into PostgreSQL single table.
Database credentials are read from environment variables by default:
HIS_DB_HOST, HIS_DB_PORT, HIS_DB_NAME, HIS_DB_USER, HIS_DB_PASSWORD.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Any
DEFAULT_RESULT_JSON = Path("数据处理结果区/合并_患者列表_结构化.json")
DEFAULT_SCHEMA_SQL = Path("数据处理工作区/06_PostgreSQL建表结构.sql")
TABLE_NAME = '"Patient_Lists"'
PATIENT_FIELDS = [
"batch_name",
"major_department",
"sub_department",
"source_folder",
"image_path",
"image_name",
"image_row_no",
"patient_name",
"gender",
"age",
"inpatient_no",
"diagnosis",
"admission_time",
"last_write_time",
"hospital_days",
"discharge_time",
"postoperative_days",
"review_status",
"review_notes",
"manual_corrected",
]
def scalar(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def clean_integer(value: Any) -> tuple[str, str]:
text = scalar(value).strip()
if not text:
return "", ""
if text.isdigit():
return text, ""
return "", f"住院天数非数字,原识别值: {text}"
def review_note_items(review: dict[str, Any]) -> list[str]:
tips = review.get("提示", [])
if isinstance(tips, list):
note_items = [str(tip) for tip in tips if str(tip) and str(tip) != "缺少出院时间"]
else:
note_items = [str(tips)] if str(tips) else []
if review.get("人工备注"):
note_items.append(f"人工备注: {review['人工备注']}")
return note_items
def psql_quote(path: Path) -> str:
return "'" + str(path.resolve()).replace("'", "''") + "'"
def flatten_patient_record(record: dict[str, Any]) -> dict[str, Any]:
patient = record["患者信息"]
image = record["图片信息"]
review = record["复核"]
note_items = review_note_items(review)
hospital_days, hospital_days_note = clean_integer(patient["住院天数"])
if hospital_days_note:
note_items.append(hospital_days_note)
review_notes = "".join(note_items)
return {
"batch_name": record["处理批次"],
"major_department": record["大科室"],
"sub_department": record["子科室"],
"source_folder": record["来源文件夹"],
"image_path": image["图片路径"],
"image_name": image["图片名"],
"image_row_no": image["图片内行号"],
"patient_name": patient["姓名"],
"gender": patient["性别"],
"age": patient["年龄"],
"inpatient_no": patient["住院号"],
"diagnosis": patient["诊断"],
"admission_time": patient["入院时间"],
"last_write_time": patient["最后书写时间"],
"hospital_days": hospital_days,
"discharge_time": patient["出院时间"],
"postoperative_days": patient["手术后天数"],
"review_status": review["状态"],
"review_notes": review_notes,
"manual_corrected": bool(review.get("人工修正")),
}
def prepare_unique_rows(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str], list[str]]:
kept_by_no: dict[str, dict[str, Any]] = {}
kept_order: list[str] = []
replacements: list[str] = []
invalid: list[str] = []
for row in rows:
inpatient_no = str(row.get("inpatient_no", "")).strip()
location = f"{row['batch_name']} / {row['image_path']} / 第{row['image_row_no']}"
if not inpatient_no:
invalid.append(f"{location}: 空")
continue
if inpatient_no not in kept_by_no:
kept_order.append(inpatient_no)
else:
previous = kept_by_no[inpatient_no]
replacements.append(
f"{inpatient_no}: {previous['batch_name']} / {previous['image_path']} / 第{previous['image_row_no']}行 -> {location}"
)
kept_by_no[inpatient_no] = row
return [kept_by_no[inpatient_no] for inpatient_no in kept_order], replacements, invalid
def load_rows(result_json: Path) -> tuple[list[dict[str, Any]], list[str], list[str]]:
merged = json.loads(result_json.read_text(encoding="utf-8"))
rows = [flatten_patient_record(record) for record in merged.get("患者记录", [])]
return prepare_unique_rows(rows)
def write_temp_csv(rows: list[dict[str, Any]]) -> Path:
temp_file = tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
newline="",
suffix="_his_patient_lists.csv",
delete=False,
)
with temp_file:
writer = csv.DictWriter(temp_file, fieldnames=PATIENT_FIELDS)
writer.writeheader()
for row in rows:
writer.writerow({field: scalar(row.get(field, "")) for field in PATIENT_FIELDS})
return Path(temp_file.name)
def sync_to_postgres(args: argparse.Namespace, csv_path: Path, row_count: int) -> None:
sql = f"""\\set ON_ERROR_STOP on
\\i {psql_quote(Path(args.schema))}
TRUNCATE TABLE {TABLE_NAME} RESTART IDENTITY;
\\copy {TABLE_NAME}({','.join(PATIENT_FIELDS)}) FROM {psql_quote(csv_path)} WITH (FORMAT csv, HEADER true, NULL '')
"""
env = os.environ.copy()
password = args.password or env.get("HIS_DB_PASSWORD")
if password:
env["PGPASSWORD"] = password
command = [
"psql",
"-h",
args.host,
"-p",
str(args.port),
"-U",
args.user,
"-d",
args.dbname,
"-v",
"ON_ERROR_STOP=1",
]
completed = subprocess.run(command, input=sql, text=True, env=env, capture_output=True)
print(completed.stdout, end="")
if completed.returncode != 0:
print(completed.stderr, end="")
completed.check_returncode()
print(json.dumps({"synced_table": "Patient_Lists", "records": row_count}, ensure_ascii=False))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", default=str(DEFAULT_RESULT_JSON), help="合并后的患者记录 JSON")
parser.add_argument("--schema", default=str(DEFAULT_SCHEMA_SQL), help="单表建表 SQL")
parser.add_argument("--host", default=os.getenv("HIS_DB_HOST", "DB_HOST"))
parser.add_argument("--port", default=os.getenv("HIS_DB_PORT", "5432"))
parser.add_argument("--dbname", default=os.getenv("HIS_DB_NAME", "DB_NAME"))
parser.add_argument("--user", default=os.getenv("HIS_DB_USER", "DB_USER"))
parser.add_argument("--password", default="", help="数据库密码;建议改用 HIS_DB_PASSWORD 环境变量")
return parser.parse_args()
def main() -> None:
args = parse_args()
rows, replacements, invalid = load_rows(Path(args.input))
csv_path = write_temp_csv(rows)
try:
sync_to_postgres(args, csv_path, len(rows))
if invalid:
print(
json.dumps(
{
"skipped_empty_inpatient_no": len(invalid),
"empty_examples": invalid[:20],
},
ensure_ascii=False,
)
)
if replacements:
print(
json.dumps(
{
"deduplicated_by_inpatient_no": len(replacements),
"replacement_examples": replacements[:20],
},
ensure_ascii=False,
)
)
finally:
csv_path.unlink(missing_ok=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,84 @@
DROP TABLE IF EXISTS patient_records CASCADE;
CREATE TABLE IF NOT EXISTS "Patient_Lists" (
record_id bigserial PRIMARY KEY,
batch_name text NOT NULL,
major_department text NOT NULL,
sub_department text NOT NULL,
source_folder text NOT NULL,
image_path text NOT NULL,
image_name text NOT NULL,
image_row_no integer NOT NULL,
patient_name text NOT NULL,
gender text,
age text,
inpatient_no text NOT NULL,
diagnosis text,
admission_time text,
last_write_time text,
hospital_days integer,
discharge_time text,
postoperative_days text,
review_status text NOT NULL,
review_notes text,
manual_corrected boolean NOT NULL DEFAULT false,
imported_at timestamptz NOT NULL DEFAULT now(),
audit_result text,
audit_ai_feedback text,
audit_manual_feedback text,
audit_machine_verdict text,
audit_source text,
audit_checked_by text,
audit_checked_at timestamptz,
CONSTRAINT uq_patient_lists_inpatient_no UNIQUE (inpatient_no),
CONSTRAINT ck_patient_lists_inpatient_no_present CHECK (btrim(inpatient_no) <> ''),
CONSTRAINT ck_patient_lists_admission_before_discharge CHECK (
COALESCE(discharge_time, '') = ''
OR COALESCE(admission_time, '') = ''
OR admission_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
OR discharge_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
OR admission_time <= discharge_time
OR review_status = '需人工复核'
)
);
CREATE INDEX IF NOT EXISTS idx_patient_lists_batch_name ON "Patient_Lists"(batch_name);
CREATE INDEX IF NOT EXISTS idx_patient_lists_department ON "Patient_Lists"(major_department, sub_department);
CREATE INDEX IF NOT EXISTS idx_patient_lists_source_folder ON "Patient_Lists"(source_folder);
CREATE INDEX IF NOT EXISTS idx_patient_lists_patient_name ON "Patient_Lists"(patient_name);
CREATE INDEX IF NOT EXISTS idx_patient_lists_review_status ON "Patient_Lists"(review_status);
CREATE INDEX IF NOT EXISTS idx_patient_lists_audit_result ON "Patient_Lists"(audit_result);
COMMENT ON TABLE "Patient_Lists" IS 'HIS患者列表图片OCR归档后的正式患者记录单表';
COMMENT ON COLUMN "Patient_Lists".record_id IS '患者记录主键,数据库自动生成';
COMMENT ON COLUMN "Patient_Lists".batch_name IS '处理批次名称,通常为原始图片集群文件夹名';
COMMENT ON COLUMN "Patient_Lists".major_department IS '归类后的大科室';
COMMENT ON COLUMN "Patient_Lists".sub_department IS '归类后的子科室';
COMMENT ON COLUMN "Patient_Lists".source_folder IS '原始第一层科室文件夹名';
COMMENT ON COLUMN "Patient_Lists".image_path IS '原始患者列表图片路径,用于定位数据来源';
COMMENT ON COLUMN "Patient_Lists".image_name IS '原始患者列表图片文件名';
COMMENT ON COLUMN "Patient_Lists".image_row_no IS '患者记录在原始图片内的行号';
COMMENT ON COLUMN "Patient_Lists".patient_name IS '患者姓名';
COMMENT ON COLUMN "Patient_Lists".gender IS '患者性别';
COMMENT ON COLUMN "Patient_Lists".age IS '患者年龄,保留原始岁数字符串';
COMMENT ON COLUMN "Patient_Lists".inpatient_no IS '住院号,不能为空且全库唯一;不强制校验格式';
COMMENT ON COLUMN "Patient_Lists".diagnosis IS '诊断,可能为空';
COMMENT ON COLUMN "Patient_Lists".admission_time IS '入院时间按OCR/清洗后的文本保存';
COMMENT ON COLUMN "Patient_Lists".last_write_time IS '最后书写时间按OCR/清洗后的文本保存';
COMMENT ON COLUMN "Patient_Lists".hospital_days IS '住院天数';
COMMENT ON COLUMN "Patient_Lists".discharge_time IS '出院时间按OCR/清洗后的文本保存';
COMMENT ON COLUMN "Patient_Lists".postoperative_days IS '手术后天数,可能为空';
COMMENT ON COLUMN "Patient_Lists".review_status IS '自动复核或人工复核状态';
COMMENT ON COLUMN "Patient_Lists".review_notes IS '复核提示信息';
COMMENT ON COLUMN "Patient_Lists".manual_corrected IS '是否经过人工修正';
COMMENT ON COLUMN "Patient_Lists".imported_at IS '导入数据库的时间';
COMMENT ON COLUMN "Patient_Lists".audit_result IS '抽查人工核验结果';
COMMENT ON COLUMN "Patient_Lists".audit_ai_feedback IS '抽查项AI反馈原文';
COMMENT ON COLUMN "Patient_Lists".audit_manual_feedback IS '抽查项人工反馈';
COMMENT ON COLUMN "Patient_Lists".audit_machine_verdict IS '抽查机器核验判断';
COMMENT ON COLUMN "Patient_Lists".audit_source IS '抽查来源';
COMMENT ON COLUMN "Patient_Lists".audit_checked_by IS '抽查操作用户';
COMMENT ON COLUMN "Patient_Lists".audit_checked_at IS '抽查结果保存时间';
COMMENT ON CONSTRAINT uq_patient_lists_inpatient_no ON "Patient_Lists" IS '保证同一个住院号只归档一条患者记录';
COMMENT ON CONSTRAINT ck_patient_lists_inpatient_no_present ON "Patient_Lists" IS '住院号不能为空;格式不做强制校验';
COMMENT ON CONSTRAINT ck_patient_lists_admission_before_discharge ON "Patient_Lists" IS '出院时间为空时放行;入院时间晚于出院时间时必须标记为需人工复核';

View File

@@ -0,0 +1,72 @@
# 患者列表归档处理程序说明
## 工作区文件
1. `01_科室分类规则.json`:大科室、子科室、文件夹别名归类规则。
2. `02_患者列表OCR归档.py`:单个图片集群的 OCR、字段清洗、去重、复核报告生成程序。
3. `03_人工复核修正.json`:少量无法靠规则自动判断的人工修正项,可能包含患者信息,不提交到 Git。
4. `04_合并批次结果.py`:合并所有 `*-列表归档结果`,生成全局 JSON/CSV 和 `信息记录`
5. `05_同步PostgreSQL单表.py`:把合并结果同步到 PostgreSQL 的 `"Patient_Lists"` 单表。
6. `06_PostgreSQL建表结构.sql`:数据库单表结构、索引、表注释、字段注释。
7. `07_处理程序说明.md`:当前说明。
8. `08_PostgreSQL调整Patient_Lists列顺序.sql`:保留数据的前提下,把现有 `"Patient_Lists"` 物理列顺序调整为当前字段顺序。
9. `09_PostgreSQL住院号非空唯一约束.sql`:删除住院号为空的记录,重复住院号保留最新一条,并让 PostgreSQL 强制住院号不能为空且唯一。
## 智能体处理顺序
1. 检查待处理批次目录和图片数量。
2. 必要时更新 `01_科室分类规则.json` 的别名。
3. 运行 `02_患者列表OCR归档.py` 处理一个批次。
4. 查看 `复核报告.json``重复住院号报告.json`
5. 若有需人工复核项,优先补自动清洗规则;仍不能自动判断时,写入 `03_人工复核修正.json`
6.`--rebuild-from-cache` 重建该批次结果,确认复核项和重复住院号符合预期。
7. 运行 `04_合并批次结果.py` 更新全局 `信息记录`
8. 运行 `05_同步PostgreSQL单表.py` 同步到 PostgreSQL。
9. 导入 PostgreSQL 后查询记录数、需复核记录数、重复住院号组数。
## 人工复核网页端
- 代码目录:`人工复核网页端/`
- 启动方式:`cd 人工复核网页端 && docker compose up -d --build`
- 登录配置从项目根目录 `.env` 读取:`REVIEW_APP_USERNAME``REVIEW_APP_PASSWORD``REVIEW_APP_SECRET_KEY``REVIEW_APP_PORT`
- 网页端读取各批次 `复核报告.json`,按原始图片路径与图片内行号裁剪截图;住院号不能为空且唯一,格式是否规范交由人工复核时判断。
- 保存修订后写入 `数据处理工作区/03_人工复核修正.json`,并尽量同步 PostgreSQL若 PostgreSQL 暂不可用,会保留待同步状态,之后可在网页端点击“提交待同步”。
- 出院时间默认允许为空;确有出院时间时,网页端和处理程序会校验时间格式以及是否早于入院时间。
- 页面包含概览、复核、抽查、抽查查看、设置区域;抽查功能可随机抽取患者行,调用 Kimi 多模态模型二次核验,并把 AI 反馈、机器判断、人工抽查结论写入 PostgreSQL。
## 识别策略
- OCR 接口:腾讯云 `RecognizeTableAccurateOCR` 表格识别 V3。
- 新批次拼接图默认给每张原图上下各加 `24px` 白色边界,减少贴边表格行被裁掉或和相邻图片粘连的概率。
- 正式处理优先用 6 张拼接;接口超时、失败或行数偏少时,程序会自动降到 4/3/2/单张。
- 行数校验:按图片高度推断应有行数,返回行数偏少即判定该拼接档位不可靠。
- 漏行不一定只发生在最上方或最下方两行;长拼接图中间也可能漏行,白边只能缓解贴边问题。
- 同一科室内会记忆稳定回退档位;跨科室不共享该记忆,避免重建历史缓存时误用。
- 只用缓存重建时,若前序回退策略需要的缓存不完整,会先尝试该组主缓存,避免因缓存形态不同造成少行。
- 字段顺序固定为:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数。
- 字段清洗包含日期规范化、住院号常见误识别修正、长诊断吞入入院时间、空时间字段导致列左移、`后XX天` 错位等。
## 住院号核验规则
- `住院号` 是唯一强制校验条件:不能为空且全库唯一。
- 单批次、全局合并和 PostgreSQL 同步都会剔除住院号为空的记录;重复住院号按后出现记录覆盖先出现记录;格式异常但非空的住院号保留。
- 网页端保存时优先按图片路径和图片内行号定位数据库记录;如果当前住院号已存在于另一条记录,会用当前修订覆盖既有住院号记录,并删除当前重复图片记录。
- 数据库约束允许 `出院时间` 为空;若入院时间和出院时间均为标准格式且入院时间晚于出院时间,该记录必须标记为 `需人工复核`
## PostgreSQL 单表字段
`"Patient_Lists"` 只保留正式查看和追溯需要的字段:
- 数据定位:`batch_name``source_folder``image_path``image_name``image_row_no`
- 科室信息:`major_department``sub_department`
- 患者信息:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数
- 复核信息:`review_status``review_notes``manual_corrected`
- 抽查信息:`audit_result``audit_ai_feedback``audit_manual_feedback``audit_machine_verdict``audit_source``audit_checked_by``audit_checked_at`
复核状态含义:
- `自动复核通过`OCR 结果经过程序规则校验后没有发现明显异常,`manual_corrected=false`
- `人工复核通过`:该行命中了 `03_人工复核修正.json` 中的人工修正项,修正后校验通过,`manual_corrected=true`
- `AI修改-待确认`:网页端 Kimi 辅助生成的草稿状态,不会被待同步流程写入 PostgreSQL需人工保存确认。
OCR 请求号、OCR 缓存路径、拼接图路径、拼接组号等中间态字段不进入数据库正式表。

View File

@@ -0,0 +1,168 @@
-- 保留现有数据,重建 "Patient_Lists" 的物理列顺序。
-- 只调整列顺序,不交换 last_write_time/discharge_time 的值。
-- 执行前建议先确认已备份数据库。
BEGIN;
LOCK TABLE "Patient_Lists" IN ACCESS EXCLUSIVE MODE;
DROP TABLE IF EXISTS "Patient_Lists__reordered";
CREATE TABLE "Patient_Lists__reordered" (
record_id bigint NOT NULL,
batch_name text NOT NULL,
major_department text NOT NULL,
sub_department text NOT NULL,
source_folder text NOT NULL,
image_path text NOT NULL,
image_name text NOT NULL,
image_row_no integer NOT NULL,
patient_name text NOT NULL,
gender text,
age text,
inpatient_no text NOT NULL,
diagnosis text,
admission_time text,
last_write_time text,
hospital_days integer,
discharge_time text,
postoperative_days text,
review_status text NOT NULL,
review_notes text,
manual_corrected boolean NOT NULL DEFAULT false,
imported_at timestamptz NOT NULL DEFAULT now(),
audit_result text,
audit_ai_feedback text,
audit_manual_feedback text,
audit_machine_verdict text,
audit_source text,
audit_checked_by text,
audit_checked_at timestamptz,
CONSTRAINT ck_patient_lists_inpatient_no_present CHECK (btrim(inpatient_no) <> ''),
CONSTRAINT ck_patient_lists_admission_before_discharge CHECK (
COALESCE(discharge_time, '') = ''
OR COALESCE(admission_time, '') = ''
OR admission_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
OR discharge_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
OR admission_time <= discharge_time
OR review_status = '需人工复核'
)
);
INSERT INTO "Patient_Lists__reordered" (
record_id,
batch_name,
major_department,
sub_department,
source_folder,
image_path,
image_name,
image_row_no,
patient_name,
gender,
age,
inpatient_no,
diagnosis,
admission_time,
last_write_time,
hospital_days,
discharge_time,
postoperative_days,
review_status,
review_notes,
manual_corrected,
imported_at,
audit_result,
audit_ai_feedback,
audit_manual_feedback,
audit_machine_verdict,
audit_source,
audit_checked_by,
audit_checked_at
)
SELECT
record_id,
batch_name,
major_department,
sub_department,
source_folder,
image_path,
image_name,
image_row_no,
patient_name,
gender,
age,
inpatient_no,
diagnosis,
admission_time,
last_write_time,
hospital_days,
discharge_time,
postoperative_days,
review_status,
review_notes,
manual_corrected,
imported_at,
audit_result,
audit_ai_feedback,
audit_manual_feedback,
audit_machine_verdict,
audit_source,
audit_checked_by,
audit_checked_at
FROM "Patient_Lists";
DROP TABLE "Patient_Lists";
ALTER TABLE "Patient_Lists__reordered" RENAME TO "Patient_Lists";
DROP SEQUENCE IF EXISTS "Patient_Lists_record_id_seq";
CREATE SEQUENCE "Patient_Lists_record_id_seq";
ALTER SEQUENCE "Patient_Lists_record_id_seq" OWNED BY "Patient_Lists".record_id;
ALTER TABLE "Patient_Lists" ALTER COLUMN record_id SET DEFAULT nextval('"Patient_Lists_record_id_seq"'::regclass);
SELECT setval('"Patient_Lists_record_id_seq"', COALESCE((SELECT max(record_id) FROM "Patient_Lists"), 1), true);
ALTER TABLE "Patient_Lists" ADD CONSTRAINT "Patient_Lists_pkey" PRIMARY KEY (record_id);
ALTER TABLE "Patient_Lists" ADD CONSTRAINT uq_patient_lists_inpatient_no UNIQUE (inpatient_no);
CREATE INDEX idx_patient_lists_batch_name ON "Patient_Lists"(batch_name);
CREATE INDEX idx_patient_lists_department ON "Patient_Lists"(major_department, sub_department);
CREATE INDEX idx_patient_lists_source_folder ON "Patient_Lists"(source_folder);
CREATE INDEX idx_patient_lists_patient_name ON "Patient_Lists"(patient_name);
CREATE INDEX idx_patient_lists_review_status ON "Patient_Lists"(review_status);
CREATE INDEX idx_patient_lists_audit_result ON "Patient_Lists"(audit_result);
COMMENT ON TABLE "Patient_Lists" IS 'HIS患者列表图片OCR归档后的正式患者记录单表';
COMMENT ON COLUMN "Patient_Lists".record_id IS '患者记录主键,数据库自动生成';
COMMENT ON COLUMN "Patient_Lists".batch_name IS '处理批次名称,通常为原始图片集群文件夹名';
COMMENT ON COLUMN "Patient_Lists".major_department IS '归类后的大科室';
COMMENT ON COLUMN "Patient_Lists".sub_department IS '归类后的子科室';
COMMENT ON COLUMN "Patient_Lists".source_folder IS '原始第一层科室文件夹名';
COMMENT ON COLUMN "Patient_Lists".image_path IS '原始患者列表图片路径,用于定位数据来源';
COMMENT ON COLUMN "Patient_Lists".image_name IS '原始患者列表图片文件名';
COMMENT ON COLUMN "Patient_Lists".image_row_no IS '患者记录在原始图片内的行号';
COMMENT ON COLUMN "Patient_Lists".patient_name IS '患者姓名';
COMMENT ON COLUMN "Patient_Lists".gender IS '患者性别';
COMMENT ON COLUMN "Patient_Lists".age IS '患者年龄,保留原始岁数字符串';
COMMENT ON COLUMN "Patient_Lists".inpatient_no IS '住院号,不能为空且全库唯一;不强制校验格式';
COMMENT ON COLUMN "Patient_Lists".diagnosis IS '诊断,可能为空';
COMMENT ON COLUMN "Patient_Lists".admission_time IS '入院时间按OCR/清洗后的文本保存';
COMMENT ON COLUMN "Patient_Lists".last_write_time IS '最后书写时间按OCR/清洗后的文本保存';
COMMENT ON COLUMN "Patient_Lists".hospital_days IS '住院天数';
COMMENT ON COLUMN "Patient_Lists".discharge_time IS '出院时间按OCR/清洗后的文本保存';
COMMENT ON COLUMN "Patient_Lists".postoperative_days IS '手术后天数可能为空格式通常为“后X天”';
COMMENT ON COLUMN "Patient_Lists".review_status IS '自动复核或人工复核状态';
COMMENT ON COLUMN "Patient_Lists".review_notes IS '复核提示信息';
COMMENT ON COLUMN "Patient_Lists".manual_corrected IS '是否经过人工修正';
COMMENT ON COLUMN "Patient_Lists".imported_at IS '导入数据库或人工同步更新的时间';
COMMENT ON COLUMN "Patient_Lists".audit_result IS '抽查人工核验结果';
COMMENT ON COLUMN "Patient_Lists".audit_ai_feedback IS '抽查项AI反馈原文';
COMMENT ON COLUMN "Patient_Lists".audit_manual_feedback IS '抽查项人工反馈';
COMMENT ON COLUMN "Patient_Lists".audit_machine_verdict IS '抽查机器核验判断';
COMMENT ON COLUMN "Patient_Lists".audit_source IS '抽查来源';
COMMENT ON COLUMN "Patient_Lists".audit_checked_by IS '抽查操作用户';
COMMENT ON COLUMN "Patient_Lists".audit_checked_at IS '抽查结果保存时间';
COMMENT ON CONSTRAINT uq_patient_lists_inpatient_no ON "Patient_Lists" IS '保证同一个住院号只归档一条患者记录';
COMMENT ON CONSTRAINT ck_patient_lists_inpatient_no_present ON "Patient_Lists" IS '住院号不能为空;格式不做强制校验';
COMMENT ON CONSTRAINT ck_patient_lists_admission_before_discharge ON "Patient_Lists" IS '出院时间为空时放行;入院时间晚于出院时间时必须标记为需人工复核';
COMMIT;

View File

@@ -0,0 +1,102 @@
-- 住院号为唯一定位条件:数据库强制“不能为空且唯一”。
-- 不校验 ZY 位数格式;空住院号记录会先删除,重复住院号保留 id 最大/最新的一条。
DELETE FROM "Patient_Lists"
WHERE inpatient_no IS NULL OR btrim(inpatient_no) = '';
DELETE FROM "Patient_Lists" p
USING (
SELECT record_id
FROM (
SELECT record_id, row_number() OVER (PARTITION BY inpatient_no ORDER BY record_id DESC) AS rn
FROM "Patient_Lists"
) ranked
WHERE rn > 1
) duplicate_rows
WHERE p.record_id = duplicate_rows.record_id;
ALTER TABLE "Patient_Lists"
DROP CONSTRAINT IF EXISTS ck_patient_lists_inpatient_no_format;
ALTER TABLE "Patient_Lists"
DROP CONSTRAINT IF EXISTS uq_patient_lists_inpatient_no;
DROP INDEX IF EXISTS uq_patient_lists_inpatient_no;
ALTER TABLE "Patient_Lists"
DROP CONSTRAINT IF EXISTS ck_patient_lists_inpatient_no_present;
ALTER TABLE "Patient_Lists"
DROP CONSTRAINT IF EXISTS ck_patient_lists_inpatient_no_required;
ALTER TABLE "Patient_Lists"
ALTER COLUMN inpatient_no SET NOT NULL;
ALTER TABLE "Patient_Lists"
ADD CONSTRAINT ck_patient_lists_inpatient_no_present
CHECK (btrim(inpatient_no) <> '');
ALTER TABLE "Patient_Lists"
ADD CONSTRAINT uq_patient_lists_inpatient_no UNIQUE (inpatient_no);
COMMENT ON COLUMN "Patient_Lists".inpatient_no
IS '住院号,不能为空且全库唯一;不强制校验格式';
COMMENT ON CONSTRAINT ck_patient_lists_inpatient_no_present ON "Patient_Lists"
IS '住院号不能为空;格式不做强制校验';
COMMENT ON CONSTRAINT uq_patient_lists_inpatient_no ON "Patient_Lists"
IS '保证同一个住院号只归档一条患者记录';
DELETE FROM "Patient_FrontPages"
WHERE inpatient_no IS NULL OR btrim(inpatient_no) = '';
DELETE FROM "Patient_FrontPages" p
USING (
SELECT id
FROM (
SELECT id, row_number() OVER (PARTITION BY inpatient_no ORDER BY id DESC) AS rn
FROM "Patient_FrontPages"
) ranked
WHERE rn > 1
) duplicate_rows
WHERE p.id = duplicate_rows.id;
ALTER TABLE "Patient_FrontPages"
DROP CONSTRAINT IF EXISTS "ck_Patient_FrontPages_inpatient_no_format";
ALTER TABLE "Patient_FrontPages"
DROP CONSTRAINT IF EXISTS ck_patient_frontpages_inpatient_no_format;
ALTER TABLE "Patient_FrontPages"
DROP CONSTRAINT IF EXISTS uq_patient_frontpages_inpatient_no;
DROP INDEX IF EXISTS uq_patient_frontpages_inpatient_no;
ALTER TABLE "Patient_FrontPages"
DROP CONSTRAINT IF EXISTS ck_patient_frontpages_inpatient_no_present;
ALTER TABLE "Patient_FrontPages"
DROP CONSTRAINT IF EXISTS "ck_Patient_FrontPages_inpatient_no_required";
ALTER TABLE "Patient_FrontPages"
DROP CONSTRAINT IF EXISTS ck_patient_frontpages_inpatient_no_required;
ALTER TABLE "Patient_FrontPages"
ALTER COLUMN inpatient_no SET NOT NULL;
ALTER TABLE "Patient_FrontPages"
ADD CONSTRAINT ck_patient_frontpages_inpatient_no_present
CHECK (btrim(inpatient_no) <> '');
ALTER TABLE "Patient_FrontPages"
ADD CONSTRAINT uq_patient_frontpages_inpatient_no UNIQUE (inpatient_no);
COMMENT ON COLUMN "Patient_FrontPages".inpatient_no
IS '住院号,不能为空且全库唯一;不强制校验格式';
COMMENT ON CONSTRAINT ck_patient_frontpages_inpatient_no_present ON "Patient_FrontPages"
IS '住院号不能为空;格式不做强制校验';
COMMENT ON CONSTRAINT uq_patient_frontpages_inpatient_no ON "Patient_FrontPages"
IS '保证同一个住院号只保留一条首页记录';