Import sanitized HIS processing tools
This commit is contained in:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Local secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.pgpass
|
||||||
|
|
||||||
|
# Patient data and generated outputs
|
||||||
|
数据处理结果区/
|
||||||
|
待处理-患者目录图片集群/
|
||||||
|
已处理-患者目录图片集群/
|
||||||
|
待处理-患者首页PDF/
|
||||||
|
已处理-患者首页PDF/
|
||||||
|
*.csv
|
||||||
|
*.jsonl
|
||||||
|
*.pdf
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.xlsx
|
||||||
|
*.xls
|
||||||
|
|
||||||
|
# Runtime/cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Local review settings
|
||||||
|
review_settings.local.json
|
||||||
|
数据可视化网页端/review_settings.local.json
|
||||||
|
人工复核网页端配置.json
|
||||||
|
03_人工复核修正.json
|
||||||
19
README.md
Normal file
19
README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# HIS 数据处理工具集
|
||||||
|
|
||||||
|
本仓库合并保存 HIS 相关处理程序、网页端、配置模板和流程文档。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- `患者列表处理/`:患者列表 OCR、合并、PostgreSQL 同步、人工复核网页端。
|
||||||
|
- `患者首页处理/`:患者首页 PDF 解析入库、人工复核、质量体检、可视化网页端、Kimi 视觉兜底工具。
|
||||||
|
|
||||||
|
## 入库边界
|
||||||
|
|
||||||
|
仓库只保存程序、文档、结构脚本和配置模板,不保存以下内容:
|
||||||
|
|
||||||
|
- 原始 PDF、图片、截图、OCR 裁剪图等患者数据。
|
||||||
|
- `数据处理结果区/` 中的结构化结果、日志、质检报告和复核导出。
|
||||||
|
- `.env`、`.pgpass`、本地网页端配置、明文数据库连接信息、API Key 或登录密码。
|
||||||
|
- 两个旧工程的 `.git` 历史。
|
||||||
|
|
||||||
|
需要运行程序时,请在本地根据各子目录的 `.env.example` 创建 `.env`,并从安全渠道填入数据库和 API 配置。
|
||||||
19
患者列表处理/.env.example
Normal file
19
患者列表处理/.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
HIS_DB_HOST=DB_HOST
|
||||||
|
HIS_DB_PORT=5432
|
||||||
|
HIS_DB_NAME=DB_NAME
|
||||||
|
HIS_DB_USER=DB_USER
|
||||||
|
HIS_DB_PASSWORD=请填写数据库密码
|
||||||
|
|
||||||
|
REVIEW_APP_USERNAME=admin
|
||||||
|
REVIEW_APP_PASSWORD=请填写网页端登录密码
|
||||||
|
REVIEW_APP_SECRET_KEY=请填写随机字符串
|
||||||
|
REVIEW_APP_PORT=8090
|
||||||
|
REVIEW_POSTGRES_SYNC=1
|
||||||
|
|
||||||
|
KIMI_API_KEY=请填写Kimi或Moonshot API Key
|
||||||
|
KIMI_MODEL=kimi-k2.6
|
||||||
|
KIMI_TIMEOUT_SECONDS=80
|
||||||
|
KIMI_IMAGE_MAX_WIDTH=1200
|
||||||
|
|
||||||
|
TENCENTCLOUD_SECRET_ID=请填写腾讯云SecretId
|
||||||
|
TENCENTCLOUD_SECRET_KEY=请填写腾讯云SecretKey
|
||||||
22
患者列表处理/.gitignore
vendored
Normal file
22
患者列表处理/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Patient images and generated results
|
||||||
|
待处理-患者目录图片集群/
|
||||||
|
已处理-患者目录图片集群/
|
||||||
|
数据处理结果区/
|
||||||
|
|
||||||
|
# Local correction data can contain patient information
|
||||||
|
数据处理工作区/03_人工复核修正.json
|
||||||
|
数据处理工作区/人工复核网页端配置.json
|
||||||
|
|
||||||
|
# Local secrets and runtime files
|
||||||
|
.env
|
||||||
|
.pgpass
|
||||||
|
工作流_本地使用版.md
|
||||||
|
*.log
|
||||||
|
2026_5_23_HIS患者目录图片集群处理*.zip
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# OS/editor noise
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
125
患者列表处理/README.md
Normal file
125
患者列表处理/README.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# HIS 患者列表图片归档
|
||||||
|
|
||||||
|
这个项目用于把 HIS 患者列表截图批量 OCR 为结构化记录,并同步到 PostgreSQL 单表。
|
||||||
|
|
||||||
|
## 目录约定
|
||||||
|
|
||||||
|
- `待处理-患者目录图片集群/`:后续新下载、尚未归档的图片集群。
|
||||||
|
- `已处理-患者目录图片集群/`:已经完成归档的原始图片集群。
|
||||||
|
- `数据处理工作区/`:处理脚本、科室分类规则、数据库建表脚本和说明文档。
|
||||||
|
- `人工复核网页端/`:Docker 化的人工复核网页端。
|
||||||
|
- `数据处理结果区/已处理-患者目录图片集群/`:各批次本地归档结果。
|
||||||
|
- `数据处理结果区/信息记录/`:全局信息记录和批次信息记录。
|
||||||
|
|
||||||
|
数据、图片、OCR 缓存、处理结果不应提交到 Git。
|
||||||
|
|
||||||
|
## 工作区脚本
|
||||||
|
|
||||||
|
1. `数据处理工作区/01_科室分类规则.json`
|
||||||
|
2. `数据处理工作区/02_患者列表OCR归档.py`
|
||||||
|
3. `数据处理工作区/03_人工复核修正.json`
|
||||||
|
4. `数据处理工作区/04_合并批次结果.py`
|
||||||
|
5. `数据处理工作区/05_同步PostgreSQL单表.py`
|
||||||
|
6. `数据处理工作区/06_PostgreSQL建表结构.sql`
|
||||||
|
7. `数据处理工作区/07_处理程序说明.md`
|
||||||
|
8. `数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql`
|
||||||
|
9. `数据处理工作区/09_PostgreSQL住院号非空唯一约束.sql`
|
||||||
|
|
||||||
|
`03_人工复核修正.json` 可能包含患者信息,仓库中只保留模板文件。
|
||||||
|
|
||||||
|
## 处理新批次
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export TENCENTCLOUD_SECRET_ID='...'
|
||||||
|
export TENCENTCLOUD_SECRET_KEY='...'
|
||||||
|
|
||||||
|
python3 数据处理工作区/02_患者列表OCR归档.py \
|
||||||
|
--input "待处理-患者目录图片集群/批次文件夹名" \
|
||||||
|
--output "数据处理结果区/已处理-患者目录图片集群/批次文件夹名-列表归档结果" \
|
||||||
|
--ocr-engine table-v3 \
|
||||||
|
--batch-size 6 \
|
||||||
|
--image-padding-y 24 \
|
||||||
|
--workers 1 \
|
||||||
|
--folder-workers 2 \
|
||||||
|
--timeout 90 \
|
||||||
|
--max-retries 1
|
||||||
|
```
|
||||||
|
|
||||||
|
处理完成后优先查看:
|
||||||
|
|
||||||
|
- `患者列表_结构化.json`
|
||||||
|
- `患者列表_记录.csv`
|
||||||
|
- `复核报告.json`
|
||||||
|
- `重复住院号报告.json`
|
||||||
|
|
||||||
|
如果只想用已有 OCR 缓存重建结果:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 数据处理工作区/02_患者列表OCR归档.py \
|
||||||
|
--input "待处理-患者目录图片集群/批次文件夹名" \
|
||||||
|
--output "数据处理结果区/已处理-患者目录图片集群/批次文件夹名-列表归档结果" \
|
||||||
|
--ocr-engine table-v3 \
|
||||||
|
--batch-size 6 \
|
||||||
|
--image-padding-y 24 \
|
||||||
|
--workers 1 \
|
||||||
|
--folder-workers 2 \
|
||||||
|
--rebuild-from-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
确认无误后,把原始图片目录移动到 `已处理-患者目录图片集群/`。
|
||||||
|
|
||||||
|
## 合并与入库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 数据处理工作区/04_合并批次结果.py
|
||||||
|
|
||||||
|
export HIS_DB_HOST='DB_HOST'
|
||||||
|
export HIS_DB_PORT='5432'
|
||||||
|
export HIS_DB_NAME='DB_NAME'
|
||||||
|
export HIS_DB_USER='DB_USER'
|
||||||
|
export HIS_DB_PASSWORD='...'
|
||||||
|
|
||||||
|
python3 数据处理工作区/05_同步PostgreSQL单表.py
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL 只使用 `"Patient_Lists"` 一个正式表。科室映射、OCR 缓存路径、拼接图路径、OCR 请求号等处理过程信息保留在本地工作区和结果目录,不进入数据库正式表。
|
||||||
|
|
||||||
|
## 人工复核网页端
|
||||||
|
|
||||||
|
在根目录 `.env` 中配置 `REVIEW_APP_USERNAME`、`REVIEW_APP_PASSWORD`、`REVIEW_APP_SECRET_KEY`、`REVIEW_APP_PORT`;如需抽查功能,再配置 `KIMI_API_KEY`、`KIMI_MODEL`。可参考 `.env.example`。启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd 人工复核网页端
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问 `http://127.0.0.1:8090`。网页端包含概览、复核、抽查、抽查查看、设置区域,会读取各批次 `复核报告.json`,按图片内行号裁剪原图,人工修订会保存到 `数据处理工作区/03_人工复核修正.json` 并尽量同步 PostgreSQL。出院时间允许为空;若填写,则会校验时间格式以及是否早于入院时间。
|
||||||
|
|
||||||
|
入库后建议核对:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT count(*) FROM "Patient_Lists";
|
||||||
|
SELECT count(*) FROM "Patient_Lists" WHERE review_status = '需人工复核';
|
||||||
|
SELECT inpatient_no FROM "Patient_Lists" GROUP BY inpatient_no HAVING count(*) > 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
- 默认使用 `RecognizeTableAccurateOCR` 表格识别 V3。
|
||||||
|
- 患者字段顺序固定为:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数。
|
||||||
|
- 拼接图默认给每张原图上下各加 `24px` 白边,减少贴边表格行被 OCR 忽略的概率。
|
||||||
|
- 处理时优先 6 张拼接;如果行数偏少、接口超时或识别失败,程序会动态降到 4/3/2/单张。
|
||||||
|
- `住院号` 是唯一强制校验条件:不能为空且全库唯一。重复住院号按后出现记录覆盖先出现记录;格式异常但非空的住院号保留,后续在网页端人工核验。
|
||||||
|
- `出院时间` 允许为空;若入院时间晚于出院时间,程序和 PostgreSQL 都会把它作为需复核问题处理。
|
||||||
|
- `自动复核通过` 表示 OCR 结果经过规则校验后无明显异常;`人工复核通过` 表示该行命中了人工修正配置,修正后校验通过。
|
||||||
|
- 抽查结果直接写入 PostgreSQL 的 `audit_*` 字段,不再使用本地 `抽查记录.json`。
|
||||||
|
- `AI修改-待确认` 只作为网页端辅助修正草稿,不会被“提交待同步”当作人工确认结果入库。
|
||||||
|
- 旧批次若用缓存重建,要沿用当时的拼接和白边参数,避免新图片名和旧 OCR 缓存不匹配。
|
||||||
|
|
||||||
|
## 迁移注意
|
||||||
|
|
||||||
|
- 需要 Python 3、`Pillow` 和 PostgreSQL 客户端 `psql`。
|
||||||
|
- 腾讯云密钥和数据库密码只通过环境变量传入,不要写入脚本或提交到文件。
|
||||||
|
- `raw_ocr/` 是 OCR 响应缓存,迁移时建议保留,可减少重复调用。
|
||||||
|
- `merged_images/` 是拼接图,便于复核。
|
||||||
|
- PostgreSQL 结构以 `数据处理工作区/06_PostgreSQL建表结构.sql` 为准。
|
||||||
4
患者列表处理/人工复核网页端/.dockerignore
Normal file
4
患者列表处理/人工复核网页端/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.log
|
||||||
15
患者列表处理/人工复核网页端/.env.example
Normal file
15
患者列表处理/人工复核网页端/.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
REVIEW_APP_USERNAME=admin
|
||||||
|
REVIEW_APP_PASSWORD=change-me
|
||||||
|
REVIEW_APP_SECRET_KEY=change-me-to-a-random-string
|
||||||
|
REVIEW_APP_PORT=8090
|
||||||
|
REVIEW_POSTGRES_SYNC=1
|
||||||
|
KIMI_API_KEY=change-me
|
||||||
|
KIMI_API_ENABLED=1
|
||||||
|
KIMI_MODEL=kimi-k2.6
|
||||||
|
KIMI_TIMEOUT_SECONDS=120
|
||||||
|
KIMI_IMAGE_MAX_WIDTH=1200
|
||||||
|
KIMI_AUDIT_MAX_TOKENS=768
|
||||||
|
KIMI_CORRECTION_MAX_TOKENS=768
|
||||||
|
|
||||||
|
# Docker 内默认挂载项目根目录到 /workspace;本机直接运行时可改为项目根目录。
|
||||||
|
WORKSPACE_ROOT=/workspace
|
||||||
17
患者列表处理/人工复核网页端/Dockerfile
Normal file
17
患者列表处理/人工复核网页端/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py ./app.py
|
||||||
|
COPY templates ./templates
|
||||||
|
COPY static ./static
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app", "--workers", "2", "--threads", "4", "--timeout", "180"]
|
||||||
62
患者列表处理/人工复核网页端/README.md
Normal file
62
患者列表处理/人工复核网页端/README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# HIS 患者列表人工复核网页端
|
||||||
|
|
||||||
|
用于查看 `复核报告.json` 中的待处理记录,按原图行号生成局部截图,保存人工修订到 `数据处理工作区/03_人工复核修正.json`。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
在项目根目录 `.env` 中增加:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REVIEW_APP_USERNAME=admin
|
||||||
|
REVIEW_APP_PASSWORD=请填写本地登录密码
|
||||||
|
REVIEW_APP_SECRET_KEY=请填写随机字符串
|
||||||
|
REVIEW_APP_PORT=8090
|
||||||
|
KIMI_API_KEY=请填写Kimi或Moonshot API Key
|
||||||
|
KIMI_MODEL=kimi-k2.6
|
||||||
|
```
|
||||||
|
|
||||||
|
如果未设置 `REVIEW_APP_PASSWORD`,程序会回退使用 `HIS_DB_PASSWORD` 作为登录密码。
|
||||||
|
|
||||||
|
默认启用 PostgreSQL 联动,保存或删除人工修订时会先按 `image_path + image_row_no` 更新 `"Patient_Lists"` 对应记录;如果合并去重后正式表只保留了同住院号的另一条记录,会继续按 `inpatient_no` 匹配更新。PostgreSQL 暂不可用时,本地修订不会丢失,会写入待同步状态,恢复连接后点击“提交待同步”即可补交。需要临时关闭时可设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REVIEW_POSTGRES_SYNC=0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd 人工复核网页端
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8090
|
||||||
|
```
|
||||||
|
|
||||||
|
## 复核后重建
|
||||||
|
|
||||||
|
网页保存后,人工修订会写入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
数据处理工作区/03_人工复核修正.json
|
||||||
|
```
|
||||||
|
|
||||||
|
之后对相应批次运行 `02_患者列表OCR归档.py --rebuild-from-cache`,再执行合并和入库脚本即可。
|
||||||
|
|
||||||
|
出院时间默认允许为空;确有出院时间时,网页端会校验时间格式以及是否早于入院时间。
|
||||||
|
PostgreSQL 中也已加入对应约束:空出院时间放行;入院时间晚于出院时间时,该记录必须保持 `需人工复核` 状态。
|
||||||
|
|
||||||
|
时间字段支持输入 `2021-11-24 9:46:59`、`2021-12-8 9:38:00` 这类单数字月、日、小时格式;网页端和后端保存时会统一补全为 `YYYY-MM-DD HH:MM:SS`。
|
||||||
|
|
||||||
|
保存前如果仍有校验提示,网页会弹窗确认是否继续保存;无提示保存成功后会自动切到下一条待处理记录。
|
||||||
|
|
||||||
|
页面右上角会显示 PostgreSQL 连接状态,也会每 20 秒自动检测一次;如果本地修订保存成功但数据库同步失败,会标记为待同步,之后点击“提交待同步”可重新提交。提交后会汇总成功、未匹配、失败数量,未成功项可选择保留或从待同步列表删除。
|
||||||
|
|
||||||
|
字段顺序固定为:入院时间、最后书写时间、住院天数、出院时间。字段校验提示统一显示在截图下方,右侧编辑区不再重复显示。截图区支持鼠标滚轮缩放,“恢复尺寸”会回到图片原始大小。
|
||||||
|
|
||||||
|
抽查页会随机抽取患者行,调用 Kimi 多模态模型核对裁剪截图与结构化字段;抽查结论、AI 反馈、人工反馈直接写入 PostgreSQL 的 `audit_*` 字段,可在“抽查查看”页回看。复核页也可以调用“Kimi自动修改当前项/5项”,生成的内容会标记为 `AI修改-待确认`,不会进入待同步;人工点击“保存确认”后才会作为正式修订同步数据库。
|
||||||
|
|
||||||
|
设置页可新增网页端用户,为每个配置用户单独设置是否能看到概览、复核、抽查、抽查查看、设置,也可修改抽查模型和 API Key。设置内容保存在本地 `数据处理工作区/人工复核网页端配置.json`,该文件不提交到 Git。
|
||||||
2127
患者列表处理/人工复核网页端/app.py
Normal file
2127
患者列表处理/人工复核网页端/app.py
Normal file
File diff suppressed because it is too large
Load Diff
19
患者列表处理/人工复核网页端/docker-compose.yml
Normal file
19
患者列表处理/人工复核网页端/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
name: his-list-review
|
||||||
|
|
||||||
|
services:
|
||||||
|
manual-review:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
image: his-list-manual-review:latest
|
||||||
|
container_name: his-list-manual-review
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
|
environment:
|
||||||
|
WORKSPACE_ROOT: /workspace
|
||||||
|
REVIEW_APP_HOST: 0.0.0.0
|
||||||
|
REVIEW_APP_PORT: 8000
|
||||||
|
ports:
|
||||||
|
- "${REVIEW_APP_PORT:-8090}:8000"
|
||||||
|
volumes:
|
||||||
|
- ..:/workspace
|
||||||
|
restart: unless-stopped
|
||||||
5
患者列表处理/人工复核网页端/requirements.txt
Normal file
5
患者列表处理/人工复核网页端/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
gunicorn==23.0.0
|
||||||
|
Pillow==10.4.0
|
||||||
|
psycopg[binary]==3.2.9
|
||||||
|
python-dotenv==1.0.1
|
||||||
1339
患者列表处理/人工复核网页端/static/app.css
Normal file
1339
患者列表处理/人工复核网页端/static/app.css
Normal file
File diff suppressed because it is too large
Load Diff
2042
患者列表处理/人工复核网页端/static/app.js
Normal file
2042
患者列表处理/人工复核网页端/static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
382
患者列表处理/人工复核网页端/templates/index.html
Normal file
382
患者列表处理/人工复核网页端/templates/index.html
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
<!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/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="top-title">
|
||||||
|
<div class="eyebrow">HIS LIST REVIEW</div>
|
||||||
|
<h1>患者目录图片集群人工复核及抽查</h1>
|
||||||
|
</div>
|
||||||
|
<span id="saveState" class="save-state"></span>
|
||||||
|
<div class="top-actions">
|
||||||
|
<nav class="top-nav">
|
||||||
|
<button class="nav-button active" type="button" data-view="overview">概览</button>
|
||||||
|
<button class="nav-button" type="button" data-view="review">复核</button>
|
||||||
|
<button class="nav-button" type="button" data-view="audit">抽查</button>
|
||||||
|
<button class="nav-button" type="button" data-view="auditHistory">抽查一览</button>
|
||||||
|
<button class="nav-button" type="button" data-view="settings">设置</button>
|
||||||
|
</nav>
|
||||||
|
<span id="pgState" class="pg-state"></span>
|
||||||
|
<button id="pgTestButton" class="ghost-button" type="button">测试PG</button>
|
||||||
|
<button id="pgSyncButton" class="ghost-button" type="button">提交待同步</button>
|
||||||
|
<form method="post" action="/logout">
|
||||||
|
<button class="ghost-button" type="submit">退出</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="overviewView" class="page-view overview-view">
|
||||||
|
<section class="overview-grid" id="overviewGrid"></section>
|
||||||
|
<section class="overview-panel">
|
||||||
|
<h2>复核工作台</h2>
|
||||||
|
<div id="overviewBatches" class="batch-grid"></div>
|
||||||
|
</section>
|
||||||
|
<section class="overview-panel">
|
||||||
|
<h2>抽查一览工作台</h2>
|
||||||
|
<div id="overviewAudit" class="batch-grid"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="reviewView" class="workspace page-view hidden">
|
||||||
|
<aside class="queue-panel" id="queuePanel" tabindex="0">
|
||||||
|
<section class="stats-grid" id="statsGrid"></section>
|
||||||
|
<div class="filters">
|
||||||
|
<label class="inline-field review-filter-field">
|
||||||
|
<span class="field-prefix">数据源:</span>
|
||||||
|
<select id="batchFilter"></select>
|
||||||
|
</label>
|
||||||
|
<input type="hidden" id="statusFilter" value="pending">
|
||||||
|
<div class="review-status-row">
|
||||||
|
<div class="inline-field review-filter-field">
|
||||||
|
<span class="field-prefix">状态:</span>
|
||||||
|
<div id="statusSegment" class="status-segment" aria-label="复核状态">
|
||||||
|
<button type="button" class="status-pill active" data-status="pending">待处理</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="inline-field review-filter-field">
|
||||||
|
<span class="field-prefix">其他:</span>
|
||||||
|
<select id="moreStatusFilter" aria-label="其他复核状态">
|
||||||
|
<option value="">其他状态</option>
|
||||||
|
<option value="confirming">待确认</option>
|
||||||
|
<option value="done">人工复核通过</option>
|
||||||
|
<option value="all">全部</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="inline-field review-filter-field">
|
||||||
|
<span class="field-prefix">排序:</span>
|
||||||
|
<select id="sortFilter">
|
||||||
|
<option value="source">默认顺序</option>
|
||||||
|
<option value="updated_desc">最近修订优先</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inline-field review-filter-field">
|
||||||
|
<span class="field-prefix">搜索:</span>
|
||||||
|
<input id="searchInput" placeholder="姓名/住院号/科室">
|
||||||
|
</label>
|
||||||
|
<div class="ai-action-panel review-ai-panel">
|
||||||
|
<div class="ai-action-title"><span class="ai-badge">AI</span><span>辅助修改</span></div>
|
||||||
|
<div class="ai-action-buttons">
|
||||||
|
<button type="button" id="kimiCurrentButton" class="secondary-button ai-button">修改当前项</button>
|
||||||
|
<button type="button" id="kimiFiveButton" class="secondary-button ai-button">修改下5项</button>
|
||||||
|
<button type="button" id="kimiRemainingButton" class="secondary-button ai-button">修改下所有项</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="queueList" class="queue-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="image-panel">
|
||||||
|
<div class="index-note">核对与同步都以住院号作为唯一定位依据,姓名和科室只作辅助参考</div>
|
||||||
|
<div class="record-strip">
|
||||||
|
<div>
|
||||||
|
<div id="recordTitle" class="record-title">未选择记录</div>
|
||||||
|
<div id="recordMeta" class="record-meta"></div>
|
||||||
|
</div>
|
||||||
|
<a id="fullImageLink" class="ghost-button" target="_blank" rel="noreferrer">原图</a>
|
||||||
|
</div>
|
||||||
|
<div class="crop-frame" id="cropFrame">
|
||||||
|
<img id="cropImage" alt="裁剪截图" draggable="false">
|
||||||
|
</div>
|
||||||
|
<div class="zoom-actions">
|
||||||
|
<button id="zoomOutButton" class="ghost-button" type="button">缩小</button>
|
||||||
|
<button id="zoomResetButton" class="ghost-button" type="button">恢复尺寸</button>
|
||||||
|
<button id="zoomInButton" class="ghost-button" type="button">放大</button>
|
||||||
|
<span id="zoomLabel" class="queue-meta">100%</span>
|
||||||
|
</div>
|
||||||
|
<div id="tipsBox" class="tips-box"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="edit-panel">
|
||||||
|
<form id="editForm" class="edit-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>姓名<input name="姓名"></label>
|
||||||
|
<label>性别<input name="性别"></label>
|
||||||
|
<label>年龄<input name="年龄"></label>
|
||||||
|
<label>住院号<input name="住院号"></label>
|
||||||
|
</div>
|
||||||
|
<label>诊断<textarea name="诊断" rows="4"></textarea></label>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>入院时间<input name="入院时间"></label>
|
||||||
|
<label>最后书写时间<input name="最后书写时间"></label>
|
||||||
|
<label>住院天数<input name="住院天数"></label>
|
||||||
|
<label>出院时间<input name="出院时间"></label>
|
||||||
|
</div>
|
||||||
|
<label>手术后天数(格式为 空 或 后X天)<input name="手术后天数"></label>
|
||||||
|
<div id="reviewChangeLog" class="change-log review-change-log"></div>
|
||||||
|
<label>复核备注<textarea id="manualNote" rows="3"></textarea></label>
|
||||||
|
<div class="button-row review-actions">
|
||||||
|
<button type="submit" class="primary-button">保存确认</button>
|
||||||
|
<button type="button" id="resetButton" class="secondary-button">还原 OCR</button>
|
||||||
|
<button type="button" id="deleteButton" class="danger-button">删除修订</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="auditView" class="page-view hidden audit-view">
|
||||||
|
<section class="audit-toolbar">
|
||||||
|
<label class="inline-field">
|
||||||
|
<span class="field-prefix">数据源:</span>
|
||||||
|
<select id="auditSource">
|
||||||
|
<option value="manual_review_passed">已处理-人工复核通过项</option>
|
||||||
|
<option value="db_auto_passed">数据库-自动复核通过</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inline-field">
|
||||||
|
<span class="field-prefix">数量:</span>
|
||||||
|
<input id="auditCount" type="number" min="1" max="20" value="5">
|
||||||
|
</label>
|
||||||
|
<button id="auditSampleButton" class="primary-button" type="button">随机抽取</button>
|
||||||
|
<div class="ai-action-panel audit-ai-panel">
|
||||||
|
<div class="ai-action-title"><span class="ai-badge">AI</span><span>智能抽查</span></div>
|
||||||
|
<div class="ai-action-buttons">
|
||||||
|
<button id="auditKimiButton" class="secondary-button ai-button" type="button">AI抽查当前项</button>
|
||||||
|
<button id="auditKimiAllButton" class="secondary-button ai-button" type="button">AI抽查所有项</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="auditState" class="save-state"></span>
|
||||||
|
</section>
|
||||||
|
<section class="audit-layout">
|
||||||
|
<div id="auditList" class="queue-list"></div>
|
||||||
|
<div class="audit-detail">
|
||||||
|
<div class="record-strip">
|
||||||
|
<div>
|
||||||
|
<div id="auditTitle" class="record-title">未选择抽查记录</div>
|
||||||
|
<div id="auditMeta" class="record-meta"></div>
|
||||||
|
</div>
|
||||||
|
<a id="auditFullImageLink" class="ghost-button" target="_blank" rel="noreferrer">原图</a>
|
||||||
|
</div>
|
||||||
|
<div class="crop-frame audit-frame" id="auditFrame">
|
||||||
|
<div class="audit-image-stage">
|
||||||
|
<img id="auditImage" alt="抽查截图" draggable="false">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="zoom-actions">
|
||||||
|
<button id="auditZoomOutButton" class="ghost-button" type="button">缩小</button>
|
||||||
|
<button id="auditZoomResetButton" class="ghost-button" type="button">恢复尺寸</button>
|
||||||
|
<button id="auditZoomInButton" class="ghost-button" type="button">放大</button>
|
||||||
|
<span id="auditZoomLabel" class="queue-meta">100%</span>
|
||||||
|
</div>
|
||||||
|
<section class="model-label ai-result-section">
|
||||||
|
<span>AI结论与建议</span>
|
||||||
|
<div id="auditModelResult" class="model-result"></div>
|
||||||
|
</section>
|
||||||
|
<details id="auditPromptDetails" class="ai-full-output ai-prompt-output">
|
||||||
|
<summary>询问提示词</summary>
|
||||||
|
<pre id="auditPromptText"></pre>
|
||||||
|
</details>
|
||||||
|
<details id="auditRawOutputDetails" class="ai-full-output">
|
||||||
|
<summary>AI完整输出</summary>
|
||||||
|
<pre id="auditRawOutput"></pre>
|
||||||
|
</details>
|
||||||
|
<textarea id="auditPrompt" class="hidden" readonly></textarea>
|
||||||
|
</div>
|
||||||
|
<aside class="audit-side">
|
||||||
|
<h2>患者信息修订</h2>
|
||||||
|
<div id="auditPatientInfo" class="audit-edit-grid"></div>
|
||||||
|
<div id="auditTipsBox" class="tips-box"></div>
|
||||||
|
<div id="auditChangeLog" class="change-log"></div>
|
||||||
|
<input id="auditMachineVerdict" type="hidden" value="">
|
||||||
|
<div class="audit-verdict-grid">
|
||||||
|
<span>AI核验判断</span>
|
||||||
|
<strong id="auditMachineVerdictText">未判断</strong>
|
||||||
|
<span>人工核验判断</span>
|
||||||
|
<strong id="auditManualVerdictText">未选择</strong>
|
||||||
|
</div>
|
||||||
|
<label>人工反馈<textarea id="auditManualFeedback" rows="4"></textarea></label>
|
||||||
|
<div class="settings-label">人工核验判断</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="auditPassButton" class="primary-button" type="button">人工核验通过</button>
|
||||||
|
<button id="auditFailButton" class="danger-button" type="button">人工核验异常</button>
|
||||||
|
<button id="auditUnsureButton" class="secondary-button" type="button">暂不确定</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="auditHistoryView" class="page-view hidden audit-view audit-history-view">
|
||||||
|
<section class="audit-toolbar audit-history-toolbar">
|
||||||
|
<button id="auditHistoryRefreshButton" class="primary-button" type="button">刷新抽查记录</button>
|
||||||
|
<span id="auditHistoryState" class="save-state"></span>
|
||||||
|
</section>
|
||||||
|
<section class="audit-layout audit-history-layout">
|
||||||
|
<aside class="audit-history-left">
|
||||||
|
<div class="audit-history-filters">
|
||||||
|
<label class="inline-field">
|
||||||
|
<span class="field-prefix">数据源:</span>
|
||||||
|
<select id="auditHistorySourceFilter">
|
||||||
|
<option value="">全部抽查源</option>
|
||||||
|
<option value="manual_review_passed">已处理-人工复核通过项</option>
|
||||||
|
<option value="db_auto_passed">数据库-自动复核通过</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inline-field">
|
||||||
|
<span class="field-prefix">状态:</span>
|
||||||
|
<select id="auditHistoryStatusFilter">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="manual_pending">未人工核验</option>
|
||||||
|
<option value="manual_passed">人工核验通过</option>
|
||||||
|
<option value="manual_failed">人工核验异常</option>
|
||||||
|
<option value="manual_unsure">暂不确定</option>
|
||||||
|
<option value="ai_failed">AI核验异常</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inline-field">
|
||||||
|
<span class="field-prefix">排序:</span>
|
||||||
|
<select id="auditHistorySortFilter">
|
||||||
|
<option value="checked_desc">最近抽查优先</option>
|
||||||
|
<option value="checked_asc">最早抽查优先</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inline-field">
|
||||||
|
<span class="field-prefix">搜索:</span>
|
||||||
|
<input id="auditHistorySearchInput" placeholder="姓名/住院号/科室">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="auditHistoryList" class="queue-list"></div>
|
||||||
|
</aside>
|
||||||
|
<div class="audit-detail">
|
||||||
|
<div class="record-strip">
|
||||||
|
<div>
|
||||||
|
<div id="auditHistoryTitle" class="record-title">未选择抽查记录</div>
|
||||||
|
<div id="auditHistoryMeta" class="record-meta"></div>
|
||||||
|
</div>
|
||||||
|
<a id="auditHistoryFullImageLink" class="ghost-button" target="_blank" rel="noreferrer">原图</a>
|
||||||
|
</div>
|
||||||
|
<div class="crop-frame audit-frame" id="auditHistoryFrame">
|
||||||
|
<div class="audit-image-stage">
|
||||||
|
<img id="auditHistoryImage" alt="抽查截图" draggable="false">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="zoom-actions">
|
||||||
|
<button id="auditHistoryZoomOutButton" class="ghost-button" type="button">缩小</button>
|
||||||
|
<button id="auditHistoryZoomResetButton" class="ghost-button" type="button">恢复尺寸</button>
|
||||||
|
<button id="auditHistoryZoomInButton" class="ghost-button" type="button">放大</button>
|
||||||
|
<span id="auditHistoryZoomLabel" class="queue-meta">100%</span>
|
||||||
|
</div>
|
||||||
|
<section class="model-label">
|
||||||
|
<span>AI结论与建议</span>
|
||||||
|
<div id="auditHistoryAiResult" class="model-result"></div>
|
||||||
|
</section>
|
||||||
|
<details class="ai-full-output">
|
||||||
|
<summary>AI完整输出</summary>
|
||||||
|
<pre id="auditHistoryRawOutput"></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<aside class="audit-side">
|
||||||
|
<h2>最终审核</h2>
|
||||||
|
<div id="auditHistoryPatientInfo" class="audit-edit-grid"></div>
|
||||||
|
<div id="auditHistoryTipsBox" class="tips-box"></div>
|
||||||
|
<div id="auditHistoryChangeLog" class="change-log"></div>
|
||||||
|
<div class="audit-verdict-grid">
|
||||||
|
<span>AI核验判断</span>
|
||||||
|
<strong id="auditHistoryAiVerdict"></strong>
|
||||||
|
<span>人工核验判断</span>
|
||||||
|
<strong id="auditHistoryManualVerdict"></strong>
|
||||||
|
<span>抽查源</span>
|
||||||
|
<strong id="auditHistorySource"></strong>
|
||||||
|
<span>操作人</span>
|
||||||
|
<strong id="auditHistoryUser"></strong>
|
||||||
|
<span>抽查时间</span>
|
||||||
|
<strong id="auditHistoryCheckedAt"></strong>
|
||||||
|
</div>
|
||||||
|
<div class="settings-label">最终人工核验判断</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="auditHistoryPassButton" class="primary-button" type="button">人工核验通过</button>
|
||||||
|
<button id="auditHistoryFailButton" class="danger-button" type="button">人工核验异常</button>
|
||||||
|
<button id="auditHistoryUnsureButton" class="secondary-button" type="button">暂不确定</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="settingsView" class="page-view hidden settings-view">
|
||||||
|
<section class="settings-layout">
|
||||||
|
<section class="settings-panel user-admin-panel">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<h2>用户与权限</h2>
|
||||||
|
<span id="settingsUserCount" class="badge">0 个用户</span>
|
||||||
|
</div>
|
||||||
|
<form id="userForm" class="new-user-form">
|
||||||
|
<div class="settings-form-grid">
|
||||||
|
<label>新用户名<input id="newUsername" autocomplete="off"></label>
|
||||||
|
<label>密码<input id="newPassword" type="password" autocomplete="new-password"></label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="settings-label">页面权限</div>
|
||||||
|
<div id="newUserPermissions" class="permission-grid"></div>
|
||||||
|
</div>
|
||||||
|
<button class="primary-button" type="submit">新建用户</button>
|
||||||
|
</form>
|
||||||
|
<div id="userList" class="settings-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="settings-side">
|
||||||
|
<form id="kimiForm" class="settings-panel">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<h2>抽查大模型 API</h2>
|
||||||
|
<button id="kimiToggleButton" class="secondary-button compact-button" type="button">关闭API</button>
|
||||||
|
</div>
|
||||||
|
<label>Kimi API Key<input id="kimiApiKey" type="password" autocomplete="off"></label>
|
||||||
|
<label>模型<input id="kimiModel" value="kimi-k2.6"></label>
|
||||||
|
<button class="primary-button" type="submit">保存 Kimi 设置</button>
|
||||||
|
<div id="settingsState" class="save-state"></div>
|
||||||
|
</form>
|
||||||
|
<section class="settings-panel">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<h2>处理项寻找</h2>
|
||||||
|
</div>
|
||||||
|
<p class="settings-note">进入系统时会自动扫描一次,查找复核、抽查和同步里仍需处理的数据。</p>
|
||||||
|
<div id="processingFinderGrid" class="finder-grid"></div>
|
||||||
|
<button id="processingFindButton" class="secondary-button" type="button">重新寻找处理项</button>
|
||||||
|
<div id="processingFinderState" class="save-state"></div>
|
||||||
|
</section>
|
||||||
|
<section class="settings-panel">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<h2>数据提交</h2>
|
||||||
|
</div>
|
||||||
|
<p class="settings-note">将已人工复核通过的修订写入合并结果文件,重试同步数据库,并把这些条目归档隐藏。</p>
|
||||||
|
<button id="commitManualPassedButton" class="primary-button" type="button">提交已人工通过数据</button>
|
||||||
|
<div id="commitManualPassedState" class="save-state"></div>
|
||||||
|
</section>
|
||||||
|
<section class="settings-panel">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<h2>抽查记录管理</h2>
|
||||||
|
</div>
|
||||||
|
<p class="settings-note">清空数据库中的抽查一览结果,不影响患者基础信息和复核修订。</p>
|
||||||
|
<button id="resetAuditHistoryButton" class="danger-button" type="button">重置抽查一览</button>
|
||||||
|
<div id="resetAuditHistoryState" class="save-state"></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
患者列表处理/人工复核网页端/templates/login.html
Normal file
29
患者列表处理/人工复核网页端/templates/login.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!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/app.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-shell">
|
||||||
|
<main class="login-panel">
|
||||||
|
<div class="login-mark">HIS</div>
|
||||||
|
<h1>患者目录图片集群人工复核及抽查</h1>
|
||||||
|
<form method="post" class="login-form">
|
||||||
|
<label>
|
||||||
|
账号
|
||||||
|
<input name="username" autocomplete="username" autofocus>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
密码
|
||||||
|
<input name="password" type="password" autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
{% if error %}
|
||||||
|
<div class="form-error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit">登录</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
202
患者列表处理/工作流_Gitea版.md
Normal file
202
患者列表处理/工作流_Gitea版.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# HIS 患者列表 OCR 归档工作流
|
||||||
|
|
||||||
|
这是可提交到 Gitea 的无密钥版本。不要在本文件中写入腾讯云密钥、数据库密码、Gitea 密码或患者处理结果。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把 HIS 患者列表截图整理为结构化患者记录,并同步到 PostgreSQL 的 `"Patient_Lists"` 单表。
|
||||||
|
|
||||||
|
最终数据库只保留正式查看和追溯需要的信息:
|
||||||
|
|
||||||
|
- 数据定位:`batch_name`、`source_folder`、`image_path`、`image_name`、`image_row_no`
|
||||||
|
- 科室信息:`major_department`、`sub_department`
|
||||||
|
- 患者信息:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数
|
||||||
|
- 复核信息:`review_status`、`review_notes`、`manual_corrected`
|
||||||
|
|
||||||
|
OCR 请求号、OCR 缓存路径、拼接图路径、拼接组号等中间态字段不进入数据库正式表。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
- `待处理-患者目录图片集群/`:尚未处理的 HIS 图片批次。
|
||||||
|
- `已处理-患者目录图片集群/`:已经完成处理的原始图片批次。
|
||||||
|
- `数据处理工作区/`:程序、科室规则、数据库结构、说明文档。
|
||||||
|
- `数据处理结果区/已处理-患者目录图片集群/`:各批次本地处理结果。
|
||||||
|
- `数据处理结果区/信息记录/`:全局信息记录、批次汇总、待处理盘点等。
|
||||||
|
|
||||||
|
仓库只提交程序和文档。图片、OCR 缓存、处理结果、人工修正数据都应被 `.gitignore` 排除。
|
||||||
|
|
||||||
|
## 关键文件
|
||||||
|
|
||||||
|
1. `数据处理工作区/01_科室分类规则.json`
|
||||||
|
2. `数据处理工作区/02_患者列表OCR归档.py`
|
||||||
|
3. `数据处理工作区/03_人工复核修正.template.json`
|
||||||
|
4. `数据处理工作区/04_合并批次结果.py`
|
||||||
|
5. `数据处理工作区/05_同步PostgreSQL单表.py`
|
||||||
|
6. `数据处理工作区/06_PostgreSQL建表结构.sql`
|
||||||
|
7. `数据处理工作区/07_处理程序说明.md`
|
||||||
|
8. `数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql`
|
||||||
|
|
||||||
|
本地实际使用时,可以从模板复制出 `数据处理工作区/03_人工复核修正.json`。这个文件可能包含患者信息,不提交到 Git。
|
||||||
|
|
||||||
|
患者列表字段顺序固定为:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数。
|
||||||
|
|
||||||
|
## 处理前准备
|
||||||
|
|
||||||
|
安装依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pip install pillow
|
||||||
|
psql --version
|
||||||
|
```
|
||||||
|
|
||||||
|
设置腾讯云 OCR 环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export TENCENTCLOUD_SECRET_ID='填入腾讯云 SecretId'
|
||||||
|
export TENCENTCLOUD_SECRET_KEY='填入腾讯云 SecretKey'
|
||||||
|
```
|
||||||
|
|
||||||
|
OCR 接口使用腾讯云 `RecognizeTableAccurateOCR` 表格识别 V3:
|
||||||
|
|
||||||
|
- 表格识别 V3 文档:https://cloud.tencent.com/document/product/866/86721
|
||||||
|
|
||||||
|
## 处理一个批次
|
||||||
|
|
||||||
|
示例批次名用 `批次文件夹名` 表示:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 数据处理工作区/02_患者列表OCR归档.py \
|
||||||
|
--input "待处理-患者目录图片集群/批次文件夹名" \
|
||||||
|
--output "数据处理结果区/已处理-患者目录图片集群/批次文件夹名-列表归档结果" \
|
||||||
|
--ocr-engine table-v3 \
|
||||||
|
--batch-size 6 \
|
||||||
|
--image-padding-y 24 \
|
||||||
|
--workers 1 \
|
||||||
|
--folder-workers 2 \
|
||||||
|
--timeout 90 \
|
||||||
|
--max-retries 1
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐正式处理优先使用 `--batch-size 6`。程序会根据行数校验和接口错误自动降到 4/3/2/单张;降档会增加调用次数,但能降低漏行风险。
|
||||||
|
|
||||||
|
处理完成后检查:
|
||||||
|
|
||||||
|
- `患者列表_结构化.json`
|
||||||
|
- `患者列表_记录.csv`
|
||||||
|
- `复核报告.json`
|
||||||
|
- `重复住院号报告.json`
|
||||||
|
- `信息记录/汇总.json`
|
||||||
|
|
||||||
|
## 复核规则
|
||||||
|
|
||||||
|
推荐使用 Docker 化网页端进行复核和抽查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd 人工复核网页端
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
网页端包含概览、复核、抽查、设置四个区域;抽查功能使用 Kimi 多模态模型,配置项通过 `.env` 或网页端设置页提供,不在 Gitea 文档中保存密钥。
|
||||||
|
|
||||||
|
`review_status` 常见值:
|
||||||
|
|
||||||
|
- `自动复核通过`:OCR 结果经过程序规则校验后没有发现明显异常,`manual_corrected=false`。
|
||||||
|
- `人工复核通过`:该行命中了 `03_人工复核修正.json` 中的人工修正项,修正后校验通过,`manual_corrected=true`。
|
||||||
|
- `需人工复核`:程序无法可靠判断,需要人工查看图片和记录,原因写在 `review_notes`。
|
||||||
|
|
||||||
|
`出院时间` 允许为空;若填写出院时间,则需要满足标准时间格式,且不应早于入院时间。PostgreSQL 约束会阻止“入院时间晚于出院时间”却仍被标记为通过的记录。
|
||||||
|
|
||||||
|
人工修正流程:
|
||||||
|
|
||||||
|
1. 打开该批次的 `复核报告.json`。
|
||||||
|
2. 根据 `图片路径` 和 `图片内行号` 回看原图。
|
||||||
|
3. 把确认后的患者字段写入 `数据处理工作区/03_人工复核修正.json`。
|
||||||
|
4. 使用缓存重建批次:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 数据处理工作区/02_患者列表OCR归档.py \
|
||||||
|
--input "待处理-患者目录图片集群/批次文件夹名" \
|
||||||
|
--output "数据处理结果区/已处理-患者目录图片集群/批次文件夹名-列表归档结果" \
|
||||||
|
--ocr-engine table-v3 \
|
||||||
|
--batch-size 6 \
|
||||||
|
--image-padding-y 24 \
|
||||||
|
--workers 1 \
|
||||||
|
--folder-workers 2 \
|
||||||
|
--rebuild-from-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
## 合并所有批次
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 数据处理工作区/04_合并批次结果.py
|
||||||
|
```
|
||||||
|
|
||||||
|
输出包括:
|
||||||
|
|
||||||
|
- `数据处理结果区/合并_患者列表_结构化.json`
|
||||||
|
- `数据处理结果区/合并_患者列表_记录.csv`
|
||||||
|
- `数据处理结果区/信息记录/全局汇总.json`
|
||||||
|
- `数据处理结果区/信息记录/批次汇总.csv`
|
||||||
|
- `数据处理结果区/信息记录/重复住院号报告.json`
|
||||||
|
|
||||||
|
## 同步 PostgreSQL
|
||||||
|
|
||||||
|
设置数据库环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HIS_DB_HOST='数据库主机'
|
||||||
|
export HIS_DB_PORT='5432'
|
||||||
|
export HIS_DB_NAME='DB_NAME'
|
||||||
|
export HIS_DB_USER='DB_USER'
|
||||||
|
export HIS_DB_PASSWORD='填入数据库密码'
|
||||||
|
```
|
||||||
|
|
||||||
|
同步:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 数据处理工作区/05_同步PostgreSQL单表.py
|
||||||
|
```
|
||||||
|
|
||||||
|
核对:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT count(*) FROM "Patient_Lists";
|
||||||
|
SELECT review_status, manual_corrected, count(*) FROM "Patient_Lists" GROUP BY review_status, manual_corrected;
|
||||||
|
SELECT inpatient_no FROM "Patient_Lists" GROUP BY inpatient_no HAVING count(*) > 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
`"Patient_Lists"` 是带大小写的 PostgreSQL 表名,查询时必须加双引号。
|
||||||
|
|
||||||
|
## 处理完成后
|
||||||
|
|
||||||
|
确认批次结果和数据库无误后,将原始图片批次移动到:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv "待处理-患者目录图片集群/批次文件夹名" "已处理-患者目录图片集群/"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gitea 提交
|
||||||
|
|
||||||
|
仓库地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://gitea.huijutec.cn/admin/HIS_List.git
|
||||||
|
```
|
||||||
|
|
||||||
|
提交前检查不要包含数据和密钥:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short --ignored
|
||||||
|
git diff --cached --name-only
|
||||||
|
grep -R "Secret\\|PASSWORD\\|密码" -n README.md 数据处理工作区 --exclude-dir='__pycache__' || true
|
||||||
|
```
|
||||||
|
|
||||||
|
提交:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .gitignore .env.example README.md 工作流_Gitea版.md 人工复核网页端 数据处理工作区/01_科室分类规则.json 数据处理工作区/02_患者列表OCR归档.py 数据处理工作区/03_人工复核修正.template.json 数据处理工作区/04_合并批次结果.py 数据处理工作区/05_同步PostgreSQL单表.py 数据处理工作区/06_PostgreSQL建表结构.sql 数据处理工作区/07_处理程序说明.md 数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql
|
||||||
|
git commit -m "Update HIS patient list workflow"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
如果本地 `origin` 没有保存认证信息,需要使用一次性认证或交互式登录,但不要把密码写入文档或 Git 配置。
|
||||||
258
患者列表处理/数据处理工作区/01_科室分类规则.json
Normal file
258
患者列表处理/数据处理工作区/01_科室分类规则.json
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
{
|
||||||
|
"说明": "每个具体子科室只归属一个大科室;aliases 用于把图片文件夹名归一到标准子科室名。",
|
||||||
|
"大科室列表": [
|
||||||
|
{
|
||||||
|
"大科室": "肝胆外科及肝移植相关",
|
||||||
|
"子科室": [
|
||||||
|
"肝胆外科1",
|
||||||
|
"肝胆外科2",
|
||||||
|
"肝胆外科3",
|
||||||
|
"肝胆外科4",
|
||||||
|
"肝胆外科5D",
|
||||||
|
"肝胆外科手术室",
|
||||||
|
"肝胆特种病区L",
|
||||||
|
"肝移植内",
|
||||||
|
"肝移植内N"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "普通外科及腹部外科",
|
||||||
|
"子科室": [
|
||||||
|
"普外科1",
|
||||||
|
"普外科2",
|
||||||
|
"普外科3",
|
||||||
|
"普外科4D",
|
||||||
|
"腹部外科L",
|
||||||
|
"普通腺体外科",
|
||||||
|
"普通胃肠外科"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "急诊医学科",
|
||||||
|
"子科室": [
|
||||||
|
"急诊中心",
|
||||||
|
"急诊中心L",
|
||||||
|
"急诊重症医学科L"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "重症医学科",
|
||||||
|
"子科室": [
|
||||||
|
"※重症医学1病区",
|
||||||
|
"重症医学2N病区",
|
||||||
|
"重症医学3病区",
|
||||||
|
"重症医学4D病区",
|
||||||
|
"外科ICU",
|
||||||
|
"肝胆ICU",
|
||||||
|
"肿瘤外科重症",
|
||||||
|
"感染重症"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "骨科",
|
||||||
|
"子科室": [
|
||||||
|
"骨科",
|
||||||
|
"骨科1",
|
||||||
|
"骨科2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "泌尿外科",
|
||||||
|
"子科室": [
|
||||||
|
"泌尿外1",
|
||||||
|
"泌尿外2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "呼吸内科",
|
||||||
|
"子科室": [
|
||||||
|
"呼吸内科3病区"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "耳鼻喉头颈外科",
|
||||||
|
"子科室": [
|
||||||
|
"耳鼻喉D",
|
||||||
|
"耳鼻喉头颈外科",
|
||||||
|
"耳鼻喉头颈外科L",
|
||||||
|
"耳鼻喉2病区"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "日间诊疗中心",
|
||||||
|
"子科室": [
|
||||||
|
"日间手术中心",
|
||||||
|
"日间中心病房L"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "乳腺外科",
|
||||||
|
"子科室": [
|
||||||
|
"乳腺外科1N",
|
||||||
|
"乳腺外科2N",
|
||||||
|
"乳腺外科2D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "胸外科",
|
||||||
|
"子科室": [
|
||||||
|
"胸外1",
|
||||||
|
"胸外2",
|
||||||
|
"胸外3",
|
||||||
|
"胸外4D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "肝病内科",
|
||||||
|
"子科室": [
|
||||||
|
"肝病内科"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "感染科",
|
||||||
|
"子科室": [
|
||||||
|
"感染1",
|
||||||
|
"感染2",
|
||||||
|
"感染3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "肿瘤放疗科",
|
||||||
|
"子科室": [
|
||||||
|
"肿瘤放疗1",
|
||||||
|
"肿瘤放疗2",
|
||||||
|
"肿瘤放疗3",
|
||||||
|
"肿瘤放疗病区L",
|
||||||
|
"肿瘤放疗中心L【无人】",
|
||||||
|
"肿瘤放疗日间【无人】"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "特需/涉外病房",
|
||||||
|
"子科室": [
|
||||||
|
"健苑五涉外病房"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "老年外科",
|
||||||
|
"子科室": [
|
||||||
|
"老年外科"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aliases": {
|
||||||
|
"呼吸内科3": "呼吸内科3病区",
|
||||||
|
"外科ICU": "外科ICU",
|
||||||
|
"急诊中心病房": "急诊中心",
|
||||||
|
"急诊中心": "急诊中心",
|
||||||
|
"急诊中心病房L": "急诊中心L",
|
||||||
|
"急诊中心L": "急诊中心L",
|
||||||
|
"急诊重症医学L": "急诊重症医学科L",
|
||||||
|
"急诊重症医学": "急诊重症医学科L",
|
||||||
|
"急诊重症L": "急诊重症医学科L",
|
||||||
|
"急诊重症医学科L": "急诊重症医学科L",
|
||||||
|
"感染科1": "感染1",
|
||||||
|
"感染科2": "感染2",
|
||||||
|
"感染科3": "感染3",
|
||||||
|
"感染1": "感染1",
|
||||||
|
"感染2": "感染2",
|
||||||
|
"感染3": "感染3",
|
||||||
|
"感染科重症": "感染重症",
|
||||||
|
"感染重症": "感染重症",
|
||||||
|
"肝病内科": "肝病内科",
|
||||||
|
"肝病内科N": "肝病内科",
|
||||||
|
"肝胆外科1": "肝胆外科1",
|
||||||
|
"肝胆外科2": "肝胆外科2",
|
||||||
|
"肝胆外科3": "肝胆外科3",
|
||||||
|
"肝胆外科4": "肝胆外科4",
|
||||||
|
"肝胆1": "肝胆外科1",
|
||||||
|
"肝胆2": "肝胆外科2",
|
||||||
|
"肝胆3": "肝胆外科3",
|
||||||
|
"肝胆4": "肝胆外科4",
|
||||||
|
"肝胆5": "肝胆外科5D",
|
||||||
|
"肝胆_5": "肝胆外科5D",
|
||||||
|
"肝胆5D": "肝胆外科5D",
|
||||||
|
"肝胆外科5病房D": "肝胆外科5D",
|
||||||
|
"肝胆特种L": "肝胆特种病区L",
|
||||||
|
"肝胆特种病区L": "肝胆特种病区L",
|
||||||
|
"肝胆移植内": "肝移植内",
|
||||||
|
"肝移植内": "肝移植内",
|
||||||
|
"肝移植内科": "肝移植内",
|
||||||
|
"肝移植内N": "肝移植内N",
|
||||||
|
"肝移植内科N": "肝移植内N",
|
||||||
|
"肿瘤放疗1": "肿瘤放疗1",
|
||||||
|
"肿瘤放疗2": "肿瘤放疗2",
|
||||||
|
"肿瘤放疗3": "肿瘤放疗3",
|
||||||
|
"肿放1": "肿瘤放疗1",
|
||||||
|
"肿放2": "肿瘤放疗2",
|
||||||
|
"肿放3": "肿瘤放疗3",
|
||||||
|
"肿瘤放疗L": "肿瘤放疗病区L",
|
||||||
|
"肿放L": "肿瘤放疗病区L",
|
||||||
|
"重症医学1": "※重症医学1病区",
|
||||||
|
"重症医学科1": "※重症医学1病区",
|
||||||
|
"重症1": "※重症医学1病区",
|
||||||
|
"重症医学2": "重症医学2N病区",
|
||||||
|
"重症医学科2": "重症医学2N病区",
|
||||||
|
"重症2": "重症医学2N病区",
|
||||||
|
"重症2N": "重症医学2N病区",
|
||||||
|
"重症医学3": "重症医学3病区",
|
||||||
|
"重症医学科3": "重症医学3病区",
|
||||||
|
"重症3": "重症医学3病区",
|
||||||
|
"重症医学4": "重症医学4D病区",
|
||||||
|
"重症医学科4": "重症医学4D病区",
|
||||||
|
"重症4": "重症医学4D病区",
|
||||||
|
"重症4D": "重症医学4D病区",
|
||||||
|
"胸外1": "胸外1",
|
||||||
|
"胸外2": "胸外2",
|
||||||
|
"胸外3": "胸外3",
|
||||||
|
"胸外4D": "胸外4D",
|
||||||
|
"普外1": "普外科1",
|
||||||
|
"普外2": "普外科2",
|
||||||
|
"普外3": "普外科3",
|
||||||
|
"普外4D": "普外科4D",
|
||||||
|
"普通外科1": "普外科1",
|
||||||
|
"普通外科2": "普外科2",
|
||||||
|
"普通外科3": "普外科3",
|
||||||
|
"普通外科4": "普外科4D",
|
||||||
|
"普通外科4D": "普外科4D",
|
||||||
|
"普通腺体外科": "普通腺体外科",
|
||||||
|
"普通胃肠": "普通胃肠外科",
|
||||||
|
"普通胃肠外科": "普通胃肠外科",
|
||||||
|
"腹部外科L": "腹部外科L",
|
||||||
|
"肝胆ICU": "肝胆ICU",
|
||||||
|
"日间手术": "日间手术中心",
|
||||||
|
"日间手术中心": "日间手术中心",
|
||||||
|
"日间手术L": "日间中心病房L",
|
||||||
|
"日间中心L": "日间中心病房L",
|
||||||
|
"日间中心病房L": "日间中心病房L",
|
||||||
|
"耳鼻喉": "耳鼻喉头颈外科",
|
||||||
|
"耳鼻喉D": "耳鼻喉D",
|
||||||
|
"耳鼻喉L": "耳鼻喉头颈外科L",
|
||||||
|
"耳鼻喉头颈外科": "耳鼻喉头颈外科",
|
||||||
|
"耳鼻喉头颈外科L": "耳鼻喉头颈外科L",
|
||||||
|
"耳鼻咽喉": "耳鼻喉头颈外科",
|
||||||
|
"耳鼻咽喉头颈外科": "耳鼻喉头颈外科",
|
||||||
|
"耳鼻喉2": "耳鼻喉2病区",
|
||||||
|
"耳鼻喉2D": "耳鼻喉2病区",
|
||||||
|
"耳鼻喉头颈2": "耳鼻喉2病区",
|
||||||
|
"乳腺外科": "乳腺外科1N",
|
||||||
|
"乳腺外科1": "乳腺外科1N",
|
||||||
|
"乳腺外科1N": "乳腺外科1N",
|
||||||
|
"乳腺外科2": "乳腺外科2N",
|
||||||
|
"乳腺外科2N": "乳腺外科2N",
|
||||||
|
"乳腺外科2D": "乳腺外科2D",
|
||||||
|
"乳腺1N": "乳腺外科1N",
|
||||||
|
"乳腺2N": "乳腺外科2N",
|
||||||
|
"乳腺2D": "乳腺外科2D",
|
||||||
|
"骨科": "骨科",
|
||||||
|
"骨科1": "骨科1",
|
||||||
|
"骨科2": "骨科2",
|
||||||
|
"骨科_1": "骨科1",
|
||||||
|
"骨科_2": "骨科2",
|
||||||
|
"泌尿1": "泌尿外1",
|
||||||
|
"泌尿2": "泌尿外2",
|
||||||
|
"健苑五涉外": "健苑五涉外病房",
|
||||||
|
"健苑五涉外病房": "健苑五涉外病房",
|
||||||
|
"老年外科": "老年外科"
|
||||||
|
}
|
||||||
|
}
|
||||||
1346
患者列表处理/数据处理工作区/02_患者列表OCR归档.py
Normal file
1346
患者列表处理/数据处理工作区/02_患者列表OCR归档.py
Normal file
File diff suppressed because it is too large
Load Diff
1
患者列表处理/数据处理工作区/03_人工复核修正.template.json
Normal file
1
患者列表处理/数据处理工作区/03_人工复核修正.template.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
247
患者列表处理/数据处理工作区/04_合并批次结果.py
Normal file
247
患者列表处理/数据处理工作区/04_合并批次结果.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Consolidate patient archive result folders."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path("数据处理结果区")
|
||||||
|
RESULT_SUFFIX = "-列表归档结果"
|
||||||
|
INFO_DIR_NAME = "信息记录"
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, data: Any) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def write_jsonl(path: Path, records: list[dict[str, Any]]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as file:
|
||||||
|
for record in records:
|
||||||
|
file.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_record(batch_name: str, record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
patient = record["患者信息"]
|
||||||
|
image = record["图片信息"]
|
||||||
|
review = record["复核"]
|
||||||
|
return {
|
||||||
|
"处理批次": batch_name,
|
||||||
|
"大科室": record["大科室"],
|
||||||
|
"子科室": record["子科室"],
|
||||||
|
"来源文件夹": record["来源文件夹"],
|
||||||
|
"图片路径": image["图片路径"],
|
||||||
|
"图片名": image["图片名"],
|
||||||
|
"图片内行号": image["图片内行号"],
|
||||||
|
"拼接组序号": image["拼接组序号"],
|
||||||
|
"OCR请求ID": image.get("OCR请求ID", ""),
|
||||||
|
"姓名": patient["姓名"],
|
||||||
|
"性别": patient["性别"],
|
||||||
|
"年龄": patient["年龄"],
|
||||||
|
"住院号": patient["住院号"],
|
||||||
|
"诊断": patient["诊断"],
|
||||||
|
"入院时间": patient["入院时间"],
|
||||||
|
"最后书写时间": patient["最后书写时间"],
|
||||||
|
"住院天数": patient["住院天数"],
|
||||||
|
"出院时间": patient["出院时间"],
|
||||||
|
"手术后天数": patient["手术后天数"],
|
||||||
|
"复核状态": review["状态"],
|
||||||
|
"复核提示": review_note_text(review),
|
||||||
|
"人工修正": bool(review.get("人工修正")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def review_note_text(review: dict[str, Any]) -> str:
|
||||||
|
notes = [str(item) for item in review.get("提示", []) if str(item) and str(item) != "缺少出院时间"]
|
||||||
|
if review.get("人工备注"):
|
||||||
|
notes.append(f"人工备注: {review['人工备注']}")
|
||||||
|
return ";".join(notes)
|
||||||
|
|
||||||
|
|
||||||
|
def record_quality_rank(record: dict[str, Any]) -> tuple[int, int, int, int]:
|
||||||
|
patient = record["患者信息"]
|
||||||
|
review = record["复核"]
|
||||||
|
review_ok = 0 if review.get("状态") == "需人工复核" else 1
|
||||||
|
manual_corrected = 1 if review.get("人工修正") else 0
|
||||||
|
date_count = int(bool(patient.get("入院时间"))) + int(bool(patient.get("出院时间")))
|
||||||
|
filled_count = sum(1 for value in patient.values() if value not in ("", None))
|
||||||
|
return (review_ok, manual_corrected, date_count, filled_count)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_record_for_duplicate(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
patient = record["患者信息"]
|
||||||
|
image = record["图片信息"]
|
||||||
|
review = record["复核"]
|
||||||
|
return {
|
||||||
|
"处理批次": record["处理批次"],
|
||||||
|
"大科室": record["大科室"],
|
||||||
|
"子科室": record["子科室"],
|
||||||
|
"来源文件夹": record["来源文件夹"],
|
||||||
|
"图片路径": image["图片路径"],
|
||||||
|
"图片名": image["图片名"],
|
||||||
|
"图片内行号": image["图片内行号"],
|
||||||
|
"姓名": patient.get("姓名", ""),
|
||||||
|
"住院号": patient.get("住院号", ""),
|
||||||
|
"入院时间": patient.get("入院时间", ""),
|
||||||
|
"出院时间": patient.get("出院时间", ""),
|
||||||
|
"复核状态": review.get("状态", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def deduplicate_records_by_inpatient_no(
|
||||||
|
records: list[dict[str, Any]],
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
kept_by_no: dict[str, dict[str, Any]] = {}
|
||||||
|
kept_order: list[str] = []
|
||||||
|
duplicates: list[dict[str, Any]] = []
|
||||||
|
missing_inpatient_no_records: list[dict[str, Any]] = []
|
||||||
|
for record in records:
|
||||||
|
inpatient_no = str(record["患者信息"].get("住院号", "")).strip()
|
||||||
|
if not inpatient_no:
|
||||||
|
missing_inpatient_no_records.append(
|
||||||
|
{
|
||||||
|
"剔除记录": summarize_record_for_duplicate(record),
|
||||||
|
"规则": "住院号为空,未纳入合并总表和数据库",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if inpatient_no not in kept_by_no:
|
||||||
|
kept_order.append(inpatient_no)
|
||||||
|
else:
|
||||||
|
previous = kept_by_no[inpatient_no]
|
||||||
|
duplicates.append(
|
||||||
|
{
|
||||||
|
"住院号": inpatient_no,
|
||||||
|
"保留记录": summarize_record_for_duplicate(record),
|
||||||
|
"剔除记录": summarize_record_for_duplicate(previous),
|
||||||
|
"规则": "住院号重复,后出现记录覆盖先出现记录",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
kept_by_no[inpatient_no] = record
|
||||||
|
return [kept_by_no[inpatient_no] for inpatient_no in kept_order], duplicates, missing_inpatient_no_records
|
||||||
|
|
||||||
|
|
||||||
|
def slim_department_summary(batch_name: str, item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
raw_tips = item.get("提示", [])
|
||||||
|
if isinstance(raw_tips, list):
|
||||||
|
tips = ";".join(str(tip) for tip in raw_tips)
|
||||||
|
else:
|
||||||
|
tips = str(raw_tips)
|
||||||
|
return {
|
||||||
|
"处理批次": batch_name,
|
||||||
|
"来源文件夹": item["来源文件夹"],
|
||||||
|
"大科室": item["大科室"],
|
||||||
|
"子科室": item["子科室"],
|
||||||
|
"图片数": item["图片数"],
|
||||||
|
"记录数": item["记录数"],
|
||||||
|
"拼接组数": item["拼接组数"],
|
||||||
|
"提示": tips,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8-sig", newline="") as file:
|
||||||
|
writer = csv.DictWriter(file, fieldnames=list(rows[0].keys()))
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ROOT.mkdir(exist_ok=True)
|
||||||
|
raw_records: list[dict[str, Any]] = []
|
||||||
|
batch_summaries: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for result_dir in sorted(ROOT.rglob(f"*{RESULT_SUFFIX}")):
|
||||||
|
archive_path = result_dir / "患者列表_结构化.json"
|
||||||
|
review_path = result_dir / "复核报告.json"
|
||||||
|
if not archive_path.exists():
|
||||||
|
continue
|
||||||
|
batch_name = result_dir.name[: -len(RESULT_SUFFIX)]
|
||||||
|
archive = json.loads(archive_path.read_text(encoding="utf-8"))
|
||||||
|
review = json.loads(review_path.read_text(encoding="utf-8")) if review_path.exists() else {}
|
||||||
|
department_summaries = [
|
||||||
|
slim_department_summary(batch_name, item) for item in archive.get("科室汇总", [])
|
||||||
|
]
|
||||||
|
summary = {
|
||||||
|
"处理批次": batch_name,
|
||||||
|
"结果目录": str(result_dir),
|
||||||
|
**archive.get("汇总", {}),
|
||||||
|
"科室归类": archive.get("科室归类", []),
|
||||||
|
"科室汇总": department_summaries,
|
||||||
|
"需人工复核记录数": len(review.get("需人工复核记录", [])),
|
||||||
|
"人工修正记录数": len(review.get("人工修正记录", [])),
|
||||||
|
}
|
||||||
|
batch_summaries.append(summary)
|
||||||
|
|
||||||
|
summary_dir = result_dir / INFO_DIR_NAME
|
||||||
|
write_json(summary_dir / "汇总.json", {**summary, "科室汇总": department_summaries})
|
||||||
|
write_csv(summary_dir / "科室汇总.csv", department_summaries)
|
||||||
|
|
||||||
|
for record in archive.get("患者记录", []):
|
||||||
|
enriched = {"处理批次": batch_name, **record}
|
||||||
|
raw_records.append(enriched)
|
||||||
|
|
||||||
|
all_records, duplicate_records, missing_inpatient_no_records = deduplicate_records_by_inpatient_no(raw_records)
|
||||||
|
flat_records = [flatten_record(record["处理批次"], record) for record in all_records]
|
||||||
|
kept_by_batch = Counter(record["处理批次"] for record in all_records)
|
||||||
|
dropped_by_batch = Counter(item["剔除记录"]["处理批次"] for item in duplicate_records)
|
||||||
|
missing_by_batch = Counter(item["剔除记录"]["处理批次"] for item in missing_inpatient_no_records)
|
||||||
|
batch_level_duplicate_count = sum(int(item.get("重复住院号剔除记录数", 0)) for item in batch_summaries)
|
||||||
|
batch_level_raw_count = sum(
|
||||||
|
int(item.get("去重前患者记录数", int(item.get("患者记录数", 0)) + int(item.get("重复住院号剔除记录数", 0))))
|
||||||
|
for item in batch_summaries
|
||||||
|
)
|
||||||
|
|
||||||
|
for summary in batch_summaries:
|
||||||
|
batch_name = summary["处理批次"]
|
||||||
|
original_count = int(summary.get("患者记录数", 0))
|
||||||
|
summary["原始患者记录数"] = original_count
|
||||||
|
summary["合并阶段重复住院号剔除记录数"] = int(dropped_by_batch.get(batch_name, 0))
|
||||||
|
summary["合并阶段缺少住院号剔除记录数"] = int(missing_by_batch.get(batch_name, 0))
|
||||||
|
summary["患者记录数"] = int(kept_by_batch.get(batch_name, 0))
|
||||||
|
write_json(Path(summary["结果目录"]) / INFO_DIR_NAME / "汇总.json", summary)
|
||||||
|
|
||||||
|
global_summary = {
|
||||||
|
"批次数": len(batch_summaries),
|
||||||
|
"总图片数": sum(int(item.get("图片数", 0)) for item in batch_summaries),
|
||||||
|
"批次内去重前患者记录数": batch_level_raw_count,
|
||||||
|
"批次内重复住院号剔除记录数": batch_level_duplicate_count,
|
||||||
|
"合并前患者记录数": len(raw_records),
|
||||||
|
"总患者记录数": len(all_records),
|
||||||
|
"合并阶段重复住院号剔除记录数": len(duplicate_records),
|
||||||
|
"合并阶段缺少住院号剔除记录数": len(missing_inpatient_no_records),
|
||||||
|
"重复住院号剔除记录数": batch_level_duplicate_count + len(duplicate_records),
|
||||||
|
"需人工复核记录数": sum(int(item.get("需人工复核记录数", 0)) for item in batch_summaries),
|
||||||
|
"人工修正记录数": sum(int(item.get("人工修正记录数", 0)) for item in batch_summaries),
|
||||||
|
"批次汇总": batch_summaries,
|
||||||
|
}
|
||||||
|
write_json(ROOT / INFO_DIR_NAME / "全局汇总.json", global_summary)
|
||||||
|
write_csv(ROOT / INFO_DIR_NAME / "批次汇总.csv", batch_summaries)
|
||||||
|
write_json(
|
||||||
|
ROOT / "合并_患者列表_结构化.json",
|
||||||
|
{
|
||||||
|
"汇总": global_summary,
|
||||||
|
"重复住院号剔除记录": duplicate_records,
|
||||||
|
"缺少住院号剔除记录": missing_inpatient_no_records,
|
||||||
|
"患者记录": all_records,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
write_jsonl(ROOT / "合并_患者列表_记录.jsonl", all_records)
|
||||||
|
write_csv(ROOT / "合并_患者列表_记录.csv", flat_records)
|
||||||
|
write_json(ROOT / INFO_DIR_NAME / "重复住院号报告.json", duplicate_records)
|
||||||
|
write_json(ROOT / INFO_DIR_NAME / "缺少住院号报告.json", missing_inpatient_no_records)
|
||||||
|
print(json.dumps(global_summary, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
233
患者列表处理/数据处理工作区/05_同步PostgreSQL单表.py
Normal file
233
患者列表处理/数据处理工作区/05_同步PostgreSQL单表.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Sync consolidated HIS patient records into PostgreSQL single table.
|
||||||
|
|
||||||
|
Database credentials are read from environment variables by default:
|
||||||
|
HIS_DB_HOST, HIS_DB_PORT, HIS_DB_NAME, HIS_DB_USER, HIS_DB_PASSWORD.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_RESULT_JSON = Path("数据处理结果区/合并_患者列表_结构化.json")
|
||||||
|
DEFAULT_SCHEMA_SQL = Path("数据处理工作区/06_PostgreSQL建表结构.sql")
|
||||||
|
|
||||||
|
TABLE_NAME = '"Patient_Lists"'
|
||||||
|
|
||||||
|
PATIENT_FIELDS = [
|
||||||
|
"batch_name",
|
||||||
|
"major_department",
|
||||||
|
"sub_department",
|
||||||
|
"source_folder",
|
||||||
|
"image_path",
|
||||||
|
"image_name",
|
||||||
|
"image_row_no",
|
||||||
|
"patient_name",
|
||||||
|
"gender",
|
||||||
|
"age",
|
||||||
|
"inpatient_no",
|
||||||
|
"diagnosis",
|
||||||
|
"admission_time",
|
||||||
|
"last_write_time",
|
||||||
|
"hospital_days",
|
||||||
|
"discharge_time",
|
||||||
|
"postoperative_days",
|
||||||
|
"review_status",
|
||||||
|
"review_notes",
|
||||||
|
"manual_corrected",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def scalar(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_integer(value: Any) -> tuple[str, str]:
|
||||||
|
text = scalar(value).strip()
|
||||||
|
if not text:
|
||||||
|
return "", ""
|
||||||
|
if text.isdigit():
|
||||||
|
return text, ""
|
||||||
|
return "", f"住院天数非数字,原识别值: {text}"
|
||||||
|
|
||||||
|
|
||||||
|
def review_note_items(review: dict[str, Any]) -> list[str]:
|
||||||
|
tips = review.get("提示", [])
|
||||||
|
if isinstance(tips, list):
|
||||||
|
note_items = [str(tip) for tip in tips if str(tip) and str(tip) != "缺少出院时间"]
|
||||||
|
else:
|
||||||
|
note_items = [str(tips)] if str(tips) else []
|
||||||
|
if review.get("人工备注"):
|
||||||
|
note_items.append(f"人工备注: {review['人工备注']}")
|
||||||
|
return note_items
|
||||||
|
|
||||||
|
|
||||||
|
def psql_quote(path: Path) -> str:
|
||||||
|
return "'" + str(path.resolve()).replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_patient_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
patient = record["患者信息"]
|
||||||
|
image = record["图片信息"]
|
||||||
|
review = record["复核"]
|
||||||
|
note_items = review_note_items(review)
|
||||||
|
hospital_days, hospital_days_note = clean_integer(patient["住院天数"])
|
||||||
|
if hospital_days_note:
|
||||||
|
note_items.append(hospital_days_note)
|
||||||
|
review_notes = ";".join(note_items)
|
||||||
|
return {
|
||||||
|
"batch_name": record["处理批次"],
|
||||||
|
"major_department": record["大科室"],
|
||||||
|
"sub_department": record["子科室"],
|
||||||
|
"source_folder": record["来源文件夹"],
|
||||||
|
"image_path": image["图片路径"],
|
||||||
|
"image_name": image["图片名"],
|
||||||
|
"image_row_no": image["图片内行号"],
|
||||||
|
"patient_name": patient["姓名"],
|
||||||
|
"gender": patient["性别"],
|
||||||
|
"age": patient["年龄"],
|
||||||
|
"inpatient_no": patient["住院号"],
|
||||||
|
"diagnosis": patient["诊断"],
|
||||||
|
"admission_time": patient["入院时间"],
|
||||||
|
"last_write_time": patient["最后书写时间"],
|
||||||
|
"hospital_days": hospital_days,
|
||||||
|
"discharge_time": patient["出院时间"],
|
||||||
|
"postoperative_days": patient["手术后天数"],
|
||||||
|
"review_status": review["状态"],
|
||||||
|
"review_notes": review_notes,
|
||||||
|
"manual_corrected": bool(review.get("人工修正")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_unique_rows(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str], list[str]]:
|
||||||
|
kept_by_no: dict[str, dict[str, Any]] = {}
|
||||||
|
kept_order: list[str] = []
|
||||||
|
replacements: list[str] = []
|
||||||
|
invalid: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
inpatient_no = str(row.get("inpatient_no", "")).strip()
|
||||||
|
location = f"{row['batch_name']} / {row['image_path']} / 第{row['image_row_no']}行"
|
||||||
|
if not inpatient_no:
|
||||||
|
invalid.append(f"{location}: 空")
|
||||||
|
continue
|
||||||
|
if inpatient_no not in kept_by_no:
|
||||||
|
kept_order.append(inpatient_no)
|
||||||
|
else:
|
||||||
|
previous = kept_by_no[inpatient_no]
|
||||||
|
replacements.append(
|
||||||
|
f"{inpatient_no}: {previous['batch_name']} / {previous['image_path']} / 第{previous['image_row_no']}行 -> {location}"
|
||||||
|
)
|
||||||
|
kept_by_no[inpatient_no] = row
|
||||||
|
return [kept_by_no[inpatient_no] for inpatient_no in kept_order], replacements, invalid
|
||||||
|
|
||||||
|
|
||||||
|
def load_rows(result_json: Path) -> tuple[list[dict[str, Any]], list[str], list[str]]:
|
||||||
|
merged = json.loads(result_json.read_text(encoding="utf-8"))
|
||||||
|
rows = [flatten_patient_record(record) for record in merged.get("患者记录", [])]
|
||||||
|
return prepare_unique_rows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def write_temp_csv(rows: list[dict[str, Any]]) -> Path:
|
||||||
|
temp_file = tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="",
|
||||||
|
suffix="_his_patient_lists.csv",
|
||||||
|
delete=False,
|
||||||
|
)
|
||||||
|
with temp_file:
|
||||||
|
writer = csv.DictWriter(temp_file, fieldnames=PATIENT_FIELDS)
|
||||||
|
writer.writeheader()
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow({field: scalar(row.get(field, "")) for field in PATIENT_FIELDS})
|
||||||
|
return Path(temp_file.name)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_to_postgres(args: argparse.Namespace, csv_path: Path, row_count: int) -> None:
|
||||||
|
sql = f"""\\set ON_ERROR_STOP on
|
||||||
|
\\i {psql_quote(Path(args.schema))}
|
||||||
|
TRUNCATE TABLE {TABLE_NAME} RESTART IDENTITY;
|
||||||
|
\\copy {TABLE_NAME}({','.join(PATIENT_FIELDS)}) FROM {psql_quote(csv_path)} WITH (FORMAT csv, HEADER true, NULL '')
|
||||||
|
"""
|
||||||
|
env = os.environ.copy()
|
||||||
|
password = args.password or env.get("HIS_DB_PASSWORD")
|
||||||
|
if password:
|
||||||
|
env["PGPASSWORD"] = password
|
||||||
|
command = [
|
||||||
|
"psql",
|
||||||
|
"-h",
|
||||||
|
args.host,
|
||||||
|
"-p",
|
||||||
|
str(args.port),
|
||||||
|
"-U",
|
||||||
|
args.user,
|
||||||
|
"-d",
|
||||||
|
args.dbname,
|
||||||
|
"-v",
|
||||||
|
"ON_ERROR_STOP=1",
|
||||||
|
]
|
||||||
|
completed = subprocess.run(command, input=sql, text=True, env=env, capture_output=True)
|
||||||
|
print(completed.stdout, end="")
|
||||||
|
if completed.returncode != 0:
|
||||||
|
print(completed.stderr, end="")
|
||||||
|
completed.check_returncode()
|
||||||
|
print(json.dumps({"synced_table": "Patient_Lists", "records": row_count}, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--input", default=str(DEFAULT_RESULT_JSON), help="合并后的患者记录 JSON")
|
||||||
|
parser.add_argument("--schema", default=str(DEFAULT_SCHEMA_SQL), help="单表建表 SQL")
|
||||||
|
parser.add_argument("--host", default=os.getenv("HIS_DB_HOST", "DB_HOST"))
|
||||||
|
parser.add_argument("--port", default=os.getenv("HIS_DB_PORT", "5432"))
|
||||||
|
parser.add_argument("--dbname", default=os.getenv("HIS_DB_NAME", "DB_NAME"))
|
||||||
|
parser.add_argument("--user", default=os.getenv("HIS_DB_USER", "DB_USER"))
|
||||||
|
parser.add_argument("--password", default="", help="数据库密码;建议改用 HIS_DB_PASSWORD 环境变量")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
rows, replacements, invalid = load_rows(Path(args.input))
|
||||||
|
csv_path = write_temp_csv(rows)
|
||||||
|
try:
|
||||||
|
sync_to_postgres(args, csv_path, len(rows))
|
||||||
|
if invalid:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"skipped_empty_inpatient_no": len(invalid),
|
||||||
|
"empty_examples": invalid[:20],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if replacements:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"deduplicated_by_inpatient_no": len(replacements),
|
||||||
|
"replacement_examples": replacements[:20],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
csv_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
84
患者列表处理/数据处理工作区/06_PostgreSQL建表结构.sql
Normal file
84
患者列表处理/数据处理工作区/06_PostgreSQL建表结构.sql
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
DROP TABLE IF EXISTS patient_records CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "Patient_Lists" (
|
||||||
|
record_id bigserial PRIMARY KEY,
|
||||||
|
batch_name text NOT NULL,
|
||||||
|
major_department text NOT NULL,
|
||||||
|
sub_department text NOT NULL,
|
||||||
|
source_folder text NOT NULL,
|
||||||
|
image_path text NOT NULL,
|
||||||
|
image_name text NOT NULL,
|
||||||
|
image_row_no integer NOT NULL,
|
||||||
|
patient_name text NOT NULL,
|
||||||
|
gender text,
|
||||||
|
age text,
|
||||||
|
inpatient_no text NOT NULL,
|
||||||
|
diagnosis text,
|
||||||
|
admission_time text,
|
||||||
|
last_write_time text,
|
||||||
|
hospital_days integer,
|
||||||
|
discharge_time text,
|
||||||
|
postoperative_days text,
|
||||||
|
review_status text NOT NULL,
|
||||||
|
review_notes text,
|
||||||
|
manual_corrected boolean NOT NULL DEFAULT false,
|
||||||
|
imported_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
audit_result text,
|
||||||
|
audit_ai_feedback text,
|
||||||
|
audit_manual_feedback text,
|
||||||
|
audit_machine_verdict text,
|
||||||
|
audit_source text,
|
||||||
|
audit_checked_by text,
|
||||||
|
audit_checked_at timestamptz,
|
||||||
|
CONSTRAINT uq_patient_lists_inpatient_no UNIQUE (inpatient_no),
|
||||||
|
CONSTRAINT ck_patient_lists_inpatient_no_present CHECK (btrim(inpatient_no) <> ''),
|
||||||
|
CONSTRAINT ck_patient_lists_admission_before_discharge CHECK (
|
||||||
|
COALESCE(discharge_time, '') = ''
|
||||||
|
OR COALESCE(admission_time, '') = ''
|
||||||
|
OR admission_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
|
||||||
|
OR discharge_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
|
||||||
|
OR admission_time <= discharge_time
|
||||||
|
OR review_status = '需人工复核'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_patient_lists_batch_name ON "Patient_Lists"(batch_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_patient_lists_department ON "Patient_Lists"(major_department, sub_department);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_patient_lists_source_folder ON "Patient_Lists"(source_folder);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_patient_lists_patient_name ON "Patient_Lists"(patient_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_patient_lists_review_status ON "Patient_Lists"(review_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_patient_lists_audit_result ON "Patient_Lists"(audit_result);
|
||||||
|
|
||||||
|
COMMENT ON TABLE "Patient_Lists" IS 'HIS患者列表图片OCR归档后的正式患者记录单表';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".record_id IS '患者记录主键,数据库自动生成';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".batch_name IS '处理批次名称,通常为原始图片集群文件夹名';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".major_department IS '归类后的大科室';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".sub_department IS '归类后的子科室';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".source_folder IS '原始第一层科室文件夹名';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".image_path IS '原始患者列表图片路径,用于定位数据来源';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".image_name IS '原始患者列表图片文件名';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".image_row_no IS '患者记录在原始图片内的行号';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".patient_name IS '患者姓名';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".gender IS '患者性别';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".age IS '患者年龄,保留原始岁数字符串';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".inpatient_no IS '住院号,不能为空且全库唯一;不强制校验格式';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".diagnosis IS '诊断,可能为空';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".admission_time IS '入院时间,按OCR/清洗后的文本保存';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".last_write_time IS '最后书写时间,按OCR/清洗后的文本保存';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".hospital_days IS '住院天数';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".discharge_time IS '出院时间,按OCR/清洗后的文本保存';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".postoperative_days IS '手术后天数,可能为空';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".review_status IS '自动复核或人工复核状态';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".review_notes IS '复核提示信息';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".manual_corrected IS '是否经过人工修正';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".imported_at IS '导入数据库的时间';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_result IS '抽查人工核验结果';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_ai_feedback IS '抽查项AI反馈原文';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_manual_feedback IS '抽查项人工反馈';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_machine_verdict IS '抽查机器核验判断';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_source IS '抽查来源';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_checked_by IS '抽查操作用户';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_checked_at IS '抽查结果保存时间';
|
||||||
|
COMMENT ON CONSTRAINT uq_patient_lists_inpatient_no ON "Patient_Lists" IS '保证同一个住院号只归档一条患者记录';
|
||||||
|
COMMENT ON CONSTRAINT ck_patient_lists_inpatient_no_present ON "Patient_Lists" IS '住院号不能为空;格式不做强制校验';
|
||||||
|
COMMENT ON CONSTRAINT ck_patient_lists_admission_before_discharge ON "Patient_Lists" IS '出院时间为空时放行;入院时间晚于出院时间时必须标记为需人工复核';
|
||||||
72
患者列表处理/数据处理工作区/07_处理程序说明.md
Normal file
72
患者列表处理/数据处理工作区/07_处理程序说明.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# 患者列表归档处理程序说明
|
||||||
|
|
||||||
|
## 工作区文件
|
||||||
|
|
||||||
|
1. `01_科室分类规则.json`:大科室、子科室、文件夹别名归类规则。
|
||||||
|
2. `02_患者列表OCR归档.py`:单个图片集群的 OCR、字段清洗、去重、复核报告生成程序。
|
||||||
|
3. `03_人工复核修正.json`:少量无法靠规则自动判断的人工修正项,可能包含患者信息,不提交到 Git。
|
||||||
|
4. `04_合并批次结果.py`:合并所有 `*-列表归档结果`,生成全局 JSON/CSV 和 `信息记录`。
|
||||||
|
5. `05_同步PostgreSQL单表.py`:把合并结果同步到 PostgreSQL 的 `"Patient_Lists"` 单表。
|
||||||
|
6. `06_PostgreSQL建表结构.sql`:数据库单表结构、索引、表注释、字段注释。
|
||||||
|
7. `07_处理程序说明.md`:当前说明。
|
||||||
|
8. `08_PostgreSQL调整Patient_Lists列顺序.sql`:保留数据的前提下,把现有 `"Patient_Lists"` 物理列顺序调整为当前字段顺序。
|
||||||
|
9. `09_PostgreSQL住院号非空唯一约束.sql`:删除住院号为空的记录,重复住院号保留最新一条,并让 PostgreSQL 强制住院号不能为空且唯一。
|
||||||
|
|
||||||
|
## 智能体处理顺序
|
||||||
|
|
||||||
|
1. 检查待处理批次目录和图片数量。
|
||||||
|
2. 必要时更新 `01_科室分类规则.json` 的别名。
|
||||||
|
3. 运行 `02_患者列表OCR归档.py` 处理一个批次。
|
||||||
|
4. 查看 `复核报告.json` 和 `重复住院号报告.json`。
|
||||||
|
5. 若有需人工复核项,优先补自动清洗规则;仍不能自动判断时,写入 `03_人工复核修正.json`。
|
||||||
|
6. 用 `--rebuild-from-cache` 重建该批次结果,确认复核项和重复住院号符合预期。
|
||||||
|
7. 运行 `04_合并批次结果.py` 更新全局 `信息记录`。
|
||||||
|
8. 运行 `05_同步PostgreSQL单表.py` 同步到 PostgreSQL。
|
||||||
|
9. 导入 PostgreSQL 后查询记录数、需复核记录数、重复住院号组数。
|
||||||
|
|
||||||
|
## 人工复核网页端
|
||||||
|
|
||||||
|
- 代码目录:`人工复核网页端/`。
|
||||||
|
- 启动方式:`cd 人工复核网页端 && docker compose up -d --build`。
|
||||||
|
- 登录配置从项目根目录 `.env` 读取:`REVIEW_APP_USERNAME`、`REVIEW_APP_PASSWORD`、`REVIEW_APP_SECRET_KEY`、`REVIEW_APP_PORT`。
|
||||||
|
- 网页端读取各批次 `复核报告.json`,按原始图片路径与图片内行号裁剪截图;住院号不能为空且唯一,格式是否规范交由人工复核时判断。
|
||||||
|
- 保存修订后写入 `数据处理工作区/03_人工复核修正.json`,并尽量同步 PostgreSQL;若 PostgreSQL 暂不可用,会保留待同步状态,之后可在网页端点击“提交待同步”。
|
||||||
|
- 出院时间默认允许为空;确有出院时间时,网页端和处理程序会校验时间格式以及是否早于入院时间。
|
||||||
|
- 页面包含概览、复核、抽查、抽查查看、设置区域;抽查功能可随机抽取患者行,调用 Kimi 多模态模型二次核验,并把 AI 反馈、机器判断、人工抽查结论写入 PostgreSQL。
|
||||||
|
|
||||||
|
## 识别策略
|
||||||
|
|
||||||
|
- OCR 接口:腾讯云 `RecognizeTableAccurateOCR` 表格识别 V3。
|
||||||
|
- 新批次拼接图默认给每张原图上下各加 `24px` 白色边界,减少贴边表格行被裁掉或和相邻图片粘连的概率。
|
||||||
|
- 正式处理优先用 6 张拼接;接口超时、失败或行数偏少时,程序会自动降到 4/3/2/单张。
|
||||||
|
- 行数校验:按图片高度推断应有行数,返回行数偏少即判定该拼接档位不可靠。
|
||||||
|
- 漏行不一定只发生在最上方或最下方两行;长拼接图中间也可能漏行,白边只能缓解贴边问题。
|
||||||
|
- 同一科室内会记忆稳定回退档位;跨科室不共享该记忆,避免重建历史缓存时误用。
|
||||||
|
- 只用缓存重建时,若前序回退策略需要的缓存不完整,会先尝试该组主缓存,避免因缓存形态不同造成少行。
|
||||||
|
- 字段顺序固定为:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数。
|
||||||
|
- 字段清洗包含日期规范化、住院号常见误识别修正、长诊断吞入入院时间、空时间字段导致列左移、`后XX天` 错位等。
|
||||||
|
|
||||||
|
## 住院号核验规则
|
||||||
|
|
||||||
|
- `住院号` 是唯一强制校验条件:不能为空且全库唯一。
|
||||||
|
- 单批次、全局合并和 PostgreSQL 同步都会剔除住院号为空的记录;重复住院号按后出现记录覆盖先出现记录;格式异常但非空的住院号保留。
|
||||||
|
- 网页端保存时优先按图片路径和图片内行号定位数据库记录;如果当前住院号已存在于另一条记录,会用当前修订覆盖既有住院号记录,并删除当前重复图片记录。
|
||||||
|
- 数据库约束允许 `出院时间` 为空;若入院时间和出院时间均为标准格式且入院时间晚于出院时间,该记录必须标记为 `需人工复核`。
|
||||||
|
|
||||||
|
## PostgreSQL 单表字段
|
||||||
|
|
||||||
|
`"Patient_Lists"` 只保留正式查看和追溯需要的字段:
|
||||||
|
|
||||||
|
- 数据定位:`batch_name`、`source_folder`、`image_path`、`image_name`、`image_row_no`
|
||||||
|
- 科室信息:`major_department`、`sub_department`
|
||||||
|
- 患者信息:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数
|
||||||
|
- 复核信息:`review_status`、`review_notes`、`manual_corrected`
|
||||||
|
- 抽查信息:`audit_result`、`audit_ai_feedback`、`audit_manual_feedback`、`audit_machine_verdict`、`audit_source`、`audit_checked_by`、`audit_checked_at`
|
||||||
|
|
||||||
|
复核状态含义:
|
||||||
|
|
||||||
|
- `自动复核通过`:OCR 结果经过程序规则校验后没有发现明显异常,`manual_corrected=false`。
|
||||||
|
- `人工复核通过`:该行命中了 `03_人工复核修正.json` 中的人工修正项,修正后校验通过,`manual_corrected=true`。
|
||||||
|
- `AI修改-待确认`:网页端 Kimi 辅助生成的草稿状态,不会被待同步流程写入 PostgreSQL,需人工保存确认。
|
||||||
|
|
||||||
|
OCR 请求号、OCR 缓存路径、拼接图路径、拼接组号等中间态字段不进入数据库正式表。
|
||||||
168
患者列表处理/数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql
Normal file
168
患者列表处理/数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
-- 保留现有数据,重建 "Patient_Lists" 的物理列顺序。
|
||||||
|
-- 只调整列顺序,不交换 last_write_time/discharge_time 的值。
|
||||||
|
-- 执行前建议先确认已备份数据库。
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
LOCK TABLE "Patient_Lists" IN ACCESS EXCLUSIVE MODE;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS "Patient_Lists__reordered";
|
||||||
|
|
||||||
|
CREATE TABLE "Patient_Lists__reordered" (
|
||||||
|
record_id bigint NOT NULL,
|
||||||
|
batch_name text NOT NULL,
|
||||||
|
major_department text NOT NULL,
|
||||||
|
sub_department text NOT NULL,
|
||||||
|
source_folder text NOT NULL,
|
||||||
|
image_path text NOT NULL,
|
||||||
|
image_name text NOT NULL,
|
||||||
|
image_row_no integer NOT NULL,
|
||||||
|
patient_name text NOT NULL,
|
||||||
|
gender text,
|
||||||
|
age text,
|
||||||
|
inpatient_no text NOT NULL,
|
||||||
|
diagnosis text,
|
||||||
|
admission_time text,
|
||||||
|
last_write_time text,
|
||||||
|
hospital_days integer,
|
||||||
|
discharge_time text,
|
||||||
|
postoperative_days text,
|
||||||
|
review_status text NOT NULL,
|
||||||
|
review_notes text,
|
||||||
|
manual_corrected boolean NOT NULL DEFAULT false,
|
||||||
|
imported_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
audit_result text,
|
||||||
|
audit_ai_feedback text,
|
||||||
|
audit_manual_feedback text,
|
||||||
|
audit_machine_verdict text,
|
||||||
|
audit_source text,
|
||||||
|
audit_checked_by text,
|
||||||
|
audit_checked_at timestamptz,
|
||||||
|
CONSTRAINT ck_patient_lists_inpatient_no_present CHECK (btrim(inpatient_no) <> ''),
|
||||||
|
CONSTRAINT ck_patient_lists_admission_before_discharge CHECK (
|
||||||
|
COALESCE(discharge_time, '') = ''
|
||||||
|
OR COALESCE(admission_time, '') = ''
|
||||||
|
OR admission_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
|
||||||
|
OR discharge_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
|
||||||
|
OR admission_time <= discharge_time
|
||||||
|
OR review_status = '需人工复核'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "Patient_Lists__reordered" (
|
||||||
|
record_id,
|
||||||
|
batch_name,
|
||||||
|
major_department,
|
||||||
|
sub_department,
|
||||||
|
source_folder,
|
||||||
|
image_path,
|
||||||
|
image_name,
|
||||||
|
image_row_no,
|
||||||
|
patient_name,
|
||||||
|
gender,
|
||||||
|
age,
|
||||||
|
inpatient_no,
|
||||||
|
diagnosis,
|
||||||
|
admission_time,
|
||||||
|
last_write_time,
|
||||||
|
hospital_days,
|
||||||
|
discharge_time,
|
||||||
|
postoperative_days,
|
||||||
|
review_status,
|
||||||
|
review_notes,
|
||||||
|
manual_corrected,
|
||||||
|
imported_at,
|
||||||
|
audit_result,
|
||||||
|
audit_ai_feedback,
|
||||||
|
audit_manual_feedback,
|
||||||
|
audit_machine_verdict,
|
||||||
|
audit_source,
|
||||||
|
audit_checked_by,
|
||||||
|
audit_checked_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
record_id,
|
||||||
|
batch_name,
|
||||||
|
major_department,
|
||||||
|
sub_department,
|
||||||
|
source_folder,
|
||||||
|
image_path,
|
||||||
|
image_name,
|
||||||
|
image_row_no,
|
||||||
|
patient_name,
|
||||||
|
gender,
|
||||||
|
age,
|
||||||
|
inpatient_no,
|
||||||
|
diagnosis,
|
||||||
|
admission_time,
|
||||||
|
last_write_time,
|
||||||
|
hospital_days,
|
||||||
|
discharge_time,
|
||||||
|
postoperative_days,
|
||||||
|
review_status,
|
||||||
|
review_notes,
|
||||||
|
manual_corrected,
|
||||||
|
imported_at,
|
||||||
|
audit_result,
|
||||||
|
audit_ai_feedback,
|
||||||
|
audit_manual_feedback,
|
||||||
|
audit_machine_verdict,
|
||||||
|
audit_source,
|
||||||
|
audit_checked_by,
|
||||||
|
audit_checked_at
|
||||||
|
FROM "Patient_Lists";
|
||||||
|
|
||||||
|
DROP TABLE "Patient_Lists";
|
||||||
|
ALTER TABLE "Patient_Lists__reordered" RENAME TO "Patient_Lists";
|
||||||
|
|
||||||
|
DROP SEQUENCE IF EXISTS "Patient_Lists_record_id_seq";
|
||||||
|
CREATE SEQUENCE "Patient_Lists_record_id_seq";
|
||||||
|
ALTER SEQUENCE "Patient_Lists_record_id_seq" OWNED BY "Patient_Lists".record_id;
|
||||||
|
ALTER TABLE "Patient_Lists" ALTER COLUMN record_id SET DEFAULT nextval('"Patient_Lists_record_id_seq"'::regclass);
|
||||||
|
SELECT setval('"Patient_Lists_record_id_seq"', COALESCE((SELECT max(record_id) FROM "Patient_Lists"), 1), true);
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_Lists" ADD CONSTRAINT "Patient_Lists_pkey" PRIMARY KEY (record_id);
|
||||||
|
ALTER TABLE "Patient_Lists" ADD CONSTRAINT uq_patient_lists_inpatient_no UNIQUE (inpatient_no);
|
||||||
|
|
||||||
|
CREATE INDEX idx_patient_lists_batch_name ON "Patient_Lists"(batch_name);
|
||||||
|
CREATE INDEX idx_patient_lists_department ON "Patient_Lists"(major_department, sub_department);
|
||||||
|
CREATE INDEX idx_patient_lists_source_folder ON "Patient_Lists"(source_folder);
|
||||||
|
CREATE INDEX idx_patient_lists_patient_name ON "Patient_Lists"(patient_name);
|
||||||
|
CREATE INDEX idx_patient_lists_review_status ON "Patient_Lists"(review_status);
|
||||||
|
CREATE INDEX idx_patient_lists_audit_result ON "Patient_Lists"(audit_result);
|
||||||
|
|
||||||
|
COMMENT ON TABLE "Patient_Lists" IS 'HIS患者列表图片OCR归档后的正式患者记录单表';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".record_id IS '患者记录主键,数据库自动生成';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".batch_name IS '处理批次名称,通常为原始图片集群文件夹名';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".major_department IS '归类后的大科室';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".sub_department IS '归类后的子科室';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".source_folder IS '原始第一层科室文件夹名';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".image_path IS '原始患者列表图片路径,用于定位数据来源';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".image_name IS '原始患者列表图片文件名';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".image_row_no IS '患者记录在原始图片内的行号';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".patient_name IS '患者姓名';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".gender IS '患者性别';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".age IS '患者年龄,保留原始岁数字符串';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".inpatient_no IS '住院号,不能为空且全库唯一;不强制校验格式';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".diagnosis IS '诊断,可能为空';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".admission_time IS '入院时间,按OCR/清洗后的文本保存';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".last_write_time IS '最后书写时间,按OCR/清洗后的文本保存';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".hospital_days IS '住院天数';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".discharge_time IS '出院时间,按OCR/清洗后的文本保存';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".postoperative_days IS '手术后天数,可能为空,格式通常为“后X天”';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".review_status IS '自动复核或人工复核状态';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".review_notes IS '复核提示信息';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".manual_corrected IS '是否经过人工修正';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".imported_at IS '导入数据库或人工同步更新的时间';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_result IS '抽查人工核验结果';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_ai_feedback IS '抽查项AI反馈原文';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_manual_feedback IS '抽查项人工反馈';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_machine_verdict IS '抽查机器核验判断';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_source IS '抽查来源';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_checked_by IS '抽查操作用户';
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".audit_checked_at IS '抽查结果保存时间';
|
||||||
|
COMMENT ON CONSTRAINT uq_patient_lists_inpatient_no ON "Patient_Lists" IS '保证同一个住院号只归档一条患者记录';
|
||||||
|
COMMENT ON CONSTRAINT ck_patient_lists_inpatient_no_present ON "Patient_Lists" IS '住院号不能为空;格式不做强制校验';
|
||||||
|
COMMENT ON CONSTRAINT ck_patient_lists_admission_before_discharge ON "Patient_Lists" IS '出院时间为空时放行;入院时间晚于出院时间时必须标记为需人工复核';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
102
患者列表处理/数据处理工作区/09_PostgreSQL住院号非空唯一约束.sql
Normal file
102
患者列表处理/数据处理工作区/09_PostgreSQL住院号非空唯一约束.sql
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
-- 住院号为唯一定位条件:数据库强制“不能为空且唯一”。
|
||||||
|
-- 不校验 ZY 位数格式;空住院号记录会先删除,重复住院号保留 id 最大/最新的一条。
|
||||||
|
|
||||||
|
DELETE FROM "Patient_Lists"
|
||||||
|
WHERE inpatient_no IS NULL OR btrim(inpatient_no) = '';
|
||||||
|
|
||||||
|
DELETE FROM "Patient_Lists" p
|
||||||
|
USING (
|
||||||
|
SELECT record_id
|
||||||
|
FROM (
|
||||||
|
SELECT record_id, row_number() OVER (PARTITION BY inpatient_no ORDER BY record_id DESC) AS rn
|
||||||
|
FROM "Patient_Lists"
|
||||||
|
) ranked
|
||||||
|
WHERE rn > 1
|
||||||
|
) duplicate_rows
|
||||||
|
WHERE p.record_id = duplicate_rows.record_id;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_Lists"
|
||||||
|
DROP CONSTRAINT IF EXISTS ck_patient_lists_inpatient_no_format;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_Lists"
|
||||||
|
DROP CONSTRAINT IF EXISTS uq_patient_lists_inpatient_no;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS uq_patient_lists_inpatient_no;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_Lists"
|
||||||
|
DROP CONSTRAINT IF EXISTS ck_patient_lists_inpatient_no_present;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_Lists"
|
||||||
|
DROP CONSTRAINT IF EXISTS ck_patient_lists_inpatient_no_required;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_Lists"
|
||||||
|
ALTER COLUMN inpatient_no SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_Lists"
|
||||||
|
ADD CONSTRAINT ck_patient_lists_inpatient_no_present
|
||||||
|
CHECK (btrim(inpatient_no) <> '');
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_Lists"
|
||||||
|
ADD CONSTRAINT uq_patient_lists_inpatient_no UNIQUE (inpatient_no);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN "Patient_Lists".inpatient_no
|
||||||
|
IS '住院号,不能为空且全库唯一;不强制校验格式';
|
||||||
|
|
||||||
|
COMMENT ON CONSTRAINT ck_patient_lists_inpatient_no_present ON "Patient_Lists"
|
||||||
|
IS '住院号不能为空;格式不做强制校验';
|
||||||
|
|
||||||
|
COMMENT ON CONSTRAINT uq_patient_lists_inpatient_no ON "Patient_Lists"
|
||||||
|
IS '保证同一个住院号只归档一条患者记录';
|
||||||
|
|
||||||
|
DELETE FROM "Patient_FrontPages"
|
||||||
|
WHERE inpatient_no IS NULL OR btrim(inpatient_no) = '';
|
||||||
|
|
||||||
|
DELETE FROM "Patient_FrontPages" p
|
||||||
|
USING (
|
||||||
|
SELECT id
|
||||||
|
FROM (
|
||||||
|
SELECT id, row_number() OVER (PARTITION BY inpatient_no ORDER BY id DESC) AS rn
|
||||||
|
FROM "Patient_FrontPages"
|
||||||
|
) ranked
|
||||||
|
WHERE rn > 1
|
||||||
|
) duplicate_rows
|
||||||
|
WHERE p.id = duplicate_rows.id;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_FrontPages"
|
||||||
|
DROP CONSTRAINT IF EXISTS "ck_Patient_FrontPages_inpatient_no_format";
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_FrontPages"
|
||||||
|
DROP CONSTRAINT IF EXISTS ck_patient_frontpages_inpatient_no_format;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_FrontPages"
|
||||||
|
DROP CONSTRAINT IF EXISTS uq_patient_frontpages_inpatient_no;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS uq_patient_frontpages_inpatient_no;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_FrontPages"
|
||||||
|
DROP CONSTRAINT IF EXISTS ck_patient_frontpages_inpatient_no_present;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_FrontPages"
|
||||||
|
DROP CONSTRAINT IF EXISTS "ck_Patient_FrontPages_inpatient_no_required";
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_FrontPages"
|
||||||
|
DROP CONSTRAINT IF EXISTS ck_patient_frontpages_inpatient_no_required;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_FrontPages"
|
||||||
|
ALTER COLUMN inpatient_no SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_FrontPages"
|
||||||
|
ADD CONSTRAINT ck_patient_frontpages_inpatient_no_present
|
||||||
|
CHECK (btrim(inpatient_no) <> '');
|
||||||
|
|
||||||
|
ALTER TABLE "Patient_FrontPages"
|
||||||
|
ADD CONSTRAINT uq_patient_frontpages_inpatient_no UNIQUE (inpatient_no);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN "Patient_FrontPages".inpatient_no
|
||||||
|
IS '住院号,不能为空且全库唯一;不强制校验格式';
|
||||||
|
|
||||||
|
COMMENT ON CONSTRAINT ck_patient_frontpages_inpatient_no_present ON "Patient_FrontPages"
|
||||||
|
IS '住院号不能为空;格式不做强制校验';
|
||||||
|
|
||||||
|
COMMENT ON CONSTRAINT uq_patient_frontpages_inpatient_no ON "Patient_FrontPages"
|
||||||
|
IS '保证同一个住院号只保留一条首页记录';
|
||||||
22
患者首页处理/.gitignore
vendored
Normal file
22
患者首页处理/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 原始 PDF 与处理结果不进入 Gitea
|
||||||
|
待处理-患者首页PDF/
|
||||||
|
已处理-患者首页PDF/
|
||||||
|
数据处理结果区/
|
||||||
|
|
||||||
|
# 旧参考程序不作为本次工作流代码提交
|
||||||
|
参考-过去很烂的程序/
|
||||||
|
参考-病案首页图片/
|
||||||
|
|
||||||
|
# 本地工作流不进入 Gitea
|
||||||
|
工作流_本地使用版.md
|
||||||
|
工作流_本地使用版*.zip
|
||||||
|
|
||||||
|
# 本地配置、缓存和临时文件
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
数据可视化网页端/review_settings.local.json
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.tmp
|
||||||
|
*.log
|
||||||
259
患者首页处理/README.md
Normal file
259
患者首页处理/README.md
Normal 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 继续补规则。
|
||||||
306
患者首页处理/工作流_Gitea版.md
Normal file
306
患者首页处理/工作流_Gitea版.md
Normal 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。
|
||||||
4
患者首页处理/数据可视化网页端/.dockerignore
Normal file
4
患者首页处理/数据可视化网页端/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
17
患者首页处理/数据可视化网页端/.env.example
Normal file
17
患者首页处理/数据可视化网页端/.env.example
Normal 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
|
||||||
19
患者首页处理/数据可视化网页端/Dockerfile
Normal file
19
患者首页处理/数据可视化网页端/Dockerfile
Normal 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}"]
|
||||||
58
患者首页处理/数据可视化网页端/README.md
Normal file
58
患者首页处理/数据可视化网页端/README.md
Normal 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`,不会由网页端自动上传。
|
||||||
1
患者首页处理/数据可视化网页端/app/__init__.py
Normal file
1
患者首页处理/数据可视化网页端/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Patient front page visual review web app."""
|
||||||
2779
患者首页处理/数据可视化网页端/app/main.py
Normal file
2779
患者首页处理/数据可视化网页端/app/main.py
Normal file
File diff suppressed because it is too large
Load Diff
1393
患者首页处理/数据可视化网页端/app/static/app.js
Normal file
1393
患者首页处理/数据可视化网页端/app/static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
273
患者首页处理/数据可视化网页端/app/static/index.html
Normal file
273
患者首页处理/数据可视化网页端/app/static/index.html
Normal 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>
|
||||||
1193
患者首页处理/数据可视化网页端/app/static/styles.css
Normal file
1193
患者首页处理/数据可视化网页端/app/static/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
20
患者首页处理/数据可视化网页端/docker-compose.yml
Normal file
20
患者首页处理/数据可视化网页端/docker-compose.yml
Normal 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:
|
||||||
245
患者首页处理/数据处理工作区/01_配置规则/01_科室分类规则.json
Normal file
245
患者首页处理/数据处理工作区/01_配置规则/01_科室分类规则.json
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{
|
||||||
|
"说明": "每个具体子科室只归属一个大科室;aliases 用于把图片文件夹名归一到标准子科室名。",
|
||||||
|
"大科室列表": [
|
||||||
|
{
|
||||||
|
"大科室": "肝胆外科及肝移植相关",
|
||||||
|
"子科室": [
|
||||||
|
"肝胆外科1",
|
||||||
|
"肝胆外科2",
|
||||||
|
"肝胆外科3",
|
||||||
|
"肝胆外科4",
|
||||||
|
"肝胆外科5D",
|
||||||
|
"肝胆外科手术室",
|
||||||
|
"肝胆特种病区L",
|
||||||
|
"肝移植内",
|
||||||
|
"肝移植内N"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "普通外科及腹部外科",
|
||||||
|
"子科室": [
|
||||||
|
"普外科1",
|
||||||
|
"普外科2",
|
||||||
|
"普外科3",
|
||||||
|
"普外科4D",
|
||||||
|
"腹部外科L",
|
||||||
|
"普通腺体外科"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "急诊医学科",
|
||||||
|
"子科室": [
|
||||||
|
"急诊中心",
|
||||||
|
"急诊中心L",
|
||||||
|
"急诊重症医学科L"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "重症医学科",
|
||||||
|
"子科室": [
|
||||||
|
"※重症医学1病区",
|
||||||
|
"重症医学2N病区",
|
||||||
|
"重症医学3病区",
|
||||||
|
"重症医学4D病区",
|
||||||
|
"外科ICU",
|
||||||
|
"肝胆ICU",
|
||||||
|
"肿瘤外科重症",
|
||||||
|
"感染重症"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "骨科",
|
||||||
|
"子科室": [
|
||||||
|
"骨科",
|
||||||
|
"骨科1",
|
||||||
|
"骨科2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "泌尿外科",
|
||||||
|
"子科室": [
|
||||||
|
"泌尿外1",
|
||||||
|
"泌尿外2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "呼吸内科",
|
||||||
|
"子科室": [
|
||||||
|
"呼吸内科3病区"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "耳鼻喉头颈外科",
|
||||||
|
"子科室": [
|
||||||
|
"耳鼻喉D",
|
||||||
|
"耳鼻喉头颈外科",
|
||||||
|
"耳鼻喉头颈外科L",
|
||||||
|
"耳鼻喉2病区"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "日间诊疗中心",
|
||||||
|
"子科室": [
|
||||||
|
"日间手术中心",
|
||||||
|
"日间中心病房L"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "乳腺外科",
|
||||||
|
"子科室": [
|
||||||
|
"乳腺外科1N",
|
||||||
|
"乳腺外科2N",
|
||||||
|
"乳腺外科2D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "胸外科",
|
||||||
|
"子科室": [
|
||||||
|
"胸外1",
|
||||||
|
"胸外2",
|
||||||
|
"胸外3",
|
||||||
|
"胸外4D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "肝病内科",
|
||||||
|
"子科室": [
|
||||||
|
"肝病内科"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "感染科",
|
||||||
|
"子科室": [
|
||||||
|
"感染1",
|
||||||
|
"感染2",
|
||||||
|
"感染3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "肿瘤放疗科",
|
||||||
|
"子科室": [
|
||||||
|
"肿瘤放疗1",
|
||||||
|
"肿瘤放疗2",
|
||||||
|
"肿瘤放疗3",
|
||||||
|
"肿瘤放疗病区L",
|
||||||
|
"肿瘤放疗中心L【无人】",
|
||||||
|
"肿瘤放疗日间【无人】"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "特需/涉外病房",
|
||||||
|
"子科室": [
|
||||||
|
"健苑五涉外病房"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"大科室": "老年外科",
|
||||||
|
"子科室": [
|
||||||
|
"老年外科"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aliases": {
|
||||||
|
"呼吸内科3": "呼吸内科3病区",
|
||||||
|
"外科ICU": "外科ICU",
|
||||||
|
"急诊中心病房": "急诊中心",
|
||||||
|
"急诊中心": "急诊中心",
|
||||||
|
"急诊中心病房L": "急诊中心L",
|
||||||
|
"急诊中心L": "急诊中心L",
|
||||||
|
"急诊重症医学L": "急诊重症医学科L",
|
||||||
|
"急诊重症L": "急诊重症医学科L",
|
||||||
|
"急诊重症医学科L": "急诊重症医学科L",
|
||||||
|
"感染科1": "感染1",
|
||||||
|
"感染科2": "感染2",
|
||||||
|
"感染科3": "感染3",
|
||||||
|
"感染1": "感染1",
|
||||||
|
"感染2": "感染2",
|
||||||
|
"感染3": "感染3",
|
||||||
|
"感染科重症": "感染重症",
|
||||||
|
"感染重症": "感染重症",
|
||||||
|
"肝病内科": "肝病内科",
|
||||||
|
"肝病内科N": "肝病内科",
|
||||||
|
"肝胆外科1": "肝胆外科1",
|
||||||
|
"肝胆外科2": "肝胆外科2",
|
||||||
|
"肝胆外科3": "肝胆外科3",
|
||||||
|
"肝胆外科4": "肝胆外科4",
|
||||||
|
"肝胆1": "肝胆外科1",
|
||||||
|
"肝胆2": "肝胆外科2",
|
||||||
|
"肝胆3": "肝胆外科3",
|
||||||
|
"肝胆4": "肝胆外科4",
|
||||||
|
"肝胆5D": "肝胆外科5D",
|
||||||
|
"肝胆外科5病房D": "肝胆外科5D",
|
||||||
|
"肝胆特种L": "肝胆特种病区L",
|
||||||
|
"肝胆特种病区L": "肝胆特种病区L",
|
||||||
|
"肝胆移植内": "肝移植内",
|
||||||
|
"肝移植内": "肝移植内",
|
||||||
|
"肝移植内科": "肝移植内",
|
||||||
|
"肝移植内N": "肝移植内N",
|
||||||
|
"肝移植内科N": "肝移植内N",
|
||||||
|
"肿瘤放疗3": "肿瘤放疗3",
|
||||||
|
"肿放3": "肿瘤放疗3",
|
||||||
|
"肿瘤放疗L": "肿瘤放疗病区L",
|
||||||
|
"肿放L": "肿瘤放疗病区L",
|
||||||
|
"重症医学1": "※重症医学1病区",
|
||||||
|
"重症医学科1": "※重症医学1病区",
|
||||||
|
"重症1": "※重症医学1病区",
|
||||||
|
"重症医学2": "重症医学2N病区",
|
||||||
|
"重症医学科2": "重症医学2N病区",
|
||||||
|
"重症2": "重症医学2N病区",
|
||||||
|
"重症2N": "重症医学2N病区",
|
||||||
|
"重症医学3": "重症医学3病区",
|
||||||
|
"重症医学科3": "重症医学3病区",
|
||||||
|
"重症3": "重症医学3病区",
|
||||||
|
"重症医学4": "重症医学4D病区",
|
||||||
|
"重症医学科4": "重症医学4D病区",
|
||||||
|
"重症4": "重症医学4D病区",
|
||||||
|
"重症4D": "重症医学4D病区",
|
||||||
|
"胸外1": "胸外1",
|
||||||
|
"胸外2": "胸外2",
|
||||||
|
"胸外3": "胸外3",
|
||||||
|
"胸外4D": "胸外4D",
|
||||||
|
"普外1": "普外科1",
|
||||||
|
"普外2": "普外科2",
|
||||||
|
"普外3": "普外科3",
|
||||||
|
"普外4D": "普外科4D",
|
||||||
|
"普通外科1": "普外科1",
|
||||||
|
"普通外科2": "普外科2",
|
||||||
|
"普通外科3": "普外科3",
|
||||||
|
"普通外科4": "普外科4D",
|
||||||
|
"普通外科4D": "普外科4D",
|
||||||
|
"普通腺体外科": "普通腺体外科",
|
||||||
|
"腹部外科L": "腹部外科L",
|
||||||
|
"肝胆ICU": "肝胆ICU",
|
||||||
|
"日间手术": "日间手术中心",
|
||||||
|
"日间手术中心": "日间手术中心",
|
||||||
|
"日间手术L": "日间中心病房L",
|
||||||
|
"日间中心病房L": "日间中心病房L",
|
||||||
|
"耳鼻喉": "耳鼻喉头颈外科",
|
||||||
|
"耳鼻喉D": "耳鼻喉D",
|
||||||
|
"耳鼻喉L": "耳鼻喉头颈外科L",
|
||||||
|
"耳鼻喉头颈外科": "耳鼻喉头颈外科",
|
||||||
|
"耳鼻喉头颈外科L": "耳鼻喉头颈外科L",
|
||||||
|
"耳鼻咽喉": "耳鼻喉头颈外科",
|
||||||
|
"耳鼻咽喉头颈外科": "耳鼻喉头颈外科",
|
||||||
|
"耳鼻喉2": "耳鼻喉2病区",
|
||||||
|
"耳鼻喉2D": "耳鼻喉2病区",
|
||||||
|
"耳鼻喉头颈2": "耳鼻喉2病区",
|
||||||
|
"乳腺外科": "乳腺外科1N",
|
||||||
|
"乳腺外科1": "乳腺外科1N",
|
||||||
|
"乳腺外科1N": "乳腺外科1N",
|
||||||
|
"乳腺外科2": "乳腺外科2N",
|
||||||
|
"乳腺外科2N": "乳腺外科2N",
|
||||||
|
"乳腺外科2D": "乳腺外科2D",
|
||||||
|
"乳腺1N": "乳腺外科1N",
|
||||||
|
"乳腺2N": "乳腺外科2N",
|
||||||
|
"乳腺2D": "乳腺外科2D",
|
||||||
|
"骨科": "骨科",
|
||||||
|
"骨科1": "骨科1",
|
||||||
|
"骨科2": "骨科2",
|
||||||
|
"泌尿1": "泌尿外1",
|
||||||
|
"泌尿2": "泌尿外2",
|
||||||
|
"健苑五涉外": "健苑五涉外病房",
|
||||||
|
"健苑五涉外病房": "健苑五涉外病房",
|
||||||
|
"老年外科": "老年外科"
|
||||||
|
}
|
||||||
|
}
|
||||||
2181
患者首页处理/数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py
Executable file
2181
患者首页处理/数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py
Executable file
File diff suppressed because it is too large
Load Diff
224
患者首页处理/数据处理工作区/03_人工复核/03_人工复核导出与回写.py
Executable file
224
患者首页处理/数据处理工作区/03_人工复核/03_人工复核导出与回写.py
Executable file
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
从 PostgreSQL 导出患者首页复核表,并把人工复核结果回写到 Patient_FrontPages。
|
||||||
|
|
||||||
|
依赖系统命令 psql;数据库密码通过 PGPASSWORD 环境变量或 --pg-password 传入。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_OUTPUT = PROJECT_ROOT / "数据处理结果区" / "04_复核与人工校正" / "患者首页_人工复核表.csv"
|
||||||
|
DEFAULT_TABLE = "Patient_FrontPages"
|
||||||
|
|
||||||
|
|
||||||
|
def quote_pg_identifier(identifier: str) -> str:
|
||||||
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", identifier):
|
||||||
|
raise ValueError(f"非法 PostgreSQL 表名:{identifier}")
|
||||||
|
return '"' + identifier.replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def connection_env(args: argparse.Namespace) -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update(
|
||||||
|
{
|
||||||
|
"PGHOST": args.pg_host or os.environ.get("PGHOST", ""),
|
||||||
|
"PGPORT": str(args.pg_port or os.environ.get("PGPORT", "")),
|
||||||
|
"PGDATABASE": args.pg_database or os.environ.get("PGDATABASE", ""),
|
||||||
|
"PGUSER": args.pg_user or os.environ.get("PGUSER", ""),
|
||||||
|
"PGPASSWORD": args.pg_password or os.environ.get("PGPASSWORD", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
missing = [name for name in ["PGHOST", "PGPORT", "PGDATABASE", "PGUSER", "PGPASSWORD"] if not env.get(name)]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError("缺少 PostgreSQL 连接配置:" + "、".join(missing))
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def run_psql(sql: str, args: argparse.Namespace) -> None:
|
||||||
|
if shutil.which("psql") is None:
|
||||||
|
raise RuntimeError("未找到 psql。请先安装 PostgreSQL 客户端。")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="patient_front_review_") as tmpdir:
|
||||||
|
sql_path = Path(tmpdir) / "review.sql"
|
||||||
|
sql_path.write_text(sql, encoding="utf-8")
|
||||||
|
completed = subprocess.run(
|
||||||
|
["psql", "--no-password", "--file", str(sql_path)],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
env=connection_env(args),
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise RuntimeError(completed.stderr.strip() or completed.stdout.strip() or "psql 执行失败")
|
||||||
|
if completed.stdout.strip():
|
||||||
|
print(completed.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def export_review(args: argparse.Namespace) -> None:
|
||||||
|
output = args.output.resolve()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
escaped_output = str(output).replace("'", "''")
|
||||||
|
table = quote_pg_identifier(args.pg_table)
|
||||||
|
where_sql = "" if args.all else "WHERE review_status <> 'auto_pass' OR manual_corrected IS TRUE"
|
||||||
|
select_sql = (
|
||||||
|
"SELECT inpatient_no, source_file, medical_record_no, front_page_medical_record_no, patient_name, "
|
||||||
|
"gender, birth_date, discharge_dept, major_department, primary_diagnosis, "
|
||||||
|
"primary_diagnosis_code, total_cost, self_pay_amount, review_status, "
|
||||||
|
"review_notes::text AS review_notes, auto_corrections::text AS auto_corrections, "
|
||||||
|
"manual_corrected, contact_address, contact_phone, discharge_diagnoses::text AS discharge_diagnoses, "
|
||||||
|
"operations::text AS operations, pathology_diagnosis, pathology_diagnosis_code, pathology_no, "
|
||||||
|
"discharge_disposition_code, readmission_plan_code, quality_status, quality_notes::text AS quality_notes, "
|
||||||
|
"'' AS manual_review_notes, '' AS corrected_medical_record_no, "
|
||||||
|
"'' AS corrected_patient_name, '' AS corrected_major_department, "
|
||||||
|
"'' AS corrected_primary_diagnosis, '' AS corrected_primary_diagnosis_code, "
|
||||||
|
"'' AS corrected_total_cost, '' AS corrected_self_pay_amount, '' AS mark_review_status "
|
||||||
|
f"FROM {table} {where_sql} ORDER BY source_file"
|
||||||
|
)
|
||||||
|
sql = f"\\set ON_ERROR_STOP on\n\\copy ({select_sql}) TO '{escaped_output}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')\n"
|
||||||
|
run_psql(sql, args)
|
||||||
|
print(f"已导出复核表:{output}")
|
||||||
|
|
||||||
|
|
||||||
|
def import_review(args: argparse.Namespace) -> None:
|
||||||
|
input_path = args.input.resolve()
|
||||||
|
if not input_path.exists():
|
||||||
|
raise RuntimeError(f"复核表不存在:{input_path}")
|
||||||
|
escaped_input = str(input_path).replace("'", "''")
|
||||||
|
table = quote_pg_identifier(args.pg_table)
|
||||||
|
sql = f"""
|
||||||
|
\\set ON_ERROR_STOP on
|
||||||
|
CREATE TEMP TABLE patient_front_review_import (
|
||||||
|
inpatient_no TEXT,
|
||||||
|
source_file TEXT,
|
||||||
|
medical_record_no TEXT,
|
||||||
|
front_page_medical_record_no TEXT,
|
||||||
|
patient_name TEXT,
|
||||||
|
gender TEXT,
|
||||||
|
birth_date TEXT,
|
||||||
|
discharge_dept TEXT,
|
||||||
|
major_department TEXT,
|
||||||
|
primary_diagnosis TEXT,
|
||||||
|
primary_diagnosis_code TEXT,
|
||||||
|
total_cost TEXT,
|
||||||
|
self_pay_amount TEXT,
|
||||||
|
review_status TEXT,
|
||||||
|
review_notes TEXT,
|
||||||
|
auto_corrections TEXT,
|
||||||
|
manual_corrected TEXT,
|
||||||
|
contact_address TEXT,
|
||||||
|
contact_phone TEXT,
|
||||||
|
discharge_diagnoses TEXT,
|
||||||
|
operations TEXT,
|
||||||
|
pathology_diagnosis TEXT,
|
||||||
|
pathology_diagnosis_code TEXT,
|
||||||
|
pathology_no TEXT,
|
||||||
|
discharge_disposition_code TEXT,
|
||||||
|
readmission_plan_code TEXT,
|
||||||
|
quality_status TEXT,
|
||||||
|
quality_notes TEXT,
|
||||||
|
manual_review_notes TEXT,
|
||||||
|
corrected_medical_record_no TEXT,
|
||||||
|
corrected_patient_name TEXT,
|
||||||
|
corrected_major_department TEXT,
|
||||||
|
corrected_primary_diagnosis TEXT,
|
||||||
|
corrected_primary_diagnosis_code TEXT,
|
||||||
|
corrected_total_cost TEXT,
|
||||||
|
corrected_self_pay_amount TEXT,
|
||||||
|
mark_review_status TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
\\copy patient_front_review_import FROM '{escaped_input}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
|
||||||
|
|
||||||
|
UPDATE {table} AS t
|
||||||
|
SET
|
||||||
|
medical_record_no = COALESCE(NULLIF(i.corrected_medical_record_no, ''), t.medical_record_no),
|
||||||
|
patient_name = COALESCE(NULLIF(i.corrected_patient_name, ''), t.patient_name),
|
||||||
|
major_department = COALESCE(NULLIF(i.corrected_major_department, ''), t.major_department),
|
||||||
|
primary_diagnosis = COALESCE(NULLIF(i.corrected_primary_diagnosis, ''), t.primary_diagnosis),
|
||||||
|
primary_diagnosis_code = COALESCE(NULLIF(i.corrected_primary_diagnosis_code, ''), t.primary_diagnosis_code),
|
||||||
|
total_cost = COALESCE(NULLIF(i.corrected_total_cost, '')::numeric, t.total_cost),
|
||||||
|
self_pay_amount = COALESCE(NULLIF(i.corrected_self_pay_amount, '')::numeric, t.self_pay_amount),
|
||||||
|
review_status = COALESCE(NULLIF(i.mark_review_status, ''), 'reviewed'),
|
||||||
|
review_notes = CASE
|
||||||
|
WHEN NULLIF(i.manual_review_notes, '') IS NULL THEN t.review_notes
|
||||||
|
ELSE t.review_notes || jsonb_build_array(i.manual_review_notes)
|
||||||
|
END,
|
||||||
|
manual_corrected = CASE
|
||||||
|
WHEN NULLIF(i.corrected_medical_record_no, '') IS NOT NULL
|
||||||
|
OR NULLIF(i.corrected_patient_name, '') IS NOT NULL
|
||||||
|
OR NULLIF(i.corrected_major_department, '') IS NOT NULL
|
||||||
|
OR NULLIF(i.corrected_primary_diagnosis, '') IS NOT NULL
|
||||||
|
OR NULLIF(i.corrected_primary_diagnosis_code, '') IS NOT NULL
|
||||||
|
OR NULLIF(i.corrected_total_cost, '') IS NOT NULL
|
||||||
|
OR NULLIF(i.corrected_self_pay_amount, '') IS NOT NULL
|
||||||
|
OR lower(COALESCE(i.manual_corrected, '')) IN ('true', 't', '1', 'yes', 'y')
|
||||||
|
THEN true
|
||||||
|
ELSE t.manual_corrected
|
||||||
|
END
|
||||||
|
FROM patient_front_review_import AS i
|
||||||
|
WHERE (
|
||||||
|
NULLIF(i.inpatient_no, '') IS NOT NULL
|
||||||
|
AND t.inpatient_no = i.inpatient_no
|
||||||
|
) OR (
|
||||||
|
NULLIF(i.inpatient_no, '') IS NULL
|
||||||
|
AND t.source_file = i.source_file
|
||||||
|
);
|
||||||
|
""".strip()
|
||||||
|
run_psql(sql, args)
|
||||||
|
print(f"已回写人工复核表:{input_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="导出/回写患者首页 PostgreSQL 人工复核表。")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
def add_pg_options(subparser: argparse.ArgumentParser) -> None:
|
||||||
|
subparser.add_argument("--pg-host", default=os.environ.get("PGHOST", ""), help="PostgreSQL 主机;也可用 PGHOST")
|
||||||
|
subparser.add_argument("--pg-port", default=os.environ.get("PGPORT", "5432"), help="PostgreSQL 端口;也可用 PGPORT")
|
||||||
|
subparser.add_argument("--pg-database", default=os.environ.get("PGDATABASE", ""), help="PostgreSQL 数据库;也可用 PGDATABASE")
|
||||||
|
subparser.add_argument("--pg-user", default=os.environ.get("PGUSER", ""), help="PostgreSQL 用户名;也可用 PGUSER")
|
||||||
|
subparser.add_argument("--pg-password", default=os.environ.get("PGPASSWORD", ""), help="PostgreSQL 密码;也可用 PGPASSWORD")
|
||||||
|
subparser.add_argument("--pg-table", default=os.environ.get("PG_PATIENT_TABLE", DEFAULT_TABLE), help="PostgreSQL 表名")
|
||||||
|
|
||||||
|
export_parser = subparsers.add_parser("export", help="从 PostgreSQL 导出待复核 CSV")
|
||||||
|
add_pg_options(export_parser)
|
||||||
|
export_parser.add_argument("-o", "--output", type=Path, default=DEFAULT_OUTPUT, help="复核 CSV 输出路径")
|
||||||
|
export_parser.add_argument("--all", action="store_true", help="导出全部记录,而不仅是需复核/已人工修正记录")
|
||||||
|
|
||||||
|
import_parser = subparsers.add_parser("import", help="把人工复核 CSV 回写 PostgreSQL")
|
||||||
|
add_pg_options(import_parser)
|
||||||
|
import_parser.add_argument("-i", "--input", type=Path, default=DEFAULT_OUTPUT, help="人工复核 CSV 路径")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
if args.command == "export":
|
||||||
|
export_review(args)
|
||||||
|
elif args.command == "import":
|
||||||
|
import_review(args)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"未知命令:{args.command}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except Exception as exc: # noqa: BLE001 - 命令行工具需要清楚输出错误
|
||||||
|
print(f"错误:{exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
175
患者首页处理/数据处理工作区/04_质量体检/04_字段核验与数据库体检.py
Executable file
175
患者首页处理/数据处理工作区/04_质量体检/04_字段核验与数据库体检.py
Executable file
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
对 Patient_FrontPages 做字段级体检。
|
||||||
|
|
||||||
|
输出:
|
||||||
|
- 数据处理结果区/05_质量体检/患者首页_数据库体检报告.txt
|
||||||
|
- 数据处理结果区/05_质量体检/患者首页_字段空值统计.csv
|
||||||
|
- 数据处理结果区/05_质量体检/患者首页_疑似异常记录.csv
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "数据处理结果区" / "05_质量体检"
|
||||||
|
DEFAULT_TABLE = "Patient_FrontPages"
|
||||||
|
|
||||||
|
|
||||||
|
def quote_pg_identifier(identifier: str) -> str:
|
||||||
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", identifier):
|
||||||
|
raise ValueError(f"非法 PostgreSQL 表名:{identifier}")
|
||||||
|
return '"' + identifier.replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def connection_env(args: argparse.Namespace) -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update(
|
||||||
|
{
|
||||||
|
"PGHOST": args.pg_host or os.environ.get("PGHOST", ""),
|
||||||
|
"PGPORT": str(args.pg_port or os.environ.get("PGPORT", "")),
|
||||||
|
"PGDATABASE": args.pg_database or os.environ.get("PGDATABASE", ""),
|
||||||
|
"PGUSER": args.pg_user or os.environ.get("PGUSER", ""),
|
||||||
|
"PGPASSWORD": args.pg_password or os.environ.get("PGPASSWORD", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
missing = [name for name in ["PGHOST", "PGPORT", "PGDATABASE", "PGUSER", "PGPASSWORD"] if not env.get(name)]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError("缺少 PostgreSQL 连接配置:" + "、".join(missing))
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def run_psql(sql: str, args: argparse.Namespace) -> str:
|
||||||
|
if shutil.which("psql") is None:
|
||||||
|
raise RuntimeError("未找到 psql。请先安装 PostgreSQL 客户端。")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="patient_front_audit_") as tmpdir:
|
||||||
|
sql_path = Path(tmpdir) / "audit.sql"
|
||||||
|
sql_path.write_text(sql, encoding="utf-8")
|
||||||
|
completed = subprocess.run(
|
||||||
|
["psql", "--no-password", "--file", str(sql_path)],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
env=connection_env(args),
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise RuntimeError(completed.stderr.strip() or completed.stdout.strip() or "psql 执行失败")
|
||||||
|
return completed.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def build_empty_stat_sql(table: str) -> str:
|
||||||
|
monitored_columns = [
|
||||||
|
"inpatient_no",
|
||||||
|
"medical_record_no",
|
||||||
|
"patient_name",
|
||||||
|
"gender",
|
||||||
|
"birth_date",
|
||||||
|
"id_card_no",
|
||||||
|
"contact_address",
|
||||||
|
"admission_time",
|
||||||
|
"discharge_time",
|
||||||
|
"major_department",
|
||||||
|
"primary_diagnosis",
|
||||||
|
"primary_diagnosis_code",
|
||||||
|
"discharge_diagnoses",
|
||||||
|
"operations",
|
||||||
|
"quality_status",
|
||||||
|
"review_status",
|
||||||
|
]
|
||||||
|
selects = []
|
||||||
|
for column in monitored_columns:
|
||||||
|
identifier = quote_pg_identifier(column)
|
||||||
|
if column in {"discharge_diagnoses", "operations"}:
|
||||||
|
empty_expr = f"{identifier} IS NULL OR jsonb_array_length({identifier}) = 0"
|
||||||
|
else:
|
||||||
|
empty_expr = f"{identifier} IS NULL OR {identifier}::text = ''"
|
||||||
|
selects.append(f"SELECT '{column}' AS column_name, count(*) FILTER (WHERE {empty_expr}) AS empty_count, count(*) AS total_count FROM {table}")
|
||||||
|
return " UNION ALL ".join(selects)
|
||||||
|
|
||||||
|
|
||||||
|
def audit(args: argparse.Namespace) -> None:
|
||||||
|
output_dir = args.output_dir.resolve()
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
table = quote_pg_identifier(args.pg_table)
|
||||||
|
empty_csv = output_dir / "患者首页_字段空值统计.csv"
|
||||||
|
suspicious_csv = output_dir / "患者首页_疑似异常记录.csv"
|
||||||
|
report_path = output_dir / "患者首页_数据库体检报告.txt"
|
||||||
|
empty_stat_sql = build_empty_stat_sql(table)
|
||||||
|
suspicious_sql = (
|
||||||
|
"SELECT source_file, inpatient_no, medical_record_no, patient_name, review_status, "
|
||||||
|
"review_notes::text AS review_notes, contact_address, primary_diagnosis, primary_diagnosis_code "
|
||||||
|
f"FROM {table} "
|
||||||
|
"WHERE review_status <> 'auto_pass' "
|
||||||
|
"OR inpatient_no !~ '^ZY\\d{12}$' "
|
||||||
|
"OR medical_record_no !~ '^\\d{10}$' "
|
||||||
|
"OR contact_address ~ '急诊|门诊|其他医疗机构转入|医嘱离院' "
|
||||||
|
"OR primary_diagnosis_code IS NULL "
|
||||||
|
"OR primary_diagnosis_code = '' "
|
||||||
|
"ORDER BY source_file"
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
\\set ON_ERROR_STOP on
|
||||||
|
\\pset pager off
|
||||||
|
\\o {str(report_path).replace("'", "''")}
|
||||||
|
SELECT '总记录数' AS item, count(*)::text AS value FROM {table}
|
||||||
|
UNION ALL
|
||||||
|
SELECT '需复核记录数', count(*)::text FROM {table} WHERE review_status <> 'auto_pass'
|
||||||
|
UNION ALL
|
||||||
|
SELECT '缺字段注释数', count(*)::text
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.oid
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND c.relname = {args.pg_table!r}
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped
|
||||||
|
AND col_description(c.oid, a.attnum) IS NULL;
|
||||||
|
\\o
|
||||||
|
\\copy ({empty_stat_sql}) TO '{str(empty_csv).replace("'", "''")}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
|
||||||
|
\\copy ({suspicious_sql}) TO '{str(suspicious_csv).replace("'", "''")}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
|
||||||
|
""".strip()
|
||||||
|
stdout = run_psql(sql, args)
|
||||||
|
if stdout.strip():
|
||||||
|
print(stdout.strip())
|
||||||
|
print(f"体检报告:{report_path}")
|
||||||
|
print(f"字段空值统计:{empty_csv}")
|
||||||
|
print(f"疑似异常记录:{suspicious_csv}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="核验 Patient_FrontPages 字段注释、空值和疑似错位记录。")
|
||||||
|
parser.add_argument("--pg-host", default=os.environ.get("PGHOST", ""), help="PostgreSQL 主机;也可用 PGHOST")
|
||||||
|
parser.add_argument("--pg-port", default=os.environ.get("PGPORT", "5432"), help="PostgreSQL 端口;也可用 PGPORT")
|
||||||
|
parser.add_argument("--pg-database", default=os.environ.get("PGDATABASE", ""), help="PostgreSQL 数据库;也可用 PGDATABASE")
|
||||||
|
parser.add_argument("--pg-user", default=os.environ.get("PGUSER", ""), help="PostgreSQL 用户名;也可用 PGUSER")
|
||||||
|
parser.add_argument("--pg-password", default=os.environ.get("PGPASSWORD", ""), help="PostgreSQL 密码;也可用 PGPASSWORD")
|
||||||
|
parser.add_argument("--pg-table", default=os.environ.get("PG_PATIENT_TABLE", DEFAULT_TABLE), help="PostgreSQL 表名")
|
||||||
|
parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="体检结果输出目录")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
audit(build_parser().parse_args())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"错误:{exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
130
患者首页处理/数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py
Normal file
130
患者首页处理/数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import zipfile
|
||||||
|
import io
|
||||||
|
import shutil # 【新增】用于删除非空文件夹
|
||||||
|
import argparse # <--- 1. 确保这里导入了
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 1. 配置命令行参数解析
|
||||||
|
parser = argparse.ArgumentParser(description="Mineru PDF 转 Markdown 批量处理工具")
|
||||||
|
|
||||||
|
# 添加参数
|
||||||
|
parser.add_argument("-s", "--source", type=str, default="./Papers/ORI_PDF",
|
||||||
|
help="源 PDF 文件夹路径 (默认: ./Papers/ORI_PDF)")
|
||||||
|
parser.add_argument("-t", "--target", type=str, default="./Papers/ORI_MD",
|
||||||
|
help="目标 Markdown 文件夹路径 (默认: ./Papers/ORI_MD)")
|
||||||
|
parser.add_argument("-u", "--url", type=str, default="http://10.168.1.103:4000/extract",
|
||||||
|
help="API 服务端地址 (默认: http://10.168.1.103:4000/extract)")
|
||||||
|
parser.add_argument("--sync", action="store_true",
|
||||||
|
help="任务完成后是否开启反向同步清理(删除多余的输出文件夹)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 使用解析后的参数
|
||||||
|
url = args.url
|
||||||
|
source_dir = args.source
|
||||||
|
target_dir = args.target
|
||||||
|
|
||||||
|
# 2. 确保源目录存在,避免报错
|
||||||
|
if not os.path.exists(source_dir):
|
||||||
|
print(f"错误: 找不到源文件夹 '{source_dir}'")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# 确保目标主文件夹存在
|
||||||
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 3. 遍历源文件夹下的所有文件进行上传处理
|
||||||
|
for filename in os.listdir(source_dir):
|
||||||
|
# 只处理 PDF 文件
|
||||||
|
if not filename.lower().endswith(".pdf"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = os.path.join(source_dir, filename)
|
||||||
|
# 获取去掉 .pdf 后缀的文件名
|
||||||
|
pdf_name_no_ext = os.path.splitext(filename)[0]
|
||||||
|
|
||||||
|
# 对应的输出文件夹路径
|
||||||
|
output_folder = os.path.join(target_dir, pdf_name_no_ext)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 检查是否已经处理过
|
||||||
|
# 如果输出文件夹已存在,且内部有文件,则视为已转换,直接跳过
|
||||||
|
# ==========================================
|
||||||
|
if os.path.exists(output_folder) and len(os.listdir(output_folder)) > 0:
|
||||||
|
print(f"⏩ 已存在转换结果,跳过处理: {filename}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"正在上传并处理 {filename}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 4. 发送请求
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
files = {"file": (filename, f, "application/pdf")}
|
||||||
|
response = requests.post(url, files=files)
|
||||||
|
|
||||||
|
# 5. 处理响应结果
|
||||||
|
if response.status_code == 200:
|
||||||
|
# 检查服务端是否返回了包含报错信息的 JSON
|
||||||
|
if response.headers.get('content-type') == 'application/json':
|
||||||
|
error_msg = response.json()
|
||||||
|
print(f"❌ 服务端处理失败 ({filename}):{error_msg.get('message', '未知错误')}")
|
||||||
|
continue # 跳过解压,处理下一个
|
||||||
|
|
||||||
|
# 确保该 PDF 专属的输出文件夹存在
|
||||||
|
os.makedirs(output_folder, exist_ok=True)
|
||||||
|
|
||||||
|
# 核心:使用 io.BytesIO 直接在内存中读取 zip 内容并解压
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.content)) as zip_ref:
|
||||||
|
zip_ref.extractall(output_folder)
|
||||||
|
print(f"✅ 成功!已解压并保存至文件夹: {output_folder}")
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
print(f"❌ 失败!{filename} 返回的内容不是有效的 ZIP 格式。")
|
||||||
|
else:
|
||||||
|
print(f"❌ 失败!{filename} 状态码: {response.status_code}, 报错: {response.text}")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ 网络请求异常 ({filename}): {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 处理 {filename} 时发生未知错误: {e}")
|
||||||
|
|
||||||
|
print("-" * 30)
|
||||||
|
print("转换任务结束,开始进行文件夹同步清理...")
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 【新增逻辑】:反向比对,清理多余的 MD 文件夹
|
||||||
|
# ==========================================
|
||||||
|
# 1. 收集当前 ORI_PDF 中所有有效的 PDF 名字(不含后缀)
|
||||||
|
valid_pdf_names = set()
|
||||||
|
for filename in os.listdir(source_dir):
|
||||||
|
if filename.lower().endswith(".pdf"):
|
||||||
|
valid_pdf_names.add(os.path.splitext(filename)[0])
|
||||||
|
|
||||||
|
# 2. 遍历 ORI_MD 文件夹
|
||||||
|
if os.path.exists(target_dir):
|
||||||
|
for folder_name in os.listdir(target_dir):
|
||||||
|
folder_path = os.path.join(target_dir, folder_name)
|
||||||
|
|
||||||
|
# 仅处理文件夹(以防里面有意外的独立文件)
|
||||||
|
if os.path.isdir(folder_path):
|
||||||
|
# 3. 如果这个文件夹的名字不在有效的 PDF 列表中,说明是被删掉的“孤儿”
|
||||||
|
if folder_name not in valid_pdf_names:
|
||||||
|
print(f"🧹 发现多余文件,正在清理: {folder_name}")
|
||||||
|
try:
|
||||||
|
# 使用 shutil.rmtree 删除整个文件夹及其内部所有内容
|
||||||
|
shutil.rmtree(folder_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 删除 {folder_name} 时发生错误: {e}")
|
||||||
|
|
||||||
|
print("-" * 30)
|
||||||
|
print("所有批量任务彻底完成!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n检测到用户中断,程序退出。")
|
||||||
697
患者首页处理/数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py
Normal file
697
患者首页处理/数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
把患者首页 PDF 转为图片,并生成图片-结构化字段对照核验清单。
|
||||||
|
|
||||||
|
依赖系统命令 pdftoppm,通常由 poppler-utils 提供。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from string import Template
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_INPUT_DIR = PROJECT_ROOT / "待处理-患者首页PDF"
|
||||||
|
DEFAULT_RESULT_DIR = PROJECT_ROOT / "数据处理结果区"
|
||||||
|
DEFAULT_IMAGE_REVIEW_DIR = DEFAULT_RESULT_DIR / "06_PDF图片对照"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FieldCheck:
|
||||||
|
group: str
|
||||||
|
key: str
|
||||||
|
level: str
|
||||||
|
location_hint: str
|
||||||
|
|
||||||
|
|
||||||
|
FIELD_CHECKS = [
|
||||||
|
FieldCheck("基本信息", "医疗机构", "recommended", "首页抬头及组织机构代码附近"),
|
||||||
|
FieldCheck("基本信息", "医疗付费方式", "recommended", "首页左上付费方式"),
|
||||||
|
FieldCheck("基本信息", "健康卡号", "recommended", "首页左上健康卡号"),
|
||||||
|
FieldCheck("基本信息", "住院次数", "recommended", "首页左上住院次数"),
|
||||||
|
FieldCheck("基本信息", "病案号", "required", "首页左上病案号"),
|
||||||
|
FieldCheck("基本信息", "姓名", "required", "基本信息行"),
|
||||||
|
FieldCheck("基本信息", "性别", "required", "基本信息行"),
|
||||||
|
FieldCheck("基本信息", "出生日期", "required", "基本信息行"),
|
||||||
|
FieldCheck("基本信息", "年龄", "required", "基本信息行"),
|
||||||
|
FieldCheck("基本信息", "国籍", "recommended", "基本信息行"),
|
||||||
|
FieldCheck("基本信息", "身份证号", "required", "基本信息行"),
|
||||||
|
FieldCheck("基本信息", "职业", "recommended", "基本信息行"),
|
||||||
|
FieldCheck("基本信息", "婚姻代码", "recommended", "基本信息行"),
|
||||||
|
FieldCheck("基本信息", "出生地", "recommended", "地址信息区"),
|
||||||
|
FieldCheck("基本信息", "籍贯", "recommended", "地址信息区"),
|
||||||
|
FieldCheck("基本信息", "民族", "recommended", "地址信息区"),
|
||||||
|
FieldCheck("地址联系人", "现住址", "required", "现住址行"),
|
||||||
|
FieldCheck("地址联系人", "现住址电话", "recommended", "现住址行"),
|
||||||
|
FieldCheck("地址联系人", "现住址邮编", "recommended", "现住址行"),
|
||||||
|
FieldCheck("地址联系人", "户口地址", "recommended", "户口地址行"),
|
||||||
|
FieldCheck("地址联系人", "户口地址邮编", "recommended", "户口地址行"),
|
||||||
|
FieldCheck("地址联系人", "工作单位及地址", "recommended", "工作单位及地址行"),
|
||||||
|
FieldCheck("地址联系人", "单位电话", "recommended", "工作单位及地址行"),
|
||||||
|
FieldCheck("地址联系人", "单位邮编", "recommended", "工作单位及地址行"),
|
||||||
|
FieldCheck("地址联系人", "联系人姓名", "required", "联系人信息行"),
|
||||||
|
FieldCheck("地址联系人", "联系人关系", "recommended", "联系人信息行"),
|
||||||
|
FieldCheck("地址联系人", "联系人地址", "recommended", "联系人信息行"),
|
||||||
|
FieldCheck("地址联系人", "联系人电话", "required", "联系人信息行"),
|
||||||
|
FieldCheck("入出院", "入院途径代码", "recommended", "入院途径勾选项"),
|
||||||
|
FieldCheck("入出院", "入院时间", "required", "入院记录行"),
|
||||||
|
FieldCheck("入出院", "入院科别", "required", "入院记录行"),
|
||||||
|
FieldCheck("入出院", "入院病房", "recommended", "入院记录行"),
|
||||||
|
FieldCheck("入出院", "转科科别", "optional", "转科科别行"),
|
||||||
|
FieldCheck("入出院", "出院时间", "required", "出院记录行"),
|
||||||
|
FieldCheck("入出院", "出院科别", "required", "出院记录行"),
|
||||||
|
FieldCheck("入出院", "出院病房", "recommended", "出院记录行"),
|
||||||
|
FieldCheck("入出院", "实际住院天数", "required", "出院记录行"),
|
||||||
|
FieldCheck("入出院", "大科室", "required", "由入院/出院科别映射"),
|
||||||
|
FieldCheck("诊断手术", "门急诊诊断", "recommended", "诊断区顶部"),
|
||||||
|
FieldCheck("诊断手术", "门急诊诊断编码", "recommended", "诊断区顶部"),
|
||||||
|
FieldCheck("诊断手术", "主要诊断名称", "required", "出院诊断表第一行"),
|
||||||
|
FieldCheck("诊断手术", "主要诊断编码", "required", "出院诊断表第一行"),
|
||||||
|
FieldCheck("诊断手术", "主要诊断入院病情", "required", "出院诊断表第一行"),
|
||||||
|
FieldCheck("诊断手术", "出院诊断", "recommended", "出院诊断表全部行"),
|
||||||
|
FieldCheck("诊断手术", "手术操作", "recommended", "手术及操作表"),
|
||||||
|
FieldCheck("诊断手术", "损伤中毒外部原因", "optional", "损伤中毒外部原因行"),
|
||||||
|
FieldCheck("诊断手术", "损伤中毒疾病编码", "optional", "损伤中毒外部原因行"),
|
||||||
|
FieldCheck("诊断手术", "病理诊断", "optional", "病理诊断行"),
|
||||||
|
FieldCheck("诊断手术", "病理诊断编码", "optional", "病理诊断行"),
|
||||||
|
FieldCheck("诊断手术", "病理号", "optional", "病理号"),
|
||||||
|
FieldCheck("质控信息", "药物过敏代码", "recommended", "药物过敏勾选项"),
|
||||||
|
FieldCheck("质控信息", "过敏药物", "optional", "过敏药物填写处"),
|
||||||
|
FieldCheck("质控信息", "死亡患者尸检代码", "recommended", "死亡患者尸检勾选项"),
|
||||||
|
FieldCheck("质控信息", "血型代码", "recommended", "血型勾选项"),
|
||||||
|
FieldCheck("质控信息", "Rh代码", "recommended", "Rh勾选项"),
|
||||||
|
FieldCheck("质控信息", "科主任", "recommended", "医师签名区"),
|
||||||
|
FieldCheck("质控信息", "主任副主任医师", "recommended", "医师签名区"),
|
||||||
|
FieldCheck("质控信息", "主治医师", "recommended", "医师签名区"),
|
||||||
|
FieldCheck("质控信息", "住院医师", "recommended", "医师签名区"),
|
||||||
|
FieldCheck("质控信息", "责任护士", "recommended", "护理签名区"),
|
||||||
|
FieldCheck("质控信息", "编码员", "recommended", "编码员签名区"),
|
||||||
|
FieldCheck("质控信息", "病案质量代码", "recommended", "病案质量勾选项"),
|
||||||
|
FieldCheck("质控信息", "质控医师", "recommended", "质控签名区"),
|
||||||
|
FieldCheck("质控信息", "质控护士", "recommended", "质控签名区"),
|
||||||
|
FieldCheck("质控信息", "质控日期", "recommended", "质控日期"),
|
||||||
|
FieldCheck("离院费用", "离院方式代码", "recommended", "离院方式勾选项"),
|
||||||
|
FieldCheck("离院费用", "出院31天内再住院计划代码", "recommended", "再住院计划勾选项"),
|
||||||
|
FieldCheck("离院费用", "再住院计划目的", "optional", "再住院计划目的"),
|
||||||
|
FieldCheck("离院费用", "入院前昏迷天数", "optional", "昏迷时间区"),
|
||||||
|
FieldCheck("离院费用", "入院后昏迷天数", "optional", "昏迷时间区"),
|
||||||
|
FieldCheck("离院费用", "总费用", "optional", "费用区"),
|
||||||
|
FieldCheck("离院费用", "自付金额", "optional", "费用区"),
|
||||||
|
FieldCheck("离院费用", "费用明细", "optional", "费用明细区"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="患者首页 PDF 转图片并生成字段对照核验报告")
|
||||||
|
parser.add_argument("-i", "--input-dir", type=Path, default=DEFAULT_INPUT_DIR, help="PDF 输入目录")
|
||||||
|
parser.add_argument("-r", "--result-dir", type=Path, default=DEFAULT_RESULT_DIR, help="解析结果根目录")
|
||||||
|
parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_IMAGE_REVIEW_DIR, help="图片对照输出目录")
|
||||||
|
parser.add_argument("--dpi", type=int, default=180, help="图片分辨率,默认 180")
|
||||||
|
parser.add_argument("--format", choices=["png", "jpeg"], default="png", help="图片格式,默认 png")
|
||||||
|
parser.add_argument("--force", action="store_true", help="重新生成已存在图片")
|
||||||
|
parser.add_argument("--strict-recommended", action="store_true", help="把建议字段缺项也写入对照建议")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(cmd, check=True, text=True, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
def numeric_page_key(path: Path) -> tuple[int, str]:
|
||||||
|
match = re.search(r"-(\d+)\.(png|jpe?g)$", path.name, flags=re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
return (10**9, path.name)
|
||||||
|
return (int(match.group(1)), path.name)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_pdf_to_images(pdf_path: Path, output_dir: Path, dpi: int, image_format: str, force: bool) -> tuple[list[Path], str]:
|
||||||
|
pdftoppm = shutil.which("pdftoppm")
|
||||||
|
if not pdftoppm:
|
||||||
|
return [], "未找到 pdftoppm,请安装 poppler-utils。"
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
extension = "jpg" if image_format == "jpeg" else "png"
|
||||||
|
existing = sorted(output_dir.glob(f"page-*.{extension}"), key=numeric_page_key)
|
||||||
|
if existing and not force:
|
||||||
|
return existing, ""
|
||||||
|
|
||||||
|
if force:
|
||||||
|
for old_image in output_dir.glob("page-*.*"):
|
||||||
|
old_image.unlink()
|
||||||
|
|
||||||
|
prefix = output_dir / "page"
|
||||||
|
cmd = [pdftoppm, "-r", str(dpi), f"-{image_format}", str(pdf_path), str(prefix)]
|
||||||
|
try:
|
||||||
|
run(cmd)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
detail = exc.stderr.strip() or exc.stdout.strip() or str(exc)
|
||||||
|
return [], f"图片转换失败:{detail}"
|
||||||
|
|
||||||
|
images = sorted(output_dir.glob(f"page-*.{extension}"), key=numeric_page_key)
|
||||||
|
normalized_images: list[Path] = []
|
||||||
|
for index, image_path in enumerate(images, start=1):
|
||||||
|
target = output_dir / f"page-{index:03d}.{extension}"
|
||||||
|
if image_path != target:
|
||||||
|
if target.exists():
|
||||||
|
target.unlink()
|
||||||
|
image_path.rename(target)
|
||||||
|
normalized_images.append(target)
|
||||||
|
return normalized_images, "" if normalized_images else "图片转换后未找到输出文件。"
|
||||||
|
|
||||||
|
|
||||||
|
def load_record(result_dir: Path, pdf_path: Path) -> tuple[dict[str, Any], Path | None, str]:
|
||||||
|
json_path = result_dir / "02_单份JSON" / f"{pdf_path.stem}.json"
|
||||||
|
if not json_path.exists():
|
||||||
|
return {}, None, "未找到结构化 JSON,请先运行 02_解析入库 步骤。"
|
||||||
|
try:
|
||||||
|
return json.loads(json_path.read_text(encoding="utf-8")), json_path, ""
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return {}, json_path, f"结构化 JSON 读取失败:{exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_blank(value: Any) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return True
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
return text == "" or text in {"-", "--", "null", "None", "[]", "{}"}
|
||||||
|
if isinstance(value, (list, tuple, set, dict)):
|
||||||
|
return len(value) == 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def value_preview(value: Any, max_length: int = 120) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, (list, dict)):
|
||||||
|
text = json.dumps(value, ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
text = str(value)
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
if len(text) > max_length:
|
||||||
|
return text[: max_length - 1] + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def build_field_rows(record: dict[str, Any]) -> tuple[list[dict[str, str]], list[str], list[str]]:
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
missing_required: list[str] = []
|
||||||
|
missing_recommended: list[str] = []
|
||||||
|
|
||||||
|
for check in FIELD_CHECKS:
|
||||||
|
value = record.get(check.key)
|
||||||
|
missing = is_blank(value)
|
||||||
|
if missing and check.level == "required":
|
||||||
|
missing_required.append(check.key)
|
||||||
|
elif missing and check.level == "recommended":
|
||||||
|
missing_recommended.append(check.key)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"group": check.group,
|
||||||
|
"key": check.key,
|
||||||
|
"level": check.level,
|
||||||
|
"location_hint": check.location_hint,
|
||||||
|
"value": value_preview(value),
|
||||||
|
"missing": "是" if missing else "否",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows, missing_required, missing_recommended
|
||||||
|
|
||||||
|
|
||||||
|
def as_csv_text(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, (list, dict)):
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def relative_to(path: Path, start: Path) -> str:
|
||||||
|
try:
|
||||||
|
return path.relative_to(start).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def html_escape(value: Any) -> str:
|
||||||
|
return html.escape(str(value), quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def make_case_summary(
|
||||||
|
pdf_path: Path,
|
||||||
|
images: list[Path],
|
||||||
|
image_error: str,
|
||||||
|
record: dict[str, Any],
|
||||||
|
json_path: Path | None,
|
||||||
|
json_error: str,
|
||||||
|
output_dir: Path,
|
||||||
|
strict_recommended: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
field_rows, missing_required, missing_recommended = build_field_rows(record) if record else ([], [], [])
|
||||||
|
review_notes = record.get("复核备注", []) if record else []
|
||||||
|
if not isinstance(review_notes, list):
|
||||||
|
review_notes = [review_notes]
|
||||||
|
|
||||||
|
suggestions: list[str] = []
|
||||||
|
if image_error:
|
||||||
|
suggestions.append(image_error)
|
||||||
|
if json_error:
|
||||||
|
suggestions.append(json_error)
|
||||||
|
if missing_required:
|
||||||
|
suggestions.append("核心字段缺项,需对照图片补齐:" + "、".join(missing_required))
|
||||||
|
if strict_recommended and missing_recommended:
|
||||||
|
suggestions.append("建议字段缺项,抽样确认是否首页未填写:" + "、".join(missing_recommended))
|
||||||
|
if record.get("复核状态") and record.get("复核状态") != "auto_pass":
|
||||||
|
suggestions.append("该记录已有复核状态:" + str(record.get("复核状态")))
|
||||||
|
if review_notes:
|
||||||
|
suggestions.append("已有复核备注:" + ";".join(value_preview(note, 80) for note in review_notes))
|
||||||
|
if not suggestions:
|
||||||
|
suggestions.append("图片与结构化字段抽查一致即可。")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pdf_path": pdf_path,
|
||||||
|
"source_file": pdf_path.name,
|
||||||
|
"images": images,
|
||||||
|
"image_dir": images[0].parent if images else output_dir / "图片" / pdf_path.stem,
|
||||||
|
"first_image": images[0] if images else None,
|
||||||
|
"page_count": len(images),
|
||||||
|
"image_error": image_error,
|
||||||
|
"record": record,
|
||||||
|
"json_path": json_path,
|
||||||
|
"json_error": json_error,
|
||||||
|
"field_rows": field_rows,
|
||||||
|
"missing_required": missing_required,
|
||||||
|
"missing_recommended": missing_recommended,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_index_csv(cases: list[dict[str, Any]], output_dir: Path) -> Path:
|
||||||
|
csv_path = output_dir / "患者首页_PDF图片对照索引.csv"
|
||||||
|
fieldnames = [
|
||||||
|
"源文件",
|
||||||
|
"页数",
|
||||||
|
"图片目录",
|
||||||
|
"首页图片",
|
||||||
|
"结构化JSON",
|
||||||
|
"病案号",
|
||||||
|
"姓名",
|
||||||
|
"复核状态",
|
||||||
|
"复核备注",
|
||||||
|
"核心缺项",
|
||||||
|
"建议核对缺项",
|
||||||
|
"对照建议",
|
||||||
|
]
|
||||||
|
with csv_path.open("w", encoding="utf-8-sig", newline="") as fp:
|
||||||
|
writer = csv.DictWriter(fp, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
for case in cases:
|
||||||
|
record = case["record"]
|
||||||
|
writer.writerow(
|
||||||
|
{
|
||||||
|
"源文件": case["source_file"],
|
||||||
|
"页数": case["page_count"],
|
||||||
|
"图片目录": relative_to(case["image_dir"], output_dir),
|
||||||
|
"首页图片": relative_to(case["first_image"], output_dir) if case["first_image"] else "",
|
||||||
|
"结构化JSON": relative_to(case["json_path"], output_dir) if case["json_path"] else "",
|
||||||
|
"病案号": record.get("病案号", ""),
|
||||||
|
"姓名": record.get("姓名", ""),
|
||||||
|
"复核状态": record.get("复核状态", ""),
|
||||||
|
"复核备注": as_csv_text(record.get("复核备注", [])),
|
||||||
|
"核心缺项": "、".join(case["missing_required"]),
|
||||||
|
"建议核对缺项": "、".join(case["missing_recommended"]),
|
||||||
|
"对照建议": ";".join(case["suggestions"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return csv_path
|
||||||
|
|
||||||
|
|
||||||
|
def render_field_table(field_rows: list[dict[str, str]]) -> str:
|
||||||
|
if not field_rows:
|
||||||
|
return '<p class="empty">未生成字段核验表。</p>'
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
'<table class="field-table">',
|
||||||
|
"<thead><tr><th>组</th><th>字段</th><th>级别</th><th>首页位置</th><th>值</th><th>缺项</th></tr></thead>",
|
||||||
|
"<tbody>",
|
||||||
|
]
|
||||||
|
for row in field_rows:
|
||||||
|
missing_class = " is-missing" if row["missing"] == "是" and row["level"] != "optional" else ""
|
||||||
|
parts.append(
|
||||||
|
"<tr class=\"{missing_class}\">"
|
||||||
|
"<td>{group}</td><td>{key}</td><td>{level}</td><td>{location}</td><td>{value}</td><td>{missing}</td>"
|
||||||
|
"</tr>".format(
|
||||||
|
missing_class=missing_class.strip(),
|
||||||
|
group=html_escape(row["group"]),
|
||||||
|
key=html_escape(row["key"]),
|
||||||
|
level=html_escape(row["level"]),
|
||||||
|
location=html_escape(row["location_hint"]),
|
||||||
|
value=html_escape(row["value"]),
|
||||||
|
missing=html_escape(row["missing"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parts.append("</tbody></table>")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(cases: list[dict[str, Any]], output_dir: Path) -> Path:
|
||||||
|
html_path = output_dir / "患者首页_PDF图片对照.html"
|
||||||
|
total = len(cases)
|
||||||
|
with_required_missing = sum(1 for case in cases if case["missing_required"])
|
||||||
|
with_review_status = sum(1 for case in cases if case["record"].get("复核状态") not in {"", "auto_pass", None})
|
||||||
|
failed_images = sum(1 for case in cases if case["image_error"])
|
||||||
|
|
||||||
|
nav_rows = []
|
||||||
|
case_sections = []
|
||||||
|
for index, case in enumerate(cases, start=1):
|
||||||
|
anchor = f"case-{index}"
|
||||||
|
record = case["record"]
|
||||||
|
status = record.get("复核状态", "未解析")
|
||||||
|
nav_rows.append(
|
||||||
|
"<tr><td><a href=\"#{anchor}\">{source}</a></td><td>{name}</td><td>{mrn}</td><td>{status}</td><td>{required}</td></tr>".format(
|
||||||
|
anchor=anchor,
|
||||||
|
source=html_escape(case["source_file"]),
|
||||||
|
name=html_escape(record.get("姓名", "")),
|
||||||
|
mrn=html_escape(record.get("病案号", "")),
|
||||||
|
status=html_escape(status),
|
||||||
|
required=html_escape("、".join(case["missing_required"]) or "无"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
images_html = []
|
||||||
|
for image_path in case["images"]:
|
||||||
|
rel_image = relative_to(image_path, output_dir)
|
||||||
|
images_html.append(
|
||||||
|
'<figure><img loading="lazy" src="{src}" alt="{alt}"><figcaption>{caption}</figcaption></figure>'.format(
|
||||||
|
src=html_escape(rel_image),
|
||||||
|
alt=html_escape(f"{case['source_file']} {image_path.name}"),
|
||||||
|
caption=html_escape(image_path.name),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not images_html:
|
||||||
|
images_html.append('<p class="empty">未生成图片。</p>')
|
||||||
|
|
||||||
|
suggestion_items = "\n".join(f"<li>{html_escape(note)}</li>" for note in case["suggestions"])
|
||||||
|
summary_items = [
|
||||||
|
("病案号", record.get("病案号", "")),
|
||||||
|
("姓名", record.get("姓名", "")),
|
||||||
|
("大科室", record.get("大科室", "")),
|
||||||
|
("出院科别", record.get("出院科别", "")),
|
||||||
|
("主要诊断", record.get("主要诊断名称", "")),
|
||||||
|
("主要诊断编码", record.get("主要诊断编码", "")),
|
||||||
|
("复核状态", status),
|
||||||
|
]
|
||||||
|
summary_html = "\n".join(
|
||||||
|
f"<dt>{html_escape(label)}</dt><dd>{html_escape(value_preview(value, 160))}</dd>" for label, value in summary_items
|
||||||
|
)
|
||||||
|
case_sections.append(
|
||||||
|
"""
|
||||||
|
<section class="case" id="{anchor}">
|
||||||
|
<header class="case-header">
|
||||||
|
<div>
|
||||||
|
<h2>{source}</h2>
|
||||||
|
<p>{name} · {mrn} · {status}</p>
|
||||||
|
</div>
|
||||||
|
<a class="back-link" href="#top">返回顶部</a>
|
||||||
|
</header>
|
||||||
|
<div class="case-grid">
|
||||||
|
<div class="image-pane">{images}</div>
|
||||||
|
<div class="data-pane">
|
||||||
|
<dl class="summary">{summary}</dl>
|
||||||
|
<div class="suggestions"><h3>对照建议</h3><ul>{suggestions}</ul></div>
|
||||||
|
{field_table}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""".format(
|
||||||
|
anchor=anchor,
|
||||||
|
source=html_escape(case["source_file"]),
|
||||||
|
name=html_escape(record.get("姓名", "")),
|
||||||
|
mrn=html_escape(record.get("病案号", "")),
|
||||||
|
status=html_escape(status),
|
||||||
|
images="\n".join(images_html),
|
||||||
|
summary=summary_html,
|
||||||
|
suggestions=suggestion_items,
|
||||||
|
field_table=render_field_table(case["field_rows"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
page_template = Template(
|
||||||
|
"""<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>患者首页 PDF 图片对照核验</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #15181c;
|
||||||
|
--muted: #5f6975;
|
||||||
|
--line: #d9dde3;
|
||||||
|
--paper: #f6f7f9;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--bad: #b42318;
|
||||||
|
--bad-bg: #fff2ef;
|
||||||
|
--warn: #8a5a00;
|
||||||
|
--warn-bg: #fff7df;
|
||||||
|
--ok: #1f7a4d;
|
||||||
|
--ok-bg: #edf8f2;
|
||||||
|
--accent: #245b8f;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
header.report-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 14px 22px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
h1, h2, h3 { margin: 0; letter-spacing: 0; }
|
||||||
|
h1 { font-size: 20px; }
|
||||||
|
h2 { font-size: 16px; }
|
||||||
|
h3 { font-size: 14px; margin-bottom: 8px; }
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
main { padding: 18px 22px 32px; }
|
||||||
|
.index {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 7px 8px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #eef1f5;
|
||||||
|
color: #303844;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.case {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
margin: 18px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.case-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: #fbfcfd;
|
||||||
|
}
|
||||||
|
.case-header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.back-link { white-space: nowrap; align-self: center; }
|
||||||
|
.case-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(420px, 55%) minmax(360px, 45%);
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
.image-pane {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: #eceff3;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 86vh;
|
||||||
|
}
|
||||||
|
figure { margin: 0 0 14px; }
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid #c5cbd3;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.data-pane {
|
||||||
|
padding: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 86vh;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 108px 1fr;
|
||||||
|
gap: 5px 10px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfcfd;
|
||||||
|
}
|
||||||
|
dt { color: var(--muted); }
|
||||||
|
dd { margin: 0; font-weight: 600; }
|
||||||
|
.suggestions {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ead7a7;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--warn-bg);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.suggestions ul { margin: 0; padding-left: 18px; }
|
||||||
|
.field-table td:nth-child(5) { max-width: 360px; }
|
||||||
|
.field-table .is-missing td {
|
||||||
|
background: var(--bad-bg);
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.case-grid { grid-template-columns: 1fr; }
|
||||||
|
.image-pane { border-right: 0; border-bottom: 1px solid var(--line); max-height: none; }
|
||||||
|
.data-pane { max-height: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="report-header" id="top">
|
||||||
|
<h1>患者首页 PDF 图片对照核验</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="pill">PDF:$total</span>
|
||||||
|
<span class="pill">核心缺项:$with_required_missing</span>
|
||||||
|
<span class="pill">需复核状态:$with_review_status</span>
|
||||||
|
<span class="pill">图片失败:$failed_images</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section class="index">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>源文件</th><th>姓名</th><th>病案号</th><th>复核状态</th><th>核心缺项</th></tr></thead>
|
||||||
|
<tbody>$nav_rows</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
$case_sections
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
html_path.write_text(
|
||||||
|
page_template.substitute(
|
||||||
|
total=total,
|
||||||
|
with_required_missing=with_required_missing,
|
||||||
|
with_review_status=with_review_status,
|
||||||
|
failed_images=failed_images,
|
||||||
|
nav_rows="\n".join(nav_rows),
|
||||||
|
case_sections="\n".join(case_sections),
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return html_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
input_dir = args.input_dir.resolve()
|
||||||
|
result_dir = args.result_dir.resolve()
|
||||||
|
output_dir = args.output_dir.resolve()
|
||||||
|
image_root = output_dir / "图片"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
pdf_files = sorted(input_dir.glob("*.pdf"))
|
||||||
|
if not pdf_files:
|
||||||
|
raise SystemExit(f"未找到 PDF:{input_dir}")
|
||||||
|
|
||||||
|
cases: list[dict[str, Any]] = []
|
||||||
|
for pdf_path in pdf_files:
|
||||||
|
case_image_dir = image_root / pdf_path.stem
|
||||||
|
images, image_error = convert_pdf_to_images(pdf_path, case_image_dir, args.dpi, args.format, args.force)
|
||||||
|
record, json_path, json_error = load_record(result_dir, pdf_path)
|
||||||
|
cases.append(
|
||||||
|
make_case_summary(
|
||||||
|
pdf_path=pdf_path,
|
||||||
|
images=images,
|
||||||
|
image_error=image_error,
|
||||||
|
record=record,
|
||||||
|
json_path=json_path,
|
||||||
|
json_error=json_error,
|
||||||
|
output_dir=output_dir,
|
||||||
|
strict_recommended=args.strict_recommended,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
index_csv = write_index_csv(cases, output_dir)
|
||||||
|
html_path = render_html(cases, output_dir)
|
||||||
|
required_missing_count = sum(1 for case in cases if case["missing_required"])
|
||||||
|
print(f"PDF 数量:{len(cases)}")
|
||||||
|
print(f"图片输出:{image_root}")
|
||||||
|
print(f"对照索引:{index_csv}")
|
||||||
|
print(f"HTML 对照页:{html_path}")
|
||||||
|
print(f"核心缺项病例数:{required_missing_count}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
205
患者首页处理/数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py
Normal file
205
患者首页处理/数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Kimi 视觉兜底识别:用于 pdftotext/Mineru 对局部字段解析不稳定时,人工指定 PDF 图片页进行辅助抽取。
|
||||||
|
|
||||||
|
默认只输出 JSON 文件,不直接覆盖 PostgreSQL。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_IMAGE_ROOT = PROJECT_ROOT / "数据处理结果区" / "06_PDF图片对照" / "图片"
|
||||||
|
DEFAULT_PDF_ROOT = PROJECT_ROOT / "已处理-患者首页PDF" / "2026_5_25_处理"
|
||||||
|
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "数据处理结果区" / "07_Kimi视觉识别"
|
||||||
|
DEFAULT_API_BASE = "https://api.moonshot.cn/v1"
|
||||||
|
DEFAULT_MODEL = os.environ.get("KIMI_MODEL", "kimi-k2.6")
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT = """请读取这张住院病案首页图片,仅返回 JSON。
|
||||||
|
优先核对这些区域:基本信息、地址联系人、入出院信息、诊断、手术及操作、质控签名、离院方式、费用。
|
||||||
|
如果是手术及操作区域,请按以下列拆分:
|
||||||
|
手术操作编码、手术操作日期、手术级别、手术操作名称、术者、I助、II助、切口愈合等级、麻醉方式、麻醉医师。
|
||||||
|
无法确认的字段填空字符串,不要编造。输出结构:
|
||||||
|
{
|
||||||
|
"page_summary": "",
|
||||||
|
"suspected_missing_fields": [],
|
||||||
|
"fields": {},
|
||||||
|
"discharge_diagnoses": [],
|
||||||
|
"operations": [],
|
||||||
|
"fees": {},
|
||||||
|
"notes": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="使用 Kimi 视觉模型辅助识别患者首页图片")
|
||||||
|
parser.add_argument("--image-root", type=Path, default=DEFAULT_IMAGE_ROOT, help="PDF 转图片根目录")
|
||||||
|
parser.add_argument("--pdf-root", type=Path, default=DEFAULT_PDF_ROOT, help="没有图片目录时,从该 PDF 目录临时渲染页面")
|
||||||
|
parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="Kimi 识别结果输出目录")
|
||||||
|
parser.add_argument("--case", help="只处理指定病例目录名,例如 ZY010001672803_ori")
|
||||||
|
parser.add_argument("--page", help="只处理指定图片名,例如 page-001.png")
|
||||||
|
parser.add_argument("--pdf-page", type=int, help="从 PDF 临时渲染时,只处理指定页码,从 1 开始")
|
||||||
|
parser.add_argument("--dpi", type=int, default=160, help="PDF 临时渲染分辨率")
|
||||||
|
parser.add_argument("--model", default=DEFAULT_MODEL, help="Kimi 模型名")
|
||||||
|
parser.add_argument("--api-base", default=os.environ.get("MOONSHOT_API_BASE", DEFAULT_API_BASE), help="Moonshot API base URL")
|
||||||
|
parser.add_argument("--api-key", default=os.environ.get("MOONSHOT_API_KEY") or os.environ.get("KIMI_API_KEY"), help="API Key,也可用 MOONSHOT_API_KEY")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def image_to_data_url(path: Path) -> str:
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if mime_type not in {"image/png", "image/jpeg", "image/webp", "image/gif"}:
|
||||||
|
mime_type = "image/png"
|
||||||
|
encoded = base64.b64encode(path.read_bytes()).decode("utf-8")
|
||||||
|
return f"data:{mime_type};base64,{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def call_kimi(image_path: Path, args: argparse.Namespace) -> dict[str, Any]:
|
||||||
|
if not args.api_key:
|
||||||
|
raise RuntimeError("未设置 MOONSHOT_API_KEY/KIMI_API_KEY,无法调用 Kimi API。")
|
||||||
|
payload = {
|
||||||
|
"model": args.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "你是严谨的病案首页结构化抽取助手,只输出 JSON。"},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image_url", "image_url": {"url": image_to_data_url(image_path)}},
|
||||||
|
{"type": "text", "text": PROMPT},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{args.api_base.rstrip('/')}/chat/completions",
|
||||||
|
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {args.api_key}",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=180) as response:
|
||||||
|
data = json.loads(response.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
raise RuntimeError(f"Kimi API 返回错误 {exc.code}: {detail}") from exc
|
||||||
|
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
parsed = parse_json_content(content)
|
||||||
|
return {
|
||||||
|
"image": str(image_path),
|
||||||
|
"model": args.model,
|
||||||
|
"recognized_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"raw_response": content,
|
||||||
|
"parsed": parsed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_content(content: str) -> Any:
|
||||||
|
text = content.strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.strip("`")
|
||||||
|
if text.startswith("json"):
|
||||||
|
text = text[4:].strip()
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
return json.loads(text[start : end + 1])
|
||||||
|
return {"notes": ["Kimi 返回内容不是合法 JSON"], "text": content}
|
||||||
|
|
||||||
|
|
||||||
|
def iter_images(args: argparse.Namespace) -> list[Path]:
|
||||||
|
root = args.image_root
|
||||||
|
if args.case:
|
||||||
|
case_dir = root / args.case
|
||||||
|
if args.page:
|
||||||
|
return [case_dir / args.page]
|
||||||
|
return sorted(case_dir.glob("page-*.*"))
|
||||||
|
images: list[Path] = []
|
||||||
|
for case_dir in sorted(path for path in root.iterdir() if path.is_dir()):
|
||||||
|
images.extend(sorted(case_dir.glob("page-*.*")))
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def find_case_pdf(args: argparse.Namespace) -> Path | None:
|
||||||
|
if not args.case:
|
||||||
|
return None
|
||||||
|
stem = Path(args.case).stem
|
||||||
|
candidates = [
|
||||||
|
args.pdf_root / f"{stem}.pdf",
|
||||||
|
args.pdf_root / args.case,
|
||||||
|
]
|
||||||
|
return next((path for path in candidates if path.exists() and path.is_file()), None)
|
||||||
|
|
||||||
|
|
||||||
|
def render_pdf_pages(pdf_path: Path, output_root: Path, args: argparse.Namespace) -> list[Path]:
|
||||||
|
case_dir = output_root / pdf_path.stem
|
||||||
|
case_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
if args.pdf_page:
|
||||||
|
prefix = case_dir / f"page-{args.pdf_page:03d}"
|
||||||
|
command = [
|
||||||
|
"pdftoppm",
|
||||||
|
"-png",
|
||||||
|
"-r",
|
||||||
|
str(args.dpi),
|
||||||
|
"-f",
|
||||||
|
str(args.pdf_page),
|
||||||
|
"-singlefile",
|
||||||
|
str(pdf_path),
|
||||||
|
str(prefix),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
command = ["pdftoppm", "-png", "-r", str(args.dpi), str(pdf_path), str(case_dir / "page")]
|
||||||
|
subprocess.run(command, check=True)
|
||||||
|
return sorted(case_dir.glob("page-*.png")) or sorted(case_dir.glob("page.png"))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
images = [path for path in iter_images(args) if path.exists() and path.is_file()]
|
||||||
|
with tempfile.TemporaryDirectory(prefix="frontpage_kimi_") as temp_dir:
|
||||||
|
if not images:
|
||||||
|
pdf_path = find_case_pdf(args)
|
||||||
|
if pdf_path:
|
||||||
|
images = render_pdf_pages(pdf_path, Path(temp_dir), args)
|
||||||
|
if not images:
|
||||||
|
raise SystemExit("未找到待识别图片,也未找到可临时渲染的 PDF。")
|
||||||
|
|
||||||
|
for image_path in images:
|
||||||
|
print(f"识别:{image_path}")
|
||||||
|
result = call_kimi(image_path, args)
|
||||||
|
out_path = args.output_dir / f"{image_path.parent.name}_{image_path.stem}.json"
|
||||||
|
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print(f"输出:{out_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"错误:{exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
Reference in New Issue
Block a user