Import sanitized HIS processing tools
This commit is contained in:
19
患者列表处理/.env.example
Normal file
19
患者列表处理/.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
HIS_DB_HOST=DB_HOST
|
||||
HIS_DB_PORT=5432
|
||||
HIS_DB_NAME=DB_NAME
|
||||
HIS_DB_USER=DB_USER
|
||||
HIS_DB_PASSWORD=请填写数据库密码
|
||||
|
||||
REVIEW_APP_USERNAME=admin
|
||||
REVIEW_APP_PASSWORD=请填写网页端登录密码
|
||||
REVIEW_APP_SECRET_KEY=请填写随机字符串
|
||||
REVIEW_APP_PORT=8090
|
||||
REVIEW_POSTGRES_SYNC=1
|
||||
|
||||
KIMI_API_KEY=请填写Kimi或Moonshot API Key
|
||||
KIMI_MODEL=kimi-k2.6
|
||||
KIMI_TIMEOUT_SECONDS=80
|
||||
KIMI_IMAGE_MAX_WIDTH=1200
|
||||
|
||||
TENCENTCLOUD_SECRET_ID=请填写腾讯云SecretId
|
||||
TENCENTCLOUD_SECRET_KEY=请填写腾讯云SecretKey
|
||||
22
患者列表处理/.gitignore
vendored
Normal file
22
患者列表处理/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Patient images and generated results
|
||||
待处理-患者目录图片集群/
|
||||
已处理-患者目录图片集群/
|
||||
数据处理结果区/
|
||||
|
||||
# Local correction data can contain patient information
|
||||
数据处理工作区/03_人工复核修正.json
|
||||
数据处理工作区/人工复核网页端配置.json
|
||||
|
||||
# Local secrets and runtime files
|
||||
.env
|
||||
.pgpass
|
||||
工作流_本地使用版.md
|
||||
*.log
|
||||
2026_5_23_HIS患者目录图片集群处理*.zip
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# OS/editor noise
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
125
患者列表处理/README.md
Normal file
125
患者列表处理/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# HIS 患者列表图片归档
|
||||
|
||||
这个项目用于把 HIS 患者列表截图批量 OCR 为结构化记录,并同步到 PostgreSQL 单表。
|
||||
|
||||
## 目录约定
|
||||
|
||||
- `待处理-患者目录图片集群/`:后续新下载、尚未归档的图片集群。
|
||||
- `已处理-患者目录图片集群/`:已经完成归档的原始图片集群。
|
||||
- `数据处理工作区/`:处理脚本、科室分类规则、数据库建表脚本和说明文档。
|
||||
- `人工复核网页端/`:Docker 化的人工复核网页端。
|
||||
- `数据处理结果区/已处理-患者目录图片集群/`:各批次本地归档结果。
|
||||
- `数据处理结果区/信息记录/`:全局信息记录和批次信息记录。
|
||||
|
||||
数据、图片、OCR 缓存、处理结果不应提交到 Git。
|
||||
|
||||
## 工作区脚本
|
||||
|
||||
1. `数据处理工作区/01_科室分类规则.json`
|
||||
2. `数据处理工作区/02_患者列表OCR归档.py`
|
||||
3. `数据处理工作区/03_人工复核修正.json`
|
||||
4. `数据处理工作区/04_合并批次结果.py`
|
||||
5. `数据处理工作区/05_同步PostgreSQL单表.py`
|
||||
6. `数据处理工作区/06_PostgreSQL建表结构.sql`
|
||||
7. `数据处理工作区/07_处理程序说明.md`
|
||||
8. `数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql`
|
||||
9. `数据处理工作区/09_PostgreSQL住院号非空唯一约束.sql`
|
||||
|
||||
`03_人工复核修正.json` 可能包含患者信息,仓库中只保留模板文件。
|
||||
|
||||
## 处理新批次
|
||||
|
||||
```bash
|
||||
export TENCENTCLOUD_SECRET_ID='...'
|
||||
export TENCENTCLOUD_SECRET_KEY='...'
|
||||
|
||||
python3 数据处理工作区/02_患者列表OCR归档.py \
|
||||
--input "待处理-患者目录图片集群/批次文件夹名" \
|
||||
--output "数据处理结果区/已处理-患者目录图片集群/批次文件夹名-列表归档结果" \
|
||||
--ocr-engine table-v3 \
|
||||
--batch-size 6 \
|
||||
--image-padding-y 24 \
|
||||
--workers 1 \
|
||||
--folder-workers 2 \
|
||||
--timeout 90 \
|
||||
--max-retries 1
|
||||
```
|
||||
|
||||
处理完成后优先查看:
|
||||
|
||||
- `患者列表_结构化.json`
|
||||
- `患者列表_记录.csv`
|
||||
- `复核报告.json`
|
||||
- `重复住院号报告.json`
|
||||
|
||||
如果只想用已有 OCR 缓存重建结果:
|
||||
|
||||
```bash
|
||||
python3 数据处理工作区/02_患者列表OCR归档.py \
|
||||
--input "待处理-患者目录图片集群/批次文件夹名" \
|
||||
--output "数据处理结果区/已处理-患者目录图片集群/批次文件夹名-列表归档结果" \
|
||||
--ocr-engine table-v3 \
|
||||
--batch-size 6 \
|
||||
--image-padding-y 24 \
|
||||
--workers 1 \
|
||||
--folder-workers 2 \
|
||||
--rebuild-from-cache
|
||||
```
|
||||
|
||||
确认无误后,把原始图片目录移动到 `已处理-患者目录图片集群/`。
|
||||
|
||||
## 合并与入库
|
||||
|
||||
```bash
|
||||
python3 数据处理工作区/04_合并批次结果.py
|
||||
|
||||
export HIS_DB_HOST='DB_HOST'
|
||||
export HIS_DB_PORT='5432'
|
||||
export HIS_DB_NAME='DB_NAME'
|
||||
export HIS_DB_USER='DB_USER'
|
||||
export HIS_DB_PASSWORD='...'
|
||||
|
||||
python3 数据处理工作区/05_同步PostgreSQL单表.py
|
||||
```
|
||||
|
||||
PostgreSQL 只使用 `"Patient_Lists"` 一个正式表。科室映射、OCR 缓存路径、拼接图路径、OCR 请求号等处理过程信息保留在本地工作区和结果目录,不进入数据库正式表。
|
||||
|
||||
## 人工复核网页端
|
||||
|
||||
在根目录 `.env` 中配置 `REVIEW_APP_USERNAME`、`REVIEW_APP_PASSWORD`、`REVIEW_APP_SECRET_KEY`、`REVIEW_APP_PORT`;如需抽查功能,再配置 `KIMI_API_KEY`、`KIMI_MODEL`。可参考 `.env.example`。启动:
|
||||
|
||||
```bash
|
||||
cd 人工复核网页端
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
默认访问 `http://127.0.0.1:8090`。网页端包含概览、复核、抽查、抽查查看、设置区域,会读取各批次 `复核报告.json`,按图片内行号裁剪原图,人工修订会保存到 `数据处理工作区/03_人工复核修正.json` 并尽量同步 PostgreSQL。出院时间允许为空;若填写,则会校验时间格式以及是否早于入院时间。
|
||||
|
||||
入库后建议核对:
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM "Patient_Lists";
|
||||
SELECT count(*) FROM "Patient_Lists" WHERE review_status = '需人工复核';
|
||||
SELECT inpatient_no FROM "Patient_Lists" GROUP BY inpatient_no HAVING count(*) > 1;
|
||||
```
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 默认使用 `RecognizeTableAccurateOCR` 表格识别 V3。
|
||||
- 患者字段顺序固定为:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数。
|
||||
- 拼接图默认给每张原图上下各加 `24px` 白边,减少贴边表格行被 OCR 忽略的概率。
|
||||
- 处理时优先 6 张拼接;如果行数偏少、接口超时或识别失败,程序会动态降到 4/3/2/单张。
|
||||
- `住院号` 是唯一强制校验条件:不能为空且全库唯一。重复住院号按后出现记录覆盖先出现记录;格式异常但非空的住院号保留,后续在网页端人工核验。
|
||||
- `出院时间` 允许为空;若入院时间晚于出院时间,程序和 PostgreSQL 都会把它作为需复核问题处理。
|
||||
- `自动复核通过` 表示 OCR 结果经过规则校验后无明显异常;`人工复核通过` 表示该行命中了人工修正配置,修正后校验通过。
|
||||
- 抽查结果直接写入 PostgreSQL 的 `audit_*` 字段,不再使用本地 `抽查记录.json`。
|
||||
- `AI修改-待确认` 只作为网页端辅助修正草稿,不会被“提交待同步”当作人工确认结果入库。
|
||||
- 旧批次若用缓存重建,要沿用当时的拼接和白边参数,避免新图片名和旧 OCR 缓存不匹配。
|
||||
|
||||
## 迁移注意
|
||||
|
||||
- 需要 Python 3、`Pillow` 和 PostgreSQL 客户端 `psql`。
|
||||
- 腾讯云密钥和数据库密码只通过环境变量传入,不要写入脚本或提交到文件。
|
||||
- `raw_ocr/` 是 OCR 响应缓存,迁移时建议保留,可减少重复调用。
|
||||
- `merged_images/` 是拼接图,便于复核。
|
||||
- PostgreSQL 结构以 `数据处理工作区/06_PostgreSQL建表结构.sql` 为准。
|
||||
4
患者列表处理/人工复核网页端/.dockerignore
Normal file
4
患者列表处理/人工复核网页端/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.log
|
||||
15
患者列表处理/人工复核网页端/.env.example
Normal file
15
患者列表处理/人工复核网页端/.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
REVIEW_APP_USERNAME=admin
|
||||
REVIEW_APP_PASSWORD=change-me
|
||||
REVIEW_APP_SECRET_KEY=change-me-to-a-random-string
|
||||
REVIEW_APP_PORT=8090
|
||||
REVIEW_POSTGRES_SYNC=1
|
||||
KIMI_API_KEY=change-me
|
||||
KIMI_API_ENABLED=1
|
||||
KIMI_MODEL=kimi-k2.6
|
||||
KIMI_TIMEOUT_SECONDS=120
|
||||
KIMI_IMAGE_MAX_WIDTH=1200
|
||||
KIMI_AUDIT_MAX_TOKENS=768
|
||||
KIMI_CORRECTION_MAX_TOKENS=768
|
||||
|
||||
# Docker 内默认挂载项目根目录到 /workspace;本机直接运行时可改为项目根目录。
|
||||
WORKSPACE_ROOT=/workspace
|
||||
17
患者列表处理/人工复核网页端/Dockerfile
Normal file
17
患者列表处理/人工复核网页端/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py ./app.py
|
||||
COPY templates ./templates
|
||||
COPY static ./static
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app", "--workers", "2", "--threads", "4", "--timeout", "180"]
|
||||
62
患者列表处理/人工复核网页端/README.md
Normal file
62
患者列表处理/人工复核网页端/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# HIS 患者列表人工复核网页端
|
||||
|
||||
用于查看 `复核报告.json` 中的待处理记录,按原图行号生成局部截图,保存人工修订到 `数据处理工作区/03_人工复核修正.json`。
|
||||
|
||||
## 配置
|
||||
|
||||
在项目根目录 `.env` 中增加:
|
||||
|
||||
```bash
|
||||
REVIEW_APP_USERNAME=admin
|
||||
REVIEW_APP_PASSWORD=请填写本地登录密码
|
||||
REVIEW_APP_SECRET_KEY=请填写随机字符串
|
||||
REVIEW_APP_PORT=8090
|
||||
KIMI_API_KEY=请填写Kimi或Moonshot API Key
|
||||
KIMI_MODEL=kimi-k2.6
|
||||
```
|
||||
|
||||
如果未设置 `REVIEW_APP_PASSWORD`,程序会回退使用 `HIS_DB_PASSWORD` 作为登录密码。
|
||||
|
||||
默认启用 PostgreSQL 联动,保存或删除人工修订时会先按 `image_path + image_row_no` 更新 `"Patient_Lists"` 对应记录;如果合并去重后正式表只保留了同住院号的另一条记录,会继续按 `inpatient_no` 匹配更新。PostgreSQL 暂不可用时,本地修订不会丢失,会写入待同步状态,恢复连接后点击“提交待同步”即可补交。需要临时关闭时可设置:
|
||||
|
||||
```bash
|
||||
REVIEW_POSTGRES_SYNC=0
|
||||
```
|
||||
|
||||
## Docker 启动
|
||||
|
||||
```bash
|
||||
cd 人工复核网页端
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8090
|
||||
```
|
||||
|
||||
## 复核后重建
|
||||
|
||||
网页保存后,人工修订会写入:
|
||||
|
||||
```text
|
||||
数据处理工作区/03_人工复核修正.json
|
||||
```
|
||||
|
||||
之后对相应批次运行 `02_患者列表OCR归档.py --rebuild-from-cache`,再执行合并和入库脚本即可。
|
||||
|
||||
出院时间默认允许为空;确有出院时间时,网页端会校验时间格式以及是否早于入院时间。
|
||||
PostgreSQL 中也已加入对应约束:空出院时间放行;入院时间晚于出院时间时,该记录必须保持 `需人工复核` 状态。
|
||||
|
||||
时间字段支持输入 `2021-11-24 9:46:59`、`2021-12-8 9:38:00` 这类单数字月、日、小时格式;网页端和后端保存时会统一补全为 `YYYY-MM-DD HH:MM:SS`。
|
||||
|
||||
保存前如果仍有校验提示,网页会弹窗确认是否继续保存;无提示保存成功后会自动切到下一条待处理记录。
|
||||
|
||||
页面右上角会显示 PostgreSQL 连接状态,也会每 20 秒自动检测一次;如果本地修订保存成功但数据库同步失败,会标记为待同步,之后点击“提交待同步”可重新提交。提交后会汇总成功、未匹配、失败数量,未成功项可选择保留或从待同步列表删除。
|
||||
|
||||
字段顺序固定为:入院时间、最后书写时间、住院天数、出院时间。字段校验提示统一显示在截图下方,右侧编辑区不再重复显示。截图区支持鼠标滚轮缩放,“恢复尺寸”会回到图片原始大小。
|
||||
|
||||
抽查页会随机抽取患者行,调用 Kimi 多模态模型核对裁剪截图与结构化字段;抽查结论、AI 反馈、人工反馈直接写入 PostgreSQL 的 `audit_*` 字段,可在“抽查查看”页回看。复核页也可以调用“Kimi自动修改当前项/5项”,生成的内容会标记为 `AI修改-待确认`,不会进入待同步;人工点击“保存确认”后才会作为正式修订同步数据库。
|
||||
|
||||
设置页可新增网页端用户,为每个配置用户单独设置是否能看到概览、复核、抽查、抽查查看、设置,也可修改抽查模型和 API Key。设置内容保存在本地 `数据处理工作区/人工复核网页端配置.json`,该文件不提交到 Git。
|
||||
2127
患者列表处理/人工复核网页端/app.py
Normal file
2127
患者列表处理/人工复核网页端/app.py
Normal file
File diff suppressed because it is too large
Load Diff
19
患者列表处理/人工复核网页端/docker-compose.yml
Normal file
19
患者列表处理/人工复核网页端/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
name: his-list-review
|
||||
|
||||
services:
|
||||
manual-review:
|
||||
build:
|
||||
context: .
|
||||
image: his-list-manual-review:latest
|
||||
container_name: his-list-manual-review
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
WORKSPACE_ROOT: /workspace
|
||||
REVIEW_APP_HOST: 0.0.0.0
|
||||
REVIEW_APP_PORT: 8000
|
||||
ports:
|
||||
- "${REVIEW_APP_PORT:-8090}:8000"
|
||||
volumes:
|
||||
- ..:/workspace
|
||||
restart: unless-stopped
|
||||
5
患者列表处理/人工复核网页端/requirements.txt
Normal file
5
患者列表处理/人工复核网页端/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Flask==3.0.3
|
||||
gunicorn==23.0.0
|
||||
Pillow==10.4.0
|
||||
psycopg[binary]==3.2.9
|
||||
python-dotenv==1.0.1
|
||||
1339
患者列表处理/人工复核网页端/static/app.css
Normal file
1339
患者列表处理/人工复核网页端/static/app.css
Normal file
File diff suppressed because it is too large
Load Diff
2042
患者列表处理/人工复核网页端/static/app.js
Normal file
2042
患者列表处理/人工复核网页端/static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
382
患者列表处理/人工复核网页端/templates/index.html
Normal file
382
患者列表处理/人工复核网页端/templates/index.html
Normal file
@@ -0,0 +1,382 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>患者目录图片集群人工复核及抽查</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="top-title">
|
||||
<div class="eyebrow">HIS LIST REVIEW</div>
|
||||
<h1>患者目录图片集群人工复核及抽查</h1>
|
||||
</div>
|
||||
<span id="saveState" class="save-state"></span>
|
||||
<div class="top-actions">
|
||||
<nav class="top-nav">
|
||||
<button class="nav-button active" type="button" data-view="overview">概览</button>
|
||||
<button class="nav-button" type="button" data-view="review">复核</button>
|
||||
<button class="nav-button" type="button" data-view="audit">抽查</button>
|
||||
<button class="nav-button" type="button" data-view="auditHistory">抽查一览</button>
|
||||
<button class="nav-button" type="button" data-view="settings">设置</button>
|
||||
</nav>
|
||||
<span id="pgState" class="pg-state"></span>
|
||||
<button id="pgTestButton" class="ghost-button" type="button">测试PG</button>
|
||||
<button id="pgSyncButton" class="ghost-button" type="button">提交待同步</button>
|
||||
<form method="post" action="/logout">
|
||||
<button class="ghost-button" type="submit">退出</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="overviewView" class="page-view overview-view">
|
||||
<section class="overview-grid" id="overviewGrid"></section>
|
||||
<section class="overview-panel">
|
||||
<h2>复核工作台</h2>
|
||||
<div id="overviewBatches" class="batch-grid"></div>
|
||||
</section>
|
||||
<section class="overview-panel">
|
||||
<h2>抽查一览工作台</h2>
|
||||
<div id="overviewAudit" class="batch-grid"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main id="reviewView" class="workspace page-view hidden">
|
||||
<aside class="queue-panel" id="queuePanel" tabindex="0">
|
||||
<section class="stats-grid" id="statsGrid"></section>
|
||||
<div class="filters">
|
||||
<label class="inline-field review-filter-field">
|
||||
<span class="field-prefix">数据源:</span>
|
||||
<select id="batchFilter"></select>
|
||||
</label>
|
||||
<input type="hidden" id="statusFilter" value="pending">
|
||||
<div class="review-status-row">
|
||||
<div class="inline-field review-filter-field">
|
||||
<span class="field-prefix">状态:</span>
|
||||
<div id="statusSegment" class="status-segment" aria-label="复核状态">
|
||||
<button type="button" class="status-pill active" data-status="pending">待处理</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="inline-field review-filter-field">
|
||||
<span class="field-prefix">其他:</span>
|
||||
<select id="moreStatusFilter" aria-label="其他复核状态">
|
||||
<option value="">其他状态</option>
|
||||
<option value="confirming">待确认</option>
|
||||
<option value="done">人工复核通过</option>
|
||||
<option value="all">全部</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="inline-field review-filter-field">
|
||||
<span class="field-prefix">排序:</span>
|
||||
<select id="sortFilter">
|
||||
<option value="source">默认顺序</option>
|
||||
<option value="updated_desc">最近修订优先</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inline-field review-filter-field">
|
||||
<span class="field-prefix">搜索:</span>
|
||||
<input id="searchInput" placeholder="姓名/住院号/科室">
|
||||
</label>
|
||||
<div class="ai-action-panel review-ai-panel">
|
||||
<div class="ai-action-title"><span class="ai-badge">AI</span><span>辅助修改</span></div>
|
||||
<div class="ai-action-buttons">
|
||||
<button type="button" id="kimiCurrentButton" class="secondary-button ai-button">修改当前项</button>
|
||||
<button type="button" id="kimiFiveButton" class="secondary-button ai-button">修改下5项</button>
|
||||
<button type="button" id="kimiRemainingButton" class="secondary-button ai-button">修改下所有项</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="queueList" class="queue-list"></div>
|
||||
</aside>
|
||||
|
||||
<section class="image-panel">
|
||||
<div class="index-note">核对与同步都以住院号作为唯一定位依据,姓名和科室只作辅助参考</div>
|
||||
<div class="record-strip">
|
||||
<div>
|
||||
<div id="recordTitle" class="record-title">未选择记录</div>
|
||||
<div id="recordMeta" class="record-meta"></div>
|
||||
</div>
|
||||
<a id="fullImageLink" class="ghost-button" target="_blank" rel="noreferrer">原图</a>
|
||||
</div>
|
||||
<div class="crop-frame" id="cropFrame">
|
||||
<img id="cropImage" alt="裁剪截图" draggable="false">
|
||||
</div>
|
||||
<div class="zoom-actions">
|
||||
<button id="zoomOutButton" class="ghost-button" type="button">缩小</button>
|
||||
<button id="zoomResetButton" class="ghost-button" type="button">恢复尺寸</button>
|
||||
<button id="zoomInButton" class="ghost-button" type="button">放大</button>
|
||||
<span id="zoomLabel" class="queue-meta">100%</span>
|
||||
</div>
|
||||
<div id="tipsBox" class="tips-box"></div>
|
||||
</section>
|
||||
|
||||
<section class="edit-panel">
|
||||
<form id="editForm" class="edit-form">
|
||||
<div class="form-grid">
|
||||
<label>姓名<input name="姓名"></label>
|
||||
<label>性别<input name="性别"></label>
|
||||
<label>年龄<input name="年龄"></label>
|
||||
<label>住院号<input name="住院号"></label>
|
||||
</div>
|
||||
<label>诊断<textarea name="诊断" rows="4"></textarea></label>
|
||||
<div class="form-grid">
|
||||
<label>入院时间<input name="入院时间"></label>
|
||||
<label>最后书写时间<input name="最后书写时间"></label>
|
||||
<label>住院天数<input name="住院天数"></label>
|
||||
<label>出院时间<input name="出院时间"></label>
|
||||
</div>
|
||||
<label>手术后天数(格式为 空 或 后X天)<input name="手术后天数"></label>
|
||||
<div id="reviewChangeLog" class="change-log review-change-log"></div>
|
||||
<label>复核备注<textarea id="manualNote" rows="3"></textarea></label>
|
||||
<div class="button-row review-actions">
|
||||
<button type="submit" class="primary-button">保存确认</button>
|
||||
<button type="button" id="resetButton" class="secondary-button">还原 OCR</button>
|
||||
<button type="button" id="deleteButton" class="danger-button">删除修订</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main id="auditView" class="page-view hidden audit-view">
|
||||
<section class="audit-toolbar">
|
||||
<label class="inline-field">
|
||||
<span class="field-prefix">数据源:</span>
|
||||
<select id="auditSource">
|
||||
<option value="manual_review_passed">已处理-人工复核通过项</option>
|
||||
<option value="db_auto_passed">数据库-自动复核通过</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inline-field">
|
||||
<span class="field-prefix">数量:</span>
|
||||
<input id="auditCount" type="number" min="1" max="20" value="5">
|
||||
</label>
|
||||
<button id="auditSampleButton" class="primary-button" type="button">随机抽取</button>
|
||||
<div class="ai-action-panel audit-ai-panel">
|
||||
<div class="ai-action-title"><span class="ai-badge">AI</span><span>智能抽查</span></div>
|
||||
<div class="ai-action-buttons">
|
||||
<button id="auditKimiButton" class="secondary-button ai-button" type="button">AI抽查当前项</button>
|
||||
<button id="auditKimiAllButton" class="secondary-button ai-button" type="button">AI抽查所有项</button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="auditState" class="save-state"></span>
|
||||
</section>
|
||||
<section class="audit-layout">
|
||||
<div id="auditList" class="queue-list"></div>
|
||||
<div class="audit-detail">
|
||||
<div class="record-strip">
|
||||
<div>
|
||||
<div id="auditTitle" class="record-title">未选择抽查记录</div>
|
||||
<div id="auditMeta" class="record-meta"></div>
|
||||
</div>
|
||||
<a id="auditFullImageLink" class="ghost-button" target="_blank" rel="noreferrer">原图</a>
|
||||
</div>
|
||||
<div class="crop-frame audit-frame" id="auditFrame">
|
||||
<div class="audit-image-stage">
|
||||
<img id="auditImage" alt="抽查截图" draggable="false">
|
||||
</div>
|
||||
</div>
|
||||
<div class="zoom-actions">
|
||||
<button id="auditZoomOutButton" class="ghost-button" type="button">缩小</button>
|
||||
<button id="auditZoomResetButton" class="ghost-button" type="button">恢复尺寸</button>
|
||||
<button id="auditZoomInButton" class="ghost-button" type="button">放大</button>
|
||||
<span id="auditZoomLabel" class="queue-meta">100%</span>
|
||||
</div>
|
||||
<section class="model-label ai-result-section">
|
||||
<span>AI结论与建议</span>
|
||||
<div id="auditModelResult" class="model-result"></div>
|
||||
</section>
|
||||
<details id="auditPromptDetails" class="ai-full-output ai-prompt-output">
|
||||
<summary>询问提示词</summary>
|
||||
<pre id="auditPromptText"></pre>
|
||||
</details>
|
||||
<details id="auditRawOutputDetails" class="ai-full-output">
|
||||
<summary>AI完整输出</summary>
|
||||
<pre id="auditRawOutput"></pre>
|
||||
</details>
|
||||
<textarea id="auditPrompt" class="hidden" readonly></textarea>
|
||||
</div>
|
||||
<aside class="audit-side">
|
||||
<h2>患者信息修订</h2>
|
||||
<div id="auditPatientInfo" class="audit-edit-grid"></div>
|
||||
<div id="auditTipsBox" class="tips-box"></div>
|
||||
<div id="auditChangeLog" class="change-log"></div>
|
||||
<input id="auditMachineVerdict" type="hidden" value="">
|
||||
<div class="audit-verdict-grid">
|
||||
<span>AI核验判断</span>
|
||||
<strong id="auditMachineVerdictText">未判断</strong>
|
||||
<span>人工核验判断</span>
|
||||
<strong id="auditManualVerdictText">未选择</strong>
|
||||
</div>
|
||||
<label>人工反馈<textarea id="auditManualFeedback" rows="4"></textarea></label>
|
||||
<div class="settings-label">人工核验判断</div>
|
||||
<div class="button-row">
|
||||
<button id="auditPassButton" class="primary-button" type="button">人工核验通过</button>
|
||||
<button id="auditFailButton" class="danger-button" type="button">人工核验异常</button>
|
||||
<button id="auditUnsureButton" class="secondary-button" type="button">暂不确定</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main id="auditHistoryView" class="page-view hidden audit-view audit-history-view">
|
||||
<section class="audit-toolbar audit-history-toolbar">
|
||||
<button id="auditHistoryRefreshButton" class="primary-button" type="button">刷新抽查记录</button>
|
||||
<span id="auditHistoryState" class="save-state"></span>
|
||||
</section>
|
||||
<section class="audit-layout audit-history-layout">
|
||||
<aside class="audit-history-left">
|
||||
<div class="audit-history-filters">
|
||||
<label class="inline-field">
|
||||
<span class="field-prefix">数据源:</span>
|
||||
<select id="auditHistorySourceFilter">
|
||||
<option value="">全部抽查源</option>
|
||||
<option value="manual_review_passed">已处理-人工复核通过项</option>
|
||||
<option value="db_auto_passed">数据库-自动复核通过</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inline-field">
|
||||
<span class="field-prefix">状态:</span>
|
||||
<select id="auditHistoryStatusFilter">
|
||||
<option value="">全部状态</option>
|
||||
<option value="manual_pending">未人工核验</option>
|
||||
<option value="manual_passed">人工核验通过</option>
|
||||
<option value="manual_failed">人工核验异常</option>
|
||||
<option value="manual_unsure">暂不确定</option>
|
||||
<option value="ai_failed">AI核验异常</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inline-field">
|
||||
<span class="field-prefix">排序:</span>
|
||||
<select id="auditHistorySortFilter">
|
||||
<option value="checked_desc">最近抽查优先</option>
|
||||
<option value="checked_asc">最早抽查优先</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inline-field">
|
||||
<span class="field-prefix">搜索:</span>
|
||||
<input id="auditHistorySearchInput" placeholder="姓名/住院号/科室">
|
||||
</label>
|
||||
</div>
|
||||
<div id="auditHistoryList" class="queue-list"></div>
|
||||
</aside>
|
||||
<div class="audit-detail">
|
||||
<div class="record-strip">
|
||||
<div>
|
||||
<div id="auditHistoryTitle" class="record-title">未选择抽查记录</div>
|
||||
<div id="auditHistoryMeta" class="record-meta"></div>
|
||||
</div>
|
||||
<a id="auditHistoryFullImageLink" class="ghost-button" target="_blank" rel="noreferrer">原图</a>
|
||||
</div>
|
||||
<div class="crop-frame audit-frame" id="auditHistoryFrame">
|
||||
<div class="audit-image-stage">
|
||||
<img id="auditHistoryImage" alt="抽查截图" draggable="false">
|
||||
</div>
|
||||
</div>
|
||||
<div class="zoom-actions">
|
||||
<button id="auditHistoryZoomOutButton" class="ghost-button" type="button">缩小</button>
|
||||
<button id="auditHistoryZoomResetButton" class="ghost-button" type="button">恢复尺寸</button>
|
||||
<button id="auditHistoryZoomInButton" class="ghost-button" type="button">放大</button>
|
||||
<span id="auditHistoryZoomLabel" class="queue-meta">100%</span>
|
||||
</div>
|
||||
<section class="model-label">
|
||||
<span>AI结论与建议</span>
|
||||
<div id="auditHistoryAiResult" class="model-result"></div>
|
||||
</section>
|
||||
<details class="ai-full-output">
|
||||
<summary>AI完整输出</summary>
|
||||
<pre id="auditHistoryRawOutput"></pre>
|
||||
</details>
|
||||
</div>
|
||||
<aside class="audit-side">
|
||||
<h2>最终审核</h2>
|
||||
<div id="auditHistoryPatientInfo" class="audit-edit-grid"></div>
|
||||
<div id="auditHistoryTipsBox" class="tips-box"></div>
|
||||
<div id="auditHistoryChangeLog" class="change-log"></div>
|
||||
<div class="audit-verdict-grid">
|
||||
<span>AI核验判断</span>
|
||||
<strong id="auditHistoryAiVerdict"></strong>
|
||||
<span>人工核验判断</span>
|
||||
<strong id="auditHistoryManualVerdict"></strong>
|
||||
<span>抽查源</span>
|
||||
<strong id="auditHistorySource"></strong>
|
||||
<span>操作人</span>
|
||||
<strong id="auditHistoryUser"></strong>
|
||||
<span>抽查时间</span>
|
||||
<strong id="auditHistoryCheckedAt"></strong>
|
||||
</div>
|
||||
<div class="settings-label">最终人工核验判断</div>
|
||||
<div class="button-row">
|
||||
<button id="auditHistoryPassButton" class="primary-button" type="button">人工核验通过</button>
|
||||
<button id="auditHistoryFailButton" class="danger-button" type="button">人工核验异常</button>
|
||||
<button id="auditHistoryUnsureButton" class="secondary-button" type="button">暂不确定</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main id="settingsView" class="page-view hidden settings-view">
|
||||
<section class="settings-layout">
|
||||
<section class="settings-panel user-admin-panel">
|
||||
<div class="settings-heading">
|
||||
<h2>用户与权限</h2>
|
||||
<span id="settingsUserCount" class="badge">0 个用户</span>
|
||||
</div>
|
||||
<form id="userForm" class="new-user-form">
|
||||
<div class="settings-form-grid">
|
||||
<label>新用户名<input id="newUsername" autocomplete="off"></label>
|
||||
<label>密码<input id="newPassword" type="password" autocomplete="new-password"></label>
|
||||
</div>
|
||||
<div>
|
||||
<div class="settings-label">页面权限</div>
|
||||
<div id="newUserPermissions" class="permission-grid"></div>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">新建用户</button>
|
||||
</form>
|
||||
<div id="userList" class="settings-list"></div>
|
||||
</section>
|
||||
|
||||
<aside class="settings-side">
|
||||
<form id="kimiForm" class="settings-panel">
|
||||
<div class="settings-heading">
|
||||
<h2>抽查大模型 API</h2>
|
||||
<button id="kimiToggleButton" class="secondary-button compact-button" type="button">关闭API</button>
|
||||
</div>
|
||||
<label>Kimi API Key<input id="kimiApiKey" type="password" autocomplete="off"></label>
|
||||
<label>模型<input id="kimiModel" value="kimi-k2.6"></label>
|
||||
<button class="primary-button" type="submit">保存 Kimi 设置</button>
|
||||
<div id="settingsState" class="save-state"></div>
|
||||
</form>
|
||||
<section class="settings-panel">
|
||||
<div class="settings-heading">
|
||||
<h2>处理项寻找</h2>
|
||||
</div>
|
||||
<p class="settings-note">进入系统时会自动扫描一次,查找复核、抽查和同步里仍需处理的数据。</p>
|
||||
<div id="processingFinderGrid" class="finder-grid"></div>
|
||||
<button id="processingFindButton" class="secondary-button" type="button">重新寻找处理项</button>
|
||||
<div id="processingFinderState" class="save-state"></div>
|
||||
</section>
|
||||
<section class="settings-panel">
|
||||
<div class="settings-heading">
|
||||
<h2>数据提交</h2>
|
||||
</div>
|
||||
<p class="settings-note">将已人工复核通过的修订写入合并结果文件,重试同步数据库,并把这些条目归档隐藏。</p>
|
||||
<button id="commitManualPassedButton" class="primary-button" type="button">提交已人工通过数据</button>
|
||||
<div id="commitManualPassedState" class="save-state"></div>
|
||||
</section>
|
||||
<section class="settings-panel">
|
||||
<div class="settings-heading">
|
||||
<h2>抽查记录管理</h2>
|
||||
</div>
|
||||
<p class="settings-note">清空数据库中的抽查一览结果,不影响患者基础信息和复核修订。</p>
|
||||
<button id="resetAuditHistoryButton" class="danger-button" type="button">重置抽查一览</button>
|
||||
<div id="resetAuditHistoryState" class="save-state"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
患者列表处理/人工复核网页端/templates/login.html
Normal file
29
患者列表处理/人工复核网页端/templates/login.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>患者目录图片集群人工复核及抽查</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body class="login-shell">
|
||||
<main class="login-panel">
|
||||
<div class="login-mark">HIS</div>
|
||||
<h1>患者目录图片集群人工复核及抽查</h1>
|
||||
<form method="post" class="login-form">
|
||||
<label>
|
||||
账号
|
||||
<input name="username" autocomplete="username" autofocus>
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input name="password" type="password" autocomplete="current-password">
|
||||
</label>
|
||||
{% if error %}
|
||||
<div class="form-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
202
患者列表处理/工作流_Gitea版.md
Normal file
202
患者列表处理/工作流_Gitea版.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# HIS 患者列表 OCR 归档工作流
|
||||
|
||||
这是可提交到 Gitea 的无密钥版本。不要在本文件中写入腾讯云密钥、数据库密码、Gitea 密码或患者处理结果。
|
||||
|
||||
## 目标
|
||||
|
||||
把 HIS 患者列表截图整理为结构化患者记录,并同步到 PostgreSQL 的 `"Patient_Lists"` 单表。
|
||||
|
||||
最终数据库只保留正式查看和追溯需要的信息:
|
||||
|
||||
- 数据定位:`batch_name`、`source_folder`、`image_path`、`image_name`、`image_row_no`
|
||||
- 科室信息:`major_department`、`sub_department`
|
||||
- 患者信息:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数
|
||||
- 复核信息:`review_status`、`review_notes`、`manual_corrected`
|
||||
|
||||
OCR 请求号、OCR 缓存路径、拼接图路径、拼接组号等中间态字段不进入数据库正式表。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `待处理-患者目录图片集群/`:尚未处理的 HIS 图片批次。
|
||||
- `已处理-患者目录图片集群/`:已经完成处理的原始图片批次。
|
||||
- `数据处理工作区/`:程序、科室规则、数据库结构、说明文档。
|
||||
- `数据处理结果区/已处理-患者目录图片集群/`:各批次本地处理结果。
|
||||
- `数据处理结果区/信息记录/`:全局信息记录、批次汇总、待处理盘点等。
|
||||
|
||||
仓库只提交程序和文档。图片、OCR 缓存、处理结果、人工修正数据都应被 `.gitignore` 排除。
|
||||
|
||||
## 关键文件
|
||||
|
||||
1. `数据处理工作区/01_科室分类规则.json`
|
||||
2. `数据处理工作区/02_患者列表OCR归档.py`
|
||||
3. `数据处理工作区/03_人工复核修正.template.json`
|
||||
4. `数据处理工作区/04_合并批次结果.py`
|
||||
5. `数据处理工作区/05_同步PostgreSQL单表.py`
|
||||
6. `数据处理工作区/06_PostgreSQL建表结构.sql`
|
||||
7. `数据处理工作区/07_处理程序说明.md`
|
||||
8. `数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql`
|
||||
|
||||
本地实际使用时,可以从模板复制出 `数据处理工作区/03_人工复核修正.json`。这个文件可能包含患者信息,不提交到 Git。
|
||||
|
||||
患者列表字段顺序固定为:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数。
|
||||
|
||||
## 处理前准备
|
||||
|
||||
安装依赖:
|
||||
|
||||
```bash
|
||||
python3 -m pip install pillow
|
||||
psql --version
|
||||
```
|
||||
|
||||
设置腾讯云 OCR 环境变量:
|
||||
|
||||
```bash
|
||||
export TENCENTCLOUD_SECRET_ID='填入腾讯云 SecretId'
|
||||
export TENCENTCLOUD_SECRET_KEY='填入腾讯云 SecretKey'
|
||||
```
|
||||
|
||||
OCR 接口使用腾讯云 `RecognizeTableAccurateOCR` 表格识别 V3:
|
||||
|
||||
- 表格识别 V3 文档:https://cloud.tencent.com/document/product/866/86721
|
||||
|
||||
## 处理一个批次
|
||||
|
||||
示例批次名用 `批次文件夹名` 表示:
|
||||
|
||||
```bash
|
||||
python3 数据处理工作区/02_患者列表OCR归档.py \
|
||||
--input "待处理-患者目录图片集群/批次文件夹名" \
|
||||
--output "数据处理结果区/已处理-患者目录图片集群/批次文件夹名-列表归档结果" \
|
||||
--ocr-engine table-v3 \
|
||||
--batch-size 6 \
|
||||
--image-padding-y 24 \
|
||||
--workers 1 \
|
||||
--folder-workers 2 \
|
||||
--timeout 90 \
|
||||
--max-retries 1
|
||||
```
|
||||
|
||||
推荐正式处理优先使用 `--batch-size 6`。程序会根据行数校验和接口错误自动降到 4/3/2/单张;降档会增加调用次数,但能降低漏行风险。
|
||||
|
||||
处理完成后检查:
|
||||
|
||||
- `患者列表_结构化.json`
|
||||
- `患者列表_记录.csv`
|
||||
- `复核报告.json`
|
||||
- `重复住院号报告.json`
|
||||
- `信息记录/汇总.json`
|
||||
|
||||
## 复核规则
|
||||
|
||||
推荐使用 Docker 化网页端进行复核和抽查:
|
||||
|
||||
```bash
|
||||
cd 人工复核网页端
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
网页端包含概览、复核、抽查、设置四个区域;抽查功能使用 Kimi 多模态模型,配置项通过 `.env` 或网页端设置页提供,不在 Gitea 文档中保存密钥。
|
||||
|
||||
`review_status` 常见值:
|
||||
|
||||
- `自动复核通过`:OCR 结果经过程序规则校验后没有发现明显异常,`manual_corrected=false`。
|
||||
- `人工复核通过`:该行命中了 `03_人工复核修正.json` 中的人工修正项,修正后校验通过,`manual_corrected=true`。
|
||||
- `需人工复核`:程序无法可靠判断,需要人工查看图片和记录,原因写在 `review_notes`。
|
||||
|
||||
`出院时间` 允许为空;若填写出院时间,则需要满足标准时间格式,且不应早于入院时间。PostgreSQL 约束会阻止“入院时间晚于出院时间”却仍被标记为通过的记录。
|
||||
|
||||
人工修正流程:
|
||||
|
||||
1. 打开该批次的 `复核报告.json`。
|
||||
2. 根据 `图片路径` 和 `图片内行号` 回看原图。
|
||||
3. 把确认后的患者字段写入 `数据处理工作区/03_人工复核修正.json`。
|
||||
4. 使用缓存重建批次:
|
||||
|
||||
```bash
|
||||
python3 数据处理工作区/02_患者列表OCR归档.py \
|
||||
--input "待处理-患者目录图片集群/批次文件夹名" \
|
||||
--output "数据处理结果区/已处理-患者目录图片集群/批次文件夹名-列表归档结果" \
|
||||
--ocr-engine table-v3 \
|
||||
--batch-size 6 \
|
||||
--image-padding-y 24 \
|
||||
--workers 1 \
|
||||
--folder-workers 2 \
|
||||
--rebuild-from-cache
|
||||
```
|
||||
|
||||
## 合并所有批次
|
||||
|
||||
```bash
|
||||
python3 数据处理工作区/04_合并批次结果.py
|
||||
```
|
||||
|
||||
输出包括:
|
||||
|
||||
- `数据处理结果区/合并_患者列表_结构化.json`
|
||||
- `数据处理结果区/合并_患者列表_记录.csv`
|
||||
- `数据处理结果区/信息记录/全局汇总.json`
|
||||
- `数据处理结果区/信息记录/批次汇总.csv`
|
||||
- `数据处理结果区/信息记录/重复住院号报告.json`
|
||||
|
||||
## 同步 PostgreSQL
|
||||
|
||||
设置数据库环境变量:
|
||||
|
||||
```bash
|
||||
export HIS_DB_HOST='数据库主机'
|
||||
export HIS_DB_PORT='5432'
|
||||
export HIS_DB_NAME='DB_NAME'
|
||||
export HIS_DB_USER='DB_USER'
|
||||
export HIS_DB_PASSWORD='填入数据库密码'
|
||||
```
|
||||
|
||||
同步:
|
||||
|
||||
```bash
|
||||
python3 数据处理工作区/05_同步PostgreSQL单表.py
|
||||
```
|
||||
|
||||
核对:
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM "Patient_Lists";
|
||||
SELECT review_status, manual_corrected, count(*) FROM "Patient_Lists" GROUP BY review_status, manual_corrected;
|
||||
SELECT inpatient_no FROM "Patient_Lists" GROUP BY inpatient_no HAVING count(*) > 1;
|
||||
```
|
||||
|
||||
`"Patient_Lists"` 是带大小写的 PostgreSQL 表名,查询时必须加双引号。
|
||||
|
||||
## 处理完成后
|
||||
|
||||
确认批次结果和数据库无误后,将原始图片批次移动到:
|
||||
|
||||
```bash
|
||||
mv "待处理-患者目录图片集群/批次文件夹名" "已处理-患者目录图片集群/"
|
||||
```
|
||||
|
||||
## Gitea 提交
|
||||
|
||||
仓库地址:
|
||||
|
||||
```text
|
||||
https://gitea.huijutec.cn/admin/HIS_List.git
|
||||
```
|
||||
|
||||
提交前检查不要包含数据和密钥:
|
||||
|
||||
```bash
|
||||
git status --short --ignored
|
||||
git diff --cached --name-only
|
||||
grep -R "Secret\\|PASSWORD\\|密码" -n README.md 数据处理工作区 --exclude-dir='__pycache__' || true
|
||||
```
|
||||
|
||||
提交:
|
||||
|
||||
```bash
|
||||
git add .gitignore .env.example README.md 工作流_Gitea版.md 人工复核网页端 数据处理工作区/01_科室分类规则.json 数据处理工作区/02_患者列表OCR归档.py 数据处理工作区/03_人工复核修正.template.json 数据处理工作区/04_合并批次结果.py 数据处理工作区/05_同步PostgreSQL单表.py 数据处理工作区/06_PostgreSQL建表结构.sql 数据处理工作区/07_处理程序说明.md 数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql
|
||||
git commit -m "Update HIS patient list workflow"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
如果本地 `origin` 没有保存认证信息,需要使用一次性认证或交互式登录,但不要把密码写入文档或 Git 配置。
|
||||
258
患者列表处理/数据处理工作区/01_科室分类规则.json
Normal file
258
患者列表处理/数据处理工作区/01_科室分类规则.json
Normal file
@@ -0,0 +1,258 @@
|
||||
{
|
||||
"说明": "每个具体子科室只归属一个大科室;aliases 用于把图片文件夹名归一到标准子科室名。",
|
||||
"大科室列表": [
|
||||
{
|
||||
"大科室": "肝胆外科及肝移植相关",
|
||||
"子科室": [
|
||||
"肝胆外科1",
|
||||
"肝胆外科2",
|
||||
"肝胆外科3",
|
||||
"肝胆外科4",
|
||||
"肝胆外科5D",
|
||||
"肝胆外科手术室",
|
||||
"肝胆特种病区L",
|
||||
"肝移植内",
|
||||
"肝移植内N"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "普通外科及腹部外科",
|
||||
"子科室": [
|
||||
"普外科1",
|
||||
"普外科2",
|
||||
"普外科3",
|
||||
"普外科4D",
|
||||
"腹部外科L",
|
||||
"普通腺体外科",
|
||||
"普通胃肠外科"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "急诊医学科",
|
||||
"子科室": [
|
||||
"急诊中心",
|
||||
"急诊中心L",
|
||||
"急诊重症医学科L"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "重症医学科",
|
||||
"子科室": [
|
||||
"※重症医学1病区",
|
||||
"重症医学2N病区",
|
||||
"重症医学3病区",
|
||||
"重症医学4D病区",
|
||||
"外科ICU",
|
||||
"肝胆ICU",
|
||||
"肿瘤外科重症",
|
||||
"感染重症"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "骨科",
|
||||
"子科室": [
|
||||
"骨科",
|
||||
"骨科1",
|
||||
"骨科2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "泌尿外科",
|
||||
"子科室": [
|
||||
"泌尿外1",
|
||||
"泌尿外2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "呼吸内科",
|
||||
"子科室": [
|
||||
"呼吸内科3病区"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "耳鼻喉头颈外科",
|
||||
"子科室": [
|
||||
"耳鼻喉D",
|
||||
"耳鼻喉头颈外科",
|
||||
"耳鼻喉头颈外科L",
|
||||
"耳鼻喉2病区"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "日间诊疗中心",
|
||||
"子科室": [
|
||||
"日间手术中心",
|
||||
"日间中心病房L"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "乳腺外科",
|
||||
"子科室": [
|
||||
"乳腺外科1N",
|
||||
"乳腺外科2N",
|
||||
"乳腺外科2D"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "胸外科",
|
||||
"子科室": [
|
||||
"胸外1",
|
||||
"胸外2",
|
||||
"胸外3",
|
||||
"胸外4D"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "肝病内科",
|
||||
"子科室": [
|
||||
"肝病内科"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "感染科",
|
||||
"子科室": [
|
||||
"感染1",
|
||||
"感染2",
|
||||
"感染3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "肿瘤放疗科",
|
||||
"子科室": [
|
||||
"肿瘤放疗1",
|
||||
"肿瘤放疗2",
|
||||
"肿瘤放疗3",
|
||||
"肿瘤放疗病区L",
|
||||
"肿瘤放疗中心L【无人】",
|
||||
"肿瘤放疗日间【无人】"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "特需/涉外病房",
|
||||
"子科室": [
|
||||
"健苑五涉外病房"
|
||||
]
|
||||
},
|
||||
{
|
||||
"大科室": "老年外科",
|
||||
"子科室": [
|
||||
"老年外科"
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": {
|
||||
"呼吸内科3": "呼吸内科3病区",
|
||||
"外科ICU": "外科ICU",
|
||||
"急诊中心病房": "急诊中心",
|
||||
"急诊中心": "急诊中心",
|
||||
"急诊中心病房L": "急诊中心L",
|
||||
"急诊中心L": "急诊中心L",
|
||||
"急诊重症医学L": "急诊重症医学科L",
|
||||
"急诊重症医学": "急诊重症医学科L",
|
||||
"急诊重症L": "急诊重症医学科L",
|
||||
"急诊重症医学科L": "急诊重症医学科L",
|
||||
"感染科1": "感染1",
|
||||
"感染科2": "感染2",
|
||||
"感染科3": "感染3",
|
||||
"感染1": "感染1",
|
||||
"感染2": "感染2",
|
||||
"感染3": "感染3",
|
||||
"感染科重症": "感染重症",
|
||||
"感染重症": "感染重症",
|
||||
"肝病内科": "肝病内科",
|
||||
"肝病内科N": "肝病内科",
|
||||
"肝胆外科1": "肝胆外科1",
|
||||
"肝胆外科2": "肝胆外科2",
|
||||
"肝胆外科3": "肝胆外科3",
|
||||
"肝胆外科4": "肝胆外科4",
|
||||
"肝胆1": "肝胆外科1",
|
||||
"肝胆2": "肝胆外科2",
|
||||
"肝胆3": "肝胆外科3",
|
||||
"肝胆4": "肝胆外科4",
|
||||
"肝胆5": "肝胆外科5D",
|
||||
"肝胆_5": "肝胆外科5D",
|
||||
"肝胆5D": "肝胆外科5D",
|
||||
"肝胆外科5病房D": "肝胆外科5D",
|
||||
"肝胆特种L": "肝胆特种病区L",
|
||||
"肝胆特种病区L": "肝胆特种病区L",
|
||||
"肝胆移植内": "肝移植内",
|
||||
"肝移植内": "肝移植内",
|
||||
"肝移植内科": "肝移植内",
|
||||
"肝移植内N": "肝移植内N",
|
||||
"肝移植内科N": "肝移植内N",
|
||||
"肿瘤放疗1": "肿瘤放疗1",
|
||||
"肿瘤放疗2": "肿瘤放疗2",
|
||||
"肿瘤放疗3": "肿瘤放疗3",
|
||||
"肿放1": "肿瘤放疗1",
|
||||
"肿放2": "肿瘤放疗2",
|
||||
"肿放3": "肿瘤放疗3",
|
||||
"肿瘤放疗L": "肿瘤放疗病区L",
|
||||
"肿放L": "肿瘤放疗病区L",
|
||||
"重症医学1": "※重症医学1病区",
|
||||
"重症医学科1": "※重症医学1病区",
|
||||
"重症1": "※重症医学1病区",
|
||||
"重症医学2": "重症医学2N病区",
|
||||
"重症医学科2": "重症医学2N病区",
|
||||
"重症2": "重症医学2N病区",
|
||||
"重症2N": "重症医学2N病区",
|
||||
"重症医学3": "重症医学3病区",
|
||||
"重症医学科3": "重症医学3病区",
|
||||
"重症3": "重症医学3病区",
|
||||
"重症医学4": "重症医学4D病区",
|
||||
"重症医学科4": "重症医学4D病区",
|
||||
"重症4": "重症医学4D病区",
|
||||
"重症4D": "重症医学4D病区",
|
||||
"胸外1": "胸外1",
|
||||
"胸外2": "胸外2",
|
||||
"胸外3": "胸外3",
|
||||
"胸外4D": "胸外4D",
|
||||
"普外1": "普外科1",
|
||||
"普外2": "普外科2",
|
||||
"普外3": "普外科3",
|
||||
"普外4D": "普外科4D",
|
||||
"普通外科1": "普外科1",
|
||||
"普通外科2": "普外科2",
|
||||
"普通外科3": "普外科3",
|
||||
"普通外科4": "普外科4D",
|
||||
"普通外科4D": "普外科4D",
|
||||
"普通腺体外科": "普通腺体外科",
|
||||
"普通胃肠": "普通胃肠外科",
|
||||
"普通胃肠外科": "普通胃肠外科",
|
||||
"腹部外科L": "腹部外科L",
|
||||
"肝胆ICU": "肝胆ICU",
|
||||
"日间手术": "日间手术中心",
|
||||
"日间手术中心": "日间手术中心",
|
||||
"日间手术L": "日间中心病房L",
|
||||
"日间中心L": "日间中心病房L",
|
||||
"日间中心病房L": "日间中心病房L",
|
||||
"耳鼻喉": "耳鼻喉头颈外科",
|
||||
"耳鼻喉D": "耳鼻喉D",
|
||||
"耳鼻喉L": "耳鼻喉头颈外科L",
|
||||
"耳鼻喉头颈外科": "耳鼻喉头颈外科",
|
||||
"耳鼻喉头颈外科L": "耳鼻喉头颈外科L",
|
||||
"耳鼻咽喉": "耳鼻喉头颈外科",
|
||||
"耳鼻咽喉头颈外科": "耳鼻喉头颈外科",
|
||||
"耳鼻喉2": "耳鼻喉2病区",
|
||||
"耳鼻喉2D": "耳鼻喉2病区",
|
||||
"耳鼻喉头颈2": "耳鼻喉2病区",
|
||||
"乳腺外科": "乳腺外科1N",
|
||||
"乳腺外科1": "乳腺外科1N",
|
||||
"乳腺外科1N": "乳腺外科1N",
|
||||
"乳腺外科2": "乳腺外科2N",
|
||||
"乳腺外科2N": "乳腺外科2N",
|
||||
"乳腺外科2D": "乳腺外科2D",
|
||||
"乳腺1N": "乳腺外科1N",
|
||||
"乳腺2N": "乳腺外科2N",
|
||||
"乳腺2D": "乳腺外科2D",
|
||||
"骨科": "骨科",
|
||||
"骨科1": "骨科1",
|
||||
"骨科2": "骨科2",
|
||||
"骨科_1": "骨科1",
|
||||
"骨科_2": "骨科2",
|
||||
"泌尿1": "泌尿外1",
|
||||
"泌尿2": "泌尿外2",
|
||||
"健苑五涉外": "健苑五涉外病房",
|
||||
"健苑五涉外病房": "健苑五涉外病房",
|
||||
"老年外科": "老年外科"
|
||||
}
|
||||
}
|
||||
1346
患者列表处理/数据处理工作区/02_患者列表OCR归档.py
Normal file
1346
患者列表处理/数据处理工作区/02_患者列表OCR归档.py
Normal file
File diff suppressed because it is too large
Load Diff
1
患者列表处理/数据处理工作区/03_人工复核修正.template.json
Normal file
1
患者列表处理/数据处理工作区/03_人工复核修正.template.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
247
患者列表处理/数据处理工作区/04_合并批次结果.py
Normal file
247
患者列表处理/数据处理工作区/04_合并批次结果.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Consolidate patient archive result folders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path("数据处理结果区")
|
||||
RESULT_SUFFIX = "-列表归档结果"
|
||||
INFO_DIR_NAME = "信息记录"
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def write_jsonl(path: Path, records: list[dict[str, Any]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as file:
|
||||
for record in records:
|
||||
file.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def flatten_record(batch_name: str, record: dict[str, Any]) -> dict[str, Any]:
|
||||
patient = record["患者信息"]
|
||||
image = record["图片信息"]
|
||||
review = record["复核"]
|
||||
return {
|
||||
"处理批次": batch_name,
|
||||
"大科室": record["大科室"],
|
||||
"子科室": record["子科室"],
|
||||
"来源文件夹": record["来源文件夹"],
|
||||
"图片路径": image["图片路径"],
|
||||
"图片名": image["图片名"],
|
||||
"图片内行号": image["图片内行号"],
|
||||
"拼接组序号": image["拼接组序号"],
|
||||
"OCR请求ID": image.get("OCR请求ID", ""),
|
||||
"姓名": patient["姓名"],
|
||||
"性别": patient["性别"],
|
||||
"年龄": patient["年龄"],
|
||||
"住院号": patient["住院号"],
|
||||
"诊断": patient["诊断"],
|
||||
"入院时间": patient["入院时间"],
|
||||
"最后书写时间": patient["最后书写时间"],
|
||||
"住院天数": patient["住院天数"],
|
||||
"出院时间": patient["出院时间"],
|
||||
"手术后天数": patient["手术后天数"],
|
||||
"复核状态": review["状态"],
|
||||
"复核提示": review_note_text(review),
|
||||
"人工修正": bool(review.get("人工修正")),
|
||||
}
|
||||
|
||||
|
||||
def review_note_text(review: dict[str, Any]) -> str:
|
||||
notes = [str(item) for item in review.get("提示", []) if str(item) and str(item) != "缺少出院时间"]
|
||||
if review.get("人工备注"):
|
||||
notes.append(f"人工备注: {review['人工备注']}")
|
||||
return ";".join(notes)
|
||||
|
||||
|
||||
def record_quality_rank(record: dict[str, Any]) -> tuple[int, int, int, int]:
|
||||
patient = record["患者信息"]
|
||||
review = record["复核"]
|
||||
review_ok = 0 if review.get("状态") == "需人工复核" else 1
|
||||
manual_corrected = 1 if review.get("人工修正") else 0
|
||||
date_count = int(bool(patient.get("入院时间"))) + int(bool(patient.get("出院时间")))
|
||||
filled_count = sum(1 for value in patient.values() if value not in ("", None))
|
||||
return (review_ok, manual_corrected, date_count, filled_count)
|
||||
|
||||
|
||||
def summarize_record_for_duplicate(record: dict[str, Any]) -> dict[str, Any]:
|
||||
patient = record["患者信息"]
|
||||
image = record["图片信息"]
|
||||
review = record["复核"]
|
||||
return {
|
||||
"处理批次": record["处理批次"],
|
||||
"大科室": record["大科室"],
|
||||
"子科室": record["子科室"],
|
||||
"来源文件夹": record["来源文件夹"],
|
||||
"图片路径": image["图片路径"],
|
||||
"图片名": image["图片名"],
|
||||
"图片内行号": image["图片内行号"],
|
||||
"姓名": patient.get("姓名", ""),
|
||||
"住院号": patient.get("住院号", ""),
|
||||
"入院时间": patient.get("入院时间", ""),
|
||||
"出院时间": patient.get("出院时间", ""),
|
||||
"复核状态": review.get("状态", ""),
|
||||
}
|
||||
|
||||
|
||||
def deduplicate_records_by_inpatient_no(
|
||||
records: list[dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
kept_by_no: dict[str, dict[str, Any]] = {}
|
||||
kept_order: list[str] = []
|
||||
duplicates: list[dict[str, Any]] = []
|
||||
missing_inpatient_no_records: list[dict[str, Any]] = []
|
||||
for record in records:
|
||||
inpatient_no = str(record["患者信息"].get("住院号", "")).strip()
|
||||
if not inpatient_no:
|
||||
missing_inpatient_no_records.append(
|
||||
{
|
||||
"剔除记录": summarize_record_for_duplicate(record),
|
||||
"规则": "住院号为空,未纳入合并总表和数据库",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if inpatient_no not in kept_by_no:
|
||||
kept_order.append(inpatient_no)
|
||||
else:
|
||||
previous = kept_by_no[inpatient_no]
|
||||
duplicates.append(
|
||||
{
|
||||
"住院号": inpatient_no,
|
||||
"保留记录": summarize_record_for_duplicate(record),
|
||||
"剔除记录": summarize_record_for_duplicate(previous),
|
||||
"规则": "住院号重复,后出现记录覆盖先出现记录",
|
||||
}
|
||||
)
|
||||
kept_by_no[inpatient_no] = record
|
||||
return [kept_by_no[inpatient_no] for inpatient_no in kept_order], duplicates, missing_inpatient_no_records
|
||||
|
||||
|
||||
def slim_department_summary(batch_name: str, item: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_tips = item.get("提示", [])
|
||||
if isinstance(raw_tips, list):
|
||||
tips = ";".join(str(tip) for tip in raw_tips)
|
||||
else:
|
||||
tips = str(raw_tips)
|
||||
return {
|
||||
"处理批次": batch_name,
|
||||
"来源文件夹": item["来源文件夹"],
|
||||
"大科室": item["大科室"],
|
||||
"子科室": item["子科室"],
|
||||
"图片数": item["图片数"],
|
||||
"记录数": item["记录数"],
|
||||
"拼接组数": item["拼接组数"],
|
||||
"提示": tips,
|
||||
}
|
||||
|
||||
|
||||
def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
|
||||
if not rows:
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8-sig", newline="") as file:
|
||||
writer = csv.DictWriter(file, fieldnames=list(rows[0].keys()))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ROOT.mkdir(exist_ok=True)
|
||||
raw_records: list[dict[str, Any]] = []
|
||||
batch_summaries: list[dict[str, Any]] = []
|
||||
|
||||
for result_dir in sorted(ROOT.rglob(f"*{RESULT_SUFFIX}")):
|
||||
archive_path = result_dir / "患者列表_结构化.json"
|
||||
review_path = result_dir / "复核报告.json"
|
||||
if not archive_path.exists():
|
||||
continue
|
||||
batch_name = result_dir.name[: -len(RESULT_SUFFIX)]
|
||||
archive = json.loads(archive_path.read_text(encoding="utf-8"))
|
||||
review = json.loads(review_path.read_text(encoding="utf-8")) if review_path.exists() else {}
|
||||
department_summaries = [
|
||||
slim_department_summary(batch_name, item) for item in archive.get("科室汇总", [])
|
||||
]
|
||||
summary = {
|
||||
"处理批次": batch_name,
|
||||
"结果目录": str(result_dir),
|
||||
**archive.get("汇总", {}),
|
||||
"科室归类": archive.get("科室归类", []),
|
||||
"科室汇总": department_summaries,
|
||||
"需人工复核记录数": len(review.get("需人工复核记录", [])),
|
||||
"人工修正记录数": len(review.get("人工修正记录", [])),
|
||||
}
|
||||
batch_summaries.append(summary)
|
||||
|
||||
summary_dir = result_dir / INFO_DIR_NAME
|
||||
write_json(summary_dir / "汇总.json", {**summary, "科室汇总": department_summaries})
|
||||
write_csv(summary_dir / "科室汇总.csv", department_summaries)
|
||||
|
||||
for record in archive.get("患者记录", []):
|
||||
enriched = {"处理批次": batch_name, **record}
|
||||
raw_records.append(enriched)
|
||||
|
||||
all_records, duplicate_records, missing_inpatient_no_records = deduplicate_records_by_inpatient_no(raw_records)
|
||||
flat_records = [flatten_record(record["处理批次"], record) for record in all_records]
|
||||
kept_by_batch = Counter(record["处理批次"] for record in all_records)
|
||||
dropped_by_batch = Counter(item["剔除记录"]["处理批次"] for item in duplicate_records)
|
||||
missing_by_batch = Counter(item["剔除记录"]["处理批次"] for item in missing_inpatient_no_records)
|
||||
batch_level_duplicate_count = sum(int(item.get("重复住院号剔除记录数", 0)) for item in batch_summaries)
|
||||
batch_level_raw_count = sum(
|
||||
int(item.get("去重前患者记录数", int(item.get("患者记录数", 0)) + int(item.get("重复住院号剔除记录数", 0))))
|
||||
for item in batch_summaries
|
||||
)
|
||||
|
||||
for summary in batch_summaries:
|
||||
batch_name = summary["处理批次"]
|
||||
original_count = int(summary.get("患者记录数", 0))
|
||||
summary["原始患者记录数"] = original_count
|
||||
summary["合并阶段重复住院号剔除记录数"] = int(dropped_by_batch.get(batch_name, 0))
|
||||
summary["合并阶段缺少住院号剔除记录数"] = int(missing_by_batch.get(batch_name, 0))
|
||||
summary["患者记录数"] = int(kept_by_batch.get(batch_name, 0))
|
||||
write_json(Path(summary["结果目录"]) / INFO_DIR_NAME / "汇总.json", summary)
|
||||
|
||||
global_summary = {
|
||||
"批次数": len(batch_summaries),
|
||||
"总图片数": sum(int(item.get("图片数", 0)) for item in batch_summaries),
|
||||
"批次内去重前患者记录数": batch_level_raw_count,
|
||||
"批次内重复住院号剔除记录数": batch_level_duplicate_count,
|
||||
"合并前患者记录数": len(raw_records),
|
||||
"总患者记录数": len(all_records),
|
||||
"合并阶段重复住院号剔除记录数": len(duplicate_records),
|
||||
"合并阶段缺少住院号剔除记录数": len(missing_inpatient_no_records),
|
||||
"重复住院号剔除记录数": batch_level_duplicate_count + len(duplicate_records),
|
||||
"需人工复核记录数": sum(int(item.get("需人工复核记录数", 0)) for item in batch_summaries),
|
||||
"人工修正记录数": sum(int(item.get("人工修正记录数", 0)) for item in batch_summaries),
|
||||
"批次汇总": batch_summaries,
|
||||
}
|
||||
write_json(ROOT / INFO_DIR_NAME / "全局汇总.json", global_summary)
|
||||
write_csv(ROOT / INFO_DIR_NAME / "批次汇总.csv", batch_summaries)
|
||||
write_json(
|
||||
ROOT / "合并_患者列表_结构化.json",
|
||||
{
|
||||
"汇总": global_summary,
|
||||
"重复住院号剔除记录": duplicate_records,
|
||||
"缺少住院号剔除记录": missing_inpatient_no_records,
|
||||
"患者记录": all_records,
|
||||
},
|
||||
)
|
||||
write_jsonl(ROOT / "合并_患者列表_记录.jsonl", all_records)
|
||||
write_csv(ROOT / "合并_患者列表_记录.csv", flat_records)
|
||||
write_json(ROOT / INFO_DIR_NAME / "重复住院号报告.json", duplicate_records)
|
||||
write_json(ROOT / INFO_DIR_NAME / "缺少住院号报告.json", missing_inpatient_no_records)
|
||||
print(json.dumps(global_summary, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
233
患者列表处理/数据处理工作区/05_同步PostgreSQL单表.py
Normal file
233
患者列表处理/数据处理工作区/05_同步PostgreSQL单表.py
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Sync consolidated HIS patient records into PostgreSQL single table.
|
||||
|
||||
Database credentials are read from environment variables by default:
|
||||
HIS_DB_HOST, HIS_DB_PORT, HIS_DB_NAME, HIS_DB_USER, HIS_DB_PASSWORD.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_RESULT_JSON = Path("数据处理结果区/合并_患者列表_结构化.json")
|
||||
DEFAULT_SCHEMA_SQL = Path("数据处理工作区/06_PostgreSQL建表结构.sql")
|
||||
|
||||
TABLE_NAME = '"Patient_Lists"'
|
||||
|
||||
PATIENT_FIELDS = [
|
||||
"batch_name",
|
||||
"major_department",
|
||||
"sub_department",
|
||||
"source_folder",
|
||||
"image_path",
|
||||
"image_name",
|
||||
"image_row_no",
|
||||
"patient_name",
|
||||
"gender",
|
||||
"age",
|
||||
"inpatient_no",
|
||||
"diagnosis",
|
||||
"admission_time",
|
||||
"last_write_time",
|
||||
"hospital_days",
|
||||
"discharge_time",
|
||||
"postoperative_days",
|
||||
"review_status",
|
||||
"review_notes",
|
||||
"manual_corrected",
|
||||
]
|
||||
|
||||
|
||||
def scalar(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def clean_integer(value: Any) -> tuple[str, str]:
|
||||
text = scalar(value).strip()
|
||||
if not text:
|
||||
return "", ""
|
||||
if text.isdigit():
|
||||
return text, ""
|
||||
return "", f"住院天数非数字,原识别值: {text}"
|
||||
|
||||
|
||||
def review_note_items(review: dict[str, Any]) -> list[str]:
|
||||
tips = review.get("提示", [])
|
||||
if isinstance(tips, list):
|
||||
note_items = [str(tip) for tip in tips if str(tip) and str(tip) != "缺少出院时间"]
|
||||
else:
|
||||
note_items = [str(tips)] if str(tips) else []
|
||||
if review.get("人工备注"):
|
||||
note_items.append(f"人工备注: {review['人工备注']}")
|
||||
return note_items
|
||||
|
||||
|
||||
def psql_quote(path: Path) -> str:
|
||||
return "'" + str(path.resolve()).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def flatten_patient_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||
patient = record["患者信息"]
|
||||
image = record["图片信息"]
|
||||
review = record["复核"]
|
||||
note_items = review_note_items(review)
|
||||
hospital_days, hospital_days_note = clean_integer(patient["住院天数"])
|
||||
if hospital_days_note:
|
||||
note_items.append(hospital_days_note)
|
||||
review_notes = ";".join(note_items)
|
||||
return {
|
||||
"batch_name": record["处理批次"],
|
||||
"major_department": record["大科室"],
|
||||
"sub_department": record["子科室"],
|
||||
"source_folder": record["来源文件夹"],
|
||||
"image_path": image["图片路径"],
|
||||
"image_name": image["图片名"],
|
||||
"image_row_no": image["图片内行号"],
|
||||
"patient_name": patient["姓名"],
|
||||
"gender": patient["性别"],
|
||||
"age": patient["年龄"],
|
||||
"inpatient_no": patient["住院号"],
|
||||
"diagnosis": patient["诊断"],
|
||||
"admission_time": patient["入院时间"],
|
||||
"last_write_time": patient["最后书写时间"],
|
||||
"hospital_days": hospital_days,
|
||||
"discharge_time": patient["出院时间"],
|
||||
"postoperative_days": patient["手术后天数"],
|
||||
"review_status": review["状态"],
|
||||
"review_notes": review_notes,
|
||||
"manual_corrected": bool(review.get("人工修正")),
|
||||
}
|
||||
|
||||
|
||||
def prepare_unique_rows(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str], list[str]]:
|
||||
kept_by_no: dict[str, dict[str, Any]] = {}
|
||||
kept_order: list[str] = []
|
||||
replacements: list[str] = []
|
||||
invalid: list[str] = []
|
||||
for row in rows:
|
||||
inpatient_no = str(row.get("inpatient_no", "")).strip()
|
||||
location = f"{row['batch_name']} / {row['image_path']} / 第{row['image_row_no']}行"
|
||||
if not inpatient_no:
|
||||
invalid.append(f"{location}: 空")
|
||||
continue
|
||||
if inpatient_no not in kept_by_no:
|
||||
kept_order.append(inpatient_no)
|
||||
else:
|
||||
previous = kept_by_no[inpatient_no]
|
||||
replacements.append(
|
||||
f"{inpatient_no}: {previous['batch_name']} / {previous['image_path']} / 第{previous['image_row_no']}行 -> {location}"
|
||||
)
|
||||
kept_by_no[inpatient_no] = row
|
||||
return [kept_by_no[inpatient_no] for inpatient_no in kept_order], replacements, invalid
|
||||
|
||||
|
||||
def load_rows(result_json: Path) -> tuple[list[dict[str, Any]], list[str], list[str]]:
|
||||
merged = json.loads(result_json.read_text(encoding="utf-8"))
|
||||
rows = [flatten_patient_record(record) for record in merged.get("患者记录", [])]
|
||||
return prepare_unique_rows(rows)
|
||||
|
||||
|
||||
def write_temp_csv(rows: list[dict[str, Any]]) -> Path:
|
||||
temp_file = tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
newline="",
|
||||
suffix="_his_patient_lists.csv",
|
||||
delete=False,
|
||||
)
|
||||
with temp_file:
|
||||
writer = csv.DictWriter(temp_file, fieldnames=PATIENT_FIELDS)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow({field: scalar(row.get(field, "")) for field in PATIENT_FIELDS})
|
||||
return Path(temp_file.name)
|
||||
|
||||
|
||||
def sync_to_postgres(args: argparse.Namespace, csv_path: Path, row_count: int) -> None:
|
||||
sql = f"""\\set ON_ERROR_STOP on
|
||||
\\i {psql_quote(Path(args.schema))}
|
||||
TRUNCATE TABLE {TABLE_NAME} RESTART IDENTITY;
|
||||
\\copy {TABLE_NAME}({','.join(PATIENT_FIELDS)}) FROM {psql_quote(csv_path)} WITH (FORMAT csv, HEADER true, NULL '')
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
password = args.password or env.get("HIS_DB_PASSWORD")
|
||||
if password:
|
||||
env["PGPASSWORD"] = password
|
||||
command = [
|
||||
"psql",
|
||||
"-h",
|
||||
args.host,
|
||||
"-p",
|
||||
str(args.port),
|
||||
"-U",
|
||||
args.user,
|
||||
"-d",
|
||||
args.dbname,
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
]
|
||||
completed = subprocess.run(command, input=sql, text=True, env=env, capture_output=True)
|
||||
print(completed.stdout, end="")
|
||||
if completed.returncode != 0:
|
||||
print(completed.stderr, end="")
|
||||
completed.check_returncode()
|
||||
print(json.dumps({"synced_table": "Patient_Lists", "records": row_count}, ensure_ascii=False))
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input", default=str(DEFAULT_RESULT_JSON), help="合并后的患者记录 JSON")
|
||||
parser.add_argument("--schema", default=str(DEFAULT_SCHEMA_SQL), help="单表建表 SQL")
|
||||
parser.add_argument("--host", default=os.getenv("HIS_DB_HOST", "DB_HOST"))
|
||||
parser.add_argument("--port", default=os.getenv("HIS_DB_PORT", "5432"))
|
||||
parser.add_argument("--dbname", default=os.getenv("HIS_DB_NAME", "DB_NAME"))
|
||||
parser.add_argument("--user", default=os.getenv("HIS_DB_USER", "DB_USER"))
|
||||
parser.add_argument("--password", default="", help="数据库密码;建议改用 HIS_DB_PASSWORD 环境变量")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
rows, replacements, invalid = load_rows(Path(args.input))
|
||||
csv_path = write_temp_csv(rows)
|
||||
try:
|
||||
sync_to_postgres(args, csv_path, len(rows))
|
||||
if invalid:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"skipped_empty_inpatient_no": len(invalid),
|
||||
"empty_examples": invalid[:20],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
if replacements:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"deduplicated_by_inpatient_no": len(replacements),
|
||||
"replacement_examples": replacements[:20],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
csv_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
84
患者列表处理/数据处理工作区/06_PostgreSQL建表结构.sql
Normal file
84
患者列表处理/数据处理工作区/06_PostgreSQL建表结构.sql
Normal file
@@ -0,0 +1,84 @@
|
||||
DROP TABLE IF EXISTS patient_records CASCADE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "Patient_Lists" (
|
||||
record_id bigserial PRIMARY KEY,
|
||||
batch_name text NOT NULL,
|
||||
major_department text NOT NULL,
|
||||
sub_department text NOT NULL,
|
||||
source_folder text NOT NULL,
|
||||
image_path text NOT NULL,
|
||||
image_name text NOT NULL,
|
||||
image_row_no integer NOT NULL,
|
||||
patient_name text NOT NULL,
|
||||
gender text,
|
||||
age text,
|
||||
inpatient_no text NOT NULL,
|
||||
diagnosis text,
|
||||
admission_time text,
|
||||
last_write_time text,
|
||||
hospital_days integer,
|
||||
discharge_time text,
|
||||
postoperative_days text,
|
||||
review_status text NOT NULL,
|
||||
review_notes text,
|
||||
manual_corrected boolean NOT NULL DEFAULT false,
|
||||
imported_at timestamptz NOT NULL DEFAULT now(),
|
||||
audit_result text,
|
||||
audit_ai_feedback text,
|
||||
audit_manual_feedback text,
|
||||
audit_machine_verdict text,
|
||||
audit_source text,
|
||||
audit_checked_by text,
|
||||
audit_checked_at timestamptz,
|
||||
CONSTRAINT uq_patient_lists_inpatient_no UNIQUE (inpatient_no),
|
||||
CONSTRAINT ck_patient_lists_inpatient_no_present CHECK (btrim(inpatient_no) <> ''),
|
||||
CONSTRAINT ck_patient_lists_admission_before_discharge CHECK (
|
||||
COALESCE(discharge_time, '') = ''
|
||||
OR COALESCE(admission_time, '') = ''
|
||||
OR admission_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
|
||||
OR discharge_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
|
||||
OR admission_time <= discharge_time
|
||||
OR review_status = '需人工复核'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_lists_batch_name ON "Patient_Lists"(batch_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_lists_department ON "Patient_Lists"(major_department, sub_department);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_lists_source_folder ON "Patient_Lists"(source_folder);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_lists_patient_name ON "Patient_Lists"(patient_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_lists_review_status ON "Patient_Lists"(review_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_lists_audit_result ON "Patient_Lists"(audit_result);
|
||||
|
||||
COMMENT ON TABLE "Patient_Lists" IS 'HIS患者列表图片OCR归档后的正式患者记录单表';
|
||||
COMMENT ON COLUMN "Patient_Lists".record_id IS '患者记录主键,数据库自动生成';
|
||||
COMMENT ON COLUMN "Patient_Lists".batch_name IS '处理批次名称,通常为原始图片集群文件夹名';
|
||||
COMMENT ON COLUMN "Patient_Lists".major_department IS '归类后的大科室';
|
||||
COMMENT ON COLUMN "Patient_Lists".sub_department IS '归类后的子科室';
|
||||
COMMENT ON COLUMN "Patient_Lists".source_folder IS '原始第一层科室文件夹名';
|
||||
COMMENT ON COLUMN "Patient_Lists".image_path IS '原始患者列表图片路径,用于定位数据来源';
|
||||
COMMENT ON COLUMN "Patient_Lists".image_name IS '原始患者列表图片文件名';
|
||||
COMMENT ON COLUMN "Patient_Lists".image_row_no IS '患者记录在原始图片内的行号';
|
||||
COMMENT ON COLUMN "Patient_Lists".patient_name IS '患者姓名';
|
||||
COMMENT ON COLUMN "Patient_Lists".gender IS '患者性别';
|
||||
COMMENT ON COLUMN "Patient_Lists".age IS '患者年龄,保留原始岁数字符串';
|
||||
COMMENT ON COLUMN "Patient_Lists".inpatient_no IS '住院号,不能为空且全库唯一;不强制校验格式';
|
||||
COMMENT ON COLUMN "Patient_Lists".diagnosis IS '诊断,可能为空';
|
||||
COMMENT ON COLUMN "Patient_Lists".admission_time IS '入院时间,按OCR/清洗后的文本保存';
|
||||
COMMENT ON COLUMN "Patient_Lists".last_write_time IS '最后书写时间,按OCR/清洗后的文本保存';
|
||||
COMMENT ON COLUMN "Patient_Lists".hospital_days IS '住院天数';
|
||||
COMMENT ON COLUMN "Patient_Lists".discharge_time IS '出院时间,按OCR/清洗后的文本保存';
|
||||
COMMENT ON COLUMN "Patient_Lists".postoperative_days IS '手术后天数,可能为空';
|
||||
COMMENT ON COLUMN "Patient_Lists".review_status IS '自动复核或人工复核状态';
|
||||
COMMENT ON COLUMN "Patient_Lists".review_notes IS '复核提示信息';
|
||||
COMMENT ON COLUMN "Patient_Lists".manual_corrected IS '是否经过人工修正';
|
||||
COMMENT ON COLUMN "Patient_Lists".imported_at IS '导入数据库的时间';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_result IS '抽查人工核验结果';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_ai_feedback IS '抽查项AI反馈原文';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_manual_feedback IS '抽查项人工反馈';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_machine_verdict IS '抽查机器核验判断';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_source IS '抽查来源';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_checked_by IS '抽查操作用户';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_checked_at IS '抽查结果保存时间';
|
||||
COMMENT ON CONSTRAINT uq_patient_lists_inpatient_no ON "Patient_Lists" IS '保证同一个住院号只归档一条患者记录';
|
||||
COMMENT ON CONSTRAINT ck_patient_lists_inpatient_no_present ON "Patient_Lists" IS '住院号不能为空;格式不做强制校验';
|
||||
COMMENT ON CONSTRAINT ck_patient_lists_admission_before_discharge ON "Patient_Lists" IS '出院时间为空时放行;入院时间晚于出院时间时必须标记为需人工复核';
|
||||
72
患者列表处理/数据处理工作区/07_处理程序说明.md
Normal file
72
患者列表处理/数据处理工作区/07_处理程序说明.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 患者列表归档处理程序说明
|
||||
|
||||
## 工作区文件
|
||||
|
||||
1. `01_科室分类规则.json`:大科室、子科室、文件夹别名归类规则。
|
||||
2. `02_患者列表OCR归档.py`:单个图片集群的 OCR、字段清洗、去重、复核报告生成程序。
|
||||
3. `03_人工复核修正.json`:少量无法靠规则自动判断的人工修正项,可能包含患者信息,不提交到 Git。
|
||||
4. `04_合并批次结果.py`:合并所有 `*-列表归档结果`,生成全局 JSON/CSV 和 `信息记录`。
|
||||
5. `05_同步PostgreSQL单表.py`:把合并结果同步到 PostgreSQL 的 `"Patient_Lists"` 单表。
|
||||
6. `06_PostgreSQL建表结构.sql`:数据库单表结构、索引、表注释、字段注释。
|
||||
7. `07_处理程序说明.md`:当前说明。
|
||||
8. `08_PostgreSQL调整Patient_Lists列顺序.sql`:保留数据的前提下,把现有 `"Patient_Lists"` 物理列顺序调整为当前字段顺序。
|
||||
9. `09_PostgreSQL住院号非空唯一约束.sql`:删除住院号为空的记录,重复住院号保留最新一条,并让 PostgreSQL 强制住院号不能为空且唯一。
|
||||
|
||||
## 智能体处理顺序
|
||||
|
||||
1. 检查待处理批次目录和图片数量。
|
||||
2. 必要时更新 `01_科室分类规则.json` 的别名。
|
||||
3. 运行 `02_患者列表OCR归档.py` 处理一个批次。
|
||||
4. 查看 `复核报告.json` 和 `重复住院号报告.json`。
|
||||
5. 若有需人工复核项,优先补自动清洗规则;仍不能自动判断时,写入 `03_人工复核修正.json`。
|
||||
6. 用 `--rebuild-from-cache` 重建该批次结果,确认复核项和重复住院号符合预期。
|
||||
7. 运行 `04_合并批次结果.py` 更新全局 `信息记录`。
|
||||
8. 运行 `05_同步PostgreSQL单表.py` 同步到 PostgreSQL。
|
||||
9. 导入 PostgreSQL 后查询记录数、需复核记录数、重复住院号组数。
|
||||
|
||||
## 人工复核网页端
|
||||
|
||||
- 代码目录:`人工复核网页端/`。
|
||||
- 启动方式:`cd 人工复核网页端 && docker compose up -d --build`。
|
||||
- 登录配置从项目根目录 `.env` 读取:`REVIEW_APP_USERNAME`、`REVIEW_APP_PASSWORD`、`REVIEW_APP_SECRET_KEY`、`REVIEW_APP_PORT`。
|
||||
- 网页端读取各批次 `复核报告.json`,按原始图片路径与图片内行号裁剪截图;住院号不能为空且唯一,格式是否规范交由人工复核时判断。
|
||||
- 保存修订后写入 `数据处理工作区/03_人工复核修正.json`,并尽量同步 PostgreSQL;若 PostgreSQL 暂不可用,会保留待同步状态,之后可在网页端点击“提交待同步”。
|
||||
- 出院时间默认允许为空;确有出院时间时,网页端和处理程序会校验时间格式以及是否早于入院时间。
|
||||
- 页面包含概览、复核、抽查、抽查查看、设置区域;抽查功能可随机抽取患者行,调用 Kimi 多模态模型二次核验,并把 AI 反馈、机器判断、人工抽查结论写入 PostgreSQL。
|
||||
|
||||
## 识别策略
|
||||
|
||||
- OCR 接口:腾讯云 `RecognizeTableAccurateOCR` 表格识别 V3。
|
||||
- 新批次拼接图默认给每张原图上下各加 `24px` 白色边界,减少贴边表格行被裁掉或和相邻图片粘连的概率。
|
||||
- 正式处理优先用 6 张拼接;接口超时、失败或行数偏少时,程序会自动降到 4/3/2/单张。
|
||||
- 行数校验:按图片高度推断应有行数,返回行数偏少即判定该拼接档位不可靠。
|
||||
- 漏行不一定只发生在最上方或最下方两行;长拼接图中间也可能漏行,白边只能缓解贴边问题。
|
||||
- 同一科室内会记忆稳定回退档位;跨科室不共享该记忆,避免重建历史缓存时误用。
|
||||
- 只用缓存重建时,若前序回退策略需要的缓存不完整,会先尝试该组主缓存,避免因缓存形态不同造成少行。
|
||||
- 字段顺序固定为:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数。
|
||||
- 字段清洗包含日期规范化、住院号常见误识别修正、长诊断吞入入院时间、空时间字段导致列左移、`后XX天` 错位等。
|
||||
|
||||
## 住院号核验规则
|
||||
|
||||
- `住院号` 是唯一强制校验条件:不能为空且全库唯一。
|
||||
- 单批次、全局合并和 PostgreSQL 同步都会剔除住院号为空的记录;重复住院号按后出现记录覆盖先出现记录;格式异常但非空的住院号保留。
|
||||
- 网页端保存时优先按图片路径和图片内行号定位数据库记录;如果当前住院号已存在于另一条记录,会用当前修订覆盖既有住院号记录,并删除当前重复图片记录。
|
||||
- 数据库约束允许 `出院时间` 为空;若入院时间和出院时间均为标准格式且入院时间晚于出院时间,该记录必须标记为 `需人工复核`。
|
||||
|
||||
## PostgreSQL 单表字段
|
||||
|
||||
`"Patient_Lists"` 只保留正式查看和追溯需要的字段:
|
||||
|
||||
- 数据定位:`batch_name`、`source_folder`、`image_path`、`image_name`、`image_row_no`
|
||||
- 科室信息:`major_department`、`sub_department`
|
||||
- 患者信息:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数
|
||||
- 复核信息:`review_status`、`review_notes`、`manual_corrected`
|
||||
- 抽查信息:`audit_result`、`audit_ai_feedback`、`audit_manual_feedback`、`audit_machine_verdict`、`audit_source`、`audit_checked_by`、`audit_checked_at`
|
||||
|
||||
复核状态含义:
|
||||
|
||||
- `自动复核通过`:OCR 结果经过程序规则校验后没有发现明显异常,`manual_corrected=false`。
|
||||
- `人工复核通过`:该行命中了 `03_人工复核修正.json` 中的人工修正项,修正后校验通过,`manual_corrected=true`。
|
||||
- `AI修改-待确认`:网页端 Kimi 辅助生成的草稿状态,不会被待同步流程写入 PostgreSQL,需人工保存确认。
|
||||
|
||||
OCR 请求号、OCR 缓存路径、拼接图路径、拼接组号等中间态字段不进入数据库正式表。
|
||||
168
患者列表处理/数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql
Normal file
168
患者列表处理/数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql
Normal file
@@ -0,0 +1,168 @@
|
||||
-- 保留现有数据,重建 "Patient_Lists" 的物理列顺序。
|
||||
-- 只调整列顺序,不交换 last_write_time/discharge_time 的值。
|
||||
-- 执行前建议先确认已备份数据库。
|
||||
|
||||
BEGIN;
|
||||
|
||||
LOCK TABLE "Patient_Lists" IN ACCESS EXCLUSIVE MODE;
|
||||
|
||||
DROP TABLE IF EXISTS "Patient_Lists__reordered";
|
||||
|
||||
CREATE TABLE "Patient_Lists__reordered" (
|
||||
record_id bigint NOT NULL,
|
||||
batch_name text NOT NULL,
|
||||
major_department text NOT NULL,
|
||||
sub_department text NOT NULL,
|
||||
source_folder text NOT NULL,
|
||||
image_path text NOT NULL,
|
||||
image_name text NOT NULL,
|
||||
image_row_no integer NOT NULL,
|
||||
patient_name text NOT NULL,
|
||||
gender text,
|
||||
age text,
|
||||
inpatient_no text NOT NULL,
|
||||
diagnosis text,
|
||||
admission_time text,
|
||||
last_write_time text,
|
||||
hospital_days integer,
|
||||
discharge_time text,
|
||||
postoperative_days text,
|
||||
review_status text NOT NULL,
|
||||
review_notes text,
|
||||
manual_corrected boolean NOT NULL DEFAULT false,
|
||||
imported_at timestamptz NOT NULL DEFAULT now(),
|
||||
audit_result text,
|
||||
audit_ai_feedback text,
|
||||
audit_manual_feedback text,
|
||||
audit_machine_verdict text,
|
||||
audit_source text,
|
||||
audit_checked_by text,
|
||||
audit_checked_at timestamptz,
|
||||
CONSTRAINT ck_patient_lists_inpatient_no_present CHECK (btrim(inpatient_no) <> ''),
|
||||
CONSTRAINT ck_patient_lists_admission_before_discharge CHECK (
|
||||
COALESCE(discharge_time, '') = ''
|
||||
OR COALESCE(admission_time, '') = ''
|
||||
OR admission_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
|
||||
OR discharge_time !~ '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
|
||||
OR admission_time <= discharge_time
|
||||
OR review_status = '需人工复核'
|
||||
)
|
||||
);
|
||||
|
||||
INSERT INTO "Patient_Lists__reordered" (
|
||||
record_id,
|
||||
batch_name,
|
||||
major_department,
|
||||
sub_department,
|
||||
source_folder,
|
||||
image_path,
|
||||
image_name,
|
||||
image_row_no,
|
||||
patient_name,
|
||||
gender,
|
||||
age,
|
||||
inpatient_no,
|
||||
diagnosis,
|
||||
admission_time,
|
||||
last_write_time,
|
||||
hospital_days,
|
||||
discharge_time,
|
||||
postoperative_days,
|
||||
review_status,
|
||||
review_notes,
|
||||
manual_corrected,
|
||||
imported_at,
|
||||
audit_result,
|
||||
audit_ai_feedback,
|
||||
audit_manual_feedback,
|
||||
audit_machine_verdict,
|
||||
audit_source,
|
||||
audit_checked_by,
|
||||
audit_checked_at
|
||||
)
|
||||
SELECT
|
||||
record_id,
|
||||
batch_name,
|
||||
major_department,
|
||||
sub_department,
|
||||
source_folder,
|
||||
image_path,
|
||||
image_name,
|
||||
image_row_no,
|
||||
patient_name,
|
||||
gender,
|
||||
age,
|
||||
inpatient_no,
|
||||
diagnosis,
|
||||
admission_time,
|
||||
last_write_time,
|
||||
hospital_days,
|
||||
discharge_time,
|
||||
postoperative_days,
|
||||
review_status,
|
||||
review_notes,
|
||||
manual_corrected,
|
||||
imported_at,
|
||||
audit_result,
|
||||
audit_ai_feedback,
|
||||
audit_manual_feedback,
|
||||
audit_machine_verdict,
|
||||
audit_source,
|
||||
audit_checked_by,
|
||||
audit_checked_at
|
||||
FROM "Patient_Lists";
|
||||
|
||||
DROP TABLE "Patient_Lists";
|
||||
ALTER TABLE "Patient_Lists__reordered" RENAME TO "Patient_Lists";
|
||||
|
||||
DROP SEQUENCE IF EXISTS "Patient_Lists_record_id_seq";
|
||||
CREATE SEQUENCE "Patient_Lists_record_id_seq";
|
||||
ALTER SEQUENCE "Patient_Lists_record_id_seq" OWNED BY "Patient_Lists".record_id;
|
||||
ALTER TABLE "Patient_Lists" ALTER COLUMN record_id SET DEFAULT nextval('"Patient_Lists_record_id_seq"'::regclass);
|
||||
SELECT setval('"Patient_Lists_record_id_seq"', COALESCE((SELECT max(record_id) FROM "Patient_Lists"), 1), true);
|
||||
|
||||
ALTER TABLE "Patient_Lists" ADD CONSTRAINT "Patient_Lists_pkey" PRIMARY KEY (record_id);
|
||||
ALTER TABLE "Patient_Lists" ADD CONSTRAINT uq_patient_lists_inpatient_no UNIQUE (inpatient_no);
|
||||
|
||||
CREATE INDEX idx_patient_lists_batch_name ON "Patient_Lists"(batch_name);
|
||||
CREATE INDEX idx_patient_lists_department ON "Patient_Lists"(major_department, sub_department);
|
||||
CREATE INDEX idx_patient_lists_source_folder ON "Patient_Lists"(source_folder);
|
||||
CREATE INDEX idx_patient_lists_patient_name ON "Patient_Lists"(patient_name);
|
||||
CREATE INDEX idx_patient_lists_review_status ON "Patient_Lists"(review_status);
|
||||
CREATE INDEX idx_patient_lists_audit_result ON "Patient_Lists"(audit_result);
|
||||
|
||||
COMMENT ON TABLE "Patient_Lists" IS 'HIS患者列表图片OCR归档后的正式患者记录单表';
|
||||
COMMENT ON COLUMN "Patient_Lists".record_id IS '患者记录主键,数据库自动生成';
|
||||
COMMENT ON COLUMN "Patient_Lists".batch_name IS '处理批次名称,通常为原始图片集群文件夹名';
|
||||
COMMENT ON COLUMN "Patient_Lists".major_department IS '归类后的大科室';
|
||||
COMMENT ON COLUMN "Patient_Lists".sub_department IS '归类后的子科室';
|
||||
COMMENT ON COLUMN "Patient_Lists".source_folder IS '原始第一层科室文件夹名';
|
||||
COMMENT ON COLUMN "Patient_Lists".image_path IS '原始患者列表图片路径,用于定位数据来源';
|
||||
COMMENT ON COLUMN "Patient_Lists".image_name IS '原始患者列表图片文件名';
|
||||
COMMENT ON COLUMN "Patient_Lists".image_row_no IS '患者记录在原始图片内的行号';
|
||||
COMMENT ON COLUMN "Patient_Lists".patient_name IS '患者姓名';
|
||||
COMMENT ON COLUMN "Patient_Lists".gender IS '患者性别';
|
||||
COMMENT ON COLUMN "Patient_Lists".age IS '患者年龄,保留原始岁数字符串';
|
||||
COMMENT ON COLUMN "Patient_Lists".inpatient_no IS '住院号,不能为空且全库唯一;不强制校验格式';
|
||||
COMMENT ON COLUMN "Patient_Lists".diagnosis IS '诊断,可能为空';
|
||||
COMMENT ON COLUMN "Patient_Lists".admission_time IS '入院时间,按OCR/清洗后的文本保存';
|
||||
COMMENT ON COLUMN "Patient_Lists".last_write_time IS '最后书写时间,按OCR/清洗后的文本保存';
|
||||
COMMENT ON COLUMN "Patient_Lists".hospital_days IS '住院天数';
|
||||
COMMENT ON COLUMN "Patient_Lists".discharge_time IS '出院时间,按OCR/清洗后的文本保存';
|
||||
COMMENT ON COLUMN "Patient_Lists".postoperative_days IS '手术后天数,可能为空,格式通常为“后X天”';
|
||||
COMMENT ON COLUMN "Patient_Lists".review_status IS '自动复核或人工复核状态';
|
||||
COMMENT ON COLUMN "Patient_Lists".review_notes IS '复核提示信息';
|
||||
COMMENT ON COLUMN "Patient_Lists".manual_corrected IS '是否经过人工修正';
|
||||
COMMENT ON COLUMN "Patient_Lists".imported_at IS '导入数据库或人工同步更新的时间';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_result IS '抽查人工核验结果';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_ai_feedback IS '抽查项AI反馈原文';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_manual_feedback IS '抽查项人工反馈';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_machine_verdict IS '抽查机器核验判断';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_source IS '抽查来源';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_checked_by IS '抽查操作用户';
|
||||
COMMENT ON COLUMN "Patient_Lists".audit_checked_at IS '抽查结果保存时间';
|
||||
COMMENT ON CONSTRAINT uq_patient_lists_inpatient_no ON "Patient_Lists" IS '保证同一个住院号只归档一条患者记录';
|
||||
COMMENT ON CONSTRAINT ck_patient_lists_inpatient_no_present ON "Patient_Lists" IS '住院号不能为空;格式不做强制校验';
|
||||
COMMENT ON CONSTRAINT ck_patient_lists_admission_before_discharge ON "Patient_Lists" IS '出院时间为空时放行;入院时间晚于出院时间时必须标记为需人工复核';
|
||||
|
||||
COMMIT;
|
||||
102
患者列表处理/数据处理工作区/09_PostgreSQL住院号非空唯一约束.sql
Normal file
102
患者列表处理/数据处理工作区/09_PostgreSQL住院号非空唯一约束.sql
Normal file
@@ -0,0 +1,102 @@
|
||||
-- 住院号为唯一定位条件:数据库强制“不能为空且唯一”。
|
||||
-- 不校验 ZY 位数格式;空住院号记录会先删除,重复住院号保留 id 最大/最新的一条。
|
||||
|
||||
DELETE FROM "Patient_Lists"
|
||||
WHERE inpatient_no IS NULL OR btrim(inpatient_no) = '';
|
||||
|
||||
DELETE FROM "Patient_Lists" p
|
||||
USING (
|
||||
SELECT record_id
|
||||
FROM (
|
||||
SELECT record_id, row_number() OVER (PARTITION BY inpatient_no ORDER BY record_id DESC) AS rn
|
||||
FROM "Patient_Lists"
|
||||
) ranked
|
||||
WHERE rn > 1
|
||||
) duplicate_rows
|
||||
WHERE p.record_id = duplicate_rows.record_id;
|
||||
|
||||
ALTER TABLE "Patient_Lists"
|
||||
DROP CONSTRAINT IF EXISTS ck_patient_lists_inpatient_no_format;
|
||||
|
||||
ALTER TABLE "Patient_Lists"
|
||||
DROP CONSTRAINT IF EXISTS uq_patient_lists_inpatient_no;
|
||||
|
||||
DROP INDEX IF EXISTS uq_patient_lists_inpatient_no;
|
||||
|
||||
ALTER TABLE "Patient_Lists"
|
||||
DROP CONSTRAINT IF EXISTS ck_patient_lists_inpatient_no_present;
|
||||
|
||||
ALTER TABLE "Patient_Lists"
|
||||
DROP CONSTRAINT IF EXISTS ck_patient_lists_inpatient_no_required;
|
||||
|
||||
ALTER TABLE "Patient_Lists"
|
||||
ALTER COLUMN inpatient_no SET NOT NULL;
|
||||
|
||||
ALTER TABLE "Patient_Lists"
|
||||
ADD CONSTRAINT ck_patient_lists_inpatient_no_present
|
||||
CHECK (btrim(inpatient_no) <> '');
|
||||
|
||||
ALTER TABLE "Patient_Lists"
|
||||
ADD CONSTRAINT uq_patient_lists_inpatient_no UNIQUE (inpatient_no);
|
||||
|
||||
COMMENT ON COLUMN "Patient_Lists".inpatient_no
|
||||
IS '住院号,不能为空且全库唯一;不强制校验格式';
|
||||
|
||||
COMMENT ON CONSTRAINT ck_patient_lists_inpatient_no_present ON "Patient_Lists"
|
||||
IS '住院号不能为空;格式不做强制校验';
|
||||
|
||||
COMMENT ON CONSTRAINT uq_patient_lists_inpatient_no ON "Patient_Lists"
|
||||
IS '保证同一个住院号只归档一条患者记录';
|
||||
|
||||
DELETE FROM "Patient_FrontPages"
|
||||
WHERE inpatient_no IS NULL OR btrim(inpatient_no) = '';
|
||||
|
||||
DELETE FROM "Patient_FrontPages" p
|
||||
USING (
|
||||
SELECT id
|
||||
FROM (
|
||||
SELECT id, row_number() OVER (PARTITION BY inpatient_no ORDER BY id DESC) AS rn
|
||||
FROM "Patient_FrontPages"
|
||||
) ranked
|
||||
WHERE rn > 1
|
||||
) duplicate_rows
|
||||
WHERE p.id = duplicate_rows.id;
|
||||
|
||||
ALTER TABLE "Patient_FrontPages"
|
||||
DROP CONSTRAINT IF EXISTS "ck_Patient_FrontPages_inpatient_no_format";
|
||||
|
||||
ALTER TABLE "Patient_FrontPages"
|
||||
DROP CONSTRAINT IF EXISTS ck_patient_frontpages_inpatient_no_format;
|
||||
|
||||
ALTER TABLE "Patient_FrontPages"
|
||||
DROP CONSTRAINT IF EXISTS uq_patient_frontpages_inpatient_no;
|
||||
|
||||
DROP INDEX IF EXISTS uq_patient_frontpages_inpatient_no;
|
||||
|
||||
ALTER TABLE "Patient_FrontPages"
|
||||
DROP CONSTRAINT IF EXISTS ck_patient_frontpages_inpatient_no_present;
|
||||
|
||||
ALTER TABLE "Patient_FrontPages"
|
||||
DROP CONSTRAINT IF EXISTS "ck_Patient_FrontPages_inpatient_no_required";
|
||||
|
||||
ALTER TABLE "Patient_FrontPages"
|
||||
DROP CONSTRAINT IF EXISTS ck_patient_frontpages_inpatient_no_required;
|
||||
|
||||
ALTER TABLE "Patient_FrontPages"
|
||||
ALTER COLUMN inpatient_no SET NOT NULL;
|
||||
|
||||
ALTER TABLE "Patient_FrontPages"
|
||||
ADD CONSTRAINT ck_patient_frontpages_inpatient_no_present
|
||||
CHECK (btrim(inpatient_no) <> '');
|
||||
|
||||
ALTER TABLE "Patient_FrontPages"
|
||||
ADD CONSTRAINT uq_patient_frontpages_inpatient_no UNIQUE (inpatient_no);
|
||||
|
||||
COMMENT ON COLUMN "Patient_FrontPages".inpatient_no
|
||||
IS '住院号,不能为空且全库唯一;不强制校验格式';
|
||||
|
||||
COMMENT ON CONSTRAINT ck_patient_frontpages_inpatient_no_present ON "Patient_FrontPages"
|
||||
IS '住院号不能为空;格式不做强制校验';
|
||||
|
||||
COMMENT ON CONSTRAINT uq_patient_frontpages_inpatient_no ON "Patient_FrontPages"
|
||||
IS '保证同一个住院号只保留一条首页记录';
|
||||
Reference in New Issue
Block a user