Import sanitized HIS processing tools
This commit is contained in:
245
患者首页处理/数据处理工作区/01_配置规则/01_科室分类规则.json
Normal file
245
患者首页处理/数据处理工作区/01_配置规则/01_科室分类规则.json
Normal file
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"说明": "每个具体子科室只归属一个大科室;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",
|
||||
"感染科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",
|
||||
"肝胆5D": "肝胆外科5D",
|
||||
"肝胆外科5病房D": "肝胆外科5D",
|
||||
"肝胆特种L": "肝胆特种病区L",
|
||||
"肝胆特种病区L": "肝胆特种病区L",
|
||||
"肝胆移植内": "肝移植内",
|
||||
"肝移植内": "肝移植内",
|
||||
"肝移植内科": "肝移植内",
|
||||
"肝移植内N": "肝移植内N",
|
||||
"肝移植内科N": "肝移植内N",
|
||||
"肿瘤放疗3": "肿瘤放疗3",
|
||||
"肿放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",
|
||||
"耳鼻喉": "耳鼻喉头颈外科",
|
||||
"耳鼻喉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",
|
||||
"健苑五涉外": "健苑五涉外病房",
|
||||
"健苑五涉外病房": "健苑五涉外病房",
|
||||
"老年外科": "老年外科"
|
||||
}
|
||||
}
|
||||
2181
患者首页处理/数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py
Executable file
2181
患者首页处理/数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py
Executable file
File diff suppressed because it is too large
Load Diff
224
患者首页处理/数据处理工作区/03_人工复核/03_人工复核导出与回写.py
Executable file
224
患者首页处理/数据处理工作区/03_人工复核/03_人工复核导出与回写.py
Executable file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从 PostgreSQL 导出患者首页复核表,并把人工复核结果回写到 Patient_FrontPages。
|
||||
|
||||
依赖系统命令 psql;数据库密码通过 PGPASSWORD 环境变量或 --pg-password 传入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_OUTPUT = PROJECT_ROOT / "数据处理结果区" / "04_复核与人工校正" / "患者首页_人工复核表.csv"
|
||||
DEFAULT_TABLE = "Patient_FrontPages"
|
||||
|
||||
|
||||
def quote_pg_identifier(identifier: str) -> str:
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", identifier):
|
||||
raise ValueError(f"非法 PostgreSQL 表名:{identifier}")
|
||||
return '"' + identifier.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def connection_env(args: argparse.Namespace) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"PGHOST": args.pg_host or os.environ.get("PGHOST", ""),
|
||||
"PGPORT": str(args.pg_port or os.environ.get("PGPORT", "")),
|
||||
"PGDATABASE": args.pg_database or os.environ.get("PGDATABASE", ""),
|
||||
"PGUSER": args.pg_user or os.environ.get("PGUSER", ""),
|
||||
"PGPASSWORD": args.pg_password or os.environ.get("PGPASSWORD", ""),
|
||||
}
|
||||
)
|
||||
missing = [name for name in ["PGHOST", "PGPORT", "PGDATABASE", "PGUSER", "PGPASSWORD"] if not env.get(name)]
|
||||
if missing:
|
||||
raise RuntimeError("缺少 PostgreSQL 连接配置:" + "、".join(missing))
|
||||
return env
|
||||
|
||||
|
||||
def run_psql(sql: str, args: argparse.Namespace) -> None:
|
||||
if shutil.which("psql") is None:
|
||||
raise RuntimeError("未找到 psql。请先安装 PostgreSQL 客户端。")
|
||||
with tempfile.TemporaryDirectory(prefix="patient_front_review_") as tmpdir:
|
||||
sql_path = Path(tmpdir) / "review.sql"
|
||||
sql_path.write_text(sql, encoding="utf-8")
|
||||
completed = subprocess.run(
|
||||
["psql", "--no-password", "--file", str(sql_path)],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=connection_env(args),
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(completed.stderr.strip() or completed.stdout.strip() or "psql 执行失败")
|
||||
if completed.stdout.strip():
|
||||
print(completed.stdout.strip())
|
||||
|
||||
|
||||
def export_review(args: argparse.Namespace) -> None:
|
||||
output = args.output.resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
escaped_output = str(output).replace("'", "''")
|
||||
table = quote_pg_identifier(args.pg_table)
|
||||
where_sql = "" if args.all else "WHERE review_status <> 'auto_pass' OR manual_corrected IS TRUE"
|
||||
select_sql = (
|
||||
"SELECT inpatient_no, source_file, medical_record_no, front_page_medical_record_no, patient_name, "
|
||||
"gender, birth_date, discharge_dept, major_department, primary_diagnosis, "
|
||||
"primary_diagnosis_code, total_cost, self_pay_amount, review_status, "
|
||||
"review_notes::text AS review_notes, auto_corrections::text AS auto_corrections, "
|
||||
"manual_corrected, contact_address, contact_phone, discharge_diagnoses::text AS discharge_diagnoses, "
|
||||
"operations::text AS operations, pathology_diagnosis, pathology_diagnosis_code, pathology_no, "
|
||||
"discharge_disposition_code, readmission_plan_code, quality_status, quality_notes::text AS quality_notes, "
|
||||
"'' AS manual_review_notes, '' AS corrected_medical_record_no, "
|
||||
"'' AS corrected_patient_name, '' AS corrected_major_department, "
|
||||
"'' AS corrected_primary_diagnosis, '' AS corrected_primary_diagnosis_code, "
|
||||
"'' AS corrected_total_cost, '' AS corrected_self_pay_amount, '' AS mark_review_status "
|
||||
f"FROM {table} {where_sql} ORDER BY source_file"
|
||||
)
|
||||
sql = f"\\set ON_ERROR_STOP on\n\\copy ({select_sql}) TO '{escaped_output}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')\n"
|
||||
run_psql(sql, args)
|
||||
print(f"已导出复核表:{output}")
|
||||
|
||||
|
||||
def import_review(args: argparse.Namespace) -> None:
|
||||
input_path = args.input.resolve()
|
||||
if not input_path.exists():
|
||||
raise RuntimeError(f"复核表不存在:{input_path}")
|
||||
escaped_input = str(input_path).replace("'", "''")
|
||||
table = quote_pg_identifier(args.pg_table)
|
||||
sql = f"""
|
||||
\\set ON_ERROR_STOP on
|
||||
CREATE TEMP TABLE patient_front_review_import (
|
||||
inpatient_no TEXT,
|
||||
source_file TEXT,
|
||||
medical_record_no TEXT,
|
||||
front_page_medical_record_no TEXT,
|
||||
patient_name TEXT,
|
||||
gender TEXT,
|
||||
birth_date TEXT,
|
||||
discharge_dept TEXT,
|
||||
major_department TEXT,
|
||||
primary_diagnosis TEXT,
|
||||
primary_diagnosis_code TEXT,
|
||||
total_cost TEXT,
|
||||
self_pay_amount TEXT,
|
||||
review_status TEXT,
|
||||
review_notes TEXT,
|
||||
auto_corrections TEXT,
|
||||
manual_corrected TEXT,
|
||||
contact_address TEXT,
|
||||
contact_phone TEXT,
|
||||
discharge_diagnoses TEXT,
|
||||
operations TEXT,
|
||||
pathology_diagnosis TEXT,
|
||||
pathology_diagnosis_code TEXT,
|
||||
pathology_no TEXT,
|
||||
discharge_disposition_code TEXT,
|
||||
readmission_plan_code TEXT,
|
||||
quality_status TEXT,
|
||||
quality_notes TEXT,
|
||||
manual_review_notes TEXT,
|
||||
corrected_medical_record_no TEXT,
|
||||
corrected_patient_name TEXT,
|
||||
corrected_major_department TEXT,
|
||||
corrected_primary_diagnosis TEXT,
|
||||
corrected_primary_diagnosis_code TEXT,
|
||||
corrected_total_cost TEXT,
|
||||
corrected_self_pay_amount TEXT,
|
||||
mark_review_status TEXT
|
||||
);
|
||||
|
||||
\\copy patient_front_review_import FROM '{escaped_input}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
|
||||
|
||||
UPDATE {table} AS t
|
||||
SET
|
||||
medical_record_no = COALESCE(NULLIF(i.corrected_medical_record_no, ''), t.medical_record_no),
|
||||
patient_name = COALESCE(NULLIF(i.corrected_patient_name, ''), t.patient_name),
|
||||
major_department = COALESCE(NULLIF(i.corrected_major_department, ''), t.major_department),
|
||||
primary_diagnosis = COALESCE(NULLIF(i.corrected_primary_diagnosis, ''), t.primary_diagnosis),
|
||||
primary_diagnosis_code = COALESCE(NULLIF(i.corrected_primary_diagnosis_code, ''), t.primary_diagnosis_code),
|
||||
total_cost = COALESCE(NULLIF(i.corrected_total_cost, '')::numeric, t.total_cost),
|
||||
self_pay_amount = COALESCE(NULLIF(i.corrected_self_pay_amount, '')::numeric, t.self_pay_amount),
|
||||
review_status = COALESCE(NULLIF(i.mark_review_status, ''), 'reviewed'),
|
||||
review_notes = CASE
|
||||
WHEN NULLIF(i.manual_review_notes, '') IS NULL THEN t.review_notes
|
||||
ELSE t.review_notes || jsonb_build_array(i.manual_review_notes)
|
||||
END,
|
||||
manual_corrected = CASE
|
||||
WHEN NULLIF(i.corrected_medical_record_no, '') IS NOT NULL
|
||||
OR NULLIF(i.corrected_patient_name, '') IS NOT NULL
|
||||
OR NULLIF(i.corrected_major_department, '') IS NOT NULL
|
||||
OR NULLIF(i.corrected_primary_diagnosis, '') IS NOT NULL
|
||||
OR NULLIF(i.corrected_primary_diagnosis_code, '') IS NOT NULL
|
||||
OR NULLIF(i.corrected_total_cost, '') IS NOT NULL
|
||||
OR NULLIF(i.corrected_self_pay_amount, '') IS NOT NULL
|
||||
OR lower(COALESCE(i.manual_corrected, '')) IN ('true', 't', '1', 'yes', 'y')
|
||||
THEN true
|
||||
ELSE t.manual_corrected
|
||||
END
|
||||
FROM patient_front_review_import AS i
|
||||
WHERE (
|
||||
NULLIF(i.inpatient_no, '') IS NOT NULL
|
||||
AND t.inpatient_no = i.inpatient_no
|
||||
) OR (
|
||||
NULLIF(i.inpatient_no, '') IS NULL
|
||||
AND t.source_file = i.source_file
|
||||
);
|
||||
""".strip()
|
||||
run_psql(sql, args)
|
||||
print(f"已回写人工复核表:{input_path}")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="导出/回写患者首页 PostgreSQL 人工复核表。")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
def add_pg_options(subparser: argparse.ArgumentParser) -> None:
|
||||
subparser.add_argument("--pg-host", default=os.environ.get("PGHOST", ""), help="PostgreSQL 主机;也可用 PGHOST")
|
||||
subparser.add_argument("--pg-port", default=os.environ.get("PGPORT", "5432"), help="PostgreSQL 端口;也可用 PGPORT")
|
||||
subparser.add_argument("--pg-database", default=os.environ.get("PGDATABASE", ""), help="PostgreSQL 数据库;也可用 PGDATABASE")
|
||||
subparser.add_argument("--pg-user", default=os.environ.get("PGUSER", ""), help="PostgreSQL 用户名;也可用 PGUSER")
|
||||
subparser.add_argument("--pg-password", default=os.environ.get("PGPASSWORD", ""), help="PostgreSQL 密码;也可用 PGPASSWORD")
|
||||
subparser.add_argument("--pg-table", default=os.environ.get("PG_PATIENT_TABLE", DEFAULT_TABLE), help="PostgreSQL 表名")
|
||||
|
||||
export_parser = subparsers.add_parser("export", help="从 PostgreSQL 导出待复核 CSV")
|
||||
add_pg_options(export_parser)
|
||||
export_parser.add_argument("-o", "--output", type=Path, default=DEFAULT_OUTPUT, help="复核 CSV 输出路径")
|
||||
export_parser.add_argument("--all", action="store_true", help="导出全部记录,而不仅是需复核/已人工修正记录")
|
||||
|
||||
import_parser = subparsers.add_parser("import", help="把人工复核 CSV 回写 PostgreSQL")
|
||||
add_pg_options(import_parser)
|
||||
import_parser.add_argument("-i", "--input", type=Path, default=DEFAULT_OUTPUT, help="人工复核 CSV 路径")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
if args.command == "export":
|
||||
export_review(args)
|
||||
elif args.command == "import":
|
||||
import_review(args)
|
||||
else:
|
||||
raise RuntimeError(f"未知命令:{args.command}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc: # noqa: BLE001 - 命令行工具需要清楚输出错误
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
175
患者首页处理/数据处理工作区/04_质量体检/04_字段核验与数据库体检.py
Executable file
175
患者首页处理/数据处理工作区/04_质量体检/04_字段核验与数据库体检.py
Executable file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
对 Patient_FrontPages 做字段级体检。
|
||||
|
||||
输出:
|
||||
- 数据处理结果区/05_质量体检/患者首页_数据库体检报告.txt
|
||||
- 数据处理结果区/05_质量体检/患者首页_字段空值统计.csv
|
||||
- 数据处理结果区/05_质量体检/患者首页_疑似异常记录.csv
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "数据处理结果区" / "05_质量体检"
|
||||
DEFAULT_TABLE = "Patient_FrontPages"
|
||||
|
||||
|
||||
def quote_pg_identifier(identifier: str) -> str:
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", identifier):
|
||||
raise ValueError(f"非法 PostgreSQL 表名:{identifier}")
|
||||
return '"' + identifier.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def connection_env(args: argparse.Namespace) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"PGHOST": args.pg_host or os.environ.get("PGHOST", ""),
|
||||
"PGPORT": str(args.pg_port or os.environ.get("PGPORT", "")),
|
||||
"PGDATABASE": args.pg_database or os.environ.get("PGDATABASE", ""),
|
||||
"PGUSER": args.pg_user or os.environ.get("PGUSER", ""),
|
||||
"PGPASSWORD": args.pg_password or os.environ.get("PGPASSWORD", ""),
|
||||
}
|
||||
)
|
||||
missing = [name for name in ["PGHOST", "PGPORT", "PGDATABASE", "PGUSER", "PGPASSWORD"] if not env.get(name)]
|
||||
if missing:
|
||||
raise RuntimeError("缺少 PostgreSQL 连接配置:" + "、".join(missing))
|
||||
return env
|
||||
|
||||
|
||||
def run_psql(sql: str, args: argparse.Namespace) -> str:
|
||||
if shutil.which("psql") is None:
|
||||
raise RuntimeError("未找到 psql。请先安装 PostgreSQL 客户端。")
|
||||
with tempfile.TemporaryDirectory(prefix="patient_front_audit_") as tmpdir:
|
||||
sql_path = Path(tmpdir) / "audit.sql"
|
||||
sql_path.write_text(sql, encoding="utf-8")
|
||||
completed = subprocess.run(
|
||||
["psql", "--no-password", "--file", str(sql_path)],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=connection_env(args),
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(completed.stderr.strip() or completed.stdout.strip() or "psql 执行失败")
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def build_empty_stat_sql(table: str) -> str:
|
||||
monitored_columns = [
|
||||
"inpatient_no",
|
||||
"medical_record_no",
|
||||
"patient_name",
|
||||
"gender",
|
||||
"birth_date",
|
||||
"id_card_no",
|
||||
"contact_address",
|
||||
"admission_time",
|
||||
"discharge_time",
|
||||
"major_department",
|
||||
"primary_diagnosis",
|
||||
"primary_diagnosis_code",
|
||||
"discharge_diagnoses",
|
||||
"operations",
|
||||
"quality_status",
|
||||
"review_status",
|
||||
]
|
||||
selects = []
|
||||
for column in monitored_columns:
|
||||
identifier = quote_pg_identifier(column)
|
||||
if column in {"discharge_diagnoses", "operations"}:
|
||||
empty_expr = f"{identifier} IS NULL OR jsonb_array_length({identifier}) = 0"
|
||||
else:
|
||||
empty_expr = f"{identifier} IS NULL OR {identifier}::text = ''"
|
||||
selects.append(f"SELECT '{column}' AS column_name, count(*) FILTER (WHERE {empty_expr}) AS empty_count, count(*) AS total_count FROM {table}")
|
||||
return " UNION ALL ".join(selects)
|
||||
|
||||
|
||||
def audit(args: argparse.Namespace) -> None:
|
||||
output_dir = args.output_dir.resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
table = quote_pg_identifier(args.pg_table)
|
||||
empty_csv = output_dir / "患者首页_字段空值统计.csv"
|
||||
suspicious_csv = output_dir / "患者首页_疑似异常记录.csv"
|
||||
report_path = output_dir / "患者首页_数据库体检报告.txt"
|
||||
empty_stat_sql = build_empty_stat_sql(table)
|
||||
suspicious_sql = (
|
||||
"SELECT source_file, inpatient_no, medical_record_no, patient_name, review_status, "
|
||||
"review_notes::text AS review_notes, contact_address, primary_diagnosis, primary_diagnosis_code "
|
||||
f"FROM {table} "
|
||||
"WHERE review_status <> 'auto_pass' "
|
||||
"OR inpatient_no !~ '^ZY\\d{12}$' "
|
||||
"OR medical_record_no !~ '^\\d{10}$' "
|
||||
"OR contact_address ~ '急诊|门诊|其他医疗机构转入|医嘱离院' "
|
||||
"OR primary_diagnosis_code IS NULL "
|
||||
"OR primary_diagnosis_code = '' "
|
||||
"ORDER BY source_file"
|
||||
)
|
||||
|
||||
sql = f"""
|
||||
\\set ON_ERROR_STOP on
|
||||
\\pset pager off
|
||||
\\o {str(report_path).replace("'", "''")}
|
||||
SELECT '总记录数' AS item, count(*)::text AS value FROM {table}
|
||||
UNION ALL
|
||||
SELECT '需复核记录数', count(*)::text FROM {table} WHERE review_status <> 'auto_pass'
|
||||
UNION ALL
|
||||
SELECT '缺字段注释数', count(*)::text
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relname = {args.pg_table!r}
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
AND col_description(c.oid, a.attnum) IS NULL;
|
||||
\\o
|
||||
\\copy ({empty_stat_sql}) TO '{str(empty_csv).replace("'", "''")}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
|
||||
\\copy ({suspicious_sql}) TO '{str(suspicious_csv).replace("'", "''")}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
|
||||
""".strip()
|
||||
stdout = run_psql(sql, args)
|
||||
if stdout.strip():
|
||||
print(stdout.strip())
|
||||
print(f"体检报告:{report_path}")
|
||||
print(f"字段空值统计:{empty_csv}")
|
||||
print(f"疑似异常记录:{suspicious_csv}")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="核验 Patient_FrontPages 字段注释、空值和疑似错位记录。")
|
||||
parser.add_argument("--pg-host", default=os.environ.get("PGHOST", ""), help="PostgreSQL 主机;也可用 PGHOST")
|
||||
parser.add_argument("--pg-port", default=os.environ.get("PGPORT", "5432"), help="PostgreSQL 端口;也可用 PGPORT")
|
||||
parser.add_argument("--pg-database", default=os.environ.get("PGDATABASE", ""), help="PostgreSQL 数据库;也可用 PGDATABASE")
|
||||
parser.add_argument("--pg-user", default=os.environ.get("PGUSER", ""), help="PostgreSQL 用户名;也可用 PGUSER")
|
||||
parser.add_argument("--pg-password", default=os.environ.get("PGPASSWORD", ""), help="PostgreSQL 密码;也可用 PGPASSWORD")
|
||||
parser.add_argument("--pg-table", default=os.environ.get("PG_PATIENT_TABLE", DEFAULT_TABLE), help="PostgreSQL 表名")
|
||||
parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="体检结果输出目录")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
audit(build_parser().parse_args())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
130
患者首页处理/数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py
Normal file
130
患者首页处理/数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import requests
|
||||
import zipfile
|
||||
import io
|
||||
import shutil # 【新增】用于删除非空文件夹
|
||||
import argparse # <--- 1. 确保这里导入了
|
||||
|
||||
def main():
|
||||
# 1. 配置命令行参数解析
|
||||
parser = argparse.ArgumentParser(description="Mineru PDF 转 Markdown 批量处理工具")
|
||||
|
||||
# 添加参数
|
||||
parser.add_argument("-s", "--source", type=str, default="./Papers/ORI_PDF",
|
||||
help="源 PDF 文件夹路径 (默认: ./Papers/ORI_PDF)")
|
||||
parser.add_argument("-t", "--target", type=str, default="./Papers/ORI_MD",
|
||||
help="目标 Markdown 文件夹路径 (默认: ./Papers/ORI_MD)")
|
||||
parser.add_argument("-u", "--url", type=str, default="http://10.168.1.103:4000/extract",
|
||||
help="API 服务端地址 (默认: http://10.168.1.103:4000/extract)")
|
||||
parser.add_argument("--sync", action="store_true",
|
||||
help="任务完成后是否开启反向同步清理(删除多余的输出文件夹)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 使用解析后的参数
|
||||
url = args.url
|
||||
source_dir = args.source
|
||||
target_dir = args.target
|
||||
|
||||
# 2. 确保源目录存在,避免报错
|
||||
if not os.path.exists(source_dir):
|
||||
print(f"错误: 找不到源文件夹 '{source_dir}'")
|
||||
exit(1)
|
||||
|
||||
# 确保目标主文件夹存在
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
# 3. 遍历源文件夹下的所有文件进行上传处理
|
||||
for filename in os.listdir(source_dir):
|
||||
# 只处理 PDF 文件
|
||||
if not filename.lower().endswith(".pdf"):
|
||||
continue
|
||||
|
||||
file_path = os.path.join(source_dir, filename)
|
||||
# 获取去掉 .pdf 后缀的文件名
|
||||
pdf_name_no_ext = os.path.splitext(filename)[0]
|
||||
|
||||
# 对应的输出文件夹路径
|
||||
output_folder = os.path.join(target_dir, pdf_name_no_ext)
|
||||
|
||||
# ==========================================
|
||||
# 检查是否已经处理过
|
||||
# 如果输出文件夹已存在,且内部有文件,则视为已转换,直接跳过
|
||||
# ==========================================
|
||||
if os.path.exists(output_folder) and len(os.listdir(output_folder)) > 0:
|
||||
print(f"⏩ 已存在转换结果,跳过处理: {filename}")
|
||||
continue
|
||||
|
||||
print(f"正在上传并处理 {filename}...")
|
||||
|
||||
try:
|
||||
# 4. 发送请求
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": (filename, f, "application/pdf")}
|
||||
response = requests.post(url, files=files)
|
||||
|
||||
# 5. 处理响应结果
|
||||
if response.status_code == 200:
|
||||
# 检查服务端是否返回了包含报错信息的 JSON
|
||||
if response.headers.get('content-type') == 'application/json':
|
||||
error_msg = response.json()
|
||||
print(f"❌ 服务端处理失败 ({filename}):{error_msg.get('message', '未知错误')}")
|
||||
continue # 跳过解压,处理下一个
|
||||
|
||||
# 确保该 PDF 专属的输出文件夹存在
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
|
||||
# 核心:使用 io.BytesIO 直接在内存中读取 zip 内容并解压
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as zip_ref:
|
||||
zip_ref.extractall(output_folder)
|
||||
print(f"✅ 成功!已解压并保存至文件夹: {output_folder}")
|
||||
except zipfile.BadZipFile:
|
||||
print(f"❌ 失败!{filename} 返回的内容不是有效的 ZIP 格式。")
|
||||
else:
|
||||
print(f"❌ 失败!{filename} 状态码: {response.status_code}, 报错: {response.text}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ 网络请求异常 ({filename}): {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ 处理 {filename} 时发生未知错误: {e}")
|
||||
|
||||
print("-" * 30)
|
||||
print("转换任务结束,开始进行文件夹同步清理...")
|
||||
|
||||
# ==========================================
|
||||
# 【新增逻辑】:反向比对,清理多余的 MD 文件夹
|
||||
# ==========================================
|
||||
# 1. 收集当前 ORI_PDF 中所有有效的 PDF 名字(不含后缀)
|
||||
valid_pdf_names = set()
|
||||
for filename in os.listdir(source_dir):
|
||||
if filename.lower().endswith(".pdf"):
|
||||
valid_pdf_names.add(os.path.splitext(filename)[0])
|
||||
|
||||
# 2. 遍历 ORI_MD 文件夹
|
||||
if os.path.exists(target_dir):
|
||||
for folder_name in os.listdir(target_dir):
|
||||
folder_path = os.path.join(target_dir, folder_name)
|
||||
|
||||
# 仅处理文件夹(以防里面有意外的独立文件)
|
||||
if os.path.isdir(folder_path):
|
||||
# 3. 如果这个文件夹的名字不在有效的 PDF 列表中,说明是被删掉的“孤儿”
|
||||
if folder_name not in valid_pdf_names:
|
||||
print(f"🧹 发现多余文件,正在清理: {folder_name}")
|
||||
try:
|
||||
# 使用 shutil.rmtree 删除整个文件夹及其内部所有内容
|
||||
shutil.rmtree(folder_path)
|
||||
except Exception as e:
|
||||
print(f"❌ 删除 {folder_name} 时发生错误: {e}")
|
||||
|
||||
print("-" * 30)
|
||||
print("所有批量任务彻底完成!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n检测到用户中断,程序退出。")
|
||||
697
患者首页处理/数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py
Normal file
697
患者首页处理/数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py
Normal file
@@ -0,0 +1,697 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
把患者首页 PDF 转为图片,并生成图片-结构化字段对照核验清单。
|
||||
|
||||
依赖系统命令 pdftoppm,通常由 poppler-utils 提供。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_INPUT_DIR = PROJECT_ROOT / "待处理-患者首页PDF"
|
||||
DEFAULT_RESULT_DIR = PROJECT_ROOT / "数据处理结果区"
|
||||
DEFAULT_IMAGE_REVIEW_DIR = DEFAULT_RESULT_DIR / "06_PDF图片对照"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FieldCheck:
|
||||
group: str
|
||||
key: str
|
||||
level: str
|
||||
location_hint: str
|
||||
|
||||
|
||||
FIELD_CHECKS = [
|
||||
FieldCheck("基本信息", "医疗机构", "recommended", "首页抬头及组织机构代码附近"),
|
||||
FieldCheck("基本信息", "医疗付费方式", "recommended", "首页左上付费方式"),
|
||||
FieldCheck("基本信息", "健康卡号", "recommended", "首页左上健康卡号"),
|
||||
FieldCheck("基本信息", "住院次数", "recommended", "首页左上住院次数"),
|
||||
FieldCheck("基本信息", "病案号", "required", "首页左上病案号"),
|
||||
FieldCheck("基本信息", "姓名", "required", "基本信息行"),
|
||||
FieldCheck("基本信息", "性别", "required", "基本信息行"),
|
||||
FieldCheck("基本信息", "出生日期", "required", "基本信息行"),
|
||||
FieldCheck("基本信息", "年龄", "required", "基本信息行"),
|
||||
FieldCheck("基本信息", "国籍", "recommended", "基本信息行"),
|
||||
FieldCheck("基本信息", "身份证号", "required", "基本信息行"),
|
||||
FieldCheck("基本信息", "职业", "recommended", "基本信息行"),
|
||||
FieldCheck("基本信息", "婚姻代码", "recommended", "基本信息行"),
|
||||
FieldCheck("基本信息", "出生地", "recommended", "地址信息区"),
|
||||
FieldCheck("基本信息", "籍贯", "recommended", "地址信息区"),
|
||||
FieldCheck("基本信息", "民族", "recommended", "地址信息区"),
|
||||
FieldCheck("地址联系人", "现住址", "required", "现住址行"),
|
||||
FieldCheck("地址联系人", "现住址电话", "recommended", "现住址行"),
|
||||
FieldCheck("地址联系人", "现住址邮编", "recommended", "现住址行"),
|
||||
FieldCheck("地址联系人", "户口地址", "recommended", "户口地址行"),
|
||||
FieldCheck("地址联系人", "户口地址邮编", "recommended", "户口地址行"),
|
||||
FieldCheck("地址联系人", "工作单位及地址", "recommended", "工作单位及地址行"),
|
||||
FieldCheck("地址联系人", "单位电话", "recommended", "工作单位及地址行"),
|
||||
FieldCheck("地址联系人", "单位邮编", "recommended", "工作单位及地址行"),
|
||||
FieldCheck("地址联系人", "联系人姓名", "required", "联系人信息行"),
|
||||
FieldCheck("地址联系人", "联系人关系", "recommended", "联系人信息行"),
|
||||
FieldCheck("地址联系人", "联系人地址", "recommended", "联系人信息行"),
|
||||
FieldCheck("地址联系人", "联系人电话", "required", "联系人信息行"),
|
||||
FieldCheck("入出院", "入院途径代码", "recommended", "入院途径勾选项"),
|
||||
FieldCheck("入出院", "入院时间", "required", "入院记录行"),
|
||||
FieldCheck("入出院", "入院科别", "required", "入院记录行"),
|
||||
FieldCheck("入出院", "入院病房", "recommended", "入院记录行"),
|
||||
FieldCheck("入出院", "转科科别", "optional", "转科科别行"),
|
||||
FieldCheck("入出院", "出院时间", "required", "出院记录行"),
|
||||
FieldCheck("入出院", "出院科别", "required", "出院记录行"),
|
||||
FieldCheck("入出院", "出院病房", "recommended", "出院记录行"),
|
||||
FieldCheck("入出院", "实际住院天数", "required", "出院记录行"),
|
||||
FieldCheck("入出院", "大科室", "required", "由入院/出院科别映射"),
|
||||
FieldCheck("诊断手术", "门急诊诊断", "recommended", "诊断区顶部"),
|
||||
FieldCheck("诊断手术", "门急诊诊断编码", "recommended", "诊断区顶部"),
|
||||
FieldCheck("诊断手术", "主要诊断名称", "required", "出院诊断表第一行"),
|
||||
FieldCheck("诊断手术", "主要诊断编码", "required", "出院诊断表第一行"),
|
||||
FieldCheck("诊断手术", "主要诊断入院病情", "required", "出院诊断表第一行"),
|
||||
FieldCheck("诊断手术", "出院诊断", "recommended", "出院诊断表全部行"),
|
||||
FieldCheck("诊断手术", "手术操作", "recommended", "手术及操作表"),
|
||||
FieldCheck("诊断手术", "损伤中毒外部原因", "optional", "损伤中毒外部原因行"),
|
||||
FieldCheck("诊断手术", "损伤中毒疾病编码", "optional", "损伤中毒外部原因行"),
|
||||
FieldCheck("诊断手术", "病理诊断", "optional", "病理诊断行"),
|
||||
FieldCheck("诊断手术", "病理诊断编码", "optional", "病理诊断行"),
|
||||
FieldCheck("诊断手术", "病理号", "optional", "病理号"),
|
||||
FieldCheck("质控信息", "药物过敏代码", "recommended", "药物过敏勾选项"),
|
||||
FieldCheck("质控信息", "过敏药物", "optional", "过敏药物填写处"),
|
||||
FieldCheck("质控信息", "死亡患者尸检代码", "recommended", "死亡患者尸检勾选项"),
|
||||
FieldCheck("质控信息", "血型代码", "recommended", "血型勾选项"),
|
||||
FieldCheck("质控信息", "Rh代码", "recommended", "Rh勾选项"),
|
||||
FieldCheck("质控信息", "科主任", "recommended", "医师签名区"),
|
||||
FieldCheck("质控信息", "主任副主任医师", "recommended", "医师签名区"),
|
||||
FieldCheck("质控信息", "主治医师", "recommended", "医师签名区"),
|
||||
FieldCheck("质控信息", "住院医师", "recommended", "医师签名区"),
|
||||
FieldCheck("质控信息", "责任护士", "recommended", "护理签名区"),
|
||||
FieldCheck("质控信息", "编码员", "recommended", "编码员签名区"),
|
||||
FieldCheck("质控信息", "病案质量代码", "recommended", "病案质量勾选项"),
|
||||
FieldCheck("质控信息", "质控医师", "recommended", "质控签名区"),
|
||||
FieldCheck("质控信息", "质控护士", "recommended", "质控签名区"),
|
||||
FieldCheck("质控信息", "质控日期", "recommended", "质控日期"),
|
||||
FieldCheck("离院费用", "离院方式代码", "recommended", "离院方式勾选项"),
|
||||
FieldCheck("离院费用", "出院31天内再住院计划代码", "recommended", "再住院计划勾选项"),
|
||||
FieldCheck("离院费用", "再住院计划目的", "optional", "再住院计划目的"),
|
||||
FieldCheck("离院费用", "入院前昏迷天数", "optional", "昏迷时间区"),
|
||||
FieldCheck("离院费用", "入院后昏迷天数", "optional", "昏迷时间区"),
|
||||
FieldCheck("离院费用", "总费用", "optional", "费用区"),
|
||||
FieldCheck("离院费用", "自付金额", "optional", "费用区"),
|
||||
FieldCheck("离院费用", "费用明细", "optional", "费用明细区"),
|
||||
]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="患者首页 PDF 转图片并生成字段对照核验报告")
|
||||
parser.add_argument("-i", "--input-dir", type=Path, default=DEFAULT_INPUT_DIR, help="PDF 输入目录")
|
||||
parser.add_argument("-r", "--result-dir", type=Path, default=DEFAULT_RESULT_DIR, help="解析结果根目录")
|
||||
parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_IMAGE_REVIEW_DIR, help="图片对照输出目录")
|
||||
parser.add_argument("--dpi", type=int, default=180, help="图片分辨率,默认 180")
|
||||
parser.add_argument("--format", choices=["png", "jpeg"], default="png", help="图片格式,默认 png")
|
||||
parser.add_argument("--force", action="store_true", help="重新生成已存在图片")
|
||||
parser.add_argument("--strict-recommended", action="store_true", help="把建议字段缺项也写入对照建议")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(cmd, check=True, text=True, capture_output=True)
|
||||
|
||||
|
||||
def numeric_page_key(path: Path) -> tuple[int, str]:
|
||||
match = re.search(r"-(\d+)\.(png|jpe?g)$", path.name, flags=re.IGNORECASE)
|
||||
if not match:
|
||||
return (10**9, path.name)
|
||||
return (int(match.group(1)), path.name)
|
||||
|
||||
|
||||
def convert_pdf_to_images(pdf_path: Path, output_dir: Path, dpi: int, image_format: str, force: bool) -> tuple[list[Path], str]:
|
||||
pdftoppm = shutil.which("pdftoppm")
|
||||
if not pdftoppm:
|
||||
return [], "未找到 pdftoppm,请安装 poppler-utils。"
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
extension = "jpg" if image_format == "jpeg" else "png"
|
||||
existing = sorted(output_dir.glob(f"page-*.{extension}"), key=numeric_page_key)
|
||||
if existing and not force:
|
||||
return existing, ""
|
||||
|
||||
if force:
|
||||
for old_image in output_dir.glob("page-*.*"):
|
||||
old_image.unlink()
|
||||
|
||||
prefix = output_dir / "page"
|
||||
cmd = [pdftoppm, "-r", str(dpi), f"-{image_format}", str(pdf_path), str(prefix)]
|
||||
try:
|
||||
run(cmd)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
detail = exc.stderr.strip() or exc.stdout.strip() or str(exc)
|
||||
return [], f"图片转换失败:{detail}"
|
||||
|
||||
images = sorted(output_dir.glob(f"page-*.{extension}"), key=numeric_page_key)
|
||||
normalized_images: list[Path] = []
|
||||
for index, image_path in enumerate(images, start=1):
|
||||
target = output_dir / f"page-{index:03d}.{extension}"
|
||||
if image_path != target:
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
image_path.rename(target)
|
||||
normalized_images.append(target)
|
||||
return normalized_images, "" if normalized_images else "图片转换后未找到输出文件。"
|
||||
|
||||
|
||||
def load_record(result_dir: Path, pdf_path: Path) -> tuple[dict[str, Any], Path | None, str]:
|
||||
json_path = result_dir / "02_单份JSON" / f"{pdf_path.stem}.json"
|
||||
if not json_path.exists():
|
||||
return {}, None, "未找到结构化 JSON,请先运行 02_解析入库 步骤。"
|
||||
try:
|
||||
return json.loads(json_path.read_text(encoding="utf-8")), json_path, ""
|
||||
except json.JSONDecodeError as exc:
|
||||
return {}, json_path, f"结构化 JSON 读取失败:{exc}"
|
||||
|
||||
|
||||
def is_blank(value: Any) -> bool:
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
return text == "" or text in {"-", "--", "null", "None", "[]", "{}"}
|
||||
if isinstance(value, (list, tuple, set, dict)):
|
||||
return len(value) == 0
|
||||
return False
|
||||
|
||||
|
||||
def value_preview(value: Any, max_length: int = 120) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (list, dict)):
|
||||
text = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
text = str(value)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if len(text) > max_length:
|
||||
return text[: max_length - 1] + "…"
|
||||
return text
|
||||
|
||||
|
||||
def build_field_rows(record: dict[str, Any]) -> tuple[list[dict[str, str]], list[str], list[str]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
missing_required: list[str] = []
|
||||
missing_recommended: list[str] = []
|
||||
|
||||
for check in FIELD_CHECKS:
|
||||
value = record.get(check.key)
|
||||
missing = is_blank(value)
|
||||
if missing and check.level == "required":
|
||||
missing_required.append(check.key)
|
||||
elif missing and check.level == "recommended":
|
||||
missing_recommended.append(check.key)
|
||||
rows.append(
|
||||
{
|
||||
"group": check.group,
|
||||
"key": check.key,
|
||||
"level": check.level,
|
||||
"location_hint": check.location_hint,
|
||||
"value": value_preview(value),
|
||||
"missing": "是" if missing else "否",
|
||||
}
|
||||
)
|
||||
|
||||
return rows, missing_required, missing_recommended
|
||||
|
||||
|
||||
def as_csv_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (list, dict)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return str(value)
|
||||
|
||||
|
||||
def relative_to(path: Path, start: Path) -> str:
|
||||
try:
|
||||
return path.relative_to(start).as_posix()
|
||||
except ValueError:
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def html_escape(value: Any) -> str:
|
||||
return html.escape(str(value), quote=True)
|
||||
|
||||
|
||||
def make_case_summary(
|
||||
pdf_path: Path,
|
||||
images: list[Path],
|
||||
image_error: str,
|
||||
record: dict[str, Any],
|
||||
json_path: Path | None,
|
||||
json_error: str,
|
||||
output_dir: Path,
|
||||
strict_recommended: bool,
|
||||
) -> dict[str, Any]:
|
||||
field_rows, missing_required, missing_recommended = build_field_rows(record) if record else ([], [], [])
|
||||
review_notes = record.get("复核备注", []) if record else []
|
||||
if not isinstance(review_notes, list):
|
||||
review_notes = [review_notes]
|
||||
|
||||
suggestions: list[str] = []
|
||||
if image_error:
|
||||
suggestions.append(image_error)
|
||||
if json_error:
|
||||
suggestions.append(json_error)
|
||||
if missing_required:
|
||||
suggestions.append("核心字段缺项,需对照图片补齐:" + "、".join(missing_required))
|
||||
if strict_recommended and missing_recommended:
|
||||
suggestions.append("建议字段缺项,抽样确认是否首页未填写:" + "、".join(missing_recommended))
|
||||
if record.get("复核状态") and record.get("复核状态") != "auto_pass":
|
||||
suggestions.append("该记录已有复核状态:" + str(record.get("复核状态")))
|
||||
if review_notes:
|
||||
suggestions.append("已有复核备注:" + ";".join(value_preview(note, 80) for note in review_notes))
|
||||
if not suggestions:
|
||||
suggestions.append("图片与结构化字段抽查一致即可。")
|
||||
|
||||
return {
|
||||
"pdf_path": pdf_path,
|
||||
"source_file": pdf_path.name,
|
||||
"images": images,
|
||||
"image_dir": images[0].parent if images else output_dir / "图片" / pdf_path.stem,
|
||||
"first_image": images[0] if images else None,
|
||||
"page_count": len(images),
|
||||
"image_error": image_error,
|
||||
"record": record,
|
||||
"json_path": json_path,
|
||||
"json_error": json_error,
|
||||
"field_rows": field_rows,
|
||||
"missing_required": missing_required,
|
||||
"missing_recommended": missing_recommended,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
|
||||
|
||||
def write_index_csv(cases: list[dict[str, Any]], output_dir: Path) -> Path:
|
||||
csv_path = output_dir / "患者首页_PDF图片对照索引.csv"
|
||||
fieldnames = [
|
||||
"源文件",
|
||||
"页数",
|
||||
"图片目录",
|
||||
"首页图片",
|
||||
"结构化JSON",
|
||||
"病案号",
|
||||
"姓名",
|
||||
"复核状态",
|
||||
"复核备注",
|
||||
"核心缺项",
|
||||
"建议核对缺项",
|
||||
"对照建议",
|
||||
]
|
||||
with csv_path.open("w", encoding="utf-8-sig", newline="") as fp:
|
||||
writer = csv.DictWriter(fp, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for case in cases:
|
||||
record = case["record"]
|
||||
writer.writerow(
|
||||
{
|
||||
"源文件": case["source_file"],
|
||||
"页数": case["page_count"],
|
||||
"图片目录": relative_to(case["image_dir"], output_dir),
|
||||
"首页图片": relative_to(case["first_image"], output_dir) if case["first_image"] else "",
|
||||
"结构化JSON": relative_to(case["json_path"], output_dir) if case["json_path"] else "",
|
||||
"病案号": record.get("病案号", ""),
|
||||
"姓名": record.get("姓名", ""),
|
||||
"复核状态": record.get("复核状态", ""),
|
||||
"复核备注": as_csv_text(record.get("复核备注", [])),
|
||||
"核心缺项": "、".join(case["missing_required"]),
|
||||
"建议核对缺项": "、".join(case["missing_recommended"]),
|
||||
"对照建议": ";".join(case["suggestions"]),
|
||||
}
|
||||
)
|
||||
return csv_path
|
||||
|
||||
|
||||
def render_field_table(field_rows: list[dict[str, str]]) -> str:
|
||||
if not field_rows:
|
||||
return '<p class="empty">未生成字段核验表。</p>'
|
||||
|
||||
parts = [
|
||||
'<table class="field-table">',
|
||||
"<thead><tr><th>组</th><th>字段</th><th>级别</th><th>首页位置</th><th>值</th><th>缺项</th></tr></thead>",
|
||||
"<tbody>",
|
||||
]
|
||||
for row in field_rows:
|
||||
missing_class = " is-missing" if row["missing"] == "是" and row["level"] != "optional" else ""
|
||||
parts.append(
|
||||
"<tr class=\"{missing_class}\">"
|
||||
"<td>{group}</td><td>{key}</td><td>{level}</td><td>{location}</td><td>{value}</td><td>{missing}</td>"
|
||||
"</tr>".format(
|
||||
missing_class=missing_class.strip(),
|
||||
group=html_escape(row["group"]),
|
||||
key=html_escape(row["key"]),
|
||||
level=html_escape(row["level"]),
|
||||
location=html_escape(row["location_hint"]),
|
||||
value=html_escape(row["value"]),
|
||||
missing=html_escape(row["missing"]),
|
||||
)
|
||||
)
|
||||
parts.append("</tbody></table>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def render_html(cases: list[dict[str, Any]], output_dir: Path) -> Path:
|
||||
html_path = output_dir / "患者首页_PDF图片对照.html"
|
||||
total = len(cases)
|
||||
with_required_missing = sum(1 for case in cases if case["missing_required"])
|
||||
with_review_status = sum(1 for case in cases if case["record"].get("复核状态") not in {"", "auto_pass", None})
|
||||
failed_images = sum(1 for case in cases if case["image_error"])
|
||||
|
||||
nav_rows = []
|
||||
case_sections = []
|
||||
for index, case in enumerate(cases, start=1):
|
||||
anchor = f"case-{index}"
|
||||
record = case["record"]
|
||||
status = record.get("复核状态", "未解析")
|
||||
nav_rows.append(
|
||||
"<tr><td><a href=\"#{anchor}\">{source}</a></td><td>{name}</td><td>{mrn}</td><td>{status}</td><td>{required}</td></tr>".format(
|
||||
anchor=anchor,
|
||||
source=html_escape(case["source_file"]),
|
||||
name=html_escape(record.get("姓名", "")),
|
||||
mrn=html_escape(record.get("病案号", "")),
|
||||
status=html_escape(status),
|
||||
required=html_escape("、".join(case["missing_required"]) or "无"),
|
||||
)
|
||||
)
|
||||
|
||||
images_html = []
|
||||
for image_path in case["images"]:
|
||||
rel_image = relative_to(image_path, output_dir)
|
||||
images_html.append(
|
||||
'<figure><img loading="lazy" src="{src}" alt="{alt}"><figcaption>{caption}</figcaption></figure>'.format(
|
||||
src=html_escape(rel_image),
|
||||
alt=html_escape(f"{case['source_file']} {image_path.name}"),
|
||||
caption=html_escape(image_path.name),
|
||||
)
|
||||
)
|
||||
if not images_html:
|
||||
images_html.append('<p class="empty">未生成图片。</p>')
|
||||
|
||||
suggestion_items = "\n".join(f"<li>{html_escape(note)}</li>" for note in case["suggestions"])
|
||||
summary_items = [
|
||||
("病案号", record.get("病案号", "")),
|
||||
("姓名", record.get("姓名", "")),
|
||||
("大科室", record.get("大科室", "")),
|
||||
("出院科别", record.get("出院科别", "")),
|
||||
("主要诊断", record.get("主要诊断名称", "")),
|
||||
("主要诊断编码", record.get("主要诊断编码", "")),
|
||||
("复核状态", status),
|
||||
]
|
||||
summary_html = "\n".join(
|
||||
f"<dt>{html_escape(label)}</dt><dd>{html_escape(value_preview(value, 160))}</dd>" for label, value in summary_items
|
||||
)
|
||||
case_sections.append(
|
||||
"""
|
||||
<section class="case" id="{anchor}">
|
||||
<header class="case-header">
|
||||
<div>
|
||||
<h2>{source}</h2>
|
||||
<p>{name} · {mrn} · {status}</p>
|
||||
</div>
|
||||
<a class="back-link" href="#top">返回顶部</a>
|
||||
</header>
|
||||
<div class="case-grid">
|
||||
<div class="image-pane">{images}</div>
|
||||
<div class="data-pane">
|
||||
<dl class="summary">{summary}</dl>
|
||||
<div class="suggestions"><h3>对照建议</h3><ul>{suggestions}</ul></div>
|
||||
{field_table}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
""".format(
|
||||
anchor=anchor,
|
||||
source=html_escape(case["source_file"]),
|
||||
name=html_escape(record.get("姓名", "")),
|
||||
mrn=html_escape(record.get("病案号", "")),
|
||||
status=html_escape(status),
|
||||
images="\n".join(images_html),
|
||||
summary=summary_html,
|
||||
suggestions=suggestion_items,
|
||||
field_table=render_field_table(case["field_rows"]),
|
||||
)
|
||||
)
|
||||
|
||||
page_template = Template(
|
||||
"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>患者首页 PDF 图片对照核验</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #15181c;
|
||||
--muted: #5f6975;
|
||||
--line: #d9dde3;
|
||||
--paper: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--bad: #b42318;
|
||||
--bad-bg: #fff2ef;
|
||||
--warn: #8a5a00;
|
||||
--warn-bg: #fff7df;
|
||||
--ok: #1f7a4d;
|
||||
--ok-bg: #edf8f2;
|
||||
--accent: #245b8f;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
header.report-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding: 14px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
h1, h2, h3 { margin: 0; letter-spacing: 0; }
|
||||
h1 { font-size: 20px; }
|
||||
h2 { font-size: 16px; }
|
||||
h3 { font-size: 14px; margin-bottom: 8px; }
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.pill {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
main { padding: 18px 22px 32px; }
|
||||
.index {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 7px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
th {
|
||||
background: #eef1f5;
|
||||
color: #303844;
|
||||
font-weight: 700;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.case {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
margin: 18px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.case-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fbfcfd;
|
||||
}
|
||||
.case-header p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.back-link { white-space: nowrap; align-self: center; }
|
||||
.case-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 55%) minmax(360px, 45%);
|
||||
min-height: 520px;
|
||||
}
|
||||
.image-pane {
|
||||
border-right: 1px solid var(--line);
|
||||
background: #eceff3;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
max-height: 86vh;
|
||||
}
|
||||
figure { margin: 0 0 14px; }
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid #c5cbd3;
|
||||
background: #fff;
|
||||
}
|
||||
figcaption {
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.data-pane {
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
max-height: 86vh;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: 108px 1fr;
|
||||
gap: 5px 10px;
|
||||
margin: 0 0 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
dt { color: var(--muted); }
|
||||
dd { margin: 0; font-weight: 600; }
|
||||
.suggestions {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ead7a7;
|
||||
border-radius: 8px;
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn);
|
||||
}
|
||||
.suggestions ul { margin: 0; padding-left: 18px; }
|
||||
.field-table td:nth-child(5) { max-width: 360px; }
|
||||
.field-table .is-missing td {
|
||||
background: var(--bad-bg);
|
||||
color: var(--bad);
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
padding: 8px;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.case-grid { grid-template-columns: 1fr; }
|
||||
.image-pane { border-right: 0; border-bottom: 1px solid var(--line); max-height: none; }
|
||||
.data-pane { max-height: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="report-header" id="top">
|
||||
<h1>患者首页 PDF 图片对照核验</h1>
|
||||
<div class="meta">
|
||||
<span class="pill">PDF:$total</span>
|
||||
<span class="pill">核心缺项:$with_required_missing</span>
|
||||
<span class="pill">需复核状态:$with_review_status</span>
|
||||
<span class="pill">图片失败:$failed_images</span>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="index">
|
||||
<table>
|
||||
<thead><tr><th>源文件</th><th>姓名</th><th>病案号</th><th>复核状态</th><th>核心缺项</th></tr></thead>
|
||||
<tbody>$nav_rows</tbody>
|
||||
</table>
|
||||
</section>
|
||||
$case_sections
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
)
|
||||
html_path.write_text(
|
||||
page_template.substitute(
|
||||
total=total,
|
||||
with_required_missing=with_required_missing,
|
||||
with_review_status=with_review_status,
|
||||
failed_images=failed_images,
|
||||
nav_rows="\n".join(nav_rows),
|
||||
case_sections="\n".join(case_sections),
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return html_path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
input_dir = args.input_dir.resolve()
|
||||
result_dir = args.result_dir.resolve()
|
||||
output_dir = args.output_dir.resolve()
|
||||
image_root = output_dir / "图片"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
pdf_files = sorted(input_dir.glob("*.pdf"))
|
||||
if not pdf_files:
|
||||
raise SystemExit(f"未找到 PDF:{input_dir}")
|
||||
|
||||
cases: list[dict[str, Any]] = []
|
||||
for pdf_path in pdf_files:
|
||||
case_image_dir = image_root / pdf_path.stem
|
||||
images, image_error = convert_pdf_to_images(pdf_path, case_image_dir, args.dpi, args.format, args.force)
|
||||
record, json_path, json_error = load_record(result_dir, pdf_path)
|
||||
cases.append(
|
||||
make_case_summary(
|
||||
pdf_path=pdf_path,
|
||||
images=images,
|
||||
image_error=image_error,
|
||||
record=record,
|
||||
json_path=json_path,
|
||||
json_error=json_error,
|
||||
output_dir=output_dir,
|
||||
strict_recommended=args.strict_recommended,
|
||||
)
|
||||
)
|
||||
|
||||
index_csv = write_index_csv(cases, output_dir)
|
||||
html_path = render_html(cases, output_dir)
|
||||
required_missing_count = sum(1 for case in cases if case["missing_required"])
|
||||
print(f"PDF 数量:{len(cases)}")
|
||||
print(f"图片输出:{image_root}")
|
||||
print(f"对照索引:{index_csv}")
|
||||
print(f"HTML 对照页:{html_path}")
|
||||
print(f"核心缺项病例数:{required_missing_count}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
205
患者首页处理/数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py
Normal file
205
患者首页处理/数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Kimi 视觉兜底识别:用于 pdftotext/Mineru 对局部字段解析不稳定时,人工指定 PDF 图片页进行辅助抽取。
|
||||
|
||||
默认只输出 JSON 文件,不直接覆盖 PostgreSQL。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_IMAGE_ROOT = PROJECT_ROOT / "数据处理结果区" / "06_PDF图片对照" / "图片"
|
||||
DEFAULT_PDF_ROOT = PROJECT_ROOT / "已处理-患者首页PDF" / "2026_5_25_处理"
|
||||
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "数据处理结果区" / "07_Kimi视觉识别"
|
||||
DEFAULT_API_BASE = "https://api.moonshot.cn/v1"
|
||||
DEFAULT_MODEL = os.environ.get("KIMI_MODEL", "kimi-k2.6")
|
||||
|
||||
|
||||
PROMPT = """请读取这张住院病案首页图片,仅返回 JSON。
|
||||
优先核对这些区域:基本信息、地址联系人、入出院信息、诊断、手术及操作、质控签名、离院方式、费用。
|
||||
如果是手术及操作区域,请按以下列拆分:
|
||||
手术操作编码、手术操作日期、手术级别、手术操作名称、术者、I助、II助、切口愈合等级、麻醉方式、麻醉医师。
|
||||
无法确认的字段填空字符串,不要编造。输出结构:
|
||||
{
|
||||
"page_summary": "",
|
||||
"suspected_missing_fields": [],
|
||||
"fields": {},
|
||||
"discharge_diagnoses": [],
|
||||
"operations": [],
|
||||
"fees": {},
|
||||
"notes": []
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="使用 Kimi 视觉模型辅助识别患者首页图片")
|
||||
parser.add_argument("--image-root", type=Path, default=DEFAULT_IMAGE_ROOT, help="PDF 转图片根目录")
|
||||
parser.add_argument("--pdf-root", type=Path, default=DEFAULT_PDF_ROOT, help="没有图片目录时,从该 PDF 目录临时渲染页面")
|
||||
parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="Kimi 识别结果输出目录")
|
||||
parser.add_argument("--case", help="只处理指定病例目录名,例如 ZY010001672803_ori")
|
||||
parser.add_argument("--page", help="只处理指定图片名,例如 page-001.png")
|
||||
parser.add_argument("--pdf-page", type=int, help="从 PDF 临时渲染时,只处理指定页码,从 1 开始")
|
||||
parser.add_argument("--dpi", type=int, default=160, help="PDF 临时渲染分辨率")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Kimi 模型名")
|
||||
parser.add_argument("--api-base", default=os.environ.get("MOONSHOT_API_BASE", DEFAULT_API_BASE), help="Moonshot API base URL")
|
||||
parser.add_argument("--api-key", default=os.environ.get("MOONSHOT_API_KEY") or os.environ.get("KIMI_API_KEY"), help="API Key,也可用 MOONSHOT_API_KEY")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def image_to_data_url(path: Path) -> str:
|
||||
mime_type, _ = mimetypes.guess_type(path.name)
|
||||
if mime_type not in {"image/png", "image/jpeg", "image/webp", "image/gif"}:
|
||||
mime_type = "image/png"
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("utf-8")
|
||||
return f"data:{mime_type};base64,{encoded}"
|
||||
|
||||
|
||||
def call_kimi(image_path: Path, args: argparse.Namespace) -> dict[str, Any]:
|
||||
if not args.api_key:
|
||||
raise RuntimeError("未设置 MOONSHOT_API_KEY/KIMI_API_KEY,无法调用 Kimi API。")
|
||||
payload = {
|
||||
"model": args.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是严谨的病案首页结构化抽取助手,只输出 JSON。"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": image_to_data_url(image_path)}},
|
||||
{"type": "text", "text": PROMPT},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
f"{args.api_base.rstrip('/')}/chat/completions",
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {args.api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=180) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"Kimi API 返回错误 {exc.code}: {detail}") from exc
|
||||
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
parsed = parse_json_content(content)
|
||||
return {
|
||||
"image": str(image_path),
|
||||
"model": args.model,
|
||||
"recognized_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"raw_response": content,
|
||||
"parsed": parsed,
|
||||
}
|
||||
|
||||
|
||||
def parse_json_content(content: str) -> Any:
|
||||
text = content.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
if text.startswith("json"):
|
||||
text = text[4:].strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
return {"notes": ["Kimi 返回内容不是合法 JSON"], "text": content}
|
||||
|
||||
|
||||
def iter_images(args: argparse.Namespace) -> list[Path]:
|
||||
root = args.image_root
|
||||
if args.case:
|
||||
case_dir = root / args.case
|
||||
if args.page:
|
||||
return [case_dir / args.page]
|
||||
return sorted(case_dir.glob("page-*.*"))
|
||||
images: list[Path] = []
|
||||
for case_dir in sorted(path for path in root.iterdir() if path.is_dir()):
|
||||
images.extend(sorted(case_dir.glob("page-*.*")))
|
||||
return images
|
||||
|
||||
|
||||
def find_case_pdf(args: argparse.Namespace) -> Path | None:
|
||||
if not args.case:
|
||||
return None
|
||||
stem = Path(args.case).stem
|
||||
candidates = [
|
||||
args.pdf_root / f"{stem}.pdf",
|
||||
args.pdf_root / args.case,
|
||||
]
|
||||
return next((path for path in candidates if path.exists() and path.is_file()), None)
|
||||
|
||||
|
||||
def render_pdf_pages(pdf_path: Path, output_root: Path, args: argparse.Namespace) -> list[Path]:
|
||||
case_dir = output_root / pdf_path.stem
|
||||
case_dir.mkdir(parents=True, exist_ok=True)
|
||||
if args.pdf_page:
|
||||
prefix = case_dir / f"page-{args.pdf_page:03d}"
|
||||
command = [
|
||||
"pdftoppm",
|
||||
"-png",
|
||||
"-r",
|
||||
str(args.dpi),
|
||||
"-f",
|
||||
str(args.pdf_page),
|
||||
"-singlefile",
|
||||
str(pdf_path),
|
||||
str(prefix),
|
||||
]
|
||||
else:
|
||||
command = ["pdftoppm", "-png", "-r", str(args.dpi), str(pdf_path), str(case_dir / "page")]
|
||||
subprocess.run(command, check=True)
|
||||
return sorted(case_dir.glob("page-*.png")) or sorted(case_dir.glob("page.png"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
images = [path for path in iter_images(args) if path.exists() and path.is_file()]
|
||||
with tempfile.TemporaryDirectory(prefix="frontpage_kimi_") as temp_dir:
|
||||
if not images:
|
||||
pdf_path = find_case_pdf(args)
|
||||
if pdf_path:
|
||||
images = render_pdf_pages(pdf_path, Path(temp_dir), args)
|
||||
if not images:
|
||||
raise SystemExit("未找到待识别图片,也未找到可临时渲染的 PDF。")
|
||||
|
||||
for image_path in images:
|
||||
print(f"识别:{image_path}")
|
||||
result = call_kimi(image_path, args)
|
||||
out_path = args.output_dir / f"{image_path.parent.name}_{image_path.stem}.json"
|
||||
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"输出:{out_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Reference in New Issue
Block a user