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,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",
"健苑五涉外": "健苑五涉外病房",
"健苑五涉外病房": "健苑五涉外病房",
"老年外科": "老年外科"
}
}

View 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)

View 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)

View 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检测到用户中断,程序退出。")

View 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())

View 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)