Import sanitized HIS processing tools

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

35
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,19 @@
# HIS 数据处理工具集
本仓库合并保存 HIS 相关处理程序、网页端、配置模板和流程文档。
## 目录
- `患者列表处理/`:患者列表 OCR、合并、PostgreSQL 同步、人工复核网页端。
- `患者首页处理/`:患者首页 PDF 解析入库、人工复核、质量体检、可视化网页端、Kimi 视觉兜底工具。
## 入库边界
仓库只保存程序、文档、结构脚本和配置模板,不保存以下内容:
- 原始 PDF、图片、截图、OCR 裁剪图等患者数据。
- `数据处理结果区/` 中的结构化结果、日志、质检报告和复核导出。
- `.env``.pgpass`、本地网页端配置、明文数据库连接信息、API Key 或登录密码。
- 两个旧工程的 `.git` 历史。
需要运行程序时,请在本地根据各子目录的 `.env.example` 创建 `.env`,并从安全渠道填入数据库和 API 配置。

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

View 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` 为准。

View File

@@ -0,0 +1,4 @@
.env
__pycache__/
*.py[cod]
*.log

View 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

View 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"]

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

File diff suppressed because it is too large Load Diff

View 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

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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>

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

View 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 配置。

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
从 PostgreSQL 导出患者首页复核表,并把人工复核结果回写到 Patient_FrontPages。
依赖系统命令 psql数据库密码通过 PGPASSWORD 环境变量或 --pg-password 传入。
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_OUTPUT = PROJECT_ROOT / "数据处理结果区" / "04_复核与人工校正" / "患者首页_人工复核表.csv"
DEFAULT_TABLE = "Patient_FrontPages"
def quote_pg_identifier(identifier: str) -> str:
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", identifier):
raise ValueError(f"非法 PostgreSQL 表名:{identifier}")
return '"' + identifier.replace('"', '""') + '"'
def connection_env(args: argparse.Namespace) -> dict[str, str]:
env = os.environ.copy()
env.update(
{
"PGHOST": args.pg_host or os.environ.get("PGHOST", ""),
"PGPORT": str(args.pg_port or os.environ.get("PGPORT", "")),
"PGDATABASE": args.pg_database or os.environ.get("PGDATABASE", ""),
"PGUSER": args.pg_user or os.environ.get("PGUSER", ""),
"PGPASSWORD": args.pg_password or os.environ.get("PGPASSWORD", ""),
}
)
missing = [name for name in ["PGHOST", "PGPORT", "PGDATABASE", "PGUSER", "PGPASSWORD"] if not env.get(name)]
if missing:
raise RuntimeError("缺少 PostgreSQL 连接配置:" + "".join(missing))
return env
def run_psql(sql: str, args: argparse.Namespace) -> None:
if shutil.which("psql") is None:
raise RuntimeError("未找到 psql。请先安装 PostgreSQL 客户端。")
with tempfile.TemporaryDirectory(prefix="patient_front_review_") as tmpdir:
sql_path = Path(tmpdir) / "review.sql"
sql_path.write_text(sql, encoding="utf-8")
completed = subprocess.run(
["psql", "--no-password", "--file", str(sql_path)],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
env=connection_env(args),
)
if completed.returncode != 0:
raise RuntimeError(completed.stderr.strip() or completed.stdout.strip() or "psql 执行失败")
if completed.stdout.strip():
print(completed.stdout.strip())
def export_review(args: argparse.Namespace) -> None:
output = args.output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
escaped_output = str(output).replace("'", "''")
table = quote_pg_identifier(args.pg_table)
where_sql = "" if args.all else "WHERE review_status <> 'auto_pass' OR manual_corrected IS TRUE"
select_sql = (
"SELECT inpatient_no, source_file, medical_record_no, front_page_medical_record_no, patient_name, "
"gender, birth_date, discharge_dept, major_department, primary_diagnosis, "
"primary_diagnosis_code, total_cost, self_pay_amount, review_status, "
"review_notes::text AS review_notes, auto_corrections::text AS auto_corrections, "
"manual_corrected, contact_address, contact_phone, discharge_diagnoses::text AS discharge_diagnoses, "
"operations::text AS operations, pathology_diagnosis, pathology_diagnosis_code, pathology_no, "
"discharge_disposition_code, readmission_plan_code, quality_status, quality_notes::text AS quality_notes, "
"'' AS manual_review_notes, '' AS corrected_medical_record_no, "
"'' AS corrected_patient_name, '' AS corrected_major_department, "
"'' AS corrected_primary_diagnosis, '' AS corrected_primary_diagnosis_code, "
"'' AS corrected_total_cost, '' AS corrected_self_pay_amount, '' AS mark_review_status "
f"FROM {table} {where_sql} ORDER BY source_file"
)
sql = f"\\set ON_ERROR_STOP on\n\\copy ({select_sql}) TO '{escaped_output}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')\n"
run_psql(sql, args)
print(f"已导出复核表:{output}")
def import_review(args: argparse.Namespace) -> None:
input_path = args.input.resolve()
if not input_path.exists():
raise RuntimeError(f"复核表不存在:{input_path}")
escaped_input = str(input_path).replace("'", "''")
table = quote_pg_identifier(args.pg_table)
sql = f"""
\\set ON_ERROR_STOP on
CREATE TEMP TABLE patient_front_review_import (
inpatient_no TEXT,
source_file TEXT,
medical_record_no TEXT,
front_page_medical_record_no TEXT,
patient_name TEXT,
gender TEXT,
birth_date TEXT,
discharge_dept TEXT,
major_department TEXT,
primary_diagnosis TEXT,
primary_diagnosis_code TEXT,
total_cost TEXT,
self_pay_amount TEXT,
review_status TEXT,
review_notes TEXT,
auto_corrections TEXT,
manual_corrected TEXT,
contact_address TEXT,
contact_phone TEXT,
discharge_diagnoses TEXT,
operations TEXT,
pathology_diagnosis TEXT,
pathology_diagnosis_code TEXT,
pathology_no TEXT,
discharge_disposition_code TEXT,
readmission_plan_code TEXT,
quality_status TEXT,
quality_notes TEXT,
manual_review_notes TEXT,
corrected_medical_record_no TEXT,
corrected_patient_name TEXT,
corrected_major_department TEXT,
corrected_primary_diagnosis TEXT,
corrected_primary_diagnosis_code TEXT,
corrected_total_cost TEXT,
corrected_self_pay_amount TEXT,
mark_review_status TEXT
);
\\copy patient_front_review_import FROM '{escaped_input}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
UPDATE {table} AS t
SET
medical_record_no = COALESCE(NULLIF(i.corrected_medical_record_no, ''), t.medical_record_no),
patient_name = COALESCE(NULLIF(i.corrected_patient_name, ''), t.patient_name),
major_department = COALESCE(NULLIF(i.corrected_major_department, ''), t.major_department),
primary_diagnosis = COALESCE(NULLIF(i.corrected_primary_diagnosis, ''), t.primary_diagnosis),
primary_diagnosis_code = COALESCE(NULLIF(i.corrected_primary_diagnosis_code, ''), t.primary_diagnosis_code),
total_cost = COALESCE(NULLIF(i.corrected_total_cost, '')::numeric, t.total_cost),
self_pay_amount = COALESCE(NULLIF(i.corrected_self_pay_amount, '')::numeric, t.self_pay_amount),
review_status = COALESCE(NULLIF(i.mark_review_status, ''), 'reviewed'),
review_notes = CASE
WHEN NULLIF(i.manual_review_notes, '') IS NULL THEN t.review_notes
ELSE t.review_notes || jsonb_build_array(i.manual_review_notes)
END,
manual_corrected = CASE
WHEN NULLIF(i.corrected_medical_record_no, '') IS NOT NULL
OR NULLIF(i.corrected_patient_name, '') IS NOT NULL
OR NULLIF(i.corrected_major_department, '') IS NOT NULL
OR NULLIF(i.corrected_primary_diagnosis, '') IS NOT NULL
OR NULLIF(i.corrected_primary_diagnosis_code, '') IS NOT NULL
OR NULLIF(i.corrected_total_cost, '') IS NOT NULL
OR NULLIF(i.corrected_self_pay_amount, '') IS NOT NULL
OR lower(COALESCE(i.manual_corrected, '')) IN ('true', 't', '1', 'yes', 'y')
THEN true
ELSE t.manual_corrected
END
FROM patient_front_review_import AS i
WHERE (
NULLIF(i.inpatient_no, '') IS NOT NULL
AND t.inpatient_no = i.inpatient_no
) OR (
NULLIF(i.inpatient_no, '') IS NULL
AND t.source_file = i.source_file
);
""".strip()
run_psql(sql, args)
print(f"已回写人工复核表:{input_path}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="导出/回写患者首页 PostgreSQL 人工复核表。")
subparsers = parser.add_subparsers(dest="command", required=True)
def add_pg_options(subparser: argparse.ArgumentParser) -> None:
subparser.add_argument("--pg-host", default=os.environ.get("PGHOST", ""), help="PostgreSQL 主机;也可用 PGHOST")
subparser.add_argument("--pg-port", default=os.environ.get("PGPORT", "5432"), help="PostgreSQL 端口;也可用 PGPORT")
subparser.add_argument("--pg-database", default=os.environ.get("PGDATABASE", ""), help="PostgreSQL 数据库;也可用 PGDATABASE")
subparser.add_argument("--pg-user", default=os.environ.get("PGUSER", ""), help="PostgreSQL 用户名;也可用 PGUSER")
subparser.add_argument("--pg-password", default=os.environ.get("PGPASSWORD", ""), help="PostgreSQL 密码;也可用 PGPASSWORD")
subparser.add_argument("--pg-table", default=os.environ.get("PG_PATIENT_TABLE", DEFAULT_TABLE), help="PostgreSQL 表名")
export_parser = subparsers.add_parser("export", help="从 PostgreSQL 导出待复核 CSV")
add_pg_options(export_parser)
export_parser.add_argument("-o", "--output", type=Path, default=DEFAULT_OUTPUT, help="复核 CSV 输出路径")
export_parser.add_argument("--all", action="store_true", help="导出全部记录,而不仅是需复核/已人工修正记录")
import_parser = subparsers.add_parser("import", help="把人工复核 CSV 回写 PostgreSQL")
add_pg_options(import_parser)
import_parser.add_argument("-i", "--input", type=Path, default=DEFAULT_OUTPUT, help="人工复核 CSV 路径")
return parser
def main() -> int:
args = build_parser().parse_args()
if args.command == "export":
export_review(args)
elif args.command == "import":
import_review(args)
else:
raise RuntimeError(f"未知命令:{args.command}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc: # noqa: BLE001 - 命令行工具需要清楚输出错误
print(f"错误:{exc}", file=sys.stderr)
raise SystemExit(1)

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
对 Patient_FrontPages 做字段级体检。
输出:
- 数据处理结果区/05_质量体检/患者首页_数据库体检报告.txt
- 数据处理结果区/05_质量体检/患者首页_字段空值统计.csv
- 数据处理结果区/05_质量体检/患者首页_疑似异常记录.csv
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "数据处理结果区" / "05_质量体检"
DEFAULT_TABLE = "Patient_FrontPages"
def quote_pg_identifier(identifier: str) -> str:
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", identifier):
raise ValueError(f"非法 PostgreSQL 表名:{identifier}")
return '"' + identifier.replace('"', '""') + '"'
def connection_env(args: argparse.Namespace) -> dict[str, str]:
env = os.environ.copy()
env.update(
{
"PGHOST": args.pg_host or os.environ.get("PGHOST", ""),
"PGPORT": str(args.pg_port or os.environ.get("PGPORT", "")),
"PGDATABASE": args.pg_database or os.environ.get("PGDATABASE", ""),
"PGUSER": args.pg_user or os.environ.get("PGUSER", ""),
"PGPASSWORD": args.pg_password or os.environ.get("PGPASSWORD", ""),
}
)
missing = [name for name in ["PGHOST", "PGPORT", "PGDATABASE", "PGUSER", "PGPASSWORD"] if not env.get(name)]
if missing:
raise RuntimeError("缺少 PostgreSQL 连接配置:" + "".join(missing))
return env
def run_psql(sql: str, args: argparse.Namespace) -> str:
if shutil.which("psql") is None:
raise RuntimeError("未找到 psql。请先安装 PostgreSQL 客户端。")
with tempfile.TemporaryDirectory(prefix="patient_front_audit_") as tmpdir:
sql_path = Path(tmpdir) / "audit.sql"
sql_path.write_text(sql, encoding="utf-8")
completed = subprocess.run(
["psql", "--no-password", "--file", str(sql_path)],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
env=connection_env(args),
)
if completed.returncode != 0:
raise RuntimeError(completed.stderr.strip() or completed.stdout.strip() or "psql 执行失败")
return completed.stdout
def build_empty_stat_sql(table: str) -> str:
monitored_columns = [
"inpatient_no",
"medical_record_no",
"patient_name",
"gender",
"birth_date",
"id_card_no",
"contact_address",
"admission_time",
"discharge_time",
"major_department",
"primary_diagnosis",
"primary_diagnosis_code",
"discharge_diagnoses",
"operations",
"quality_status",
"review_status",
]
selects = []
for column in monitored_columns:
identifier = quote_pg_identifier(column)
if column in {"discharge_diagnoses", "operations"}:
empty_expr = f"{identifier} IS NULL OR jsonb_array_length({identifier}) = 0"
else:
empty_expr = f"{identifier} IS NULL OR {identifier}::text = ''"
selects.append(f"SELECT '{column}' AS column_name, count(*) FILTER (WHERE {empty_expr}) AS empty_count, count(*) AS total_count FROM {table}")
return " UNION ALL ".join(selects)
def audit(args: argparse.Namespace) -> None:
output_dir = args.output_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
table = quote_pg_identifier(args.pg_table)
empty_csv = output_dir / "患者首页_字段空值统计.csv"
suspicious_csv = output_dir / "患者首页_疑似异常记录.csv"
report_path = output_dir / "患者首页_数据库体检报告.txt"
empty_stat_sql = build_empty_stat_sql(table)
suspicious_sql = (
"SELECT source_file, inpatient_no, medical_record_no, patient_name, review_status, "
"review_notes::text AS review_notes, contact_address, primary_diagnosis, primary_diagnosis_code "
f"FROM {table} "
"WHERE review_status <> 'auto_pass' "
"OR inpatient_no !~ '^ZY\\d{12}$' "
"OR medical_record_no !~ '^\\d{10}$' "
"OR contact_address ~ '急诊|门诊|其他医疗机构转入|医嘱离院' "
"OR primary_diagnosis_code IS NULL "
"OR primary_diagnosis_code = '' "
"ORDER BY source_file"
)
sql = f"""
\\set ON_ERROR_STOP on
\\pset pager off
\\o {str(report_path).replace("'", "''")}
SELECT '总记录数' AS item, count(*)::text AS value FROM {table}
UNION ALL
SELECT '需复核记录数', count(*)::text FROM {table} WHERE review_status <> 'auto_pass'
UNION ALL
SELECT '缺字段注释数', count(*)::text
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
WHERE n.nspname = 'public'
AND c.relname = {args.pg_table!r}
AND a.attnum > 0
AND NOT a.attisdropped
AND col_description(c.oid, a.attnum) IS NULL;
\\o
\\copy ({empty_stat_sql}) TO '{str(empty_csv).replace("'", "''")}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
\\copy ({suspicious_sql}) TO '{str(suspicious_csv).replace("'", "''")}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
""".strip()
stdout = run_psql(sql, args)
if stdout.strip():
print(stdout.strip())
print(f"体检报告:{report_path}")
print(f"字段空值统计:{empty_csv}")
print(f"疑似异常记录:{suspicious_csv}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="核验 Patient_FrontPages 字段注释、空值和疑似错位记录。")
parser.add_argument("--pg-host", default=os.environ.get("PGHOST", ""), help="PostgreSQL 主机;也可用 PGHOST")
parser.add_argument("--pg-port", default=os.environ.get("PGPORT", "5432"), help="PostgreSQL 端口;也可用 PGPORT")
parser.add_argument("--pg-database", default=os.environ.get("PGDATABASE", ""), help="PostgreSQL 数据库;也可用 PGDATABASE")
parser.add_argument("--pg-user", default=os.environ.get("PGUSER", ""), help="PostgreSQL 用户名;也可用 PGUSER")
parser.add_argument("--pg-password", default=os.environ.get("PGPASSWORD", ""), help="PostgreSQL 密码;也可用 PGPASSWORD")
parser.add_argument("--pg-table", default=os.environ.get("PG_PATIENT_TABLE", DEFAULT_TABLE), help="PostgreSQL 表名")
parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="体检结果输出目录")
return parser
def main() -> int:
audit(build_parser().parse_args())
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc: # noqa: BLE001
print(f"错误:{exc}", file=sys.stderr)
raise SystemExit(1)

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import requests
import zipfile
import io
import shutil # 【新增】用于删除非空文件夹
import argparse # <--- 1. 确保这里导入了
def main():
# 1. 配置命令行参数解析
parser = argparse.ArgumentParser(description="Mineru PDF 转 Markdown 批量处理工具")
# 添加参数
parser.add_argument("-s", "--source", type=str, default="./Papers/ORI_PDF",
help="源 PDF 文件夹路径 (默认: ./Papers/ORI_PDF)")
parser.add_argument("-t", "--target", type=str, default="./Papers/ORI_MD",
help="目标 Markdown 文件夹路径 (默认: ./Papers/ORI_MD)")
parser.add_argument("-u", "--url", type=str, default="http://10.168.1.103:4000/extract",
help="API 服务端地址 (默认: http://10.168.1.103:4000/extract)")
parser.add_argument("--sync", action="store_true",
help="任务完成后是否开启反向同步清理(删除多余的输出文件夹)")
args = parser.parse_args()
# 使用解析后的参数
url = args.url
source_dir = args.source
target_dir = args.target
# 2. 确保源目录存在,避免报错
if not os.path.exists(source_dir):
print(f"错误: 找不到源文件夹 '{source_dir}'")
exit(1)
# 确保目标主文件夹存在
os.makedirs(target_dir, exist_ok=True)
# 3. 遍历源文件夹下的所有文件进行上传处理
for filename in os.listdir(source_dir):
# 只处理 PDF 文件
if not filename.lower().endswith(".pdf"):
continue
file_path = os.path.join(source_dir, filename)
# 获取去掉 .pdf 后缀的文件名
pdf_name_no_ext = os.path.splitext(filename)[0]
# 对应的输出文件夹路径
output_folder = os.path.join(target_dir, pdf_name_no_ext)
# ==========================================
# 检查是否已经处理过
# 如果输出文件夹已存在,且内部有文件,则视为已转换,直接跳过
# ==========================================
if os.path.exists(output_folder) and len(os.listdir(output_folder)) > 0:
print(f"⏩ 已存在转换结果,跳过处理: {filename}")
continue
print(f"正在上传并处理 {filename}...")
try:
# 4. 发送请求
with open(file_path, "rb") as f:
files = {"file": (filename, f, "application/pdf")}
response = requests.post(url, files=files)
# 5. 处理响应结果
if response.status_code == 200:
# 检查服务端是否返回了包含报错信息的 JSON
if response.headers.get('content-type') == 'application/json':
error_msg = response.json()
print(f"❌ 服务端处理失败 ({filename}){error_msg.get('message', '未知错误')}")
continue # 跳过解压,处理下一个
# 确保该 PDF 专属的输出文件夹存在
os.makedirs(output_folder, exist_ok=True)
# 核心:使用 io.BytesIO 直接在内存中读取 zip 内容并解压
try:
with zipfile.ZipFile(io.BytesIO(response.content)) as zip_ref:
zip_ref.extractall(output_folder)
print(f"✅ 成功!已解压并保存至文件夹: {output_folder}")
except zipfile.BadZipFile:
print(f"❌ 失败!{filename} 返回的内容不是有效的 ZIP 格式。")
else:
print(f"❌ 失败!{filename} 状态码: {response.status_code}, 报错: {response.text}")
except requests.exceptions.RequestException as e:
print(f"❌ 网络请求异常 ({filename}): {e}")
except Exception as e:
print(f"❌ 处理 {filename} 时发生未知错误: {e}")
print("-" * 30)
print("转换任务结束,开始进行文件夹同步清理...")
# ==========================================
# 【新增逻辑】:反向比对,清理多余的 MD 文件夹
# ==========================================
# 1. 收集当前 ORI_PDF 中所有有效的 PDF 名字(不含后缀)
valid_pdf_names = set()
for filename in os.listdir(source_dir):
if filename.lower().endswith(".pdf"):
valid_pdf_names.add(os.path.splitext(filename)[0])
# 2. 遍历 ORI_MD 文件夹
if os.path.exists(target_dir):
for folder_name in os.listdir(target_dir):
folder_path = os.path.join(target_dir, folder_name)
# 仅处理文件夹(以防里面有意外的独立文件)
if os.path.isdir(folder_path):
# 3. 如果这个文件夹的名字不在有效的 PDF 列表中,说明是被删掉的“孤儿”
if folder_name not in valid_pdf_names:
print(f"🧹 发现多余文件,正在清理: {folder_name}")
try:
# 使用 shutil.rmtree 删除整个文件夹及其内部所有内容
shutil.rmtree(folder_path)
except Exception as e:
print(f"❌ 删除 {folder_name} 时发生错误: {e}")
print("-" * 30)
print("所有批量任务彻底完成!")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n检测到用户中断,程序退出。")

View File

@@ -0,0 +1,697 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把患者首页 PDF 转为图片,并生成图片-结构化字段对照核验清单。
依赖系统命令 pdftoppm通常由 poppler-utils 提供。
"""
from __future__ import annotations
import argparse
import csv
import html
import json
import re
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from string import Template
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_INPUT_DIR = PROJECT_ROOT / "待处理-患者首页PDF"
DEFAULT_RESULT_DIR = PROJECT_ROOT / "数据处理结果区"
DEFAULT_IMAGE_REVIEW_DIR = DEFAULT_RESULT_DIR / "06_PDF图片对照"
@dataclass(frozen=True)
class FieldCheck:
group: str
key: str
level: str
location_hint: str
FIELD_CHECKS = [
FieldCheck("基本信息", "医疗机构", "recommended", "首页抬头及组织机构代码附近"),
FieldCheck("基本信息", "医疗付费方式", "recommended", "首页左上付费方式"),
FieldCheck("基本信息", "健康卡号", "recommended", "首页左上健康卡号"),
FieldCheck("基本信息", "住院次数", "recommended", "首页左上住院次数"),
FieldCheck("基本信息", "病案号", "required", "首页左上病案号"),
FieldCheck("基本信息", "姓名", "required", "基本信息行"),
FieldCheck("基本信息", "性别", "required", "基本信息行"),
FieldCheck("基本信息", "出生日期", "required", "基本信息行"),
FieldCheck("基本信息", "年龄", "required", "基本信息行"),
FieldCheck("基本信息", "国籍", "recommended", "基本信息行"),
FieldCheck("基本信息", "身份证号", "required", "基本信息行"),
FieldCheck("基本信息", "职业", "recommended", "基本信息行"),
FieldCheck("基本信息", "婚姻代码", "recommended", "基本信息行"),
FieldCheck("基本信息", "出生地", "recommended", "地址信息区"),
FieldCheck("基本信息", "籍贯", "recommended", "地址信息区"),
FieldCheck("基本信息", "民族", "recommended", "地址信息区"),
FieldCheck("地址联系人", "现住址", "required", "现住址行"),
FieldCheck("地址联系人", "现住址电话", "recommended", "现住址行"),
FieldCheck("地址联系人", "现住址邮编", "recommended", "现住址行"),
FieldCheck("地址联系人", "户口地址", "recommended", "户口地址行"),
FieldCheck("地址联系人", "户口地址邮编", "recommended", "户口地址行"),
FieldCheck("地址联系人", "工作单位及地址", "recommended", "工作单位及地址行"),
FieldCheck("地址联系人", "单位电话", "recommended", "工作单位及地址行"),
FieldCheck("地址联系人", "单位邮编", "recommended", "工作单位及地址行"),
FieldCheck("地址联系人", "联系人姓名", "required", "联系人信息行"),
FieldCheck("地址联系人", "联系人关系", "recommended", "联系人信息行"),
FieldCheck("地址联系人", "联系人地址", "recommended", "联系人信息行"),
FieldCheck("地址联系人", "联系人电话", "required", "联系人信息行"),
FieldCheck("入出院", "入院途径代码", "recommended", "入院途径勾选项"),
FieldCheck("入出院", "入院时间", "required", "入院记录行"),
FieldCheck("入出院", "入院科别", "required", "入院记录行"),
FieldCheck("入出院", "入院病房", "recommended", "入院记录行"),
FieldCheck("入出院", "转科科别", "optional", "转科科别行"),
FieldCheck("入出院", "出院时间", "required", "出院记录行"),
FieldCheck("入出院", "出院科别", "required", "出院记录行"),
FieldCheck("入出院", "出院病房", "recommended", "出院记录行"),
FieldCheck("入出院", "实际住院天数", "required", "出院记录行"),
FieldCheck("入出院", "大科室", "required", "由入院/出院科别映射"),
FieldCheck("诊断手术", "门急诊诊断", "recommended", "诊断区顶部"),
FieldCheck("诊断手术", "门急诊诊断编码", "recommended", "诊断区顶部"),
FieldCheck("诊断手术", "主要诊断名称", "required", "出院诊断表第一行"),
FieldCheck("诊断手术", "主要诊断编码", "required", "出院诊断表第一行"),
FieldCheck("诊断手术", "主要诊断入院病情", "required", "出院诊断表第一行"),
FieldCheck("诊断手术", "出院诊断", "recommended", "出院诊断表全部行"),
FieldCheck("诊断手术", "手术操作", "recommended", "手术及操作表"),
FieldCheck("诊断手术", "损伤中毒外部原因", "optional", "损伤中毒外部原因行"),
FieldCheck("诊断手术", "损伤中毒疾病编码", "optional", "损伤中毒外部原因行"),
FieldCheck("诊断手术", "病理诊断", "optional", "病理诊断行"),
FieldCheck("诊断手术", "病理诊断编码", "optional", "病理诊断行"),
FieldCheck("诊断手术", "病理号", "optional", "病理号"),
FieldCheck("质控信息", "药物过敏代码", "recommended", "药物过敏勾选项"),
FieldCheck("质控信息", "过敏药物", "optional", "过敏药物填写处"),
FieldCheck("质控信息", "死亡患者尸检代码", "recommended", "死亡患者尸检勾选项"),
FieldCheck("质控信息", "血型代码", "recommended", "血型勾选项"),
FieldCheck("质控信息", "Rh代码", "recommended", "Rh勾选项"),
FieldCheck("质控信息", "科主任", "recommended", "医师签名区"),
FieldCheck("质控信息", "主任副主任医师", "recommended", "医师签名区"),
FieldCheck("质控信息", "主治医师", "recommended", "医师签名区"),
FieldCheck("质控信息", "住院医师", "recommended", "医师签名区"),
FieldCheck("质控信息", "责任护士", "recommended", "护理签名区"),
FieldCheck("质控信息", "编码员", "recommended", "编码员签名区"),
FieldCheck("质控信息", "病案质量代码", "recommended", "病案质量勾选项"),
FieldCheck("质控信息", "质控医师", "recommended", "质控签名区"),
FieldCheck("质控信息", "质控护士", "recommended", "质控签名区"),
FieldCheck("质控信息", "质控日期", "recommended", "质控日期"),
FieldCheck("离院费用", "离院方式代码", "recommended", "离院方式勾选项"),
FieldCheck("离院费用", "出院31天内再住院计划代码", "recommended", "再住院计划勾选项"),
FieldCheck("离院费用", "再住院计划目的", "optional", "再住院计划目的"),
FieldCheck("离院费用", "入院前昏迷天数", "optional", "昏迷时间区"),
FieldCheck("离院费用", "入院后昏迷天数", "optional", "昏迷时间区"),
FieldCheck("离院费用", "总费用", "optional", "费用区"),
FieldCheck("离院费用", "自付金额", "optional", "费用区"),
FieldCheck("离院费用", "费用明细", "optional", "费用明细区"),
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="患者首页 PDF 转图片并生成字段对照核验报告")
parser.add_argument("-i", "--input-dir", type=Path, default=DEFAULT_INPUT_DIR, help="PDF 输入目录")
parser.add_argument("-r", "--result-dir", type=Path, default=DEFAULT_RESULT_DIR, help="解析结果根目录")
parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_IMAGE_REVIEW_DIR, help="图片对照输出目录")
parser.add_argument("--dpi", type=int, default=180, help="图片分辨率,默认 180")
parser.add_argument("--format", choices=["png", "jpeg"], default="png", help="图片格式,默认 png")
parser.add_argument("--force", action="store_true", help="重新生成已存在图片")
parser.add_argument("--strict-recommended", action="store_true", help="把建议字段缺项也写入对照建议")
return parser.parse_args()
def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, check=True, text=True, capture_output=True)
def numeric_page_key(path: Path) -> tuple[int, str]:
match = re.search(r"-(\d+)\.(png|jpe?g)$", path.name, flags=re.IGNORECASE)
if not match:
return (10**9, path.name)
return (int(match.group(1)), path.name)
def convert_pdf_to_images(pdf_path: Path, output_dir: Path, dpi: int, image_format: str, force: bool) -> tuple[list[Path], str]:
pdftoppm = shutil.which("pdftoppm")
if not pdftoppm:
return [], "未找到 pdftoppm请安装 poppler-utils。"
output_dir.mkdir(parents=True, exist_ok=True)
extension = "jpg" if image_format == "jpeg" else "png"
existing = sorted(output_dir.glob(f"page-*.{extension}"), key=numeric_page_key)
if existing and not force:
return existing, ""
if force:
for old_image in output_dir.glob("page-*.*"):
old_image.unlink()
prefix = output_dir / "page"
cmd = [pdftoppm, "-r", str(dpi), f"-{image_format}", str(pdf_path), str(prefix)]
try:
run(cmd)
except subprocess.CalledProcessError as exc:
detail = exc.stderr.strip() or exc.stdout.strip() or str(exc)
return [], f"图片转换失败:{detail}"
images = sorted(output_dir.glob(f"page-*.{extension}"), key=numeric_page_key)
normalized_images: list[Path] = []
for index, image_path in enumerate(images, start=1):
target = output_dir / f"page-{index:03d}.{extension}"
if image_path != target:
if target.exists():
target.unlink()
image_path.rename(target)
normalized_images.append(target)
return normalized_images, "" if normalized_images else "图片转换后未找到输出文件。"
def load_record(result_dir: Path, pdf_path: Path) -> tuple[dict[str, Any], Path | None, str]:
json_path = result_dir / "02_单份JSON" / f"{pdf_path.stem}.json"
if not json_path.exists():
return {}, None, "未找到结构化 JSON请先运行 02_解析入库 步骤。"
try:
return json.loads(json_path.read_text(encoding="utf-8")), json_path, ""
except json.JSONDecodeError as exc:
return {}, json_path, f"结构化 JSON 读取失败:{exc}"
def is_blank(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
text = value.strip()
return text == "" or text in {"-", "--", "null", "None", "[]", "{}"}
if isinstance(value, (list, tuple, set, dict)):
return len(value) == 0
return False
def value_preview(value: Any, max_length: int = 120) -> str:
if value is None:
return ""
if isinstance(value, (list, dict)):
text = json.dumps(value, ensure_ascii=False)
else:
text = str(value)
text = re.sub(r"\s+", " ", text).strip()
if len(text) > max_length:
return text[: max_length - 1] + ""
return text
def build_field_rows(record: dict[str, Any]) -> tuple[list[dict[str, str]], list[str], list[str]]:
rows: list[dict[str, str]] = []
missing_required: list[str] = []
missing_recommended: list[str] = []
for check in FIELD_CHECKS:
value = record.get(check.key)
missing = is_blank(value)
if missing and check.level == "required":
missing_required.append(check.key)
elif missing and check.level == "recommended":
missing_recommended.append(check.key)
rows.append(
{
"group": check.group,
"key": check.key,
"level": check.level,
"location_hint": check.location_hint,
"value": value_preview(value),
"missing": "" if missing else "",
}
)
return rows, missing_required, missing_recommended
def as_csv_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (list, dict)):
return json.dumps(value, ensure_ascii=False)
return str(value)
def relative_to(path: Path, start: Path) -> str:
try:
return path.relative_to(start).as_posix()
except ValueError:
return path.as_posix()
def html_escape(value: Any) -> str:
return html.escape(str(value), quote=True)
def make_case_summary(
pdf_path: Path,
images: list[Path],
image_error: str,
record: dict[str, Any],
json_path: Path | None,
json_error: str,
output_dir: Path,
strict_recommended: bool,
) -> dict[str, Any]:
field_rows, missing_required, missing_recommended = build_field_rows(record) if record else ([], [], [])
review_notes = record.get("复核备注", []) if record else []
if not isinstance(review_notes, list):
review_notes = [review_notes]
suggestions: list[str] = []
if image_error:
suggestions.append(image_error)
if json_error:
suggestions.append(json_error)
if missing_required:
suggestions.append("核心字段缺项,需对照图片补齐:" + "".join(missing_required))
if strict_recommended and missing_recommended:
suggestions.append("建议字段缺项,抽样确认是否首页未填写:" + "".join(missing_recommended))
if record.get("复核状态") and record.get("复核状态") != "auto_pass":
suggestions.append("该记录已有复核状态:" + str(record.get("复核状态")))
if review_notes:
suggestions.append("已有复核备注:" + "".join(value_preview(note, 80) for note in review_notes))
if not suggestions:
suggestions.append("图片与结构化字段抽查一致即可。")
return {
"pdf_path": pdf_path,
"source_file": pdf_path.name,
"images": images,
"image_dir": images[0].parent if images else output_dir / "图片" / pdf_path.stem,
"first_image": images[0] if images else None,
"page_count": len(images),
"image_error": image_error,
"record": record,
"json_path": json_path,
"json_error": json_error,
"field_rows": field_rows,
"missing_required": missing_required,
"missing_recommended": missing_recommended,
"suggestions": suggestions,
}
def write_index_csv(cases: list[dict[str, Any]], output_dir: Path) -> Path:
csv_path = output_dir / "患者首页_PDF图片对照索引.csv"
fieldnames = [
"源文件",
"页数",
"图片目录",
"首页图片",
"结构化JSON",
"病案号",
"姓名",
"复核状态",
"复核备注",
"核心缺项",
"建议核对缺项",
"对照建议",
]
with csv_path.open("w", encoding="utf-8-sig", newline="") as fp:
writer = csv.DictWriter(fp, fieldnames=fieldnames)
writer.writeheader()
for case in cases:
record = case["record"]
writer.writerow(
{
"源文件": case["source_file"],
"页数": case["page_count"],
"图片目录": relative_to(case["image_dir"], output_dir),
"首页图片": relative_to(case["first_image"], output_dir) if case["first_image"] else "",
"结构化JSON": relative_to(case["json_path"], output_dir) if case["json_path"] else "",
"病案号": record.get("病案号", ""),
"姓名": record.get("姓名", ""),
"复核状态": record.get("复核状态", ""),
"复核备注": as_csv_text(record.get("复核备注", [])),
"核心缺项": "".join(case["missing_required"]),
"建议核对缺项": "".join(case["missing_recommended"]),
"对照建议": "".join(case["suggestions"]),
}
)
return csv_path
def render_field_table(field_rows: list[dict[str, str]]) -> str:
if not field_rows:
return '<p class="empty">未生成字段核验表。</p>'
parts = [
'<table class="field-table">',
"<thead><tr><th>组</th><th>字段</th><th>级别</th><th>首页位置</th><th>值</th><th>缺项</th></tr></thead>",
"<tbody>",
]
for row in field_rows:
missing_class = " is-missing" if row["missing"] == "" and row["level"] != "optional" else ""
parts.append(
"<tr class=\"{missing_class}\">"
"<td>{group}</td><td>{key}</td><td>{level}</td><td>{location}</td><td>{value}</td><td>{missing}</td>"
"</tr>".format(
missing_class=missing_class.strip(),
group=html_escape(row["group"]),
key=html_escape(row["key"]),
level=html_escape(row["level"]),
location=html_escape(row["location_hint"]),
value=html_escape(row["value"]),
missing=html_escape(row["missing"]),
)
)
parts.append("</tbody></table>")
return "\n".join(parts)
def render_html(cases: list[dict[str, Any]], output_dir: Path) -> Path:
html_path = output_dir / "患者首页_PDF图片对照.html"
total = len(cases)
with_required_missing = sum(1 for case in cases if case["missing_required"])
with_review_status = sum(1 for case in cases if case["record"].get("复核状态") not in {"", "auto_pass", None})
failed_images = sum(1 for case in cases if case["image_error"])
nav_rows = []
case_sections = []
for index, case in enumerate(cases, start=1):
anchor = f"case-{index}"
record = case["record"]
status = record.get("复核状态", "未解析")
nav_rows.append(
"<tr><td><a href=\"#{anchor}\">{source}</a></td><td>{name}</td><td>{mrn}</td><td>{status}</td><td>{required}</td></tr>".format(
anchor=anchor,
source=html_escape(case["source_file"]),
name=html_escape(record.get("姓名", "")),
mrn=html_escape(record.get("病案号", "")),
status=html_escape(status),
required=html_escape("".join(case["missing_required"]) or ""),
)
)
images_html = []
for image_path in case["images"]:
rel_image = relative_to(image_path, output_dir)
images_html.append(
'<figure><img loading="lazy" src="{src}" alt="{alt}"><figcaption>{caption}</figcaption></figure>'.format(
src=html_escape(rel_image),
alt=html_escape(f"{case['source_file']} {image_path.name}"),
caption=html_escape(image_path.name),
)
)
if not images_html:
images_html.append('<p class="empty">未生成图片。</p>')
suggestion_items = "\n".join(f"<li>{html_escape(note)}</li>" for note in case["suggestions"])
summary_items = [
("病案号", record.get("病案号", "")),
("姓名", record.get("姓名", "")),
("大科室", record.get("大科室", "")),
("出院科别", record.get("出院科别", "")),
("主要诊断", record.get("主要诊断名称", "")),
("主要诊断编码", record.get("主要诊断编码", "")),
("复核状态", status),
]
summary_html = "\n".join(
f"<dt>{html_escape(label)}</dt><dd>{html_escape(value_preview(value, 160))}</dd>" for label, value in summary_items
)
case_sections.append(
"""
<section class="case" id="{anchor}">
<header class="case-header">
<div>
<h2>{source}</h2>
<p>{name} · {mrn} · {status}</p>
</div>
<a class="back-link" href="#top">返回顶部</a>
</header>
<div class="case-grid">
<div class="image-pane">{images}</div>
<div class="data-pane">
<dl class="summary">{summary}</dl>
<div class="suggestions"><h3>对照建议</h3><ul>{suggestions}</ul></div>
{field_table}
</div>
</div>
</section>
""".format(
anchor=anchor,
source=html_escape(case["source_file"]),
name=html_escape(record.get("姓名", "")),
mrn=html_escape(record.get("病案号", "")),
status=html_escape(status),
images="\n".join(images_html),
summary=summary_html,
suggestions=suggestion_items,
field_table=render_field_table(case["field_rows"]),
)
)
page_template = Template(
"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>患者首页 PDF 图片对照核验</title>
<style>
:root {
--ink: #15181c;
--muted: #5f6975;
--line: #d9dde3;
--paper: #f6f7f9;
--panel: #ffffff;
--bad: #b42318;
--bad-bg: #fff2ef;
--warn: #8a5a00;
--warn-bg: #fff7df;
--ok: #1f7a4d;
--ok-bg: #edf8f2;
--accent: #245b8f;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
line-height: 1.55;
}
header.report-header {
position: sticky;
top: 0;
z-index: 10;
padding: 14px 22px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.96);
}
h1, h2, h3 { margin: 0; letter-spacing: 0; }
h1 { font-size: 20px; }
h2 { font-size: 16px; }
h3 { font-size: 14px; margin-bottom: 8px; }
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.pill {
border: 1px solid var(--line);
background: #fff;
padding: 4px 8px;
border-radius: 6px;
color: var(--muted);
}
main { padding: 18px 22px 32px; }
.index {
overflow: auto;
border: 1px solid var(--line);
background: var(--panel);
border-radius: 8px;
margin-bottom: 18px;
}
table { width: 100%; border-collapse: collapse; }
th, td {
border-bottom: 1px solid var(--line);
padding: 7px 8px;
text-align: left;
vertical-align: top;
white-space: normal;
word-break: break-word;
}
th {
background: #eef1f5;
color: #303844;
font-weight: 700;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.case {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
margin: 18px 0;
overflow: hidden;
}
.case-header {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: #fbfcfd;
}
.case-header p {
margin: 4px 0 0;
color: var(--muted);
}
.back-link { white-space: nowrap; align-self: center; }
.case-grid {
display: grid;
grid-template-columns: minmax(420px, 55%) minmax(360px, 45%);
min-height: 520px;
}
.image-pane {
border-right: 1px solid var(--line);
background: #eceff3;
padding: 12px;
overflow: auto;
max-height: 86vh;
}
figure { margin: 0 0 14px; }
img {
display: block;
width: 100%;
height: auto;
border: 1px solid #c5cbd3;
background: #fff;
}
figcaption {
margin-top: 5px;
color: var(--muted);
font-size: 12px;
}
.data-pane {
padding: 12px;
overflow: auto;
max-height: 86vh;
}
.summary {
display: grid;
grid-template-columns: 108px 1fr;
gap: 5px 10px;
margin: 0 0 12px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfcfd;
}
dt { color: var(--muted); }
dd { margin: 0; font-weight: 600; }
.suggestions {
margin-bottom: 12px;
padding: 10px;
border: 1px solid #ead7a7;
border-radius: 8px;
background: var(--warn-bg);
color: var(--warn);
}
.suggestions ul { margin: 0; padding-left: 18px; }
.field-table td:nth-child(5) { max-width: 360px; }
.field-table .is-missing td {
background: var(--bad-bg);
color: var(--bad);
}
.empty {
color: var(--muted);
padding: 8px;
}
@media (max-width: 980px) {
.case-grid { grid-template-columns: 1fr; }
.image-pane { border-right: 0; border-bottom: 1px solid var(--line); max-height: none; }
.data-pane { max-height: none; }
}
</style>
</head>
<body>
<header class="report-header" id="top">
<h1>患者首页 PDF 图片对照核验</h1>
<div class="meta">
<span class="pill">PDF$total</span>
<span class="pill">核心缺项:$with_required_missing</span>
<span class="pill">需复核状态:$with_review_status</span>
<span class="pill">图片失败:$failed_images</span>
</div>
</header>
<main>
<section class="index">
<table>
<thead><tr><th>源文件</th><th>姓名</th><th>病案号</th><th>复核状态</th><th>核心缺项</th></tr></thead>
<tbody>$nav_rows</tbody>
</table>
</section>
$case_sections
</main>
</body>
</html>
"""
)
html_path.write_text(
page_template.substitute(
total=total,
with_required_missing=with_required_missing,
with_review_status=with_review_status,
failed_images=failed_images,
nav_rows="\n".join(nav_rows),
case_sections="\n".join(case_sections),
),
encoding="utf-8",
)
return html_path
def main() -> int:
args = parse_args()
input_dir = args.input_dir.resolve()
result_dir = args.result_dir.resolve()
output_dir = args.output_dir.resolve()
image_root = output_dir / "图片"
output_dir.mkdir(parents=True, exist_ok=True)
pdf_files = sorted(input_dir.glob("*.pdf"))
if not pdf_files:
raise SystemExit(f"未找到 PDF{input_dir}")
cases: list[dict[str, Any]] = []
for pdf_path in pdf_files:
case_image_dir = image_root / pdf_path.stem
images, image_error = convert_pdf_to_images(pdf_path, case_image_dir, args.dpi, args.format, args.force)
record, json_path, json_error = load_record(result_dir, pdf_path)
cases.append(
make_case_summary(
pdf_path=pdf_path,
images=images,
image_error=image_error,
record=record,
json_path=json_path,
json_error=json_error,
output_dir=output_dir,
strict_recommended=args.strict_recommended,
)
)
index_csv = write_index_csv(cases, output_dir)
html_path = render_html(cases, output_dir)
required_missing_count = sum(1 for case in cases if case["missing_required"])
print(f"PDF 数量:{len(cases)}")
print(f"图片输出:{image_root}")
print(f"对照索引:{index_csv}")
print(f"HTML 对照页:{html_path}")
print(f"核心缺项病例数:{required_missing_count}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Kimi 视觉兜底识别:用于 pdftotext/Mineru 对局部字段解析不稳定时,人工指定 PDF 图片页进行辅助抽取。
默认只输出 JSON 文件,不直接覆盖 PostgreSQL。
"""
from __future__ import annotations
import argparse
import base64
import json
import mimetypes
import os
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_IMAGE_ROOT = PROJECT_ROOT / "数据处理结果区" / "06_PDF图片对照" / "图片"
DEFAULT_PDF_ROOT = PROJECT_ROOT / "已处理-患者首页PDF" / "2026_5_25_处理"
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "数据处理结果区" / "07_Kimi视觉识别"
DEFAULT_API_BASE = "https://api.moonshot.cn/v1"
DEFAULT_MODEL = os.environ.get("KIMI_MODEL", "kimi-k2.6")
PROMPT = """请读取这张住院病案首页图片,仅返回 JSON。
优先核对这些区域:基本信息、地址联系人、入出院信息、诊断、手术及操作、质控签名、离院方式、费用。
如果是手术及操作区域,请按以下列拆分:
手术操作编码、手术操作日期、手术级别、手术操作名称、术者、I助、II助、切口愈合等级、麻醉方式、麻醉医师。
无法确认的字段填空字符串,不要编造。输出结构:
{
"page_summary": "",
"suspected_missing_fields": [],
"fields": {},
"discharge_diagnoses": [],
"operations": [],
"fees": {},
"notes": []
}
"""
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="使用 Kimi 视觉模型辅助识别患者首页图片")
parser.add_argument("--image-root", type=Path, default=DEFAULT_IMAGE_ROOT, help="PDF 转图片根目录")
parser.add_argument("--pdf-root", type=Path, default=DEFAULT_PDF_ROOT, help="没有图片目录时,从该 PDF 目录临时渲染页面")
parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="Kimi 识别结果输出目录")
parser.add_argument("--case", help="只处理指定病例目录名,例如 ZY010001672803_ori")
parser.add_argument("--page", help="只处理指定图片名,例如 page-001.png")
parser.add_argument("--pdf-page", type=int, help="从 PDF 临时渲染时,只处理指定页码,从 1 开始")
parser.add_argument("--dpi", type=int, default=160, help="PDF 临时渲染分辨率")
parser.add_argument("--model", default=DEFAULT_MODEL, help="Kimi 模型名")
parser.add_argument("--api-base", default=os.environ.get("MOONSHOT_API_BASE", DEFAULT_API_BASE), help="Moonshot API base URL")
parser.add_argument("--api-key", default=os.environ.get("MOONSHOT_API_KEY") or os.environ.get("KIMI_API_KEY"), help="API Key也可用 MOONSHOT_API_KEY")
return parser.parse_args()
def image_to_data_url(path: Path) -> str:
mime_type, _ = mimetypes.guess_type(path.name)
if mime_type not in {"image/png", "image/jpeg", "image/webp", "image/gif"}:
mime_type = "image/png"
encoded = base64.b64encode(path.read_bytes()).decode("utf-8")
return f"data:{mime_type};base64,{encoded}"
def call_kimi(image_path: Path, args: argparse.Namespace) -> dict[str, Any]:
if not args.api_key:
raise RuntimeError("未设置 MOONSHOT_API_KEY/KIMI_API_KEY无法调用 Kimi API。")
payload = {
"model": args.model,
"messages": [
{"role": "system", "content": "你是严谨的病案首页结构化抽取助手,只输出 JSON。"},
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": image_to_data_url(image_path)}},
{"type": "text", "text": PROMPT},
],
},
],
}
request = urllib.request.Request(
f"{args.api_base.rstrip('/')}/chat/completions",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {args.api_key}",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=180) as response:
data = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Kimi API 返回错误 {exc.code}: {detail}") from exc
content = data["choices"][0]["message"]["content"]
parsed = parse_json_content(content)
return {
"image": str(image_path),
"model": args.model,
"recognized_at": datetime.now().isoformat(timespec="seconds"),
"raw_response": content,
"parsed": parsed,
}
def parse_json_content(content: str) -> Any:
text = content.strip()
if text.startswith("```"):
text = text.strip("`")
if text.startswith("json"):
text = text[4:].strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return json.loads(text[start : end + 1])
return {"notes": ["Kimi 返回内容不是合法 JSON"], "text": content}
def iter_images(args: argparse.Namespace) -> list[Path]:
root = args.image_root
if args.case:
case_dir = root / args.case
if args.page:
return [case_dir / args.page]
return sorted(case_dir.glob("page-*.*"))
images: list[Path] = []
for case_dir in sorted(path for path in root.iterdir() if path.is_dir()):
images.extend(sorted(case_dir.glob("page-*.*")))
return images
def find_case_pdf(args: argparse.Namespace) -> Path | None:
if not args.case:
return None
stem = Path(args.case).stem
candidates = [
args.pdf_root / f"{stem}.pdf",
args.pdf_root / args.case,
]
return next((path for path in candidates if path.exists() and path.is_file()), None)
def render_pdf_pages(pdf_path: Path, output_root: Path, args: argparse.Namespace) -> list[Path]:
case_dir = output_root / pdf_path.stem
case_dir.mkdir(parents=True, exist_ok=True)
if args.pdf_page:
prefix = case_dir / f"page-{args.pdf_page:03d}"
command = [
"pdftoppm",
"-png",
"-r",
str(args.dpi),
"-f",
str(args.pdf_page),
"-singlefile",
str(pdf_path),
str(prefix),
]
else:
command = ["pdftoppm", "-png", "-r", str(args.dpi), str(pdf_path), str(case_dir / "page")]
subprocess.run(command, check=True)
return sorted(case_dir.glob("page-*.png")) or sorted(case_dir.glob("page.png"))
def main() -> int:
args = parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
images = [path for path in iter_images(args) if path.exists() and path.is_file()]
with tempfile.TemporaryDirectory(prefix="frontpage_kimi_") as temp_dir:
if not images:
pdf_path = find_case_pdf(args)
if pdf_path:
images = render_pdf_pages(pdf_path, Path(temp_dir), args)
if not images:
raise SystemExit("未找到待识别图片,也未找到可临时渲染的 PDF。")
for image_path in images:
print(f"识别:{image_path}")
result = call_kimi(image_path, args)
out_path = args.output_dir / f"{image_path.parent.name}_{image_path.stem}.json"
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"输出:{out_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc: # noqa: BLE001
print(f"错误:{exc}", file=sys.stderr)
raise SystemExit(1)