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

22
患者首页处理/.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# 原始 PDF 与处理结果不进入 Gitea
待处理-患者首页PDF/
已处理-患者首页PDF/
数据处理结果区/
# 旧参考程序不作为本次工作流代码提交
参考-过去很烂的程序/
参考-病案首页图片/
# 本地工作流不进入 Gitea
工作流_本地使用版.md
工作流_本地使用版*.zip
# 本地配置、缓存和临时文件
.env
.env.*
!.env.example
数据可视化网页端/review_settings.local.json
__pycache__/
*.pyc
*.tmp
*.log

View File

@@ -0,0 +1,259 @@
# 患者首页 PDF 本地处理工作流
这个目录用于批量处理“患者首页 PDF”把每份 PDF 抽取成结构化记录,并输出到 `数据处理结果区/`
## 目录约定
- `待处理-患者首页PDF/`:放入等待处理的 PDF文件名建议保留首页病案号或住院号。
- `数据处理工作区/`:存放处理脚本和后续工作流工具。
- `数据处理结果区/`:脚本输出 CSV、JSONL、单份 JSON、抽取文本、PDF 图片对照页和复核清单。
- `数据可视化网页端/`Docker 化复核网页端,包含概览、复核、抽查、抽查一览、设置;设置内统一管理用户与权限、数据库状态和目录信息。
- `已处理-患者首页PDF/`:人工确认后,可把已处理 PDF 移到这里归档。
## 工作区文件
- `数据处理工作区/01_配置规则/01_科室分类规则.json`:子科室到大科室的分类规则。
- `数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py`:解析 PDF生成 CSV/JSON并可写入 PostgreSQL。
- `数据处理工作区/03_人工复核/03_人工复核导出与回写.py`:从 PostgreSQL 导出人工复核表,并把人工修正结果回写。
- `数据处理工作区/04_质量体检/04_字段核验与数据库体检.py`:检查字段注释、关键字段空值和疑似错位记录。
- `数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py`:备用 PDF 转 Markdown 工具,用于扫描件或 `pdftotext` 读取异常时兜底。
- `数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py`:把 PDF 转为图片,生成图片-字段对照 HTML 和缺项核验索引。
- `数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py`:可选的 Kimi 视觉识别兜底,仅在文本/Markdown 解析仍不稳定时人工触发。
## 环境文件
本地 `.env` 保存 PostgreSQL 连接信息和网页端目录映射,已被 `.gitignore` 忽略,不会提交到 Gitea。网页端模板见
```text
数据可视化网页端/.env.example
```
`.env` 也可配置 `MOONSHOT_API_KEY``KIMI_MODEL`。涉及患者信息的图片不会自动上传Kimi 视觉步骤只在人工运行 `07_Kimi图片识别辅助.py` 时调用。
## 快速运行
在当前项目根目录执行:
```bash
python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py
```
默认 `--text-source auto`:先用 `pdftotext`,如果抽取文本过短或缺少首页关键标记,再调用 Mineru 转 Markdown。
强制使用 Mineru
```bash
python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py \
--text-source mineru \
--mineru-url "http://MINERU_HOST:4000/extract"
```
单独运行 PDF 转 Markdown
```bash
python3 数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py \
-s 待处理-患者首页PDF \
-t 数据处理结果区/06_Mineru_MD \
-u "http://MINERU_HOST:4000/extract"
```
正式流程建议同时写入 PostgreSQL
```bash
export PGPASSWORD='请填写数据库密码'
python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py \
--write-postgres \
--pg-host DB_HOST \
--pg-port 5432 \
--pg-database DB_NAME \
--pg-user DB_USER \
--pg-table Patient_FrontPages
unset PGPASSWORD
```
也可以指定输入和输出目录:
```bash
python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py \
--input-dir 待处理-患者首页PDF \
--output-dir 数据处理结果区
```
生成 PDF 图片对照页:
```bash
python3 数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py --force
```
该步骤会读取 `02_单份JSON/` 的结构化结果,把每份 PDF 转为 PNG并生成
- `数据处理结果区/06_PDF图片对照/图片/`:每份 PDF 一个图片目录。
- `数据处理结果区/06_PDF图片对照/患者首页_PDF图片对照.html`:浏览器打开后可左侧看原图、右侧看结构化字段和缺项提示。
- `数据处理结果区/06_PDF图片对照/患者首页_PDF图片对照索引.csv`:按 PDF 汇总页数、复核状态、核心缺项、建议核对缺项。
启动可视化网页端:
```bash
docker compose -f 数据可视化网页端/docker-compose.yml up -d --build
```
打开:
```text
http://localhost:8501
```
首次进入会显示登录界面;默认管理员为 `admin`,默认密码为 `change-me`。设置页创建的本地用户会同步作为登录账号,并按权限控制各页面。
网页端会显示数据库连接情况、记录数、需复核数、PDF 数量;人工修改右侧字段并点击“复核并保存”后,会同步更新 PostgreSQL并把 `manual_corrected` 标记为 `true`
网页端说明:
- PDF 以 `inline` 方式在页面内打开,不再作为附件触发保存;浏览器内核无法彻底禁止用户保存已展示内容。
- “复核并保存”后,复核状态由后端自动标记为 `reviewed`,复核状态和质控状态只读显示在 PDF 下方。
- 诊断、手术、费用等 JSON 字段在右侧以表格方式编辑,保存时再写回 JSONB。
- 手术操作按编码、日期、级别、名称、术者、I助、II助、切口愈合等级、麻醉方式、麻醉医师拆分展示。
可选 Kimi 视觉兜底:
```bash
python3 数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py \
--case ZY010001672803_ori \
--page page-001.png
```
输出位于 `数据处理结果区/07_Kimi视觉识别/`,默认不直接覆盖 PostgreSQL需要人工确认后再回写。
## 人工复核流程
从 PostgreSQL 导出待复核表:
```bash
export PGPASSWORD='请填写数据库密码'
python3 数据处理工作区/03_人工复核/03_人工复核导出与回写.py export \
--pg-host DB_HOST \
--pg-port 5432 \
--pg-database DB_NAME \
--pg-user DB_USER \
--pg-table Patient_FrontPages
unset PGPASSWORD
```
人工在 `数据处理结果区/04_复核与人工校正/患者首页_人工复核表.csv` 中填写:
- `manual_review_notes`:人工复核说明。
- `corrected_medical_record_no`:修正后的病案号。
- `corrected_patient_name`:修正后的姓名。
- `corrected_major_department`:修正后的大科室。
- `corrected_primary_diagnosis``corrected_primary_diagnosis_code`:修正后的主要诊断。
- `corrected_total_cost``corrected_self_pay_amount`:修正后的费用。
- `mark_review_status`:建议填 `reviewed`;确实仍有问题可填 `needs_review`
回写 PostgreSQL
```bash
export PGPASSWORD='请填写数据库密码'
python3 数据处理工作区/03_人工复核/03_人工复核导出与回写.py import \
--pg-host DB_HOST \
--pg-port 5432 \
--pg-database DB_NAME \
--pg-user DB_USER \
--pg-table Patient_FrontPages
unset PGPASSWORD
```
回写后建议做数据库体检:
```bash
export PGPASSWORD='请填写数据库密码'
python3 数据处理工作区/04_质量体检/04_字段核验与数据库体检.py \
--pg-host DB_HOST \
--pg-port 5432 \
--pg-database DB_NAME \
--pg-user DB_USER \
--pg-table Patient_FrontPages
unset PGPASSWORD
```
体检会生成:
- `数据处理结果区/05_质量体检/患者首页_数据库体检报告.txt`
- `数据处理结果区/05_质量体检/患者首页_字段空值统计.csv`
- `数据处理结果区/05_质量体检/患者首页_疑似异常记录.csv`
## 输出文件
运行后会生成:
- `数据处理结果区/01_结构化结果/患者首页_结构化结果.csv`:适合 Excel/WPS 查看的一行一病例总表。
- `数据处理结果区/01_结构化结果/患者首页_结构化结果.jsonl`:每行一个完整 JSON适合后续程序继续处理。
- `数据处理结果区/02_单份JSON/*.json`:每份 PDF 一个完整 JSON保留嵌套诊断、手术、费用明细和原始文本。
- `数据处理结果区/03_提取文本/*.txt``pdftotext` 抽出的原始文本,用于人工核查。
- `数据处理结果区/04_复核与人工校正/患者首页_复核清单.csv`:脚本认为需要人工复核的病例。
- `数据处理结果区/04_复核与人工校正/患者首页_处理失败.csv`:只有处理失败时才会生成。
- `数据处理结果区/06_PDF图片对照/患者首页_PDF图片对照.html`PDF 图片与结构化字段的对照复核页。
- `数据处理结果区/06_PDF图片对照/患者首页_PDF图片对照索引.csv`:图片对照与字段缺项索引。
## PostgreSQL 表结构
`--write-postgres` 会写入 `public."Patient_FrontPages"` 宽表,不再使用 `payload` 总 JSON 字段,也不写入 `parsed_at/created_at/updated_at`
入库时会同步写入表注释和字段注释,便于在数据库客户端中查看每列含义。
网页端会按需把人工复核和抽查记录写入主表 JSONB 列:`review_logs` 记录人工保存时间、修改字段和备注;`audit_logs` 记录抽查来源、结论和备注。历史辅助表 `"Patient_FrontPages_review_logs"``"Patient_FrontPages_audit_logs"` 会自动迁移并删除,数据库最终只保留 `"Patient_FrontPages"` 一张业务表。
患者号/住院号 `inpatient_no` 是首页与患者列表联动唯一键,也是本程序唯一强制校验条件:不能为空。程序不校验编号格式,同一非空 `inpatient_no` 只保留一条首页记录;迁移时会删除历史重复住院号并保留最新记录,后续重复入库或网页写入会按 `inpatient_no` 覆盖旧首页。若 PDF 文件名和首页病案号可推导,程序仍会自动生成类似 `ZY020001447443` 的默认住院号,后续格式问题交由患者目录核验网页端处理。
病案号和首页病案号都按 10 位文本保存,保留前导 0。若 PDF 首页病案号缺前导 0但文件名可还原例如 `01447443` -> `0001447443`,脚本会自动修正,并写入:
- `inpatient_no`
- `front_page_medical_record_no`
- `major_department`
- `text_extraction_method`
- `mineru_markdown_dir`
- `review_status`
- `review_notes`
- `auto_corrections`
- `manual_corrected`
`Patient_Lists` 会同步增加 `has_front_page``front_page_id``front_page_source_file`:凡是患者号非空的首页记录,都会通过 PostgreSQL 触发器自动和 `Patient_Lists.inpatient_no` 关联;如果患者列表没有对应患者号,会由首页表自动补建一条列表记录并标记 `has_front_page = true`同时写入主要诊断、入院时间和出院时间。空患者号记录会在迁移和入库时被删除PGSQL 会阻止再次写入空患者号。当首页记录删除,或 `inpatient_no` 改为其他患者号时,触发器会把旧患者号对应的 `has_front_page` 置回 `false` 并清空首页引用。若同一患者号的姓名不一致,以首页表中的非空姓名覆盖 `Patient_Lists.patient_name`,保证患者号、姓名对齐;已有列表记录的诊断和入出院时间不被首页联动覆盖。
## 环境要求
脚本使用 Python 标准库,不需要安装 pandas/pdfplumber。
系统需要有 `pdftotext``pdftoppm` 命令。Linux 上通常都来自 `poppler-utils`
```bash
sudo apt-get install poppler-utils
```
`05_备用PDF转Markdown_Mineru.py` 需要 `requests`;只在单独使用 Mineru 客户端时安装即可:
```bash
python3 -m pip install requests
```
Docker 网页端依赖已写入 `数据可视化网页端/Dockerfile`,不再单独维护 `requirements.txt`
## 当前解析范围
脚本会抽取:
- 基本信息:住院号、病案号、首页病案号、姓名、性别、出生日期、年龄、身份证号、付费方式等。
- 入出院信息:入院/出院时间、科别、病房、实际住院天数、门急诊诊断。
- 科室分类:根据 `数据处理工作区/01_配置规则/01_科室分类规则.json` 生成 CSV 的 `大科室` 和 PostgreSQL 的 `major_department`
- 诊断信息:`discharge_diagnoses` 出院诊断 JSONB 数组,包含主要诊断、其他诊断、疾病编码、入院病情。
- 手术操作:编码、日期、名称、原始行内容。
- 首页质控信息:病理、药物过敏、尸检、血型/Rh、医师、护士、编码员、病案质量、质控日期、离院方式、再住院计划等。
- 费用信息:总费用、自付金额、各费用明细;若 PDF 首页未填写费用,不强制判为失败。
## 建议审核
每次运行后先看:
```bash
sed -n '1,20p' 数据处理结果区/04_复核与人工校正/患者首页_复核清单.csv
```
随后打开 `数据处理结果区/06_PDF图片对照/患者首页_PDF图片对照.html`,用 PDF 图片逐项对照病案号、姓名、入出院信息、诊断、手术、质控和费用。遇到新医院、新模板、扫描件或字段错位时,把 PDF、对应 TXT、图片对照索引中的缺项提示留在目录中再让 Codex 继续补规则。

View File

@@ -0,0 +1,306 @@
# 患者首页 PDF 处理工作流_Gitea版
## Gitea 仓库
仓库地址:
```text
https://gitea.huijutec.cn/admin/HIS_Front_Page.git
```
仓库只保存程序、文档和配置说明。不要提交原始 PDF、处理结果、明文密码、`.env` 文件,也不再提交加密本地工作流压缩包。
## 仓库内容
```text
README.md
工作流_Gitea版.md
数据可视化网页端/.env.example
数据可视化网页端/Dockerfile
数据可视化网页端/docker-compose.yml
数据可视化网页端/README.md
数据可视化网页端/app/
数据处理工作区/01_配置规则/01_科室分类规则.json
数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py
数据处理工作区/03_人工复核/03_人工复核导出与回写.py
数据处理工作区/04_质量体检/04_字段核验与数据库体检.py
数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py
数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py
数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py
```
## PostgreSQL 存储约定
目标数据库:
```text
主机/IP: DB_HOST
端口: 5432
数据库名: DB_NAME
用户名: DB_USER
表名: Patient_FrontPages
```
脚本会在首次写入时自动创建或迁移表 `"Patient_FrontPages"`。表是宽表结构,`payload` 中的顶层信息会展开为独立列;不再保存 `payload``parsed_at``created_at``updated_at`
脚本会为表和全部字段写入 PostgreSQL 注释,数据库客户端中可直接查看每列含义。
网页端会按需在主表中创建两个 JSONB 日志列:
- `review_logs`:记录人工保存时间、修改字段、修改前后值和人工备注。
- `audit_logs`:记录抽查来源、抽查状态、抽查备注和更新时间。
历史辅助表 `"Patient_FrontPages_review_logs"``"Patient_FrontPages_audit_logs"` 会自动迁移进主表并删除,最终只保留 `"Patient_FrontPages"` 一张业务表。
核心列包括:
- `inpatient_no`:患者号/住院号,作为首页与患者列表联动唯一键;这是本程序唯一强制校验条件,不能为空。程序不校验格式,同一非空患者号只允许一条首页,迁移时保留最新记录,后续同号入库或网页写入会覆盖旧首页。
- `source_file`PDF 文件名;重复运行以 `inpatient_no` 为准更新。
- `medical_record_no`10 位病案号,文本保存,保留前导 0。
- `front_page_medical_record_no`10 位首页病案号,文本保存,保留前导 0。
- `patient_name``gender``birth_date``id_card_no` 等基本信息列。
- `admission_time``discharge_time``primary_diagnosis``total_cost` 等业务字段列。
- `major_department`:根据 `数据处理工作区/01_配置规则/01_科室分类规则.json` 从出院科别优先、入院科别兜底映射出的大科室。
- `discharge_diagnoses``operations``fee_details`:复杂明细仍以 JSONB 保存;出院诊断已包含主要诊断和其他诊断,不再单独保存 `other_diagnoses`
- `quality_status``quality_notes`:程序质控结果。
- `text_extraction_method`:本次使用的文本抽取方式,如 `pdftotext``mineru_markdown`
- `mineru_markdown_dir`Mineru Markdown 输出目录。
- `review_status``auto_pass``auto_corrected``needs_review`
- `review_notes`:需要复核的原因。
- `manual_corrected`:人工是否已修正,默认 `false`
- `auto_corrections`:程序自动修正记录。
- `review_logs``audit_logs`:网页端人工复核与抽查归类记录。
- `raw_text`PDF 抽取出的原始文本。
`Patient_Lists` 会自动增加并维护 `has_front_page``front_page_id``front_page_source_file`。首页 `inpatient_no` 非空时PostgreSQL 触发器按 `Patient_FrontPages.inpatient_no = Patient_Lists.inpatient_no` 联动列表没有对应患者号时会自动补建列表记录并写入主要诊断、入院时间和出院时间患者号重复时以唯一键覆盖更新保证一个患者号只有一个病案首页。空患者号记录会在迁移和入库时被删除PGSQL 会阻止再次写入空患者号。删除首页记录,或把首页 `inpatient_no` 改成其他患者号时,旧患者号的 `has_front_page` 会自动回落为 `false` 并清空首页引用。若同一患者号姓名不一致,以首页表中的非空姓名覆盖 `Patient_Lists.patient_name`;已有列表记录的诊断和入出院时间不被首页联动覆盖。患者号格式核验交由患者目录核验网页端处理。
病案号规则:
- 住院号 = `ZY` + 住院次数 2 位 + 首页病案号 10 位,例如 `ZY020001447443`
- `0001906820` 是合法 10 位病案号,前导 0 不能删除。
- 若 PDF 首页病案号少前导 0但文件名可还原例如 `ZY020001447443_ori.pdf` 中可得到 `0001447443`,脚本会把 PDF 值 `01447443` 自动修正为 `0001447443`,并记录到 `auto_corrections`
## 从 Gitea 拉取后运行
1. 克隆仓库:
```bash
git clone https://gitea.huijutec.cn/admin/HIS_Front_Page.git
cd HIS_Front_Page
```
2. 准备目录:
```bash
mkdir -p 待处理-患者首页PDF 数据处理结果区 已处理-患者首页PDF
```
3. 放入 PDF
```text
待处理-患者首页PDF/
```
4. 本地生成 CSV/JSON
```bash
python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py
```
默认使用 `--text-source auto`:先用 `pdftotext`,若读取结果异常再调用 Mineru。需要强制 Mineru 时:
```bash
python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py \
--text-source mineru \
--mineru-url "http://MINERU_HOST:4000/extract"
```
也可以先单独转 Markdown
```bash
python3 数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py \
-s 待处理-患者首页PDF \
-t 数据处理结果区/06_Mineru_MD \
-u "http://MINERU_HOST:4000/extract"
```
5. 正式处理并写入 PostgreSQL
```bash
export PGPASSWORD='请填写数据库密码'
python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py \
--write-postgres \
--pg-host DB_HOST \
--pg-port 5432 \
--pg-database DB_NAME \
--pg-user DB_USER \
--pg-table Patient_FrontPages
unset PGPASSWORD
```
6. 生成 PDF 图片对照核验页:
```bash
python3 数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py --force
```
输出位于:
```text
数据处理结果区/06_PDF图片对照/图片/
数据处理结果区/06_PDF图片对照/患者首页_PDF图片对照.html
数据处理结果区/06_PDF图片对照/患者首页_PDF图片对照索引.csv
```
HTML 对照页用于人工左侧看首页图片、右侧看结构化字段CSV 索引用于快速筛出核心缺项、建议核对缺项和已有复核状态。
7. 启动可视化网页端:
```bash
docker compose -f 数据可视化网页端/docker-compose.yml up -d --build
```
浏览器打开:
```text
http://localhost:8501
```
首次进入会显示登录界面;默认管理员为 `admin`,默认密码为 `change-me`。设置页创建的本地用户会同步作为登录账号,并按权限控制各页面。
页面顶部显示数据库连接状态、总记录数、需复核数、PDF 数量;顶部页签包括概览、复核、抽查、抽查一览、设置,设置内合并用户与权限、数据库状态和目录信息。复核页默认进入“全部复核数据”,包含需复核、自动修正、已人工复核/已人工修改;左侧选择病例,中间查看 PDF右侧以表格方式编辑摘录字段。点击“复核并保存”后会同步写回 PostgreSQL把 `manual_corrected` 标记为 `true`,自动把 `review_status` 标记为 `reviewed``Patient_Lists.has_front_page/front_page_id/front_page_source_file` 由数据库触发器按住院号自动维护。
复核提示会定位到右侧模块和字段:需要核对的位置标红,无关模块默认收起。人工备注下方会显示修改记录表,主表 `review_logs` 记录修改时间、修改字段、修改前后值和人工备注。
抽查页可从已人工复核或自动通过记录中随机抽样;右侧和复核页一样显示并可编辑全部结构化信息,下方有人工备注和修改记录。随机抽取不会写入“待抽查”记录,只有点击“归类通过并保存”“归类异常并保存”“归类不确定并保存”后,才把通过、异常、不确定记录写入主表 `audit_logs`;抽查一览展示修改内容、人工备注和更新时间。
PDF 以 `inline` 方式展示,不再作为附件触发保存。浏览器内核不能彻底禁止用户保存已展示内容。
8. 可选 Kimi 视觉兜底:
```bash
python3 数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py \
--case ZY010001672803_ori \
--page page-001.png
```
Kimi 调用需要本地 `.env` 中配置 `MOONSHOT_API_KEY`。该步骤只在人工触发时上传指定图片,输出到 `数据处理结果区/07_Kimi视觉识别/`,默认不直接覆盖 PostgreSQL。
## 审核流程
每批数据处理后执行:
```bash
sed -n '1,50p' 数据处理结果区/04_复核与人工校正/患者首页_复核清单.csv
```
如果复核清单为空,仍建议抽查:
- `数据处理结果区/01_结构化结果/患者首页_结构化结果.csv`
- `数据处理结果区/02_单份JSON/`
- `数据处理结果区/03_提取文本/`
- `数据处理结果区/06_PDF图片对照/患者首页_PDF图片对照.html`
- `数据处理结果区/06_PDF图片对照/患者首页_PDF图片对照索引.csv`
- `http://localhost:8501` 可视化网页端
如果出现新模板、扫描件、字段错位、少项漏项或手术操作跨行异常,把对应 PDF 文件名、抽取文本、图片对照索引中的缺项提示交给 Codex 继续补规则。
## PostgreSQL 人工复核流程
1. 导出待复核表:
```bash
export PGPASSWORD='请填写数据库密码'
python3 数据处理工作区/03_人工复核/03_人工复核导出与回写.py export \
--pg-host DB_HOST \
--pg-port 5432 \
--pg-database DB_NAME \
--pg-user DB_USER \
--pg-table Patient_FrontPages
unset PGPASSWORD
```
2. 打开 `数据处理结果区/04_复核与人工校正/患者首页_人工复核表.csv`,人工填写这些列:
```text
manual_review_notes
corrected_medical_record_no
corrected_patient_name
corrected_major_department
corrected_primary_diagnosis
corrected_primary_diagnosis_code
corrected_total_cost
corrected_self_pay_amount
mark_review_status
```
3. 回写 PostgreSQL
```bash
export PGPASSWORD='请填写数据库密码'
python3 数据处理工作区/03_人工复核/03_人工复核导出与回写.py import \
--pg-host DB_HOST \
--pg-port 5432 \
--pg-database DB_NAME \
--pg-user DB_USER \
--pg-table Patient_FrontPages
unset PGPASSWORD
```
回写后,程序会更新可修正字段,并把 `manual_corrected` 标记为 `true``manual_review_notes` 会追加进入 `review_notes`。
4. 做数据库体检:
```bash
export PGPASSWORD='请填写数据库密码'
python3 数据处理工作区/04_质量体检/04_字段核验与数据库体检.py \
--pg-host DB_HOST \
--pg-port 5432 \
--pg-database DB_NAME \
--pg-user DB_USER \
--pg-table Patient_FrontPages
unset PGPASSWORD
```
体检输出:
```text
数据处理结果区/05_质量体检/患者首页_数据库体检报告.txt
数据处理结果区/05_质量体检/患者首页_字段空值统计.csv
数据处理结果区/05_质量体检/患者首页_疑似异常记录.csv
```
## 提交规则
提交到 Gitea 前务必检查:
```bash
git status --short
```
不应出现这些内容:
- `待处理-患者首页PDF/`
- `数据处理结果区/`
- `已处理-患者首页PDF/`
- `.env`
- 含真实密码的环境文件
- 明文 `工作流_本地使用版.md`
可以提交:
- 脚本
- `01_~0X_` 前缀的工作区文件
- README
- Gitea 工作流文档
- 不含密码的配置模板
依赖说明:
- 主解析脚本使用 Python 标准库和系统命令 `pdftotext/pdftoppm`。
- Mineru 备用读取单独需要 `python3 -m pip install requests`。
- Docker 网页端依赖写在 `数据可视化网页端/Dockerfile` 中,不再维护独立 `requirements.txt`。
## 本地工作流
明文 `工作流_本地使用版.md` 只用于本机执行,不提交到 Gitea加密压缩包已从仓库删除后续不再维护该 zip。

View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.env
.env.*

View File

@@ -0,0 +1,17 @@
PGHOST=DB_HOST
PGPORT=5432
PGDATABASE=DB_NAME
PGUSER=DB_USER
PGPASSWORD=请填写数据库密码
PGTABLE=Patient_FrontPages
APP_HOST=0.0.0.0
APP_PORT=8501
REVIEW_ADMIN_USER=admin
REVIEW_ADMIN_PASSWORD=change-me
REVIEW_STATUS_CHECK_TIME=03:00
PDF_DIR=/data/pdfs
MOONSHOT_API_KEY=请填写 Kimi API Key
KIMI_MODEL=kimi-k2.6

View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN pip install --no-cache-dir \
fastapi==0.115.6 \
"uvicorn[standard]==0.34.0" \
psycopg2-binary==2.9.10 \
python-dotenv==1.0.1 \
PyMuPDF==1.24.14
COPY app /app/app
EXPOSE 8501
CMD ["sh", "-c", "uvicorn app.main:app --host ${APP_HOST:-0.0.0.0} --port ${APP_PORT:-8501}"]

View File

@@ -0,0 +1,58 @@
# 患者首页可视化对照网页端
这个网页端用于人工复核患者首页:概览看批次状态,复核页只处理需复核记录,中间查看 PDF右侧编辑结构化摘录信息保存后直接同步到 PostgreSQL 表 `"Patient_FrontPages"`
## 启动
在项目根目录确认 `.env` 已存在,然后运行:
```bash
docker compose -f 数据可视化网页端/docker-compose.yml up -d --build
```
浏览器打开:
```text
http://localhost:8501
```
首次进入会显示登录界面。默认管理员为 `admin`,默认密码为 `change-me`;可在 `.env` 中通过 `REVIEW_ADMIN_USER``REVIEW_ADMIN_PASSWORD` 修改。设置页创建的本地用户会同步作为登录账号,并按权限控制概览、复核、抽查、抽查一览、设置页面。
## 目录挂载
- `已处理-患者首页PDF/2026_5_25_处理/` 挂载为容器内 `/data/pdfs`,只读。
- 网页端只预览 PDF不再挂载或依赖 PDF 转图片目录。
## 保存规则
右侧字段保存后会:
- 更新被编辑的 PostgreSQL 字段。
- 设置 `manual_corrected = true`
- 自动将 `review_status` 标记为 `reviewed`
- 把人工备注追加到 `review_notes` JSONB 数组中。
- 在主表 `review_logs` JSONB 列中记录修改时间、修改字段、修改前后值和人工备注。
- PostgreSQL 触发器会按非空 `inpatient_no` 自动同步 `Patient_Lists.has_front_page/front_page_id/front_page_source_file`;本网页端只校验患者号不能为空,不校验编号格式,同一患者号只保留一个首页,迁移时保留最新记录,后续同号写入会覆盖旧首页,删除首页记录时旧关联会自动解除;同一患者号姓名不一致时,以首页表中的非空姓名覆盖 `Patient_Lists.patient_name`。只有自动补建新列表记录时,才把主要诊断、入院时间、出院时间写入 `Patient_Lists`
`.env` 含数据库密码,只保留本地,不提交到 Gitea可参考 `.env.example` 创建。
## 页面规则
- 顶部页签包括:概览、复核、抽查、抽查一览、设置;设置内合并用户与权限、数据库状态和目录信息。
- 复核页以住院号作为第一识别项;住院号由住院次数 2 位和首页病案号 10 位自动生成,例如 `ZY020001447443`
- 复核页默认进入“全部复核数据”,包含需复核、自动修正、已人工复核/已人工修改,不处理无需复核的自动通过记录。
- 复核提示会自动定位到右侧模块;命中的模块和字段标红,无关模块默认收起。
- 人工备注下方展示修改记录表,时间由 PostgreSQL 后台记录。
- PDF 以 `inline` 方式展示,不再作为附件触发保存;浏览器不能彻底禁止保存已展示内容。
- 顶部数据库/PDF 状态读取缓存;设置页可手动“立即检查”,也可设置每日凌晨等固定时间自动检查。
- 复核状态、质控状态显示在 PDF 下方,只读。
- 诊断、手术、费用等 JSONB 字段以表格方式展示和编辑。
- 手术操作按编码、日期、级别、名称、术者、I助、II助、切口愈合等级、麻醉方式、麻醉医师拆分。
## 抽查规则
- 抽查页可从 `reviewed``auto_pass` 中随机抽样。
- 抽查页右侧和复核页一样展示并可编辑全部结构化信息,下方有人工备注和修改记录。
- 随机抽取不会写入“待抽查”记录;只有点击“归类通过并保存”“归类异常并保存”“归类不确定并保存”后,才写入主表 `audit_logs` JSONB 列。
- 抽查一览只展示已归类的通过、异常、不确定记录,并显示修改内容、人工备注和更新时间。
- 涉及患者资料的视觉识别仍需人工运行 `07_Kimi图片识别辅助.py`,不会由网页端自动上传。

View File

@@ -0,0 +1 @@
"""Patient front page visual review web app."""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,273 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>患者首页复核工作台</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<section id="loginView" class="login-view">
<form id="loginForm" class="login-panel">
<div class="login-mark"></div>
<h1>患者首页复核工作台</h1>
<p>登录后进入复核、抽查与数据回写工作区</p>
<label>
<span>用户名</span>
<input id="loginUsername" autocomplete="username" value="admin" placeholder="用户名">
</label>
<label>
<span>密码</span>
<input id="loginPassword" type="password" autocomplete="current-password" placeholder="密码">
</label>
<button id="loginBtn" type="submit">登录</button>
<div id="loginMessage" class="login-message"></div>
</form>
</section>
<div id="appShell" class="app-shell is-hidden">
<header class="topbar">
<div class="brand">
<span class="mark"></span>
<div>
<h1>患者首页复核工作台</h1>
<p>PDF 预览 · 复核定位 · PostgreSQL 回写</p>
</div>
</div>
<nav class="top-nav" aria-label="工作台导航">
<button class="nav-button is-active" type="button" data-page="overview">概览</button>
<button class="nav-button" type="button" data-page="review">复核</button>
<button class="nav-button" type="button" data-page="audit">抽查</button>
<button class="nav-button" type="button" data-page="auditHistory">抽查一览</button>
<button class="nav-button" type="button" data-page="settings">设置</button>
</nav>
<div class="status-grid" id="statusGrid">
<div class="status-card"><span>数据库</span><strong id="dbState">检查中</strong></div>
<div class="status-card"><span>记录总数</span><strong id="dbTotal">--</strong></div>
<div class="status-card"><span>仅需复核</span><strong id="dbNeedsReview">--</strong></div>
<div class="status-card"><span>AI无问题</span><strong id="dbAiPassed">--</strong></div>
<div class="status-card"><span>AI待确认</span><strong id="dbAiPending">--</strong></div>
<div class="status-card"><span>已人工复核</span><strong id="dbReviewed">--</strong></div>
</div>
<div class="session-box">
<span id="currentUserLabel">未登录</span>
<button id="logoutBtn" type="button">退出</button>
</div>
</header>
<main id="overviewPage" class="page-view overview-page">
<section class="metric-grid" id="overviewMetrics"></section>
<section class="page-band">
<div class="section-head">
<h2>待处理概况</h2>
<button id="overviewRefreshBtn" type="button">刷新</button>
</div>
<div id="overviewQueues" class="queue-summary"></div>
</section>
<section class="page-band">
<div class="section-head">
<h2>最近人工修改</h2>
</div>
<div id="overviewRecentLogs" class="data-table-wrap"></div>
</section>
</main>
<main id="reviewPage" class="workspace page-view is-hidden">
<aside class="record-panel">
<div class="panel-tools">
<input id="searchInput" type="search" placeholder="搜索住院号、病案号、姓名、诊断">
<select id="statusFilter">
<option value="review_all">全部数据</option>
<option value="needs_review">仅需复核</option>
<option value="AI复核-无问题">仅AI复核-无问题</option>
<option value="AI复核-待确认">仅AI复核-待确认</option>
<option value="reviewed">已人工复核</option>
</select>
<div id="aiActions" class="ai-actions is-hidden">
<button id="aiCurrentBtn" type="button">AI当前项</button>
<button id="aiFiveBtn" type="button">AI后5项</button>
<button id="aiAllBtn" type="button">AI后全部</button>
</div>
<button id="approveAiNoIssueBtn" class="bulk-action is-hidden" type="button">复核并保存所有项</button>
</div>
<div class="record-list" id="recordList" tabindex="0"></div>
</aside>
<section class="pdf-panel">
<div class="pdf-toolbar">
<div>
<strong id="pdfTitle">未选择病例</strong>
<span id="pdfSubtitle"></span>
</div>
<div class="pdf-zoom-controls" aria-label="PDF缩放">
<button id="pdfZoomOutBtn" type="button" title="缩小 PDF"></button>
<span id="pdfZoomLabel">110%</span>
<button id="pdfZoomInBtn" type="button" title="放大 PDF">+</button>
<button id="pdfZoomResetBtn" type="button" title="重置 PDF 缩放">1:1</button>
</div>
</div>
<div class="viewer" id="pdfView">
<iframe id="pdfFrame" title="患者首页 PDF" tabindex="-1"></iframe>
</div>
<div class="review-strip" id="reviewStrip"></div>
</section>
<aside class="detail-panel">
<div class="detail-head">
<div>
<h2 id="detailTitle">摘录信息</h2>
<p id="detailMeta">选择左侧记录后开始复核</p>
</div>
<div class="detail-actions">
<button id="saveBtn" type="button" disabled>复核并保存</button>
</div>
</div>
<div id="targetSummary" class="target-summary"></div>
<form id="detailForm" class="detail-form structured-form"></form>
<details class="review-log-panel collapsible-panel" open>
<summary class="section-head compact">
<h2>修改记录</h2>
<span id="changeLogCount">0 条</span>
</summary>
<div id="changeLogTable" class="data-table-wrap"></div>
</details>
<div class="save-message" id="saveMessage"></div>
</aside>
</main>
<main id="auditPage" class="page-view audit-page is-hidden">
<section class="page-band audit-toolbar">
<label>
<span>抽查来源</span>
<select id="auditSource">
<option value="reviewed">已人工复核</option>
<option value="auto_pass">自动通过</option>
</select>
</label>
<label>
<span>抽取数量</span>
<input id="auditCount" type="number" min="1" max="50" value="5">
</label>
<button id="auditSampleBtn" type="button">随机抽取</button>
<span id="auditMessage" class="inline-message"></span>
</section>
<section class="audit-layout">
<aside class="record-panel">
<div class="record-list" id="auditSampleList"></div>
</aside>
<section class="pdf-panel">
<div class="pdf-toolbar">
<div>
<strong id="auditPdfTitle">未选择抽查记录</strong>
<span id="auditPdfSubtitle"></span>
</div>
<div class="pdf-zoom-controls" aria-label="抽查PDF缩放">
<button id="auditPdfZoomOutBtn" type="button" title="缩小 PDF"></button>
<span id="auditPdfZoomLabel">110%</span>
<button id="auditPdfZoomInBtn" type="button" title="放大 PDF">+</button>
<button id="auditPdfZoomResetBtn" type="button" title="重置 PDF 缩放">1:1</button>
</div>
</div>
<div class="viewer" id="auditPdfView">
<iframe id="auditPdfFrame" title="抽查 PDF" tabindex="-1"></iframe>
</div>
<div class="review-strip" id="auditReviewStrip"></div>
</section>
<aside class="detail-panel">
<div class="detail-head audit-detail-head">
<div>
<h2 id="auditDetailTitle">抽查信息</h2>
<p id="auditDetailMeta">随机抽取后开始抽查</p>
</div>
<div class="audit-save-actions">
<button id="auditPassBtn" type="button">归类通过并保存</button>
<button id="auditFailBtn" class="danger" type="button">归类异常并保存</button>
<button id="auditUnsureBtn" type="button">归类不确定并保存</button>
</div>
</div>
<div id="auditTargetSummary" class="target-summary"></div>
<form id="auditDetailForm" class="detail-form structured-form"></form>
<div class="manual-note">
<label for="auditNotes">抽查人工备注</label>
<textarea id="auditNotes" rows="3" placeholder="填写抽查判断、修改原因或后续处理说明"></textarea>
</div>
<section class="review-log-panel">
<div class="section-head compact">
<h2>修改记录</h2>
<span id="auditChangeLogCount">0 条</span>
</div>
<div id="auditChangeLogTable" class="data-table-wrap"></div>
</section>
</aside>
</section>
</main>
<main id="auditHistoryPage" class="page-view audit-history-page is-hidden">
<section class="page-band">
<div class="section-head">
<h2>抽查一览</h2>
<button id="auditHistoryRefreshBtn" type="button">刷新抽查记录</button>
</div>
<div id="auditHistoryTable" class="data-table-wrap"></div>
</section>
</main>
<main id="settingsPage" class="page-view settings-page is-hidden">
<section class="settings-grid">
<section class="page-band settings-wide">
<div class="section-head">
<h2>用户与权限</h2>
<span id="settingsUserCount">0 个用户</span>
</div>
<form id="userForm" class="settings-form">
<input id="newUsername" autocomplete="off" placeholder="新用户名">
<input id="newPassword" type="password" autocomplete="new-password" placeholder="密码">
<div id="newUserPermissions" class="permission-grid"></div>
<button type="submit">新建用户</button>
</form>
<div id="userList" class="user-list"></div>
</section>
<section class="page-band">
<h2>数据库与目录</h2>
<div id="settingsStatus" class="settings-list"></div>
</section>
<section class="page-band">
<h2>系统设置</h2>
<div class="settings-list">
<div>
<span>状态检查</span>
<form id="systemSettingsForm" class="status-check-form">
<input id="statusCheckTime" type="time" value="03:00" aria-label="每日状态检查时间">
<button id="saveSystemSettingsBtn" type="submit">保存时间</button>
<button id="statusCheckBtn" type="button">立即检查</button>
</form>
</div>
<div><span>检查计划</span><strong id="statusCheckMeta">尚未检查</strong></div>
<div>
<span>Kimi AI</span>
<form id="kimiSettingsForm" class="status-check-form">
<label class="toggle-field"><input id="kimiEnabled" type="checkbox">启用</label>
<input id="kimiModel" type="text" placeholder="模型">
<input id="kimiApiBase" type="url" placeholder="API Base">
<input id="kimiConcurrency" type="number" min="1" max="6" value="3" aria-label="AI并发数">
<button id="saveKimiSettingsBtn" type="submit">保存AI</button>
</form>
</div>
<div><span>Kimi API</span><strong id="kimiMeta">未检查</strong></div>
<div><span>复核工作台</span><strong>默认显示全部复核数据,仅使用 PDF 预览</strong></div>
<div><span>保存动作</span><strong>写入字段、修改日志、reviewed 状态</strong></div>
<div>
<span>提交已复核</span>
<button id="submitReviewedBtn" type="button">一键提交已人工复核项目</button>
</div>
<div><span>抽查来源</span><strong>reviewed / auto_pass</strong></div>
<div><span>日志表</span><strong>复核和抽查日志并入主表 JSONB</strong></div>
</div>
</section>
</section>
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
name: patient-frontpage-review
services:
patient-frontpage-review:
build:
context: .
container_name: patient-frontpage-review
env_file:
- ../.env
environment:
REVIEW_SETTINGS_PATH: /app/settings/review_settings.local.json
ports:
- "8501:8501"
volumes:
- ../已处理-患者首页PDF/2026_5_25_处理:/data/pdfs:ro
- review-settings:/app/settings
restart: unless-stopped
volumes:
review-settings:

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)