From 7e329f1d8598f0a792f111db22e9d5a48a2e3f99 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Tue, 26 May 2026 08:51:10 +0800 Subject: [PATCH] Import sanitized HIS processing tools --- .gitignore | 35 + README.md | 19 + 患者列表处理/.env.example | 19 + 患者列表处理/.gitignore | 22 + 患者列表处理/README.md | 125 + 患者列表处理/人工复核网页端/.dockerignore | 4 + 患者列表处理/人工复核网页端/.env.example | 15 + 患者列表处理/人工复核网页端/Dockerfile | 17 + 患者列表处理/人工复核网页端/README.md | 62 + 患者列表处理/人工复核网页端/app.py | 2127 +++++++++++++ .../人工复核网页端/docker-compose.yml | 19 + 患者列表处理/人工复核网页端/requirements.txt | 5 + 患者列表处理/人工复核网页端/static/app.css | 1339 ++++++++ 患者列表处理/人工复核网页端/static/app.js | 2042 ++++++++++++ .../人工复核网页端/templates/index.html | 382 +++ .../人工复核网页端/templates/login.html | 29 + 患者列表处理/工作流_Gitea版.md | 202 ++ .../数据处理工作区/01_科室分类规则.json | 258 ++ .../数据处理工作区/02_患者列表OCR归档.py | 1346 ++++++++ .../数据处理工作区/03_人工复核修正.template.json | 1 + .../数据处理工作区/04_合并批次结果.py | 247 ++ .../数据处理工作区/05_同步PostgreSQL单表.py | 233 ++ .../数据处理工作区/06_PostgreSQL建表结构.sql | 84 + .../数据处理工作区/07_处理程序说明.md | 72 + .../08_PostgreSQL调整Patient_Lists列顺序.sql | 168 + .../09_PostgreSQL住院号非空唯一约束.sql | 102 + 患者首页处理/.gitignore | 22 + 患者首页处理/README.md | 259 ++ 患者首页处理/工作流_Gitea版.md | 306 ++ 患者首页处理/数据可视化网页端/.dockerignore | 4 + 患者首页处理/数据可视化网页端/.env.example | 17 + 患者首页处理/数据可视化网页端/Dockerfile | 19 + 患者首页处理/数据可视化网页端/README.md | 58 + 患者首页处理/数据可视化网页端/app/__init__.py | 1 + 患者首页处理/数据可视化网页端/app/main.py | 2779 +++++++++++++++++ .../数据可视化网页端/app/static/app.js | 1393 +++++++++ .../数据可视化网页端/app/static/index.html | 273 ++ .../数据可视化网页端/app/static/styles.css | 1193 +++++++ .../数据可视化网页端/docker-compose.yml | 20 + .../数据处理工作区/01_配置规则/01_科室分类规则.json | 245 ++ .../02_解析入库/02_患者首页PDF解析与入库.py | 2181 +++++++++++++ .../03_人工复核/03_人工复核导出与回写.py | 224 ++ .../04_质量体检/04_字段核验与数据库体检.py | 175 ++ .../05_备用读取/05_备用PDF转Markdown_Mineru.py | 130 + .../06_图片对照核验/06_PDF转图片与对照核验.py | 697 +++++ .../07_Kimi视觉兜底/07_Kimi图片识别辅助.py | 205 ++ 46 files changed, 19175 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 患者列表处理/.env.example create mode 100644 患者列表处理/.gitignore create mode 100644 患者列表处理/README.md create mode 100644 患者列表处理/人工复核网页端/.dockerignore create mode 100644 患者列表处理/人工复核网页端/.env.example create mode 100644 患者列表处理/人工复核网页端/Dockerfile create mode 100644 患者列表处理/人工复核网页端/README.md create mode 100644 患者列表处理/人工复核网页端/app.py create mode 100644 患者列表处理/人工复核网页端/docker-compose.yml create mode 100644 患者列表处理/人工复核网页端/requirements.txt create mode 100644 患者列表处理/人工复核网页端/static/app.css create mode 100644 患者列表处理/人工复核网页端/static/app.js create mode 100644 患者列表处理/人工复核网页端/templates/index.html create mode 100644 患者列表处理/人工复核网页端/templates/login.html create mode 100644 患者列表处理/工作流_Gitea版.md create mode 100644 患者列表处理/数据处理工作区/01_科室分类规则.json create mode 100644 患者列表处理/数据处理工作区/02_患者列表OCR归档.py create mode 100644 患者列表处理/数据处理工作区/03_人工复核修正.template.json create mode 100644 患者列表处理/数据处理工作区/04_合并批次结果.py create mode 100644 患者列表处理/数据处理工作区/05_同步PostgreSQL单表.py create mode 100644 患者列表处理/数据处理工作区/06_PostgreSQL建表结构.sql create mode 100644 患者列表处理/数据处理工作区/07_处理程序说明.md create mode 100644 患者列表处理/数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql create mode 100644 患者列表处理/数据处理工作区/09_PostgreSQL住院号非空唯一约束.sql create mode 100644 患者首页处理/.gitignore create mode 100644 患者首页处理/README.md create mode 100644 患者首页处理/工作流_Gitea版.md create mode 100644 患者首页处理/数据可视化网页端/.dockerignore create mode 100644 患者首页处理/数据可视化网页端/.env.example create mode 100644 患者首页处理/数据可视化网页端/Dockerfile create mode 100644 患者首页处理/数据可视化网页端/README.md create mode 100644 患者首页处理/数据可视化网页端/app/__init__.py create mode 100644 患者首页处理/数据可视化网页端/app/main.py create mode 100644 患者首页处理/数据可视化网页端/app/static/app.js create mode 100644 患者首页处理/数据可视化网页端/app/static/index.html create mode 100644 患者首页处理/数据可视化网页端/app/static/styles.css create mode 100644 患者首页处理/数据可视化网页端/docker-compose.yml create mode 100644 患者首页处理/数据处理工作区/01_配置规则/01_科室分类规则.json create mode 100755 患者首页处理/数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py create mode 100755 患者首页处理/数据处理工作区/03_人工复核/03_人工复核导出与回写.py create mode 100755 患者首页处理/数据处理工作区/04_质量体检/04_字段核验与数据库体检.py create mode 100644 患者首页处理/数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py create mode 100644 患者首页处理/数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py create mode 100644 患者首页处理/数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0c0412 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a702b1a --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# HIS 数据处理工具集 + +本仓库合并保存 HIS 相关处理程序、网页端、配置模板和流程文档。 + +## 目录 + +- `患者列表处理/`:患者列表 OCR、合并、PostgreSQL 同步、人工复核网页端。 +- `患者首页处理/`:患者首页 PDF 解析入库、人工复核、质量体检、可视化网页端、Kimi 视觉兜底工具。 + +## 入库边界 + +仓库只保存程序、文档、结构脚本和配置模板,不保存以下内容: + +- 原始 PDF、图片、截图、OCR 裁剪图等患者数据。 +- `数据处理结果区/` 中的结构化结果、日志、质检报告和复核导出。 +- `.env`、`.pgpass`、本地网页端配置、明文数据库连接信息、API Key 或登录密码。 +- 两个旧工程的 `.git` 历史。 + +需要运行程序时,请在本地根据各子目录的 `.env.example` 创建 `.env`,并从安全渠道填入数据库和 API 配置。 diff --git a/患者列表处理/.env.example b/患者列表处理/.env.example new file mode 100644 index 0000000..6a97306 --- /dev/null +++ b/患者列表处理/.env.example @@ -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 diff --git a/患者列表处理/.gitignore b/患者列表处理/.gitignore new file mode 100644 index 0000000..3d7389d --- /dev/null +++ b/患者列表处理/.gitignore @@ -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/ diff --git a/患者列表处理/README.md b/患者列表处理/README.md new file mode 100644 index 0000000..763fd91 --- /dev/null +++ b/患者列表处理/README.md @@ -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` 为准。 diff --git a/患者列表处理/人工复核网页端/.dockerignore b/患者列表处理/人工复核网页端/.dockerignore new file mode 100644 index 0000000..f86ec77 --- /dev/null +++ b/患者列表处理/人工复核网页端/.dockerignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +*.py[cod] +*.log diff --git a/患者列表处理/人工复核网页端/.env.example b/患者列表处理/人工复核网页端/.env.example new file mode 100644 index 0000000..a5aaf90 --- /dev/null +++ b/患者列表处理/人工复核网页端/.env.example @@ -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 diff --git a/患者列表处理/人工复核网页端/Dockerfile b/患者列表处理/人工复核网页端/Dockerfile new file mode 100644 index 0000000..1316b54 --- /dev/null +++ b/患者列表处理/人工复核网页端/Dockerfile @@ -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"] diff --git a/患者列表处理/人工复核网页端/README.md b/患者列表处理/人工复核网页端/README.md new file mode 100644 index 0000000..f8a44f2 --- /dev/null +++ b/患者列表处理/人工复核网页端/README.md @@ -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。 diff --git a/患者列表处理/人工复核网页端/app.py b/患者列表处理/人工复核网页端/app.py new file mode 100644 index 0000000..1c290b5 --- /dev/null +++ b/患者列表处理/人工复核网页端/app.py @@ -0,0 +1,2127 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Manual review web app for HIS patient-list OCR results.""" + +from __future__ import annotations + +import datetime as dt +import base64 +import csv +import hashlib +import io +import json +import os +import random +import re +import secrets +import socket +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import psycopg +from psycopg.rows import dict_row +from dotenv import load_dotenv +from flask import Flask, Response, abort, jsonify, redirect, render_template, request, send_file, session, url_for +from PIL import Image + + +load_dotenv() + +COLUMNS = [ + "姓名", + "性别", + "年龄", + "住院号", + "诊断", + "入院时间", + "最后书写时间", + "住院天数", + "出院时间", + "手术后天数", +] + +DATETIME_PATTERN = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}") +FLEX_DATETIME_PATTERN = re.compile(r"^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})$") +INPATIENT_NO_PATTERN = re.compile(r".*") +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"} +IGNORED_REVIEW_TIPS = {"缺少出院时间"} +IGNORED_AI_ISSUE_PATTERNS = ( + "最后书写时间与出院时间顺序异常", + "最后书写时间晚于出院时间", + "出院后仍有书写记录", + "已出院后仍有书写记录", +) +FORMAT_ONLY_AI_ISSUE_PATTERNS = ( + "实际值一致", + "时间值一致", + "格式差异", + "补零差异", + "小时格式", + "日期格式补零", + "仅格式", + "无需修正", +) +AI_PENDING_STATE = "AI修改-待确认" +STILL_CONFIRM_STATE = "修订后仍需确认" +MANUAL_PASSED_STATE = "人工复核通过" +DATETIME_COLUMNS = {"入院时间", "最后书写时间", "出院时间"} + +WORKSPACE_ROOT = Path(os.getenv("WORKSPACE_ROOT", ".")).resolve() +RESULT_ROOT = WORKSPACE_ROOT / os.getenv("REVIEW_RESULT_ROOT", "数据处理结果区/已处理-患者目录图片集群") +CORRECTIONS_PATH = WORKSPACE_ROOT / os.getenv("REVIEW_CORRECTIONS_PATH", "数据处理工作区/03_人工复核修正.json") +CONFIG_PATH = WORKSPACE_ROOT / os.getenv("REVIEW_CONFIG_PATH", "数据处理工作区/人工复核网页端配置.json") +POSTGRES_SYNC_ENABLED = os.getenv("REVIEW_POSTGRES_SYNC", "1").lower() not in {"0", "false", "no", "off"} +MERGED_RESULT_PATH = WORKSPACE_ROOT / os.getenv("REVIEW_MERGED_RESULT_PATH", "数据处理结果区/合并_患者列表_结构化.json") +MERGED_CSV_PATH = WORKSPACE_ROOT / os.getenv("REVIEW_MERGED_CSV_PATH", "数据处理结果区/合并_患者列表_记录.csv") +RESULT_INFO_DIR = WORKSPACE_ROOT / os.getenv("REVIEW_RESULT_INFO_DIR", "数据处理结果区/信息记录") +PERMISSION_KEYS = ("overview", "review", "audit", "audit_history", "settings") +PERMISSION_LABELS = { + "overview": "概览", + "review": "复核", + "audit": "抽查", + "audit_history": "抽查一览", + "settings": "设置", +} +FULL_PERMISSIONS = {key: True for key in PERMISSION_KEYS} +KIMI_TIMEOUT_SECONDS = int(os.getenv("KIMI_TIMEOUT_SECONDS", "120")) +KIMI_IMAGE_MAX_WIDTH = int(os.getenv("KIMI_IMAGE_MAX_WIDTH", "1200")) +KIMI_AUDIT_MAX_TOKENS = int(os.getenv("KIMI_AUDIT_MAX_TOKENS", "768")) +KIMI_CORRECTION_MAX_TOKENS = int(os.getenv("KIMI_CORRECTION_MAX_TOKENS", "768")) + +app = Flask(__name__) +app.secret_key = ( + os.getenv("REVIEW_APP_SECRET_KEY") + or os.getenv("FLASK_SECRET_KEY") + or hashlib.sha256((os.getenv("HIS_DB_PASSWORD", "") + str(WORKSPACE_ROOT)).encode("utf-8")).hexdigest() + or secrets.token_hex(32) +) + + +@dataclass(frozen=True) +class ReviewItem: + key: str + batch_name: str + source: str + record: dict[str, Any] + origin: str + + +def json_response(data: Any, status: int = 200) -> Response: + return app.response_class( + json.dumps(data, ensure_ascii=False), + status=status, + mimetype="application/json; charset=utf-8", + ) + + +def error_response(message: str, status: int = 400) -> Response: + return json_response({"error": message}, status=status) + + +def normalize_text(value: Any) -> str: + if value is None: + return "" + return re.sub(r"\s+", " ", str(value)).strip() + + +def normalize_datetime_text(value: Any) -> str: + text = normalize_text(value) + if not text: + return "" + match = FLEX_DATETIME_PATTERN.fullmatch(text) + if not match: + return text + year, month, day, hour, minute, second = (int(part) for part in match.groups()) + try: + parsed = dt.datetime(year, month, day, hour, minute, second) + except ValueError: + return text + return parsed.strftime("%Y-%m-%d %H:%M:%S") + + +def item_key(image_path: str, row_no: int) -> str: + return hashlib.sha1(f"{image_path}|{row_no}".encode("utf-8")).hexdigest()[:20] + + +def require_login() -> None: + if not session.get("authenticated"): + abort(401) + + +@app.before_request +def auth_gate() -> None: + if request.path in {"/login", "/favicon.ico"} or request.path.startswith("/static/"): + return + if not session.get("authenticated"): + if request.path.startswith("/api/"): + abort(401) + return redirect(url_for("login")) + + +def load_json(path: Path, default: Any) -> Any: + if not path.exists(): + return default + return json.loads(path.read_text(encoding="utf-8")) + + +def load_config() -> dict[str, Any]: + return load_json(CONFIG_PATH, {"users": [], "kimi": {}}) + + +def normalized_permissions(value: Any) -> dict[str, bool]: + if not isinstance(value, dict): + return dict(FULL_PERMISSIONS) + return {key: bool(value.get(key, False)) for key in PERMISSION_KEYS} + + +def current_permissions() -> dict[str, bool]: + return normalized_permissions(session.get("permissions") or FULL_PERMISSIONS) + + +def require_permission(permission: str) -> None: + require_login() + if not current_permissions().get(permission): + abort(403) + + +def atomic_write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.with_suffix(path.suffix + ".tmp") + temp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + temp_path.replace(path) + + +def save_config(config: dict[str, Any]) -> None: + atomic_write_json(CONFIG_PATH, config) + + +def password_hash(password: str, salt: str | None = None) -> dict[str, str]: + salt = salt or secrets.token_hex(16) + digest = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() + return {"salt": salt, "hash": digest} + + +def password_matches(password: str, user: dict[str, Any]) -> bool: + salt = str(user.get("salt", "")) + expected = str(user.get("password_hash", "")) + digest = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() + return bool(expected) and secrets.compare_digest(digest, expected) + + +def correction_index() -> dict[str, dict[str, Any]]: + items = load_json(CORRECTIONS_PATH, []) + index: dict[str, dict[str, Any]] = {} + for item in items: + if item.get("已提交人工通过"): + continue + image_path = normalize_text(item.get("图片路径")) + row_no = int(item.get("图片内行号") or 0) + if image_path and row_no: + index[item_key(image_path, row_no)] = item + return index + + +def archived_correction_keys() -> set[str]: + keys: set[str] = set() + for item in load_json(CORRECTIONS_PATH, []): + if not item.get("已提交人工通过"): + continue + image_path = normalize_text(item.get("图片路径")) + row_no = int(item.get("图片内行号") or 0) + if image_path and row_no: + keys.add(item_key(image_path, row_no)) + return keys + + +def save_correction(item: dict[str, Any]) -> None: + data = load_json(CORRECTIONS_PATH, []) + key = item_key(item["图片路径"], int(item["图片内行号"])) + replaced = False + for index, existing in enumerate(data): + existing_key = item_key(normalize_text(existing.get("图片路径")), int(existing.get("图片内行号") or 0)) + if existing_key == key: + data[index] = item + replaced = True + break + if not replaced: + data.append(item) + atomic_write_json(CORRECTIONS_PATH, data) + + +def set_correction_sync_status(key: str, sync_status: dict[str, Any]) -> None: + data = load_json(CORRECTIONS_PATH, []) + changed = False + for item in data: + existing_key = item_key(normalize_text(item.get("图片路径")), int(item.get("图片内行号") or 0)) + if existing_key == key: + item["PostgreSQL同步"] = sync_status + changed = True + break + if changed: + atomic_write_json(CORRECTIONS_PATH, data) + + +def delete_correction(key: str) -> bool: + data = load_json(CORRECTIONS_PATH, []) + kept = [] + removed = False + for item in data: + existing_key = item_key(normalize_text(item.get("图片路径")), int(item.get("图片内行号") or 0)) + if existing_key == key: + removed = True + else: + kept.append(item) + if removed: + atomic_write_json(CORRECTIONS_PATH, kept) + return removed + + +def result_dirs() -> list[Path]: + if not RESULT_ROOT.exists(): + return [] + return sorted(path for path in RESULT_ROOT.glob("*-列表归档结果") if path.is_dir()) + + +def build_review_items() -> list[ReviewItem]: + items: dict[str, ReviewItem] = {} + for result_dir in result_dirs(): + batch_name = result_dir.name.removesuffix("-列表归档结果") + report = load_json(result_dir / "复核报告.json", {}) + for source, records in ( + ("需人工复核记录", report.get("需人工复核记录", [])), + ("人工修正记录", report.get("人工修正记录", [])), + ): + for record in records: + patient = normalize_patient(record.get("患者信息", {})) + tips = effective_review_tips(record.get("复核", {}).get("提示", [])) + if source == "需人工复核记录" and not tips and not validate_patient(patient, {}): + continue + image = record.get("图片信息", {}) + image_path = normalize_text(image.get("图片路径")) + row_no = int(image.get("图片内行号") or 0) + if not image_path or not row_no: + continue + key = item_key(image_path, row_no) + items[key] = ReviewItem(key, batch_name, source, record, "review_report") + + return sorted(items.values(), key=lambda item: (item.batch_name, item.record.get("来源文件夹", ""), item.record.get("图片信息", {}).get("图片名", ""), item.record.get("图片信息", {}).get("图片内行号", 0))) + + +def parse_datetime(text: str) -> dt.datetime | None: + text = normalize_datetime_text(text) + if not text: + return None + if not DATETIME_PATTERN.fullmatch(text): + return None + try: + return dt.datetime.strptime(text, "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + + +def validate_patient(patient: dict[str, Any], options: dict[str, Any] | None = None) -> list[str]: + options = options or {} + warnings: list[str] = [] + if not normalize_text(patient.get("姓名")): + warnings.append("缺少姓名") + if normalize_text(patient.get("性别")) not in {"男", "女"}: + warnings.append("性别异常") + age = normalize_text(patient.get("年龄")) + if age and not re.fullmatch(r"\d{1,3}岁", age): + warnings.append("年龄格式异常") + if not normalize_text(patient.get("住院号")): + warnings.append("缺少住院号") + admission = normalize_datetime_text(patient.get("入院时间")) + discharge = normalize_datetime_text(patient.get("出院时间")) + last_write = normalize_datetime_text(patient.get("最后书写时间")) + + admission_dt = parse_datetime(admission) + discharge_dt = parse_datetime(discharge) + last_write_dt = parse_datetime(last_write) + + if not admission: + warnings.append("缺少入院时间") + elif not admission_dt: + warnings.append("入院时间格式异常") + + if discharge and not discharge_dt: + warnings.append("出院时间格式异常") + + if last_write and not last_write_dt: + warnings.append("最后书写时间格式异常") + if admission_dt and discharge_dt and discharge_dt < admission_dt: + warnings.append("出院时间早于入院时间") + if admission_dt and last_write_dt and last_write_dt < admission_dt: + warnings.append("最后书写时间早于入院时间") + + hospital_days = normalize_text(patient.get("住院天数")) + if hospital_days and not hospital_days.isdigit(): + warnings.append("住院天数格式异常") + postoperative_days = normalize_text(patient.get("手术后天数")) + if postoperative_days and not re.fullmatch(r"后\d+天", postoperative_days): + warnings.append("手术后天数格式异常") + return warnings + + +def patient_change_list(before: dict[str, Any], after: dict[str, Any]) -> list[dict[str, str]]: + changes: list[dict[str, str]] = [] + normalized_before = normalize_patient(before or {}) + normalized_after = normalize_patient(after or {}) + for column in COLUMNS: + old = normalize_text(normalized_before.get(column, "")) + new = normalize_text(normalized_after.get(column, "")) + if old != new: + changes.append({"字段": column, "修改前": old, "修改后": new}) + return changes + + +def change_log_entries(before: dict[str, Any], after: dict[str, Any], source: str) -> list[dict[str, str]]: + return [{**item, "修改者": source} for item in patient_change_list(before, after)] + + +def normalized_change_log(value: Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, str]] = [] + for item in value: + if not isinstance(item, dict): + continue + field = normalize_text(item.get("字段") or item.get("column")) + if field not in COLUMNS: + continue + normalized.append( + { + "字段": field, + "修改前": normalize_text(item.get("修改前") if "修改前" in item else item.get("before", "")), + "修改后": normalize_text(item.get("修改后") if "修改后" in item else item.get("after", "")), + "修改者": normalize_text(item.get("修改者") or item.get("source") or "人工"), + } + ) + return normalized + + +def fallback_change_log(record: dict[str, Any], correction: dict[str, Any] | None) -> list[dict[str, str]]: + if not correction: + return [] + existing_log = normalized_change_log(correction.get("修改记录")) + if existing_log: + return existing_log + source = "AI" if correction.get("AI修改") or correction.get("修改来源") == "AI修改" else "人工" + return change_log_entries(record.get("患者信息", {}), merged_patient(record, correction), source) + + +def patient_change_summary(before: dict[str, Any], after: dict[str, Any]) -> str: + changes = patient_change_list(before, after) + if not changes: + return "" + return ";".join(f"{item['字段']}:{item['修改前'] or '空'} -> {item['修改后'] or '空'}" for item in changes) + + +def effective_review_tips(tips: Any) -> list[str]: + if not isinstance(tips, list): + tips = [tips] if normalize_text(tips) else [] + return [normalize_text(tip) for tip in tips if normalize_text(tip) and normalize_text(tip) not in IGNORED_REVIEW_TIPS] + + +def merged_patient(record: dict[str, Any], correction: dict[str, Any] | None) -> dict[str, Any]: + patient = dict(record.get("患者信息", {})) + if correction: + patient.update(correction.get("患者信息", {})) + for column in COLUMNS: + patient.setdefault(column, "") + for column in ("入院时间", "出院时间", "最后书写时间"): + patient[column] = normalize_datetime_text(patient.get(column, "")) + return patient + + +def normalize_patient(patient: dict[str, Any]) -> dict[str, str]: + normalized = {column: normalize_text(patient.get(column, "")) for column in COLUMNS} + normalized["住院号"] = normalized["住院号"].upper() + for column in ("入院时间", "出院时间", "最后书写时间"): + normalized[column] = normalize_datetime_text(normalized[column]) + return normalized + + +def has_valid_inpatient_no(patient: dict[str, Any]) -> bool: + return bool(normalize_text(patient.get("住院号"))) + + +def record_has_valid_inpatient_no(record: dict[str, Any]) -> bool: + return has_valid_inpatient_no(record.get("患者信息", {})) + + +def hospital_days_value(value: Any) -> int | None: + text = normalize_text(value) + return int(text) if text.isdigit() else None + + +def review_notes(warnings: list[str], options: dict[str, Any], manual_note: str) -> str: + notes = list(warnings) + if manual_note: + notes.append(f"人工备注: {manual_note}") + return ";".join(notes) + + +def db_configured() -> bool: + return all(os.getenv(name) for name in ("HIS_DB_HOST", "HIS_DB_NAME", "HIS_DB_USER", "HIS_DB_PASSWORD")) + + +def postgres_connection() -> psycopg.Connection: + return psycopg.connect( + host=os.getenv("HIS_DB_HOST"), + port=int(os.getenv("HIS_DB_PORT", "5432")), + dbname=os.getenv("HIS_DB_NAME"), + user=os.getenv("HIS_DB_USER"), + password=os.getenv("HIS_DB_PASSWORD"), + connect_timeout=5, + ) + + +def update_postgres_record( + record: dict[str, Any], + patient: dict[str, str], + options: dict[str, Any], + warnings: list[str], + manual_note: str, + manual_corrected: bool, +) -> dict[str, Any]: + if not POSTGRES_SYNC_ENABLED: + return {"enabled": False, "updated": 0, "message": "PostgreSQL同步已关闭"} + if not db_configured(): + return {"enabled": True, "updated": 0, "error": "数据库环境变量未完整配置"} + + image = record.get("图片信息", {}) + image_path = normalize_text(image.get("图片路径")) + row_no = int(image.get("图片内行号") or 0) + if not image_path or not row_no: + return {"enabled": True, "updated": 0, "error": "缺少图片路径或图片内行号,无法定位数据库记录"} + if not has_valid_inpatient_no(patient): + return {"enabled": True, "updated": 0, "error": "缺少住院号,未同步到正式表", "retryable": False} + if warnings: + review_status = "需人工复核" + elif manual_corrected: + review_status = "人工复核通过" + else: + review_status = "自动复核通过" + notes = review_notes(warnings, options, manual_note) + params = { + "image_path": image_path, + "image_row_no": row_no, + "patient_name": patient["姓名"], + "gender": patient["性别"], + "age": patient["年龄"], + "inpatient_no": patient["住院号"], + "diagnosis": patient["诊断"], + "admission_time": patient["入院时间"], + "last_write_time": patient["最后书写时间"], + "hospital_days": hospital_days_value(patient["住院天数"]), + "discharge_time": patient["出院时间"], + "postoperative_days": patient["手术后天数"], + "review_status": review_status, + "review_notes": notes, + "manual_corrected": manual_corrected, + } + set_sql = """ + UPDATE "Patient_Lists" + SET patient_name = %(patient_name)s, + gender = %(gender)s, + age = %(age)s, + inpatient_no = %(inpatient_no)s, + diagnosis = %(diagnosis)s, + admission_time = %(admission_time)s, + last_write_time = %(last_write_time)s, + hospital_days = %(hospital_days)s, + discharge_time = %(discharge_time)s, + postoperative_days = %(postoperative_days)s, + review_status = %(review_status)s, + review_notes = %(review_notes)s, + manual_corrected = %(manual_corrected)s, + imported_at = now() + """ + try: + with postgres_connection() as conn: + with conn.cursor() as cur: + cur.execute( + 'SELECT record_id FROM "Patient_Lists" WHERE image_path = %(image_path)s AND image_row_no = %(image_row_no)s', + params, + ) + image_match = cur.fetchone() + inpatient_matches = [] + if patient["住院号"]: + cur.execute('SELECT record_id FROM "Patient_Lists" WHERE inpatient_no = %(inpatient_no)s LIMIT 2', params) + inpatient_matches = cur.fetchall() + if image_match and inpatient_matches and inpatient_matches[0][0] != image_match[0]: + cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": inpatient_matches[0][0]}) + updated = cur.rowcount + if updated: + cur.execute('DELETE FROM "Patient_Lists" WHERE record_id = %(record_id)s', {"record_id": image_match[0]}) + match_method = "inpatient_no覆盖重复图片记录" if updated else "" + elif image_match: + cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": image_match[0]}) + updated = cur.rowcount + match_method = "image_path+image_row_no" if updated else "" + elif len(inpatient_matches) == 1: + cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": inpatient_matches[0][0]}) + updated = cur.rowcount + match_method = "inpatient_no" if updated else "" + else: + updated = 0 + match_method = "" + result = {"enabled": True, "updated": updated, "match_method": match_method} + if updated == 0: + result["message"] = "PostgreSQL未找到对应记录,已保留本地修订,后续可重建/入库后再同步" + result["retryable"] = True + return result + except Exception as exc: + return {"enabled": True, "updated": 0, "error": str(exc), "retryable": True} + + +def sync_status_from_result(result: dict[str, Any]) -> dict[str, Any]: + if not result.get("enabled"): + state = "未启用" + elif result.get("error"): + state = "失败" + elif result.get("updated", 0) > 0: + state = "成功" + else: + state = "待同步" + return { + "状态": state, + "更新时间": dt.datetime.now().isoformat(timespec="seconds"), + "更新行数": result.get("updated", 0), + "匹配方式": result.get("match_method", ""), + "提示": result.get("error") or result.get("message", ""), + } + + +def pending_postgres_sync_count() -> int: + count = 0 + for correction in load_json(CORRECTIONS_PATH, []): + if correction.get("AI修改") or correction.get("已提交人工通过"): + continue + if not has_valid_inpatient_no(correction.get("患者信息", {})): + continue + status = correction.get("PostgreSQL同步", {}) + if status.get("状态") != "成功": + count += 1 + return count + + +def db_status() -> dict[str, Any]: + status = { + "enabled": POSTGRES_SYNC_ENABLED, + "configured": db_configured(), + "ok": False, + "pending_sync_count": pending_postgres_sync_count(), + } + if not POSTGRES_SYNC_ENABLED or not db_configured(): + return status + try: + with postgres_connection() as conn: + with conn.cursor() as cur: + cur.execute('SELECT 1 FROM "Patient_Lists" LIMIT 1') + status["ok"] = True + except Exception as exc: + status["error"] = str(exc) + return status + + +def ensure_audit_columns() -> None: + if not db_configured(): + return + statements = [ + 'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_result text', + 'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_ai_feedback text', + 'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_ai_raw_output text', + 'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_manual_feedback text', + 'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_machine_verdict text', + 'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_source text', + 'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_checked_by text', + 'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_checked_at timestamptz', + 'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_patient_before jsonb', + 'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_patient_after jsonb', + 'ALTER TABLE "Patient_Lists" ADD COLUMN IF NOT EXISTS audit_change_summary text', + 'CREATE INDEX IF NOT EXISTS idx_patient_lists_audit_result ON "Patient_Lists"(audit_result)', + ] + with postgres_connection() as conn: + with conn.cursor() as cur: + for statement in statements: + cur.execute(statement) + + +def valid_inpatient_sql(alias: str = "") -> str: + prefix = f"{alias}." if alias else "" + return f"{prefix}inpatient_no IS NOT NULL AND btrim({prefix}inpatient_no) <> ''" + + +def sample_db_records(source: str, count: int) -> list[dict[str, Any]]: + if not db_configured(): + return [] + ensure_audit_columns() + review_where = "review_status = '自动复核通过'" if source == "db_auto_passed" else "review_status = '人工复核通过'" + where = f"{review_where} AND {valid_inpatient_sql()}" + with postgres_connection() as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f'SELECT * FROM "Patient_Lists" WHERE {where} ORDER BY random() LIMIT %s', (count,)) + return [db_row_to_public(row) for row in cur.fetchall()] + + +def update_audit_postgres(public: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + if not POSTGRES_SYNC_ENABLED: + return {"enabled": False, "updated": 0, "message": "PostgreSQL同步已关闭"} + if not db_configured(): + return {"enabled": True, "updated": 0, "error": "数据库环境变量未完整配置"} + ensure_audit_columns() + image_path = normalize_text(public.get("image_path")) + row_no = int(public.get("image_row_no") or 0) + item_payload = payload.get("item") if isinstance(payload.get("item"), dict) else {} + original_patient = normalize_patient(payload.get("original_patient") or item_payload.get("audit_original_patient") or public.get("patient", {})) + patient = normalize_patient(item_payload.get("patient") or public.get("patient", {})) + inpatient_no = normalize_text(patient.get("住院号") or public.get("patient", {}).get("住院号")).upper() + if not inpatient_no: + return {"enabled": True, "updated": 0, "error": "缺少住院号,未同步到正式表", "retryable": False} + change_summary = patient_change_summary(original_patient, patient) + params = { + "image_path": image_path, + "image_row_no": row_no, + "inpatient_no": inpatient_no, + "patient_name": patient["姓名"], + "gender": patient["性别"], + "age": patient["年龄"], + "diagnosis": patient["诊断"], + "admission_time": patient["入院时间"], + "last_write_time": patient["最后书写时间"], + "hospital_days": hospital_days_value(patient["住院天数"]), + "discharge_time": patient["出院时间"], + "postoperative_days": patient["手术后天数"], + "audit_result": normalize_text(payload.get("audit_result") or payload.get("status")), + "audit_ai_feedback": normalize_text(payload.get("audit_ai_feedback") or payload.get("model_result")), + "audit_ai_raw_output": normalize_text(payload.get("audit_ai_raw_output")), + "audit_manual_feedback": normalize_text(payload.get("audit_manual_feedback")), + "audit_machine_verdict": normalize_text(payload.get("audit_machine_verdict")), + "audit_source": normalize_text(payload.get("audit_source")), + "audit_checked_by": session.get("username", ""), + "audit_patient_before": json.dumps(original_patient, ensure_ascii=False), + "audit_patient_after": json.dumps(patient, ensure_ascii=False), + "audit_change_summary": change_summary, + } + set_sql = """ + UPDATE "Patient_Lists" + SET patient_name = %(patient_name)s, + gender = %(gender)s, + age = %(age)s, + inpatient_no = %(inpatient_no)s, + diagnosis = %(diagnosis)s, + admission_time = %(admission_time)s, + last_write_time = %(last_write_time)s, + hospital_days = %(hospital_days)s, + discharge_time = %(discharge_time)s, + postoperative_days = %(postoperative_days)s, + audit_result = %(audit_result)s, + audit_ai_feedback = %(audit_ai_feedback)s, + audit_ai_raw_output = %(audit_ai_raw_output)s, + audit_manual_feedback = %(audit_manual_feedback)s, + audit_machine_verdict = %(audit_machine_verdict)s, + audit_source = %(audit_source)s, + audit_checked_by = %(audit_checked_by)s, + audit_patient_before = %(audit_patient_before)s::jsonb, + audit_patient_after = %(audit_patient_after)s::jsonb, + audit_change_summary = %(audit_change_summary)s, + audit_checked_at = now() + """ + try: + with postgres_connection() as conn: + with conn.cursor() as cur: + updated = 0 + match_method = "" + image_match = None + inpatient_matches = [] + if image_path and row_no: + cur.execute( + 'SELECT record_id FROM "Patient_Lists" WHERE image_path = %(image_path)s AND image_row_no = %(image_row_no)s', + params, + ) + image_match = cur.fetchone() + if inpatient_no: + cur.execute('SELECT record_id FROM "Patient_Lists" WHERE inpatient_no = %(inpatient_no)s LIMIT 2', params) + inpatient_matches = cur.fetchall() + if image_match and inpatient_matches and inpatient_matches[0][0] != image_match[0]: + cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": inpatient_matches[0][0]}) + updated = cur.rowcount + if updated: + cur.execute('DELETE FROM "Patient_Lists" WHERE record_id = %(record_id)s', {"record_id": image_match[0]}) + match_method = "inpatient_no覆盖重复图片记录" if updated else "" + elif image_match: + cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": image_match[0]}) + updated = cur.rowcount + match_method = "image_path+image_row_no" if updated else "" + elif len(inpatient_matches) == 1: + cur.execute(set_sql + " WHERE record_id = %(record_id)s", {**params, "record_id": inpatient_matches[0][0]}) + updated = cur.rowcount + match_method = "inpatient_no" if updated else "" + result = {"enabled": True, "updated": updated, "match_method": match_method} + if updated == 0: + result["message"] = "PostgreSQL未找到对应抽查记录" + return result + except Exception as exc: + return {"enabled": True, "updated": 0, "error": str(exc)} + + +def audit_history_rows( + page: int, + page_size: int, + source: str = "", + sort: str = "checked_desc", + query: str = "", + status: str = "", +) -> dict[str, Any]: + if not db_configured(): + return {"items": [], "total": 0, "page": page, "page_size": page_size} + ensure_audit_columns() + clauses = [""" + COALESCE(audit_result, '') <> '' + OR COALESCE(audit_ai_feedback, '') <> '' + OR COALESCE(audit_ai_raw_output, '') <> '' + OR COALESCE(audit_manual_feedback, '') <> '' + OR COALESCE(audit_machine_verdict, '') <> '' + OR COALESCE(audit_change_summary, '') <> '' + """] + clauses.append(valid_inpatient_sql()) + params: list[Any] = [] + if source: + clauses.append("audit_source = %s") + params.append(source) + status_clauses = { + "manual_pending": "COALESCE(audit_result, '') = ''", + "manual_passed": "audit_result = '人工核验通过'", + "manual_failed": "audit_result = '人工核验异常'", + "manual_unsure": "audit_result = '暂不确定'", + "ai_failed": "audit_machine_verdict = '异常'", + } + if status in status_clauses: + clauses.append(status_clauses[status]) + if query: + like = f"%{query}%" + clauses.append(""" + patient_name ILIKE %s + OR inpatient_no ILIKE %s + OR major_department ILIKE %s + OR sub_department ILIKE %s + OR source_folder ILIKE %s + """) + params.extend([like, like, like, like, like]) + where = " AND ".join(f"({clause})" for clause in clauses) + order_sql = "audit_checked_at ASC NULLS LAST, record_id ASC" if sort == "checked_asc" else "audit_checked_at DESC NULLS LAST, record_id DESC" + with postgres_connection() as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute(f'SELECT count(*) AS total FROM "Patient_Lists" WHERE {where}', params) + total = int(cur.fetchone()["total"]) + cur.execute( + f'SELECT * FROM "Patient_Lists" WHERE {where} ORDER BY {order_sql} LIMIT %s OFFSET %s', + (*params, page_size, (page - 1) * page_size), + ) + rows = [db_row_to_public(row) for row in cur.fetchall()] + return {"items": rows, "total": total, "page": page, "page_size": page_size} + + +def reset_audit_history_records() -> dict[str, Any]: + if not POSTGRES_SYNC_ENABLED: + return {"enabled": False, "updated": 0, "message": "PostgreSQL同步已关闭"} + if not db_configured(): + return {"enabled": True, "updated": 0, "error": "数据库环境变量未完整配置"} + ensure_audit_columns() + where = """ + COALESCE(audit_result, '') <> '' + OR COALESCE(audit_ai_feedback, '') <> '' + OR COALESCE(audit_ai_raw_output, '') <> '' + OR COALESCE(audit_manual_feedback, '') <> '' + OR COALESCE(audit_machine_verdict, '') <> '' + OR COALESCE(audit_source, '') <> '' + OR COALESCE(audit_change_summary, '') <> '' + OR audit_checked_at IS NOT NULL + """ + with postgres_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f""" + UPDATE "Patient_Lists" + SET audit_result = NULL, + audit_ai_feedback = NULL, + audit_ai_raw_output = NULL, + audit_manual_feedback = NULL, + audit_machine_verdict = NULL, + audit_source = NULL, + audit_checked_by = NULL, + audit_patient_before = NULL, + audit_patient_after = NULL, + audit_change_summary = NULL, + audit_checked_at = NULL + WHERE {where} + """ + ) + updated = cur.rowcount + return {"enabled": True, "updated": updated} + + +def audit_overview_summary() -> dict[str, Any]: + summary = { + "total": 0, + "unreviewed": 0, + "passed": 0, + "failed": 0, + "unsure": 0, + "ai_only": 0, + "manual_review_passed_source": 0, + "db_auto_passed_source": 0, + } + if not db_configured(): + return summary + try: + ensure_audit_columns() + with postgres_connection() as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + count(*) FILTER ( + WHERE COALESCE(audit_result, '') <> '' + OR COALESCE(audit_ai_feedback, '') <> '' + OR COALESCE(audit_ai_raw_output, '') <> '' + OR COALESCE(audit_machine_verdict, '') <> '' + ) AS total, + count(*) FILTER ( + WHERE COALESCE(audit_result, '') = '' + AND ( + COALESCE(audit_ai_feedback, '') <> '' + OR COALESCE(audit_ai_raw_output, '') <> '' + OR COALESCE(audit_machine_verdict, '') <> '' + ) + ) AS unreviewed, + count(*) FILTER (WHERE audit_result = '人工核验通过') AS passed, + count(*) FILTER (WHERE audit_result = '人工核验异常') AS failed, + count(*) FILTER (WHERE audit_result = '暂不确定') AS unsure, + count(*) FILTER ( + WHERE COALESCE(audit_result, '') = '' + AND COALESCE(audit_machine_verdict, '') <> '' + ) AS ai_only, + count(*) FILTER (WHERE audit_source = 'manual_review_passed') AS manual_review_passed_source, + count(*) FILTER (WHERE audit_source = 'db_auto_passed') AS db_auto_passed_source + FROM "Patient_Lists" + """ + ) + row = cur.fetchone() or {} + summary.update({key: int(row.get(key) or 0) for key in summary}) + except Exception as exc: + summary["error"] = str(exc) + return summary + + +def processing_items_overview() -> dict[str, Any]: + corrections = correction_index() + archived = archived_correction_keys() + items = [ + public + for item in build_review_items() + if item.key not in archived + for public in [public_item(item, corrections.get(item.key))] + if has_valid_inpatient_no(public["patient"]) + ] + state_counts = {"待处理": 0, AI_PENDING_STATE: 0, STILL_CONFIRM_STATE: 0, MANUAL_PASSED_STATE: 0} + for item in items: + state_counts[item["manual_state"]] = state_counts.get(item["manual_state"], 0) + 1 + audit_summary = audit_overview_summary() + confirm_review = state_counts[AI_PENDING_STATE] + state_counts[STILL_CONFIRM_STATE] + return { + "review_total": len(items), + "pending_review": state_counts["待处理"], + "ai_pending": state_counts[AI_PENDING_STATE], + "still_issue": confirm_review, + "manual_passed": state_counts[MANUAL_PASSED_STATE], + "postgres_pending_sync": pending_postgres_sync_count(), + "audit_unreviewed": audit_summary.get("unreviewed", 0), + "audit_total": audit_summary.get("total", 0), + "audit_failed": audit_summary.get("failed", 0), + "audit_unsure": audit_summary.get("unsure", 0), + "audit_summary": audit_summary, + } + + +def public_item(item: ReviewItem, correction: dict[str, Any] | None = None) -> dict[str, Any]: + record = item.record + image = record.get("图片信息", {}) + review = record.get("复核", {}) + correction = correction or correction_index().get(item.key) + options = (correction or {}).get("复核选项", {}) + patient = merged_patient(record, correction) + warnings = validate_patient(patient, options) + if correction and correction.get("AI修改"): + state = AI_PENDING_STATE + elif correction and not warnings: + state = MANUAL_PASSED_STATE + elif correction: + state = STILL_CONFIRM_STATE + else: + state = "待处理" + return { + "key": item.key, + "batch_name": item.batch_name, + "source": item.source, + "origin": item.origin, + "major_department": record.get("大科室", ""), + "sub_department": record.get("子科室", ""), + "source_folder": record.get("来源文件夹", ""), + "image_path": image.get("图片路径", ""), + "image_name": image.get("图片名", ""), + "image_row_no": image.get("图片内行号", ""), + "patient": patient, + "original_patient": record.get("患者信息", {}), + "review_status": review.get("状态", ""), + "review_tips": effective_review_tips(review.get("提示", [])), + "manual_state": state, + "manual_note": (correction or {}).get("复核备注", ""), + "change_source": (correction or {}).get("修改来源") or ("AI修改" if (correction or {}).get("AI修改") else ("人工修改" if correction else "")), + "change_log": fallback_change_log(record, correction), + "ai_corrected": bool((correction or {}).get("AI修改")), + "ai_feedback": (correction or {}).get("AI反馈", ""), + "options": options, + "validation_warnings": warnings, + "updated_at": (correction or {}).get("更新时间", ""), + "postgres_sync": (correction or {}).get("PostgreSQL同步", {}), + "audit_original_patient": patient, + "audit_ai_feedback": record.get("audit_ai_feedback", ""), + "audit_ai_raw_output": record.get("audit_ai_raw_output", ""), + "audit_manual_feedback": record.get("audit_manual_feedback", ""), + "audit_machine_verdict": record.get("audit_machine_verdict", ""), + "audit_source": record.get("audit_source", ""), + "audit_checked_by": record.get("audit_checked_by", ""), + "audit_checked_at": str(record.get("audit_checked_at") or ""), + "audit_change_summary": record.get("audit_change_summary", ""), + } + + +def public_record(record: dict[str, Any]) -> dict[str, Any]: + image = record.get("图片信息", {}) + patient = record.get("患者信息", {}) + image_path = normalize_text(image.get("图片路径")) + row_no = int(image.get("图片内行号") or 0) + return { + "key": item_key(image_path, row_no), + "batch_name": record.get("处理批次", ""), + "major_department": record.get("大科室", ""), + "sub_department": record.get("子科室", ""), + "source_folder": record.get("来源文件夹", ""), + "image_path": image_path, + "image_name": image.get("图片名", ""), + "image_row_no": row_no, + "patient": {column: patient.get(column, "") for column in COLUMNS}, + "audit_original_patient": {column: patient.get(column, "") for column in COLUMNS}, + "audit_result": record.get("audit_result", ""), + "audit_ai_feedback": record.get("audit_ai_feedback", ""), + "audit_ai_raw_output": record.get("audit_ai_raw_output", ""), + "audit_manual_feedback": record.get("audit_manual_feedback", ""), + "audit_machine_verdict": record.get("audit_machine_verdict", ""), + "audit_source": record.get("audit_source", ""), + "audit_checked_by": record.get("audit_checked_by", ""), + "audit_checked_at": str(record.get("audit_checked_at") or ""), + "audit_change_summary": record.get("audit_change_summary", ""), + } + + +def db_row_to_public(row: dict[str, Any]) -> dict[str, Any]: + patient = { + "姓名": row.get("patient_name", ""), + "性别": row.get("gender", ""), + "年龄": row.get("age", ""), + "住院号": row.get("inpatient_no", ""), + "诊断": row.get("diagnosis", ""), + "入院时间": row.get("admission_time", ""), + "最后书写时间": row.get("last_write_time", ""), + "住院天数": "" if row.get("hospital_days") is None else str(row.get("hospital_days")), + "出院时间": row.get("discharge_time", ""), + "手术后天数": row.get("postoperative_days", ""), + } + image_path = normalize_text(row.get("image_path")) + row_no = int(row.get("image_row_no") or 0) + return { + "key": item_key(image_path, row_no), + "record_id": row.get("record_id"), + "batch_name": row.get("batch_name", ""), + "major_department": row.get("major_department", ""), + "sub_department": row.get("sub_department", ""), + "source_folder": row.get("source_folder", ""), + "image_path": image_path, + "image_name": row.get("image_name", ""), + "image_row_no": row_no, + "patient": patient, + "audit_original_patient": row.get("audit_patient_before") or patient, + "review_status": row.get("review_status", ""), + "audit_result": row.get("audit_result", ""), + "audit_ai_feedback": row.get("audit_ai_feedback", ""), + "audit_ai_raw_output": row.get("audit_ai_raw_output", ""), + "audit_manual_feedback": row.get("audit_manual_feedback", ""), + "audit_machine_verdict": row.get("audit_machine_verdict", ""), + "audit_source": row.get("audit_source", ""), + "audit_checked_by": row.get("audit_checked_by", ""), + "audit_checked_at": str(row.get("audit_checked_at") or ""), + "audit_change_summary": row.get("audit_change_summary", ""), + } + + +def all_patient_records() -> list[dict[str, Any]]: + if not MERGED_RESULT_PATH.exists(): + return [] + merged = load_json(MERGED_RESULT_PATH, {}) + return [ + record + for record in merged.get("患者记录", []) + if record.get("图片信息", {}).get("图片路径") and record_has_valid_inpatient_no(record) + ] + + +def record_lookup_key(record: dict[str, Any]) -> str: + image = record.get("图片信息", {}) + image_path = normalize_text(image.get("图片路径")) + row_no = int(image.get("图片内行号") or 0) + return item_key(image_path, row_no) if image_path and row_no else "" + + +def storage_patient(patient: dict[str, Any]) -> dict[str, Any]: + normalized = normalize_patient(patient) + stored: dict[str, Any] = {column: normalized.get(column, "") for column in COLUMNS} + if stored["住院天数"].isdigit(): + stored["住院天数"] = int(stored["住院天数"]) + return stored + + +def merged_csv_row(record: dict[str, Any]) -> dict[str, Any]: + image = record.get("图片信息", {}) + patient = normalize_patient(record.get("患者信息", {})) + review = record.get("复核", {}) + tips = review.get("提示", []) + if not isinstance(tips, list): + tips = [tips] if normalize_text(tips) else [] + return { + "处理批次": record.get("处理批次", ""), + "大科室": record.get("大科室", ""), + "子科室": record.get("子科室", ""), + "来源文件夹": record.get("来源文件夹", ""), + "图片路径": image.get("图片路径", ""), + "图片名": image.get("图片名", ""), + "图片内行号": image.get("图片内行号", ""), + "拼接组序号": image.get("拼接组序号", ""), + "OCR请求ID": image.get("OCR请求ID", ""), + "姓名": patient["姓名"], + "性别": patient["性别"], + "年龄": patient["年龄"], + "住院号": patient["住院号"], + "诊断": patient["诊断"], + "入院时间": patient["入院时间"], + "最后书写时间": patient["最后书写时间"], + "住院天数": patient["住院天数"], + "出院时间": patient["出院时间"], + "手术后天数": patient["手术后天数"], + "复核状态": review.get("状态", ""), + "复核提示": ";".join(normalize_text(tip) for tip in tips if normalize_text(tip)), + "人工修正": bool(review.get("人工修正")), + } + + +def write_merged_csv(records: list[dict[str, Any]]) -> None: + fields = [ + "处理批次", + "大科室", + "子科室", + "来源文件夹", + "图片路径", + "图片名", + "图片内行号", + "拼接组序号", + "OCR请求ID", + "姓名", + "性别", + "年龄", + "住院号", + "诊断", + "入院时间", + "最后书写时间", + "住院天数", + "出院时间", + "手术后天数", + "复核状态", + "复核提示", + "人工修正", + ] + MERGED_CSV_PATH.parent.mkdir(parents=True, exist_ok=True) + temp_path = MERGED_CSV_PATH.with_suffix(MERGED_CSV_PATH.suffix + ".tmp") + with temp_path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + for record in records: + writer.writerow(merged_csv_row(record)) + temp_path.replace(MERGED_CSV_PATH) + + +def commit_manual_passed_records() -> dict[str, Any]: + corrections = load_json(CORRECTIONS_PATH, []) + review_items = {item.key: item for item in build_review_items()} + merged = load_json(MERGED_RESULT_PATH, {"汇总": {}, "患者记录": []}) + records = merged.get("患者记录", []) + record_index = {record_lookup_key(record): record for record in records if record_lookup_key(record)} + summary = { + "eligible": 0, + "merged_updated": 0, + "merged_already_current": 0, + "merged_missing": 0, + "postgres_success": 0, + "postgres_already_success": 0, + "postgres_not_found": 0, + "postgres_failed": 0, + "skipped": 0, + "skipped_missing_key": 0, + "skipped_ai": 0, + "skipped_archived": 0, + "skipped_issue": 0, + "archived_corrections": 0, + "items": [], + } + changed_merged = False + changed_corrections = False + archived_now: set[str] = set() + + for correction in corrections: + key = item_key(normalize_text(correction.get("图片路径")), int(correction.get("图片内行号") or 0)) + if not key: + summary["skipped"] += 1 + summary["skipped_missing_key"] += 1 + continue + if correction.get("已提交人工通过"): + summary["skipped"] += 1 + summary["skipped_archived"] += 1 + continue + if correction.get("AI修改"): + summary["skipped"] += 1 + summary["skipped_ai"] += 1 + continue + patient = normalize_patient(correction.get("患者信息", {})) + warnings = validate_patient(patient, correction.get("复核选项", {})) + if warnings: + summary["skipped"] += 1 + summary["skipped_issue"] += 1 + continue + summary["eligible"] += 1 + + merged_record = record_index.get(key) + if merged_record: + stored_patient = storage_patient(patient) + review = merged_record.setdefault("复核", {}) + manual_note = normalize_text(correction.get("复核备注", "")) + desired_review = { + "状态": "人工复核通过", + "提示": [], + "人工修正": True, + } + if manual_note: + desired_review["人工备注"] = manual_note + needs_update = ( + merged_record.get("患者信息") != stored_patient + or any(review.get(key) != value for key, value in desired_review.items()) + ) + if needs_update: + merged_record["患者信息"] = stored_patient + review.update(desired_review) + review["人工复核提交时间"] = dt.datetime.now().isoformat(timespec="seconds") + summary["merged_updated"] += 1 + changed_merged = True + else: + summary["merged_already_current"] += 1 + else: + summary["merged_missing"] += 1 + + sync_status = correction.get("PostgreSQL同步", {}) + if sync_status.get("状态") == "成功": + summary["postgres_already_success"] += 1 + summary["items"].append({"key": key, "status": "already_success"}) + correction["已提交人工通过"] = True + correction["提交归档时间"] = dt.datetime.now().isoformat(timespec="seconds") + archived_now.add(key) + changed_corrections = True + continue + + target = review_items.get(key) + if target is None: + summary["postgres_failed"] += 1 + summary["items"].append({"key": key, "status": "failed", "message": "未找到本地复核条目"}) + correction["已提交人工通过"] = True + correction["提交归档时间"] = dt.datetime.now().isoformat(timespec="seconds") + archived_now.add(key) + changed_corrections = True + continue + result = update_postgres_record( + target.record, + patient, + correction.get("复核选项", {}), + [], + normalize_text(correction.get("复核备注", "")), + True, + ) + correction["PostgreSQL同步"] = sync_status_from_result(result) + changed_corrections = True + if result.get("error"): + summary["postgres_failed"] += 1 + item_status = "failed" + elif result.get("updated", 0) > 0: + summary["postgres_success"] += 1 + item_status = "success" + else: + summary["postgres_not_found"] += 1 + item_status = "not_found" + summary["items"].append({"key": key, "status": item_status, "postgres": result}) + correction["已提交人工通过"] = True + correction["提交归档时间"] = dt.datetime.now().isoformat(timespec="seconds") + archived_now.add(key) + changed_corrections = True + + if changed_merged: + merged.setdefault("汇总", {})["人工复核提交时间"] = dt.datetime.now().isoformat(timespec="seconds") + atomic_write_json(MERGED_RESULT_PATH, merged) + write_merged_csv(records) + summary["archived_corrections"] = len(archived_now) + if changed_corrections: + atomic_write_json(CORRECTIONS_PATH, corrections) + RESULT_INFO_DIR.mkdir(parents=True, exist_ok=True) + report_path = RESULT_INFO_DIR / "提交已人工通过数据报告.json" + atomic_write_json(report_path, {**summary, "生成时间": dt.datetime.now().isoformat(timespec="seconds")}) + summary["report_path"] = str(report_path.relative_to(WORKSPACE_ROOT)) + summary["postgres"] = db_status() + return summary + + +def audit_public_by_key(key: str) -> dict[str, Any] | None: + if key in archived_correction_keys(): + return None + for item in build_review_items(): + public = public_item(item, correction_index().get(item.key)) + if public["key"] == key and has_valid_inpatient_no(public["patient"]): + return public + for record in all_patient_records(): + public = public_record(record) + if public["key"] == key: + return public + if db_configured(): + try: + with postgres_connection() as conn: + with conn.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT * FROM "Patient_Lists" + WHERE encode(sha1((image_path || '|' || image_row_no::text)::bytea), 'hex') LIKE %(prefix)s + LIMIT 1 + """, + {"prefix": key + "%"}, + ) + row = cur.fetchone() + return db_row_to_public(row) if row else None + except Exception: + return None + return None + + +def kimi_config() -> dict[str, Any]: + config = load_config().get("kimi", {}) + enabled_value = config.get("enabled", os.getenv("KIMI_API_ENABLED", "1")) + enabled = str(enabled_value).lower() not in {"0", "false", "no", "off"} + return { + "api_key": config.get("api_key") or os.getenv("KIMI_API_KEY") or os.getenv("MOONSHOT_API_KEY") or "", + "model": config.get("model") or os.getenv("KIMI_MODEL") or "kimi-k2.6", + "enabled": enabled, + } + + +def kimi_audit_prompt(public: dict[str, Any]) -> str: + return ( + "你是HIS患者列表OCR抽查助手。请核对截图中目标患者所在行与给定结构化字段是否一致;目标行通常位于裁剪区域中部附近。" + "重点核对姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数。" + "患者出院后医生可能继续补充病历,所以最后书写时间晚于出院时间不算异常,不要把它列为问题。" + "日期时间只比较真实时间值,不比较补零格式;例如2021-11-12 7:39:05与2021-11-12 07:39:05完全一致,必须判定通过,不要列为异常,也不要建议改成未补零格式。" + "只返回一个JSON对象,不要解释、不要Markdown表格、不要代码块外文本。" + "JSON格式为:{\"结论\":\"通过/异常/不确定\",\"问题\":[...],\"建议修正\":{...}}。" + f"结构化字段:{json.dumps(public['patient'], ensure_ascii=False)}" + ) + + +def equivalent_field_value(field: str, left: Any, right: Any) -> bool: + left_text = normalize_text(left) + right_text = normalize_text(right) + if left_text == right_text: + return True + if field in DATETIME_COLUMNS: + return normalize_datetime_text(left_text) == normalize_datetime_text(right_text) + return False + + +def clean_audit_model_json(data: dict[str, Any], public: dict[str, Any] | None = None) -> dict[str, Any]: + cleaned = dict(data) + patient = normalize_patient((public or {}).get("patient", {})) + suggestions = cleaned.get("建议修正") + if isinstance(suggestions, dict): + kept_suggestions = {} + for field, value in suggestions.items(): + normalized_field = normalize_text(field) + if normalized_field in COLUMNS and equivalent_field_value(normalized_field, value, patient.get(normalized_field, "")): + continue + kept_suggestions[field] = value + cleaned["建议修正"] = kept_suggestions + issues = cleaned.get("问题") + if isinstance(issues, list): + kept = [] + removed = [] + for issue in issues: + text = normalize_text(issue) + if any(pattern in text for pattern in IGNORED_AI_ISSUE_PATTERNS) or any(pattern in text for pattern in FORMAT_ONLY_AI_ISSUE_PATTERNS): + removed.append(text) + else: + kept.append(issue) + cleaned["问题"] = kept + suggestions = cleaned.get("建议修正") + if not cleaned.get("问题") and (not isinstance(suggestions, dict) or not suggestions): + cleaned["结论"] = "通过" + return cleaned + + +def parse_model_verdict(text: str) -> str: + cleaned = normalize_text(text) + if cleaned.startswith("```"): + cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned) + cleaned = re.sub(r"\s*```$", "", cleaned) + try: + data = json.loads(cleaned) + except Exception: + match = re.search(r"\{.*\}", cleaned, flags=re.S) + if not match: + return "" + try: + data = json.loads(match.group(0)) + except Exception: + return "" + verdict = normalize_text(data.get("结论") or data.get("verdict") or data.get("result")) + return verdict if verdict in {"通过", "异常", "不确定"} else verdict + + +def call_kimi_vision(public: dict[str, Any]) -> dict[str, str]: + config = kimi_config() + if not config["enabled"]: + raise RuntimeError("目前无AI功能") + if not config["api_key"]: + raise RuntimeError("未设置 AI API Key,请在设置页配置") + path = safe_workspace_path(public["image_path"]) + cropped = crop_image(path, int(public["image_row_no"] or 1), 2) + image_base64 = image_to_base64(cropped) + prompt = kimi_audit_prompt(public) + payload = { + "model": config["model"], + "temperature": 0.6, + "max_tokens": KIMI_AUDIT_MAX_TOKENS, + "thinking": {"type": "disabled"}, + "messages": [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}}, + {"type": "text", "text": prompt}, + ], + } + ], + } + request = urllib.request.Request( + "https://api.moonshot.cn/v1/chat/completions", + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Authorization": f"Bearer {config['api_key']}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=KIMI_TIMEOUT_SECONDS) 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(detail or str(exc)) from exc + except (TimeoutError, socket.timeout) as exc: + raise RuntimeError(f"AI接口超时({KIMI_TIMEOUT_SECONDS}秒),请稍后重试或减少批量数量") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"AI接口连接失败:{exc.reason}") from exc + result = data.get("choices", [{}])[0].get("message", {}).get("content", "") + parsed = parse_json_object(result) + if parsed: + parsed = clean_audit_model_json(parsed, public) + clean_result = json.dumps(parsed, ensure_ascii=False, indent=2) if parsed else "" + verdict_source = clean_result or result + return { + "prompt": prompt, + "result": clean_result, + "raw_result": result, + "machine_verdict": parse_model_verdict(verdict_source), + } + + +def parse_json_object(text: str) -> dict[str, Any]: + cleaned = normalize_text(text) + fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text or "", flags=re.S | re.I) + candidates = [fenced.group(1)] if fenced else [cleaned] + decoder = json.JSONDecoder() + for start, char in enumerate(cleaned): + if char == "{": + candidates.append(cleaned[start:]) + for candidate in candidates: + try: + data = json.loads(candidate) + return data if isinstance(data, dict) else {} + except Exception: + try: + data, _ = decoder.raw_decode(candidate) + return data if isinstance(data, dict) else {} + except Exception: + continue + return {} + + +def call_kimi_correction(public: dict[str, Any]) -> dict[str, Any]: + config = kimi_config() + if not config["enabled"]: + raise RuntimeError("目前无AI功能") + if not config["api_key"]: + raise RuntimeError("未设置 AI API Key,请在设置页配置") + path = safe_workspace_path(public["image_path"]) + cropped = crop_image(path, int(public["image_row_no"] or 1), 2) + image_base64 = image_to_base64(cropped) + prompt = ( + "你是HIS患者列表OCR修正助手。请根据截图中目标患者所在行修正给定结构化字段;目标行通常位于裁剪区域中部附近。" + "字段只能包含:姓名、性别、年龄、住院号、诊断、入院时间、最后书写时间、住院天数、出院时间、手术后天数。" + "时间格式尽量输出YYYY-MM-DD HH:MM:SS;出院时间可以为空;手术后天数为空或形如后X天。" + "请只返回JSON:{\"患者信息\":{...},\"说明\":\"...\"}。" + f"当前字段:{json.dumps(public['patient'], ensure_ascii=False)}" + ) + payload = { + "model": config["model"], + "temperature": 0.6, + "max_tokens": KIMI_CORRECTION_MAX_TOKENS, + "thinking": {"type": "disabled"}, + "messages": [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}}, + {"type": "text", "text": prompt}, + ], + } + ], + } + request = urllib.request.Request( + "https://api.moonshot.cn/v1/chat/completions", + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Authorization": f"Bearer {config['api_key']}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=KIMI_TIMEOUT_SECONDS) 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(detail or str(exc)) from exc + except (TimeoutError, socket.timeout) as exc: + raise RuntimeError(f"AI接口超时({KIMI_TIMEOUT_SECONDS}秒),请稍后重试或减少批量数量") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"AI接口连接失败:{exc.reason}") from exc + result = data.get("choices", [{}])[0].get("message", {}).get("content", "") + parsed = parse_json_object(result) + patient = parsed.get("患者信息", parsed) + if not isinstance(patient, dict): + patient = {} + corrected = dict(public.get("patient", {})) + corrected.update({column: patient.get(column, corrected.get(column, "")) for column in COLUMNS}) + return {"prompt": prompt, "result": result, "patient": normalize_patient(corrected), "note": normalize_text(parsed.get("说明", ""))} + + +def filtered_items() -> list[dict[str, Any]]: + corrections = correction_index() + archived = archived_correction_keys() + batch = normalize_text(request.args.get("batch")) + status = normalize_text(request.args.get("status") or "pending") + sort = normalize_text(request.args.get("sort") or "source") + query = normalize_text(request.args.get("q")).lower() + items = [] + for item in build_review_items(): + if item.key in archived: + continue + public = public_item(item, corrections.get(item.key)) + if not has_valid_inpatient_no(public["patient"]): + continue + if batch and public["batch_name"] != batch: + continue + if status == "pending" and public["manual_state"] != "待处理": + continue + if status == "done" and public["manual_state"] != MANUAL_PASSED_STATE: + continue + if status == "still_issue" and public["manual_state"] != STILL_CONFIRM_STATE: + continue + if status == "confirming" and public["manual_state"] not in {STILL_CONFIRM_STATE, AI_PENDING_STATE}: + continue + if status == "ai_pending" and public["manual_state"] != AI_PENDING_STATE: + continue + if query: + haystack = json.dumps(public, ensure_ascii=False).lower() + if query not in haystack: + continue + items.append(public) + if sort == "updated_desc": + items.sort(key=lambda item: (bool(item.get("updated_at")), item.get("updated_at") or ""), reverse=True) + return items + + +def safe_workspace_path(path_text: str) -> Path: + raw = Path(path_text) + path = raw if raw.is_absolute() else WORKSPACE_ROOT / raw + resolved = path.resolve() + if WORKSPACE_ROOT not in resolved.parents and resolved != WORKSPACE_ROOT: + abort(403) + if not resolved.exists(): + abort(404) + return resolved + + +def crop_image(path: Path, row_no: int, context_rows: int = 2) -> Image.Image: + if path.suffix.lower() not in IMAGE_EXTENSIONS: + abort(404) + with Image.open(path) as image: + source = image.convert("RGB") + width, height = source.size + inferred_rows = max(1, int(row_no or 1), round(height / 39.0)) + row_height = max(1.0, height / inferred_rows) + row_index = min(inferred_rows - 1, max(0, int(row_no or 1) - 1)) + target_top = int(row_index * row_height) + target_bottom = int(min(height, (row_index + 1) * row_height)) + top = int(max(0, target_top - row_height * context_rows)) + bottom = int(min(height, target_bottom + row_height * context_rows)) + if bottom <= top: + top = max(0, min(height - 1, target_top)) + bottom = min(height, top + max(1, int(row_height * max(1, context_rows + 1)))) + return source.crop((0, top, width, bottom)) + + +def image_to_base64(image: Image.Image) -> str: + source = image.convert("RGB") + if source.width > KIMI_IMAGE_MAX_WIDTH: + ratio = KIMI_IMAGE_MAX_WIDTH / source.width + next_size = (KIMI_IMAGE_MAX_WIDTH, max(1, round(source.height * ratio))) + source = source.resize(next_size, Image.Resampling.LANCZOS) + buffer = io.BytesIO() + source.save(buffer, format="PNG", optimize=True) + return base64.b64encode(buffer.getvalue()).decode("ascii") + + +def image_crop_response(path: Path, row_no: int, context_rows: int = 2) -> Response: + cropped = crop_image(path, row_no, context_rows) + buffer = io.BytesIO() + cropped.save(buffer, format="PNG") + buffer.seek(0) + return send_file(buffer, mimetype="image/png", max_age=0) + + +@app.route("/") +def index() -> str: + return render_template("index.html") + + +@app.route("/favicon.ico") +def favicon() -> Response: + return Response(status=204) + + +@app.route("/login", methods=["GET", "POST"]) +def login() -> str | Response: + if request.method == "POST": + username = request.form.get("username", "") + password = request.form.get("password", "") + expected_user = os.getenv("REVIEW_APP_USERNAME", "admin") + expected_password = os.getenv("REVIEW_APP_PASSWORD") or os.getenv("HIS_DB_PASSWORD") + if expected_password and secrets.compare_digest(username, expected_user) and secrets.compare_digest(password, expected_password): + session["authenticated"] = True + session["username"] = username + session["permissions"] = dict(FULL_PERMISSIONS) + return redirect(url_for("index")) + for user in load_config().get("users", []): + if secrets.compare_digest(username, str(user.get("username", ""))) and password_matches(password, user): + session["authenticated"] = True + session["username"] = username + session["permissions"] = normalized_permissions(user.get("permissions")) + return redirect(url_for("index")) + return render_template("login.html", error="登录失败"), 401 + return render_template("login.html", error="") + + +@app.route("/logout", methods=["POST"]) +def logout() -> Response: + session.clear() + return redirect(url_for("login")) + + +@app.route("/api/session") +def api_session() -> Response: + require_login() + return json_response({ + "username": session.get("username", ""), + "permissions": current_permissions(), + "permission_labels": PERMISSION_LABELS, + "kimi_enabled": kimi_config()["enabled"], + }) + + +@app.route("/api/summary") +def api_summary() -> Response: + require_login() + corrections = correction_index() + archived = archived_correction_keys() + items = [ + public + for item in build_review_items() + if item.key not in archived + for public in [public_item(item, corrections.get(item.key))] + if has_valid_inpatient_no(public["patient"]) + ] + batches: dict[str, dict[str, Any]] = {} + state_counts = {"待处理": 0, STILL_CONFIRM_STATE: 0, MANUAL_PASSED_STATE: 0, AI_PENDING_STATE: 0} + for item in items: + state_counts[item["manual_state"]] = state_counts.get(item["manual_state"], 0) + 1 + batch = batches.setdefault( + item["batch_name"], + {"batch_name": item["batch_name"], "total": 0, "pending": 0, "ai_pending": 0, "still_issue": 0, "done": 0}, + ) + batch["total"] += 1 + if item["manual_state"] == MANUAL_PASSED_STATE: + batch["done"] += 1 + elif item["manual_state"] == AI_PENDING_STATE: + batch["ai_pending"] += 1 + batch["still_issue"] += 1 + elif item["manual_state"] == STILL_CONFIRM_STATE: + batch["still_issue"] += 1 + else: + batch["pending"] += 1 + state_counts["待确认"] = state_counts[AI_PENDING_STATE] + state_counts[STILL_CONFIRM_STATE] + return json_response( + { + "total": len(items), + "state_counts": state_counts, + "batches": sorted(batches.values(), key=lambda value: value["batch_name"]), + "corrections_path": str(CORRECTIONS_PATH), + "postgres": db_status(), + "audit_summary": audit_overview_summary(), + "permissions": current_permissions(), + } + ) + + +@app.route("/api/processing-items") +def api_processing_items() -> Response: + require_login() + return json_response(processing_items_overview()) + + +@app.route("/api/items") +def api_items() -> Response: + require_permission("review") + page = max(1, int(request.args.get("page", 1))) + page_size = min(100, max(10, int(request.args.get("page_size", 40)))) + items = filtered_items() + start = (page - 1) * page_size + return json_response({"items": items[start : start + page_size], "total": len(items), "page": page, "page_size": page_size}) + + +@app.route("/api/items/") +def api_item(key: str) -> Response: + require_permission("review") + if key in archived_correction_keys(): + abort(404) + corrections = correction_index() + for item in build_review_items(): + if item.key == key: + public = public_item(item, corrections.get(key)) + if not has_valid_inpatient_no(public["patient"]): + abort(404) + return json_response(public) + abort(404) + + +@app.route("/api/items//validate", methods=["POST"]) +def api_validate(key: str) -> Response: + require_permission("review") + payload = request.get_json(force=True) + patient = normalize_patient(payload.get("patient", {})) + options = payload.get("options", {}) + return json_response({"warnings": validate_patient(patient, options), "patient": patient}) + + +@app.route("/api/items//correction", methods=["POST", "DELETE"]) +def api_correction(key: str) -> Response: + require_permission("review") + corrections = correction_index() + target = None + for item in build_review_items(): + if item.key == key: + target = item + break + if target is None: + abort(404) + if request.method == "DELETE": + original_patient = normalize_patient(target.record.get("患者信息", {})) + original_warnings = validate_patient(original_patient, {}) + pg_sync = update_postgres_record(target.record, original_patient, {}, original_warnings, "", False) + delete_correction(key) + return json_response({"ok": True, "postgres": pg_sync}) + payload = request.get_json(force=True) + patient = normalize_patient(payload.get("patient", {})) + options = {} + warnings = validate_patient(patient, options) + if not has_valid_inpatient_no(patient): + return error_response("缺少住院号,不能保存到复核表格。", status=400) + previous_correction = corrections.get(key) + previous_patient = merged_patient(target.record, previous_correction) + change_log = fallback_change_log(target.record, previous_correction) + change_log.extend(change_log_entries(previous_patient, patient, "人工")) + image = target.record.get("图片信息", {}) + correction = { + "图片路径": image.get("图片路径", ""), + "图片内行号": int(image.get("图片内行号") or 0), + "患者信息": patient, + "复核选项": options, + "复核备注": normalize_text(payload.get("manual_note", "")), + "修改来源": "人工修改", + "修改记录": change_log, + "更新时间": dt.datetime.now().isoformat(timespec="seconds"), + } + pg_sync = update_postgres_record(target.record, patient, options, warnings, correction["复核备注"], True) + correction["PostgreSQL同步"] = sync_status_from_result(pg_sync) + save_correction(correction) + return json_response({"ok": True, "warnings": warnings, "item": public_item(target, correction), "postgres": pg_sync}) + + +@app.route("/api/items//kimi-correction", methods=["POST"]) +def api_item_kimi_correction(key: str) -> Response: + require_permission("review") + corrections = correction_index() + target = None + for item in build_review_items(): + if item.key == key: + target = item + break + if target is None: + abort(404) + public = public_item(target, corrections.get(key)) + try: + result = call_kimi_correction(public) + except RuntimeError as exc: + return error_response(str(exc), status=502) + except Exception as exc: + return error_response(f"AI修改失败:{exc}", status=500) + if not has_valid_inpatient_no(result["patient"]): + return error_response("AI结果缺少有效住院号,已拒绝写入复核表格。", status=422) + previous_correction = corrections.get(key) + previous_patient = public.get("patient", {}) + change_log = fallback_change_log(target.record, previous_correction) + change_log.extend(change_log_entries(previous_patient, result["patient"], "AI")) + image = target.record.get("图片信息", {}) + correction = { + "图片路径": image.get("图片路径", ""), + "图片内行号": int(image.get("图片内行号") or 0), + "患者信息": result["patient"], + "复核选项": {}, + "复核备注": result["note"] or "AI修改,待人工确认", + "修改来源": "AI修改", + "修改记录": change_log, + "更新时间": dt.datetime.now().isoformat(timespec="seconds"), + "AI修改": True, + "AI反馈": result["result"], + "PostgreSQL同步": { + "状态": "AI待确认", + "更新时间": dt.datetime.now().isoformat(timespec="seconds"), + "提示": "AI修改结果需人工保存确认后再同步数据库", + }, + } + save_correction(correction) + return json_response({"ok": True, "item": public_item(target, correction), "result": result}) + + +@app.route("/api/postgres/status") +def api_postgres_status() -> Response: + require_login() + return json_response(db_status()) + + +@app.route("/api/postgres/sync", methods=["POST"]) +def api_postgres_sync() -> Response: + require_permission("review") + corrections = load_json(CORRECTIONS_PATH, []) + review_items = {item.key: item for item in build_review_items()} + summary = {"total": 0, "success": 0, "failed": 0, "not_found": 0, "skipped": 0, "items": []} + changed = False + for correction in corrections: + if correction.get("AI修改") or correction.get("已提交人工通过"): + summary["skipped"] += 1 + continue + sync_status = correction.get("PostgreSQL同步", {}) + if sync_status.get("状态") == "成功": + summary["skipped"] += 1 + continue + key = item_key(normalize_text(correction.get("图片路径")), int(correction.get("图片内行号") or 0)) + target = review_items.get(key) + if target is None: + summary["failed"] += 1 + summary["items"].append({"key": key, "status": "failed", "message": "未找到本地复核条目"}) + continue + patient = normalize_patient(correction.get("患者信息", {})) + if not has_valid_inpatient_no(patient): + summary["skipped"] += 1 + continue + options = correction.get("复核选项", {}) + manual_note = normalize_text(correction.get("复核备注", "")) + warnings = validate_patient(patient, options) + result = update_postgres_record(target.record, patient, options, warnings, manual_note, True) + correction["PostgreSQL同步"] = sync_status_from_result(result) + changed = True + summary["total"] += 1 + if result.get("error"): + summary["failed"] += 1 + item_status = "failed" + elif result.get("updated", 0) > 0: + summary["success"] += 1 + item_status = "success" + else: + summary["not_found"] += 1 + item_status = "not_found" + summary["items"].append({"key": key, "status": item_status, "postgres": result}) + if changed: + atomic_write_json(CORRECTIONS_PATH, corrections) + summary["postgres"] = db_status() + return json_response(summary) + + +@app.route("/api/postgres/sync/drop_failed", methods=["POST"]) +def api_postgres_sync_drop_failed() -> Response: + require_permission("review") + payload = request.get_json(force=True) + keys = set(str(key) for key in payload.get("keys", []) if str(key)) + if not keys: + return json_response({"removed": 0}) + corrections = load_json(CORRECTIONS_PATH, []) + kept = [] + removed = 0 + for correction in corrections: + key = item_key(normalize_text(correction.get("图片路径")), int(correction.get("图片内行号") or 0)) + if key in keys: + removed += 1 + continue + kept.append(correction) + if removed: + atomic_write_json(CORRECTIONS_PATH, kept) + return json_response({"removed": removed, "postgres": db_status()}) + + +@app.route("/api/audit/sample", methods=["POST"]) +def api_audit_sample() -> Response: + require_permission("audit") + payload = request.get_json(force=True) + count = min(20, max(1, int(payload.get("count", 5)))) + source = normalize_text(payload.get("source") or "manual_review_passed") + if source == "manual_review_passed": + corrections = correction_index() + archived = archived_correction_keys() + candidates = [ + public + for item in build_review_items() + if item.key not in archived + for public in [public_item(item, corrections.get(item.key))] + if public["manual_state"] == MANUAL_PASSED_STATE and has_valid_inpatient_no(public["patient"]) + ] + sampled = random.sample(candidates, min(count, len(candidates))) if candidates else [] + else: + sampled = sample_db_records("db_auto_passed", count) + for item in sampled: + item["audit_source"] = source + return json_response({"items": sampled}) + + +@app.route("/api/audit/kimi", methods=["POST"]) +def api_audit_kimi() -> Response: + require_permission("audit") + payload = request.get_json(force=True) + key = normalize_text(payload.get("key")) + public = payload.get("item") if isinstance(payload.get("item"), dict) else None + if public is None: + public = audit_public_by_key(key) + if public is None: + abort(404) + try: + result = call_kimi_vision(public) + except RuntimeError as exc: + return error_response(str(exc), status=502) + except Exception as exc: + return error_response(f"AI抽查失败:{exc}", status=500) + return json_response(result) + + +@app.route("/api/audit/result", methods=["POST"]) +def api_audit_result() -> Response: + require_permission("audit") + payload = request.get_json(force=True) + key = normalize_text(payload.get("key")) + public = payload.get("item") if isinstance(payload.get("item"), dict) else None + if public is None: + public = audit_public_by_key(key) + if public is None: + abort(404) + result = update_audit_postgres(public, payload) + return json_response({"ok": result.get("updated", 0) > 0, "postgres": result}) + + +@app.route("/api/audit/history") +def api_audit_history() -> Response: + require_permission("audit_history") + page = max(1, int(request.args.get("page", 1))) + page_size = min(100, max(10, int(request.args.get("page_size", 40)))) + source = normalize_text(request.args.get("source")) + status = normalize_text(request.args.get("status")) + sort = normalize_text(request.args.get("sort") or "checked_desc") + query = normalize_text(request.args.get("q")) + return json_response(audit_history_rows(page, page_size, source, sort, query, status)) + + +@app.route("/api/settings") +def api_settings() -> Response: + require_permission("settings") + config = load_config() + kimi = kimi_config() + env_user = os.getenv("REVIEW_APP_USERNAME", "admin") + return json_response( + { + "permissions": current_permissions(), + "permission_labels": PERMISSION_LABELS, + "users": [ + {"username": env_user, "created_at": "环境变量用户", "permissions": dict(FULL_PERMISSIONS), "builtin": True}, + *[ + { + "username": user.get("username"), + "created_at": user.get("created_at", ""), + "permissions": normalized_permissions(user.get("permissions")), + "builtin": False, + } + for user in config.get("users", []) + ], + ], + "kimi_model": kimi["model"], + "kimi_api_key_set": bool(kimi["api_key"]), + "kimi_enabled": bool(kimi["enabled"]), + } + ) + + +@app.route("/api/settings/users", methods=["POST"]) +def api_settings_users() -> Response: + require_permission("settings") + payload = request.get_json(force=True) + username = normalize_text(payload.get("username")) + password = str(payload.get("password") or "") + permissions = normalized_permissions(payload.get("permissions")) + if not username or not password: + return json_response({"error": "用户名和密码不能为空"}, status=400) + config = load_config() + users = config.setdefault("users", []) + if any(user.get("username") == username for user in users): + return json_response({"error": "用户已存在"}, status=400) + hashed = password_hash(password) + users.append({ + "username": username, + "password_hash": hashed["hash"], + "salt": hashed["salt"], + "permissions": permissions, + "created_at": dt.datetime.now().isoformat(timespec="seconds"), + }) + save_config(config) + return json_response({"ok": True}) + + +@app.route("/api/settings/users//permissions", methods=["POST"]) +def api_settings_user_permissions(username: str) -> Response: + require_permission("settings") + payload = request.get_json(force=True) + config = load_config() + for user in config.get("users", []): + if user.get("username") == username: + user["permissions"] = normalized_permissions(payload.get("permissions")) + save_config(config) + return json_response({"ok": True}) + abort(404) + + +@app.route("/api/settings/kimi", methods=["POST"]) +def api_settings_kimi() -> Response: + require_permission("settings") + payload = request.get_json(force=True) + config = load_config() + kimi = config.setdefault("kimi", {}) + model = normalize_text(payload.get("model")) or "kimi-k2.6" + api_key = normalize_text(payload.get("api_key")) + kimi["model"] = model + if api_key: + kimi["api_key"] = api_key + save_config(config) + return json_response({"ok": True}) + + +@app.route("/api/settings/kimi/enabled", methods=["POST"]) +def api_settings_kimi_enabled() -> Response: + require_permission("settings") + payload = request.get_json(force=True) + config = load_config() + kimi = config.setdefault("kimi", {}) + kimi["enabled"] = bool(payload.get("enabled")) + save_config(config) + return json_response({"ok": True, "enabled": kimi["enabled"]}) + + +@app.route("/api/settings/commit-manual-passed", methods=["POST"]) +def api_settings_commit_manual_passed() -> Response: + require_permission("settings") + return json_response(commit_manual_passed_records()) + + +@app.route("/api/settings/reset-audit-history", methods=["POST"]) +def api_settings_reset_audit_history() -> Response: + require_permission("settings") + result = reset_audit_history_records() + if result.get("error"): + return error_response(result["error"], status=500) + return json_response(result) + + +@app.route("/api/crop") +def api_crop() -> Response: + require_login() + path = safe_workspace_path(request.args.get("path", "")) + row_no = max(1, int(request.args.get("row", 1))) + context_rows = min(5, max(0, int(request.args.get("context", 2)))) + return image_crop_response(path, row_no, context_rows) + + +@app.route("/api/image") +def api_image() -> Response: + require_login() + path = safe_workspace_path(request.args.get("path", "")) + if path.suffix.lower() not in IMAGE_EXTENSIONS: + abort(404) + return send_file(path, max_age=0) + + +if __name__ == "__main__": + host = os.getenv("REVIEW_APP_HOST", "127.0.0.1") + port = int(os.getenv("REVIEW_APP_PORT", "8090")) + app.run(host=host, port=port) diff --git a/患者列表处理/人工复核网页端/docker-compose.yml b/患者列表处理/人工复核网页端/docker-compose.yml new file mode 100644 index 0000000..472f8d1 --- /dev/null +++ b/患者列表处理/人工复核网页端/docker-compose.yml @@ -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 diff --git a/患者列表处理/人工复核网页端/requirements.txt b/患者列表处理/人工复核网页端/requirements.txt new file mode 100644 index 0000000..63e644e --- /dev/null +++ b/患者列表处理/人工复核网页端/requirements.txt @@ -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 diff --git a/患者列表处理/人工复核网页端/static/app.css b/患者列表处理/人工复核网页端/static/app.css new file mode 100644 index 0000000..2fea719 --- /dev/null +++ b/患者列表处理/人工复核网页端/static/app.css @@ -0,0 +1,1339 @@ +:root { + --ink: #1f2933; + --muted: #68717d; + --line: #d8dee4; + --paper: #f4f6f8; + --panel: #ffffff; + --field: #f9fafb; + --teal: #147c72; + --teal-dark: #0d5d55; + --cinnabar: #c94a36; + --amber: #b7822f; + --ok: #497c43; + --shadow: 0 18px 50px rgba(31, 41, 51, 0.12); + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", sans-serif; + letter-spacing: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.topbar { + min-height: 76px; + display: grid; + grid-template-columns: minmax(280px, max-content) minmax(260px, 1fr) max-content; + align-items: center; + gap: 14px; + padding: 14px 22px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.94); + position: sticky; + top: 0; + z-index: 10; +} + +#saveState { + max-width: min(760px, 100%); + justify-self: center; + color: var(--ink); + font-size: 16px; + font-weight: 800; + line-height: 1.35; + text-align: center; + text-decoration: underline; + text-underline-offset: 4px; + white-space: normal; + overflow-wrap: anywhere; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + pointer-events: none; +} + +.eyebrow { + color: var(--teal); + font-size: 12px; + font-weight: 800; + letter-spacing: 0; +} + +h1 { + margin: 2px 0 0; + font-size: 22px; + font-weight: 800; +} + +.top-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; +} + +.top-nav { + display: flex; + gap: 6px; + padding: 4px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--field); +} + +.nav-button { + min-height: 32px; + border: 0; + padding: 6px 10px; + background: transparent; + color: var(--muted); +} + +.nav-button.active { + background: var(--teal); + color: #fff; +} + +.save-state { + color: var(--muted); + font-size: 13px; +} + +.pg-state { + color: var(--ok); + font-size: 13px; + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pg-state.warn { + color: var(--cinnabar); +} + +.workspace { + height: calc(100vh - 76px); + display: grid; + grid-template-columns: minmax(320px, 26vw) minmax(420px, 1fr) minmax(390px, 28vw); + min-height: 640px; +} + +.page-view.hidden { + display: none; +} + +.hidden { + display: none !important; +} + +.overview-view, +.audit-view, +.settings-view { + min-height: calc(100vh - 76px); + padding: 18px 22px; +} + +.audit-view { + height: calc(100vh - 76px); + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 14px; + overflow: hidden; +} + +.audit-history-view { + height: calc(100vh - 76px); + min-height: 0; +} + +.overview-grid, +.batch-grid, +.settings-layout { + display: grid; + gap: 12px; +} + +.overview-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + margin-bottom: 16px; +} + +.overview-panel, +.settings-panel, +.audit-toolbar, +.audit-detail { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 16px; +} + +.overview-panel h2 { + margin: 0 0 12px; + font-size: 18px; +} + +.batch-grid { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); +} + +.settings-layout { + grid-template-columns: minmax(560px, 1fr) minmax(340px, 420px); + align-items: start; +} + +.settings-panel { + display: grid; + gap: 12px; +} + +.settings-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.settings-heading h2 { + margin: 0; + font-size: 18px; +} + +.settings-side, +.new-user-form { + display: grid; + gap: 12px; +} + +.settings-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.settings-label { + color: var(--muted); + font-size: 12px; + font-weight: 700; + margin-bottom: 7px; +} + +.settings-note { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} + +.settings-panel label, +.audit-toolbar label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.settings-panel input, +.audit-toolbar input, +.audit-toolbar select, +.audit-history-filters input, +.audit-history-filters select { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--field); + padding: 10px 11px; + color: var(--ink); + outline: none; +} + +.audit-toolbar { + display: grid; + grid-template-columns: minmax(180px, 220px) 150px auto minmax(420px, auto) minmax(150px, 1fr); + gap: 10px; + align-items: end; +} + +.ai-disabled .audit-toolbar { + grid-template-columns: minmax(180px, 220px) 150px auto minmax(150px, 1fr); +} + +.inline-field { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.audit-toolbar label.inline-field, +.audit-history-filters .inline-field { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + align-items: center; +} + +.field-prefix { + color: var(--muted); + font-size: 12px; + font-weight: 900; + white-space: nowrap; +} + +.settings-panel input:focus, +.audit-toolbar input:focus, +.audit-toolbar select:focus, +.audit-history-filters input:focus, +.audit-history-filters select:focus { + border-color: var(--teal); + box-shadow: 0 0 0 3px rgba(20, 124, 114, 0.12); +} + +.audit-layout { + display: grid; + grid-template-columns: minmax(280px, 24vw) minmax(420px, 1fr) minmax(320px, 24vw); + gap: 14px; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.audit-history-view .audit-toolbar { + grid-template-columns: auto minmax(140px, 1fr); + margin-bottom: 0; +} + +.audit-history-layout { + height: 100%; + min-height: 0; + overflow: hidden; + grid-template-columns: minmax(300px, 25vw) minmax(440px, 1fr) minmax(340px, 26vw); + gap: 16px; +} + +.audit-history-layout > .audit-history-left, +.audit-history-layout > .audit-detail, +.audit-history-layout > .audit-side, +.audit-layout > .queue-list, +.audit-layout > .audit-detail, +.audit-layout > .audit-side { + min-height: 0; + max-height: 100%; + overflow: auto; + overscroll-behavior: contain; +} + +.audit-layout > .audit-detail { + border: 0; + background: transparent; + padding: 2px 4px 14px; +} + +.audit-history-left { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; + overflow: hidden; +} + +.audit-history-layout > .audit-history-left { + overflow: hidden; +} + +.audit-history-filters { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.audit-history-left > .queue-list { + overflow: auto; + align-content: start; + padding-right: 4px; + gap: 10px; +} + +.audit-history-layout .queue-item { + padding: 11px 12px; + min-height: 94px; +} + +.audit-history-layout .queue-title { + line-height: 1.25; +} + +.audit-history-layout .queue-meta { + display: block; + margin-top: 3px; + line-height: 1.45; + white-space: normal; + overflow-wrap: anywhere; +} + +.finder-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.finder-card { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--field); + padding: 10px; +} + +.finder-card strong { + display: block; + color: var(--ink); + font-size: 22px; + line-height: 1.1; +} + +.finder-card span { + color: var(--muted); + font-size: 12px; +} + +.queue-panel, +.image-panel, +.edit-panel { + min-width: 0; + min-height: 0; + overflow: auto; +} + +.queue-panel { + border-right: 1px solid var(--line); + padding: 16px; +} + +.queue-panel:focus { + outline: none; +} + +.image-panel { + padding: 16px 18px; +} + +.edit-panel { + border-left: 1px solid var(--line); + padding: 16px; + background: var(--panel); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(86px, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.stat { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px; +} + +.stat strong { + display: block; + font-size: 24px; + line-height: 1; +} + +.stat span { + color: var(--muted); + font-size: 12px; +} + +.batch-card { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 12px; +} + +.batch-card h3 { + margin: 0 0 10px; + font-size: 14px; +} + +.batch-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.batch-metrics span { + min-width: 0; + color: var(--muted); + font-size: 12px; +} + +.batch-metrics strong { + display: block; + color: var(--ink); + font-size: 18px; + line-height: 1.1; +} + +.filters { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.filter-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} + +.filter-row #sortFilter { + grid-column: 1 / -1; +} + +.filters .review-filter-field { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.review-status-row { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(130px, 0.65fr); + gap: 8px; + align-items: stretch; +} + +.status-segment { + display: grid; + grid-template-columns: 1fr; + padding: 3px; + border: 1px solid var(--line); + border-radius: 8px; + background: #eef3f4; +} + +.status-pill { + min-height: 36px; + border: 0; + border-radius: 6px; + padding: 7px 9px; + background: transparent; + color: var(--muted); + font-weight: 800; +} + +.status-pill.active { + background: var(--teal); + color: #fff; + box-shadow: 0 5px 14px rgba(20, 124, 114, 0.18); +} + +.ai-action-panel { + border: 1px solid rgba(20, 124, 114, 0.28); + border-radius: 8px; + background: linear-gradient(180deg, #f5fbf8, #eef6f4); + padding: 9px; + display: grid; + gap: 8px; +} + +.review-ai-panel { + margin-top: 2px; +} + +.audit-ai-panel { + min-width: 420px; + grid-template-columns: auto 1fr; + align-items: center; + gap: 10px; +} + +.ai-action-title { + display: flex; + align-items: center; + gap: 7px; + color: var(--teal-dark); + font-size: 12px; + font-weight: 900; +} + +.ai-badge { + width: 28px; + height: 22px; + display: inline-grid; + place-items: center; + border-radius: 7px; + background: var(--teal); + color: #fff; + font-size: 12px; + font-weight: 900; +} + +.ai-action-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.review-ai-panel .ai-action-buttons { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.audit-ai-panel .ai-action-buttons { + min-width: 0; +} + +.ai-button { + background: #fff; + border-color: rgba(20, 124, 114, 0.34); + font-weight: 800; +} + +.filters select, +.filters input, +.edit-form input, +.edit-form textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--field); + padding: 10px 11px; + color: var(--ink); + outline: none; +} + +.filters select:focus, +.filters input:focus, +.edit-form input:focus, +.edit-form textarea:focus { + border-color: var(--teal); + box-shadow: 0 0 0 3px rgba(20, 124, 114, 0.12); +} + +.queue-list { + display: grid; + gap: 8px; +} + +.queue-item { + width: 100%; + text-align: left; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px 11px; + cursor: pointer; + transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; +} + +.queue-item:hover, +.queue-item.active { + border-color: var(--teal); + box-shadow: var(--shadow); +} + +.queue-item:hover { + transform: translateY(-1px); +} + +.queue-title { + font-weight: 800; + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + min-width: 0; +} + +.queue-title > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queue-badges { + display: flex; + justify-content: flex-end; + gap: 6px; + flex: 0 1 auto; + flex-wrap: wrap; + min-width: 0; +} + +.queue-badges .badge { + white-space: nowrap; +} + +.queue-badges .validation-badge { + max-width: 100%; +} + +.queue-meta, +.record-meta { + color: var(--muted); + font-size: 12px; + line-height: 1.55; +} + +.queue-load-state { + color: var(--muted); + font-size: 12px; + line-height: 1.55; + text-align: center; + padding: 8px 0 2px; +} + +.badge { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + white-space: nowrap; + background: #eef1f4; + color: var(--muted); +} + +.badge.pending { + background: #f3e1dc; + color: var(--cinnabar); +} + +.badge.done { + background: #dde9dc; + color: var(--ok); +} + +.badge.warn { + background: #f0e4cb; + color: var(--amber); +} + +.badge.ai { + background: #dbe7f5; + color: #2f5f91; +} + +.record-strip { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 12px; +} + +.index-note { + margin-bottom: 10px; + border: 1px solid rgba(20, 124, 114, 0.28); + border-radius: 8px; + background: #f3fbf8; + color: var(--teal-dark); + padding: 10px 12px; + font-size: 15px; + font-weight: 800; + text-align: center; + text-decoration: underline; + text-underline-offset: 4px; +} + +.record-title { + font-size: 18px; + font-weight: 800; +} + +.record-title .title-badges { + display: inline-flex; + vertical-align: middle; + margin-left: 8px; +} + +.crop-frame { + background: #e5e9ee; + border: 1px solid var(--line); + border-radius: 8px; + min-height: 280px; + display: grid; + place-items: center; + overflow: auto; + cursor: grab; + user-select: none; + overscroll-behavior: contain; + touch-action: none; +} + +.crop-frame.is-dragging { + cursor: grabbing; +} + +.crop-frame img { + max-width: none; + display: block; + height: auto; + transition: width 0.12s ease; + pointer-events: none; +} + +.zoom-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.tips-box, +.validation-box { + margin-top: 12px; + display: grid; + gap: 8px; +} + +.tips-box .validation { + font-size: 15px; + font-weight: 800; + text-decoration: underline; + text-underline-offset: 4px; +} + +.tip, +.validation { + border-radius: 8px; + border: 1px solid var(--line); + background: var(--panel); + padding: 9px 10px; + font-size: 13px; +} + +.validation.error { + border-color: rgba(201, 74, 54, 0.42); + color: var(--cinnabar); + background: #fff7f4; +} + +.validation.ok { + border-color: rgba(73, 124, 67, 0.38); + color: var(--ok); + background: #f5fbf2; +} + +.validation.info { + border-color: rgba(104, 113, 125, 0.28); + color: var(--muted); + background: #f9fafb; +} + +.model-result { + min-height: 150px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + background: var(--field); + color: var(--ink); + overflow: auto; +} + +.model-result table { + width: 100%; + border-collapse: collapse; + background: var(--panel); + border-radius: 8px; + overflow: hidden; +} + +.model-result th, +.model-result td { + border: 1px solid var(--line); + padding: 9px 10px; + vertical-align: top; + text-align: left; + font-size: 13px; +} + +.model-result th { + width: 120px; + color: var(--muted); + background: #eef3f4; + font-weight: 900; +} + +.model-result pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: inherit; +} + +.ai-full-output { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--field); + padding: 10px 12px; +} + +.ai-full-output summary { + cursor: pointer; + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +.ai-full-output pre { + margin: 10px 0 0; + max-height: 220px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--ink); + font: inherit; + font-size: 13px; +} + +.change-log { + display: grid; + gap: 8px; +} + +.change-log:empty { + display: none; +} + +.change-log details { + border: 1px solid rgba(20, 124, 114, 0.25); + border-radius: 8px; + background: #f5fbf8; + padding: 10px; +} + +.change-log summary { + cursor: pointer; + color: var(--teal-dark); + font-size: 12px; + font-weight: 900; +} + +.change-log-source { + margin: 8px 0 0; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.change-log table { + width: 100%; + border-collapse: collapse; + margin-top: 8px; + background: var(--panel); +} + +.change-log th, +.change-log td { + border: 1px solid var(--line); + padding: 7px 8px; + text-align: left; + vertical-align: top; + font-size: 12px; +} + +.audit-frame { + height: 360px; + min-height: 360px; + margin: 12px 0; + display: block; + padding: 0; +} + +.audit-image-stage { + width: max-content; + height: max-content; + min-width: 100%; + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.audit-image-stage img { + flex: 0 0 auto; + margin: 0; +} + +.ai-result-section, +.ai-prompt-output, +.ai-full-output { + width: 100%; +} + +.audit-side, +.history-card { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 14px; +} + +.audit-side { + display: grid; + gap: 12px; + align-content: start; +} + +.audit-side h2 { + margin: 0; + font-size: 17px; +} + +.audit-side label, +.model-label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.audit-side select, +.audit-side input, +.audit-side textarea, +.model-label textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--field); + padding: 10px 11px; + color: var(--ink); +} + +.patient-kv, +.audit-verdict-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 10px; + font-size: 13px; +} + +.audit-edit-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.audit-edit-grid label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.audit-edit-grid label.wide { + grid-column: 1 / -1; +} + +.audit-edit-grid textarea { + min-height: 72px; + resize: vertical; +} + +.patient-kv span:nth-child(odd), +.audit-verdict-grid span { + color: var(--muted); +} + +.audit-verdict-grid { + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--field); +} + +.audit-verdict-grid strong { + min-width: 0; + overflow-wrap: anywhere; +} + +.history-list { + display: grid; + gap: 10px; +} + +.history-card { + display: grid; + gap: 8px; +} + +.edit-form { + display: grid; + gap: 12px; +} + +.edit-form label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.edit-form input, +.edit-form textarea { + color: var(--ink); + font-size: 14px; + font-weight: 500; +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.check-row { + grid-template-columns: auto 1fr !important; + align-items: center; +} + +.check-row input { + width: 18px; + height: 18px; +} + +.button-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} + +.review-actions { + grid-template-columns: repeat(3, 1fr); +} + +.permission-grid, +.settings-list { + display: grid; + gap: 8px; +} + +.permission-grid { + grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); +} + +.permission-grid label, +.user-permissions label { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 38px; + padding: 7px 9px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--field); + color: var(--ink); + font-size: 13px; + font-weight: 500; +} + +.permission-grid input, +.user-permissions input { + width: auto; +} + +.settings-user { + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + display: grid; + gap: 10px; +} + +.user-permissions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); + gap: 8px; +} + +button, +.ghost-button, +.primary-button, +.secondary-button, +.danger-button { + border: 1px solid var(--line); + border-radius: 8px; + min-height: 40px; + padding: 9px 12px; + cursor: pointer; + text-decoration: none; + display: inline-grid; + place-items: center; + background: var(--panel); + color: var(--ink); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.compact-button { + min-height: 32px; + padding: 6px 10px; + font-size: 13px; +} + +.primary-button { + background: var(--teal); + border-color: var(--teal); + color: #fff; + font-weight: 800; +} + +.primary-button:hover { + background: var(--teal-dark); +} + +.secondary-button:hover, +.ghost-button:hover { + border-color: var(--teal); +} + +.danger-button { + color: var(--cinnabar); +} + +.danger-button:hover { + border-color: var(--cinnabar); +} + +.login-shell { + min-height: 100vh; + display: grid; + place-items: center; + background: var(--paper); +} + +.login-panel { + width: min(420px, calc(100vw - 32px)); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 28px; + box-shadow: var(--shadow); +} + +.login-mark { + width: 54px; + height: 54px; + display: grid; + place-items: center; + border-radius: 8px; + background: var(--teal); + color: #fff; + font-weight: 900; + margin-bottom: 16px; +} + +.login-form { + display: grid; + gap: 14px; +} + +.login-form label { + display: grid; + gap: 7px; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.login-form input { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + padding: 11px 12px; + background: var(--field); +} + +.login-form button { + background: var(--teal); + color: white; + font-weight: 800; + border-color: var(--teal); +} + +.form-error { + color: var(--cinnabar); + font-size: 13px; +} + +@media (max-width: 1120px) { + .workspace { + grid-template-columns: 330px minmax(420px, 1fr); + height: auto; + } + + .edit-panel { + grid-column: 1 / -1; + border-left: 0; + border-top: 1px solid var(--line); + } +} + +@media (max-width: 760px) { + .topbar { + height: auto; + grid-template-columns: 1fr; + align-items: flex-start; + } + + #saveState { + justify-self: stretch; + text-align: left; + } + + .workspace { + display: block; + } + + .queue-panel, + .image-panel, + .edit-panel { + border: 0; + border-bottom: 1px solid var(--line); + } + + .form-grid, + .button-row, + .stats-grid, + .overview-grid, + .settings-layout, + .settings-form-grid, + .audit-toolbar, + .audit-layout { + grid-template-columns: 1fr; + } +} diff --git a/患者列表处理/人工复核网页端/static/app.js b/患者列表处理/人工复核网页端/static/app.js new file mode 100644 index 0000000..413e677 --- /dev/null +++ b/患者列表处理/人工复核网页端/static/app.js @@ -0,0 +1,2042 @@ +const columns = [ + "姓名", + "性别", + "年龄", + "住院号", + "诊断", + "入院时间", + "最后书写时间", + "住院天数", + "出院时间", + "手术后天数", +]; + +const state = { + items: [], + current: null, + auditItems: [], + auditCurrent: null, + auditHistory: [], + auditHistoryCurrent: null, + processingOverview: null, + permissions: {}, + permissionLabels: {}, + kimiEnabled: true, + activeView: "overview", + aiBusy: false, + aiTaskLabel: "", + imageZoom: 1, + imagePan: null, + auditImageZoom: 1, + auditImagePan: null, + auditHistoryImageZoom: 1, + auditHistoryImagePan: null, + page: 1, + pageSize: 60, + totalItems: 0, + loadingItems: false, + pgTimer: null, + debounce: null, +}; + +const datetimeColumns = new Set(["入院时间", "出院时间", "最后书写时间"]); + +const $ = (selector) => document.querySelector(selector); + +function permissionKeyForView(view) { + return view === "auditHistory" ? "audit_history" : view; +} + +function permissionEntries() { + const labels = state.permissionLabels || {}; + const fallback = { + overview: "概览", + review: "复核", + audit: "抽查", + audit_history: "抽查一览", + settings: "设置", + }; + return Object.entries({ ...fallback, ...labels }); +} + +function badgeClass(item) { + if (item.manual_state === "AI修改-待确认") return "ai"; + if (item.manual_state === "人工复核通过") return "done"; + if (item.manual_state === "修订后仍需确认") return "warn"; + return "pending"; +} + +function auditBadgeClass(item) { + if (item.audit_result === "人工核验异常") return "pending"; + if (item.audit_result === "人工核验通过") return "done"; + if (item.audit_result === "暂不确定") return "warn"; + if (item.audit_machine_verdict === "异常") return "pending"; + if (item.audit_machine_verdict) return "ai"; + return "warn"; +} + +function auditBadgeText(item) { + if (item.audit_result) return item.audit_result; + if (item.audit_machine_verdict === "通过") return "AI核验通过"; + if (item.audit_machine_verdict === "异常") return "AI核验异常"; + if (item.audit_machine_verdict === "不确定") return "AI无法确定"; + return "未核验"; +} + +function auditHistoryBadgeText(item) { + if (item.audit_result) return item.audit_result; + return item.audit_ai_feedback || item.audit_ai_raw_output || item.audit_machine_verdict ? "未人工核验" : "未填写人工结论"; +} + +function auditSourceLabel(value) { + const labels = { + manual_review_passed: "已处理-人工复核通过项", + db_auto_passed: "数据库-自动复核通过", + }; + return labels[value] || value || ""; +} + +function validationBadgeHtml(item) { + const validationWarnings = validatePatientLocal(item.patient || {}); + return validationWarnings.length + ? `当前字段未通过校验` + : ""; +} + +function auditTitleHtml(item, badgeText = auditBadgeText(item)) { + return ` + ${escapeHtml(item.patient["姓名"] || "未识别姓名")} · ${escapeHtml(item.patient["住院号"] || "无住院号")} + + ${escapeHtml(badgeText)} + ${validationBadgeHtml(item)} + + `; +} + +function auditQueueItemHtml(item, activeKey, extraRows = [], badgeText = auditBadgeText(item)) { + const metaRows = [ + item.patient["住院号"] || "无住院号", + ...extraRows, + ].filter(Boolean); + return ` + + `; +} + +function auditProgressText() { + const total = state.auditItems.length; + if (!total) return ""; + const aiDone = state.auditItems.filter((item) => item.audit_machine_verdict || item.audit_ai_feedback).length; + const manualDone = state.auditItems.filter((item) => item.audit_result).length; + return `抽查进度:AI ${aiDone}/${total},人工 ${manualDone}/${total}`; +} + +function setSaveState(text) { + $("#saveState").textContent = text || ""; +} + +function setStatusFilter(value, reload = true) { + if (value === "ai_pending" || value === "still_issue") value = "confirming"; + $("#statusFilter").value = value; + document.querySelectorAll(".status-pill").forEach((button) => { + button.classList.toggle("active", button.dataset.status === value); + }); + const more = $("#moreStatusFilter"); + more.value = value === "pending" ? "" : value; + if (value === "done") { + $("#sortFilter").value = "updated_desc"; + } + if (reload) loadItems(true); +} + +function reviewConfirmCount(summary) { + return summary.state_counts?.["待确认"] ?? ( + (summary.state_counts?.["AI修改-待确认"] || 0) + (summary.state_counts?.["修订后仍需确认"] || 0) + ); +} + +function setAiBusy(label, busy) { + state.aiBusy = busy; + state.aiTaskLabel = busy ? label : ""; + document.body.classList.toggle("ai-busy", busy); + document.querySelectorAll("#kimiCurrentButton, #kimiFiveButton, #kimiRemainingButton, #auditKimiButton, #auditKimiAllButton").forEach((button) => { + button.disabled = busy; + }); +} + +function updateAiVisibility() { + document.body.classList.toggle("ai-disabled", !state.kimiEnabled); + document.querySelectorAll(".review-ai-panel, .audit-ai-panel").forEach((panel) => { + panel.classList.toggle("hidden", !state.kimiEnabled); + }); +} + +function ensureAiReady(label) { + if (state.aiBusy) { + setSaveState(`${state.aiTaskLabel || "AI处理"}中,请等待完成后再操作`); + window.alert(`${state.aiTaskLabel || "AI处理"}中,请等待完成后再切换页面或发起新的AI任务。`); + return false; + } + if (!state.kimiEnabled) { + setSaveState("目前无AI功能"); + return false; + } + setAiBusy(label, true); + return true; +} + +function finishAiTask() { + setAiBusy("", false); +} + +function extractJsonData(text) { + const raw = String(text ?? "").trim(); + if (!raw) return null; + const candidates = []; + const fencePattern = /```(?:json)?\s*([\s\S]*?)```/gi; + let fenced; + while ((fenced = fencePattern.exec(raw))) { + candidates.push(fenced[1].trim()); + } + candidates.push(raw); + for (let index = 0; index < raw.length; index += 1) { + if (raw[index] === "{") candidates.push(raw.slice(index)); + } + for (const candidate of candidates) { + try { + const data = JSON.parse(candidate); + if (data && typeof data === "object" && !Array.isArray(data)) return data; + } catch (error) { + const parsed = parseJsonPrefix(candidate); + if (parsed) return parsed; + } + } + return null; +} + +function parseJsonPrefix(text) { + let depth = 0; + let inString = false; + let escaped = false; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + } else if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + try { + const data = JSON.parse(text.slice(0, index + 1)); + return data && typeof data === "object" && !Array.isArray(data) ? data : null; + } catch (error) { + return null; + } + } + } + } + return null; +} + +function equivalentFieldValue(column, left, right) { + const leftText = normalizeFieldValue(column, left); + const rightText = normalizeFieldValue(column, right); + return leftText === rightText; +} + +function cleanAuditJsonData(data, patient = null) { + if (!data) return null; + const cleaned = { ...data }; + const patterns = [ + "最后书写时间与出院时间顺序异常", + "最后书写时间晚于出院时间", + "出院后仍有书写记录", + "已出院后仍有书写记录", + "实际值一致", + "时间值一致", + "格式差异", + "补零差异", + "小时格式", + "日期格式补零", + "仅格式", + "无需修正", + ]; + if (cleaned.建议修正 && typeof cleaned.建议修正 === "object" && !Array.isArray(cleaned.建议修正) && patient) { + const keptSuggestions = {}; + Object.entries(cleaned.建议修正).forEach(([column, value]) => { + if (columns.includes(column) && equivalentFieldValue(column, value, patient[column] || "")) return; + keptSuggestions[column] = value; + }); + cleaned.建议修正 = keptSuggestions; + } + if (Array.isArray(cleaned.问题)) { + const kept = cleaned.问题.filter((issue) => !patterns.some((pattern) => String(issue).includes(pattern))); + cleaned.问题 = kept; + } + if ((!cleaned.问题 || !cleaned.问题.length) && (!cleaned.建议修正 || !Object.keys(cleaned.建议修正).length)) { + cleaned.结论 = "通过"; + } + return cleaned; +} + +function normalizeModelResultText(text, patient = null) { + const data = cleanAuditJsonData(extractJsonData(text), patient); + if (data) return JSON.stringify(data, null, 2); + return ""; +} + +function renderModelResult(element, jsonText, rawText = jsonText, patient = null) { + const normalized = normalizeModelResultText(jsonText || rawText, patient); + element.dataset.json = normalized; + element.dataset.raw = String(rawText ?? ""); + const data = extractJsonData(normalized); + if (!String(jsonText || rawText || "").trim()) { + element.innerHTML = `
暂无AI输出
`; + return; + } + if (!data) { + element.innerHTML = `
无法解析
`; + return; + } + const rows = Object.entries(data).map(([key, value]) => { + const display = typeof value === "string" ? value : JSON.stringify(value, null, 2); + return `${escapeHtml(key)}
${escapeHtml(display)}
`; + }).join(""); + element.innerHTML = `${rows}
`; +} + +function setAuditModelResult(jsonText, rawText = jsonText, patient = state.auditCurrent?.patient || null) { + renderModelResult($("#auditModelResult"), jsonText, rawText, patient); + $("#auditRawOutput").textContent = String(rawText || jsonText || ""); + const hasStructuredResult = Boolean($("#auditModelResult").dataset.json || String(jsonText || rawText || "").trim()); + $("#auditPromptDetails").open = !hasStructuredResult; + $("#auditRawOutputDetails").open = !hasStructuredResult; +} + +function setAuditPrompt(text) { + const value = String(text || ""); + $("#auditPrompt").value = value; + $("#auditPromptText").textContent = value || "暂无询问提示词"; +} + +function getAuditModelResult() { + return $("#auditModelResult").dataset.json || ""; +} + +function getAuditRawOutput() { + return $("#auditModelResult").dataset.raw || ""; +} + +function setAuditHistoryModelResult(jsonText, rawText = jsonText, patient = state.auditHistoryCurrent?.patient || null) { + renderModelResult($("#auditHistoryAiResult"), jsonText, rawText, patient); + $("#auditHistoryRawOutput").textContent = String(rawText || jsonText || ""); +} + +function modelVerdict(rawText, patient = null) { + const data = cleanAuditJsonData(extractJsonData(rawText), patient); + const verdict = String(data?.结论 || data?.verdict || data?.result || "").trim(); + return ["通过", "异常", "不确定"].includes(verdict) ? verdict : verdict; +} + +function formatTime(value) { + if (!value) return ""; + return String(value).replace("T", " "); +} + +async function api(path, options = {}) { + let response; + try { + response = await fetch(path, { + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + } catch (error) { + throw new Error("网络连接中断或网页端服务暂不可用,请确认服务仍在运行后重试"); + } + if (response.status === 401) { + window.location.href = "/login"; + return null; + } + if (!response.ok) { + const text = await response.text(); + try { + const data = JSON.parse(text); + throw new Error(data.error || text || response.statusText); + } catch (error) { + if (error instanceof SyntaxError) throw new Error(text || response.statusText); + throw error; + } + } + return response.json(); +} + +async function loadSummary() { + const summary = await api("/api/summary"); + if (!summary) return; + renderOverview(summary); + $("#statsGrid").innerHTML = ` +
${summary.total}复核条目
+
${summary.state_counts["待处理"]}待处理
+
${reviewConfirmCount(summary)}待确认
+
${summary.state_counts["人工复核通过"]}人工通过条目
+ `; + if (summary.postgres) { + renderPostgresState(summary.postgres); + } + const current = $("#batchFilter").value; + $("#batchFilter").innerHTML = `` + summary.batches + .map((batch) => ``) + .join(""); + $("#batchFilter").value = current; +} + +function renderOverview(summary) { + $("#overviewGrid").innerHTML = ` +
${summary.total}复核条目
+
${summary.state_counts["待处理"]}待处理
+
${reviewConfirmCount(summary)}待确认
+
${summary.state_counts["人工复核通过"]}人工通过
+
${summary.postgres?.pending_sync_count ?? 0}PG待同步
+ `; + $("#overviewBatches").innerHTML = summary.batches.map((batch) => ` +
+

${escapeHtml(batch.batch_name)}

+
+ ${escapeHtml(batch.pending)}待处理 + ${escapeHtml(batch.still_issue)}待确认 + ${escapeHtml(batch.done)}人工通过 + ${escapeHtml(batch.total)}总条目 +
+
+ `).join(""); + renderAuditOverview(summary.audit_summary || {}); +} + +function renderAuditOverview(auditSummary) { + const cards = [ + ["抽查记录", auditSummary.total || 0], + ["未人工核验", auditSummary.unreviewed || 0], + ["人工通过", auditSummary.passed || 0], + ["人工异常", auditSummary.failed || 0], + ["暂不确定", auditSummary.unsure || 0], + ]; + $("#overviewAudit").innerHTML = cards.map(([label, value]) => ` +
+

${escapeHtml(label)}

+
+ ${escapeHtml(value)}${escapeHtml(label)} +
+
+ `).join(""); +} + +function renderProcessingOverview(data) { + state.processingOverview = data; + const grid = $("#processingFinderGrid"); + if (!grid) return; + const cards = [ + ["复核待处理", data.pending_review || 0], + ["待确认", data.still_issue || 0], + ["抽查未人工核验", data.audit_unreviewed || 0], + ["抽查异常", data.audit_failed || 0], + ["待同步", data.postgres_pending_sync || 0], + ]; + grid.innerHTML = cards.map(([label, value]) => ` +
+ ${escapeHtml(value)} + ${escapeHtml(label)} +
+ `).join(""); +} + +async function findProcessingItems(quiet = false) { + const stateBox = $("#processingFinderState"); + if (!quiet && stateBox) stateBox.textContent = "寻找中"; + try { + const data = await api("/api/processing-items"); + if (!data) return; + renderProcessingOverview(data); + if (stateBox) { + stateBox.textContent = `复核待处理${data.pending_review || 0},抽查未人工核验${data.audit_unreviewed || 0},待同步${data.postgres_pending_sync || 0}`; + } + } catch (error) { + if (stateBox) stateBox.textContent = `寻找失败:${error.message}`; + } +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function itemSubtitle(item) { + const rowText = displayRowText(item); + return [ + item.batch_name, + item.source_folder, + item.image_name ? `${item.image_name} ${rowText}` : "", + ].filter(Boolean).join(" / "); +} + +function displayRowNo(item) { + const row = Number(item.image_row_no || 0); + if (!row) return ""; + return Math.max(1, row - 2); +} + +function displayRowText(item) { + const row = Number(item.image_row_no || 0); + if (!row) return ""; + const patientRow = displayRowNo(item); + return patientRow === row ? `第${row}行` : `第${patientRow}条(图片第${row}行)`; +} + +async function loadItems(selectFirst = false, append = false) { + if (state.loadingItems) return; + state.loadingItems = true; + if (!append) { + state.page = 1; + } + const params = new URLSearchParams({ + page: state.page, + page_size: state.pageSize, + status: $("#statusFilter").value, + sort: $("#sortFilter").value, + batch: $("#batchFilter").value, + q: $("#searchInput").value, + }); + try { + const data = await api(`/api/items?${params.toString()}`); + if (!data) return; + state.totalItems = data.total; + state.page = data.page; + if (append) { + const seen = new Set(state.items.map((item) => item.key)); + state.items = state.items.concat((data.items || []).filter((item) => !seen.has(item.key))); + } else { + state.items = data.items || []; + } + renderQueue(); + if (selectFirst && state.items.length) { + selectItem(state.items[0].key); + } else if (state.current) { + highlightActive(state.current.key); + } + } catch (error) { + setSaveState(`列表加载失败:${error.message}`); + if (!append) { + $("#queueList").innerHTML = `
${escapeHtml(error.message)}
`; + } + } finally { + state.loadingItems = false; + } +} + +async function loadNextItems() { + if (state.loadingItems || state.items.length >= state.totalItems) return; + state.page += 1; + await loadItems(false, true); +} + +async function reloadLoadedItems() { + const pageCount = Math.max(1, Math.ceil(Math.max(state.items.length, state.pageSize) / state.pageSize)); + state.items = []; + state.totalItems = 0; + for (let page = 1; page <= pageCount; page += 1) { + state.page = page; + await loadItems(false, page > 1); + if (state.items.length >= state.totalItems) break; + } +} + +function renderQueue() { + const list = $("#queueList"); + if (!state.items.length) { + list.innerHTML = `
当前筛选下没有记录
`; + return; + } + const footer = state.items.length < state.totalItems ? "继续向下滚动加载更多" : "已加载全部"; + list.innerHTML = state.items.map((item) => ` + + `).join("") + `
${state.items.length} / ${state.totalItems} · ${footer}
`; + list.querySelectorAll(".queue-item").forEach((button) => { + button.addEventListener("click", () => { + $("#queuePanel").focus({ preventScroll: true }); + selectItem(button.dataset.key); + }); + }); + if (state.current) highlightActive(state.current.key); +} + +function highlightActive(key) { + document.querySelectorAll(".queue-item").forEach((button) => { + const active = button.dataset.key === key; + button.classList.toggle("active", active); + if (active) { + button.scrollIntoView({ block: "nearest" }); + } + }); +} + +async function selectItem(key) { + const item = await api(`/api/items/${key}`); + if (!item) return; + state.current = item; + highlightActive(key); + renderCurrent(); +} + +async function selectAdjacentItem(offset) { + if (!state.current) return; + let index = state.items.findIndex((item) => item.key === state.current.key); + if (index < 0) return; + let nextIndex = index + offset; + if (nextIndex >= state.items.length && state.items.length < state.totalItems) { + await loadNextItems(); + index = state.items.findIndex((item) => item.key === state.current.key); + nextIndex = index + offset; + } + if (nextIndex < 0 || nextIndex >= state.items.length) return; + await selectItem(state.items[nextIndex].key); +} + +function renderCurrent() { + const item = state.current; + if (!item) return; + $("#recordTitle").textContent = `${item.patient["姓名"] || "未识别姓名"} · ${item.patient["住院号"] || "无住院号"}`; + const handledAt = item.updated_at ? ` / 处理时间 ${formatTime(item.updated_at)}` : ""; + $("#recordMeta").textContent = `${item.major_department} / ${item.sub_department} / ${item.source_folder} / ${item.image_name} ${displayRowText(item)}${handledAt}`; + const cropUrl = `/api/crop?path=${encodeURIComponent(item.image_path)}&row=${encodeURIComponent(item.image_row_no)}&context=2&v=${Date.now()}`; + $("#cropImage").src = cropUrl; + resetImageView(); + $("#fullImageLink").href = `/api/image?path=${encodeURIComponent(item.image_path)}`; + columns.forEach((column) => { + const field = document.querySelector(`[name="${column}"]`); + if (field) field.value = item.patient[column] ?? ""; + }); + $("#manualNote").value = item.manual_note || ""; + renderReviewChangeLog(); + renderTips(); +} + +function renderTips() { + const item = state.current; + const currentTips = (item.validation_warnings || []).map((tip) => ({ text: `当前修订: ${tip}`, cls: "error" })); + const historyTips = (item.review_tips || []).map((tip) => ({ text: `原始OCR提示: ${tip}`, cls: "info" })); + if (!currentTips.length && !historyTips.length) { + $("#tipsBox").innerHTML = `
当前字段通过校验
`; + return; + } + const currentHtml = currentTips.length + ? currentTips.map((tip) => `
${escapeHtml(tip.text)}
`).join("") + : `
当前字段通过校验
`; + $("#tipsBox").innerHTML = currentHtml + historyTips.map((tip) => `
${escapeHtml(tip.text)}
`).join(""); +} + +function formPatient(writeBack = false) { + const patient = {}; + columns.forEach((column) => { + const field = document.querySelector(`[name="${column}"]`); + patient[column] = field ? normalizeFieldValue(column, field.value) : ""; + if (writeBack && field && datetimeColumns.has(column)) field.value = patient[column]; + }); + return patient; +} + +function normalizeDateTime(value) { + const text = String(value ?? "").replaceAll(":", ":").trim().replace(/\s+/g, " "); + if (!text) return ""; + const match = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})$/); + if (!match) return text; + const [, year, month, day, hour, minute, second] = match; + const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second)); + if ( + date.getFullYear() !== Number(year) || + date.getMonth() !== Number(month) - 1 || + date.getDate() !== Number(day) || + date.getHours() !== Number(hour) || + date.getMinutes() !== Number(minute) || + date.getSeconds() !== Number(second) + ) { + return text; + } + const pad = (value) => String(value).padStart(2, "0"); + return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`; +} + +function normalizeFieldValue(column, value) { + const text = String(value ?? "").replaceAll(":", ":").trim(); + return datetimeColumns.has(column) ? normalizeDateTime(text) : text; +} + +function patientFromEditor(selector) { + const patient = {}; + document.querySelectorAll(`${selector} [data-patient-field]`).forEach((field) => { + patient[field.dataset.patientField] = normalizeFieldValue(field.dataset.patientField, field.value); + }); + return patient; +} + +function parseDateTime(value) { + const text = normalizeDateTime(value); + const match = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!match) return null; + const [, year, month, day, hour, minute, second] = match.map(Number); + const date = new Date(year, month - 1, day, hour, minute, second); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day || + date.getHours() !== hour || + date.getMinutes() !== minute || + date.getSeconds() !== second + ) return null; + return date; +} + +function validatePatientLocal(patient) { + const warnings = []; + if (!String(patient["姓名"] || "").trim()) warnings.push("缺少姓名"); + if (!["男", "女"].includes(String(patient["性别"] || "").trim())) warnings.push("性别异常"); + const age = String(patient["年龄"] || "").trim(); + if (age && !/^\d{1,3}岁$/.test(age)) warnings.push("年龄格式异常"); + if (!String(patient["住院号"] || "").trim()) warnings.push("缺少住院号"); + const admission = String(patient["入院时间"] || "").trim(); + const discharge = String(patient["出院时间"] || "").trim(); + const lastWrite = String(patient["最后书写时间"] || "").trim(); + const admissionTime = parseDateTime(admission); + const dischargeTime = parseDateTime(discharge); + const lastWriteTime = parseDateTime(lastWrite); + if (!admission) warnings.push("缺少入院时间"); + else if (!admissionTime) warnings.push("入院时间格式异常"); + if (discharge && !dischargeTime) warnings.push("出院时间格式异常"); + if (lastWrite && !lastWriteTime) warnings.push("最后书写时间格式异常"); + if (admissionTime && dischargeTime && dischargeTime < admissionTime) warnings.push("出院时间早于入院时间"); + if (admissionTime && lastWriteTime && lastWriteTime < admissionTime) warnings.push("最后书写时间早于入院时间"); + const hospitalDays = String(patient["住院天数"] || "").trim(); + if (hospitalDays && !/^\d+$/.test(hospitalDays)) warnings.push("住院天数格式异常"); + const postoperativeDays = String(patient["手术后天数"] || "").trim(); + if (postoperativeDays && !/^后\d+天$/.test(postoperativeDays)) warnings.push("手术后天数格式异常"); + return warnings; +} + +function renderValidationBox(selector, warnings, prefix = "当前修订") { + const box = $(selector); + if (!box) return; + if (!warnings.length) { + box.innerHTML = `
当前字段通过校验
`; + return; + } + box.innerHTML = warnings.map((warning) => `
${escapeHtml(prefix)}: ${escapeHtml(warning)}
`).join(""); +} + +function updateAuditValidation(editorSelector, tipsSelector, targetItem = null) { + const patient = patientFromEditor(editorSelector); + const warnings = validatePatientLocal(patient); + if (targetItem) targetItem.validation_warnings = warnings; + renderValidationBox(tipsSelector, warnings); + return warnings; +} + +function patientChanges(before = {}, after = {}) { + return columns + .map((column) => ({ + column, + before: normalizeFieldValue(column, before[column] || ""), + after: normalizeFieldValue(column, after[column] || ""), + })) + .filter((item) => item.before !== item.after); +} + +function displayChangeLogEntries(entries = []) { + if (!Array.isArray(entries)) return []; + return entries + .map((entry) => ({ + column: entry.column || entry["字段"] || "", + before: normalizeFieldValue(entry.column || entry["字段"] || "", entry.before ?? entry["修改前"] ?? ""), + after: normalizeFieldValue(entry.column || entry["字段"] || "", entry.after ?? entry["修改后"] ?? ""), + source: entry.source || entry["修改者"] || "", + })) + .filter((entry) => columns.includes(entry.column)); +} + +function renderChangeLog(selector, item, title = "修改记录", sourceLabel = "", explicitChanges = null) { + const box = $(selector); + if (!box) return; + const changes = displayChangeLogEntries(explicitChanges || item.change_log || []) + .concat(explicitChanges || item.change_log ? [] : patientChanges(item.audit_original_patient || item.original_patient || {}, item.patient || {})); + if (!changes.length && !item.audit_change_summary) { + box.innerHTML = ""; + return; + } + const showSource = changes.some((change) => change.source); + const rows = changes.length + ? changes.map((change) => ` + + ${escapeHtml(change.column)} + ${escapeHtml(change.before || "空")} + ${escapeHtml(change.after || "空")} + ${showSource ? `${escapeHtml(change.source || sourceLabel || "人工")}` : ""} + + `).join("") + : `${escapeHtml(item.audit_change_summary || "")}`; + box.innerHTML = ` +
+ ${escapeHtml(title)} + + ${showSource ? "" : ""} + ${rows} +
字段修改前修改后修改者
+
+ `; +} + +function reviewPatientChangesWithSource(draftPatient) { + const currentPatient = state.current?.patient || {}; + const savedLog = displayChangeLogEntries(state.current?.change_log || []); + const pendingManualChanges = patientChanges(currentPatient, draftPatient).map((change) => ({ ...change, source: "人工" })); + if (savedLog.length || pendingManualChanges.length) return savedLog.concat(pendingManualChanges); + const original = state.current?.original_patient || {}; + const source = state.current?.ai_corrected ? "AI" : "人工"; + return patientChanges(original, draftPatient).map((change) => ({ ...change, source })); +} + +function renderReviewChangeLog() { + if (!state.current) { + $("#reviewChangeLog").innerHTML = ""; + return; + } + const draft = { + ...state.current, + patient: formPatient(), + }; + const changes = reviewPatientChangesWithSource(draft.patient); + renderChangeLog("#reviewChangeLog", draft, "修改记录", "", changes); +} + +function replaceFullwidthColon(field) { + if (!field.value.includes(":")) return; + const start = field.selectionStart; + const end = field.selectionEnd; + field.value = field.value.replaceAll(":", ":"); + if (typeof start === "number" && typeof end === "number") { + field.setSelectionRange(start, end); + } +} + +function formOptions() { + return {}; +} + +async function validateForm() { + if (!state.current) return; + const data = await api(`/api/items/${state.current.key}/validate`, { + method: "POST", + body: JSON.stringify({ patient: formPatient(), options: formOptions() }), + }); + if (!data) return; + if (data.patient) fillPatientFields(data.patient); + if (state.current) state.current.validation_warnings = data.warnings || []; + renderTips(); + renderReviewChangeLog(); + return data.warnings || []; +} + +function updateLocalReviewValidation() { + if (!state.current) return; + state.current.validation_warnings = validatePatientLocal(formPatient(false)); + renderTips(); + renderReviewChangeLog(); +} + +function setImageZoom(value, anchorEvent = null) { + const frame = $("#cropFrame"); + const image = $("#cropImage"); + const previousZoom = state.imageZoom; + const nextZoom = Math.min(3, Math.max(0.4, value)); + if (!image.naturalWidth) { + state.imageZoom = nextZoom; + $("#zoomLabel").textContent = `${Math.round(state.imageZoom * 100)}%`; + return; + } + const rect = frame.getBoundingClientRect(); + const anchorX = anchorEvent ? anchorEvent.clientX - rect.left : frame.clientWidth / 2; + const anchorY = anchorEvent ? anchorEvent.clientY - rect.top : frame.clientHeight / 2; + const scrollX = frame.scrollLeft + anchorX; + const scrollY = frame.scrollTop + anchorY; + const ratio = nextZoom / previousZoom; + state.imageZoom = nextZoom; + image.style.width = `${Math.round(image.naturalWidth * state.imageZoom)}px`; + $("#zoomLabel").textContent = `${Math.round(state.imageZoom * 100)}%`; + requestAnimationFrame(() => { + frame.scrollLeft = scrollX * ratio - anchorX; + frame.scrollTop = scrollY * ratio - anchorY; + }); +} + +function resetImageView() { + const frame = $("#cropFrame"); + state.imageZoom = 1; + $("#cropImage").style.width = ""; + $("#zoomLabel").textContent = "100%"; + requestAnimationFrame(() => { + frame.scrollLeft = 0; + frame.scrollTop = 0; + }); +} + +function startImagePan(event) { + if (event.button !== 0) return; + const frame = $("#cropFrame"); + state.imagePan = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + scrollLeft: frame.scrollLeft, + scrollTop: frame.scrollTop, + }; + frame.classList.add("is-dragging"); + frame.setPointerCapture(event.pointerId); +} + +function moveImagePan(event) { + const pan = state.imagePan; + if (!pan || pan.pointerId !== event.pointerId) return; + event.preventDefault(); + const frame = $("#cropFrame"); + frame.scrollLeft = pan.scrollLeft - (event.clientX - pan.startX); + frame.scrollTop = pan.scrollTop - (event.clientY - pan.startY); +} + +function endImagePan(event) { + const pan = state.imagePan; + if (!pan || pan.pointerId !== event.pointerId) return; + const frame = $("#cropFrame"); + state.imagePan = null; + frame.classList.remove("is-dragging"); + if (frame.hasPointerCapture(event.pointerId)) { + frame.releasePointerCapture(event.pointerId); + } +} + +function setAuditImageZoom(value, anchorEvent = null) { + const frame = $("#auditFrame"); + const image = $("#auditImage"); + const previousZoom = state.auditImageZoom; + const nextZoom = Math.min(3, Math.max(0.4, value)); + if (!image.naturalWidth) { + state.auditImageZoom = nextZoom; + $("#auditZoomLabel").textContent = `${Math.round(state.auditImageZoom * 100)}%`; + return; + } + const rect = frame.getBoundingClientRect(); + const anchorX = anchorEvent ? anchorEvent.clientX - rect.left : frame.clientWidth / 2; + const anchorY = anchorEvent ? anchorEvent.clientY - rect.top : frame.clientHeight / 2; + const scrollX = frame.scrollLeft + anchorX; + const scrollY = frame.scrollTop + anchorY; + const ratio = nextZoom / previousZoom; + state.auditImageZoom = nextZoom; + image.style.width = `${Math.round(image.naturalWidth * state.auditImageZoom)}px`; + $("#auditZoomLabel").textContent = `${Math.round(state.auditImageZoom * 100)}%`; + requestAnimationFrame(() => { + frame.scrollLeft = scrollX * ratio - anchorX; + frame.scrollTop = scrollY * ratio - anchorY; + }); +} + +function resetAuditImageView() { + const frame = $("#auditFrame"); + state.auditImageZoom = 1; + $("#auditImage").style.width = ""; + $("#auditZoomLabel").textContent = "100%"; + requestAnimationFrame(() => { + frame.scrollLeft = Math.max(0, (frame.scrollWidth - frame.clientWidth) / 2); + frame.scrollTop = Math.max(0, (frame.scrollHeight - frame.clientHeight) / 2); + }); +} + +function startAuditImagePan(event) { + if (event.button !== 0) return; + const frame = $("#auditFrame"); + state.auditImagePan = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + scrollLeft: frame.scrollLeft, + scrollTop: frame.scrollTop, + }; + frame.classList.add("is-dragging"); + frame.setPointerCapture(event.pointerId); +} + +function moveAuditImagePan(event) { + const pan = state.auditImagePan; + if (!pan || pan.pointerId !== event.pointerId) return; + event.preventDefault(); + const frame = $("#auditFrame"); + frame.scrollLeft = pan.scrollLeft - (event.clientX - pan.startX); + frame.scrollTop = pan.scrollTop - (event.clientY - pan.startY); +} + +function endAuditImagePan(event) { + const pan = state.auditImagePan; + if (!pan || pan.pointerId !== event.pointerId) return; + const frame = $("#auditFrame"); + state.auditImagePan = null; + frame.classList.remove("is-dragging"); + if (frame.hasPointerCapture(event.pointerId)) { + frame.releasePointerCapture(event.pointerId); + } +} + +function setAuditHistoryImageZoom(value, anchorEvent = null) { + const frame = $("#auditHistoryFrame"); + const image = $("#auditHistoryImage"); + const previousZoom = state.auditHistoryImageZoom; + const nextZoom = Math.min(3, Math.max(0.4, value)); + if (!image.naturalWidth) { + state.auditHistoryImageZoom = nextZoom; + $("#auditHistoryZoomLabel").textContent = `${Math.round(state.auditHistoryImageZoom * 100)}%`; + return; + } + const rect = frame.getBoundingClientRect(); + const anchorX = anchorEvent ? anchorEvent.clientX - rect.left : frame.clientWidth / 2; + const anchorY = anchorEvent ? anchorEvent.clientY - rect.top : frame.clientHeight / 2; + const scrollX = frame.scrollLeft + anchorX; + const scrollY = frame.scrollTop + anchorY; + const ratio = nextZoom / previousZoom; + state.auditHistoryImageZoom = nextZoom; + image.style.width = `${Math.round(image.naturalWidth * state.auditHistoryImageZoom)}px`; + $("#auditHistoryZoomLabel").textContent = `${Math.round(state.auditHistoryImageZoom * 100)}%`; + requestAnimationFrame(() => { + frame.scrollLeft = scrollX * ratio - anchorX; + frame.scrollTop = scrollY * ratio - anchorY; + }); +} + +function resetAuditHistoryImageView() { + const frame = $("#auditHistoryFrame"); + state.auditHistoryImageZoom = 1; + $("#auditHistoryImage").style.width = ""; + $("#auditHistoryZoomLabel").textContent = "100%"; + requestAnimationFrame(() => { + frame.scrollLeft = Math.max(0, (frame.scrollWidth - frame.clientWidth) / 2); + frame.scrollTop = Math.max(0, (frame.scrollHeight - frame.clientHeight) / 2); + }); +} + +function startAuditHistoryImagePan(event) { + if (event.button !== 0) return; + const frame = $("#auditHistoryFrame"); + state.auditHistoryImagePan = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + scrollLeft: frame.scrollLeft, + scrollTop: frame.scrollTop, + }; + frame.classList.add("is-dragging"); + frame.setPointerCapture(event.pointerId); +} + +function moveAuditHistoryImagePan(event) { + const pan = state.auditHistoryImagePan; + if (!pan || pan.pointerId !== event.pointerId) return; + event.preventDefault(); + const frame = $("#auditHistoryFrame"); + frame.scrollLeft = pan.scrollLeft - (event.clientX - pan.startX); + frame.scrollTop = pan.scrollTop - (event.clientY - pan.startY); +} + +function endAuditHistoryImagePan(event) { + const pan = state.auditHistoryImagePan; + if (!pan || pan.pointerId !== event.pointerId) return; + const frame = $("#auditHistoryFrame"); + state.auditHistoryImagePan = null; + frame.classList.remove("is-dragging"); + if (frame.hasPointerCapture(event.pointerId)) { + frame.releasePointerCapture(event.pointerId); + } +} + +function switchView(view) { + if (state.permissions && state.permissions[permissionKeyForView(view)] === false) return; + if (state.aiBusy && view !== state.activeView) { + window.alert(`${state.aiTaskLabel || "AI处理"}中,请保持当前页面打开,完成后再切换到其他页面。`); + setSaveState(`${state.aiTaskLabel || "AI处理"}中,请保持当前页面打开`); + return; + } + document.querySelectorAll(".page-view").forEach((panel) => { + panel.classList.toggle("hidden", panel.id !== `${view}View`); + }); + document.querySelectorAll(".nav-button").forEach((button) => { + button.classList.toggle("active", button.dataset.view === view); + }); + state.activeView = view; + if (view === "review" && !state.items.length) loadItems(true); + if (view === "auditHistory") loadAuditHistory(); + if (view === "settings") loadSettings(); +} + +async function loadSession() { + const data = await api("/api/session"); + if (!data) return; + state.permissions = data.permissions || {}; + state.permissionLabels = data.permission_labels || {}; + state.kimiEnabled = data.kimi_enabled !== false; + updateAiVisibility(); + document.querySelectorAll(".nav-button").forEach((button) => { + const view = button.dataset.view; + button.classList.toggle("hidden", state.permissions[permissionKeyForView(view)] === false); + }); + const active = document.querySelector(".nav-button.active:not(.hidden)"); + if (!active) { + const first = document.querySelector(".nav-button:not(.hidden)"); + if (first) switchView(first.dataset.view); + } +} + +async function saveCorrection(event) { + event.preventDefault(); + if (!state.current) return; + const currentKey = state.current.key; + const wasAiPending = state.current.manual_state === "AI修改-待确认"; + const currentIndex = state.items.findIndex((item) => item.key === currentKey); + const nextKey = state.items[currentIndex + 1]?.key || state.items[currentIndex - 1]?.key || ""; + const precheckWarnings = await validateForm(); + if (precheckWarnings && precheckWarnings.length) { + const message = `当前修订仍有 ${precheckWarnings.length} 个提示:\n\n${precheckWarnings.join("\n")}\n\n仍然保存吗?`; + if (!window.confirm(message)) { + setSaveState("已取消保存"); + return; + } + } + setSaveState("保存中"); + const result = await api(`/api/items/${state.current.key}/correction`, { + method: "POST", + body: JSON.stringify({ + patient: formPatient(), + options: formOptions(), + manual_note: $("#manualNote").value.trim(), + }), + }); + if (!result) return; + if (result.postgres?.error) { + window.alert(`修订已保存到本地,但同步 PostgreSQL 失败,已纳入待同步:\n${result.postgres.error}`); + } else if (result.postgres?.enabled && result.postgres.updated === 0) { + window.alert("修订已保存到本地,但 PostgreSQL 暂未找到匹配记录,已纳入待同步。"); + } + await loadSummary(); + if (wasAiPending) { + setStatusFilter("confirming", false); + } + await reloadLoadedItems(); + if (!result.warnings.length && state.items.length) { + const fallbackIndex = currentIndex >= 0 ? Math.min(currentIndex, state.items.length - 1) : 0; + const targetKey = state.items.some((item) => item.key === nextKey) ? nextKey : state.items[fallbackIndex].key; + await selectItem(targetKey); + } else { + state.current = result.item; + renderCurrent(); + highlightActive(result.item.key); + } + setSaveState(result.warnings.length ? "已保存,仍需确认" : "已保存"); +} + +async function deleteCorrection() { + if (!state.current) return; + setSaveState("删除中"); + const result = await api(`/api/items/${state.current.key}/correction`, { method: "DELETE" }); + if (result?.postgres?.error) { + window.alert(`本地修订已删除,但同步 PostgreSQL 失败:\n${result.postgres.error}`); + } + const item = await api(`/api/items/${state.current.key}`); + state.current = item; + renderCurrent(); + await loadSummary(); + await reloadLoadedItems(); + setSaveState("已删除修订"); +} + +function resetToOriginal() { + if (!state.current) return; + columns.forEach((column) => { + const field = document.querySelector(`[name="${column}"]`); + if (field) field.value = state.current.original_patient[column] ?? ""; + }); + $("#manualNote").value = ""; + renderReviewChangeLog(); + validateForm(); +} + +function fillPatientFields(patient) { + columns.forEach((column) => { + const field = document.querySelector(`[name="${column}"]`); + if (field && Object.prototype.hasOwnProperty.call(patient, column)) { + field.value = patient[column] ?? ""; + } + }); +} + +function bindEvents() { + document.querySelectorAll(".nav-button").forEach((button) => { + button.addEventListener("click", () => switchView(button.dataset.view)); + }); + $("#batchFilter").addEventListener("change", () => loadItems(true)); + document.querySelectorAll(".status-pill").forEach((button) => { + button.addEventListener("click", () => setStatusFilter(button.dataset.status)); + }); + $("#moreStatusFilter").addEventListener("change", () => { + if ($("#moreStatusFilter").value) { + setStatusFilter($("#moreStatusFilter").value); + } + }); + $("#sortFilter").addEventListener("change", () => loadItems(true)); + $("#queuePanel").addEventListener("scroll", () => { + const panel = $("#queuePanel"); + if (panel.scrollTop + panel.clientHeight >= panel.scrollHeight - 180) { + loadNextItems(); + } + }); + $("#queuePanel").addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + selectAdjacentItem(1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + selectAdjacentItem(-1); + } + }); + $("#pgTestButton").addEventListener("click", () => testPostgres(false)); + $("#pgSyncButton").addEventListener("click", syncPostgres); + $("#searchInput").addEventListener("input", () => { + clearTimeout(state.debounce); + state.debounce = setTimeout(() => loadItems(true), 220); + }); + $("#editForm").addEventListener("submit", saveCorrection); + $("#deleteButton").addEventListener("click", deleteCorrection); + $("#resetButton").addEventListener("click", resetToOriginal); + $("#zoomInButton").addEventListener("click", () => setImageZoom(state.imageZoom + 0.15)); + $("#zoomOutButton").addEventListener("click", () => setImageZoom(state.imageZoom - 0.15)); + $("#zoomResetButton").addEventListener("click", resetImageView); + $("#cropFrame").addEventListener("wheel", (event) => { + event.preventDefault(); + setImageZoom(state.imageZoom + (event.deltaY < 0 ? 0.12 : -0.12), event); + }, { passive: false }); + $("#cropFrame").addEventListener("pointerdown", startImagePan); + $("#cropFrame").addEventListener("pointermove", moveImagePan); + $("#cropFrame").addEventListener("pointerup", endImagePan); + $("#cropFrame").addEventListener("pointercancel", endImagePan); + $("#cropImage").addEventListener("load", resetImageView); + $("#cropImage").addEventListener("error", () => { + $("#tipsBox").innerHTML = `
截图加载失败,请检查原图路径或点击“原图”查看。
`; + }); + $("#auditZoomInButton").addEventListener("click", () => setAuditImageZoom(state.auditImageZoom + 0.15)); + $("#auditZoomOutButton").addEventListener("click", () => setAuditImageZoom(state.auditImageZoom - 0.15)); + $("#auditZoomResetButton").addEventListener("click", resetAuditImageView); + $("#auditFrame").addEventListener("wheel", (event) => { + event.preventDefault(); + setAuditImageZoom(state.auditImageZoom + (event.deltaY < 0 ? 0.12 : -0.12), event); + }, { passive: false }); + $("#auditFrame").addEventListener("pointerdown", startAuditImagePan); + $("#auditFrame").addEventListener("pointermove", moveAuditImagePan); + $("#auditFrame").addEventListener("pointerup", endAuditImagePan); + $("#auditFrame").addEventListener("pointercancel", endAuditImagePan); + $("#auditImage").addEventListener("load", resetAuditImageView); + $("#auditImage").addEventListener("error", () => { + $("#auditState").textContent = "截图加载失败,请检查原图路径"; + }); + $("#auditSampleButton").addEventListener("click", loadAuditSample); + $("#auditKimiButton").addEventListener("click", () => runKimiAudit()); + $("#auditKimiAllButton").addEventListener("click", runKimiAuditAll); + $("#auditHistoryRefreshButton").addEventListener("click", loadAuditHistory); + $("#auditHistorySourceFilter").addEventListener("change", loadAuditHistory); + $("#auditHistoryStatusFilter").addEventListener("change", loadAuditHistory); + $("#auditHistorySortFilter").addEventListener("change", loadAuditHistory); + $("#auditHistorySearchInput").addEventListener("input", () => { + clearTimeout(state.debounce); + state.debounce = setTimeout(loadAuditHistory, 220); + }); + $("#auditHistoryZoomInButton").addEventListener("click", () => setAuditHistoryImageZoom(state.auditHistoryImageZoom + 0.15)); + $("#auditHistoryZoomOutButton").addEventListener("click", () => setAuditHistoryImageZoom(state.auditHistoryImageZoom - 0.15)); + $("#auditHistoryZoomResetButton").addEventListener("click", resetAuditHistoryImageView); + $("#auditHistoryFrame").addEventListener("wheel", (event) => { + event.preventDefault(); + setAuditHistoryImageZoom(state.auditHistoryImageZoom + (event.deltaY < 0 ? 0.12 : -0.12), event); + }, { passive: false }); + $("#auditHistoryFrame").addEventListener("pointerdown", startAuditHistoryImagePan); + $("#auditHistoryFrame").addEventListener("pointermove", moveAuditHistoryImagePan); + $("#auditHistoryFrame").addEventListener("pointerup", endAuditHistoryImagePan); + $("#auditHistoryFrame").addEventListener("pointercancel", endAuditHistoryImagePan); + $("#auditHistoryImage").addEventListener("load", resetAuditHistoryImageView); + $("#auditHistoryImage").addEventListener("error", () => { + $("#auditHistoryState").textContent = "截图加载失败,请检查原图路径"; + }); + $("#auditPassButton").addEventListener("click", () => saveAuditResult("人工核验通过")); + $("#auditFailButton").addEventListener("click", () => saveAuditResult("人工核验异常")); + $("#auditUnsureButton").addEventListener("click", () => saveAuditResult("暂不确定")); + $("#auditHistoryPassButton").addEventListener("click", () => saveAuditHistoryResult("人工核验通过")); + $("#auditHistoryFailButton").addEventListener("click", () => saveAuditHistoryResult("人工核验异常")); + $("#auditHistoryUnsureButton").addEventListener("click", () => saveAuditHistoryResult("暂不确定")); + $("#userForm").addEventListener("submit", saveUser); + $("#kimiForm").addEventListener("submit", saveKimiSettings); + $("#kimiToggleButton").addEventListener("click", toggleKimiEnabled); + $("#processingFindButton").addEventListener("click", () => findProcessingItems(false)); + $("#commitManualPassedButton").addEventListener("click", commitManualPassed); + $("#resetAuditHistoryButton").addEventListener("click", resetAuditHistory); + $("#kimiCurrentButton").addEventListener("click", kimiCorrectCurrent); + $("#kimiFiveButton").addEventListener("click", kimiCorrectFive); + $("#kimiRemainingButton").addEventListener("click", kimiCorrectRemaining); + document.querySelectorAll(".edit-form input, .edit-form textarea").forEach((field) => { + field.addEventListener("input", () => { + const column = field.getAttribute("name"); + if (datetimeColumns.has(column)) { + replaceFullwidthColon(field); + } + updateLocalReviewValidation(); + }); + field.addEventListener("keydown", (event) => { + const column = field.getAttribute("name"); + if (event.key === "Enter" && datetimeColumns.has(column)) { + event.preventDefault(); + field.value = normalizeDateTime(field.value); + validateForm(); + } + }); + field.addEventListener("blur", () => { + const column = field.getAttribute("name"); + if (datetimeColumns.has(column)) { + field.value = normalizeDateTime(field.value); + validateForm(); + } else { + updateLocalReviewValidation(); + } + }); + }); +} + +async function loadAuditSample() { + $("#auditState").textContent = "抽取中"; + const count = Math.min(20, Math.max(1, Number($("#auditCount").value || 5))); + $("#auditCount").value = count; + const source = $("#auditSource").value; + try { + const data = await api("/api/audit/sample", { + method: "POST", + body: JSON.stringify({ count, source }), + }); + if (!data) return; + state.auditItems = data.items || []; + renderAuditList(); + if (state.auditItems.length) selectAuditItem(state.auditItems[0].key); + $("#auditState").textContent = `已抽取 ${state.auditItems.length} 条,${auditProgressText()}`; + } catch (error) { + $("#auditState").textContent = `抽取失败:${error.message}`; + } +} + +function renderAuditList() { + $("#auditList").innerHTML = state.auditItems + .map((item) => auditQueueItemHtml(item, state.auditCurrent?.key, item.audit_checked_at ? [`抽查时间 ${formatTime(item.audit_checked_at)}`] : [])) + .join(""); + $("#auditList").querySelectorAll(".queue-item").forEach((button) => { + button.addEventListener("click", () => selectAuditItem(button.dataset.key)); + }); +} + +function selectAuditItem(key) { + const item = state.auditItems.find((candidate) => candidate.key === key); + if (!item) return; + if (!item.audit_original_patient) { + item.audit_original_patient = { ...(item.patient || {}) }; + } + state.auditCurrent = item; + $("#auditTitle").innerHTML = auditTitleHtml(item); + $("#auditMeta").textContent = `${item.major_department} / ${item.sub_department} / ${item.source_folder} / ${item.image_name} ${displayRowText(item)}`; + $("#auditImage").src = `/api/crop?path=${encodeURIComponent(item.image_path)}&row=${encodeURIComponent(item.image_row_no)}&context=2&v=${Date.now()}`; + $("#auditFullImageLink").href = `/api/image?path=${encodeURIComponent(item.image_path)}`; + resetAuditImageView(); + setAuditPrompt(item.audit_prompt || ""); + setAuditModelResult(item.audit_ai_feedback || item.audit_result_text || "", item.audit_ai_raw_output || item.audit_ai_feedback || item.audit_result_text || "", item.patient || null); + $("#auditMachineVerdict").value = item.audit_machine_verdict || ""; + $("#auditManualFeedback").value = item.audit_manual_feedback || ""; + renderAuditPatientInfo(item); + renderAuditVerdicts(item); + updateAuditValidation("#auditPatientInfo", "#auditTipsBox", item); + renderChangeLog("#auditChangeLog", item); + renderAuditList(); +} + +function renderAuditPatientInfo(item) { + renderPatientEditor("#auditPatientInfo", item, "#auditTipsBox", "#auditChangeLog"); +} + +function renderPatientEditor(selector, item, tipsSelector = "", changeLogSelector = "") { + const wideColumns = new Set(["诊断", "手术后天数"]); + $(selector).innerHTML = columns.map((column) => { + const value = escapeHtml(item.patient[column] || ""); + const wide = wideColumns.has(column) ? "wide" : ""; + const control = column === "诊断" + ? `` + : ``; + return ``; + }).join(""); + document.querySelectorAll(`${selector} [data-patient-field]`).forEach((field) => { + field.addEventListener("input", () => { + const column = field.dataset.patientField; + if (datetimeColumns.has(column)) replaceFullwidthColon(field); + readPatientEditor(selector, item, false); + if (tipsSelector) updateAuditValidation(selector, tipsSelector, item); + if (changeLogSelector) renderChangeLog(changeLogSelector, item); + refreshAuditIdentity(selector, item); + }); + field.addEventListener("blur", () => { + const column = field.dataset.patientField; + field.value = normalizeFieldValue(column, field.value); + readPatientEditor(selector, item); + if (tipsSelector) updateAuditValidation(selector, tipsSelector, item); + if (changeLogSelector) renderChangeLog(changeLogSelector, item); + refreshAuditIdentity(selector, item); + }); + }); +} + +function refreshAuditIdentity(selector, item) { + if (selector === "#auditPatientInfo") { + $("#auditTitle").innerHTML = auditTitleHtml(item); + renderAuditList(); + } else if (selector === "#auditHistoryPatientInfo") { + $("#auditHistoryTitle").innerHTML = auditTitleHtml(item, auditHistoryBadgeText(item)); + renderAuditHistoryList(); + } +} + +function readPatientEditor(selector, targetItem, writeBack = true) { + const patient = { ...(targetItem.patient || {}) }; + document.querySelectorAll(`${selector} [data-patient-field]`).forEach((field) => { + const column = field.dataset.patientField; + patient[column] = normalizeFieldValue(column, field.value); + if (writeBack) field.value = patient[column]; + }); + targetItem.patient = patient; + return patient; +} + +function renderAuditVerdicts(item) { + $("#auditMachineVerdictText").textContent = item.audit_machine_verdict || "未判断"; + $("#auditManualVerdictText").textContent = item.audit_result || "未选择"; +} + +async function runKimiAudit(options = {}) { + if (!state.auditCurrent) return; + if (!ensureAiReady("AI抽查")) return; + readPatientEditor("#auditPatientInfo", state.auditCurrent); + setSaveState("AI抽查中,请保持当前页面打开"); + $("#auditState").textContent = auditProgressText(); + try { + await runKimiAuditForItem(state.auditCurrent); + setAuditPrompt(state.auditCurrent.audit_prompt); + setAuditModelResult(state.auditCurrent.audit_ai_feedback, state.auditCurrent.audit_ai_raw_output, state.auditCurrent.patient || null); + $("#auditMachineVerdict").value = state.auditCurrent.audit_machine_verdict; + renderAuditVerdicts(state.auditCurrent); + renderAuditList(); + await persistAuditCurrent(state.auditCurrent.audit_result || ""); + $("#auditState").textContent = auditProgressText(); + setSaveState("AI抽查完成,AI反馈已保存"); + } catch (error) { + setSaveState(`AI抽查失败:${error.message}`); + $("#auditState").textContent = auditProgressText(); + if (options.bubble) throw error; + } finally { + finishAiTask(); + } +} + +async function runKimiAuditForItem(item) { + const result = await api("/api/audit/kimi", { + method: "POST", + body: JSON.stringify({ key: item.key, item }), + }); + if (!result) return null; + item.audit_prompt = result.prompt || ""; + item.audit_ai_feedback = normalizeModelResultText(result.result || "", item.patient || null); + item.audit_ai_raw_output = result.raw_result || result.result || ""; + item.audit_machine_verdict = result.machine_verdict || modelVerdict(item.audit_ai_feedback || item.audit_ai_raw_output, item.patient || null) || ""; + return result; +} + +async function persistAuditItem(item, status, source = $("#auditSource").value) { + const originalPatient = item.audit_original_patient || item.original_patient || item.patient || {}; + const changes = patientChanges(originalPatient, item.patient || {}); + const updates = { + audit_result: status, + audit_ai_feedback: normalizeModelResultText(item.audit_ai_feedback || item.audit_ai_raw_output || "", item.patient || null), + audit_ai_raw_output: item.audit_ai_raw_output || item.audit_ai_feedback || "", + audit_manual_feedback: item.audit_manual_feedback || "", + audit_machine_verdict: item.audit_machine_verdict || "", + audit_source: source || item.audit_source || "", + audit_change_summary: changes.map((change) => `${change.column}: ${change.before || "空"} -> ${change.after || "空"}`).join(";"), + }; + const payload = { + key: item.key, + item, + original_patient: originalPatient, + ...updates, + }; + const result = await api("/api/audit/result", { + method: "POST", + body: JSON.stringify(payload), + }); + if (!result) return null; + Object.assign(item, updates, { + audit_checked_at: new Date().toISOString().slice(0, 19), + }); + return result; +} + +async function persistAuditCurrent(status) { + if (!state.auditCurrent) return; + readPatientEditor("#auditPatientInfo", state.auditCurrent); + state.auditCurrent.audit_result = status; + state.auditCurrent.audit_ai_feedback = getAuditModelResult(); + state.auditCurrent.audit_ai_raw_output = getAuditRawOutput(); + state.auditCurrent.audit_manual_feedback = $("#auditManualFeedback").value.trim(); + state.auditCurrent.audit_machine_verdict = $("#auditMachineVerdict").value; + const result = await persistAuditItem(state.auditCurrent, status, $("#auditSource").value); + if (!result) return; + renderAuditList(); + renderAuditVerdicts(state.auditCurrent); + return result; +} + +async function saveAuditResult(status) { + if (!state.auditCurrent) return; + state.auditCurrent.audit_result = status; + renderAuditVerdicts(state.auditCurrent); + const result = await persistAuditCurrent(status); + if (!result) return; + $("#auditState").textContent = result.postgres?.updated ? `当前项已保存:${status},${auditProgressText()}` : `当前项未匹配数据库,${auditProgressText()}`; + renderChangeLog("#auditChangeLog", state.auditCurrent); +} + +async function runKimiAuditAll() { + if (!state.auditItems.length) return; + if (!window.confirm(`将对当前抽取的 ${state.auditItems.length} 条逐条调用 AI,确定继续?`)) return; + if (!ensureAiReady("AI抽查")) return; + let success = 0; + let failed = 0; + const currentKey = state.auditCurrent?.key || ""; + try { + for (const item of state.auditItems) { + try { + await runKimiAuditForItem(item); + await persistAuditItem(item, item.audit_result || "", item.audit_source || $("#auditSource").value); + success += 1; + if (item.key === currentKey) { + setAuditPrompt(item.audit_prompt || ""); + setAuditModelResult(item.audit_ai_feedback || "", item.audit_ai_raw_output || item.audit_ai_feedback || "", item.patient || null); + $("#auditMachineVerdict").value = item.audit_machine_verdict || ""; + renderAuditVerdicts(item); + } + renderAuditList(); + const progress = `AI抽查中 ${success + failed}/${state.auditItems.length},${auditProgressText()}`; + $("#auditState").textContent = auditProgressText(); + setSaveState(progress); + } catch (error) { + failed += 1; + item.audit_ai_feedback = `调用失败:${error.message}`; + item.audit_ai_raw_output = item.audit_ai_feedback; + item.audit_machine_verdict = "不确定"; + if (state.auditCurrent?.key === item.key) { + setAuditModelResult("", item.audit_ai_raw_output); + $("#auditMachineVerdict").value = item.audit_machine_verdict; + renderAuditVerdicts(item); + } + renderAuditList(); + } + } + } finally { + finishAiTask(); + } + const doneText = `AI抽查所有项完成:成功${success},失败${failed},${auditProgressText()}`; + $("#auditState").textContent = auditProgressText(); + setSaveState(doneText); +} + +async function loadAuditHistory() { + $("#auditHistoryState").textContent = "加载中"; + try { + const params = new URLSearchParams({ + page: "1", + page_size: "80", + source: $("#auditHistorySourceFilter").value, + status: $("#auditHistoryStatusFilter").value, + sort: $("#auditHistorySortFilter").value, + q: $("#auditHistorySearchInput").value, + }); + const data = await api(`/api/audit/history?${params.toString()}`); + if (!data) return; + state.auditHistory = data.items || []; + $("#auditHistoryState").textContent = `${state.auditHistory.length} / ${data.total}`; + renderAuditHistoryList(); + if (state.auditHistory.length) { + selectAuditHistoryItem(state.auditHistory[0].key); + } else { + clearAuditHistoryDetail(); + } + } catch (error) { + $("#auditHistoryState").textContent = `加载失败:${error.message}`; + } +} + +function renderAuditHistoryList() { + $("#auditHistoryList").innerHTML = state.auditHistory.length + ? state.auditHistory.map((item) => auditQueueItemHtml(item, state.auditHistoryCurrent?.key, [ + `抽查源 ${auditSourceLabel(item.audit_source)}`, + `抽查时间 ${formatTime(item.audit_checked_at)}`, + ], auditHistoryBadgeText(item))).join("") + : `
暂无抽查记录
`; + $("#auditHistoryList").querySelectorAll(".queue-item").forEach((button) => { + button.addEventListener("click", () => selectAuditHistoryItem(button.dataset.key)); + }); +} + +function clearAuditHistoryDetail() { + state.auditHistoryCurrent = null; + $("#auditHistoryTitle").textContent = "未选择抽查记录"; + $("#auditHistoryMeta").textContent = ""; + $("#auditHistoryFullImageLink").removeAttribute("href"); + $("#auditHistoryImage").removeAttribute("src"); + setAuditHistoryModelResult("", ""); + $("#auditHistoryPatientInfo").innerHTML = ""; + $("#auditHistoryTipsBox").innerHTML = ""; + $("#auditHistoryChangeLog").innerHTML = ""; + $("#auditHistoryAiVerdict").textContent = ""; + $("#auditHistoryManualVerdict").textContent = ""; + $("#auditHistorySource").textContent = ""; + $("#auditHistoryUser").textContent = ""; + $("#auditHistoryCheckedAt").textContent = ""; + resetAuditHistoryImageView(); +} + +function selectAuditHistoryItem(key) { + const item = state.auditHistory.find((candidate) => candidate.key === key); + if (!item) return; + if (!item.audit_original_patient) { + item.audit_original_patient = { ...(item.patient || {}) }; + } + state.auditHistoryCurrent = item; + $("#auditHistoryTitle").innerHTML = auditTitleHtml(item, auditHistoryBadgeText(item)); + $("#auditHistoryMeta").textContent = `${item.major_department} / ${item.sub_department} / ${item.source_folder} / ${item.image_name} ${displayRowText(item)}`; + $("#auditHistoryImage").src = `/api/crop?path=${encodeURIComponent(item.image_path)}&row=${encodeURIComponent(item.image_row_no)}&context=2&v=${Date.now()}`; + $("#auditHistoryFullImageLink").href = `/api/image?path=${encodeURIComponent(item.image_path)}`; + resetAuditHistoryImageView(); + setAuditHistoryModelResult(item.audit_ai_feedback || "", item.audit_ai_raw_output || item.audit_ai_feedback || "", item.patient || null); + renderPatientEditor("#auditHistoryPatientInfo", item, "#auditHistoryTipsBox", "#auditHistoryChangeLog"); + updateAuditValidation("#auditHistoryPatientInfo", "#auditHistoryTipsBox", item); + renderChangeLog("#auditHistoryChangeLog", item); + $("#auditHistoryAiVerdict").textContent = item.audit_machine_verdict || "未判断"; + $("#auditHistoryManualVerdict").textContent = item.audit_result || "未人工核验"; + $("#auditHistorySource").textContent = auditSourceLabel(item.audit_source); + $("#auditHistoryUser").textContent = item.audit_checked_by || ""; + $("#auditHistoryCheckedAt").textContent = formatTime(item.audit_checked_at); + renderAuditHistoryList(); +} + +async function saveAuditHistoryResult(status) { + if (!state.auditHistoryCurrent) return; + readPatientEditor("#auditHistoryPatientInfo", state.auditHistoryCurrent); + state.auditHistoryCurrent.audit_result = status; + state.auditHistoryCurrent.audit_manual_feedback = ""; + const result = await persistAuditItem( + state.auditHistoryCurrent, + status, + state.auditHistoryCurrent.audit_source || $("#auditHistorySourceFilter").value, + ); + if (!result) return; + $("#auditHistoryManualVerdict").textContent = status; + renderChangeLog("#auditHistoryChangeLog", state.auditHistoryCurrent); + renderAuditHistoryList(); + $("#auditHistoryState").textContent = result.postgres?.updated ? `已保存:${status}` : "保存成功,本次未匹配数据库"; +} + +async function loadSettings() { + const data = await api("/api/settings"); + if (!data) return; + state.permissionLabels = data.permission_labels || state.permissionLabels; + state.kimiEnabled = data.kimi_enabled !== false; + updateAiVisibility(); + $("#kimiModel").value = data.kimi_model || "kimi-k2.6"; + $("#kimiApiKey").placeholder = data.kimi_api_key_set ? "已设置,留空表示不修改" : "未设置"; + $("#kimiToggleButton").textContent = state.kimiEnabled ? "关闭API" : "开启API"; + const defaultPermissions = { overview: true, review: true, audit: true, audit_history: true, settings: false }; + $("#settingsUserCount").textContent = `${(data.users || []).length} 个用户`; + $("#newUserPermissions").innerHTML = renderPermissionControls("new-perm", defaultPermissions); + $("#userList").innerHTML = (data.users || []).map((user, index) => ` +
+
+ ${escapeHtml(user.username)} + ${user.builtin ? "环境变量用户" : "配置用户"} +
+
${escapeHtml(user.created_at || "")}
+
${renderPermissionControls(`user-perm-${index}`, user.permissions || {}, user.builtin)}
+ ${user.builtin ? "" : ``} +
+ `).join(""); + document.querySelectorAll(".user-permission-save").forEach((button) => { + button.addEventListener("click", async () => { + const wrapper = button.closest(".settings-user"); + await saveUserPermissions(wrapper.dataset.username, wrapper.dataset.prefix); + }); + }); + if (state.processingOverview) { + renderProcessingOverview(state.processingOverview); + } else { + findProcessingItems(true); + } +} + +async function saveUser(event) { + event.preventDefault(); + const username = $("#newUsername").value.trim(); + const password = $("#newPassword").value; + if (!username || !password) return; + await api("/api/settings/users", { + method: "POST", + body: JSON.stringify({ username, password, permissions: readPermissionControls("new-perm") }), + }); + $("#newUsername").value = ""; + $("#newPassword").value = ""; + await loadSettings(); +} + +function renderPermissionControls(prefix, permissions = {}, disabled = false) { + return permissionEntries().map(([key, label]) => { + const checked = permissions[key] !== false; + return ` + + `; + }).join(""); +} + +function readPermissionControls(prefix) { + const permissions = {}; + document.querySelectorAll(`.${prefix}[data-permission]`).forEach((input) => { + permissions[input.dataset.permission] = input.checked; + }); + return permissions; +} + +async function saveUserPermissions(username, prefix) { + if (!username || !prefix) return; + await api(`/api/settings/users/${encodeURIComponent(username)}/permissions`, { + method: "POST", + body: JSON.stringify({ permissions: readPermissionControls(prefix) }), + }); + $("#settingsState").textContent = "用户权限已保存"; + await loadSettings(); +} + +async function saveKimiSettings(event) { + event.preventDefault(); + await api("/api/settings/kimi", { + method: "POST", + body: JSON.stringify({ api_key: $("#kimiApiKey").value.trim(), model: $("#kimiModel").value.trim() }), + }); + $("#kimiApiKey").value = ""; + $("#settingsState").textContent = "已保存"; + await loadSettings(); +} + +async function toggleKimiEnabled() { + const enabled = !state.kimiEnabled; + await api("/api/settings/kimi/enabled", { + method: "POST", + body: JSON.stringify({ enabled }), + }); + state.kimiEnabled = enabled; + updateAiVisibility(); + $("#kimiToggleButton").textContent = enabled ? "关闭API" : "开启API"; + $("#settingsState").textContent = enabled ? "AI功能已开启" : "AI功能已关闭"; +} + +async function commitManualPassed() { + if (!window.confirm("确定提交已人工复核通过的数据?提交后这些人工通过条目会归档隐藏,不再出现在复核工作台。")) return; + $("#commitManualPassedState").textContent = "提交中"; + try { + const result = await api("/api/settings/commit-manual-passed", { + method: "POST", + body: JSON.stringify({}), + }); + if (!result) return; + const message = [ + `可提交${result.eligible}`, + `合并结果更新${result.merged_updated}`, + `合并已是最新${result.merged_already_current ?? 0}`, + `数据库成功${result.postgres_success}`, + `已同步${result.postgres_already_success ?? 0}`, + `未匹配${result.postgres_not_found}`, + `失败${result.postgres_failed}`, + `跳过${result.skipped}(AI待确认${result.skipped_ai ?? 0},仍有问题${result.skipped_issue ?? 0})`, + `已归档隐藏${result.archived_corrections ?? 0}`, + ].join(","); + $("#commitManualPassedState").textContent = message; + setSaveState("已提交人工通过数据"); + await loadSummary(); + window.alert(message); + } catch (error) { + $("#commitManualPassedState").textContent = `提交失败:${error.message}`; + } +} + +async function resetAuditHistory() { + if (!window.confirm("确定清空抽查一览结果?这只会删除抽查结论、AI反馈和人工核验结果,不会删除患者基础信息。")) return; + $("#resetAuditHistoryState").textContent = "重置中"; + try { + const result = await api("/api/settings/reset-audit-history", { + method: "POST", + body: JSON.stringify({}), + }); + if (!result) return; + const message = `已重置${result.updated ?? 0}条抽查记录`; + $("#resetAuditHistoryState").textContent = message; + state.auditHistory = []; + clearAuditHistoryDetail(); + renderAuditHistoryList(); + $("#auditHistoryState").textContent = "暂无抽查记录"; + } catch (error) { + $("#resetAuditHistoryState").textContent = `重置失败:${error.message}`; + } +} + +function renderPostgresState(pg) { + const pending = `待同步${pg.pending_sync_count ?? 0}`; + const text = pg.ok + ? `数据库已连接,${pending}` + : `数据库${pg.enabled ? "未连接" : "未启用"},${pending}${pg.error ? `:${pg.error}` : ""}`; + $("#pgState").textContent = text; + $("#pgState").classList.toggle("warn", !pg.ok && pg.enabled); +} + +async function testPostgres(quiet = false) { + if (!quiet) setSaveState("测试PG中"); + try { + const status = await api("/api/postgres/status"); + if (!status) return; + renderPostgresState(status); + if (!quiet) setSaveState(status.ok ? `待同步${status.pending_sync_count ?? 0}` : "数据库未连接"); + } catch (error) { + $("#pgState").textContent = `数据库未连接:${error.message}`; + $("#pgState").classList.add("warn"); + if (!quiet) setSaveState("数据库连接失败"); + } +} + +async function syncPostgres() { + setSaveState("提交待同步中"); + try { + const result = await api("/api/postgres/sync", { method: "POST", body: JSON.stringify({}) }); + if (!result) return; + if (result.postgres) renderPostgresState(result.postgres); + await loadSummary(); + await loadItems(false); + const message = `成功${result.success},未匹配${result.not_found},失败${result.failed}`; + window.alert(message); + const failedKeys = (result.items || []).filter((item) => item.status !== "success").map((item) => item.key); + if (failedKeys.length && window.confirm(`有 ${failedKeys.length} 条未成功同步。确定删除这些待同步记录?取消则继续保留。`)) { + const cleanup = await api("/api/postgres/sync/drop_failed", { + method: "POST", + body: JSON.stringify({ keys: failedKeys }), + }); + if (cleanup?.postgres) renderPostgresState(cleanup.postgres); + await loadSummary(); + setSaveState(`已删除${cleanup?.removed ?? 0}条未成功待同步`); + } else { + setSaveState(`待同步${result.postgres?.pending_sync_count ?? 0}`); + } + } catch (error) { + setSaveState(`同步失败:${error.message}`); + } +} + +async function applyKimiCorrection(key) { + let result; + try { + result = await api(`/api/items/${key}/kimi-correction`, { + method: "POST", + body: JSON.stringify({}), + }); + } catch (error) { + throw new Error(`AI修改失败:${error.message}`); + } + if (!result) return null; + const index = state.items.findIndex((item) => item.key === key); + if (index >= 0) state.items[index] = result.item; + if (state.current?.key === key) { + state.current = result.item; + renderCurrent(); + } + renderQueue(); + return result.item; +} + +async function refreshKimiReviewList(focusKey = "") { + setStatusFilter("confirming", false); + await loadSummary(); + await reloadLoadedItems(); + if (focusKey && state.items.some((item) => item.key === focusKey)) { + await selectItem(focusKey); + } else if (state.items.length) { + await selectItem(state.items[0].key); + } +} + +async function loadKimiTargetsFromCurrent(limit = null) { + if (!state.current) return []; + const start = state.items.findIndex((item) => item.key === state.current.key); + if (start < 0) return []; + const targetEnd = limit ? start + limit : Number.POSITIVE_INFINITY; + while (state.items.length < state.totalItems && state.items.length < targetEnd) { + await loadNextItems(); + } + return state.items.slice(start, limit ? start + limit : undefined); +} + +async function kimiCorrectCurrent() { + if (!state.current) return; + if (!window.confirm("确定调用 AI 修改当前项?修改后会标记为 AI修改-待确认。")) return; + if (!ensureAiReady("AI修改")) return; + const focusKey = state.current.key; + setSaveState("AI修改当前项中,请保持当前页面打开"); + try { + await applyKimiCorrection(focusKey); + await refreshKimiReviewList(focusKey); + setSaveState("已生成 AI 修改,待人工确认"); + } catch (error) { + setSaveState(error.message); + } finally { + finishAiTask(); + } +} + +async function kimiCorrectFive() { + if (!state.current) return; + if (!window.confirm("将从当前项开始调用 AI 修改下 5 项,确定继续?")) return; + if (!ensureAiReady("AI修改")) return; + setSaveState("AI修改5项中,请保持当前页面打开"); + const focusKey = state.current.key; + let success = 0; + let failed = 0; + const failureReasons = new Map(); + let targets = []; + try { + targets = await loadKimiTargetsFromCurrent(5); + for (const item of targets) { + try { + await applyKimiCorrection(item.key); + success += 1; + setSaveState(`AI修改中 ${success + failed}/${targets.length}`); + } catch (error) { + failed += 1; + failureReasons.set(error.message, (failureReasons.get(error.message) || 0) + 1); + setSaveState(`AI修改中 ${success + failed}/${targets.length},失败原因:${error.message}`); + } + } + await refreshKimiReviewList(focusKey); + } catch (error) { + failureReasons.set(`刷新列表失败:${error.message}`, 1); + } finally { + finishAiTask(); + } + const reasonText = [...failureReasons.entries()] + .map(([reason, count]) => `${reason}${count > 1 ? ` x${count}` : ""}`) + .join(";"); + setSaveState(`AI修改完成:成功${success},失败${failed}${reasonText ? `,失败原因:${reasonText}` : ""}`); +} + +async function kimiCorrectRemaining() { + if (!state.current) return; + const start = state.items.findIndex((item) => item.key === state.current.key); + const remainingCount = start >= 0 ? Math.max(0, state.totalItems - start) : 0; + if (!remainingCount) return; + const message = `将从当前项开始调用 AI 修改下方全部 ${remainingCount} 项,会产生约 ${remainingCount} 次 AI 调用,确定继续?`; + if (!window.confirm(message)) return; + if (!ensureAiReady("AI修改")) return; + const focusKey = state.current.key; + setSaveState(`AI修改全部剩余项中,请保持当前页面打开`); + let success = 0; + let failed = 0; + const failureReasons = new Map(); + let targets = []; + try { + targets = await loadKimiTargetsFromCurrent(null); + for (const item of targets) { + try { + await applyKimiCorrection(item.key); + success += 1; + setSaveState(`AI修改中 ${success + failed}/${targets.length}`); + } catch (error) { + failed += 1; + failureReasons.set(error.message, (failureReasons.get(error.message) || 0) + 1); + setSaveState(`AI修改中 ${success + failed}/${targets.length},失败原因:${error.message}`); + } + } + await refreshKimiReviewList(focusKey); + } catch (error) { + failureReasons.set(`刷新列表失败:${error.message}`, 1); + } finally { + finishAiTask(); + } + const reasonText = [...failureReasons.entries()] + .map(([reason, count]) => `${reason}${count > 1 ? ` x${count}` : ""}`) + .join(";"); + setSaveState(`AI修改全部剩余项完成:成功${success},失败${failed}${reasonText ? `,失败原因:${reasonText}` : ""}`); +} + +async function boot() { + bindEvents(); + await loadSession(); + await loadSummary(); + await findProcessingItems(true); + if (state.permissions.review !== false) await loadItems(true); + state.pgTimer = setInterval(() => testPostgres(true).catch(() => {}), 20000); +} + +boot().catch((error) => { + setSaveState("加载失败"); + $("#queueList").innerHTML = `
${escapeHtml(error.message)}
`; +}); diff --git a/患者列表处理/人工复核网页端/templates/index.html b/患者列表处理/人工复核网页端/templates/index.html new file mode 100644 index 0000000..d07003a --- /dev/null +++ b/患者列表处理/人工复核网页端/templates/index.html @@ -0,0 +1,382 @@ + + + + + + 患者目录图片集群人工复核及抽查 + + + +
+
+
HIS LIST REVIEW
+

患者目录图片集群人工复核及抽查

+
+ +
+ + + + +
+ +
+
+
+ +
+
+
+

复核工作台

+
+
+
+

抽查一览工作台

+
+
+
+ +
+ + +
+
核对与同步都以住院号作为唯一定位依据,姓名和科室只作辅助参考
+
+
+
未选择记录
+
+
+ 原图 +
+
+ 裁剪截图 +
+
+ + + + 100% +
+
+
+ +
+
+
+ + + + +
+ +
+ + + + +
+ +
+ +
+ + + +
+
+
+
+ +
+
+ + + +
+
AI智能抽查
+
+ + +
+
+ +
+
+
+
+
+
+
未选择抽查记录
+
+
+ 原图 +
+
+
+ 抽查截图 +
+
+
+ + + + 100% +
+
+ AI结论与建议 +
+
+
+ 询问提示词 +

+          
+
+ AI完整输出 +

+          
+ +
+ +
+
+ +
+
+ + +
+
+ +
+
+
+
未选择抽查记录
+
+
+ 原图 +
+
+
+ 抽查截图 +
+
+
+ + + + 100% +
+
+ AI结论与建议 +
+
+
+ AI完整输出 +

+          
+
+ +
+
+ +
+
+
+
+

用户与权限

+ 0 个用户 +
+
+
+ + +
+
+
页面权限
+
+
+ +
+
+
+ + +
+
+ + + + diff --git a/患者列表处理/人工复核网页端/templates/login.html b/患者列表处理/人工复核网页端/templates/login.html new file mode 100644 index 0000000..6ce5ef1 --- /dev/null +++ b/患者列表处理/人工复核网页端/templates/login.html @@ -0,0 +1,29 @@ + + + + + + 患者目录图片集群人工复核及抽查 + + + +
+ +

患者目录图片集群人工复核及抽查

+ +
+ + diff --git a/患者列表处理/工作流_Gitea版.md b/患者列表处理/工作流_Gitea版.md new file mode 100644 index 0000000..2cefa05 --- /dev/null +++ b/患者列表处理/工作流_Gitea版.md @@ -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 配置。 diff --git a/患者列表处理/数据处理工作区/01_科室分类规则.json b/患者列表处理/数据处理工作区/01_科室分类规则.json new file mode 100644 index 0000000..da4c420 --- /dev/null +++ b/患者列表处理/数据处理工作区/01_科室分类规则.json @@ -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", + "健苑五涉外": "健苑五涉外病房", + "健苑五涉外病房": "健苑五涉外病房", + "老年外科": "老年外科" + } +} diff --git a/患者列表处理/数据处理工作区/02_患者列表OCR归档.py b/患者列表处理/数据处理工作区/02_患者列表OCR归档.py new file mode 100644 index 0000000..c91fe50 --- /dev/null +++ b/患者列表处理/数据处理工作区/02_患者列表OCR归档.py @@ -0,0 +1,1346 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Archive HIS patient-list screenshots into structured JSON. + +Tencent OCR credentials are read from environment variables: +TENCENTCLOUD_SECRET_ID and TENCENTCLOUD_SECRET_KEY. +""" + +from __future__ import annotations + +import argparse +import base64 +import concurrent.futures +import csv +import datetime as dt +import hashlib +import hmac +import json +import os +import re +import signal +import subprocess +import threading +import time +import unicodedata +import urllib.error +import urllib.request +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from PIL import Image + + +COLUMNS = [ + "姓名", + "性别", + "年龄", + "住院号", + "诊断", + "入院时间", + "最后书写时间", + "住院天数", + "出院时间", + "手术后天数", +] + +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp"} +OCR_ENGINE_LABELS = { + "table-v3": "腾讯云 RecognizeTableAccurateOCR 表格识别 V3", +} +GENERAL_COLUMN_ANCHORS = [0, 96, 160, 230, 450, 820, 1075, 1305, 1395, 1590] +GENERAL_REFERENCE_WIDTH = 1842 +GENERAL_ROW_Y_THRESHOLD = 18 + + +class NonRetryableOcrError(RuntimeError): + pass + + +@dataclass(frozen=True) +class Department: + major: str + sub: str + raw_name: str + + +def natural_key(path: Path) -> tuple[Any, ...]: + parts = re.split(r"(\d+)", path.stem) + key: list[Any] = [] + for part in parts: + if part.isdigit(): + key.append(int(part)) + else: + key.append(part) + return tuple(key) + + +def normalize_text(value: Any) -> str: + if value is None: + return "" + text = unicodedata.normalize("NFKC", str(value)) + text = text.replace("\u3000", " ") + return re.sub(r"\s+", " ", text).strip() + + +def normalize_folder_name(folder_name: str) -> str: + name = re.sub(r"^\d{4}[_-]\d{1,2}[_-]\d{1,2}~\d{4}[_-]\d{1,2}[_-]\d{1,2}_", "", folder_name) + name = re.sub(r"^\d{4}[_-]\d{1,2}[_-]\d{1,2}_", "", name) + name = re.sub(r"^\d{4}年\d{1,2}月\d{1,2}日[_-]?", "", name) + name = re.sub(r"病房|病区", "", name) + return normalize_text(name) + + +def load_departments(path: Path) -> tuple[dict[str, str], dict[str, str]]: + data = json.loads(path.read_text(encoding="utf-8")) + sub_to_major: dict[str, str] = {} + for group in data["大科室列表"]: + major = group["大科室"] + for sub in group["子科室"]: + if sub in sub_to_major: + raise ValueError(f"重复子科室: {sub}") + sub_to_major[sub] = major + + aliases: dict[str, str] = {} + for alias, sub in data.get("aliases", {}).items(): + if sub not in sub_to_major: + raise ValueError(f"别名 {alias} 指向不存在的子科室 {sub}") + aliases[normalize_text(alias)] = sub + for sub in sub_to_major: + aliases[normalize_text(sub)] = sub + return sub_to_major, aliases + + +def classify_department(folder_name: str, sub_to_major: dict[str, str], aliases: dict[str, str]) -> Department: + raw = normalize_folder_name(folder_name) + if raw in aliases: + sub = aliases[raw] + return Department(sub_to_major[sub], sub, raw) + + compact = raw.replace("科", "").replace("外", "外") + for alias, sub in sorted(aliases.items(), key=lambda item: len(item[0]), reverse=True): + alias_compact = alias.replace("科", "") + if alias and (alias in raw or alias_compact in compact): + return Department(sub_to_major[sub], sub, raw) + return Department("未分类", "未分类", raw) + + +def batched(items: list[Path], size: int) -> list[list[Path]]: + return [items[i : i + size] for i in range(0, len(items), size)] + + +def merge_images(image_paths: list[Path], output_path: Path, padding_y: int = 0) -> dict[str, Any]: + padding_y = max(0, int(padding_y)) + opened = [Image.open(path).convert("RGB") for path in image_paths] + try: + width = max(image.width for image in opened) + height = sum(image.height + padding_y * 2 for image in opened) + merged = Image.new("RGB", (width, height), "white") + y = 0 + source_images = [] + for path, image in zip(image_paths, opened): + image_y = y + padding_y + merged.paste(image, (0, image_y)) + source_images.append( + { + "path": str(path), + "name": path.name, + "width": image.width, + "height": image.height, + "y_offset": image_y, + "block_y_offset": y, + "block_height": image.height + padding_y * 2, + "padding_top": padding_y, + "padding_bottom": padding_y, + } + ) + y += image.height + padding_y * 2 + output_path.parent.mkdir(parents=True, exist_ok=True) + merged.save(output_path) + return { + "path": str(output_path), + "width": width, + "height": height, + "padding_y": padding_y, + "source_images": source_images, + } + finally: + for image in opened: + image.close() + + +def infer_rows_for_image(image_path: Path, override_rows: int = 0) -> int: + if override_rows > 0: + return override_rows + with Image.open(image_path) as image: + # HIS list screenshots in this batch use about 39 px per row: + # 700 px -> 18 rows, 784 px -> 20 rows. + return max(1, round(image.height / 39.0)) + + +def locate_source_row(table_row_index: int, row_counts: list[int]) -> tuple[int, int]: + offset = 0 + for image_index, row_count in enumerate(row_counts): + if table_row_index < offset + row_count: + return image_index, table_row_index - offset + offset += row_count + return len(row_counts) - 1, max(0, table_row_index - sum(row_counts[:-1])) + + +def get_credentials() -> tuple[str, str]: + secret_id = os.getenv("TENCENTCLOUD_SECRET_ID") or os.getenv("TENCENT_SECRET_ID") + secret_key = os.getenv("TENCENTCLOUD_SECRET_KEY") or os.getenv("TENCENT_SECRET_KEY") + if not secret_id or not secret_key: + raise RuntimeError("请先设置 TENCENTCLOUD_SECRET_ID 和 TENCENTCLOUD_SECRET_KEY 环境变量") + return secret_id, secret_key + + +def tc3_request( + action: str, + payload: dict[str, Any], + secret_id: str, + secret_key: str, + region: str, + timeout: int, +) -> dict[str, Any]: + service = "ocr" + host = "ocr.tencentcloudapi.com" + endpoint = f"https://{host}" + version = "2018-11-19" + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + algorithm = "TC3-HMAC-SHA256" + timestamp = int(dt.datetime.now(dt.timezone.utc).timestamp()) + date = dt.datetime.fromtimestamp(timestamp, dt.timezone.utc).strftime("%Y-%m-%d") + content_type = "application/json; charset=utf-8" + + canonical_headers = f"content-type:{content_type}\nhost:{host}\n" + signed_headers = "content-type;host" + hashed_payload = hashlib.sha256(body.encode("utf-8")).hexdigest() + canonical_request = "\n".join(["POST", "/", "", canonical_headers, signed_headers, hashed_payload]) + credential_scope = f"{date}/{service}/tc3_request" + string_to_sign = "\n".join( + [ + algorithm, + str(timestamp), + credential_scope, + hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(), + ] + ) + + def sign(key: bytes, message: str) -> bytes: + return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest() + + secret_date = sign(("TC3" + secret_key).encode("utf-8"), date) + secret_service = sign(secret_date, service) + secret_signing = sign(secret_service, "tc3_request") + signature = hmac.new(secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() + authorization = ( + f"{algorithm} Credential={secret_id}/{credential_scope}, " + f"SignedHeaders={signed_headers}, Signature={signature}" + ) + + headers = { + "Authorization": authorization, + "Content-Type": content_type, + "Host": host, + "X-TC-Action": action, + "X-TC-Timestamp": str(timestamp), + "X-TC-Version": version, + "X-TC-Region": region, + } + command = [ + "curl", + "-sS", + "--connect-timeout", + str(min(10, max(1, timeout))), + "--max-time", + str(max(1, timeout)), + "-X", + "POST", + endpoint, + ] + for key, value in headers.items(): + command.extend(["-H", f"{key}: {value}"]) + command.extend(["--data-binary", "@-"]) + completed = subprocess.run( + command, + input=body.encode("utf-8"), + capture_output=True, + timeout=max(1, timeout) + 5, + check=False, + ) + if completed.returncode != 0: + error_text = completed.stderr.decode("utf-8", errors="replace").strip() + raise urllib.error.URLError(error_text or f"curl return code {completed.returncode}") + return json.loads(completed.stdout.decode("utf-8")) + + +@contextmanager +def wall_clock_timeout(seconds: int): + if seconds <= 0 or threading.current_thread() is not threading.main_thread(): + yield + return + + def handle_timeout(_signum: int, _frame: Any) -> None: + raise TimeoutError(f"OCR请求超过 {seconds} 秒") + + previous_handler = signal.getsignal(signal.SIGALRM) + previous_timer = signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, handle_timeout) + signal.setitimer(signal.ITIMER_REAL, seconds) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous_handler) + if previous_timer[0] > 0: + signal.setitimer(signal.ITIMER_REAL, previous_timer[0], previous_timer[1]) + + +def call_tencent_ocr( + action: str, + image_path: Path, + cache_path: Path, + secret_id: str, + secret_key: str, + region: str, + timeout: int, + force: bool, + max_retries: int, +) -> dict[str, Any]: + if cache_path.exists() and not force: + return json.loads(cache_path.read_text(encoding="utf-8")) + if not secret_id or not secret_key: + raise RuntimeError(f"OCR缓存不存在,且当前为仅重建模式: {cache_path}") + + image_base64 = base64.b64encode(image_path.read_bytes()).decode("ascii") + payload = {"ImageBase64": image_base64, "UseNewModel": True} + if action == "GeneralAccurateOCR": + payload = {"ImageBase64": image_base64} + last_error: str | None = None + for attempt in range(max_retries + 1): + try: + with wall_clock_timeout(timeout): + data = tc3_request(action, payload, secret_id, secret_key, region, timeout) + response = data.get("Response", {}) + if "Error" in response: + error_text = json.dumps(response["Error"], ensure_ascii=False) + if response["Error"].get("Code") == "FailedOperation.OcrFailed": + raise NonRetryableOcrError(error_text) + raise RuntimeError(error_text) + response.pop("Data", None) + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(json.dumps(response, ensure_ascii=False, indent=2), encoding="utf-8") + return response + except NonRetryableOcrError: + raise + except (urllib.error.URLError, TimeoutError, OSError, RuntimeError) as exc: + last_error = str(exc) + if attempt >= max_retries: + break + time.sleep(2 ** attempt) + raise RuntimeError(f"OCR 调用失败: {image_path} {last_error}") + + +def call_table_v3( + image_path: Path, + cache_path: Path, + secret_id: str, + secret_key: str, + region: str, + timeout: int, + force: bool, + max_retries: int, +) -> dict[str, Any]: + return call_tencent_ocr( + "RecognizeTableAccurateOCR", + image_path, + cache_path, + secret_id, + secret_key, + region, + timeout, + force, + max_retries, + ) + + +def call_general_accurate( + image_path: Path, + cache_path: Path, + secret_id: str, + secret_key: str, + region: str, + timeout: int, + force: bool, + max_retries: int, +) -> dict[str, Any]: + return call_tencent_ocr( + "GeneralAccurateOCR", + image_path, + cache_path, + secret_id, + secret_key, + region, + timeout, + force, + max_retries, + ) + + +def cells_to_rows(table_response: dict[str, Any]) -> list[list[str]]: + tables = table_response.get("TableDetections") or [] + cells: list[dict[str, Any]] = [] + for table in tables: + cells.extend(table.get("Cells") or []) + if not cells: + return [] + + max_row = max(int(cell.get("RowTl", 0)) for cell in cells) + max_col = max(int(cell.get("ColTl", 0)) for cell in cells) + rows = [["" for _ in range(max(max_col + 1, len(COLUMNS)))] for _ in range(max_row + 1)] + for cell in cells: + row = int(cell.get("RowTl", 0)) + col = int(cell.get("ColTl", 0)) + text = normalize_text(cell.get("Text", "")) + if col >= len(rows[row]): + rows[row].extend([""] * (col - len(rows[row]) + 1)) + if rows[row][col]: + rows[row][col] = normalize_text(rows[row][col] + " " + text) + else: + rows[row][col] = text + return [row[: len(COLUMNS)] + [""] * max(0, len(COLUMNS) - len(row)) for row in rows] + + +def general_column_index(x: float, image_width: int) -> int: + scale = image_width / GENERAL_REFERENCE_WIDTH if image_width else 1.0 + anchors = [value * scale for value in GENERAL_COLUMN_ANCHORS] + boundaries = [(anchors[index] + anchors[index + 1]) / 2 for index in range(len(anchors) - 1)] + for index, boundary in enumerate(boundaries): + if x < boundary: + return index + return len(anchors) - 1 + + +def general_detections_to_rows(general_response: dict[str, Any], image_width: int) -> list[list[str]]: + detections: list[dict[str, Any]] = [] + for item in general_response.get("TextDetections") or []: + text = normalize_text(item.get("DetectedText", "")) + if not text: + continue + polygon = item.get("ItemPolygon") or {} + x = float(polygon.get("X", 0) or 0) + y = float(polygon.get("Y", 0) or 0) + height = float(polygon.get("Height", 0) or 0) + detections.append({"x": x, "y_center": y + height / 2, "text": text}) + + detections.sort(key=lambda item: (item["y_center"], item["x"])) + grouped_rows: list[dict[str, Any]] = [] + for detection in detections: + if not grouped_rows or abs(detection["y_center"] - grouped_rows[-1]["y_center"]) > GENERAL_ROW_Y_THRESHOLD: + grouped_rows.append({"y_center": detection["y_center"], "items": []}) + else: + count = len(grouped_rows[-1]["items"]) + grouped_rows[-1]["y_center"] = (grouped_rows[-1]["y_center"] * count + detection["y_center"]) / (count + 1) + grouped_rows[-1]["items"].append(detection) + + rows: list[list[str]] = [] + for grouped_row in grouped_rows: + columns: list[list[str]] = [[] for _ in COLUMNS] + for detection in sorted(grouped_row["items"], key=lambda item: item["x"]): + columns[general_column_index(detection["x"], image_width)].append(detection["text"]) + rows.append([normalize_text(" ".join(values)) for values in columns]) + return rows + + +def ocr_response_to_rows(response: dict[str, Any], engine: str, image_width: int) -> list[list[str]]: + if engine == "general-accurate": + return general_detections_to_rows(response, image_width) + return cells_to_rows(response) + + +def normalize_date(text: str) -> str: + text = normalize_text(text).replace("/", "-").replace(".", "-") + text = re.sub(r"(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})", date_repl, text) + return text + + +def date_repl(match: re.Match[str]) -> str: + year, month, day, hour, minute, second = match.groups() + return f"{int(year):04d}-{int(month):02d}-{int(day):02d} {int(hour):02d}:{int(minute):02d}:{int(second):02d}" + + +def clean_patient_row(row: list[str]) -> dict[str, Any]: + values = {column: normalize_text(row[index]) for index, column in enumerate(COLUMNS)} + if values["姓名"] and not values["性别"]: + name_gender = re.fullmatch(r"(.+?)(男|女)", values["姓名"]) + if name_gender: + values["姓名"] = normalize_text(name_gender.group(1)) + values["性别"] = name_gender.group(2) + if values["性别"] not in {"男", "女"}: + sex_age = re.fullmatch(r"(男|女)\s*(\d{1,3}岁)", values["性别"]) + if sex_age: + values["性别"] = sex_age.group(1) + if not values["年龄"]: + values["年龄"] = sex_age.group(2) + values["住院号"] = re.sub(r"\s+", "", values["住院号"]).upper() + if values["住院号"].startswith("ZV"): + values["住院号"] = "ZY" + values["住院号"][2:] + if values["住院号"].startswith("ZYS"): + values["住院号"] = "ZY5" + values["住院号"][3:] + if len(values["住院号"]) > 2: + prefix, number = values["住院号"][:2], values["住院号"][2:] + values["住院号"] = prefix + number.translate(str.maketrans({"O": "0", "I": "1", "L": "1", "S": "5"})) + def looks_datetime(value: Any) -> bool: + return bool( + re.fullmatch( + r"\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2}", + str(value), + ) + ) + + def looks_days(value: Any) -> bool: + return bool(re.fullmatch(r"\d+", str(value))) + + def looks_postop(value: Any) -> bool: + return bool(re.fullmatch(r"后\d+天", str(value))) + + values["入院时间"] = normalize_date(values["入院时间"]) + values["最后书写时间"] = normalize_date(values["最后书写时间"]) + values["出院时间"] = normalize_date(values["出院时间"]) + shifted_by_long_diagnosis = re.search( + r"(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2})$", + values["诊断"], + ) + if ( + shifted_by_long_diagnosis + and looks_datetime(values["入院时间"]) + and looks_days(values["最后书写时间"]) + and (not values["住院天数"] or looks_datetime(values["住院天数"]) or looks_postop(values["住院天数"])) + ): + shifted_admission = normalize_date(shifted_by_long_diagnosis.group(1)) + old_last_write = values["入院时间"] + old_hospital_days = values["最后书写时间"] + old_discharge_or_postop = values["住院天数"] + old_postop = values["出院时间"] + values["诊断"] = values["诊断"][: shifted_by_long_diagnosis.start()].strip() + values["入院时间"] = shifted_admission + values["最后书写时间"] = normalize_date(old_last_write) + values["住院天数"] = old_hospital_days + values["出院时间"] = normalize_date(old_discharge_or_postop) if looks_datetime(old_discharge_or_postop) else "" + if not values["手术后天数"]: + if looks_postop(old_discharge_or_postop): + values["手术后天数"] = old_discharge_or_postop + elif looks_postop(old_postop): + values["手术后天数"] = old_postop + if shifted_by_long_diagnosis and not values["入院时间"]: + values["诊断"] = values["诊断"][: shifted_by_long_diagnosis.start()].strip() + values["入院时间"] = normalize_date(shifted_by_long_diagnosis.group(1)) + + days_with_discharge = re.fullmatch( + r"(\d+)\s+(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2})", + str(values["住院天数"]), + ) + if days_with_discharge: + values["住院天数"] = days_with_discharge.group(1) + if not values["出院时间"]: + values["出院时间"] = normalize_date(days_with_discharge.group(2)) + + if looks_postop(values["出院时间"]) and not values["手术后天数"]: + values["手术后天数"] = values["出院时间"] + values["出院时间"] = "" + if looks_postop(values["住院天数"]) and not values["手术后天数"]: + values["手术后天数"] = values["住院天数"] + values["住院天数"] = "" + + if looks_days(values["最后书写时间"]) and looks_datetime(values["住院天数"]): + old_days = values["最后书写时间"] + old_discharge = values["住院天数"] + old_postop = values["出院时间"] + values["最后书写时间"] = "" + values["住院天数"] = old_days + values["出院时间"] = normalize_date(old_discharge) + if looks_postop(old_postop) and not values["手术后天数"]: + values["手术后天数"] = old_postop + elif looks_datetime(values["住院天数"]) and (not values["出院时间"] or looks_postop(values["出院时间"])): + old_discharge = values["住院天数"] + old_postop = values["出院时间"] + values["住院天数"] = "" + values["出院时间"] = normalize_date(old_discharge) + if looks_postop(old_postop) and not values["手术后天数"]: + values["手术后天数"] = old_postop + + last_write_with_postop = re.fullmatch(r"(.+?)\s+(后\d+天)", values["最后书写时间"]) + if last_write_with_postop and not values["手术后天数"]: + values["最后书写时间"] = last_write_with_postop.group(1) + values["手术后天数"] = last_write_with_postop.group(2) + if re.fullmatch(r"后\d+天", values["最后书写时间"]) and not values["手术后天数"]: + values["手术后天数"] = values["最后书写时间"] + values["最后书写时间"] = "" + if values["住院天数"].isdigit(): + values["住院天数"] = int(values["住院天数"]) + return values + + +def validate_patient_row(values: dict[str, Any]) -> list[str]: + warnings: list[str] = [] + if not values.get("姓名"): + warnings.append("缺少姓名") + if values.get("性别") not in {"男", "女"}: + warnings.append("性别异常") + if values.get("年龄") and not re.fullmatch(r"\d{1,3}岁", str(values["年龄"])): + warnings.append("年龄格式异常") + if not normalize_text(values.get("住院号", "")): + warnings.append("缺少住院号") + if not values.get("入院时间"): + warnings.append("缺少入院时间") + elif not re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(values["入院时间"])): + warnings.append("入院时间格式异常") + if values.get("出院时间") and not re.fullmatch( + r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(values["出院时间"]) + ): + warnings.append("出院时间格式异常") + if ( + re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(values.get("入院时间", ""))) + and re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(values.get("出院时间", ""))) + and str(values["入院时间"]) > str(values["出院时间"]) + ): + warnings.append("出院时间早于入院时间") + if values.get("最后书写时间") and not re.fullmatch( + r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(values["最后书写时间"]) + ): + warnings.append("最后书写时间格式异常") + if values.get("住院天数") != "" and not isinstance(values.get("住院天数"), int): + warnings.append("住院天数格式异常") + return warnings + + +def is_blank_or_footer(row: dict[str, Any]) -> bool: + if row.get("住院号"): + return False + filled = [value for value in row.values() if value not in ("", None)] + return len(filled) == 0 + + +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 write_csv(path: Path, records: list[dict[str, Any]]) -> None: + fieldnames = [ + "大科室", + "子科室", + "来源文件夹", + "图片名", + "图片内行号", + *COLUMNS, + "复核状态", + "复核提示", + ] + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as file: + writer = csv.DictWriter(file, fieldnames=fieldnames) + writer.writeheader() + for record in records: + patient = record["患者信息"] + writer.writerow( + { + "大科室": record["大科室"], + "子科室": record["子科室"], + "来源文件夹": record["来源文件夹"], + "图片名": record["图片信息"]["图片名"], + "图片内行号": record["图片信息"]["图片内行号"], + **patient, + "复核状态": record["复核"]["状态"], + "复核提示": ";".join(record["复核"]["提示"]), + } + ) + + +def load_corrections(path: Path) -> dict[tuple[str, int], dict[str, Any]]: + if not path.exists(): + return {} + data = json.loads(path.read_text(encoding="utf-8")) + corrections: dict[tuple[str, int], dict[str, Any]] = {} + for item in data: + image_path = item["图片路径"] + row_no = int(item["图片内行号"]) + corrections[(image_path, row_no)] = item + for prefix in ["已处理-患者目录图片集群/", "待处理-患者目录图片集群/"]: + corrections[(prefix + image_path, row_no)] = item + return corrections + + +def apply_review_options(warnings: list[str], correction: dict[str, Any]) -> list[str]: + return warnings + + +def apply_corrections(records: list[dict[str, Any]], corrections: dict[tuple[str, int], dict[str, Any]]) -> None: + for record in records: + key = (record["图片信息"]["图片路径"], int(record["图片信息"]["图片内行号"])) + if key not in corrections: + record["复核"]["提示"] = validate_patient_row(record["患者信息"]) + record["复核"]["状态"] = "需人工复核" if record["复核"]["提示"] else "自动复核通过" + continue + correction = corrections[key] + record["患者信息"].update(correction.get("患者信息", {})) + record["复核"]["人工修正"] = True + record["复核"]["复核选项"] = correction.get("复核选项", {}) + if correction.get("复核备注"): + record["复核"]["人工备注"] = correction.get("复核备注", "") + record["复核"]["提示"] = apply_review_options(validate_patient_row(record["患者信息"]), correction) + record["复核"]["状态"] = "需人工复核" if record["复核"]["提示"] else "人工复核通过" + + +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 column in COLUMNS if patient.get(column) 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["图片信息"] + return { + "大科室": record["大科室"], + "子科室": record["子科室"], + "来源文件夹": record["来源文件夹"], + "图片路径": image["图片路径"], + "图片名": image["图片名"], + "图片内行号": image["图片内行号"], + "姓名": patient.get("姓名", ""), + "住院号": patient.get("住院号", ""), + "入院时间": patient.get("入院时间", ""), + "出院时间": patient.get("出院时间", ""), + "复核状态": record["复核"].get("状态", ""), + } + + +def deduplicate_records_by_inpatient_no( + records: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + kept_by_no: dict[str, dict[str, Any]] = {} + kept_order: list[str] = [] + dropped: list[dict[str, Any]] = [] + for record in records: + inpatient_no = normalize_text(record["患者信息"].get("住院号", "")) + if not inpatient_no: + dropped.append( + { + "住院号": "", + "保留记录": {}, + "剔除记录": summarize_record_for_duplicate(record), + "规则": "住院号为空,未纳入归档结果和数据库", + } + ) + continue + if inpatient_no not in kept_by_no: + kept_order.append(inpatient_no) + else: + dropped.append( + { + "住院号": inpatient_no, + "保留记录": summarize_record_for_duplicate(record), + "剔除记录": summarize_record_for_duplicate(kept_by_no[inpatient_no]), + "规则": "住院号重复,后出现记录覆盖先出现记录", + } + ) + kept_by_no[inpatient_no] = record + return [kept_by_no[inpatient_no] for inpatient_no in kept_order], dropped + + +def process_folder( + folder: Path, + output_root: Path, + department: Department, + args: argparse.Namespace, + secret_id: str, + secret_key: str, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + images = sorted( + [path for path in folder.iterdir() if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS], + key=natural_key, + ) + folder_key = folder.name + composites_dir = output_root / "merged_images" / folder_key + raw_dir = output_root / "raw_ocr" / folder_key + records: list[dict[str, Any]] = [] + folder_warnings: list[str] = [] + group_infos: list[dict[str, Any]] = [] + + def cache_label(label: str) -> str: + if args.ocr_engine != "table-v3": + label = f"{args.ocr_engine.replace('-', '_')}_{label}" + if args.image_padding_y > 0: + return f"{label}_pady{args.image_padding_y}" + return label + + def records_from_rows( + rows: list[list[str]], + group_paths: list[Path], + group_index_value: int, + composite_path_value: Path, + cache_path_value: Path, + request_id: str | None, + ) -> list[dict[str, Any]]: + built_records: list[dict[str, Any]] = [] + row_counts = [infer_rows_for_image(path, args.rows_per_image) for path in group_paths] + for table_row_index, row in enumerate(rows): + patient = clean_patient_row(row) + if is_blank_or_footer(patient): + continue + source_index, image_row = locate_source_row(table_row_index, row_counts) + source_path = group_paths[source_index] + warnings = validate_patient_row(patient) + record = { + "大科室": department.major, + "子科室": department.sub, + "来源文件夹": folder.name, + "标准化文件夹科室名": department.raw_name, + "患者信息": patient, + "图片信息": { + "图片路径": str(source_path), + "图片名": source_path.name, + "图片序号": natural_key(source_path), + "图片内行号": image_row + 1, + "拼接组序号": group_index_value, + "拼接图片路径": str(composite_path_value), + "OCR缓存路径": str(cache_path_value), + "OCR请求ID": request_id, + }, + "复核": { + "状态": "需人工复核" if warnings else "自动复核通过", + "提示": warnings, + }, + } + built_records.append(record) + return built_records + + def expected_row_count(group_paths: list[Path]) -> int: + return sum(infer_rows_for_image(path, args.rows_per_image) for path in group_paths) + + def attempt_ocr_group( + group_paths: list[Path], + group_index_value: int, + label: str, + display_label: str, + prebuilt_merge_info: dict[str, Any] | None = None, + ) -> tuple[dict[str, Any], list[dict[str, Any]]]: + composite_path = composites_dir / f"{label}.png" + merge_info = prebuilt_merge_info or merge_images(group_paths, composite_path, args.image_padding_y) + cache_path = raw_dir / f"{label}.json" + print(f" {display_label}: {len(group_paths)} images -> {composite_path}", flush=True) + if args.ocr_engine == "general-accurate": + response = call_general_accurate( + composite_path, + cache_path, + secret_id, + secret_key, + args.region, + args.timeout, + args.force, + args.max_retries, + ) + else: + response = call_table_v3( + composite_path, + cache_path, + secret_id, + secret_key, + args.region, + args.timeout, + args.force, + args.max_retries, + ) + rows = ocr_response_to_rows(response, args.ocr_engine, int(merge_info.get("width", 0))) + expected_rows = expected_row_count(group_paths) + if len(group_paths) > 1 and len(rows) < expected_rows: + raise RuntimeError(f"识别行数偏少: {len(rows)} / {expected_rows}") + request_id = response.get("RequestId") + print(f" rows: {len(rows)} request: {request_id}", flush=True) + info = { + "label": label, + "merged_image": merge_info, + "ocr_cache": str(cache_path), + "ocr_request_id": request_id, + "row_count": len(rows), + "expected_row_count": expected_rows, + "image_count": len(group_paths), + } + return info, records_from_rows(rows, group_paths, group_index_value, composite_path, cache_path, request_id) + + def run_chunked_fallback( + group: list[Path], + group_index_value: int, + main_label: str, + fallback_size: int, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + candidate_infos: list[dict[str, Any]] = [] + candidate_records: list[dict[str, Any]] = [] + chunks = list(enumerate(batched(group, fallback_size))) + + def run_chunk(item: tuple[int, list[Path]]) -> tuple[dict[str, Any], list[dict[str, Any]]]: + chunk_index, chunk = item + chunk_label = f"{main_label}_fallback{fallback_size}_{chunk_index:02d}" + chunk_info, chunk_records = attempt_ocr_group( + chunk, + group_index_value, + chunk_label, + chunk_label, + ) + chunk_info["part_index"] = chunk_index + return chunk_info, chunk_records + + if args.workers > 1 and len(chunks) > 1: + with concurrent.futures.ThreadPoolExecutor(max_workers=min(args.workers, len(chunks))) as executor: + results = list(executor.map(run_chunk, chunks)) + else: + results = [run_chunk(item) for item in chunks] + + for chunk_info, chunk_records in results: + candidate_infos.append(chunk_info) + candidate_records.extend(chunk_records) + return candidate_infos, candidate_records + + def single_cache_paths(main_label: str, group: list[Path]) -> list[Path]: + return [raw_dir / f"{main_label}_part_{part_index:02d}.json" for part_index in range(len(group))] + + def chunk_cache_paths(main_label: str, group: list[Path], fallback_size: int) -> list[Path]: + chunks = batched(group, fallback_size) + return [raw_dir / f"{main_label}_fallback{fallback_size}_{chunk_index:02d}.json" for chunk_index in range(len(chunks))] + + def run_single_fallback( + group: list[Path], + group_index_value: int, + main_label: str, + main_merge_info: dict[str, Any], + main_cache_path: Path, + initial_error: str, + fallback_errors: list[str], + adaptive_from_previous: bool = False, + ) -> None: + print(f" 尝试单张OCR回退", flush=True) + single_infos: list[dict[str, Any]] = [] + for part_index, single_path in enumerate(group): + single_label = f"{main_label}_part_{part_index:02d}" + single_composite_path = composites_dir / f"{single_label}.png" + single_cache_path = raw_dir / f"{single_label}.json" + single_merge_info = merge_images([single_path], single_composite_path, args.image_padding_y) + try: + single_info, single_records = attempt_ocr_group( + [single_path], + group_index_value, + single_label, + single_label, + single_merge_info, + ) + single_info["part_index"] = part_index + records.extend(single_records) + single_infos.append(single_info) + except RuntimeError as single_exc: + single_message = f"{single_label} OCR失败: {single_exc}" + print(f" {single_message}", flush=True) + folder_warnings.append(single_message) + single_infos.append( + { + "label": single_label, + "part_index": part_index, + "merged_image": single_merge_info, + "ocr_cache": str(single_cache_path), + "ocr_request_id": None, + "row_count": 0, + "expected_row_count": expected_row_count([single_path]), + "image_count": 1, + "error": str(single_exc), + } + ) + group_infos.append( + { + "group_index": group_index_value, + "label": main_label, + "merged_image": main_merge_info, + "ocr_cache": str(main_cache_path), + "ocr_request_id": None, + "row_count": sum(item["row_count"] for item in single_infos), + "expected_row_count": expected_row_count(group), + "fallback": True, + "fallback_strategy": "single_image", + "fallback_parts": single_infos, + "initial_error": initial_error, + "fallback_errors": fallback_errors, + "adaptive_from_previous": adaptive_from_previous, + } + ) + + groups = batched(images, args.batch_size) + if args.limit_groups_per_folder: + groups = groups[: args.limit_groups_per_folder] + + preferred_fallback_size: int | None = None + preferred_fallback_failures = 0 + for group_index, group in enumerate(groups): + main_label = cache_label(f"group_{group_index:04d}") + main_composite_path = composites_dir / f"{main_label}.png" + main_cache_path = raw_dir / f"{main_label}.json" + main_merge_info = merge_images(group, main_composite_path, args.image_padding_y) + part_cache_paths = single_cache_paths(main_label, group) + + if not main_cache_path.exists() and all(path.exists() for path in part_cache_paths): + message = f"{main_label} 使用已有单图缓存重建" + print(f" {message}", flush=True) + run_single_fallback( + group, + group_index, + main_label, + main_merge_info, + main_cache_path, + message, + [], + adaptive_from_previous=True, + ) + time.sleep(args.sleep) + continue + + if preferred_fallback_size and len(group) > 1: + use_preferred_fallback = True + if args.rebuild_from_cache: + if preferred_fallback_size == 1: + preferred_paths = single_cache_paths(main_label, group) + else: + preferred_paths = chunk_cache_paths(main_label, group, int(preferred_fallback_size)) + use_preferred_fallback = all(path.exists() for path in preferred_paths) + if not use_preferred_fallback: + print(f" {main_label} 当前回退缓存不完整,先尝试主缓存", flush=True) + if not use_preferred_fallback: + preferred_fallback_size = None + elif preferred_fallback_size == 1: + message = f"{main_label} 依据前序组结果,直接使用单张OCR" + print(f" {message}", flush=True) + run_single_fallback( + group, + group_index, + main_label, + main_merge_info, + main_cache_path, + message, + [], + adaptive_from_previous=True, + ) + time.sleep(args.sleep) + continue + elif 1 < preferred_fallback_size < len(group): + message = f"{main_label} 依据前序组结果,直接使用 {preferred_fallback_size} 张拼接" + print(f" {message}", flush=True) + try: + candidate_infos, candidate_records = run_chunked_fallback( + group, + group_index, + main_label, + int(preferred_fallback_size), + ) + records.extend(candidate_records) + group_infos.append( + { + "group_index": group_index, + "label": main_label, + "merged_image": main_merge_info, + "ocr_cache": str(main_cache_path), + "ocr_request_id": None, + "row_count": sum(item["row_count"] for item in candidate_infos), + "expected_row_count": expected_row_count(group), + "fallback": True, + "fallback_strategy": f"{preferred_fallback_size}_images", + "fallback_parts": candidate_infos, + "initial_error": message, + "fallback_errors": [], + "adaptive_from_previous": True, + } + ) + time.sleep(args.sleep) + preferred_fallback_failures = 0 + continue + except RuntimeError as adaptive_exc: + failed_size = int(preferred_fallback_size) + preferred_fallback_failures += 1 + adaptive_message = f"{main_label} 前序 {failed_size} 张策略未通过,本组改用单张OCR: {adaptive_exc}" + print(f" {adaptive_message}", flush=True) + folder_warnings.append(adaptive_message) + if preferred_fallback_failures >= args.fallback_demote_threshold: + preferred_fallback_size = 1 + demote_message = ( + f"{main_label} 连续 {preferred_fallback_failures} 个拼接组未通过," + "后续直接使用单张OCR" + ) + print(f" {demote_message}", flush=True) + folder_warnings.append(demote_message) + else: + preferred_fallback_size = failed_size + run_single_fallback( + group, + group_index, + main_label, + main_merge_info, + main_cache_path, + adaptive_message, + [], + adaptive_from_previous=True, + ) + time.sleep(args.sleep) + continue + try: + main_info, main_records = attempt_ocr_group( + group, + group_index, + main_label, + main_label, + main_merge_info, + ) + main_info.update( + { + "group_index": group_index, + "fallback": False, + "fallback_strategy": None, + } + ) + group_infos.append(main_info) + records.extend(main_records) + except RuntimeError as exc: + if len(group) == 1: + message = f"{main_label} OCR失败: {exc}" + print(f" {message}", flush=True) + folder_warnings.append(message) + group_infos.append( + { + "group_index": group_index, + "label": main_label, + "merged_image": main_merge_info, + "ocr_cache": str(main_cache_path), + "ocr_request_id": None, + "row_count": 0, + "expected_row_count": expected_row_count(group), + "fallback": False, + "fallback_strategy": None, + "error": str(exc), + } + ) + time.sleep(args.sleep) + continue + + message = f"{main_label} {len(group)}张拼接OCR未通过,开始降档: {exc}" + print(f" {message}", flush=True) + folder_warnings.append(message) + fallback_errors: list[str] = [] + fallback_success = False + + for fallback_size in (4, 3, 2): + if not 1 < fallback_size < len(group): + continue + print(f" 尝试 {fallback_size} 张拼接回退", flush=True) + try: + candidate_infos, candidate_records = run_chunked_fallback(group, group_index, main_label, fallback_size) + except RuntimeError as fallback_exc: + fallback_message = f"{main_label} {fallback_size}张拼接仍未通过: {fallback_exc}" + print(f" {fallback_message}", flush=True) + folder_warnings.append(fallback_message) + fallback_errors.append(fallback_message) + continue + + records.extend(candidate_records) + preferred_fallback_size = fallback_size + preferred_fallback_failures = 0 + group_infos.append( + { + "group_index": group_index, + "label": main_label, + "merged_image": main_merge_info, + "ocr_cache": str(main_cache_path), + "ocr_request_id": None, + "row_count": sum(item["row_count"] for item in candidate_infos), + "expected_row_count": expected_row_count(group), + "fallback": True, + "fallback_strategy": f"{fallback_size}_images", + "fallback_parts": candidate_infos, + "initial_error": str(exc), + "fallback_errors": fallback_errors, + } + ) + fallback_success = True + break + + if fallback_success: + time.sleep(args.sleep) + continue + + preferred_fallback_size = 1 + run_single_fallback(group, group_index, main_label, main_merge_info, main_cache_path, str(exc), fallback_errors) + time.sleep(args.sleep) + + summary = { + "来源文件夹": folder.name, + "大科室": department.major, + "子科室": department.sub, + "图片数": len(images), + "记录数": len(records), + "拼接组数": len(group_infos), + "拼接组": group_infos, + "提示": folder_warnings, + } + return records, summary + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", default="列表", help="患者列表图片根目录") + parser.add_argument("--output", default="数据处理工作区/列表归档结果", help="输出目录") + parser.add_argument("--departments", default="数据处理工作区/01_科室分类规则.json", help="科室分类 JSON") + parser.add_argument("--batch-size", type=int, default=3, help="每次拼接图片数量,建议 3 或 4") + parser.add_argument("--image-padding-y", type=int, default=24, help="每张图拼接前添加的上下白色边界像素数") + parser.add_argument("--rows-per-image", type=int, default=0, help="每张 HIS 列表截图的行数;0 表示按图片高度自动推断") + parser.add_argument("--corrections", default="数据处理工作区/03_人工复核修正.json", help="人工复核修正 JSON") + parser.add_argument( + "--ocr-engine", + choices=sorted(OCR_ENGINE_LABELS), + default="table-v3", + help="OCR 引擎:table-v3 表格识别 V3", + ) + parser.add_argument("--region", default="ap-shanghai", help="腾讯云 OCR 地域") + parser.add_argument("--timeout", type=int, default=60, help="单次 OCR 超时秒数") + parser.add_argument("--sleep", type=float, default=0.2, help="OCR 调用间隔秒数") + parser.add_argument("--max-retries", type=int, default=0, help="OCR 调用失败重试次数") + parser.add_argument( + "--fallback-demote-threshold", + type=int, + default=3, + help="同一科室连续多少个拼接组失败后,才把后续组整体降为单张OCR", + ) + parser.add_argument("--workers", type=int, default=1, help="同一拼接组内并发OCR分片数;建议 1-2") + parser.add_argument("--folder-workers", type=int, default=1, help="并发处理科室目录数;OCR接口稳定时可设为 2-4") + parser.add_argument("--force", action="store_true", help="忽略 OCR 缓存重新识别") + parser.add_argument("--limit-folders", type=int, default=0, help="调试用:只处理前 N 个科室目录") + parser.add_argument("--limit-groups-per-folder", type=int, default=0, help="调试用:每个科室只处理前 N 个拼接组") + parser.add_argument("--rebuild-from-cache", action="store_true", help="只用已有 OCR 缓存重建结果,不发起 OCR 请求") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.batch_size < 1: + raise ValueError("--batch-size 必须大于 0") + if args.workers < 1: + raise ValueError("--workers 必须大于 0") + if args.folder_workers < 1: + raise ValueError("--folder-workers 必须大于 0") + if args.image_padding_y < 0: + raise ValueError("--image-padding-y 不能小于 0") + + input_root = Path(args.input) + output_root = Path(args.output) + sub_to_major, aliases = load_departments(Path(args.departments)) + if args.rebuild_from_cache: + secret_id, secret_key = "", "" + else: + secret_id, secret_key = get_credentials() + corrections = load_corrections(Path(args.corrections)) + + folders = sorted([path for path in input_root.iterdir() if path.is_dir()], key=lambda path: path.name) + if args.limit_folders: + folders = folders[: args.limit_folders] + + all_records: list[dict[str, Any]] = [] + folder_summaries: list[dict[str, Any]] = [] + classifications: list[dict[str, Any]] = [] + + folder_jobs: list[tuple[Path, Department]] = [] + for folder in folders: + department = classify_department(folder.name, sub_to_major, aliases) + classifications.append( + { + "来源文件夹": folder.name, + "标准化文件夹科室名": department.raw_name, + "大科室": department.major, + "子科室": department.sub, + } + ) + folder_jobs.append((folder, department)) + + def run_folder_job(item: tuple[Path, Department]) -> tuple[list[dict[str, Any]], dict[str, Any]]: + folder, department = item + print(f"[{folder.name}] -> {department.major} / {department.sub}", flush=True) + return process_folder(folder, output_root, department, args, secret_id, secret_key) + + if args.folder_workers > 1 and len(folder_jobs) > 1: + with concurrent.futures.ThreadPoolExecutor(max_workers=min(args.folder_workers, len(folder_jobs))) as executor: + folder_results = list(executor.map(run_folder_job, folder_jobs)) + else: + folder_results = [run_folder_job(item) for item in folder_jobs] + + for records, summary in folder_results: + all_records.extend(records) + folder_summaries.append(summary) + + apply_corrections(all_records, corrections) + record_count_before_dedup = len(all_records) + all_records, duplicate_records = deduplicate_records_by_inpatient_no(all_records) + issue_records = [record for record in all_records if record["复核"]["状态"] == "需人工复核"] + corrected_records = [record for record in all_records if record["复核"].get("人工修正")] + archive = { + "生成时间": dt.datetime.now().isoformat(timespec="seconds"), + "输入目录": str(input_root), + "OCR引擎": OCR_ENGINE_LABELS[args.ocr_engine], + "拼接设置": { + "batch_size": args.batch_size, + "rows_per_image": args.rows_per_image, + "image_padding_y": args.image_padding_y, + }, + "科室归类": classifications, + "汇总": { + "科室目录数": len(folders), + "图片数": sum(item["图片数"] for item in folder_summaries), + "去重前患者记录数": record_count_before_dedup, + "患者记录数": len(all_records), + "需人工复核记录数": len(issue_records), + "人工修正记录数": len(corrected_records), + "重复住院号剔除记录数": len(duplicate_records), + }, + "科室汇总": folder_summaries, + "重复住院号剔除记录": duplicate_records, + "患者记录": all_records, + } + review_report = { + "生成时间": archive["生成时间"], + "汇总": archive["汇总"], + "需人工复核记录": issue_records, + "人工修正记录": corrected_records, + "重复住院号剔除记录": duplicate_records, + "科室级提示": [ + {"来源文件夹": item["来源文件夹"], "提示": item["提示"]} + for item in folder_summaries + if item["提示"] + ], + } + + write_json(output_root / "列表_科室归类.json", classifications) + write_json(output_root / "患者列表_结构化.json", archive) + write_jsonl(output_root / "患者列表_记录.jsonl", all_records) + write_csv(output_root / "患者列表_记录.csv", all_records) + write_json(output_root / "复核报告.json", review_report) + write_json(output_root / "重复住院号报告.json", duplicate_records) + print(json.dumps(archive["汇总"], ensure_ascii=False, indent=2), flush=True) + + +if __name__ == "__main__": + main() diff --git a/患者列表处理/数据处理工作区/03_人工复核修正.template.json b/患者列表处理/数据处理工作区/03_人工复核修正.template.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/患者列表处理/数据处理工作区/03_人工复核修正.template.json @@ -0,0 +1 @@ +[] diff --git a/患者列表处理/数据处理工作区/04_合并批次结果.py b/患者列表处理/数据处理工作区/04_合并批次结果.py new file mode 100644 index 0000000..8baab0e --- /dev/null +++ b/患者列表处理/数据处理工作区/04_合并批次结果.py @@ -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() diff --git a/患者列表处理/数据处理工作区/05_同步PostgreSQL单表.py b/患者列表处理/数据处理工作区/05_同步PostgreSQL单表.py new file mode 100644 index 0000000..277da04 --- /dev/null +++ b/患者列表处理/数据处理工作区/05_同步PostgreSQL单表.py @@ -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() diff --git a/患者列表处理/数据处理工作区/06_PostgreSQL建表结构.sql b/患者列表处理/数据处理工作区/06_PostgreSQL建表结构.sql new file mode 100644 index 0000000..967b8a9 --- /dev/null +++ b/患者列表处理/数据处理工作区/06_PostgreSQL建表结构.sql @@ -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 '出院时间为空时放行;入院时间晚于出院时间时必须标记为需人工复核'; diff --git a/患者列表处理/数据处理工作区/07_处理程序说明.md b/患者列表处理/数据处理工作区/07_处理程序说明.md new file mode 100644 index 0000000..026aaa9 --- /dev/null +++ b/患者列表处理/数据处理工作区/07_处理程序说明.md @@ -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 缓存路径、拼接图路径、拼接组号等中间态字段不进入数据库正式表。 diff --git a/患者列表处理/数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql b/患者列表处理/数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql new file mode 100644 index 0000000..84d6cde --- /dev/null +++ b/患者列表处理/数据处理工作区/08_PostgreSQL调整Patient_Lists列顺序.sql @@ -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; diff --git a/患者列表处理/数据处理工作区/09_PostgreSQL住院号非空唯一约束.sql b/患者列表处理/数据处理工作区/09_PostgreSQL住院号非空唯一约束.sql new file mode 100644 index 0000000..681c07f --- /dev/null +++ b/患者列表处理/数据处理工作区/09_PostgreSQL住院号非空唯一约束.sql @@ -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 '保证同一个住院号只保留一条首页记录'; diff --git a/患者首页处理/.gitignore b/患者首页处理/.gitignore new file mode 100644 index 0000000..4c55e29 --- /dev/null +++ b/患者首页处理/.gitignore @@ -0,0 +1,22 @@ +# 原始 PDF 与处理结果不进入 Gitea +待处理-患者首页PDF/ +已处理-患者首页PDF/ +数据处理结果区/ + +# 旧参考程序不作为本次工作流代码提交 +参考-过去很烂的程序/ +参考-病案首页图片/ + +# 本地工作流不进入 Gitea +工作流_本地使用版.md +工作流_本地使用版*.zip + +# 本地配置、缓存和临时文件 +.env +.env.* +!.env.example +数据可视化网页端/review_settings.local.json +__pycache__/ +*.pyc +*.tmp +*.log diff --git a/患者首页处理/README.md b/患者首页处理/README.md new file mode 100644 index 0000000..ca289ed --- /dev/null +++ b/患者首页处理/README.md @@ -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 继续补规则。 diff --git a/患者首页处理/工作流_Gitea版.md b/患者首页处理/工作流_Gitea版.md new file mode 100644 index 0000000..03ba9c9 --- /dev/null +++ b/患者首页处理/工作流_Gitea版.md @@ -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。 diff --git a/患者首页处理/数据可视化网页端/.dockerignore b/患者首页处理/数据可视化网页端/.dockerignore new file mode 100644 index 0000000..b405bf5 --- /dev/null +++ b/患者首页处理/数据可视化网页端/.dockerignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.env +.env.* diff --git a/患者首页处理/数据可视化网页端/.env.example b/患者首页处理/数据可视化网页端/.env.example new file mode 100644 index 0000000..de4e941 --- /dev/null +++ b/患者首页处理/数据可视化网页端/.env.example @@ -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 diff --git a/患者首页处理/数据可视化网页端/Dockerfile b/患者首页处理/数据可视化网页端/Dockerfile new file mode 100644 index 0000000..5a26844 --- /dev/null +++ b/患者首页处理/数据可视化网页端/Dockerfile @@ -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}"] diff --git a/患者首页处理/数据可视化网页端/README.md b/患者首页处理/数据可视化网页端/README.md new file mode 100644 index 0000000..0ff9f0a --- /dev/null +++ b/患者首页处理/数据可视化网页端/README.md @@ -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`,不会由网页端自动上传。 diff --git a/患者首页处理/数据可视化网页端/app/__init__.py b/患者首页处理/数据可视化网页端/app/__init__.py new file mode 100644 index 0000000..82aeed3 --- /dev/null +++ b/患者首页处理/数据可视化网页端/app/__init__.py @@ -0,0 +1 @@ +"""Patient front page visual review web app.""" diff --git a/患者首页处理/数据可视化网页端/app/main.py b/患者首页处理/数据可视化网页端/app/main.py new file mode 100644 index 0000000..b909ea3 --- /dev/null +++ b/患者首页处理/数据可视化网页端/app/main.py @@ -0,0 +1,2779 @@ +from __future__ import annotations + +import json +import os +import base64 +import hashlib +import secrets +import re +import threading +import time +import urllib.error +import urllib.request +from datetime import date, datetime, timedelta +from decimal import Decimal +from pathlib import Path +from queue import Empty, Queue +from typing import Any +from urllib.parse import urlparse +from zoneinfo import ZoneInfo + +import psycopg2 +import psycopg2.extras +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Request, Response +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from psycopg2 import sql + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +load_dotenv(PROJECT_ROOT / ".env") + +APP_DIR = Path(__file__).resolve().parent +STATIC_DIR = APP_DIR / "static" + + +def env(name: str, default: str = "") -> str: + return os.getenv(name, default).strip() + + +DB_CONFIG = { + "host": env("PGHOST", "DB_HOST"), + "port": int(env("PGPORT", "5432")), + "dbname": env("PGDATABASE", "DB_NAME"), + "user": env("PGUSER", "DB_USER"), + "password": env("PGPASSWORD"), +} +PGTABLE = env("PGTABLE", "Patient_FrontPages") +PDF_DIR = Path(env("PDF_DIR", str(PROJECT_ROOT / "待处理-患者首页PDF"))).resolve() + +SETTINGS_PATH = Path(env("REVIEW_SETTINGS_PATH", str(PROJECT_ROOT / "数据可视化网页端/review_settings.local.json"))).resolve() +APP_TIMEZONE = ZoneInfo(env("APP_TIMEZONE", "Asia/Shanghai") or "Asia/Shanghai") + + +FIELD_GROUPS: list[dict[str, Any]] = [ + { + "name": "基本信息", + "fields": [ + ("inpatient_no", "住院号", "text", None), + ("medical_record_no", "病案号", "text", None), + ("front_page_medical_record_no", "首页病案号", "text", None), + ("patient_name", "姓名", "text", None), + ("gender", "性别", "text", None), + ("birth_date", "出生日期", "date", None), + ("age", "年龄", "text", None), + ("nationality", "国籍", "text", None), + ("id_card_no", "身份证号", "text", None), + ("payment_method", "医疗付费方式", "text", None), + ("health_card_no", "健康卡号", "text", None), + ("admission_count", "住院次数", "integer", None), + ("occupation", "职业", "text", None), + ("marital_status_code", "婚姻代码", "text", None), + ("admission_path_code", "入院途径代码", "text", None), + ("admission_time", "入院时间", "datetime", None), + ("admission_dept", "入院科别", "text", None), + ("admission_ward", "入院病房", "text", None), + ("transfer_dept", "转科科别", "text", None), + ("transfer_time", "转科时间", "text", None), + ("discharge_time", "出院时间", "datetime", None), + ("discharge_dept", "出院科别", "text", None), + ("discharge_ward", "出院病房", "text", None), + ("hospital_days", "实际住院天数", "integer", None), + ("major_department", "大科室", "text", None), + ], + }, + { + "name": "地址联系人", + "fields": [ + ("current_address", "现住址", "text", None), + ("current_address_phone", "现住址电话", "text", None), + ("current_address_postcode", "现住址邮编", "text", None), + ("household_address", "户口地址", "text", None), + ("household_postcode", "户口地址邮编", "text", None), + ("employer_address", "工作单位及地址", "text", None), + ("employer_phone", "单位电话", "text", None), + ("employer_postcode", "单位邮编", "text", None), + ("contact_name", "联系人姓名", "text", None), + ("contact_relationship", "联系人关系", "text", None), + ("contact_address", "联系人地址", "text", None), + ("contact_phone", "联系人电话", "text", None), + ], + }, + { + "name": "诊断表格", + "fields": [ + ("outpatient_diagnosis", "门急诊诊断", "text", None), + ("outpatient_diagnosis_code", "门急诊诊断编码", "text", None), + ("primary_diagnosis", "主要诊断", "text", None), + ("primary_diagnosis_code", "主要诊断编码", "text", None), + ("primary_admission_condition", "主要诊断入院病情", "text", None), + ("discharge_diagnoses", "出院诊断", "json", None), + ("injury_poisoning_external_cause", "损伤中毒外部原因", "text", None), + ("injury_poisoning_code", "损伤中毒疾病编码", "text", None), + ("pathology_diagnosis", "病理诊断", "text", None), + ("pathology_diagnosis_code", "病理诊断编码", "text", None), + ("pathology_no", "病理号", "text", None), + ], + }, + { + "name": "手术表格", + "fields": [ + ("operations", "手术操作 JSON", "json", None), + ], + }, + { + "name": "离院费用", + "fields": [ + ("discharge_disposition_code", "离院方式代码", "text", None), + ("receiving_org_name", "拟接收医疗机构名称", "text", None), + ("readmission_plan_code", "出院31天内再住院计划代码", "text", None), + ("readmission_plan_purpose", "再住院计划目的", "text", None), + ("coma_before_days", "入院前昏迷天数", "integer", None), + ("coma_before_hours", "入院前昏迷小时", "integer", None), + ("coma_before_minutes", "入院前昏迷分钟", "integer", None), + ("coma_after_days", "入院后昏迷天数", "integer", None), + ("coma_after_hours", "入院后昏迷小时", "integer", None), + ("coma_after_minutes", "入院后昏迷分钟", "integer", None), + ("total_cost", "总费用", "numeric", None), + ("self_pay_amount", "自付金额", "numeric", None), + ("fee_details", "费用明细 JSON", "json", None), + ], + }, +] + +FIELD_META: dict[str, dict[str, Any]] = {} +for group in FIELD_GROUPS: + for name, label, field_type, options in group["fields"]: + FIELD_META[name] = {"name": name, "label": label, "type": field_type, "options": options} + +EDITABLE_FIELDS = set(FIELD_META) +JSON_FIELDS = {name for name, meta in FIELD_META.items() if meta["type"] == "json"} +JSON_DB_FIELDS = JSON_FIELDS | {"review_notes", "quality_notes", "auto_corrections"} +INTEGER_FIELDS = {name for name, meta in FIELD_META.items() if meta["type"] == "integer"} +NUMERIC_FIELDS = {name for name, meta in FIELD_META.items() if meta["type"] == "numeric"} + + +class UpdatePayload(BaseModel): + fields: dict[str, Any] + manual_note: str = "" + note_prefix: str = "人工复核" + + +class AuditPayload(BaseModel): + audit_status: str = "pending" + audit_notes: str = "" + ai_result: Any = None + + +class AuditClassifyPayload(BaseModel): + record_id: int + audit_source: str = "reviewed" + audit_status: str + audit_notes: str = "" + fields: dict[str, Any] = {} + + +class UserPayload(BaseModel): + username: str + password: str = "" + permissions: dict[str, bool] = {} + + +class UserUpdatePayload(BaseModel): + username: str = "" + password: str = "" + permissions: dict[str, bool] = {} + + +class PasswordPayload(BaseModel): + password: str = "" + + +class PermissionPayload(BaseModel): + permissions: dict[str, bool] = {} + + +class LoginPayload(BaseModel): + username: str = "" + password: str = "" + + +class SystemSettingsPayload(BaseModel): + status_check_time: str = "" + + +class KimiSettingsPayload(BaseModel): + enabled: bool = True + model: str = "" + api_base: str = "" + concurrency: int = 3 + + +class AiReviewPayload(BaseModel): + scope: str = "current" + record_id: int | None = None + + +app = FastAPI(title="Patient Front Page Visual Review") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + +STATUS_CHECK_LOCK = threading.Lock() +WORKFLOW_LOCK = threading.Lock() +WORKFLOW_READY = False +AI_JOB_LOCK = threading.Lock() +AI_REVIEW_JOB: dict[str, Any] = { + "running": False, + "scope": "", + "total": 0, + "processed": 0, + "ok": 0, + "pending": 0, + "failed": 0, + "concurrency": 0, + "message": "", + "errors": [], + "started_at": "", + "finished_at": "", + "last_record_id": None, +} +DEFAULT_STATUS_CHECK_TIME = env("REVIEW_STATUS_CHECK_TIME", "03:00") or "03:00" +DEFAULT_KIMI_API_BASE = env("MOONSHOT_API_BASE", env("KIMI_API_BASE", "https://api.moonshot.cn/v1")) or "https://api.moonshot.cn/v1" +DEFAULT_KIMI_MODEL = env("KIMI_MODEL", "kimi-k2.6") or "kimi-k2.6" +AI_NO_ISSUE_STATUS = "AI复核-无问题" +AI_PENDING_STATUS = "AI复核-待确认" +AI_CONFIRMED_PROBLEM_KEYWORDS = ( + "缺少", + "缺失", + "为空白", + "为空", + "空白", + "无编码", + "未填写", + "不清晰", + "不一致", + "错位", + "混乱", + "需人工", + "需要人工", + "待确认", +) +AI_CONFIRMED_PROBLEM_QUALIFIERS = ("确实", "证实", "属实", "仍", "依然", "存在", "需要", "需人工", "待确认") +AI_STOP_ERROR_MARKERS = ( + "exceeded_current_quota_error", + "insufficient_quota", + "consumption budget", + "billing details", + "quota", + "余额", + "额度", +) +AI_JOB_ERROR_LIMIT = 50 +PDF_MODULE_DEFINITIONS: list[dict[str, Any]] = [ + { + "name": "基本信息", + "keywords": ["住院病案首页", "姓名", "性别", "出生日期", "入院时间", "出院时间", "住院号"], + "note_keywords": ["姓名", "性别", "出生", "入院", "出院", "住院号", "病案号", "年龄"], + "tail": 330, + }, + { + "name": "地址联系人", + "keywords": ["现住址", "户口地址", "工作单位及地址", "联系人姓名", "联系人地址"], + "note_keywords": ["地址", "电话", "联系人", "户口", "单位", "邮编"], + "tail": 260, + }, + { + "name": "诊断表格", + "keywords": ["门(急)诊诊断", "门急诊诊断", "出院诊断", "疾病编码", "入院病情", "其他诊断"], + "note_keywords": ["诊断", "疾病编码", "编码格式", "入院病情", "病理"], + "tail": 520, + }, + { + "name": "手术表格", + "keywords": ["手术及操作编码", "手术及操作日期", "手术及操作名称", "手术级别", "麻醉方式", "术者"], + "note_keywords": ["手术", "操作", "麻醉", "切口", "术者"], + "tail": 520, + }, + { + "name": "离院费用", + "keywords": ["离院方式", "出院31天内再住院计划", "住院费用", "总费用", "自付金额", "综合医疗服务类"], + "note_keywords": ["离院", "费用", "总费用", "自付", "金额", "再住院", "昏迷"], + "tail": 520, + }, +] +AI_REVIEWABLE_STATUSES = ("needs_review", AI_PENDING_STATUS) +SUBMITTED_STATUS = "已提交" + + +@app.on_event("startup") +def start_status_scheduler() -> None: + threading.Thread(target=status_scheduler_loop, name="status-check-scheduler", daemon=True).start() + + +def table_identifier() -> sql.Composable: + if "." in PGTABLE: + schema, table = PGTABLE.split(".", 1) + return sql.Identifier(schema, table) + return sql.Identifier(PGTABLE) + + +def patient_lists_identifier() -> sql.Composable: + return sql.Identifier("Patient_Lists") + + +def patient_list_trigger_function_identifier(base_table: str) -> sql.Composable: + function_name = f"{base_table}_sync_patient_lists_trigger_fn" + if "." in PGTABLE: + schema = PGTABLE.split(".", 1)[0] + return sql.Identifier(schema, function_name) + return sql.Identifier(function_name) + + +def patient_dedupe_trigger_function_identifier(base_table: str) -> sql.Composable: + function_name = f"{base_table}_dedupe_inpatient_no_trigger_fn" + if "." in PGTABLE: + schema = PGTABLE.split(".", 1)[0] + return sql.Identifier(schema, function_name) + return sql.Identifier(function_name) + + +def related_table_identifier(suffix: str) -> sql.Composable: + if "." in PGTABLE: + schema, table = PGTABLE.split(".", 1) + return sql.Identifier(schema, f"{table}{suffix}") + return sql.Identifier(f"{PGTABLE}{suffix}") + + +def connect(): + return psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.RealDictCursor) + + +def json_ready(value: Any) -> Any: + if isinstance(value, (date, datetime)): + return value.isoformat(sep=" ") if isinstance(value, datetime) else value.isoformat() + if isinstance(value, Decimal): + return str(value) + return value + + +def row_to_json(row: dict[str, Any]) -> dict[str, Any]: + return {key: json_ready(value) for key, value in row.items()} + + +def json_ready_deep(value: Any) -> Any: + if isinstance(value, dict): + return {key: json_ready_deep(item) for key, item in value.items()} + if isinstance(value, list): + return [json_ready_deep(item) for item in value] + return json_ready(value) + + +def comparable(value: Any) -> str: + return json.dumps(json_ready_deep(value), ensure_ascii=False, sort_keys=True, default=str) + + +PERMISSION_LABELS = { + "overview": "概览", + "review": "复核", + "audit": "抽查", + "audit_history": "抽查一览", + "settings": "设置", +} +DEFAULT_PERMISSIONS = {key: True for key in PERMISSION_LABELS} +SESSION_COOKIE = "frontpage_review_session" +SESSIONS: dict[str, dict[str, Any]] = {} + + +def password_hash(password: str) -> dict[str, str]: + salt = secrets.token_hex(12) + digest = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() + return {"salt": salt, "password_hash": digest} + + +def verify_password(password: str, salt: str, digest: str) -> bool: + expected = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() + return secrets.compare_digest(expected, digest or "") + + +def admin_username() -> str: + return env("REVIEW_ADMIN_USER", "admin") or "admin" + + +def admin_password() -> str: + return env("REVIEW_ADMIN_PASSWORD", "change-me") or "change-me" + + +def public_user(username: str, permissions: dict[str, bool], source: str) -> dict[str, Any]: + return { + "username": username, + "permissions": {**DEFAULT_PERMISSIONS, **(permissions or {})}, + "source": source, + } + + +def load_local_settings() -> dict[str, Any]: + if not SETTINGS_PATH.exists(): + return { + "users": [], + "permission_labels": PERMISSION_LABELS, + "system": default_system_settings(), + "kimi": default_kimi_settings(), + "status_snapshot": default_status_snapshot(), + } + try: + data = json.loads(SETTINGS_PATH.read_text(encoding="utf-8")) + except json.JSONDecodeError: + data = { + "users": [], + "permission_labels": PERMISSION_LABELS, + "system": default_system_settings(), + "kimi": default_kimi_settings(), + "status_snapshot": default_status_snapshot(), + } + data.setdefault("users", []) + data.setdefault("permission_labels", PERMISSION_LABELS) + data["system"] = normalize_system_settings(data.get("system") or {}) + data["kimi"] = normalize_kimi_settings(data.get("kimi") or {}) + data.setdefault("status_snapshot", default_status_snapshot()) + return data + + +def save_local_settings(data: dict[str, Any]) -> None: + SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + temp = SETTINGS_PATH.with_suffix(SETTINGS_PATH.suffix + ".tmp") + temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + temp.replace(SETTINGS_PATH) + + +def public_settings() -> dict[str, Any]: + data = load_local_settings() + users = [] + env_admin_seen = False + for user in data.get("users", []): + users.append( + { + "username": user.get("username", ""), + "permissions": {**DEFAULT_PERMISSIONS, **(user.get("permissions") or {})}, + "source": "local", + "created_at": user.get("created_at", ""), + "updated_at": user.get("updated_at", ""), + "has_password": bool(user.get("password_hash")), + } + ) + if user.get("username") == admin_username(): + env_admin_seen = True + if not env_admin_seen: + users.insert( + 0, + { + "username": admin_username(), + "permissions": dict(DEFAULT_PERMISSIONS), + "source": "env", + "created_at": "", + "updated_at": "", + "has_password": True, + }, + ) + return { + "users": users, + "permission_labels": PERMISSION_LABELS, + "system": normalize_system_settings(data.get("system") or {}), + "kimi": public_kimi_settings(data.get("kimi") or {}), + "status_snapshot": data.get("status_snapshot") or default_status_snapshot(), + } + + +def normalize_status_check_time(value: str) -> str: + value = (value or DEFAULT_STATUS_CHECK_TIME).strip() + match = re.fullmatch(r"([01]?\d|2[0-3]):([0-5]\d)", value) + if not match: + raise HTTPException(status_code=400, detail="状态检查时间必须是 HH:MM,例如 03:00") + return f"{int(match.group(1)):02d}:{match.group(2)}" + + +def kimi_api_key() -> str: + return env("MOONSHOT_API_KEY") or env("KIMI_API_KEY") + + +def normalize_kimi_concurrency(value: Any) -> int: + try: + concurrency = int(value) + except (TypeError, ValueError): + concurrency = int(env("KIMI_CONCURRENCY", "3") or 3) + return max(1, min(concurrency, 6)) + + +def default_kimi_settings() -> dict[str, Any]: + return { + "enabled": bool(kimi_api_key()), + "api_base": DEFAULT_KIMI_API_BASE, + "model": DEFAULT_KIMI_MODEL, + "concurrency": normalize_kimi_concurrency(env("KIMI_CONCURRENCY", "3")), + } + + +def normalize_kimi_settings(kimi: dict[str, Any]) -> dict[str, Any]: + defaults = default_kimi_settings() + api_base = str(kimi.get("api_base") or defaults["api_base"]).strip().rstrip("/") + model = str(kimi.get("model") or defaults["model"]).strip() + return { + "enabled": bool(kimi.get("enabled", defaults["enabled"])), + "api_base": api_base or DEFAULT_KIMI_API_BASE, + "model": model or DEFAULT_KIMI_MODEL, + "concurrency": normalize_kimi_concurrency(kimi.get("concurrency", defaults["concurrency"])), + } + + +def public_kimi_settings(kimi: dict[str, Any] | None = None) -> dict[str, Any]: + settings = normalize_kimi_settings(kimi or {}) + settings["api_key_configured"] = bool(kimi_api_key()) + settings["available"] = settings["enabled"] and settings["api_key_configured"] + return settings + + +def status_now() -> datetime: + return datetime.now(APP_TIMEZONE) + + +def default_system_settings() -> dict[str, Any]: + return { + "status_check_time": normalize_status_check_time(DEFAULT_STATUS_CHECK_TIME), + "last_status_check_date": "", + "last_status_checked_at": "", + } + + +def normalize_system_settings(system: dict[str, Any]) -> dict[str, Any]: + defaults = default_system_settings() + merged = {**defaults, **(system or {})} + merged["status_check_time"] = normalize_status_check_time(str(merged.get("status_check_time") or defaults["status_check_time"])) + return merged + + +def next_status_check_at(system: dict[str, Any] | None = None, now: datetime | None = None) -> str: + now = now or status_now() + system = normalize_system_settings(system or {}) + hour, minute = [int(part) for part in system["status_check_time"].split(":")] + next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if next_run <= now: + next_run += timedelta(days=1) + return next_run.isoformat(timespec="seconds") + + +def default_status_snapshot() -> dict[str, Any]: + system = default_system_settings() + return { + "database": "unchecked", + "host": DB_CONFIG["host"], + "port": DB_CONFIG["port"], + "database_name": DB_CONFIG["dbname"], + "table": PGTABLE, + "pdf_dir": str(PDF_DIR), + "pdf_count": None, + "total": None, + "review_needed": None, + "needs_review": None, + "auto_passed": None, + "ai_passed": None, + "ai_pending": None, + "reviewed": None, + "submitted": None, + "manual_corrected": None, + "audit_total": None, + "message": "尚未执行状态检查", + "checked_at": "", + "check_source": "", + "next_check_at": next_status_check_at(system), + } + + +def compute_status_snapshot(source: str = "manual") -> dict[str, Any]: + result: dict[str, Any] = { + "database": "offline", + "host": DB_CONFIG["host"], + "port": DB_CONFIG["port"], + "database_name": DB_CONFIG["dbname"], + "table": PGTABLE, + "pdf_dir": str(PDF_DIR), + "pdf_count": len(list(PDF_DIR.glob("*.pdf"))) if PDF_DIR.exists() else 0, + "checked_at": status_now().isoformat(timespec="seconds"), + "check_source": source, + } + try: + query = sql.SQL( + """ + SELECT + count(*) AS total, + count(*) FILTER (WHERE review_status IN ('needs_review', 'AI复核-待确认')) AS review_needed, + count(*) FILTER (WHERE review_status = 'needs_review') AS needs_review, + count(*) FILTER (WHERE review_status = 'auto_pass') AS auto_passed, + count(*) FILTER (WHERE review_status = 'AI复核-无问题') AS ai_passed, + count(*) FILTER (WHERE review_status = 'AI复核-待确认') AS ai_pending, + count(*) FILTER (WHERE review_status = 'reviewed') AS reviewed, + count(*) FILTER (WHERE review_status = '已提交') AS submitted, + count(*) FILTER (WHERE manual_corrected IS TRUE) AS manual_corrected + FROM {table} + """ + ).format(table=table_identifier()) + with connect() as conn, conn.cursor() as cur: + cur.execute(query) + row = cur.fetchone() + result.update(row_to_json(dict(row))) + result["audit_total"] = None + result["database"] = "online" + result["message"] = "连接正常" + except Exception as exc: # noqa: BLE001 + result["message"] = str(exc) + return result + + +def refresh_status_snapshot(source: str = "manual") -> dict[str, Any]: + with STATUS_CHECK_LOCK: + snapshot = compute_status_snapshot(source=source) + data = load_local_settings() + system = normalize_system_settings(data.get("system") or {}) + now = status_now() + if source == "scheduled": + system["last_status_check_date"] = now.date().isoformat() + system["last_status_checked_at"] = snapshot.get("checked_at", now.isoformat(timespec="seconds")) + snapshot["next_check_at"] = next_status_check_at(system, now) + data["system"] = system + data["status_snapshot"] = snapshot + save_local_settings(data) + return snapshot + + +def status_scheduler_loop() -> None: + while True: + try: + data = load_local_settings() + system = normalize_system_settings(data.get("system") or {}) + now = status_now() + hour, minute = [int(part) for part in system["status_check_time"].split(":")] + due_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + already_ran = system.get("last_status_check_date") == now.date().isoformat() + if now >= due_time and not already_ran: + refresh_status_snapshot(source="scheduled") + except Exception: + pass + time.sleep(60) + + +def clean_permissions(permissions: dict[str, bool]) -> dict[str, bool]: + return {key: bool(permissions.get(key, DEFAULT_PERMISSIONS[key])) for key in PERMISSION_LABELS} + + +def local_user_index(data: dict[str, Any], username: str) -> int | None: + for index, user in enumerate(data.get("users", [])): + if user.get("username") == username: + return index + return None + + +def validate_local_username(username: str, data: dict[str, Any], current_username: str = "") -> str: + username = username.strip() + if not username: + raise HTTPException(status_code=400, detail="用户名不能为空") + if username == admin_username() and username != current_username: + raise HTTPException(status_code=400, detail="不能覆盖环境变量管理员") + for user in data.get("users", []): + if user.get("username") == username and user.get("username") != current_username: + raise HTTPException(status_code=400, detail="用户已存在") + return username + + +def authenticate_user(username: str, password: str) -> dict[str, Any] | None: + username = username.strip() + if username == admin_username(): + if secrets.compare_digest(password, admin_password()): + return public_user(username, DEFAULT_PERMISSIONS, "env") + return None + data = load_local_settings() + for user in data.get("users", []): + if user.get("username") != username: + continue + if not user.get("password_hash") or not user.get("salt"): + return None + if verify_password(password, user.get("salt", ""), user.get("password_hash", "")): + return public_user(username, clean_permissions(user.get("permissions") or {}), "local") + return None + return None + + +def session_from_request(request: Request) -> dict[str, Any] | None: + token = request.cookies.get(SESSION_COOKIE, "") + if not token: + return None + return SESSIONS.get(token) + + +def page_permission_for_path(path: str, method: str) -> str | tuple[str, ...] | None: + if path in {"/api/status", "/api/schema"}: + return None + if path.startswith("/api/settings"): + return "settings" + if path.startswith("/api/overview"): + return "overview" + if path.startswith("/api/audit/logs") and method == "GET": + return "audit_history" + if path.startswith("/api/audit"): + return "audit" + if path.startswith("/api/ai"): + return "review" + if path.startswith("/api/pdf/"): + return ("review", "audit") + if path == "/api/records": + return "review" + if path.startswith("/api/records/"): + return "review" if method != "GET" else ("review", "audit") + return None + + +def has_page_permission(user: dict[str, Any], requirement: str | tuple[str, ...] | None) -> bool: + if requirement is None: + return True + permissions = user.get("permissions") or {} + if isinstance(requirement, tuple): + return any(permissions.get(item, False) for item in requirement) + return bool(permissions.get(requirement, False)) + + +@app.middleware("http") +async def auth_middleware(request: Request, call_next): + path = request.url.path + if path.startswith("/api/") and not path.startswith("/api/auth/"): + user = session_from_request(request) + if not user: + return JSONResponse({"detail": "请先登录"}, status_code=401) + requirement = page_permission_for_path(path, request.method) + if not has_page_permission(user, requirement): + return JSONResponse({"detail": "当前用户没有访问权限"}, status_code=403) + request.state.user = user + return await call_next(request) + + +def digits(value: Any, width: int) -> str: + text = re.sub(r"\D", "", str(value or "")) + return text[-width:].zfill(width) if text else "" + + +def source_file_inpatient_no(source_file: str) -> str: + match = re.match(r"^(ZY\d{12})", Path(source_file or "").stem, flags=re.IGNORECASE) + return match.group(1).upper() if match else "" + + +def source_file_admission_count(source_file: str) -> str: + match = re.match(r"^ZY(\d{2})\d{10}", Path(source_file or "").stem, flags=re.IGNORECASE) + return match.group(1) if match else "" + + +def source_file_medical_record_no(source_file: str) -> str: + match = re.match(r"^ZY\d{2}(\d{10})", Path(source_file or "").stem, flags=re.IGNORECASE) + return match.group(1) if match else "" + + +def build_inpatient_no_from_record(record: dict[str, Any]) -> str: + source_file = str(record.get("source_file") or "") + admission = digits(record.get("admission_count"), 2) or source_file_admission_count(source_file) + page_no = ( + digits(record.get("front_page_medical_record_no"), 10) + or digits(record.get("medical_record_no"), 10) + or source_file_medical_record_no(source_file) + ) + if admission and page_no: + return f"ZY{admission}{page_no}" + return source_file_inpatient_no(source_file) + + +def ensure_workflow_tables(force: bool = False) -> None: + global WORKFLOW_READY + if WORKFLOW_READY and not force: + return + with WORKFLOW_LOCK: + if WORKFLOW_READY and not force: + return + _ensure_workflow_tables_uncached() + WORKFLOW_READY = True + + +def _ensure_workflow_tables_uncached() -> None: + table = table_identifier() + old_review_logs = related_table_identifier("_review_logs") + old_audit_logs = related_table_identifier("_audit_logs") + schema = PGTABLE.split(".", 1)[0] if "." in PGTABLE else "public" + base_table = PGTABLE.split(".", 1)[-1] + old_review_regclass = f'{schema}."{base_table}_review_logs"' + old_audit_regclass = f'{schema}."{base_table}_audit_logs"' + with connect() as conn, conn.cursor() as cur: + cur.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (f"{PGTABLE}:workflow_storage",)) + cur.execute(sql.SQL("ALTER TABLE {table} ADD COLUMN IF NOT EXISTS inpatient_no TEXT").format(table=table)) + cur.execute(sql.SQL("ALTER TABLE {table} ADD COLUMN IF NOT EXISTS major_department TEXT").format(table=table)) + cur.execute( + sql.SQL("ALTER TABLE {table} ADD COLUMN IF NOT EXISTS review_logs JSONB NOT NULL DEFAULT '[]'::jsonb").format( + table=table + ) + ) + cur.execute( + sql.SQL("ALTER TABLE {table} ADD COLUMN IF NOT EXISTS audit_logs JSONB NOT NULL DEFAULT '[]'::jsonb").format( + table=table + ) + ) + cur.execute( + sql.SQL("COMMENT ON COLUMN {table}.review_logs IS '人工复核修改记录,JSONB数组,已合并到患者首页主表'").format( + table=table + ) + ) + cur.execute( + sql.SQL("COMMENT ON COLUMN {table}.audit_logs IS '抽查归类记录,JSONB数组,已合并到患者首页主表'").format( + table=table + ) + ) + cur.execute( + sql.SQL("COMMENT ON COLUMN {table}.inpatient_no IS '患者号/住院号,作为首页与患者列表联动唯一键;不能为空,格式由患者目录核验端处理。'").format( + table=table + ) + ) + cur.execute( + sql.SQL("COMMENT ON COLUMN {table}.major_department IS '大科室分类,来源于01_科室分类规则.json。'").format( + table=table + ) + ) + cur.execute( + sql.SQL( + """ + UPDATE {table} + SET front_page_medical_record_no = RIGHT(LPAD(regexp_replace(front_page_medical_record_no, '\\D', '', 'g'), 10, '0'), 10) + WHERE front_page_medical_record_no IS NOT NULL + AND front_page_medical_record_no <> '' + AND front_page_medical_record_no !~ '^\\d{{10}}$' + AND regexp_replace(front_page_medical_record_no, '\\D', '', 'g') <> '' + """ + ).format(table=table) + ) + cur.execute( + sql.SQL( + """ + UPDATE {table} + SET inpatient_no = + 'ZY' + || COALESCE( + LPAD(admission_count::text, 2, '0'), + substring(source_file from '^ZY([0-9]{{2}})[0-9]{{10}}') + ) + || RIGHT( + LPAD( + COALESCE( + NULLIF(regexp_replace(COALESCE(front_page_medical_record_no, ''), '\\D', '', 'g'), ''), + NULLIF(regexp_replace(COALESCE(medical_record_no, ''), '\\D', '', 'g'), ''), + substring(source_file from '^ZY[0-9]{{2}}([0-9]{{10}})') + ), + 10, + '0' + ), + 10 + ) + WHERE (inpatient_no IS NULL OR BTRIM(inpatient_no) = '') + AND COALESCE( + LPAD(admission_count::text, 2, '0'), + substring(source_file from '^ZY([0-9]{{2}})[0-9]{{10}}') + ) IS NOT NULL + AND COALESCE( + NULLIF(regexp_replace(COALESCE(front_page_medical_record_no, ''), '\\D', '', 'g'), ''), + NULLIF(regexp_replace(COALESCE(medical_record_no, ''), '\\D', '', 'g'), ''), + substring(source_file from '^ZY[0-9]{{2}}([0-9]{{10}})') + ) IS NOT NULL + """ + ).format(table=table) + ) + cur.execute( + sql.SQL("ALTER TABLE {table} DROP CONSTRAINT IF EXISTS {constraint}").format( + table=table, + constraint=sql.Identifier(f"{base_table}_source_file_key"), + ) + ) + for constraint_name in { + f"ck_{base_table}_inpatient_no_format", + f"ck_{base_table.lower()}_inpatient_no_format", + f"ck_{base_table}_inpatient_no_required", + }: + cur.execute( + sql.SQL("ALTER TABLE {table} DROP CONSTRAINT IF EXISTS {constraint}").format( + table=table, + constraint=sql.Identifier(constraint_name), + ) + ) + cur.execute( + sql.SQL("DELETE FROM {table} WHERE NULLIF(BTRIM(inpatient_no), '') IS NULL").format(table=table) + ) + cur.execute( + sql.SQL("UPDATE {table} SET inpatient_no = BTRIM(inpatient_no) WHERE inpatient_no <> BTRIM(inpatient_no)").format( + table=table + ) + ) + cur.execute( + sql.SQL( + """ + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER (PARTITION BY BTRIM(inpatient_no) ORDER BY id DESC) AS duplicate_rank + FROM {table} + WHERE NULLIF(BTRIM(inpatient_no), '') IS NOT NULL + ) + DELETE FROM {table} p + USING ranked + WHERE p.id = ranked.id + AND ranked.duplicate_rank > 1 + """ + ).format(table=table) + ) + cur.execute(sql.SQL("ALTER TABLE {table} ALTER COLUMN inpatient_no SET NOT NULL").format(table=table)) + cur.execute("SELECT 1 FROM pg_constraint WHERE conname = %s", (f"ck_{base_table}_inpatient_no_required",)) + if not cur.fetchone(): + cur.execute( + sql.SQL("ALTER TABLE {table} ADD CONSTRAINT {constraint} CHECK (NULLIF(BTRIM(inpatient_no), '') IS NOT NULL)").format( + table=table, + constraint=sql.Identifier(f"ck_{base_table}_inpatient_no_required"), + ) + ) + cur.execute( + sql.SQL("CREATE UNIQUE INDEX IF NOT EXISTS {index_name} ON {table}(inpatient_no)").format( + index_name=sql.Identifier(f"{base_table}_inpatient_no_uidx"), + table=table, + ) + ) + cur.execute("SELECT to_regclass(%s) AS table_oid", (old_review_regclass,)) + if cur.fetchone()["table_oid"]: + cur.execute( + sql.SQL( + """ + WITH grouped AS ( + SELECT + record_id, + jsonb_agg( + jsonb_build_object( + 'id', id::text, + 'record_id', record_id, + 'source_file', source_file, + 'changed_at', changed_at, + 'changed_by', changed_by, + 'manual_note', manual_note, + 'changed_fields', changed_fields + ) + ORDER BY changed_at DESC, id DESC + ) AS logs + FROM {old_review_logs} + GROUP BY record_id + ) + UPDATE {table} p + SET review_logs = COALESCE(p.review_logs, '[]'::jsonb) || grouped.logs + FROM grouped + WHERE p.id = grouped.record_id + """ + ).format(table=table, old_review_logs=old_review_logs) + ) + cur.execute(sql.SQL("DROP TABLE IF EXISTS {old_review_logs}").format(old_review_logs=old_review_logs)) + cur.execute("SELECT to_regclass(%s) AS table_oid", (old_audit_regclass,)) + if cur.fetchone()["table_oid"]: + cur.execute( + sql.SQL( + """ + WITH grouped AS ( + SELECT + record_id, + jsonb_agg( + jsonb_build_object( + 'id', id::text, + 'record_id', record_id, + 'source_file', source_file, + 'audit_source', audit_source, + 'audit_status', audit_status, + 'audit_notes', audit_notes, + 'ai_result', ai_result, + 'snapshot', snapshot, + 'created_at', created_at, + 'updated_at', updated_at + ) + ORDER BY updated_at DESC, id DESC + ) AS logs + FROM {old_audit_logs} + GROUP BY record_id + ) + UPDATE {table} p + SET audit_logs = COALESCE(p.audit_logs, '[]'::jsonb) || grouped.logs + FROM grouped + WHERE p.id = grouped.record_id + """ + ).format(table=table, old_audit_logs=old_audit_logs) + ) + cur.execute(sql.SQL("DROP TABLE IF EXISTS {old_audit_logs}").format(old_audit_logs=old_audit_logs)) + ensure_patient_frontpage_dedupe_trigger(cur) + sync_patient_lists(cur) + ensure_patient_lists_trigger(cur) + conn.commit() + + +def sync_patient_lists(cur) -> None: + table = table_identifier() + list_table = patient_lists_identifier() + cur.execute( + sql.SQL( + """ + CREATE TABLE IF NOT EXISTS {list_table} ( + record_id BIGSERIAL PRIMARY KEY, + batch_name TEXT NOT NULL DEFAULT 'Patient_FrontPages', + major_department TEXT NOT NULL DEFAULT '', + sub_department TEXT NOT NULL DEFAULT '', + source_folder TEXT NOT NULL DEFAULT 'Patient_FrontPages', + image_path TEXT NOT NULL DEFAULT '', + image_name TEXT NOT NULL DEFAULT '', + image_row_no INTEGER NOT NULL DEFAULT 0, + patient_name TEXT NOT NULL DEFAULT '', + 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 DEFAULT '首页自动关联', + review_notes TEXT, + manual_corrected BOOLEAN NOT NULL DEFAULT false, + imported_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + ).format(list_table=list_table) + ) + for name, column_type in [ + ("has_front_page", "BOOLEAN NOT NULL DEFAULT false"), + ("front_page_id", "BIGINT"), + ("front_page_source_file", "TEXT"), + ]: + cur.execute( + sql.SQL("ALTER TABLE {list_table} ADD COLUMN IF NOT EXISTS {column} " + column_type).format( + list_table=list_table, + column=sql.Identifier(name), + ) + ) + for constraint_name in { + "ck_patient_lists_inpatient_no_format", + "ck_Patient_Lists_inpatient_no_format", + "ck_patient_lists_inpatient_no_required", + }: + cur.execute( + sql.SQL("ALTER TABLE {list_table} DROP CONSTRAINT IF EXISTS {constraint}").format( + list_table=list_table, + constraint=sql.Identifier(constraint_name), + ) + ) + cur.execute( + sql.SQL("DELETE FROM {list_table} WHERE NULLIF(BTRIM(inpatient_no), '') IS NULL").format( + list_table=list_table + ) + ) + cur.execute( + sql.SQL("UPDATE {list_table} SET inpatient_no = BTRIM(inpatient_no) WHERE inpatient_no <> BTRIM(inpatient_no)").format( + list_table=list_table + ) + ) + cur.execute( + sql.SQL( + """ + WITH ranked AS ( + SELECT + record_id, + ROW_NUMBER() OVER (PARTITION BY BTRIM(inpatient_no) ORDER BY record_id DESC) AS duplicate_rank + FROM {list_table} + WHERE NULLIF(BTRIM(inpatient_no), '') IS NOT NULL + ) + DELETE FROM {list_table} pl + USING ranked + WHERE pl.record_id = ranked.record_id + AND ranked.duplicate_rank > 1 + """ + ).format(list_table=list_table) + ) + cur.execute(sql.SQL("ALTER TABLE {list_table} ALTER COLUMN inpatient_no SET NOT NULL").format(list_table=list_table)) + cur.execute("SELECT 1 FROM pg_constraint WHERE conname = %s", ("ck_patient_lists_inpatient_no_required",)) + if not cur.fetchone(): + cur.execute( + sql.SQL( + "ALTER TABLE {list_table} ADD CONSTRAINT {constraint} CHECK (NULLIF(BTRIM(inpatient_no), '') IS NOT NULL)" + ).format( + list_table=list_table, + constraint=sql.Identifier("ck_patient_lists_inpatient_no_required"), + ) + ) + cur.execute(sql.SQL("COMMENT ON COLUMN {list_table}.has_front_page IS '是否有患者首页:由Patient_FrontPages按住院号自动联动。'").format(list_table=list_table)) + cur.execute(sql.SQL("COMMENT ON COLUMN {list_table}.front_page_id IS '关联的Patient_FrontPages.id。'").format(list_table=list_table)) + cur.execute(sql.SQL("COMMENT ON COLUMN {list_table}.front_page_source_file IS '关联患者首页PDF文件名。'").format(list_table=list_table)) + cur.execute( + sql.SQL("CREATE UNIQUE INDEX IF NOT EXISTS {index_name} ON {list_table}(inpatient_no)").format( + index_name=sql.Identifier("uq_patient_lists_inpatient_no"), + list_table=list_table, + ) + ) + cur.execute( + sql.SQL( + """ + WITH front_pages AS ( + SELECT DISTINCT ON (BTRIM(inpatient_no)) + id, + BTRIM(inpatient_no) AS inpatient_no, + source_file, + COALESCE(patient_name, '') AS patient_name, + gender, + age, + COALESCE(major_department, '') AS major_department, + COALESCE(discharge_dept, admission_dept, '') AS sub_department, + primary_diagnosis, + admission_time, + discharge_time, + hospital_days, + manual_corrected + FROM {table} + WHERE NULLIF(BTRIM(inpatient_no), '') IS NOT NULL + ORDER BY BTRIM(inpatient_no), id DESC + ) + INSERT INTO {list_table} ( + batch_name, major_department, sub_department, source_folder, image_path, image_name, + image_row_no, patient_name, gender, age, inpatient_no, diagnosis, admission_time, + hospital_days, discharge_time, review_status, review_notes, manual_corrected, + has_front_page, front_page_id, front_page_source_file, imported_at + ) + SELECT + 'Patient_FrontPages', + major_department, + sub_department, + 'Patient_FrontPages', + source_file, + source_file, + 0, + patient_name, + gender, + age, + inpatient_no, + primary_diagnosis, + to_char(admission_time, 'YYYY-MM-DD HH24:MI:SS'), + hospital_days, + to_char(discharge_time, 'YYYY-MM-DD HH24:MI:SS'), + '首页自动关联', + '由Patient_FrontPages按住院号自动关联', + manual_corrected, + true, + id, + source_file, + now() + FROM front_pages + ON CONFLICT (inpatient_no) DO UPDATE SET + has_front_page = true, + front_page_id = EXCLUDED.front_page_id, + front_page_source_file = EXCLUDED.front_page_source_file, + patient_name = COALESCE(NULLIF(EXCLUDED.patient_name, ''), {list_table}.patient_name), + gender = EXCLUDED.gender, + age = EXCLUDED.age, + major_department = EXCLUDED.major_department, + sub_department = EXCLUDED.sub_department, + manual_corrected = EXCLUDED.manual_corrected, + imported_at = now() + """ + ).format(table=table, list_table=list_table) + ) + cur.execute( + sql.SQL( + """ + UPDATE {list_table} AS pl + SET has_front_page = false, + front_page_id = NULL, + front_page_source_file = NULL, + imported_at = now() + WHERE has_front_page IS TRUE + AND NOT EXISTS ( + SELECT 1 FROM {table} fp + WHERE BTRIM(fp.inpatient_no) = pl.inpatient_no + ) + """ + ).format(table=table, list_table=list_table) + ) + + +def ensure_patient_frontpage_dedupe_trigger(cur) -> None: + table = table_identifier() + schema = PGTABLE.split(".", 1)[0] if "." in PGTABLE else "public" + base_table = PGTABLE.split(".", 1)[-1] + trigger_name = sql.Identifier(f"trg_{base_table}_dedupe_inpatient_no") + trigger_function = patient_dedupe_trigger_function_identifier(base_table) + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = %s + AND table_name = %s + AND column_name NOT IN ('id', 'inpatient_no', 'review_logs', 'audit_logs') + ORDER BY ordinal_position + """, + (schema, base_table), + ) + update_columns = [row["column_name"] for row in cur.fetchall()] + update_assignments = sql.SQL(",\n ").join( + sql.SQL("{column} = NEW.{column}").format(column=sql.Identifier(column_name)) + for column_name in update_columns + ) + cur.execute( + sql.SQL( + """ + CREATE OR REPLACE FUNCTION {trigger_function}() + RETURNS trigger + LANGUAGE plpgsql + AS $trigger$ + DECLARE + existing_id BIGINT; + BEGIN + NEW.inpatient_no := BTRIM(NEW.inpatient_no); + IF NULLIF(NEW.inpatient_no, '') IS NULL THEN + RETURN NEW; + END IF; + + IF TG_OP = 'INSERT' THEN + SELECT id + INTO existing_id + FROM {table} + WHERE BTRIM(inpatient_no) = NEW.inpatient_no + ORDER BY id DESC + LIMIT 1; + + IF existing_id IS NOT NULL THEN + DELETE FROM {table} + WHERE BTRIM(inpatient_no) = NEW.inpatient_no + AND id <> existing_id; + + UPDATE {table} + SET {update_assignments} + WHERE id = existing_id; + + RETURN NULL; + END IF; + END IF; + + DELETE FROM {table} + WHERE BTRIM(inpatient_no) = NEW.inpatient_no + AND id <> NEW.id; + + RETURN NEW; + END; + $trigger$; + """ + ).format(trigger_function=trigger_function, table=table, update_assignments=update_assignments) + ) + cur.execute( + sql.SQL("DROP TRIGGER IF EXISTS {trigger_name} ON {table}").format( + trigger_name=trigger_name, + table=table, + ) + ) + cur.execute( + sql.SQL( + """ + CREATE TRIGGER {trigger_name} + BEFORE INSERT OR UPDATE OF inpatient_no ON {table} + FOR EACH ROW EXECUTE FUNCTION {trigger_function}() + """ + ).format(trigger_name=trigger_name, table=table, trigger_function=trigger_function) + ) + + +def ensure_patient_lists_trigger(cur) -> None: + table = table_identifier() + list_table = patient_lists_identifier() + base_table = PGTABLE.split(".", 1)[-1] + trigger_name = sql.Identifier(f"trg_{base_table}_sync_patient_lists") + trigger_function = patient_list_trigger_function_identifier(base_table) + cur.execute( + sql.SQL( + """ + CREATE OR REPLACE FUNCTION {trigger_function}() + RETURNS trigger + LANGUAGE plpgsql + AS $trigger$ + BEGIN + IF TG_OP = 'DELETE' THEN + IF NULLIF(BTRIM(OLD.inpatient_no), '') IS NOT NULL THEN + UPDATE {list_table} AS pl + SET has_front_page = false, + front_page_id = NULL, + front_page_source_file = NULL, + imported_at = now() + WHERE pl.inpatient_no = BTRIM(OLD.inpatient_no) + AND NOT EXISTS ( + SELECT 1 FROM {table} fp + WHERE BTRIM(fp.inpatient_no) = BTRIM(OLD.inpatient_no) + ); + END IF; + RETURN OLD; + END IF; + + IF TG_OP = 'UPDATE' + AND NULLIF(BTRIM(OLD.inpatient_no), '') IS NOT NULL + AND BTRIM(OLD.inpatient_no) IS DISTINCT FROM BTRIM(NEW.inpatient_no) THEN + UPDATE {list_table} AS pl + SET has_front_page = false, + front_page_id = NULL, + front_page_source_file = NULL, + imported_at = now() + WHERE pl.inpatient_no = BTRIM(OLD.inpatient_no) + AND NOT EXISTS ( + SELECT 1 FROM {table} fp + WHERE BTRIM(fp.inpatient_no) = BTRIM(OLD.inpatient_no) + ); + END IF; + + IF NULLIF(BTRIM(NEW.inpatient_no), '') IS NOT NULL THEN + INSERT INTO {list_table} ( + batch_name, major_department, sub_department, source_folder, image_path, image_name, + image_row_no, patient_name, gender, age, inpatient_no, diagnosis, admission_time, + hospital_days, discharge_time, review_status, review_notes, manual_corrected, + has_front_page, front_page_id, front_page_source_file, imported_at + ) + VALUES ( + 'Patient_FrontPages', + COALESCE(NEW.major_department, ''), + COALESCE(NEW.discharge_dept, NEW.admission_dept, ''), + 'Patient_FrontPages', + COALESCE(NEW.source_file, ''), + COALESCE(NEW.source_file, ''), + 0, + COALESCE(NEW.patient_name, ''), + NEW.gender, + NEW.age, + BTRIM(NEW.inpatient_no), + NEW.primary_diagnosis, + to_char(NEW.admission_time, 'YYYY-MM-DD HH24:MI:SS'), + NEW.hospital_days, + to_char(NEW.discharge_time, 'YYYY-MM-DD HH24:MI:SS'), + '首页自动关联', + '由Patient_FrontPages触发器按住院号自动关联', + COALESCE(NEW.manual_corrected, false), + true, + NEW.id, + NEW.source_file, + now() + ) + ON CONFLICT (inpatient_no) DO UPDATE SET + has_front_page = true, + front_page_id = EXCLUDED.front_page_id, + front_page_source_file = EXCLUDED.front_page_source_file, + patient_name = COALESCE(NULLIF(EXCLUDED.patient_name, ''), {list_table}.patient_name), + gender = EXCLUDED.gender, + age = EXCLUDED.age, + major_department = EXCLUDED.major_department, + sub_department = EXCLUDED.sub_department, + manual_corrected = EXCLUDED.manual_corrected, + imported_at = now(); + END IF; + + RETURN NEW; + END; + $trigger$; + """ + ).format(trigger_function=trigger_function, table=table, list_table=list_table) + ) + cur.execute( + sql.SQL("DROP TRIGGER IF EXISTS {trigger_name} ON {table}").format( + trigger_name=trigger_name, + table=table, + ) + ) + cur.execute( + sql.SQL( + """ + CREATE TRIGGER {trigger_name} + AFTER INSERT OR UPDATE OR DELETE ON {table} + FOR EACH ROW EXECUTE FUNCTION {trigger_function}() + """ + ).format(trigger_name=trigger_name, table=table, trigger_function=trigger_function) + ) + + +def fetch_review_logs(record_id: int, limit: int = 30) -> list[dict[str, Any]]: + query = sql.SQL("SELECT review_logs FROM {table} WHERE id = %s").format(table=table_identifier()) + with connect() as conn, conn.cursor() as cur: + cur.execute(query, (record_id,)) + row = cur.fetchone() + if not row: + return [] + logs = row.get("review_logs") or [] + if not isinstance(logs, list): + return [] + normalized = [row_to_json(dict(item)) for item in logs if isinstance(item, dict)] + normalized.sort(key=lambda item: (str(item.get("changed_at") or ""), str(item.get("id") or "")), reverse=True) + return normalized[:limit] + + +def fetch_audit_logs(limit: int = 100) -> list[dict[str, Any]]: + ensure_workflow_tables() + query = sql.SQL( + """ + SELECT + p.id AS record_id, + p.source_file, + p.inpatient_no, + p.medical_record_no, + p.patient_name, + p.primary_diagnosis, + p.review_status, + log_item.value AS log, + log_item.ordinality AS log_order + FROM {table} p + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.audit_logs, '[]'::jsonb)) WITH ORDINALITY AS log_item(value, ordinality) + WHERE COALESCE(log_item.value->>'audit_status', '') <> 'pending' + ORDER BY COALESCE(log_item.value->>'updated_at', log_item.value->>'created_at', '') DESC, log_item.ordinality DESC + LIMIT %s + """ + ).format(table=table_identifier()) + with connect() as conn, conn.cursor() as cur: + cur.execute(query, (limit,)) + rows = cur.fetchall() + logs: list[dict[str, Any]] = [] + for row in rows: + base = row_to_json({key: value for key, value in dict(row).items() if key not in {"log", "log_order"}}) + item = row.get("log") or {} + if isinstance(item, dict): + log = {**base, **row_to_json(dict(item))} + log.setdefault("record_id", base.get("record_id")) + log.setdefault("source_file", base.get("source_file")) + logs.append(log) + return logs + + +def insert_review_log( + cur, + record_id: int, + source_file: str, + changed_fields: list[dict[str, Any]], + manual_note: str, + changed_by: str = "web", + ai_result: Any = None, +) -> None: + changed_at = datetime.now().isoformat(timespec="seconds") + log = { + "id": datetime.now().strftime("%Y%m%d%H%M%S%f"), + "record_id": record_id, + "source_file": source_file, + "changed_at": changed_at, + "changed_by": changed_by, + "manual_note": manual_note, + "changed_fields": json_ready_deep(changed_fields), + } + if ai_result is not None: + log["ai_result"] = json_ready_deep(ai_result) + cur.execute( + sql.SQL( + """ + UPDATE {table} + SET review_logs = COALESCE(review_logs, '[]'::jsonb) || %s::jsonb + WHERE id = %s + """ + ).format(table=table_identifier()), + ( + json.dumps([log], ensure_ascii=False), + record_id, + ), + ) + + +def build_record_updates( + record_id: int, + fields: dict[str, Any], + manual_note: str = "", + note_prefix: str = "人工复核", + force_reviewed: bool = True, +) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: + before = fetch_record(record_id) + updates: dict[str, Any] = {} + for field, value in fields.items(): + if field not in EDITABLE_FIELDS: + continue + updates[field] = parse_field_value(field, value) + + for number_field in ["medical_record_no", "front_page_medical_record_no"]: + if updates.get(number_field): + normalized_number = digits(updates[number_field], 10) + if normalized_number: + updates[number_field] = normalized_number + + if "inpatient_no" in updates and updates["inpatient_no"] is not None: + updates["inpatient_no"] = str(updates["inpatient_no"]).strip() + else: + preview = {**before, **updates} + derived_inpatient_no = build_inpatient_no_from_record(preview) + if derived_inpatient_no: + updates["inpatient_no"] = derived_inpatient_no + if "inpatient_no" in updates and not str(updates["inpatient_no"] or "").strip(): + raise HTTPException(status_code=400, detail="患者号不能为空") + + manual_note = manual_note.strip() + if manual_note: + current = before.get("review_notes") or [] + current_notes = current if isinstance(current, list) else [current] + current_notes.append(f"{note_prefix}({datetime.now().strftime('%Y-%m-%d %H:%M:%S')}): {manual_note}") + updates["review_notes"] = current_notes + + changed_fields: list[dict[str, Any]] = [] + for field, value in updates.items(): + if field not in FIELD_META: + continue + old_value = before.get(field) + if comparable(old_value) == comparable(value): + continue + changed_fields.append( + { + "field": field, + "label": FIELD_META[field]["label"], + "old": json_ready_deep(old_value), + "new": json_ready_deep(value), + } + ) + + should_mark_manual = force_reviewed or bool(changed_fields) + if force_reviewed and before.get("review_status") != "reviewed": + changed_fields.append( + { + "field": "review_status", + "label": "复核状态", + "old": json_ready_deep(before.get("review_status")), + "new": "reviewed", + } + ) + if should_mark_manual and before.get("manual_corrected") is not True: + changed_fields.append( + { + "field": "manual_corrected", + "label": "人工修正", + "old": json_ready_deep(before.get("manual_corrected")), + "new": True, + } + ) + + return updates, changed_fields, before + + +def apply_record_updates( + cur, + record_id: int, + updates: dict[str, Any], + changed_fields: list[dict[str, Any]], + before: dict[str, Any], + manual_note: str = "", + force_reviewed: bool = True, +) -> None: + assignments = [] + values: list[Any] = [] + for field, value in updates.items(): + assignments.append(sql.SQL("{} = %s").format(sql.Identifier(field))) + values.append(psycopg2.extras.Json(value, dumps=lambda obj: json.dumps(obj, ensure_ascii=False)) if field in JSON_DB_FIELDS else value) + if force_reviewed: + assignments.append(sql.SQL("review_status = 'reviewed'")) + assignments.append(sql.SQL("manual_corrected = TRUE")) + elif changed_fields: + assignments.append(sql.SQL("manual_corrected = TRUE")) + if before.get("review_status") == "auto_pass": + assignments.append(sql.SQL("review_status = 'reviewed'")) + + if assignments: + query = sql.SQL("UPDATE {table} SET {assignments} WHERE id = %s").format( + table=table_identifier(), + assignments=sql.SQL(", ").join(assignments), + ) + cur.execute(query, [*values, record_id]) + if cur.rowcount != 1: + raise HTTPException(status_code=404, detail="记录不存在") + if changed_fields or manual_note.strip(): + insert_review_log(cur, record_id, before.get("source_file", ""), changed_fields, manual_note.strip()) + + +def safe_child(root: Path, child_name: str) -> Path: + if Path(child_name).name != child_name: + raise HTTPException(status_code=400, detail="非法文件名") + path = (root / child_name).resolve() + if root not in path.parents and path != root: + raise HTTPException(status_code=400, detail="非法路径") + return path + + +def get_pdf_path(source_file: str) -> Path | None: + path = safe_child(PDF_DIR, source_file) + return path if path.exists() and path.is_file() else None + + +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 as exc: + start = text.find("{") + end = text.rfind("}") + if start >= 0 and end > start: + try: + return json.loads(text[start : end + 1]) + except json.JSONDecodeError: + pass + return { + "decision": "confirm", + "confidence": 0, + "summary": f"Kimi 返回 JSON 无法解析:{exc.msg}", + "raw_text": content[:4000], + } + + +def render_pdf_page_data_url(pdf_path: Path, dpi: int = 96, page_index: int = 0, clip: Any | None = None) -> str: + try: + import fitz # type: ignore[import-not-found] + except ImportError as exc: + raise HTTPException(status_code=500, detail="Web 容器缺少 PyMuPDF,无法渲染 PDF 供 AI 核验") from exc + + with fitz.open(str(pdf_path)) as document: + if document.page_count < 1: + raise HTTPException(status_code=400, detail="PDF 没有可渲染页面") + page = document.load_page(max(0, min(page_index, document.page_count - 1))) + matrix = fitz.Matrix(dpi / 72, dpi / 72) + pixmap = page.get_pixmap(matrix=matrix, alpha=False, clip=clip) + encoded = base64.b64encode(pixmap.tobytes("png")).decode("utf-8") + return f"data:image/png;base64,{encoded}" + + +def normalize_pdf_text(text: str) -> str: + return re.sub(r"\s+", "", text or "") + + +def record_review_text(record: dict[str, Any]) -> str: + parts: list[str] = [] + for key in ("review_notes", "quality_notes", "auto_corrections"): + value = record.get(key) + if isinstance(value, list): + parts.extend(json.dumps(item, ensure_ascii=False) if not isinstance(item, str) else item for item in value) + elif value: + parts.append(str(value)) + return ";".join(parts) + + +def relevant_pdf_modules(record: dict[str, Any]) -> list[dict[str, Any]]: + text = record_review_text(record) + normalized = normalize_pdf_text(text) + selected: list[dict[str, Any]] = [] + for module in PDF_MODULE_DEFINITIONS: + if any(normalize_pdf_text(keyword) in normalized for keyword in module["note_keywords"]): + selected.append(module) + if not selected: + selected = [module for module in PDF_MODULE_DEFINITIONS if module["name"] in {"诊断表格", "手术表格"}] + return selected[:4] + + +def clip_text_from_blocks(blocks: list[dict[str, Any]], page_index: int, clip: Any) -> str: + lines: list[str] = [] + for block in blocks: + if block["page_index"] != page_index: + continue + rect = block["rect"] + if rect.intersects(clip): + text = " ".join(str(block["text"]).split()) + if text: + lines.append(text) + joined = "\n".join(lines) + return joined[:2200] + + +def add_keyword_from_value(keywords: list[str], value: Any) -> None: + if value in {None, ""}: + return + text = str(value).strip() + if len(text) >= 2: + keywords.append(text) + + +def record_module_keywords(record: dict[str, Any], module_name: str) -> list[str]: + keywords: list[str] = [] + if module_name == "诊断表格": + for field in ("outpatient_diagnosis", "outpatient_diagnosis_code", "primary_diagnosis", "primary_diagnosis_code", "pathology_diagnosis", "pathology_diagnosis_code"): + add_keyword_from_value(keywords, record.get(field)) + diagnoses = record.get("discharge_diagnoses") + if isinstance(diagnoses, list): + for row in diagnoses[:8]: + if isinstance(row, dict): + for key in ("出院诊断", "疾病编码", "诊断名称", "诊断编码"): + add_keyword_from_value(keywords, row.get(key)) + elif module_name == "手术表格": + operations = record.get("operations") + if isinstance(operations, list): + for row in operations[:6]: + if isinstance(row, dict): + for key in ("手术操作名称", "手术操作编码", "手术操作日期"): + add_keyword_from_value(keywords, row.get(key)) + elif module_name == "地址联系人": + for field in ("current_address", "household_address", "employer_address", "contact_name", "contact_address", "contact_phone"): + add_keyword_from_value(keywords, record.get(field)) + elif module_name == "基本信息": + for field in ("patient_name", "medical_record_no", "inpatient_no", "id_card_no", "admission_time", "discharge_time"): + add_keyword_from_value(keywords, record.get(field)) + elif module_name == "离院费用": + for field in ("total_cost", "self_pay_amount", "discharge_disposition_code", "receiving_org_name"): + add_keyword_from_value(keywords, record.get(field)) + return keywords + + +def extract_pdf_module_context(pdf_path: Path, record: dict[str, Any]) -> dict[str, Any]: + try: + import fitz # type: ignore[import-not-found] + except ImportError as exc: + raise HTTPException(status_code=500, detail="Web 容器缺少 PyMuPDF,无法提取 PDF 文本") from exc + + modules = relevant_pdf_modules(record) + contexts: list[dict[str, Any]] = [] + all_blocks: list[dict[str, Any]] = [] + with fitz.open(str(pdf_path)) as document: + for page_index in range(document.page_count): + page = document.load_page(page_index) + for block in page.get_text("blocks"): + if len(block) < 5: + continue + text = str(block[4] or "").strip() + if not text: + continue + all_blocks.append( + { + "page_index": page_index, + "rect": fitz.Rect(block[:4]), + "text": text, + "normalized": normalize_pdf_text(text), + } + ) + + for module in modules: + module_keywords = [*module["keywords"], *record_module_keywords(record, module["name"])] + keywords = [normalize_pdf_text(keyword) for keyword in module_keywords] + hits = [block for block in all_blocks if any(keyword and keyword in block["normalized"] for keyword in keywords)] + if not hits: + continue + page_counts: dict[int, int] = {} + for hit in hits: + page_counts[hit["page_index"]] = page_counts.get(hit["page_index"], 0) + 1 + page_index = max(page_counts, key=page_counts.get) + page = document.load_page(page_index) + page_hits = [hit for hit in hits if hit["page_index"] == page_index] + min_y = min(hit["rect"].y0 for hit in page_hits) + max_y = max(hit["rect"].y1 for hit in page_hits) + tail = float(module.get("tail") or 420) + clip = fitz.Rect( + max(page.rect.x0, page.rect.x0 + 18), + max(page.rect.y0, min_y - 60), + min(page.rect.x1, page.rect.x1 - 18), + min(page.rect.y1, max(max_y + tail, min_y + 220)), + ) + contexts.append( + { + "name": module["name"], + "page": page_index + 1, + "bbox": [round(clip.x0, 1), round(clip.y0, 1), round(clip.x1, 1), round(clip.y1, 1)], + "text": clip_text_from_blocks(all_blocks, page_index, clip), + "image_url": render_pdf_page_data_url(pdf_path, dpi=120, page_index=page_index, clip=clip), + } + ) + + full_text = "\n".join(" ".join(str(block["text"]).split()) for block in all_blocks) + return { + "modules": contexts, + "full_text_excerpt": full_text[:3500], + } + + +def ai_record_snapshot(record: dict[str, Any]) -> dict[str, Any]: + keys = [ + "source_file", + "inpatient_no", + "medical_record_no", + "front_page_medical_record_no", + "patient_name", + "gender", + "birth_date", + "age", + "admission_time", + "admission_dept", + "discharge_time", + "discharge_dept", + "hospital_days", + "outpatient_diagnosis", + "outpatient_diagnosis_code", + "primary_diagnosis", + "primary_diagnosis_code", + "primary_admission_condition", + "discharge_diagnoses", + "operations", + "pathology_diagnosis", + "pathology_diagnosis_code", + "total_cost", + "self_pay_amount", + "quality_status", + "quality_notes", + "review_notes", + ] + return {key: record.get(key) for key in keys} + + +def build_ai_prompt(record: dict[str, Any], pdf_context: dict[str, Any]) -> str: + snapshot = json.dumps(ai_record_snapshot(record), ensure_ascii=False, indent=2) + context_for_prompt = { + "modules": [ + { + "name": item["name"], + "page": item["page"], + "bbox": item["bbox"], + "pdf_text": item["text"], + } + for item in pdf_context.get("modules", []) + ], + "full_text_excerpt": pdf_context.get("full_text_excerpt", ""), + } + context_json = json.dumps(context_for_prompt, ensure_ascii=False, indent=2) + return f""" +请对这份住院病案首页做视觉核验,只返回 JSON,不要输出 Markdown。 + +核验目标: +1. 优先核对“PDF定位文本”和对应的局部截图,局部截图是按 PDF 中文本特征截取出来的区域。 +2. 将 PDF 文本、局部截图中的可见内容,与下面的结构化字段逐项对比。 +3. 只有当复核提示被 PDF 明确证明为误报,且结构化字段与 PDF 可见内容一致、完整、无实质质量问题时,decision 才能返回 "ok"。 +4. 如果复核提示是编码缺失/格式异常/错位/不一致,而 PDF 对应区域也显示为空白、缺失、异常或不清晰,这说明问题被证实,decision 必须返回 "confirm"。 +5. 如果图片不清晰、字段不一致、编码缺失/错位、手术/诊断仍有疑点,decision 返回 "confirm"。 +6. 如果能从 PDF 读出明确修正值,请放入 suggested_updates,但 decision 仍返回 "confirm",等待人工确认。 +7. 不要编造 PDF 中看不见的内容。 +8. 如果手术表格中能看到“手术及操作编码”列,但对应单元格为空,必须写“编码栏可见但为空白”,不要写“编码区域未在首页显示”。 +9. 手术操作名称可能因为换行被结构化解析截断;如果 PDF定位文本或局部截图中显示完整多行名称,请把完整名称放入 suggested_updates。 + +必须返回这个 JSON 结构: +{{ + "decision": "ok 或 confirm", + "confidence": 0.0, + "issue_resolution": "false_positive/confirmed_problem/uncertain/update_suggested", + "confirmed_issue": false, + "summary": "一句话结论,60字以内", + "method": "Kimi视觉核验:PDF文本定位+局部截图,对照复核定位和结构化字段", + "evidence": [ + {{"target": "核验点", "pdf_value": "PDF图片值", "structured_value": "结构化值", "result": "match/mismatch/uncertain", "note": "30字以内"}} + ], + "suggested_updates": [ + {{"field": "字段名", "current": "当前值", "pdf_value": "PDF图片建议值", "reason": ""}} + ] +}} + +evidence 最多返回 3 条,只保留最关键依据;没有明确修正值时 suggested_updates 返回 []。 + +结构化字段: +{snapshot} + +PDF定位文本: +{context_json} +""".strip() + + +def call_kimi_ai_review(record: dict[str, Any]) -> dict[str, Any]: + settings = public_kimi_settings(load_local_settings().get("kimi") or {}) + if not settings["available"]: + raise HTTPException(status_code=400, detail="Kimi AI 核验未启用或未配置 API Key") + pdf_path = get_pdf_path(record.get("source_file") or "") + if not pdf_path: + raise HTTPException(status_code=404, detail="PDF 文件不存在,无法 AI 核验") + + pdf_context = extract_pdf_module_context(pdf_path, record) + content_parts: list[dict[str, Any]] = [{"type": "text", "text": build_ai_prompt(record, pdf_context)}] + for item in pdf_context.get("modules", [])[:4]: + content_parts.append({"type": "text", "text": f"局部截图:{item['name']},第 {item['page']} 页,bbox={item['bbox']}"}) + content_parts.append({"type": "image_url", "image_url": {"url": item["image_url"]}}) + if not pdf_context.get("modules"): + content_parts.append({"type": "text", "text": "未能定位到局部模块,以下为首页整页截图。"}) + content_parts.append({"type": "image_url", "image_url": {"url": render_pdf_page_data_url(pdf_path)}}) + + payload = { + "model": settings["model"], + "temperature": 0.6, + "max_tokens": 1600, + "thinking": {"type": "disabled"}, + "response_format": {"type": "json_object"}, + "messages": [ + {"role": "system", "content": "你是严谨的病案首页视觉核验助手,只输出 JSON。"}, + { + "role": "user", + "content": content_parts, + }, + ], + } + request = urllib.request.Request( + f"{settings['api_base'].rstrip('/')}/chat/completions", + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {kimi_api_key()}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=90) 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 HTTPException(status_code=502, detail=f"Kimi API 返回错误 {exc.code}: {detail}") from exc + except urllib.error.URLError as exc: + raise HTTPException(status_code=502, detail=f"Kimi API 调用失败:{exc}") from exc + + message = data["choices"][0].get("message") or {} + content = message.get("content") or message.get("reasoning_content") or "" + parsed = parse_json_content(content) + if not isinstance(parsed, dict): + parsed = {"decision": "confirm", "summary": "Kimi 返回 JSON 不是对象", "raw_response": content} + return { + "model": settings["model"], + "checked_at": datetime.now().isoformat(timespec="seconds"), + "pdf_context": { + "modules": [ + {key: item[key] for key in ("name", "page", "bbox", "text")} + for item in pdf_context.get("modules", []) + ], + "full_text_excerpt": pdf_context.get("full_text_excerpt", ""), + }, + "raw_response": content, + "parsed": parsed, + } + + +def ai_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + return str(value or "").strip().lower() in {"true", "yes", "1", "是", "有", "确认"} + + +def ai_join_text(value: Any) -> str: + if isinstance(value, str): + return value + if isinstance(value, list): + return " ".join(ai_join_text(item) for item in value) + if isinstance(value, dict): + return " ".join(ai_join_text(item) for item in value.values()) + return str(value or "") + + +def ai_has_confirmed_problem(parsed: dict[str, Any]) -> bool: + resolution = str(parsed.get("issue_resolution") or "").strip().lower() + if ai_bool(parsed.get("confirmed_issue")): + return True + if resolution in {"confirmed_problem", "uncertain", "update_suggested", "problem", "待确认", "已证实"}: + return True + + suggested_updates = parsed.get("suggested_updates") + if isinstance(suggested_updates, list) and suggested_updates: + return True + + evidence = parsed.get("evidence") + if isinstance(evidence, list): + for item in evidence: + if not isinstance(item, dict): + continue + result = str(item.get("result") or "").strip().lower() + if result in {"mismatch", "uncertain", "missing", "problem"}: + return True + + if resolution in {"false_positive", "ok", "no_issue", "误报", "无问题"}: + return False + + text = ai_join_text( + { + "summary": parsed.get("summary"), + "evidence": parsed.get("evidence"), + } + ) + if any(keyword in text for keyword in ("需人工", "需要人工", "待确认")): + return True + has_problem_word = any(keyword in text for keyword in AI_CONFIRMED_PROBLEM_KEYWORDS) + has_qualifier = any(keyword in text for keyword in AI_CONFIRMED_PROBLEM_QUALIFIERS) + return bool(has_problem_word and has_qualifier) + + +def ai_status_from_result(result: dict[str, Any]) -> str: + parsed = result.get("parsed") if isinstance(result.get("parsed"), dict) else {} + decision = str(parsed.get("decision") or "").lower() + confidence = parsed.get("confidence") + try: + confidence_value = float(confidence) + except (TypeError, ValueError): + confidence_value = 0.0 + if ai_has_confirmed_problem(parsed): + return AI_PENDING_STATUS + if decision in {"ok", "pass", "no_issue", "无问题", "通过"} and confidence_value >= 0.65: + return AI_NO_ISSUE_STATUS + return AI_PENDING_STATUS + + +def ai_review_note(status: str, result: dict[str, Any]) -> str: + parsed = result.get("parsed") if isinstance(result.get("parsed"), dict) else {} + summary = str(parsed.get("summary") or "").strip() + confidence = parsed.get("confidence") + confidence_text = f",置信度 {confidence}" if confidence not in {None, ""} else "" + verdict = "未发现问题" if status == AI_NO_ISSUE_STATUS else "需要人工确认" + return f"Kimi视觉核验({datetime.now().strftime('%Y-%m-%d %H:%M:%S')}): {verdict}{confidence_text}。{summary}".strip() + + +def apply_ai_review(record_id: int) -> dict[str, Any]: + before = fetch_record(record_id) + result = call_kimi_ai_review(before) + new_status = ai_status_from_result(result) + note = ai_review_note(new_status, result) + previous_notes = before.get("review_notes") if isinstance(before.get("review_notes"), list) else [] + new_notes = [note] if new_status == AI_NO_ISSUE_STATUS else [*previous_notes, note] + changed_fields = [ + { + "field": "ai_method", + "label": "AI核验方式", + "old": "", + "new": "Kimi视觉核验:PDF文本定位+局部截图,对照复核定位和结构化字段", + }, + { + "field": "review_status", + "label": "复核状态", + "old": json_ready_deep(before.get("review_status")), + "new": new_status, + }, + { + "field": "review_notes", + "label": "复核备注", + "old": json_ready_deep(before.get("review_notes")), + "new": json_ready_deep(new_notes), + }, + ] + with connect() as conn, conn.cursor() as cur: + cur.execute( + sql.SQL("UPDATE {table} SET review_status = %s, review_notes = %s::jsonb WHERE id = %s").format(table=table_identifier()), + (new_status, json.dumps(new_notes, ensure_ascii=False), record_id), + ) + insert_review_log(cur, record_id, before.get("source_file", ""), changed_fields, note, changed_by="Kimi AI", ai_result=result) + conn.commit() + return {"record_id": record_id, "status": new_status, "result": result} + + +def apply_ai_review_with_retry(record_id: int, attempts: int = 2) -> dict[str, Any]: + last_exc: Exception | None = None + for attempt in range(attempts): + try: + return apply_ai_review(record_id) + except Exception as exc: # noqa: BLE001 + last_exc = exc + if attempt + 1 < attempts: + time.sleep(4 + attempt * 4) + if last_exc: + raise last_exc + raise RuntimeError("AI核验失败") + + +def ai_target_ids(scope: str, record_id: int | None) -> list[int]: + if scope not in {"current", "five", "all"}: + raise HTTPException(status_code=400, detail="AI核验范围只能是 current/five/all") + if scope in {"current", "five"} and not record_id: + raise HTTPException(status_code=400, detail="缺少当前记录 ID") + if scope == "current": + return [int(record_id)] + + where = sql.SQL("review_status = 'needs_review'") + params: list[Any] = [] + if scope == "five": + where = sql.SQL("review_status = 'needs_review' AND id > %s") + params.append(record_id) + limit_sql = sql.SQL("LIMIT 5") + else: + if record_id: + where = sql.SQL("review_status = 'needs_review' AND id > %s") + params.append(record_id) + limit_sql = sql.SQL("") + query = sql.SQL("SELECT id FROM {table} WHERE {where} ORDER BY id {limit}").format( + table=table_identifier(), + where=where, + limit=limit_sql, + ) + with connect() as conn, conn.cursor() as cur: + cur.execute(query, params) + rows = cur.fetchall() + return [int(row["id"]) for row in rows] + + +def update_ai_job(**updates: Any) -> dict[str, Any]: + with AI_JOB_LOCK: + AI_REVIEW_JOB.update(updates) + return dict(AI_REVIEW_JOB) + + +def is_ai_stop_error(message: str) -> bool: + lower = message.lower() + return any(marker in lower for marker in AI_STOP_ERROR_MARKERS) + + +def append_ai_job_error(record_id: int, message: str) -> None: + errors = AI_REVIEW_JOB.setdefault("errors", []) + errors.append({"record_id": record_id, "message": message}) + if len(errors) > AI_JOB_ERROR_LIMIT: + del errors[: len(errors) - AI_JOB_ERROR_LIMIT] + + +def run_ai_review_job(scope: str, ids: list[int]) -> None: + settings = public_kimi_settings(load_local_settings().get("kimi") or {}) + concurrency = min(max(1, int(settings.get("concurrency") or 3)), max(1, len(ids))) + stop_event = threading.Event() + update_ai_job( + running=True, + scope=scope, + total=len(ids), + processed=0, + ok=0, + pending=0, + failed=0, + concurrency=concurrency, + message="AI核验中", + errors=[], + started_at=datetime.now().isoformat(timespec="seconds"), + finished_at="", + last_record_id=None, + ) + record_queue: Queue[int] = Queue() + for record_id in ids: + record_queue.put(record_id) + + def worker() -> None: + try: + while not stop_event.is_set(): + try: + record_id = record_queue.get_nowait() + except Empty: + return + try: + item = apply_ai_review_with_retry(record_id) + status = item.get("status") + with AI_JOB_LOCK: + AI_REVIEW_JOB["processed"] += 1 + AI_REVIEW_JOB["last_record_id"] = record_id + if status == AI_NO_ISSUE_STATUS: + AI_REVIEW_JOB["ok"] += 1 + elif status == AI_PENDING_STATUS: + AI_REVIEW_JOB["pending"] += 1 + except Exception as exc: # noqa: BLE001 + message = str(getattr(exc, "detail", exc)) + with AI_JOB_LOCK: + AI_REVIEW_JOB["processed"] += 1 + AI_REVIEW_JOB["failed"] += 1 + AI_REVIEW_JOB["last_record_id"] = record_id + AI_REVIEW_JOB["message"] = message + append_ai_job_error(record_id, message) + if is_ai_stop_error(message): + AI_REVIEW_JOB["message"] = f"AI核验已暂停:{message}" + stop_event.set() + finally: + record_queue.task_done() + except Exception as exc: # noqa: BLE001 + with AI_JOB_LOCK: + AI_REVIEW_JOB["message"] = str(exc) + + workers = [ + threading.Thread(target=worker, name=f"kimi-ai-worker-{index + 1}", daemon=True) + for index in range(concurrency) + ] + for thread in workers: + thread.start() + for thread in workers: + thread.join() + refresh_status_snapshot(source="ai") + with AI_JOB_LOCK: + failed = int(AI_REVIEW_JOB.get("failed") or 0) + stopped = stop_event.is_set() + message = str(AI_REVIEW_JOB.get("message") or "") + update_ai_job( + running=False, + message=message if stopped else ("AI核验完成" if failed == 0 else f"AI核验完成,失败 {failed} 条"), + finished_at=datetime.now().isoformat(timespec="seconds"), + ) + + +def mark_ai_no_issue_reviewed() -> int: + changed_at = datetime.now().isoformat(timespec="seconds") + note = f"批量确认AI复核-无问题({datetime.now().strftime('%Y-%m-%d %H:%M:%S')}): 确认为已人工复核" + query = sql.SQL( + """ + UPDATE {table} + SET review_status = 'reviewed', + manual_corrected = TRUE, + review_logs = COALESCE(review_logs, '[]'::jsonb) || jsonb_build_array( + jsonb_build_object( + 'id', %s || id::text, + 'record_id', id, + 'source_file', source_file, + 'changed_at', %s, + 'changed_by', 'web', + 'manual_note', %s, + 'changed_fields', jsonb_build_array( + jsonb_build_object('field', 'review_status', 'label', '复核状态', 'old', review_status, 'new', 'reviewed'), + jsonb_build_object('field', 'manual_corrected', 'label', '人工修正', 'old', COALESCE(manual_corrected, false), 'new', true) + ) + ) + ) + WHERE review_status = 'AI复核-无问题' + """ + ).format(table=table_identifier()) + with connect() as conn, conn.cursor() as cur: + cur.execute(query, (datetime.now().strftime("%Y%m%d%H%M%S%f"), changed_at, note)) + count = cur.rowcount + conn.commit() + refresh_status_snapshot(source="bulk") + return int(count) + + +def submit_reviewed_records() -> int: + changed_at = datetime.now().isoformat(timespec="seconds") + note = f"一键提交已人工复核项目({datetime.now().strftime('%Y-%m-%d %H:%M:%S')}): 已从患者首页复核工作台隐藏" + query = sql.SQL( + """ + UPDATE {table} + SET review_status = '已提交', + review_logs = COALESCE(review_logs, '[]'::jsonb) || jsonb_build_array( + jsonb_build_object( + 'id', %s || id::text, + 'record_id', id, + 'source_file', source_file, + 'changed_at', %s, + 'changed_by', 'web', + 'manual_note', %s, + 'changed_fields', jsonb_build_array( + jsonb_build_object('field', 'review_status', 'label', '复核状态', 'old', review_status, 'new', '已提交') + ) + ) + ) + WHERE review_status = 'reviewed' + """ + ).format(table=table_identifier()) + with connect() as conn, conn.cursor() as cur: + cur.execute(query, (datetime.now().strftime("%Y%m%d%H%M%S%f"), changed_at, note)) + count = cur.rowcount + conn.commit() + refresh_status_snapshot(source="submit") + return int(count) + + + +def parse_field_value(field: str, value: Any) -> Any: + meta = FIELD_META[field] + field_type = meta["type"] + if value == "": + return [] if field in JSON_FIELDS else None + if field in JSON_FIELDS: + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail=f"{meta['label']} 不是合法 JSON:{exc}") from exc + return value + if field in INTEGER_FIELDS: + return None if value is None else int(value) + if field in NUMERIC_FIELDS: + return None if value is None else Decimal(str(value)) + return value + + +def fetch_record(record_id: int) -> dict[str, Any]: + query = sql.SQL("SELECT * FROM {table} WHERE id = %s").format(table=table_identifier()) + with connect() as conn, conn.cursor() as cur: + cur.execute(query, (record_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="记录不存在") + record = row_to_json(dict(row)) + record["pdf_url"] = f"/api/pdf/{record['source_file']}" if get_pdf_path(record["source_file"]) else "" + + record["review_logs"] = fetch_review_logs(record_id) + record["last_activity_at"] = record["review_logs"][0].get("changed_at") if record["review_logs"] else None + return record + + +@app.get("/") +def index(): + return FileResponse(STATIC_DIR / "index.html") + + +@app.get("/favicon.ico") +def favicon(): + return Response(status_code=204) + + +@app.post("/api/auth/login") +def login(payload: LoginPayload, response: Response): + user = authenticate_user(payload.username, payload.password) + if not user: + raise HTTPException(status_code=401, detail="用户名或密码错误") + token = secrets.token_urlsafe(32) + SESSIONS[token] = { + **user, + "login_at": datetime.now().isoformat(timespec="seconds"), + } + response.set_cookie( + SESSION_COOKIE, + token, + httponly=True, + samesite="lax", + max_age=12 * 60 * 60, + ) + return {"authenticated": True, "user": SESSIONS[token]} + + +@app.post("/api/auth/logout") +def logout(request: Request, response: Response): + token = request.cookies.get(SESSION_COOKIE, "") + if token: + SESSIONS.pop(token, None) + response.delete_cookie(SESSION_COOKIE) + return {"ok": True} + + +@app.get("/api/auth/me") +def auth_me(request: Request): + user = session_from_request(request) + if not user: + return {"authenticated": False, "user": None} + return {"authenticated": True, "user": user} + + +@app.get("/api/status") +def status(): + data = load_local_settings() + snapshot = data.get("status_snapshot") or default_status_snapshot() + system = normalize_system_settings(data.get("system") or {}) + snapshot["next_check_at"] = next_status_check_at(system) + try: + query = sql.SQL( + """ + SELECT + count(*) AS total, + count(*) FILTER (WHERE review_status IN ('needs_review', 'AI复核-待确认')) AS review_needed, + count(*) FILTER (WHERE review_status = 'needs_review') AS needs_review, + count(*) FILTER (WHERE review_status = 'AI复核-无问题') AS ai_passed, + count(*) FILTER (WHERE review_status = 'AI复核-待确认') AS ai_pending, + count(*) FILTER (WHERE review_status = 'reviewed') AS reviewed, + count(*) FILTER (WHERE review_status = '已提交') AS submitted, + count(*) FILTER (WHERE manual_corrected IS TRUE) AS manual_corrected + FROM {table} + """ + ).format(table=table_identifier()) + with connect() as conn, conn.cursor() as cur: + cur.execute(query) + snapshot.update(row_to_json(dict(cur.fetchone()))) + snapshot["database"] = "online" + snapshot["message"] = snapshot.get("message") or "连接正常" + snapshot["checked_at"] = status_now().isoformat(timespec="seconds") + except Exception as exc: # noqa: BLE001 + snapshot["database"] = "offline" + snapshot["message"] = str(exc) + return snapshot + + +@app.get("/api/schema") +def schema(): + return {"groups": FIELD_GROUPS} + + +@app.get("/api/overview") +def overview(): + query = sql.SQL( + """ + SELECT + count(*) AS total, + count(*) FILTER (WHERE review_status IN ('needs_review', 'AI复核-待确认')) AS review_queue, + count(*) FILTER (WHERE review_status = 'needs_review') AS needs_review, + count(*) FILTER (WHERE review_status = 'AI复核-无问题') AS ai_passed, + count(*) FILTER (WHERE review_status = 'AI复核-待确认') AS ai_pending, + count(*) FILTER (WHERE review_status = 'auto_corrected') AS auto_corrected, + count(*) FILTER (WHERE review_status = 'reviewed') AS reviewed, + count(*) FILTER (WHERE review_status = '已提交') AS submitted, + count(*) FILTER (WHERE review_status = 'auto_pass') AS auto_passed, + count(*) FILTER (WHERE manual_corrected IS TRUE) AS manual_corrected + FROM {table} + """ + ).format(table=table_identifier()) + with connect() as conn, conn.cursor() as cur: + cur.execute(query) + summary = row_to_json(dict(cur.fetchone())) + summary.update({"audit_total": 0, "audit_pending": 0, "audit_passed": 0, "audit_failed": 0, "audit_unsure": 0}) + return { + "summary": summary, + "recent_logs": [], + } + + +@app.get("/api/records") +def list_records(q: str = "", status_filter: str = "review_all", limit: int = 300, offset: int = 0): + limit = max(50, min(int(limit), 500)) + offset = max(0, int(offset)) + clauses = [] + params: list[Any] = [] + if q: + like = f"%{q}%" + clauses.append("(source_file ILIKE %s OR inpatient_no ILIKE %s OR medical_record_no ILIKE %s OR patient_name ILIKE %s OR primary_diagnosis ILIKE %s)") + params.extend([like, like, like, like, like]) + if status_filter == "review_all": + clauses.append("review_status IN ('needs_review', 'reviewed', 'AI复核-无问题', 'AI复核-待确认')") + elif status_filter != "all": + if status_filter == "reviewed": + clauses.append("review_status = 'reviewed'") + else: + clauses.append("review_status = %s") + params.append(status_filter) + where_sql = sql.SQL("") + if clauses: + where_sql = sql.SQL("WHERE ") + sql.SQL(" AND ").join(sql.SQL(clause) for clause in clauses) + + query = sql.SQL( + """ + SELECT + id, source_file, inpatient_no, medical_record_no, patient_name, review_status, manual_corrected, + major_department, discharge_dept, primary_diagnosis, primary_diagnosis_code, + contact_phone, + NULLIF(review_logs->-1->>'changed_at', '')::timestamp AS last_activity_at + FROM {table} + {where} + ORDER BY + last_activity_at DESC NULLS LAST, + CASE review_status + WHEN 'needs_review' THEN 1 + WHEN 'AI复核-待确认' THEN 2 + WHEN 'AI复核-无问题' THEN 3 + WHEN 'auto_corrected' THEN 4 + WHEN 'reviewed' THEN 5 + WHEN '已提交' THEN 6 + ELSE 7 + END, + id + LIMIT %s OFFSET %s + """ + ).format(table=table_identifier(), where=where_sql) + with connect() as conn, conn.cursor() as cur: + cur.execute(query, [*params, limit + 1, offset]) + rows = [row_to_json(dict(row)) for row in cur.fetchall()] + has_more = len(rows) > limit + rows = rows[:limit] + for row in rows: + row["has_pdf"] = get_pdf_path(row["source_file"]) is not None + + return {"records": rows, "limit": limit, "offset": offset, "has_more": has_more} + + +@app.get("/api/records/{record_id}") +def get_record(record_id: int): + return {"record": fetch_record(record_id)} + + +@app.post("/api/records/{record_id}") +def update_record(record_id: int, payload: UpdatePayload): + updates, changed_fields, before = build_record_updates( + record_id, + payload.fields, + payload.manual_note, + payload.note_prefix or "人工复核", + force_reviewed=True, + ) + if not updates and not changed_fields: + raise HTTPException(status_code=400, detail="没有可保存字段") + with connect() as conn, conn.cursor() as cur: + apply_record_updates(cur, record_id, updates, changed_fields, before, payload.manual_note, force_reviewed=True) + conn.commit() + return {"ok": True, "record": fetch_record(record_id)} + + +@app.post("/api/audit/sample") +def create_audit_sample(source: str = "reviewed", count: int = 5): + ensure_workflow_tables() + if source not in {"reviewed", "auto_pass"}: + raise HTTPException(status_code=400, detail="抽查来源只能是 reviewed 或 auto_pass") + count = max(1, min(int(count), 50)) + status_clause = "review_status = 'reviewed'" if source == "reviewed" else "review_status = 'auto_pass'" + query = sql.SQL( + """ + SELECT * + FROM {table} + WHERE {status_clause} + ORDER BY random() + LIMIT %s + """ + ).format(table=table_identifier(), status_clause=sql.SQL(status_clause)) + with connect() as conn, conn.cursor() as cur: + cur.execute(query, (count,)) + rows = [dict(row) for row in cur.fetchall()] + return { + "audit_source": source, + "records": [fetch_record(int(row["id"])) for row in rows], + } + + +@app.get("/api/audit/logs") +def list_audit_logs(limit: int = 100): + return {"logs": fetch_audit_logs(max(1, min(int(limit), 500)))} + + +@app.post("/api/audit/classify") +def classify_audit(payload: AuditClassifyPayload): + ensure_workflow_tables() + if payload.audit_status not in {"passed", "failed", "unsure"}: + raise HTTPException(status_code=400, detail="抽查归类只能是 passed/failed/unsure") + if payload.audit_source not in {"reviewed", "auto_pass"}: + raise HTTPException(status_code=400, detail="抽查来源只能是 reviewed 或 auto_pass") + + updates, changed_fields, before = build_record_updates( + payload.record_id, + payload.fields, + "", + "抽查", + force_reviewed=False, + ) + snapshot = { + key: json_ready_deep(before.get(key)) + for key in [ + "source_file", + "medical_record_no", + "patient_name", + "primary_diagnosis", + "primary_diagnosis_code", + "discharge_diagnoses", + "operations", + "review_status", + ] + } + now = datetime.now().isoformat(timespec="seconds") + log = { + "id": datetime.now().strftime("%Y%m%d%H%M%S%f"), + "record_id": payload.record_id, + "source_file": before.get("source_file"), + "audit_source": payload.audit_source, + "audit_status": payload.audit_status, + "audit_notes": payload.audit_notes.strip(), + "ai_result": None, + "snapshot": {**snapshot, "changed_fields": changed_fields}, + "created_at": now, + "updated_at": now, + } + with connect() as conn, conn.cursor() as cur: + apply_record_updates( + cur, + payload.record_id, + updates, + changed_fields, + before, + payload.audit_notes if changed_fields else "", + force_reviewed=False, + ) + cur.execute( + sql.SQL( + """ + UPDATE {table} + SET audit_logs = COALESCE(audit_logs, '[]'::jsonb) || %s::jsonb + WHERE id = %s + """ + ).format(table=table_identifier()), + ( + json.dumps([json_ready_deep(log)], ensure_ascii=False), + payload.record_id, + ), + ) + conn.commit() + return {"ok": True, "log": row_to_json(log), "record": fetch_record(payload.record_id)} + + +@app.post("/api/audit/logs/{audit_id}") +def update_audit_log(audit_id: int, payload: AuditPayload): + raise HTTPException(status_code=410, detail="抽查日志已并入主表,请使用归类保存接口") + + +@app.get("/api/settings") +def get_settings(): + return public_settings() + + +@app.post("/api/settings/status/check") +def check_status_now(): + return {"status_snapshot": refresh_status_snapshot(source="manual")} + + +@app.post("/api/settings/system") +def update_system_settings(payload: SystemSettingsPayload): + data = load_local_settings() + system = normalize_system_settings(data.get("system") or {}) + system["status_check_time"] = normalize_status_check_time(payload.status_check_time) + data["system"] = system + snapshot = data.get("status_snapshot") or default_status_snapshot() + snapshot["next_check_at"] = next_status_check_at(system) + data["status_snapshot"] = snapshot + save_local_settings(data) + return public_settings() + + +@app.post("/api/settings/kimi") +def update_kimi_settings(payload: KimiSettingsPayload): + data = load_local_settings() + data["kimi"] = normalize_kimi_settings( + { + "enabled": payload.enabled, + "model": payload.model, + "api_base": payload.api_base, + "concurrency": payload.concurrency, + } + ) + save_local_settings(data) + return public_settings() + + +@app.get("/api/ai/config") +def ai_config(): + data = load_local_settings() + return {"kimi": public_kimi_settings(data.get("kimi") or {})} + + +@app.get("/api/ai/review/status") +def ai_review_status(): + with AI_JOB_LOCK: + return dict(AI_REVIEW_JOB) + + +@app.post("/api/ai/review/approve-no-issue") +def approve_ai_no_issue(): + count = mark_ai_no_issue_reviewed() + return {"ok": True, "updated": count, "status_snapshot": load_local_settings().get("status_snapshot")} + + +@app.post("/api/ai/review") +def start_ai_review(payload: AiReviewPayload): + settings = public_kimi_settings(load_local_settings().get("kimi") or {}) + if not settings["available"]: + raise HTTPException(status_code=400, detail="Kimi AI 核验未启用或未配置 API Key") + with AI_JOB_LOCK: + if AI_REVIEW_JOB.get("running"): + raise HTTPException(status_code=409, detail="已有 AI 核验任务正在运行") + ids = ai_target_ids(payload.scope, payload.record_id) + if not ids: + raise HTTPException(status_code=400, detail="当前范围没有可 AI 核验的需复核记录") + thread = threading.Thread(target=run_ai_review_job, args=(payload.scope, ids), name="kimi-ai-review", daemon=True) + thread.start() + time.sleep(0.1) + with AI_JOB_LOCK: + return dict(AI_REVIEW_JOB) + + +@app.post("/api/settings/submit-reviewed") +def submit_reviewed(): + count = submit_reviewed_records() + return {"ok": True, "updated": count, "status_snapshot": load_local_settings().get("status_snapshot")} + + +@app.post("/api/settings/users") +def create_user(payload: UserPayload): + data = load_local_settings() + username = validate_local_username(payload.username, data) + if not payload.password: + raise HTTPException(status_code=400, detail="新用户必须设置密码") + user = { + "username": username, + "permissions": clean_permissions(payload.permissions), + "created_at": datetime.now().isoformat(timespec="seconds"), + } + user.update(password_hash(payload.password)) + data.setdefault("users", []).append(user) + save_local_settings(data) + return public_settings() + + +@app.post("/api/settings/users/{username}") +def update_user(username: str, payload: UserUpdatePayload): + if username == admin_username(): + raise HTTPException(status_code=400, detail="环境变量管理员不能在网页端编辑") + data = load_local_settings() + index = local_user_index(data, username) + if index is None: + raise HTTPException(status_code=404, detail="只能编辑本地配置用户") + user = data["users"][index] + new_username = validate_local_username(payload.username or username, data, current_username=username) + user["username"] = new_username + user["permissions"] = clean_permissions(payload.permissions) + if payload.password: + user.update(password_hash(payload.password)) + user["updated_at"] = datetime.now().isoformat(timespec="seconds") + save_local_settings(data) + return public_settings() + + +@app.post("/api/settings/users/{username}/password") +def update_user_password(username: str, payload: PasswordPayload): + if username == admin_username(): + raise HTTPException(status_code=400, detail="环境变量管理员密码请在 .env 中修改") + if not payload.password: + raise HTTPException(status_code=400, detail="密码不能为空") + data = load_local_settings() + index = local_user_index(data, username) + if index is None: + raise HTTPException(status_code=404, detail="只能修改本地配置用户") + data["users"][index].update(password_hash(payload.password)) + data["users"][index]["updated_at"] = datetime.now().isoformat(timespec="seconds") + save_local_settings(data) + return public_settings() + + +@app.post("/api/settings/users/{username}/permissions") +def update_user_permissions(username: str, payload: PermissionPayload): + if username == admin_username(): + raise HTTPException(status_code=400, detail="环境变量管理员不能在网页端编辑") + data = load_local_settings() + for user in data.get("users", []): + if user.get("username") == username: + user["permissions"] = clean_permissions(payload.permissions) + user["updated_at"] = datetime.now().isoformat(timespec="seconds") + save_local_settings(data) + return public_settings() + raise HTTPException(status_code=404, detail="只能修改本地配置用户") + + +@app.delete("/api/settings/users/{username}") +def delete_user(username: str): + if username == admin_username(): + raise HTTPException(status_code=400, detail="环境变量管理员不能删除") + data = load_local_settings() + users = data.get("users", []) + next_users = [user for user in users if user.get("username") != username] + if len(next_users) == len(users): + raise HTTPException(status_code=404, detail="只能删除本地配置用户") + data["users"] = next_users + save_local_settings(data) + return public_settings() + + +@app.get("/api/pdf/{source_file:path}") +def pdf_file(source_file: str, request: Request): + referer = request.headers.get("referer", "") + host = request.headers.get("host", "") + if not referer: + raise HTTPException(status_code=403, detail="PDF 只能在工作台内预览") + if host and urlparse(referer).netloc != host: + raise HTTPException(status_code=403, detail="PDF 只能在同源工作台内预览") + path = get_pdf_path(source_file) + if not path: + raise HTTPException(status_code=404, detail="PDF 文件不存在") + return FileResponse( + path, + media_type="application/pdf", + headers={ + "Content-Disposition": "inline", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }, + ) diff --git a/患者首页处理/数据可视化网页端/app/static/app.js b/患者首页处理/数据可视化网页端/app/static/app.js new file mode 100644 index 0000000..7d1b8c4 --- /dev/null +++ b/患者首页处理/数据可视化网页端/app/static/app.js @@ -0,0 +1,1393 @@ +const state = { + records: [], + recordLimit: 300, + recordOffset: 0, + recordHasMore: false, + recordsLoading: false, + selectedId: null, + selectedRecord: null, + schema: [], + view: localStorage.getItem("frontPageReviewView") || "pdf", + activePage: "overview", + overview: null, + auditSamples: [], + auditCurrentId: null, + auditView: localStorage.getItem("frontPageAuditView") || "pdf", + auditLogs: [], + settings: null, + ai: null, + aiPolling: null, + currentUser: null, + authenticated: false, + pdfZoom: Number(localStorage.getItem("frontPagePdfZoom")) || 110, +}; + +const $ = (id) => document.getElementById(id); +const PDF_ZOOM_STEP = 15; +const PDF_MIN_ZOOM = 55; +const PDF_MAX_ZOOM = 220; +const OPERATION_COLUMNS = ["手术操作编码", "手术操作日期", "手术级别", "手术操作名称", "术者", "I助", "II助", "切口愈合等级", "麻醉方式", "麻醉医师", "原始内容"]; +const DIAGNOSIS_COLUMNS = ["诊断类别", "出院诊断", "疾病编码", "入院病情"]; + +const GROUP_KEYWORDS = { + 基本信息: ["住院号", "病案号", "首页病案号", "姓名", "性别", "出生", "年龄", "身份证", "婚姻", "健康卡", "入院", "出院", "科别", "科室", "病房", "住院天数"], + 地址联系人: ["地址", "电话", "联系人", "单位", "邮编", "户口"], + 诊断表格: ["诊断", "疾病编码", "编码格式", "病理", "入院病情"], + 手术表格: ["手术", "操作", "麻醉", "切口"], + 离院费用: ["费用", "金额", "自付", "总费用", "离院", "再住院", "昏迷"], +}; + +const FIELD_KEYWORDS = { + inpatient_no: ["住院号", "ZY", "12位"], + medical_record_no: ["病案号", "10位", "前导0"], + front_page_medical_record_no: ["首页病案号"], + patient_name: ["姓名"], + id_card_no: ["身份证"], + contact_address: ["联系人地址"], + contact_phone: ["联系人电话"], + admission_time: ["入院时间"], + admission_dept: ["入院科别"], + discharge_time: ["出院时间"], + discharge_dept: ["出院科别", "科室"], + hospital_days: ["住院天数"], + major_department: ["大科室"], + primary_diagnosis: ["主要诊断"], + primary_diagnosis_code: ["主要诊断编码", "疾病编码"], + discharge_diagnoses: ["出院诊断", "其他诊断", "诊断编码", "入院病情"], + operations: ["手术", "操作", "麻醉", "切口"], + total_cost: ["总费用"], + self_pay_amount: ["自付"], + fee_details: ["费用明细"], +}; + +function statusTag(status) { + const value = status || "pending"; + const meta = { + auto_pass: ["自动通过", "auto_pass"], + auto_corrected: ["自动修正", "auto_corrected"], + needs_review: ["需复核", "needs_review"], + reviewed: ["已复核", "reviewed"], + "AI复核-无问题": ["AI复核-无问题", "ai_passed"], + "AI复核-待确认": ["AI复核-待确认", "ai_pending"], + "已提交": ["已提交", "submitted"], + pending: ["待处理", "pending"], + }; + const [label, className] = meta[value] || [value, "pending"]; + return `${label}`; +} + +function auditTag(status) { + const labels = { pending: "待抽查", passed: "抽查通过", failed: "抽查异常", unsure: "不确定" }; + return `${labels[status] || status || "待抽查"}`; +} + +async function api(path, options = {}) { + let response; + try { + response = await fetch(path, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + } catch (_) { + throw new Error("无法连接网页服务,请确认 8501 服务正在运行后重试"); + } + if (!response.ok) { + let message = response.statusText; + try { + const data = await response.json(); + message = data.detail || message; + } catch (_) { + // ignore non-json errors + } + if (response.status === 401) { + showLogin(message || "请先登录"); + } + throw new Error(message); + } + return response.json(); +} + +function userPermissions() { + return state.currentUser?.permissions || {}; +} + +function canOpenPage(page) { + const key = page === "auditHistory" ? "audit_history" : page; + return userPermissions()[key] !== false; +} + +function firstAllowedPage() { + return ["overview", "review", "audit", "auditHistory", "settings"].find(canOpenPage) || "overview"; +} + +function applyPermissions() { + const permissions = userPermissions(); + document.querySelectorAll(".nav-button").forEach((button) => { + const key = button.dataset.page === "auditHistory" ? "audit_history" : button.dataset.page; + button.classList.toggle("is-hidden", permissions[key] === false); + }); +} + +function showLogin(message = "") { + state.authenticated = false; + $("appShell")?.classList.add("is-hidden"); + $("loginView")?.classList.remove("is-hidden"); + if ($("loginMessage")) $("loginMessage").textContent = message; + setTimeout(() => $("loginPassword")?.focus(), 0); +} + +function showApp() { + state.authenticated = true; + $("loginView")?.classList.add("is-hidden"); + $("appShell")?.classList.remove("is-hidden"); + $("currentUserLabel").textContent = state.currentUser?.username ? `当前用户:${state.currentUser.username}` : "已登录"; + applyPermissions(); +} + +function showMessage(message, kind = "") { + const box = $("saveMessage"); + if (!box) return; + box.textContent = message; + box.className = `save-message ${kind ? `is-${kind}` : ""}`; +} + +function showAuditMessage(message, kind = "") { + const box = $("auditMessage"); + box.textContent = message; + box.className = `inline-message ${kind ? `is-${kind}` : ""}`; +} + +function pdfViewHash() { + return `#toolbar=0&navpanes=0&scrollbar=1&zoom=${state.pdfZoom}`; +} + +function setPdfFrame(frameId, url) { + const frame = $(frameId); + if (!frame) return; + frame.src = "about:blank"; + if (!url) return; + const separator = url.includes("?") ? "&" : "?"; + requestAnimationFrame(() => { + frame.src = `${url}${separator}zoom_refresh=${Date.now()}${pdfViewHash()}`; + }); +} + +function refreshPdfFrames() { + if (state.selectedRecord?.pdf_url) { + setPdfFrame("pdfFrame", state.selectedRecord.pdf_url); + } + const auditRecord = state.auditSamples.find((sample) => sample.record.id === state.auditCurrentId)?.record; + if (auditRecord?.pdf_url) { + setPdfFrame("auditPdfFrame", auditRecord.pdf_url); + } +} + +function updatePdfZoomLabels() { + ["pdfZoomLabel", "auditPdfZoomLabel"].forEach((id) => { + if ($(id)) $(id).textContent = `${state.pdfZoom}%`; + }); +} + +function setPdfZoom(value) { + state.pdfZoom = Math.max(PDF_MIN_ZOOM, Math.min(PDF_MAX_ZOOM, Number(value) || 110)); + localStorage.setItem("frontPagePdfZoom", String(state.pdfZoom)); + updatePdfZoomLabels(); + refreshPdfFrames(); +} + +function changePdfZoom(delta) { + setPdfZoom(state.pdfZoom + delta); +} + +function switchPage(page) { + if (!state.authenticated || !canOpenPage(page)) return; + state.activePage = page; + document.querySelectorAll(".page-view").forEach((el) => el.classList.add("is-hidden")); + $(`${page}Page`)?.classList.remove("is-hidden"); + document.querySelectorAll(".nav-button").forEach((button) => { + button.classList.toggle("is-active", button.dataset.page === page); + }); + if (page === "overview") loadOverview(); + if (page === "review") loadRecords(); + if (page === "auditHistory") loadAuditLogs(); + if (page === "settings") loadSettings(); +} + +async function loadStatus() { + const data = await api("/api/status"); + const stateLabel = data.database === "online" ? "在线" : data.database === "unchecked" ? "未检查" : "离线"; + $("dbState").textContent = stateLabel; + $("dbState").className = data.database === "online" ? "status-online" : data.database === "unchecked" ? "status-muted" : "status-offline"; + $("dbTotal").textContent = data.total ?? "--"; + $("dbNeedsReview").textContent = data.needs_review ?? "--"; + $("dbAiPassed").textContent = data.ai_passed ?? "--"; + $("dbAiPending").textContent = data.ai_pending ?? "--"; + $("dbReviewed").textContent = data.reviewed ?? "--"; + $("statusGrid").title = `${data.host}:${data.port}/${data.database_name} · ${data.table} · ${data.message || ""} · ${data.checked_at ? `上次检查 ${formatTime(data.checked_at)}` : "尚未检查"}`; + state.status = data; + return data; +} + +async function loadOverview() { + const data = await api("/api/overview"); + state.overview = data; + renderOverview(data); +} + +function renderOverview(data) { + const summary = data.summary || {}; + const metrics = [ + ["总记录", summary.total], + ["复核队列", summary.review_queue], + ["需复核", summary.needs_review], + ["AI待确认", summary.ai_pending], + ["AI无问题", summary.ai_passed], + ["已复核", summary.reviewed], + ["已提交", summary.submitted], + ["自动通过", summary.auto_passed], + ["抽查记录", summary.audit_total], + ["抽查异常", summary.audit_failed], + ]; + $("overviewMetrics").innerHTML = metrics.map(([label, value]) => ` +
+ ${escapeHtml(label)} + ${value ?? 0} +
+ `).join(""); + $("overviewQueues").innerHTML = ` +
复核工作台默认全部数据:需复核 ${summary.needs_review || 0} / AI待确认 ${summary.ai_pending || 0} / 已复核 ${summary.reviewed || 0}
+
抽查候选池已复核 ${summary.reviewed || 0} 条 / 自动通过 ${summary.auto_passed || 0} 条
+
日志存储人工复核和抽查记录均写入主表 JSONB 列
+ `; + $("overviewRecentLogs").innerHTML = renderLogTable(data.recent_logs || []); +} + +async function loadSchema() { + const data = await api("/api/schema"); + state.schema = data.groups; +} + +async function loadRecords({ append = false } = {}) { + if (state.recordsLoading) return; + state.recordsLoading = true; + const query = encodeURIComponent($("searchInput").value.trim()); + const status = encodeURIComponent($("statusFilter").value || "review_all"); + const offset = append ? state.recordOffset : 0; + try { + const data = await api(`/api/records?q=${query}&status_filter=${status}&limit=${state.recordLimit || 300}&offset=${offset}`); + state.records = append ? [...state.records, ...data.records] : data.records; + state.recordLimit = data.limit || 300; + state.recordOffset = (data.offset || 0) + data.records.length; + state.recordHasMore = Boolean(data.has_more); + if (!append && !state.records.some((record) => record.id === state.selectedId)) { + state.selectedId = null; + } + renderRecords(); + renderFilterActions(); + if (!state.selectedId && state.records.length) { + await selectRecord(state.records[0].id); + } + } catch (error) { + showMessage(error.message || "加载列表失败", "error"); + } finally { + state.recordsLoading = false; + renderRecords(); + renderFilterActions(); + } +} + +function renderRecords() { + const list = $("recordList"); + const previousScrollTop = list.scrollTop; + if (!state.records.length) { + list.innerHTML = `
当前筛选下没有需要处理的记录
`; + return; + } + const limitNotice = state.recordHasMore + ? `
已显示 ${state.records.length} 条,继续下拉加载更多
` + : ""; + const loadingNotice = state.recordsLoading ? `
正在加载...
` : ""; + const endNotice = !state.recordHasMore && state.records.length ? `
已加载全部 ${state.records.length} 条
` : ""; + list.innerHTML = limitNotice + state.records.map((record) => { + const active = record.id === state.selectedId ? " is-active" : ""; + const manual = record.manual_corrected ? " · 人工已改" : ""; + const activity = record.last_activity_at ? `最近 ${escapeHtml(formatTime(record.last_activity_at))}` : ""; + return ` + + `; + }).join("") + loadingNotice + endNotice; + list.querySelectorAll(".record-item").forEach((button) => { + button.addEventListener("click", () => selectRecord(Number(button.dataset.id))); + }); + list.scrollTop = previousScrollTop; +} + +function renderFilterActions() { + const button = $("approveAiNoIssueBtn"); + if (!button) return; + button.classList.toggle("is-hidden", $("statusFilter").value !== "AI复核-无问题"); +} + +function scrollSelectedRecordIntoView() { + const selected = $("recordList")?.querySelector(`.record-item[data-id="${state.selectedId}"]`); + selected?.scrollIntoView({ block: "nearest" }); +} + +function maybeLoadMoreRecords() { + const list = $("recordList"); + if (!list || !state.recordHasMore || state.recordsLoading) return; + const distanceToBottom = list.scrollHeight - list.scrollTop - list.clientHeight; + if (distanceToBottom < 240) loadRecords({ append: true }); +} + +async function selectRecord(id, options = {}) { + state.selectedId = id; + renderRecords(); + if (options.keepInView) scrollSelectedRecordIntoView(); + showMessage(""); + try { + const data = await api(`/api/records/${id}`); + if (state.selectedId !== id) return; + state.selectedRecord = data.record; + renderSelectedRecord(); + } catch (error) { + showMessage(error.message || "记录加载失败,请刷新后重试", "error"); + } +} + +function recordListIndex() { + return state.records.findIndex((record) => record.id === state.selectedId); +} + +function recordMatchesActiveFilter(record) { + const status = $("statusFilter").value || "review_all"; + if (status === "review_all") { + return ["needs_review", "reviewed", "AI复核-无问题", "AI复核-待确认"].includes(record.review_status) || record.manual_corrected === true; + } + if (status === "needs_review") { + return record.review_status === "needs_review"; + } + if (status === "reviewed") { + return record.review_status === "reviewed"; + } + return status === "all" || record.review_status === status; +} + +async function selectAdjacentRecord(delta) { + if (!state.records.length || state.recordsLoading) return; + let index = recordListIndex(); + if (index < 0) index = delta > 0 ? -1 : state.records.length; + let nextIndex = index + delta; + if (nextIndex >= state.records.length && state.recordHasMore) { + await loadRecords({ append: true }); + nextIndex = index + delta; + } + if (nextIndex < 0 || nextIndex >= state.records.length) return; + await selectRecord(state.records[nextIndex].id, { keepInView: true }); +} + +function handleRecordListKeydown(event) { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + selectAdjacentRecord(event.key === "ArrowDown" ? 1 : -1); +} + +function isTextEntryTarget(target) { + if (!target) return false; + const tagName = target.tagName; + return target.isContentEditable || tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT"; +} + +function preventWorkspacePageScroll(event) { + const scrollKeys = new Set(["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "]); + if (!scrollKeys.has(event.key) || isTextEntryTarget(event.target)) return; + if (state.activePage === "review" && (event.key === "ArrowDown" || event.key === "ArrowUp")) { + event.preventDefault(); + event.stopPropagation(); + selectAdjacentRecord(event.key === "ArrowDown" ? 1 : -1); + return; + } + if (state.activePage !== "review" && state.activePage !== "audit") return; + event.preventDefault(); +} + +function releasePdfKeyboardFocus(event) { + event.currentTarget.blur(); + document.body.setAttribute("tabindex", "-1"); + document.body.focus({ preventScroll: true }); +} + +function renderSelectedRecord() { + const record = state.selectedRecord; + $("saveBtn").disabled = false; + $("pdfTitle").textContent = record.source_file || "未找到文件"; + $("pdfSubtitle").textContent = `${record.patient_name || ""} · ${record.inpatient_no || record.medical_record_no || ""} · ${record.primary_diagnosis || ""}`; + $("detailTitle").textContent = `${record.patient_name || "未命名"} · ${record.inpatient_no || record.medical_record_no || ""}`; + $("detailMeta").textContent = `${record.source_file || ""} · ${record.major_department || ""}`; + setPdfFrame("pdfFrame", record.pdf_url); + renderReviewStrip(record); + renderForm(record, { + formId: "detailForm", + targetId: "targetSummary", + noteId: "manualNote", + logCountId: "changeLogCount", + logTableId: "changeLogTable", + }); + renderChangeLogs(record.review_logs || [], "changeLogCount", "changeLogTable"); + setView(state.view); +} + +async function refreshSelectedRecord() { + if (!state.selectedId) return; + try { + const data = await api(`/api/records/${state.selectedId}`); + if (state.selectedId !== data.record.id) return; + state.selectedRecord = data.record; + renderSelectedRecord(); + const index = state.records.findIndex((record) => record.id === data.record.id); + if (index >= 0) state.records[index] = { ...state.records[index], ...compactRecord(data.record) }; + renderRecords(); + } catch (_) { + // keep the current UI if a background refresh loses the selected record + } +} + +function reviewNotes(record) { + const notes = []; + ["review_notes", "quality_notes", "auto_corrections"].forEach((key) => { + const value = record[key]; + if (Array.isArray(value)) { + value.forEach((item) => notes.push(typeof item === "string" ? item : JSON.stringify(item))); + } else if (value) { + notes.push(String(value)); + } + }); + return notes.filter(Boolean); +} + +function reviewTargets(record) { + const notes = reviewNotes(record); + const text = notes.join(";"); + const fieldAlerts = new Set(); + const groupAlerts = new Set(); + state.schema.forEach((group) => { + let groupHit = (GROUP_KEYWORDS[group.name] || []).some((keyword) => text.includes(keyword)); + group.fields.forEach(([name, label]) => { + const keywords = [label, name, ...(FIELD_KEYWORDS[name] || [])].filter((keyword) => String(keyword).length > 1); + if (keywords.some((keyword) => text.includes(keyword))) { + fieldAlerts.add(name); + groupHit = true; + } + }); + if (groupHit) groupAlerts.add(group.name); + }); + return { notes, fieldAlerts, groupAlerts }; +} + +function renderReviewStrip(record) { + $("reviewStrip").innerHTML = ` +
复核状态${statusTag(record.review_status)}
+ + `; +} + +function renderForm(record, options = {}) { + const formId = options.formId || "detailForm"; + const targetId = options.targetId || "targetSummary"; + const noteId = options.noteId || "manualNote"; + const form = $(formId); + const targets = reviewTargets(record); + const hasTarget = targets.groupAlerts.size > 0; + renderTargetSummary(targets, targetId, formId); + form.innerHTML = state.schema.map((group, index) => { + const groupAlert = targets.groupAlerts.has(group.name); + const fields = group.fields.map(([name, label, type, options]) => renderField(record, name, label, type, options, targets.fieldAlerts.has(name))).join(""); + const open = groupAlert || (!hasTarget && index === 0); + return ` +
+ ${escapeHtml(group.name)}${groupAlert ? "需核对" : ""} +
${fields}
+
+ `; + }).join(""); + if ($(noteId)) $(noteId).value = ""; +} + +function renderTargetSummary(targets, targetId = "targetSummary", formId = "detailForm") { + const groups = [...targets.groupAlerts]; + const notesHtml = targets.notes.length + ? targets.notes.map((note) => `
  • ${escapeHtml(note)}
  • `).join("") + : "
  • 暂无复核提示
  • "; + $(targetId).innerHTML = ` +
    + + 复核定位 + ${groups.length ? `${groups.length} 个模块` : "未定位"} + +
    + ${groups.length ? groups.map((group) => ``).join("") : "未定位到具体模块,先核对基本信息"} +
    +
      ${notesHtml}
    +
    + `; + $(targetId).querySelectorAll("[data-target-group]").forEach((button) => { + button.addEventListener("click", () => { + const group = $(formId).querySelector(`.field-group[data-group="${CSS.escape(button.dataset.targetGroup)}"]`); + if (group) { + group.open = true; + group.scrollIntoView({ block: "center", behavior: "smooth" }); + } + }); + }); +} + +function renderField(record, name, label, type, options, alerted = false) { + const value = record[name]; + const wide = type === "json" || String(value || "").length > 28 ? " is-wide" : ""; + const alertClass = alerted ? " is-alert" : ""; + let control = ""; + if (type === "select") { + control = ``; + } else if (type === "json") { + control = renderJsonEditor(name, value); + } else { + const inputType = type === "date" ? "date" : "text"; + const placeholder = type === "datetime" ? "YYYY-MM-DD HH:MM:SS" : ""; + control = ``; + } + return ``; +} + +function preferredColumns(fieldName, value) { + if (fieldName === "operations") return OPERATION_COLUMNS; + if (fieldName === "discharge_diagnoses") return DIAGNOSIS_COLUMNS; + if (Array.isArray(value)) { + const keys = []; + value.forEach((row) => { + if (row && typeof row === "object" && !Array.isArray(row)) { + Object.keys(row).forEach((key) => { + if (!keys.includes(key)) keys.push(key); + }); + } + }); + return keys.length ? keys : ["值"]; + } + if (value && typeof value === "object") return ["项目", "金额"]; + return ["值"]; +} + +function renderJsonEditor(fieldName, value) { + const columns = preferredColumns(fieldName, value); + const rows = normalizeJsonRows(value, columns); + const header = columns.map((column) => `${escapeHtml(column)}`).join(""); + const body = rows.map((row) => renderJsonRow(columns, row)).join(""); + return ` +
    +
    + + +
    +
    + + ${header} + ${body || renderJsonRow(columns, {})} +
    操作
    +
    +
    + `; +} + +function normalizeJsonRows(value, columns) { + if (Array.isArray(value)) { + return value.map((row) => { + if (row && typeof row === "object" && !Array.isArray(row)) return row; + return { [columns[0] || "值"]: row ?? "" }; + }); + } + if (value && typeof value === "object") { + return Object.entries(value).map(([key, amount]) => ({ 项目: key, 金额: amount })); + } + if (value === null || value === undefined || value === "") return []; + return [{ [columns[0] || "值"]: value }]; +} + +function renderJsonRow(columns, row) { + const cells = columns.map((column) => { + const value = row[column] ?? ""; + return ``; + }).join(""); + return `${cells}`; +} + +function collectFields(formId = "detailForm") { + const fields = {}; + $(formId).querySelectorAll("input[data-field], select[data-field], textarea[data-field]").forEach((control) => { + fields[control.dataset.field] = control.value; + }); + $(formId).querySelectorAll(".json-editor[data-json-field]").forEach((editor) => { + fields[editor.dataset.jsonField] = collectJsonEditor(editor); + }); + return fields; +} + +function collectJsonEditor(editor) { + const table = editor.querySelector("table"); + const columns = JSON.parse(table.dataset.columns || "[]"); + const rows = [...table.querySelectorAll("tbody tr")].map((tr) => { + const row = {}; + tr.querySelectorAll("[data-json-key]").forEach((input) => { + row[input.dataset.jsonKey] = input.value; + }); + return row; + }).filter((row) => Object.values(row).some((value) => String(value).trim() !== "")); + + if (editor.dataset.kind === "object") { + const result = {}; + rows.forEach((row) => { + const key = row["项目"]; + if (key) result[key] = row["金额"] ?? ""; + }); + return result; + } + return rows; +} + +async function advanceAfterSave(savedId, savedRecord) { + const savedSummary = compactRecord(savedRecord); + const listIndex = state.records.findIndex((record) => record.id === savedId); + let nextIndex = listIndex >= 0 ? listIndex : recordListIndex() + 1; + + if (listIndex >= 0) { + if (recordMatchesActiveFilter(savedSummary)) { + state.records[listIndex] = { ...state.records[listIndex], ...savedSummary }; + nextIndex = listIndex + 1; + } else { + state.records.splice(listIndex, 1); + state.recordOffset = Math.max(0, state.recordOffset - 1); + nextIndex = listIndex; + } + } + + renderRecords(); + + if (nextIndex >= state.records.length && state.recordHasMore) { + await loadRecords({ append: true }); + } + + if (nextIndex < state.records.length) { + await selectRecord(state.records[nextIndex].id, { keepInView: true }); + showMessage("已复核并保存,已自动跳到下一条", "ok"); + return; + } + + state.selectedId = savedId; + state.selectedRecord = savedRecord; + renderSelectedRecord(); + renderRecords(); + showMessage("已复核并保存,当前列表已没有下一条", "ok"); +} + +async function saveRecord() { + if (!state.selectedId) return; + $("saveBtn").disabled = true; + showMessage("正在保存..."); + const savedId = state.selectedId; + try { + const payload = { + fields: collectFields(), + manual_note: $("manualNote").value.trim(), + note_prefix: "人工复核", + }; + const data = await api(`/api/records/${state.selectedId}`, { + method: "POST", + body: JSON.stringify(payload), + }); + state.selectedRecord = data.record; + renderChangeLogs(data.record.review_logs || [], "changeLogCount", "changeLogTable"); + await advanceAfterSave(savedId, data.record); + loadStatus().catch(() => null); + } catch (error) { + showMessage(error.message, "error"); + } finally { + $("saveBtn").disabled = false; + } +} + +function compactRecord(record) { + return { + id: record.id, + source_file: record.source_file, + inpatient_no: record.inpatient_no, + medical_record_no: record.medical_record_no, + patient_name: record.patient_name, + review_status: record.review_status, + manual_corrected: record.manual_corrected, + major_department: record.major_department, + discharge_dept: record.discharge_dept, + primary_diagnosis: record.primary_diagnosis, + primary_diagnosis_code: record.primary_diagnosis_code, + contact_phone: record.contact_phone, + last_activity_at: record.last_activity_at, + has_pdf: Boolean(record.pdf_url), + }; +} + +function setView(view) { + state.view = "pdf"; + $("pdfView").classList.remove("is-hidden"); +} + +function renderChangeLogs(logs, countId = "changeLogCount", tableId = "changeLogTable") { + $(countId).textContent = `${logs.length} 条`; + $(tableId).innerHTML = renderLogTable(logs); +} + +function renderLogTable(logs) { + if (!logs.length) return `
    暂无修改记录
    `; + const rows = logs.map((log) => { + const fields = Array.isArray(log.changed_fields) ? log.changed_fields : []; + const summary = fields.length + ? fields.map((field) => `${field.label || field.field}: ${shortValue(field.old)} -> ${shortValue(field.new)}`).join(";") + : "仅记录人工备注或状态确认"; + return ` + + ${escapeHtml(formatTime(log.changed_at))} + ${escapeHtml(log.changed_by || "web")} + ${escapeHtml(summary)} + ${escapeHtml(log.manual_note || "")} + + `; + }).join(""); + return ` + + + ${rows} +
    修改时间来源修改内容备注
    + `; +} + +function shortValue(value) { + if (value === null || value === undefined || value === "") return "空"; + const text = typeof value === "string" ? value : JSON.stringify(value); + return text.length > 36 ? `${text.slice(0, 36)}...` : text; +} + +async function sampleAudit() { + const source = encodeURIComponent($("auditSource").value); + const count = encodeURIComponent($("auditCount").value || "5"); + showAuditMessage("正在抽取..."); + try { + const data = await api(`/api/audit/sample?source=${source}&count=${count}`, { method: "POST" }); + state.auditSamples = data.records.map((record) => ({ + record, + audit_source: data.audit_source, + audit_status: "pending", + audit_notes: "", + })); + state.auditCurrentId = state.auditSamples[0]?.record.id || null; + renderAuditSamples(); + if (state.auditCurrentId) renderAuditCurrent(); + showAuditMessage(`已抽取 ${state.auditSamples.length} 条`, "ok"); + await loadAuditLogs(); + await loadOverview(); + } catch (error) { + showAuditMessage(error.message, "error"); + } +} + +function renderAuditSamples() { + const box = $("auditSampleList"); + if (!state.auditSamples.length) { + box.innerHTML = `
    暂无抽查样本
    `; + return; + } + box.innerHTML = state.auditSamples.map((item) => { + const { record } = item; + return ` + + `; + }).join(""); + box.querySelectorAll("[data-audit-record]").forEach((button) => { + button.addEventListener("click", () => { + state.auditCurrentId = Number(button.dataset.auditRecord); + renderAuditSamples(); + renderAuditCurrent(); + }); + }); +} + +function renderAuditCurrent() { + const item = state.auditSamples.find((sample) => sample.record.id === state.auditCurrentId); + if (!item) { + $("auditPdfTitle").textContent = "未选择抽查记录"; + $("auditPdfSubtitle").textContent = ""; + setPdfFrame("auditPdfFrame", ""); + $("auditDetailTitle").textContent = "抽查信息"; + $("auditDetailMeta").textContent = "随机抽取后开始抽查"; + $("auditDetailForm").innerHTML = ""; + $("auditTargetSummary").innerHTML = ""; + renderChangeLogs([], "auditChangeLogCount", "auditChangeLogTable"); + return; + } + const record = item.record; + $("auditPdfTitle").textContent = record.source_file || "未找到文件"; + $("auditPdfSubtitle").textContent = `${record.patient_name || ""} · ${record.inpatient_no || record.medical_record_no || ""} · ${record.primary_diagnosis || ""}`; + $("auditDetailTitle").textContent = `${record.patient_name || "未命名"} · ${record.inpatient_no || record.medical_record_no || ""}`; + $("auditDetailMeta").textContent = `${record.source_file || ""} · ${record.major_department || ""}`; + setPdfFrame("auditPdfFrame", record.pdf_url); + $("auditReviewStrip").innerHTML = ` +
    复核状态${statusTag(record.review_status)}
    +
    抽查状态${auditTag(item.audit_status || "pending")}
    +
    主要诊断${escapeHtml(record.primary_diagnosis || "无")}
    +
    抽查来源${escapeHtml(item.audit_source || "")}
    + `; + renderForm(record, { + formId: "auditDetailForm", + targetId: "auditTargetSummary", + noteId: "auditNotes", + }); + $("auditNotes").value = item.audit_notes || ""; + renderChangeLogs(record.review_logs || [], "auditChangeLogCount", "auditChangeLogTable"); + setAuditView(state.auditView); +} + +async function saveAuditStatus(status) { + const item = state.auditSamples.find((sample) => sample.record.id === state.auditCurrentId); + if (!item) return; + try { + const data = await api("/api/audit/classify", { + method: "POST", + body: JSON.stringify({ + record_id: item.record.id, + audit_source: item.audit_source || $("auditSource").value, + audit_status: status, + audit_notes: $("auditNotes").value.trim(), + fields: collectFields("auditDetailForm"), + }), + }); + item.log = data.log; + item.audit_status = data.log.audit_status; + item.audit_notes = data.log.audit_notes || ""; + item.record = data.record; + renderAuditSamples(); + renderAuditCurrent(); + showAuditMessage("抽查已归类并保存", "ok"); + await loadAuditLogs(); + await loadOverview(); + } catch (error) { + showAuditMessage(error.message, "error"); + } +} + +function setAuditView(view) { + state.auditView = "pdf"; + $("auditPdfView").classList.remove("is-hidden"); +} + +async function loadAuditLogs() { + const data = await api("/api/audit/logs?limit=200"); + state.auditLogs = data.logs; + renderAuditHistory(); +} + +function renderAuditHistory() { + const box = $("auditHistoryTable"); + if (!box) return; + if (!state.auditLogs.length) { + box.innerHTML = `
    暂无抽查记录
    `; + return; + } + const rows = state.auditLogs.map((log) => ` + + ${escapeHtml(formatTime(log.updated_at || log.created_at))} + ${escapeHtml(log.patient_name || "")} + ${escapeHtml(log.inpatient_no || log.medical_record_no || "")} + ${escapeHtml(log.audit_source || "")} + ${auditTag(log.audit_status)} + ${escapeHtml(changeSummaryFromSnapshot(log.snapshot))} + ${escapeHtml(log.audit_notes || "")} + + `).join(""); + box.innerHTML = ` + + + ${rows} +
    时间姓名住院号来源状态修改内容人工备注
    + `; +} + +function changeSummaryFromSnapshot(snapshot) { + const fields = Array.isArray(snapshot?.changed_fields) ? snapshot.changed_fields : []; + if (!fields.length) return "未修改字段"; + return fields.map((field) => `${field.label || field.field}: ${shortValue(field.old)} -> ${shortValue(field.new)}`).join(";"); +} + +async function loadSettings() { + const data = await api("/api/settings"); + state.settings = data; + state.ai = data.kimi || state.ai; + renderSettings(); + renderAiActions(); +} + +async function loadAiConfig() { + const data = await api("/api/ai/config"); + state.ai = data.kimi; + renderAiActions(); +} + +function aiAvailable() { + return Boolean(state.ai?.available); +} + +function renderAiActions() { + const actions = $("aiActions"); + if (!actions) return; + actions.classList.toggle("is-hidden", !aiAvailable()); +} + +function renderSettings() { + const status = state.status || {}; + $("settingsStatus").innerHTML = ` +
    数据库${escapeHtml(status.database || "未知")}
    +
    连接${escapeHtml(`${status.host || ""}:${status.port || ""}/${status.database_name || ""}`)}
    +
    表名${escapeHtml(status.table || "")}
    +
    PDF目录${escapeHtml(status.pdf_dir || "")}
    +
    上次检查${escapeHtml(status.checked_at ? formatTime(status.checked_at) : "尚未检查")}
    +
    下次检查${escapeHtml(status.next_check_at ? formatTime(status.next_check_at) : "--")}
    +
    检查结果${escapeHtml(status.message || "")}
    + `; + const settings = state.settings || { users: [], permission_labels: {} }; + const system = settings.system || {}; + const kimi = settings.kimi || {}; + $("statusCheckTime").value = system.status_check_time || "03:00"; + $("statusCheckMeta").textContent = `上次检查:${status.checked_at ? formatTime(status.checked_at) : "尚未检查"} · 下次:${status.next_check_at ? formatTime(status.next_check_at) : "--"}`; + $("kimiEnabled").checked = Boolean(kimi.enabled); + $("kimiModel").value = kimi.model || ""; + $("kimiApiBase").value = kimi.api_base || ""; + $("kimiConcurrency").value = kimi.concurrency || 3; + $("kimiMeta").textContent = `${kimi.api_key_configured ? "API Key 已从 .env 读取" : "未配置 API Key"} · ${kimi.available ? "AI核验已启用" : "AI核验已关闭"} · 并发 ${kimi.concurrency || 3}`; + $("settingsUserCount").textContent = `${settings.users.length} 个用户`; + renderPermissionControls("newUserPermissions", settings.permission_labels || {}, {}); + $("userList").innerHTML = settings.users.map((user) => renderUserCard(user, settings.permission_labels || {})).join(""); + $("userList").querySelectorAll("[data-save-user]").forEach((button) => { + button.addEventListener("click", () => saveUser(button.dataset.saveUser)); + }); + $("userList").querySelectorAll("[data-delete-user]").forEach((button) => { + button.addEventListener("click", () => deleteUser(button.dataset.deleteUser)); + }); +} + +async function saveKimiSettings(event) { + event.preventDefault(); + const button = $("saveKimiSettingsBtn"); + button.disabled = true; + $("kimiMeta").textContent = "正在保存..."; + try { + const data = await api("/api/settings/kimi", { + method: "POST", + body: JSON.stringify({ + enabled: $("kimiEnabled").checked, + model: $("kimiModel").value.trim(), + api_base: $("kimiApiBase").value.trim(), + concurrency: Number($("kimiConcurrency").value) || 3, + }), + }); + state.settings = data; + state.ai = data.kimi; + renderSettings(); + renderAiActions(); + } catch (error) { + $("kimiMeta").textContent = error.message; + } finally { + button.disabled = false; + } +} + +function setAiButtonsDisabled(disabled) { + ["aiCurrentBtn", "aiFiveBtn", "aiAllBtn"].forEach((id) => { + if ($(id)) $(id).disabled = disabled; + }); +} + +async function pollAiReviewJob() { + const job = await api("/api/ai/review/status"); + const total = job.total || 0; + const processed = job.processed || 0; + showMessage(`AI核验中:${processed}/${total},并发 ${job.concurrency || 1},无问题 ${job.ok || 0},待确认 ${job.pending || 0},失败 ${job.failed || 0}`, ""); + if (job.running) { + state.aiPolling = setTimeout(() => pollAiReviewJob().catch((error) => showMessage(error.message, "error")), 2000); + return; + } + setAiButtonsDisabled(false); + showMessage(`AI核验完成:无问题 ${job.ok || 0},待确认 ${job.pending || 0},失败 ${job.failed || 0}`, job.failed ? "error" : "ok"); + await loadStatus().catch(() => null); + await loadOverview().catch(() => null); + await loadRecords(); + await refreshSelectedRecord(); +} + +async function runAiReview(scope) { + if (!aiAvailable()) { + showMessage("Kimi AI 核验未启用,请先在设置中开启", "error"); + return; + } + if ((scope === "current" || scope === "five") && !state.selectedId) return; + if (scope === "all" && !window.confirm("确认对当前项之后的所有需复核记录执行 AI 核验?")) return; + if (state.aiPolling) clearTimeout(state.aiPolling); + setAiButtonsDisabled(true); + showMessage("正在启动 AI 核验..."); + try { + const job = await api("/api/ai/review", { + method: "POST", + body: JSON.stringify({ scope, record_id: state.selectedId }), + }); + showMessage(`AI核验已启动:0/${job.total || 0}`); + await pollAiReviewJob(); + } catch (error) { + setAiButtonsDisabled(false); + showMessage(error.message, "error"); + } +} + +async function approveAiNoIssueRecords() { + if (!window.confirm("确认将所有“AI复核-无问题”批量标记为已人工复核?")) return; + const button = $("approveAiNoIssueBtn"); + button.disabled = true; + showMessage("正在批量确认 AI 无问题记录..."); + try { + const data = await api("/api/ai/review/approve-no-issue", { method: "POST" }); + state.status = data.status_snapshot || state.status; + showMessage(`已批量确认 ${data.updated || 0} 条 AI 无问题记录`, "ok"); + await loadStatus().catch(() => null); + await loadOverview().catch(() => null); + await loadRecords(); + } catch (error) { + showMessage(error.message, "error"); + } finally { + button.disabled = false; + } +} + +async function submitReviewedRecords() { + if (!window.confirm("确认提交所有已人工复核项目?提交后这些项目将不再出现在复核工作台列表中。")) return; + const button = $("submitReviewedBtn"); + button.disabled = true; + $("statusCheckMeta").textContent = "正在提交已人工复核项目..."; + try { + const data = await api("/api/settings/submit-reviewed", { method: "POST" }); + state.status = data.status_snapshot || state.status; + $("statusCheckMeta").textContent = `已提交 ${data.updated || 0} 条已人工复核项目`; + await loadStatus().catch(() => null); + await loadOverview().catch(() => null); + await loadRecords().catch(() => null); + } catch (error) { + $("statusCheckMeta").textContent = error.message; + } finally { + button.disabled = false; + } +} + +async function saveSystemSettings(event) { + event.preventDefault(); + const button = $("saveSystemSettingsBtn"); + button.disabled = true; + $("statusCheckMeta").textContent = "正在保存..."; + try { + const data = await api("/api/settings/system", { + method: "POST", + body: JSON.stringify({ status_check_time: $("statusCheckTime").value }), + }); + state.settings = data; + state.status = data.status_snapshot || state.status; + renderSettings(); + await loadStatus(); + } catch (error) { + $("statusCheckMeta").textContent = error.message; + } finally { + button.disabled = false; + } +} + +async function runStatusCheck() { + const button = $("statusCheckBtn"); + button.disabled = true; + $("statusCheckMeta").textContent = "正在检查数据库与 PDF 目录..."; + try { + const data = await api("/api/settings/status/check", { method: "POST" }); + state.status = data.status_snapshot; + await loadSettings(); + await loadStatus(); + } catch (error) { + $("statusCheckMeta").textContent = error.message; + } finally { + button.disabled = false; + } +} + +function renderPermissionControls(containerId, labels, permissions) { + const container = $(containerId); + container.innerHTML = Object.entries(labels).map(([key, label]) => ` + + `).join(""); +} + +function renderUserCard(user, labels) { + const editable = user.source !== "env"; + const permissionHtml = Object.entries(labels).map(([key, label]) => ` + + `).join(""); + return ` +
    +
    + ${escapeHtml(user.username)} + ${user.source === "env" ? "内置管理员" : "本地配置用户"}${user.has_password ? " · 已设密码" : ""} +
    +
    + + +
    +
    ${permissionHtml}
    + ${editable ? ` + + ` : ""} +
    + `; +} + +function permissionsFromContainer(container) { + const result = {}; + container.querySelectorAll("input[data-permission]").forEach((input) => { + result[input.dataset.permission] = input.checked; + }); + return result; +} + +function permissionsFromUserCard(card) { + const result = {}; + card.querySelectorAll("input[data-user-permission]").forEach((input) => { + result[input.dataset.userPermission] = input.checked; + }); + return result; +} + +function cssEscape(value) { + if (window.CSS?.escape) return CSS.escape(value); + return String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +async function createUser(event) { + event.preventDefault(); + const username = $("newUsername").value.trim(); + const password = $("newPassword").value; + const permissions = permissionsFromContainer($("newUserPermissions")); + const data = await api("/api/settings/users", { + method: "POST", + body: JSON.stringify({ username, password, permissions }), + }); + state.settings = data; + $("newUsername").value = ""; + $("newPassword").value = ""; + renderSettings(); +} + +async function saveUser(username) { + const card = document.querySelector(`[data-user-card="${cssEscape(username)}"]`); + if (!card) return; + const data = await api(`/api/settings/users/${encodeURIComponent(username)}`, { + method: "POST", + body: JSON.stringify({ + username: card.querySelector("[data-user-name]").value.trim(), + password: card.querySelector("[data-user-password]").value, + permissions: permissionsFromUserCard(card), + }), + }); + state.settings = data; + renderSettings(); +} + +async function deleteUser(username) { + if (!window.confirm(`确认删除用户 ${username}?`)) return; + const data = await api(`/api/settings/users/${encodeURIComponent(username)}`, { method: "DELETE" }); + state.settings = data; + renderSettings(); +} + +async function login(event) { + event.preventDefault(); + $("loginMessage").textContent = ""; + $("loginBtn").disabled = true; + try { + const data = await api("/api/auth/login", { + method: "POST", + body: JSON.stringify({ + username: $("loginUsername").value.trim(), + password: $("loginPassword").value, + }), + }); + state.currentUser = data.user; + $("loginPassword").value = ""; + showApp(); + await loadWorkspace(); + } catch (error) { + $("loginMessage").textContent = error.message; + } finally { + $("loginBtn").disabled = false; + } +} + +async function logout() { + await api("/api/auth/logout", { method: "POST" }).catch(() => null); + state.currentUser = null; + state.records = []; + state.selectedId = null; + state.selectedRecord = null; + showLogin("已退出登录"); +} + +function wireJsonEditorButtons() { + document.querySelectorAll(".structured-form").forEach((form) => form.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + if (target.matches("[data-json-delete]")) { + const tbody = target.closest("tbody"); + target.closest("tr")?.remove(); + if (tbody && !tbody.querySelector("tr")) { + const table = tbody.closest("table"); + const columns = JSON.parse(table.dataset.columns || "[]"); + tbody.insertAdjacentHTML("beforeend", renderJsonRow(columns, {})); + } + } + if (target.matches("[data-json-add]")) { + const editor = target.closest(".json-editor"); + const table = editor.querySelector("table"); + const columns = JSON.parse(table.dataset.columns || "[]"); + table.querySelector("tbody").insertAdjacentHTML("beforeend", renderJsonRow(columns, {})); + } + if (target.matches("[data-json-raw]")) { + const editor = target.closest(".json-editor"); + const value = collectJsonEditor(editor); + navigator.clipboard?.writeText(JSON.stringify(value, null, 2)); + showMessage("当前表格 JSON 已复制", "ok"); + } + })); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatTime(value) { + if (!value) return ""; + return String(value).replace("T", " ").slice(0, 19); +} + +function debounce(fn, wait = 250) { + let timer = null; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), wait); + }; +} + +async function boot() { + $("loginForm").addEventListener("submit", login); + $("logoutBtn").addEventListener("click", logout); + document.querySelectorAll(".nav-button").forEach((button) => { + button.addEventListener("click", () => switchPage(button.dataset.page)); + }); + $("overviewRefreshBtn").addEventListener("click", loadOverview); + $("searchInput").addEventListener("input", debounce(() => loadRecords())); + $("statusFilter").addEventListener("change", () => { + renderFilterActions(); + loadRecords(); + }); + $("recordList").addEventListener("scroll", maybeLoadMoreRecords); + $("recordList").addEventListener("keydown", handleRecordListKeydown); + document.addEventListener("keydown", preventWorkspacePageScroll, { capture: true }); + $("saveBtn").addEventListener("click", saveRecord); + $("aiCurrentBtn").addEventListener("click", () => runAiReview("current")); + $("aiFiveBtn").addEventListener("click", () => runAiReview("five")); + $("aiAllBtn").addEventListener("click", () => runAiReview("all")); + $("approveAiNoIssueBtn").addEventListener("click", approveAiNoIssueRecords); + $("pdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP)); + $("pdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP)); + $("pdfZoomResetBtn").addEventListener("click", () => setPdfZoom(100)); + $("pdfFrame").addEventListener("focus", releasePdfKeyboardFocus); + $("auditPdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP)); + $("auditPdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP)); + $("auditPdfZoomResetBtn").addEventListener("click", () => setPdfZoom(100)); + $("auditPdfFrame").addEventListener("focus", releasePdfKeyboardFocus); + updatePdfZoomLabels(); + $("auditSampleBtn").addEventListener("click", sampleAudit); + $("auditPassBtn").addEventListener("click", () => saveAuditStatus("passed")); + $("auditFailBtn").addEventListener("click", () => saveAuditStatus("failed")); + $("auditUnsureBtn").addEventListener("click", () => saveAuditStatus("unsure")); + $("auditHistoryRefreshBtn").addEventListener("click", loadAuditLogs); + $("userForm").addEventListener("submit", createUser); + $("systemSettingsForm").addEventListener("submit", saveSystemSettings); + $("kimiSettingsForm").addEventListener("submit", saveKimiSettings); + $("submitReviewedBtn").addEventListener("click", submitReviewedRecords); + $("statusCheckBtn").addEventListener("click", runStatusCheck); + wireJsonEditorButtons(); + try { + const session = await api("/api/auth/me"); + if (!session.authenticated) { + showLogin(); + return; + } + state.currentUser = session.user; + showApp(); + await loadWorkspace(); + } catch (error) { + showLogin(error.message); + } +} + +async function loadWorkspace() { + await loadStatus(); + await loadSchema(); + await loadAiConfig().catch(() => null); + const page = canOpenPage(state.activePage) ? state.activePage : firstAllowedPage(); + switchPage(page); +} + +boot(); diff --git a/患者首页处理/数据可视化网页端/app/static/index.html b/患者首页处理/数据可视化网页端/app/static/index.html new file mode 100644 index 0000000..13df763 --- /dev/null +++ b/患者首页处理/数据可视化网页端/app/static/index.html @@ -0,0 +1,273 @@ + + + + + + 患者首页复核工作台 + + + + + + + + + + diff --git a/患者首页处理/数据可视化网页端/app/static/styles.css b/患者首页处理/数据可视化网页端/app/static/styles.css new file mode 100644 index 0000000..1c9ef2c --- /dev/null +++ b/患者首页处理/数据可视化网页端/app/static/styles.css @@ -0,0 +1,1193 @@ +:root { + --bg: #eef1f4; + --panel: #ffffff; + --ink: #15191f; + --muted: #66717f; + --line: #d5dbe2; + --line-dark: #b5bec8; + --accent: #1d5f8f; + --accent-dark: #17486c; + --ok: #1d7b52; + --warn: #9a6500; + --bad: #bd2d20; + --bad-bg: #fff1ef; + --warn-bg: #fff7df; + --ok-bg: #eef8f3; + --focus-bg: #fff7f5; + --shadow: 0 10px 28px rgba(28, 35, 45, 0.10); +} + +* { + box-sizing: border-box; +} + +html { + height: 100%; + overflow: hidden; +} + +body { + margin: 0; + height: 100vh; + min-height: 100vh; + overflow: hidden; + background: var(--bg); + color: var(--ink); + font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; + font-size: 14px; + letter-spacing: 0; +} + +.login-view { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: + linear-gradient(135deg, rgba(29, 95, 143, .10), transparent 42%), + linear-gradient(315deg, rgba(29, 123, 82, .12), transparent 38%), + var(--bg); +} + +.login-panel { + width: min(430px, 100%); + display: grid; + gap: 12px; + border: 1px solid var(--line-dark); + border-radius: 8px; + background: #fff; + box-shadow: 0 18px 48px rgba(28, 35, 45, .16); + padding: 24px; +} + +.login-mark { + width: 42px; + height: 42px; + border: 2px solid var(--accent); + border-radius: 8px; + background: + linear-gradient(90deg, transparent 46%, rgba(29, 95, 143, .18) 47%, rgba(29, 95, 143, .18) 53%, transparent 54%), + linear-gradient(0deg, transparent 46%, rgba(29, 95, 143, .18) 47%, rgba(29, 95, 143, .18) 53%, transparent 54%), + #fff; +} + +.login-panel p { + margin: -4px 0 4px; + color: var(--muted); +} + +.login-panel label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.login-panel button { + min-height: 40px; +} + +.login-message { + min-height: 18px; + color: var(--bad); +} + +.app-shell { + height: 100vh; + overflow: hidden; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +h1, +h2 { + margin: 0; + letter-spacing: 0; +} + +h1 { + font-size: 18px; +} + +h2 { + font-size: 16px; +} + +.topbar { + min-height: 76px; + display: grid; + grid-template-columns: minmax(250px, 320px) auto minmax(720px, 1fr) auto; + align-items: center; + gap: 14px; + padding: 10px 14px; + border-bottom: 1px solid var(--line-dark); + background: #f9fafb; +} + +.session-box { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 160px; +} + +.session-box span { + color: var(--muted); + white-space: nowrap; +} + +.session-box button { + min-height: 34px; + padding: 6px 12px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.brand p, +.detail-head p { + margin: 3px 0 0; + color: var(--muted); + font-size: 12px; +} + +.mark { + width: 34px; + height: 34px; + border: 2px solid var(--accent); + border-radius: 7px; + background: + linear-gradient(90deg, transparent 46%, rgba(29, 95, 143, .16) 47%, rgba(29, 95, 143, .16) 53%, transparent 54%), + linear-gradient(0deg, transparent 46%, rgba(29, 95, 143, .16) 47%, rgba(29, 95, 143, .16) 53%, transparent 54%), + #fff; + flex: 0 0 auto; +} + +.top-nav { + display: inline-flex; + gap: 4px; + padding: 4px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + overflow: auto; +} + +.nav-button { + min-height: 34px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + padding: 6px 10px; + white-space: nowrap; +} + +.nav-button.is-active { + background: var(--accent); + color: #fff; +} + +.status-grid { + display: grid; + grid-template-columns: repeat(6, minmax(104px, 1fr)); + gap: 8px; +} + +.status-card, +.metric-card { + min-height: 48px; + padding: 7px 9px; + border: 1px solid var(--line); + border-radius: 7px; + background: #fff; +} + +.status-card span, +.metric-card span { + display: block; + color: var(--muted); + font-size: 12px; +} + +.status-card strong, +.metric-card strong { + display: block; + margin-top: 2px; + font-size: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.status-online { + color: var(--ok); +} + +.status-offline { + color: var(--bad); +} + +.status-muted { + color: var(--muted); +} + +.page-view { + height: calc(100vh - 76px); + min-height: 0; + overflow: hidden; +} + +.is-hidden { + display: none !important; +} + +.overview-page, +.audit-page, +.audit-history-page, +.settings-page { + padding: 12px; + overflow: auto; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.page-band { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); + padding: 12px; + margin-bottom: 12px; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.section-head.compact { + margin-bottom: 6px; +} + +.section-head span { + color: var(--muted); + font-size: 12px; +} + +.queue-summary, +.settings-list { + display: grid; + gap: 8px; +} + +.queue-line, +.settings-list > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 38px; + border-bottom: 1px solid var(--line); + padding: 6px 0; +} + +.queue-line:last-child, +.settings-list > div:last-child { + border-bottom: 0; +} + +.queue-line span, +.settings-list span { + color: var(--muted); +} + +.workspace { + height: calc(100vh - 76px); + display: grid; + grid-template-columns: 300px minmax(720px, 1.25fr) minmax(390px, .85fr); + gap: 10px; + padding: 10px; +} + +.record-panel, +.pdf-panel, +.detail-panel { + min-height: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); + overflow: hidden; +} + +.record-panel, +.detail-panel { + display: flex; + flex-direction: column; +} + +.panel-tools { + display: grid; + gap: 8px; + padding: 10px; + border-bottom: 1px solid var(--line); + background: #fbfcfd; +} + +.bulk-action { + width: 100%; + min-height: 34px; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid var(--line-dark); + border-radius: 6px; + background: #fff; + color: var(--ink); +} + +input, +select { + min-height: 34px; + padding: 6px 8px; +} + +textarea { + padding: 8px; + resize: vertical; +} + +.record-list, +.audit-list { + overflow: auto; + padding: 6px; +} + +.record-item { + width: 100%; + display: block; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + text-align: left; + padding: 9px; +} + +.record-item:hover, +.record-item.is-active { + border-color: var(--line-dark); + background: #f3f6f8; +} + +.record-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.record-name { + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tag { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + padding: 2px 6px; + border-radius: 5px; + font-size: 12px; + border: 1px solid var(--line); + color: var(--muted); + background: #fff; + white-space: nowrap; +} + +.tag.needs_review, +.tag.audit-failed { + color: var(--bad); + border-color: #efb6ae; + background: var(--bad-bg); +} + +.tag.auto_corrected, +.tag.audit-unsure, +.tag.ai_pending { + color: var(--warn); + border-color: #efd28b; + background: var(--warn-bg); +} + +.tag.reviewed, +.tag.auto_pass, +.tag.ai_passed, +.tag.audit-passed { + color: var(--ok); + border-color: #b6dec9; + background: var(--ok-bg); +} + +.tag.submitted { + color: var(--muted); + border-color: var(--line-dark); + background: #f3f5f7; +} + +.record-sub { + margin-top: 5px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.record-time { + display: block; + margin-top: 2px; + color: #7b8794; +} + +.pdf-panel { + display: flex; + flex-direction: column; +} + +.pdf-toolbar, +.detail-head { + min-height: 54px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid var(--line); + background: #fbfcfd; +} + +.pdf-toolbar > div:first-child { + min-width: 0; +} + +.detail-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.ai-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.ai-actions button { + min-height: 32px; + border-color: #6f8f35; + background: #eef7df; + color: #304b12; +} + +.ai-actions button:disabled { + cursor: wait; + color: var(--muted); + border-color: var(--line-dark); + background: #eef1f4; +} + +#pdfSubtitle { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 12px; +} + +.pdf-zoom-controls { + display: inline-grid; + grid-template-columns: 32px 50px 32px 42px; + align-items: center; + gap: 4px; + flex: 0 0 auto; +} + +.pdf-zoom-controls button { + width: 100%; + height: 30px; + padding: 0; + border-radius: 6px; + font-weight: 700; + line-height: 1; +} + +.pdf-zoom-controls span { + display: block; + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.view-switch { + display: inline-flex; + border: 1px solid var(--line-dark); + border-radius: 7px; + overflow: hidden; + background: #fff; +} + +.view-switch button { + min-width: 52px; + border: 0; + border-right: 1px solid var(--line); + background: #fff; + padding: 6px 8px; +} + +.view-switch button:last-child { + border-right: 0; +} + +.view-switch button.is-active { + background: var(--accent); + color: #fff; +} + +.viewer { + flex: 1; + min-height: 0; + background: #dfe4ea; +} + +.review-strip { + display: grid; + grid-template-columns: 150px minmax(260px, 1fr); + gap: 8px; + padding: 8px 10px; + border-top: 1px solid var(--line); + background: #fbfcfd; +} + +.strip-item { + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 7px; + background: #fff; +} + +.strip-item span { + display: block; + color: var(--muted); + font-size: 12px; + margin-bottom: 3px; +} + +.strip-item strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.strip-note { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.strip-note span { + margin: 0; +} + +.strip-note input { + min-height: 30px; +} + +#pdfFrame, +#auditPdfFrame { + display: block; + width: 100%; + height: 100%; + border: 0; + background: #fff; +} + +.target-summary { + border-bottom: 1px solid var(--line); + background: #fffafa; + padding: 0; +} + +.collapsible-panel > summary { + cursor: pointer; + list-style: none; +} + +.collapsible-panel > summary::-webkit-details-marker { + display: none; +} + +.target-panel > summary, +.review-log-panel > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 40px; + padding: 8px 10px; +} + +.target-panel > summary::before, +.review-log-panel > summary::before { + content: "▸"; + color: var(--muted); + font-size: 12px; +} + +.target-panel[open] > summary::before, +.review-log-panel[open] > summary::before { + content: "▾"; +} + +.target-panel > summary strong, +.review-log-panel > summary h2 { + flex: 1; +} + +.target-panel > summary span { + color: var(--muted); + font-size: 12px; +} + +.target-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 10px 6px; +} + +.target-chips button, +.target-chips span { + border: 1px solid #e7aea7; + border-radius: 6px; + background: #fff; + color: var(--bad); + padding: 4px 7px; + font-size: 12px; +} + +.target-summary ul { + margin: 0; + padding: 0 10px 10px 28px; + color: #6e332d; + font-size: 12px; + line-height: 1.5; +} + +#saveBtn, +.login-panel button, +.session-box button, +.audit-save-actions button, +.page-band button, +.bulk-action, +.button-row button { + min-height: 34px; + border: 1px solid var(--accent-dark); + border-radius: 6px; + background: var(--accent); + color: #fff; + padding: 6px 10px; + white-space: nowrap; +} + +#saveBtn:disabled { + cursor: not-allowed; + background: #b9c4ce; + border-color: #a9b4bf; +} + +.button-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.button-row button.danger, +.audit-save-actions button.danger, +.page-band button.danger { + background: var(--bad); + border-color: #9f2118; +} + +.audit-save-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.audit-save-actions button { + min-height: 30px; + padding: 5px 8px; + font-size: 12px; +} + +.detail-form { + flex: 1; + min-height: 0; + overflow: auto; + padding: 10px; +} + +.field-group { + border: 1px solid var(--line); + border-radius: 8px; + margin-bottom: 10px; + overflow: hidden; +} + +.field-group.is-alert { + border-color: #df8e84; + box-shadow: inset 3px 0 0 var(--bad); +} + +.field-group summary { + cursor: pointer; + padding: 8px 10px; + background: #eef2f5; + font-weight: 700; +} + +.field-group.is-alert summary { + background: var(--bad-bg); + color: #6e241e; +} + +.field-group summary span { + float: right; + font-size: 12px; + color: var(--bad); +} + +.field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px; + padding: 10px; +} + +.field { + min-width: 0; +} + +.field.is-wide { + grid-column: 1 / -1; +} + +.field > span { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + color: var(--muted); + font-size: 12px; +} + +.field > span em { + color: var(--bad); + font-style: normal; +} + +.field.is-alert input, +.field.is-alert select, +.field.is-alert textarea, +.field.is-alert .json-editor { + border-color: #d9796f; + background: var(--focus-bg); +} + +.json-editor { + border: 1px solid var(--line); + border-radius: 7px; + overflow: hidden; + background: #fff; +} + +.json-toolbar { + display: flex; + gap: 6px; + padding: 6px; + border-bottom: 1px solid var(--line); + background: #f8fafb; +} + +.json-toolbar button, +.json-table button { + border: 1px solid var(--line-dark); + border-radius: 5px; + background: #fff; + padding: 4px 7px; + color: var(--ink); +} + +.json-table-wrap, +.data-table-wrap { + overflow: auto; +} + +.json-table-wrap { + max-height: 360px; +} + +.json-table, +.data-table { + width: max-content; + min-width: 100%; + border-collapse: collapse; +} + +.json-table th, +.json-table td, +.data-table th, +.data-table td { + border-bottom: 1px solid var(--line); + border-right: 1px solid var(--line); + padding: 6px; + vertical-align: top; +} + +.json-table th, +.data-table th { + position: sticky; + top: 0; + z-index: 1; + background: #eef2f5; + color: #303844; + font-size: 12px; + white-space: nowrap; +} + +.json-table input { + min-width: 120px; + border-color: #c8d0d8; + background: #fff; +} + +.json-table td:nth-child(4) input, +.json-table td:nth-child(11) input { + min-width: 260px; +} + +.manual-note, +.review-log-panel { + padding: 10px; + border-top: 1px solid var(--line); + background: #fbfcfd; +} + +.manual-note label, +.audit-toolbar label, +.audit-side label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 12px; +} + +.review-log-panel { + max-height: 230px; + overflow: auto; + padding: 0; +} + +.review-log-panel .data-table-wrap { + max-height: 184px; +} + +.save-message, +.inline-message { + min-height: 28px; + padding: 0 10px 10px; + color: var(--muted); +} + +.inline-message { + padding: 0; +} + +.save-message.is-ok, +.inline-message.is-ok { + color: var(--ok); +} + +.save-message.is-error, +.inline-message.is-error { + color: var(--bad); +} + +.audit-toolbar { + display: grid; + grid-template-columns: minmax(180px, 260px) 120px auto 1fr; + align-items: end; + gap: 10px; +} + +.audit-layout { + height: calc(100vh - 170px); + display: grid; + grid-template-columns: 300px minmax(760px, 1.35fr) minmax(390px, .9fr); + gap: 10px; +} + +.audit-list, +.audit-record, +.audit-side { + min-height: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + box-shadow: var(--shadow); + overflow: auto; +} + +.audit-record { + padding: 10px; +} + +.audit-side { + padding: 12px; + display: grid; + align-content: start; + gap: 12px; +} + +.audit-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 4px; +} + +.audit-meta { + color: var(--muted); + font-size: 12px; + margin-bottom: 8px; +} + +.audit-pdf { + width: 100%; + height: calc(100vh - 250px); + min-height: 420px; + border: 1px solid var(--line); + background: #fff; +} + +.settings-grid { + display: grid; + grid-template-columns: minmax(420px, 1fr) minmax(420px, 1fr); + gap: 12px; +} + +.settings-wide { + grid-column: 1 / -1; +} + +.settings-form { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) minmax(360px, 2fr) auto; + gap: 10px; + align-items: end; + margin-bottom: 12px; +} + +.status-check-form { + display: grid; + grid-template-columns: minmax(74px, 100px) minmax(120px, 1fr) minmax(180px, 1.4fr) 82px auto; + gap: 8px; + align-items: center; +} + +.status-check-form input { + min-height: 34px; +} + +.toggle-field { + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--ink); + white-space: nowrap; +} + +.toggle-field input { + width: auto; + min-height: 0; +} + +.permission-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 8px; +} + +.permission-item { + display: flex; + align-items: center; + gap: 7px; + min-height: 34px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fbfcfd; + padding: 5px 8px; +} + +.permission-item input { + width: auto; + min-height: auto; +} + +.user-list { + display: grid; + gap: 10px; +} + +.user-card { + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px; + background: #fff; +} + +.user-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 9px; +} + +.user-card-head span { + color: var(--muted); + font-size: 12px; +} + +.user-edit-grid { + display: grid; + grid-template-columns: repeat(2, minmax(180px, 1fr)); + gap: 10px; + margin-bottom: 10px; +} + +.user-edit-grid label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 12px; +} + +.user-actions { + margin-top: 10px; +} + +.empty { + padding: 18px; + color: var(--muted); +} + +.empty.small { + padding: 8px; + font-size: 12px; +} + +.list-note { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 10px; + border-bottom: 1px solid var(--line); + background: #fffdf4; + color: var(--warn); + font-size: 12px; +} + +.list-loading, +.list-end { + padding: 10px; + color: var(--muted); + font-size: 12px; + text-align: center; +} + +@media (max-width: 1280px) { + .topbar { + grid-template-columns: 1fr; + } + + .status-grid { + width: 100%; + } + + .workspace, + .audit-layout, + .settings-grid, + .settings-form { + height: auto; + min-height: calc(100vh - 130px); + grid-template-columns: 1fr; + } + + .settings-form { + min-height: 0; + } + + .status-check-form { + grid-template-columns: 1fr; + } + + .record-panel, + .pdf-panel, + .detail-panel, + .audit-list, + .audit-record, + .audit-side { + min-height: 420px; + } + + .review-strip { + grid-template-columns: 1fr 1fr; + } +} diff --git a/患者首页处理/数据可视化网页端/docker-compose.yml b/患者首页处理/数据可视化网页端/docker-compose.yml new file mode 100644 index 0000000..7015d70 --- /dev/null +++ b/患者首页处理/数据可视化网页端/docker-compose.yml @@ -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: diff --git a/患者首页处理/数据处理工作区/01_配置规则/01_科室分类规则.json b/患者首页处理/数据处理工作区/01_配置规则/01_科室分类规则.json new file mode 100644 index 0000000..110c8e6 --- /dev/null +++ b/患者首页处理/数据处理工作区/01_配置规则/01_科室分类规则.json @@ -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", + "健苑五涉外": "健苑五涉外病房", + "健苑五涉外病房": "健苑五涉外病房", + "老年外科": "老年外科" + } +} diff --git a/患者首页处理/数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py b/患者首页处理/数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py new file mode 100755 index 0000000..de1f146 --- /dev/null +++ b/患者首页处理/数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py @@ -0,0 +1,2181 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +批量处理住院病案首页 PDF。 + +默认从项目根目录的“待处理-患者首页PDF”读取 PDF,并把 CSV/JSONL/单份 JSON +写入“数据处理结果区”。脚本只依赖 Python 标准库和系统命令 pdftotext。 +""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_INPUT_DIR = PROJECT_ROOT / "待处理-患者首页PDF" +DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "数据处理结果区" +DEFAULT_PG_TABLE = "Patient_FrontPages" +DEFAULT_DEPARTMENT_RULE_PATH = PROJECT_ROOT / "数据处理工作区" / "01_配置规则" / "01_科室分类规则.json" +DEFAULT_MINERU_CLIENT_PATH = PROJECT_ROOT / "数据处理工作区" / "05_备用读取" / "05_备用PDF转Markdown_Mineru.py" +DEFAULT_MINERU_MD_DIR = DEFAULT_OUTPUT_DIR / "06_Mineru_MD" +DEFAULT_MINERU_URL = os.environ.get("MINERU_URL", "http://10.168.1.103:4000/extract") + + +CSV_COLUMNS = [ + "住院号", + "源文件", + "病案号", + "首页病案号", + "姓名", + "性别", + "出生日期", + "年龄", + "身份证号", + "新生儿年龄(月)", + "新生儿出生体重(克)", + "新生儿入院体重(克)", + "医疗机构", + "组织机构代码", + "医疗付费方式", + "住院次数", + "入院时间", + "入院科别", + "入院病房", + "转科科别", + "转科时间", + "出院时间", + "出院科别", + "出院病房", + "大科室", + "实际住院天数", + "门急诊诊断", + "门急诊诊断编码", + "主要诊断", + "主要诊断编码", + "主要诊断入院病情", + "出院诊断", + "手术操作", + "病理诊断", + "病理诊断编码", + "病理号", + "药物过敏代码", + "过敏药物", + "死亡患者尸检代码", + "血型代码", + "Rh代码", + "科主任", + "主任副主任医师", + "主治医师", + "住院医师", + "责任护士", + "进修医师", + "实习医师", + "规培医师", + "编码员", + "病案质量代码", + "质控医师", + "质控护士", + "质控日期", + "离院方式代码", + "出院31天内再住院计划代码", + "总费用", + "自付金额", + "质控状态", + "质控提示", + "复核状态", + "复核备注", + "文本抽取方式", + "自动修正", + "人工修正", +] + + +PG_COLUMNS: list[tuple[str, str]] = [ + ("source_file", "TEXT NOT NULL"), + ("inpatient_no", "TEXT"), + ("medical_record_no", "TEXT"), + ("front_page_medical_record_no", "TEXT"), + ("patient_name", "TEXT"), + ("gender", "TEXT"), + ("birth_date", "DATE"), + ("age", "TEXT"), + ("nationality", "TEXT"), + ("id_card_no", "TEXT"), + ("neonatal_age_months", "INTEGER"), + ("newborn_birth_weight_g", "INTEGER"), + ("newborn_admission_weight_g", "INTEGER"), + ("hospital_name", "TEXT"), + ("organization_code", "TEXT"), + ("payment_method", "TEXT"), + ("health_card_no", "TEXT"), + ("admission_count", "INTEGER"), + ("birthplace", "TEXT"), + ("native_place", "TEXT"), + ("ethnicity", "TEXT"), + ("occupation", "TEXT"), + ("marital_status_code", "TEXT"), + ("current_address", "TEXT"), + ("current_address_phone", "TEXT"), + ("current_address_postcode", "TEXT"), + ("household_address", "TEXT"), + ("household_postcode", "TEXT"), + ("employer_address", "TEXT"), + ("employer_phone", "TEXT"), + ("employer_postcode", "TEXT"), + ("contact_name", "TEXT"), + ("contact_relationship", "TEXT"), + ("contact_address", "TEXT"), + ("contact_phone", "TEXT"), + ("admission_path_code", "TEXT"), + ("admission_time", "TIMESTAMP"), + ("admission_dept", "TEXT"), + ("admission_ward", "TEXT"), + ("transfer_dept", "TEXT"), + ("transfer_time", "TEXT"), + ("discharge_time", "TIMESTAMP"), + ("discharge_dept", "TEXT"), + ("discharge_ward", "TEXT"), + ("major_department", "TEXT"), + ("hospital_days", "INTEGER"), + ("outpatient_diagnosis", "TEXT"), + ("outpatient_diagnosis_code", "TEXT"), + ("primary_diagnosis", "TEXT"), + ("primary_diagnosis_code", "TEXT"), + ("primary_admission_condition", "TEXT"), + ("discharge_diagnoses", "JSONB"), + ("operations", "JSONB"), + ("injury_poisoning_external_cause", "TEXT"), + ("injury_poisoning_code", "TEXT"), + ("pathology_diagnosis", "TEXT"), + ("pathology_diagnosis_code", "TEXT"), + ("pathology_no", "TEXT"), + ("drug_allergy_code", "TEXT"), + ("allergy_drug", "TEXT"), + ("autopsy_code", "TEXT"), + ("blood_type_code", "TEXT"), + ("rh_code", "TEXT"), + ("department_director", "TEXT"), + ("chief_physician", "TEXT"), + ("attending_physician", "TEXT"), + ("resident_physician", "TEXT"), + ("responsible_nurse", "TEXT"), + ("refresher_physician", "TEXT"), + ("intern_physician", "TEXT"), + ("standardized_resident_physician", "TEXT"), + ("coder", "TEXT"), + ("record_quality_code", "TEXT"), + ("quality_control_physician", "TEXT"), + ("quality_control_nurse", "TEXT"), + ("quality_control_date", "DATE"), + ("discharge_disposition_code", "TEXT"), + ("receiving_org_name", "TEXT"), + ("readmission_plan_code", "TEXT"), + ("readmission_plan_purpose", "TEXT"), + ("coma_before_days", "INTEGER"), + ("coma_before_hours", "INTEGER"), + ("coma_before_minutes", "INTEGER"), + ("coma_after_days", "INTEGER"), + ("coma_after_hours", "INTEGER"), + ("coma_after_minutes", "INTEGER"), + ("total_cost", "NUMERIC(12,2)"), + ("self_pay_amount", "NUMERIC(12,2)"), + ("fee_details", "JSONB"), + ("quality_status", "TEXT"), + ("quality_notes", "JSONB"), + ("review_status", "TEXT NOT NULL DEFAULT 'pending'"), + ("review_notes", "JSONB NOT NULL DEFAULT '[]'::jsonb"), + ("manual_corrected", "BOOLEAN NOT NULL DEFAULT false"), + ("auto_corrections", "JSONB NOT NULL DEFAULT '[]'::jsonb"), + ("text_extraction_method", "TEXT"), + ("mineru_markdown_dir", "TEXT"), + ("raw_text", "TEXT"), +] + + +PG_COLUMN_COMMENTS: dict[str, str] = { + "id": "自增主键,仅用于数据库内部定位记录。", + "source_file": "来源PDF文件名;重复入库时以住院号为准更新。", + "inpatient_no": "患者号/住院号,作为首页与患者列表联动唯一键;不能为空,格式由患者目录核验端处理。", + "medical_record_no": "病案号,统一保存为10位文本,保留前导0。", + "front_page_medical_record_no": "PDF首页病案号,统一保存为10位文本,保留前导0。", + "patient_name": "患者姓名。", + "gender": "患者性别。", + "birth_date": "出生日期。", + "age": "首页记录的住院年龄。", + "nationality": "国籍。", + "id_card_no": "居民身份证号。", + "neonatal_age_months": "年龄不足1周岁患儿的年龄(月)。", + "newborn_birth_weight_g": "新生儿出生体重(克)。", + "newborn_admission_weight_g": "新生儿入院体重(克)。", + "hospital_name": "医疗机构名称。", + "organization_code": "医疗机构组织机构代码。", + "payment_method": "医疗付费方式。", + "health_card_no": "健康卡号。", + "admission_count": "本机构第几次住院。", + "birthplace": "出生地。", + "native_place": "籍贯。", + "ethnicity": "民族。", + "occupation": "职业。", + "marital_status_code": "婚姻状况代码:1未婚、2已婚、3丧偶、4离婚、9其他。", + "current_address": "现住址。", + "current_address_phone": "现住址联系电话。", + "current_address_postcode": "现住址邮编。", + "household_address": "户口地址。", + "household_postcode": "户口地址邮编。", + "employer_address": "工作单位及地址。", + "employer_phone": "单位电话。", + "employer_postcode": "单位邮编。", + "contact_name": "联系人姓名,位于首页第一面“联系人姓名”栏。", + "contact_relationship": "联系人与患者关系,位于首页第一面“关系”栏。", + "contact_address": "联系人地址,位于首页第一面“联系人姓名/关系/地址/电话”这一行的“地址”栏;不是入院途径选项。", + "contact_phone": "联系人电话,位于首页第一面“电话”栏。", + "admission_path_code": "入院途径代码:1急诊、2门诊、3其他医疗机构转入、9其他。", + "admission_time": "入院时间。", + "admission_dept": "入院科别。", + "admission_ward": "入院病房。", + "transfer_dept": "转科科别。", + "transfer_time": "转科时间;首页该项常为空或为横线。", + "discharge_time": "出院时间。", + "discharge_dept": "出院科别。", + "discharge_ward": "出院病房。", + "major_department": "大科室分类,由科室分类规则根据出院科别优先、入院科别兜底映射。", + "hospital_days": "实际住院天数。", + "outpatient_diagnosis": "门(急)诊诊断。", + "outpatient_diagnosis_code": "门(急)诊诊断疾病编码。", + "primary_diagnosis": "主要出院诊断名称。", + "primary_diagnosis_code": "主要出院诊断疾病编码。", + "primary_admission_condition": "主要诊断入院病情代码。", + "discharge_diagnoses": "出院诊断明细JSON数组,包含主要诊断和其他诊断。", + "operations": "手术及操作明细JSON数组。", + "injury_poisoning_external_cause": "损伤、中毒的外部原因。", + "injury_poisoning_code": "损伤、中毒外部原因疾病编码。", + "pathology_diagnosis": "病理诊断。", + "pathology_diagnosis_code": "病理诊断疾病编码。", + "pathology_no": "病理号。", + "drug_allergy_code": "药物过敏代码:1无、2有。", + "allergy_drug": "过敏药物名称。", + "autopsy_code": "死亡患者尸检代码:1是、2否、3-。", + "blood_type_code": "ABO血型代码:1A、2B、3O、4AB、5不详、6未查。", + "rh_code": "Rh血型代码:1阴、2阳、3不详、4未查。", + "department_director": "科主任。", + "chief_physician": "主任(副主任)医师。", + "attending_physician": "主治医师。", + "resident_physician": "住院医师。", + "responsible_nurse": "责任护士。", + "refresher_physician": "进修医师。", + "intern_physician": "实习医师。", + "standardized_resident_physician": "规培医师。", + "coder": "病案首页编码员。", + "record_quality_code": "病案质量代码:1甲、2乙、3丙。", + "quality_control_physician": "质控医师。", + "quality_control_nurse": "质控护士。", + "quality_control_date": "质控日期。", + "discharge_disposition_code": "离院方式代码:1医嘱离院、2医嘱转院、3医嘱转社区/乡镇卫生院、4非医嘱离院、5死亡、9其他。", + "receiving_org_name": "拟接收医疗机构名称。", + "readmission_plan_code": "是否有出院31天内再住院计划代码:1无、2有。", + "readmission_plan_purpose": "出院31天内再住院计划目的。", + "coma_before_days": "颅脑损伤患者昏迷时间:入院前天数。", + "coma_before_hours": "颅脑损伤患者昏迷时间:入院前小时数。", + "coma_before_minutes": "颅脑损伤患者昏迷时间:入院前分钟数。", + "coma_after_days": "颅脑损伤患者昏迷时间:入院后天数。", + "coma_after_hours": "颅脑损伤患者昏迷时间:入院后小时数。", + "coma_after_minutes": "颅脑损伤患者昏迷时间:入院后分钟数。", + "total_cost": "住院总费用。", + "self_pay_amount": "自付金额。", + "fee_details": "住院费用分类明细JSON对象。", + "quality_status": "程序质控状态。", + "quality_notes": "程序质控提示JSON数组。", + "review_status": "复核状态:auto_pass自动通过、auto_corrected已自动修正、needs_review需复核、reviewed已人工复核。", + "review_notes": "人工或程序复核备注JSON数组。", + "manual_corrected": "是否经过人工修正。", + "auto_corrections": "程序自动修正记录JSON数组。", + "text_extraction_method": "本次解析使用的文本抽取方式:pdftotext或mineru_markdown。", + "mineru_markdown_dir": "Mineru Markdown输出目录;未使用Mineru时为空。", + "raw_text": "PDF抽取出的首页原始文本,可能来自pdftotext或Mineru Markdown,用于追溯和人工核对。", +} + + +def normalize_spaces(text: str) -> str: + return re.sub(r"[ \t]+", " ", text.strip()) + + +def clean_value(value: str | None) -> str: + if value is None: + return "" + value = normalize_spaces(value) + return "" if value in {"-", "—", "无"} else value + + +def clean_int_value(value: str | None) -> str: + value = clean_value(value) + return value if re.fullmatch(r"\d+", value) else "" + + +def first_match(pattern: str, text: str, group: int = 1, flags: int = 0) -> str: + match = re.search(pattern, text, flags) + if not match: + return "" + return clean_value(match.group(group)) + + +def filename_medical_record_no(pdf_path: Path) -> str: + match = re.match(r"^ZY\d{2}(\d{10})", pdf_path.stem, flags=re.IGNORECASE) + return match.group(1) if match else "" + + +def filename_admission_count(pdf_path: Path) -> str: + match = re.match(r"^ZY(\d{2})\d{10}", pdf_path.stem, flags=re.IGNORECASE) + return match.group(1) if match else "" + + +def filename_inpatient_no(pdf_path: Path) -> str: + match = re.match(r"^(ZY\d{12})", pdf_path.stem, flags=re.IGNORECASE) + return match.group(1).upper() if match else "" + + +def normalize_digits(value: Any, width: int) -> str: + digits = re.sub(r"\D", "", clean_value(str(value)) if value is not None else "") + if not digits: + return "" + return digits[-width:].zfill(width) + + +def build_inpatient_no( + admission_count: Any, + front_page_medical_record_no: Any, + medical_record_no: Any, + pdf_path: Path, +) -> tuple[str, list[str], list[str]]: + corrections: list[str] = [] + notes: list[str] = [] + filename_no = filename_inpatient_no(pdf_path) + + admission = normalize_digits(admission_count, 2) or filename_admission_count(pdf_path) + page_no = normalize_digits(front_page_medical_record_no, 10) or normalize_digits(medical_record_no, 10) or filename_medical_record_no(pdf_path) + if admission and page_no: + inpatient_no = f"ZY{admission}{page_no}" + if filename_no and filename_no != inpatient_no: + notes.append(f"住院号{inpatient_no}与文件名住院号{filename_no}不一致,请核对住院次数和首页病案号") + if clean_value(str(front_page_medical_record_no)) and normalize_digits(front_page_medical_record_no, 10) != clean_value(str(front_page_medical_record_no)): + corrections.append(f"首页病案号用于住院号时补齐为{page_no}") + return inpatient_no, corrections, notes + + if filename_no: + corrections.append(f"住院号由文件名补充为{filename_no}") + return filename_no, corrections, notes + + notes.append("住院号无法生成:缺少住院次数或首页病案号") + return "", corrections, notes + + +def normalize_medical_record_no(raw_no: str, pdf_path: Path) -> tuple[str, str, list[str], list[str]]: + original = clean_value(raw_no) + from_filename = filename_medical_record_no(pdf_path) + corrections: list[str] = [] + notes: list[str] = [] + + if from_filename and original != from_filename: + if original and from_filename.endswith(original): + corrections.append(f"病案号由PDF值{original}按文件名补齐为{from_filename}") + elif original: + notes.append(f"PDF病案号{original}与文件名病案号{from_filename}不一致,已优先采用文件名") + else: + corrections.append(f"病案号由文件名补充为{from_filename}") + return from_filename, original, corrections, notes + + if re.fullmatch(r"\d{1,9}", original): + normalized = original.zfill(10) + corrections.append(f"病案号由{original}补齐为{normalized}") + return normalized, original, corrections, notes + + return original, original, corrections, notes + + +def normalize_department_key(value: str) -> str: + return re.sub(r"\s+", "", clean_value(value)) + + +def department_lookup_candidates(value: str) -> list[str]: + base = normalize_department_key(value) + candidates: list[str] = [] + + def add(candidate: str) -> None: + candidate = normalize_department_key(candidate) + if candidate and candidate not in candidates: + candidates.append(candidate) + + add(base) + add(base.replace("病房", "")) + add(base.replace("病区", "")) + add(base.replace("病房", "").replace("病区", "")) + + for candidate in list(candidates): + add(candidate.replace("胸外科", "胸外")) + add(candidate.replace("泌尿外科", "泌尿外")) + add(candidate.replace("感染科", "感染")) + add(candidate.replace("普通外科", "普外科")) + + return candidates + + +def load_department_rules(rule_path: Path) -> dict[str, dict[str, str]]: + if not rule_path.exists(): + return {} + data = json.loads(rule_path.read_text(encoding="utf-8")) + aliases = { + normalize_department_key(alias): normalize_department_key(standard) + for alias, standard in data.get("aliases", {}).items() + } + standard_to_major: dict[str, str] = {} + for group in data.get("大科室列表", []): + major = clean_value(group.get("大科室", "")) + for department in group.get("子科室", []): + standard = normalize_department_key(department) + standard_to_major[standard] = major + aliases.setdefault(standard, standard) + return {"aliases": aliases, "standard_to_major": standard_to_major} + + +def classify_major_department(record: dict[str, Any], rules: dict[str, dict[str, str]]) -> tuple[str, str]: + if not rules: + return "", "" + aliases = rules.get("aliases", {}) + standard_to_major = rules.get("standard_to_major", {}) + for source_key in ["出院科别", "入院科别"]: + for candidate in department_lookup_candidates(str(record.get(source_key, ""))): + standard = aliases.get(candidate, candidate) + major = standard_to_major.get(standard) + if major: + return major, standard + return "", "" + + +def extract_text_with_pdftotext(pdf_path: Path) -> str: + if shutil.which("pdftotext") is None: + raise RuntimeError("未找到 pdftotext。请先安装 poppler-utils 后再运行。") + + command = ["pdftotext", "-layout", "-enc", "UTF-8", str(pdf_path), "-"] + completed = subprocess.run( + command, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + if completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or "pdftotext 转换失败") + return completed.stdout.replace("\r\n", "\n").replace("\r", "\n") + + +def text_needs_mineru_fallback(text: str) -> bool: + stripped = text.strip() + if len(stripped) < 200: + return True + required_markers = ["病案号", "医疗机构", "主要诊断"] + return sum(1 for marker in required_markers if marker in stripped) < 2 + + +def markdown_files_for_pdf(pdf_path: Path, md_dir: Path) -> list[Path]: + folder = md_dir / pdf_path.stem + if not folder.exists(): + return [] + return sorted([*folder.rglob("*.md"), *folder.rglob("*.markdown"), *folder.rglob("*.txt")]) + + +def read_mineru_markdown(pdf_path: Path, md_dir: Path) -> str: + parts: list[str] = [] + for file_path in markdown_files_for_pdf(pdf_path, md_dir): + parts.append(file_path.read_text(encoding="utf-8", errors="replace")) + return "\n\n".join(parts).replace("\r\n", "\n").replace("\r", "\n") + + +def ensure_mineru_markdown(pdf_path: Path, args: argparse.Namespace) -> None: + md_dir = args.mineru_md_dir.resolve() + if markdown_files_for_pdf(pdf_path, md_dir): + return + client_path = args.mineru_client.resolve() + if not client_path.exists(): + raise RuntimeError(f"未找到 Mineru 客户端:{client_path}") + + command = [ + sys.executable, + str(client_path), + "-s", + str(pdf_path.parent.resolve()), + "-t", + str(md_dir), + "-u", + args.mineru_url, + ] + if args.mineru_sync: + command.append("--sync") + completed = subprocess.run( + command, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + if completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or completed.stdout.strip() or "Mineru PDF转Markdown失败") + + +def extract_record_text(pdf_path: Path, args: argparse.Namespace) -> tuple[str, str, str]: + text_source = args.text_source + if text_source == "pdftotext": + return extract_text_with_pdftotext(pdf_path), "pdftotext", "" + + if text_source == "mineru": + ensure_mineru_markdown(pdf_path, args) + markdown_text = read_mineru_markdown(pdf_path, args.mineru_md_dir.resolve()) + if not markdown_text.strip(): + raise RuntimeError(f"Mineru未生成可读取的Markdown:{pdf_path.name}") + return markdown_text, "mineru_markdown", str((args.mineru_md_dir.resolve() / pdf_path.stem)) + + try: + pdftotext_text = extract_text_with_pdftotext(pdf_path) + except Exception: + pdftotext_text = "" + if pdftotext_text and not text_needs_mineru_fallback(pdftotext_text): + return pdftotext_text, "pdftotext", "" + + try: + ensure_mineru_markdown(pdf_path, args) + markdown_text = read_mineru_markdown(pdf_path, args.mineru_md_dir.resolve()) + if markdown_text.strip() and not text_needs_mineru_fallback(markdown_text): + return markdown_text, "mineru_markdown", str((args.mineru_md_dir.resolve() / pdf_path.stem)) + except Exception as exc: + if pdftotext_text: + return pdftotext_text, f"pdftotext_mineru_failed:{exc}", "" + raise + + if pdftotext_text: + return pdftotext_text, "pdftotext_suspicious_mineru_unusable", "" + return "", "empty", "" + + +def meaningful_lines(text: str) -> list[str]: + lines: list[str] = [] + for raw in text.splitlines(): + line = raw.strip() + if line: + lines.append(line) + return lines + + +def parse_header(text: str, lines: list[str]) -> dict[str, Any]: + result: dict[str, Any] = { + "组织机构代码": first_match(r"组织机构代码:\s*([^))]+)", text), + "医疗机构": first_match(r"医疗机构:\s*(.+)", text), + "医疗付费方式": first_match(r"医疗付费方式:\s*(.+)", text), + "健康卡号": first_match(r"健康卡号:\s*(.*?)\s*第", text), + "住院次数": first_match(r"第\s*(\d+)\s*次住院", text), + "病案号": first_match(r"病案号:\s*([A-Za-z0-9]+)", text), + } + + for idx, line in enumerate(lines): + if "病案号:" not in line or idx + 1 >= len(lines): + continue + patient_line = lines[idx + 1] + match = re.search( + r"^\s*(?P\S+)\s+(?P男|女)\s+" + r"(?P\d{4})\s*年\s*(?P\d{1,2})\s*月\s*" + r"(?P\d{1,2})\s*日\s+(?P[\d.]+)\s+(?P\S+)", + patient_line, + ) + if match: + result.update( + { + "姓名": clean_value(match.group("name")), + "性别": match.group("sex"), + "出生日期": f"{int(match.group('year')):04d}-{int(match.group('month')):02d}-{int(match.group('day')):02d}", + "年龄": clean_value(match.group("age")), + "国籍": clean_value(match.group("nation")), + } + ) + break + + return result + + +def parse_newborn_info(lines: list[str]) -> dict[str, str]: + result = { + "新生儿年龄(月)": "", + "新生儿出生体重(克)": "", + "新生儿入院体重(克)": "", + } + for idx, line in enumerate(lines): + if "年龄不足1周岁" not in line: + continue + joined = line + " " + (lines[idx + 1] if idx + 1 < len(lines) else "") + result["新生儿年龄(月)"] = clean_int_value(first_match(r")\s*([-\d]+)\s*月", joined)) + weights = [clean_int_value(item) for item in re.findall(r"([-\d]+)\s*克", joined)] + weights = [item for item in weights if item] + if weights: + result["新生儿出生体重(克)"] = weights[0] + result["新生儿入院体重(克)"] = weights[-1] + break + return result + + +def parse_basic_lines(lines: list[str]) -> dict[str, Any]: + result: dict[str, Any] = {} + id_index = -1 + + for idx, line in enumerate(lines): + match = re.match(r"^([1-9]\d{5}(?:18|19|20)\d{2}\d{2}\d{2}\d{3}[\dXx]|-)\s+(.+?)\s+([1-9])\s+1\.未婚", line) + if match: + id_index = idx + result["身份证号"] = clean_value(match.group(1)).upper() + result["职业"] = clean_value(match.group(2)) + result["婚姻代码"] = clean_value(match.group(3)) + if idx > 0: + parts = re.split(r"\s{2,}", lines[idx - 1].strip()) + if len(parts) >= 3: + result["出生地"] = clean_value(parts[0]) + result["籍贯"] = clean_value(parts[1]) + result["民族"] = clean_value(parts[-1]) + break + + if id_index >= 0: + address_names = [ + ("现住址", "现住址电话", "现住址邮编"), + ("户口地址", "", "户口地址邮编"), + ("工作单位及地址", "单位电话", "单位邮编"), + ] + for offset, names in enumerate(address_names, start=1): + if id_index + offset >= len(lines): + continue + address, phone, postcode = split_address_phone_postcode(lines[id_index + offset]) + result[names[0]] = address + if names[1]: + result[names[1]] = phone + result[names[2]] = postcode + + contact_line = find_contact_line(lines, id_index + 4) + if contact_line: + contact = parse_contact_line(contact_line) + result.update(contact) + + return result + + +def split_address_phone_postcode(line: str) -> tuple[str, str, str]: + pieces = re.split(r"\s{2,}", line.strip()) + if not pieces: + return "", "", "" + postcode = "" + phone = "" + if pieces and re.fullmatch(r"[\d-]{5,}|-", pieces[-1]): + postcode = clean_value(pieces.pop()) + if pieces and re.fullmatch(r"[\d-]{7,}|-", pieces[-1]): + phone = clean_value(pieces.pop()) + return clean_value(" ".join(pieces)), phone, postcode + + +def parse_contact_line(line: str) -> dict[str, str]: + if is_admission_path_line(line): + return { + "联系人姓名": "", + "联系人关系": "", + "联系人地址": "", + "联系人电话": "", + } + pieces = [clean_value(p) for p in re.split(r"\s{2,}", line.strip()) if clean_value(p)] + result = { + "联系人姓名": "", + "联系人关系": "", + "联系人地址": "", + "联系人电话": "", + } + if len(pieces) >= 1: + result["联系人姓名"] = pieces[0] + if len(pieces) >= 2: + result["联系人关系"] = pieces[1] + if len(pieces) >= 3: + if re.fullmatch(r"\d{7,}", pieces[-1]): + result["联系人电话"] = pieces[-1] + result["联系人地址"] = clean_value(" ".join(pieces[2:-1])) + else: + result["联系人地址"] = clean_value(" ".join(pieces[2:])) + return result + + +def is_admission_path_line(line: str) -> bool: + return bool(re.search(r"1\.急诊\s+2\.门诊", line)) or "其他医疗机构转入" in line + + +def find_contact_line(lines: list[str], start_index: int) -> str: + for idx in range(start_index, min(start_index + 3, len(lines))): + line = lines[idx] + if is_admission_path_line(line): + return "" + if re.search(r"\d{4}\s*年\s*\d{1,2}\s*月", line): + return "" + pieces = [clean_value(p) for p in re.split(r"\s{2,}", line.strip()) if clean_value(p)] + if len(pieces) >= 2: + return line + return "" + + +def parse_admission_discharge(lines: list[str]) -> dict[str, Any]: + result: dict[str, Any] = {} + date_line_indexes: list[int] = [] + + for idx, line in enumerate(lines): + if re.search(r"\d{4}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日\s*\d{1,2}\s*时", line): + date_line_indexes.append(idx) + + if date_line_indexes: + result.update(parse_visit_line(lines[date_line_indexes[0]], prefix="入院")) + if date_line_indexes[0] + 1 < len(lines): + result["转科科别"] = clean_value(lines[date_line_indexes[0] + 1]) + if date_line_indexes[0] + 2 < len(lines): + result["转科时间"] = clean_value(lines[date_line_indexes[0] + 2]) + if len(date_line_indexes) >= 2: + result.update(parse_visit_line(lines[date_line_indexes[1]], prefix="出院")) + + for idx, line in enumerate(lines): + if re.search(r"1\.急诊\s+2\.门诊", line): + result["入院途径代码"] = first_match(r"^([1-9-])\s+1\.急诊", line) + if "天" in line and idx in date_line_indexes: + result["实际住院天数"] = first_match(r"([0-9]+)\s*天\s*$", line) + + if len(date_line_indexes) >= 2: + discharge_line = lines[date_line_indexes[1]] + result["实际住院天数"] = first_match(r"([0-9]+)\s*天\s*$", discharge_line) or result.get("实际住院天数", "") + if date_line_indexes[1] + 1 < len(lines): + diagnosis_line = lines[date_line_indexes[1] + 1] + diagnosis_match = re.match(r"(.+?)\s+([A-Z]\d{2}[\w.]*\d*)\s*$", diagnosis_line) + if diagnosis_match: + result["门急诊诊断"] = clean_value(diagnosis_match.group(1)) + result["门急诊诊断编码"] = clean_value(diagnosis_match.group(2)) + + return result + + +def parse_visit_line(line: str, prefix: str) -> dict[str, str]: + pattern = ( + r"(?P\d{4})\s*年\s*(?P\d{1,2})\s*月\s*" + r"(?P\d{1,2})\s*日\s*(?P\d{1,2})\s*时\s+" + r"(?P.+?)\s{2,}(?P\S+)" + ) + match = re.search(pattern, line) + if not match: + return {} + return { + f"{prefix}时间": ( + f"{int(match.group('year')):04d}-{int(match.group('month')):02d}-" + f"{int(match.group('day')):02d} {int(match.group('hour')):02d}:00" + ), + f"{prefix}科别": clean_value(match.group("dept")), + f"{prefix}病房": clean_value(match.group("ward")), + } + + +def parse_diagnoses(lines: list[str]) -> dict[str, Any]: + section = diagnosis_section(lines) + diagnoses: list[dict[str, str]] = [] + if not section: + return {"出院诊断": diagnoses, "主要诊断": {}} + + main_index = next((idx for idx, line in enumerate(section) if "主要诊断" in line), -1) + if main_index >= 0: + main_line = section[main_index] + normalized_main_line = normalize_spaces(main_line) + main_match = re.search(r"主要诊断\s*(.*?)\s+([A-Z]\d{2}[\w.]*\d*)\s+([0-9-])", normalized_main_line) + if main_match: + name = clean_value(main_match.group(1)) + if not name and main_index > 0: + name = clean_value(section[main_index - 1]) + if main_index + 1 < len(section) and not re.search(r"[A-Z]\d{2}[\w.]*\d*", section[main_index + 1]): + name = clean_value(name + section[main_index + 1]) + diagnoses.append( + { + "诊断类别": "主要诊断", + "出院诊断": name, + "疾病编码": clean_value(main_match.group(2)), + "入院病情": clean_value(main_match.group(3)), + } + ) + else: + no_code_match = re.search(r"主要诊断\s*(.*?)\s+([0-9-])(?:\s+其他诊断)?$", normalized_main_line) + if no_code_match: + name = clean_value(no_code_match.group(1)) + if not name: + nearby_name_parts: list[str] = [] + if main_index > 0: + previous_line = clean_value(section[main_index - 1]) + if previous_line and "其他诊断" not in previous_line and not re.search(r"[A-Z]\d{2}[\w.]*\d*", previous_line): + nearby_name_parts.append(previous_line) + if main_index + 1 < len(section): + next_line = clean_value(section[main_index + 1]) + if next_line and "其他诊断" not in next_line and not re.search(r"[A-Z]\d{2}[\w.]*\d*", next_line): + nearby_name_parts.append(next_line) + name = clean_value("".join(nearby_name_parts)) + if name: + diagnoses.append( + { + "诊断类别": "主要诊断", + "出院诊断": name, + "疾病编码": "", + "入院病情": clean_value(no_code_match.group(2)), + } + ) + + other_started = False + for line in section: + if "其他诊断" in line: + other_started = True + line = re.sub(r"^.*?其他诊断\s*", "", line).strip() + if not line: + continue + elif not other_started: + continue + parsed = parse_other_diagnosis_line(line) + if parsed: + diagnoses.append(parsed) + + main = next((item for item in diagnoses if item["诊断类别"] == "主要诊断"), {}) + return {"出院诊断": diagnoses, "主要诊断": main} + + +def diagnosis_section(lines: list[str]) -> list[str]: + start = -1 + for idx, line in enumerate(lines): + if "主要诊断" in line: + start = max(0, idx - 1) + break + if start < 0: + return [] + + section: list[str] = [] + for line in lines[start:]: + if "B20" in line or re.match(r"^-+\s+-+$", line) or "1.无 2.有" in line: + break + section.append(line) + return section + + +def parse_other_diagnosis_line(line: str) -> dict[str, str] | None: + line = normalize_spaces(line) + match = re.match(r"^(?:其他诊断\s+)?(.+?)\s+([A-Z]\d{2}[\w.]*\d*)\s+([0-9-])$", line) + if match: + return { + "诊断类别": "其他诊断", + "出院诊断": clean_value(match.group(1)), + "疾病编码": clean_value(match.group(2)), + "入院病情": clean_value(match.group(3)), + } + no_code_match = re.match(r"^(?:其他诊断\s+)?(.+?)\s+([0-9-])$", line) + if no_code_match: + return { + "诊断类别": "其他诊断", + "出院诊断": clean_value(no_code_match.group(1)), + "疾病编码": "", + "入院病情": clean_value(no_code_match.group(2)), + } + return None + + +def parse_operations(lines: list[str]) -> list[dict[str, str]]: + operation_lines: list[str] = [] + after_quality_line = False + for raw in lines: + line = raw.replace("\f", "").strip() + if not line: + continue + if "1.甲" in line and "2.乙" in line and "3.丙" in line: + after_quality_line = True + continue + if not after_quality_line and re.match(r"^[A-Z0-9]\d?\.\d", line): + after_quality_line = True + if after_quality_line and "1.医嘱离院" in line: + break + if after_quality_line: + operation_lines.append(line) + + combined: list[str] = [] + pending_prefix = "" + for line in operation_lines: + has_date = bool(re.search(r"\d{4}-\d{1,2}-\d{1,2}", line)) + starts_with_code = bool(re.match(r"^[A-Z0-9]\d?\.\d", line)) + if starts_with_code and not has_date: + pending_prefix = normalize_spaces(line) + continue + if has_date: + if starts_with_code: + combined.append(line) + elif pending_prefix: + combined.append(normalize_spaces(pending_prefix + " " + line)) + pending_prefix = "" + else: + combined.append(line) + elif combined: + combined[-1] = normalize_spaces(combined[-1] + " " + line) + + operations: list[dict[str, str]] = [] + for line in combined: + match = re.match( + r"^(?:(?P[A-Z0-9.]+[a-zA-Z]*)\s+)?(?:(?P.*?)\s+)?" + r"(?P\d{4}-\d{1,2}-\d{1,2})\s+(?P.+)$", + line, + ) + if not match: + continue + operation = parse_operation_columns( + code=clean_value(match.group("code")), + date=normalize_date(match.group("date")), + predate=clean_value(match.group("predate") or ""), + rest=clean_value(match.group("rest")), + raw_line=clean_value(line), + ) + operations.append(operation) + return operations + + +def parse_operation_columns(code: str, date: str, predate: str, rest: str, raw_line: str) -> dict[str, str]: + tokens = [clean_operation_token(token) for token in rest.split() if clean_operation_token(token)] + anesthesia_hint = predate if is_anesthesia_text(predate) else "" + name_prefix = "" if anesthesia_hint else predate + + level = "" + if tokens and is_operation_level_token(tokens[0]): + raw_level = tokens.pop(0) + level = normalize_operation_level(raw_level) + if level == "诊断性操作" and tokens and tokens[-1] == "作": + level = "诊断性操作" + tokens.pop() + + incision_index = next((idx for idx, token in enumerate(tokens) if is_incision_healing_token(token)), -1) + incision_healing = "" + if incision_index >= 0: + before_incision = tokens[:incision_index] + incision_healing = tokens[incision_index] + after_incision = tokens[incision_index + 1 :] + else: + before_incision = list(tokens) + after_incision = [] + + if incision_index < 0: + before_incision, anesthesiologist = split_no_incision_tail(before_incision) + anesthesia_method = anesthesia_hint + name_suffix = "" + else: + anesthesia_method, anesthesiologist, name_suffix = split_anesthesia_tail(after_incision, anesthesia_hint) + + procedure_name, surgeon, assistant_1, assistant_2 = split_procedure_and_doctors(before_incision) + procedure_name = clean_value(" ".join(part for part in [name_prefix, procedure_name, name_suffix] if part)) + + return { + "手术操作编码": code, + "手术操作日期": date, + "手术级别": level, + "手术操作名称": procedure_name, + "术者": surgeon, + "I助": assistant_1, + "II助": assistant_2, + "切口愈合等级": incision_healing, + "麻醉方式": anesthesia_method, + "麻醉医师": anesthesiologist, + "原始内容": raw_line, + } + + +def clean_operation_token(token: str) -> str: + token = token.strip().strip("()()[]【】") + token = token.replace("Ⅰ", "I").replace("Ⅱ", "II").replace("Ⅲ", "III").replace("Ⅳ", "IV") + token = token.replace("/", "/") + return clean_value(token) + + +def is_operation_level_token(token: str) -> bool: + return bool(token and (token in {"一级", "二级", "三级", "四级", "诊断性", "诊断性操作", "性操"} or token.endswith("级"))) + + +def normalize_operation_level(token: str) -> str: + return "诊断性操作" if token in {"性操", "诊断性"} else token + + +def is_incision_healing_token(token: str) -> bool: + return bool(re.fullmatch(r"(?:0|I|II|III|IV|V|[一二三四五])/[甲乙丙-]", token)) + + +def is_anesthesia_text(text: str) -> bool: + return bool(text and re.search(r"麻醉|浸润|静吸|全凭|硬膜|局部", text)) + + +def has_procedure_keyword(token: str) -> bool: + return bool(re.search(r"术|切除|穿刺|栓塞|成型|松解|活检|操作|置入|修补|吻合", token)) + + +def looks_like_person_name(token: str) -> bool: + if not re.fullmatch(r"[\u4e00-\u9fa5]{2,4}", token): + return False + if has_procedure_keyword(token): + return False + if re.search(r"腹腔|经皮|经导管|CT|胆|肝|肺|肠|阑尾|粘连|总管|局部|静吸|全凭|脉麻醉", token): + return False + return True + + +def split_procedure_and_doctors(tokens: list[str]) -> tuple[str, str, str, str]: + doctor_tokens: list[str] = [] + remaining = list(tokens) + while remaining and len(doctor_tokens) < 3 and looks_like_person_name(remaining[-1]): + doctor_tokens.insert(0, remaining.pop()) + procedure_name = clean_value(" ".join(remaining)) + doctor_tokens = doctor_tokens[-3:] + surgeon = doctor_tokens[0] if len(doctor_tokens) >= 1 else "" + assistant_1 = doctor_tokens[1] if len(doctor_tokens) >= 2 else "" + assistant_2 = doctor_tokens[2] if len(doctor_tokens) >= 3 else "" + return procedure_name, surgeon, assistant_1, assistant_2 + + +def split_anesthesia_tail(tokens: list[str], anesthesia_hint: str = "") -> tuple[str, str, str]: + clean_tokens = [token for token in (clean_operation_token(item) for item in tokens) if token] + clean_tokens = [token for token in clean_tokens if not re.fullmatch(r"\d{2,4}", token) and token != "麻醉"] + doctor_index = next((idx for idx, token in enumerate(clean_tokens) if looks_like_person_name(token)), -1) + if doctor_index < 0: + method = clean_value("".join([anesthesia_hint, *clean_tokens])) + return method, "", "" + + anesthesiologist = clean_tokens[doctor_index] + method_parts = clean_tokens[:doctor_index] + suffix_parts: list[str] = [] + for token in clean_tokens[doctor_index + 1 :]: + if has_procedure_keyword(token): + suffix_parts.append(token) + else: + method_parts.append(token) + method = clean_value("".join([anesthesia_hint, *method_parts])) + name_suffix = clean_value(" ".join(suffix_parts)) + return method, anesthesiologist, name_suffix + + +def split_no_incision_tail(tokens: list[str]) -> tuple[list[str], str]: + remaining = [token for token in tokens if token != "麻醉" and not re.fullmatch(r"\d{2,4}", token)] + if remaining and looks_like_person_name(remaining[-1]): + return remaining[:-1], remaining[-1] + return remaining, "" + + +def normalize_date(value: str) -> str: + match = re.match(r"(\d{4})-(\d{1,2})-(\d{1,2})", value) + if not match: + return value + return f"{int(match.group(1)):04d}-{int(match.group(2)):02d}-{int(match.group(3)):02d}" + + +def parse_fees(text: str) -> dict[str, Any]: + result: dict[str, Any] = { + "总费用": first_match(r"\n\s*([0-9]+\.[0-9]{2})\s*\(\s*自付金额", text), + "自付金额": first_match(r"自付金额\s*([0-9]+\.[0-9]{2})", text), + "费用明细": {}, + } + for index, name, amount in re.findall(r"\((\d+)\)\s*([^::()]+?)\s*[::]\s*([0-9]+(?:\.[0-9]+)?)", text): + key = f"{int(index):02d}_{clean_value(name)}" + result["费用明细"][key] = amount + for name, amount in re.findall(r"(? dict[str, str]: + result = { + "损伤中毒外部原因": "", + "损伤中毒疾病编码": "", + "病理诊断": "", + "病理诊断编码": "", + "病理号": "", + "药物过敏代码": "", + "过敏药物": "", + "死亡患者尸检代码": "", + "血型代码": "", + "Rh代码": "", + "科主任": "", + "主任副主任医师": "", + "主治医师": "", + "住院医师": "", + "责任护士": "", + "进修医师": "", + "实习医师": "", + "规培医师": "", + "编码员": "", + "病案质量代码": "", + "质控医师": "", + "质控护士": "", + "质控日期": "", + } + + pathology_no_idx = next((idx for idx, line in enumerate(lines) if re.match(r"^B\d+", line)), -1) + if pathology_no_idx >= 0: + result["病理号"] = clean_value(lines[pathology_no_idx]) + if pathology_no_idx > 0: + pathology_cols = split_layout_columns(lines[pathology_no_idx - 1]) + if len(pathology_cols) >= 2: + result["病理诊断"] = clean_value(" ".join(pathology_cols[:-1])) + result["病理诊断编码"] = clean_value(pathology_cols[-1]) + if pathology_no_idx > 2: + injury_cols = split_layout_columns(lines[pathology_no_idx - 2]) + if len(injury_cols) >= 2: + result["损伤中毒外部原因"] = clean_value(injury_cols[0]) + result["损伤中毒疾病编码"] = clean_value(injury_cols[-1]) + + allergy_idx = next((idx for idx, line in enumerate(lines) if "1.无 2.有" in line and "1.是 2.否" in line), -1) + if allergy_idx >= 0: + match = re.search(r"^\s*([1-9-])\s+1\.无 2\.有\s+(.*?)\s+([1-9-])\s+1\.是 2\.否", lines[allergy_idx]) + if match: + result["药物过敏代码"] = clean_value(match.group(1)) + result["过敏药物"] = clean_value(match.group(2)) + result["死亡患者尸检代码"] = clean_value(match.group(3)) + + blood_idx = next((idx for idx, line in enumerate(lines) if "1.A" in line and "1.阴" in line), -1) + if blood_idx >= 0: + result["血型代码"] = first_match(r"^\s*([1-9-])\s+1\.A", lines[blood_idx]) + result["Rh代码"] = first_match(r"6\.未查\s+([1-9-])\s+1\.阴", lines[blood_idx]) + doctor_rows = [split_layout_columns_keep_positions(lines[i]) for i in range(blood_idx + 1, min(blood_idx + 3, len(lines)))] + if doctor_rows: + for key, value in zip(["科主任", "主任副主任医师", "主治医师", "住院医师"], doctor_rows[0]): + result[key] = clean_value(value) + if len(doctor_rows) >= 2: + for key, value in zip(["责任护士", "进修医师", "实习医师", "规培医师", "编码员"], doctor_rows[1]): + result[key] = clean_value(value) + + quality_idx = next((idx for idx, line in enumerate(lines) if "1.甲" in line and "2.乙" in line and "3.丙" in line), -1) + if quality_idx >= 0: + line = lines[quality_idx] + result["病案质量代码"] = first_match(r"^\s*([1-9-])\s+1\.甲", line) + date_match = re.search(r"(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日", line) + if date_match: + result["质控日期"] = f"{int(date_match.group(1)):04d}-{int(date_match.group(2)):02d}-{int(date_match.group(3)):02d}" + line = line[: date_match.start()] + line = re.sub(r"^\s*[1-9-]\s+1\.甲 2\.乙 3\.丙\s*", "", line) + cols = split_layout_columns(line) + if len(cols) >= 1: + result["质控医师"] = clean_value(cols[-2] if len(cols) >= 2 else cols[0]) + if len(cols) >= 2: + result["质控护士"] = clean_value(cols[-1]) + + return result + + +def split_layout_columns(line: str) -> list[str]: + return [clean_value(part) for part in re.split(r"\s{2,}", line.strip()) if clean_value(part)] + + +def split_layout_columns_keep_positions(line: str) -> list[str]: + return [clean_value(normalize_spaces(part)) for part in re.split(r"\s{2,}", line.strip()) if part.strip()] + + +def parse_discharge_followup(lines: list[str]) -> dict[str, str]: + result = { + "离院方式代码": "", + "拟接收医疗机构名称": "", + "出院31天内再住院计划代码": "", + "再住院计划目的": "", + "入院前昏迷天数": "", + "入院前昏迷小时": "", + "入院前昏迷分钟": "", + "入院后昏迷天数": "", + "入院后昏迷小时": "", + "入院后昏迷分钟": "", + } + for line in lines: + if "1.医嘱离院" in line: + result["离院方式代码"] = first_match(r"^\s*([1-9-])\s+1\.医嘱离院", line) + result["拟接收医疗机构名称"] = first_match(r"拟接收医疗机构名称:\s*(.*?)\s*$", line) + elif "是否有出院31天内再住院计划" in line: + result["出院31天内再住院计划代码"] = first_match(r"是否有出院31天内再住院计划\s*([1-9-])", line) + result["再住院计划目的"] = first_match(r"目的:\s*(.*?)\s*$", line) + elif "入院前" in line and "入院后" in line and "分钟" in line: + match = re.search( + r"入院前\s*([-\d]+)\s*天\s*([-\d]+)\s*小时\s*([-\d]+)\s*分钟\s*" + r"入院后\s*([-\d]+)\s*天\s*([-\d]+)\s*小时\s*([-\d]+)\s*分钟", + line, + ) + if match: + result["入院前昏迷天数"] = clean_int_value(match.group(1)) + result["入院前昏迷小时"] = clean_int_value(match.group(2)) + result["入院前昏迷分钟"] = clean_int_value(match.group(3)) + result["入院后昏迷天数"] = clean_int_value(match.group(4)) + result["入院后昏迷小时"] = clean_int_value(match.group(5)) + result["入院后昏迷分钟"] = clean_int_value(match.group(6)) + return result + + +def build_quality_warnings(record: dict[str, Any], text: str) -> list[str]: + warnings: list[str] = [] + required = ["病案号", "姓名", "性别", "入院时间", "出院时间", "主要诊断"] + for key in required: + value = record.get(key) + if isinstance(value, dict): + if not value: + warnings.append(f"缺少{key}") + elif not value: + warnings.append(f"缺少{key}") + if len(text.strip()) < 200: + warnings.append("提取文本过短,可能是扫描件或加密PDF") + return warnings + + +def validate_record(record: dict[str, Any], department_rules_loaded: bool = False) -> list[str]: + notes: list[str] = [] + if not clean_value(str(record.get("住院号", ""))): + notes.append("患者号/住院号为空") + if not re.fullmatch(r"\d{10}", str(record.get("病案号", ""))): + notes.append("病案号不是10位数字") + if record.get("性别") and record["性别"] not in {"男", "女"}: + notes.append("性别不是男/女") + if record.get("身份证号") and not re.fullmatch(r"\d{17}[\dX]", record["身份证号"]): + notes.append("身份证号格式异常") + if record.get("出生日期") and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", record["出生日期"]): + notes.append("出生日期格式异常") + if record.get("入院时间") and record.get("出院时间") and record["入院时间"] > record["出院时间"]: + notes.append("出院时间早于入院时间") + if record.get("实际住院天数") and not re.fullmatch(r"\d+", str(record["实际住院天数"])): + notes.append("实际住院天数不是整数") + + main = record.get("主要诊断") if isinstance(record.get("主要诊断"), dict) else {} + if main and not re.match(r"^[A-Z]\d{2}", main.get("疾病编码", "")): + notes.append("主要诊断编码格式异常") + other_diagnoses = [diagnosis for diagnosis in record.get("出院诊断", []) if diagnosis.get("诊断类别") == "其他诊断"] + for index, diagnosis in enumerate(other_diagnoses, start=1): + if not re.match(r"^[A-Z]\d{2}", diagnosis.get("疾病编码", "")): + notes.append(f"其他诊断{index}编码格式异常") + suspicious_phrases = ["1.急诊", "2.门诊", "其他医疗机构转入", "1.医嘱离院"] + for key in ["联系人姓名", "联系人关系", "联系人地址", "联系人电话", "现住址", "户口地址", "工作单位及地址"]: + value = str(record.get(key, "")) + if any(phrase in value for phrase in suspicious_phrases): + notes.append(f"{key}疑似串入版式选项文本") + for index, operation in enumerate(record.get("手术操作", []), start=1): + if not operation.get("手术操作日期") or not operation.get("手术操作名称"): + notes.append(f"手术操作{index}缺少日期或名称") + if not operation.get("手术操作编码"): + notes.append(f"手术操作{index}缺少手术及操作编码") + + for key in ["总费用", "自付金额"]: + if record.get(key) and not re.fullmatch(r"\d+(\.\d{1,2})?", str(record[key])): + notes.append(f"{key}不是金额格式") + if department_rules_loaded and (record.get("出院科别") or record.get("入院科别")) and not record.get("大科室"): + notes.append("科别未匹配到大科室分类") + return notes + + +def apply_review_fields(record: dict[str, Any], validation_notes: list[str]) -> None: + auto_corrections = [ + note for note in record.get("自动修正", []) + if not ("病案号由PDF值" in note and "按文件名补齐" in note) + ] + quality_notes = record.get("质控提示", []) + review_notes = list(dict.fromkeys([*quality_notes, *validation_notes, *record.get("复核备注", [])])) + record["自动修正"] = auto_corrections + record["复核备注"] = review_notes + record["人工修正"] = False + record["manual_corrected"] = False + if review_notes: + record["复核状态"] = "needs_review" + elif auto_corrections: + record["复核状态"] = "auto_corrected" + else: + record["复核状态"] = "auto_pass" + + +def parse_record( + pdf_path: Path, + department_rules: dict[str, dict[str, str]] | None = None, + args: argparse.Namespace | None = None, +) -> tuple[dict[str, Any], str]: + if args is None: + args = build_parser().parse_args([]) + text, text_method, mineru_markdown_dir = extract_record_text(pdf_path, args) + lines = meaningful_lines(text) + + diagnoses = parse_diagnoses(lines) + fees = parse_fees(text) + header = parse_header(text, lines) + normalized_no, original_no, corrections, no_notes = normalize_medical_record_no(header.get("病案号", ""), pdf_path) + header["病案号"] = normalized_no + header["首页病案号"] = normalize_digits(original_no or normalized_no, 10) + inpatient_no, inpatient_corrections, inpatient_notes = build_inpatient_no( + header.get("住院次数", ""), + header.get("首页病案号", ""), + header.get("病案号", ""), + pdf_path, + ) + record: dict[str, Any] = { + "源文件": pdf_path.name, + "住院号": inpatient_no, + "解析时间": datetime.now().isoformat(timespec="seconds"), + "解析器版本": "patient-front-page-local-v1", + "文本抽取方式": text_method, + "Mineru Markdown目录": mineru_markdown_dir, + "自动修正": [*corrections, *inpatient_corrections], + "复核备注": [*no_notes, *inpatient_notes], + } + record.update(header) + record.update(parse_newborn_info(lines)) + record.update(parse_basic_lines(lines)) + record.update(parse_admission_discharge(lines)) + department_rules = department_rules or {} + major_department, standard_department = classify_major_department(record, department_rules) + record["大科室"] = major_department + record["标准子科室"] = standard_department + record.update(diagnoses) + record["手术操作"] = parse_operations(lines) + record.update(parse_front_page_middle(lines)) + record.update(parse_discharge_followup(lines)) + record.update(fees) + record["原始文本"] = text + + main = diagnoses.get("主要诊断") or {} + if main: + record["主要诊断名称"] = main.get("出院诊断", "") + record["主要诊断编码"] = main.get("疾病编码", "") + record["主要诊断入院病情"] = main.get("入院病情", "") + + warnings = build_quality_warnings(record, text) + validation_notes = validate_record(record, department_rules_loaded=bool(department_rules)) + record["质控状态"] = "需复核" if warnings else "通过" + record["质控提示"] = warnings + apply_review_fields(record, validation_notes) + return record, text + + +def flatten_for_csv(record: dict[str, Any]) -> dict[str, str]: + main = record.get("主要诊断") if isinstance(record.get("主要诊断"), dict) else {} + return { + "住院号": record.get("住院号", ""), + "源文件": record.get("源文件", ""), + "病案号": record.get("病案号", ""), + "首页病案号": record.get("首页病案号", ""), + "姓名": record.get("姓名", ""), + "性别": record.get("性别", ""), + "出生日期": record.get("出生日期", ""), + "年龄": record.get("年龄", ""), + "身份证号": record.get("身份证号", ""), + "新生儿年龄(月)": record.get("新生儿年龄(月)", ""), + "新生儿出生体重(克)": record.get("新生儿出生体重(克)", ""), + "新生儿入院体重(克)": record.get("新生儿入院体重(克)", ""), + "医疗机构": record.get("医疗机构", ""), + "组织机构代码": record.get("组织机构代码", ""), + "医疗付费方式": record.get("医疗付费方式", ""), + "住院次数": record.get("住院次数", ""), + "入院时间": record.get("入院时间", ""), + "入院科别": record.get("入院科别", ""), + "入院病房": record.get("入院病房", ""), + "转科科别": record.get("转科科别", ""), + "转科时间": record.get("转科时间", ""), + "出院时间": record.get("出院时间", ""), + "出院科别": record.get("出院科别", ""), + "出院病房": record.get("出院病房", ""), + "大科室": record.get("大科室", ""), + "实际住院天数": record.get("实际住院天数", ""), + "门急诊诊断": record.get("门急诊诊断", ""), + "门急诊诊断编码": record.get("门急诊诊断编码", ""), + "主要诊断": main.get("出院诊断", ""), + "主要诊断编码": main.get("疾病编码", ""), + "主要诊断入院病情": main.get("入院病情", ""), + "出院诊断": json.dumps(record.get("出院诊断", []), ensure_ascii=False), + "手术操作": json.dumps(record.get("手术操作", []), ensure_ascii=False), + "病理诊断": record.get("病理诊断", ""), + "病理诊断编码": record.get("病理诊断编码", ""), + "病理号": record.get("病理号", ""), + "药物过敏代码": record.get("药物过敏代码", ""), + "过敏药物": record.get("过敏药物", ""), + "死亡患者尸检代码": record.get("死亡患者尸检代码", ""), + "血型代码": record.get("血型代码", ""), + "Rh代码": record.get("Rh代码", ""), + "科主任": record.get("科主任", ""), + "主任副主任医师": record.get("主任副主任医师", ""), + "主治医师": record.get("主治医师", ""), + "住院医师": record.get("住院医师", ""), + "责任护士": record.get("责任护士", ""), + "进修医师": record.get("进修医师", ""), + "实习医师": record.get("实习医师", ""), + "规培医师": record.get("规培医师", ""), + "编码员": record.get("编码员", ""), + "病案质量代码": record.get("病案质量代码", ""), + "质控医师": record.get("质控医师", ""), + "质控护士": record.get("质控护士", ""), + "质控日期": record.get("质控日期", ""), + "离院方式代码": record.get("离院方式代码", ""), + "出院31天内再住院计划代码": record.get("出院31天内再住院计划代码", ""), + "总费用": record.get("总费用", ""), + "自付金额": record.get("自付金额", ""), + "质控状态": record.get("质控状态", ""), + "质控提示": ";".join(record.get("质控提示", [])), + "复核状态": record.get("复核状态", ""), + "复核备注": ";".join(record.get("复核备注", [])), + "文本抽取方式": record.get("文本抽取方式", ""), + "自动修正": ";".join(record.get("自动修正", [])), + "人工修正": str(record.get("人工修正", False)), + } + + +def write_outputs(records: list[dict[str, Any]], output_dir: Path, save_text: bool) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + structured_dir = output_dir / "01_结构化结果" + per_record_dir = output_dir / "02_单份JSON" + text_dir = output_dir / "03_提取文本" + review_dir = output_dir / "04_复核与人工校正" + structured_dir.mkdir(exist_ok=True) + per_record_dir.mkdir(exist_ok=True) + review_dir.mkdir(exist_ok=True) + if save_text: + text_dir.mkdir(exist_ok=True) + + jsonl_path = structured_dir / "患者首页_结构化结果.jsonl" + csv_path = structured_dir / "患者首页_结构化结果.csv" + review_path = review_dir / "患者首页_复核清单.csv" + + with jsonl_path.open("w", encoding="utf-8") as fp: + for record in records: + fp.write(json.dumps(record, ensure_ascii=False) + "\n") + + with csv_path.open("w", encoding="utf-8-sig", newline="") as fp: + writer = csv.DictWriter(fp, fieldnames=CSV_COLUMNS) + writer.writeheader() + for record in records: + writer.writerow(flatten_for_csv(record)) + + with review_path.open("w", encoding="utf-8-sig", newline="") as fp: + writer = csv.DictWriter(fp, fieldnames=["住院号", "源文件", "病案号", "首页病案号", "姓名", "复核状态", "复核备注", "自动修正", "人工修正"]) + writer.writeheader() + for record in records: + if record.get("复核状态") != "auto_pass": + writer.writerow( + { + "住院号": record.get("住院号", ""), + "源文件": record.get("源文件", ""), + "病案号": record.get("病案号", ""), + "首页病案号": record.get("首页病案号", ""), + "姓名": record.get("姓名", ""), + "复核状态": record.get("复核状态", ""), + "复核备注": ";".join(record.get("复核备注", [])), + "自动修正": ";".join(record.get("自动修正", [])), + "人工修正": str(record.get("人工修正", False)), + } + ) + + for record in records: + stem = Path(record["源文件"]).stem + json_path = per_record_dir / f"{stem}.json" + with json_path.open("w", encoding="utf-8") as fp: + json.dump(record, fp, ensure_ascii=False, indent=2) + if save_text: + (text_dir / f"{stem}.txt").write_text(record.get("原始文本", ""), encoding="utf-8") + + +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 pg_literal(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def int_or_empty(value: Any) -> str: + value = clean_value(str(value)) if value is not None else "" + return value if re.fullmatch(r"\d+", value) else "" + + +def decimal_or_empty(value: Any) -> str: + value = clean_value(str(value)) if value is not None else "" + return value if re.fullmatch(r"\d+(\.\d{1,2})?", value) else "" + + +def pg_json(value: Any) -> str: + return json.dumps(value if value is not None else [], ensure_ascii=False) + + +def record_to_pg_row(record: dict[str, Any]) -> dict[str, str]: + main = record.get("主要诊断") if isinstance(record.get("主要诊断"), dict) else {} + return { + "source_file": record.get("源文件", ""), + "inpatient_no": record.get("住院号", ""), + "medical_record_no": record.get("病案号", ""), + "front_page_medical_record_no": record.get("首页病案号", ""), + "patient_name": record.get("姓名", ""), + "gender": record.get("性别", ""), + "birth_date": record.get("出生日期", ""), + "age": record.get("年龄", ""), + "nationality": record.get("国籍", ""), + "id_card_no": record.get("身份证号", ""), + "neonatal_age_months": int_or_empty(record.get("新生儿年龄(月)", "")), + "newborn_birth_weight_g": int_or_empty(record.get("新生儿出生体重(克)", "")), + "newborn_admission_weight_g": int_or_empty(record.get("新生儿入院体重(克)", "")), + "hospital_name": record.get("医疗机构", ""), + "organization_code": record.get("组织机构代码", ""), + "payment_method": record.get("医疗付费方式", ""), + "health_card_no": record.get("健康卡号", ""), + "admission_count": int_or_empty(record.get("住院次数", "")), + "birthplace": record.get("出生地", ""), + "native_place": record.get("籍贯", ""), + "ethnicity": record.get("民族", ""), + "occupation": record.get("职业", ""), + "marital_status_code": record.get("婚姻代码", ""), + "current_address": record.get("现住址", ""), + "current_address_phone": record.get("现住址电话", ""), + "current_address_postcode": record.get("现住址邮编", ""), + "household_address": record.get("户口地址", ""), + "household_postcode": record.get("户口地址邮编", ""), + "employer_address": record.get("工作单位及地址", ""), + "employer_phone": record.get("单位电话", ""), + "employer_postcode": record.get("单位邮编", ""), + "contact_name": record.get("联系人姓名", ""), + "contact_relationship": record.get("联系人关系", ""), + "contact_address": record.get("联系人地址", ""), + "contact_phone": record.get("联系人电话", ""), + "admission_path_code": record.get("入院途径代码", ""), + "admission_time": record.get("入院时间", ""), + "admission_dept": record.get("入院科别", ""), + "admission_ward": record.get("入院病房", ""), + "transfer_dept": record.get("转科科别", ""), + "transfer_time": record.get("转科时间", ""), + "discharge_time": record.get("出院时间", ""), + "discharge_dept": record.get("出院科别", ""), + "discharge_ward": record.get("出院病房", ""), + "major_department": record.get("大科室", ""), + "hospital_days": int_or_empty(record.get("实际住院天数", "")), + "outpatient_diagnosis": record.get("门急诊诊断", ""), + "outpatient_diagnosis_code": record.get("门急诊诊断编码", ""), + "primary_diagnosis": main.get("出院诊断", ""), + "primary_diagnosis_code": main.get("疾病编码", ""), + "primary_admission_condition": main.get("入院病情", ""), + "discharge_diagnoses": pg_json(record.get("出院诊断", [])), + "operations": pg_json(record.get("手术操作", [])), + "injury_poisoning_external_cause": record.get("损伤中毒外部原因", ""), + "injury_poisoning_code": record.get("损伤中毒疾病编码", ""), + "pathology_diagnosis": record.get("病理诊断", ""), + "pathology_diagnosis_code": record.get("病理诊断编码", ""), + "pathology_no": record.get("病理号", ""), + "drug_allergy_code": record.get("药物过敏代码", ""), + "allergy_drug": record.get("过敏药物", ""), + "autopsy_code": record.get("死亡患者尸检代码", ""), + "blood_type_code": record.get("血型代码", ""), + "rh_code": record.get("Rh代码", ""), + "department_director": record.get("科主任", ""), + "chief_physician": record.get("主任副主任医师", ""), + "attending_physician": record.get("主治医师", ""), + "resident_physician": record.get("住院医师", ""), + "responsible_nurse": record.get("责任护士", ""), + "refresher_physician": record.get("进修医师", ""), + "intern_physician": record.get("实习医师", ""), + "standardized_resident_physician": record.get("规培医师", ""), + "coder": record.get("编码员", ""), + "record_quality_code": record.get("病案质量代码", ""), + "quality_control_physician": record.get("质控医师", ""), + "quality_control_nurse": record.get("质控护士", ""), + "quality_control_date": record.get("质控日期", ""), + "discharge_disposition_code": record.get("离院方式代码", ""), + "receiving_org_name": record.get("拟接收医疗机构名称", ""), + "readmission_plan_code": record.get("出院31天内再住院计划代码", ""), + "readmission_plan_purpose": record.get("再住院计划目的", ""), + "coma_before_days": int_or_empty(record.get("入院前昏迷天数", "")), + "coma_before_hours": int_or_empty(record.get("入院前昏迷小时", "")), + "coma_before_minutes": int_or_empty(record.get("入院前昏迷分钟", "")), + "coma_after_days": int_or_empty(record.get("入院后昏迷天数", "")), + "coma_after_hours": int_or_empty(record.get("入院后昏迷小时", "")), + "coma_after_minutes": int_or_empty(record.get("入院后昏迷分钟", "")), + "total_cost": decimal_or_empty(record.get("总费用", "")), + "self_pay_amount": decimal_or_empty(record.get("自付金额", "")), + "fee_details": pg_json(record.get("费用明细", {})), + "quality_status": record.get("质控状态", ""), + "quality_notes": pg_json(record.get("质控提示", [])), + "review_status": record.get("复核状态", "pending"), + "review_notes": pg_json(record.get("复核备注", [])), + "manual_corrected": "true" if record.get("manual_corrected") or record.get("人工修正") else "false", + "auto_corrections": pg_json(record.get("自动修正", [])), + "text_extraction_method": record.get("文本抽取方式", ""), + "mineru_markdown_dir": record.get("Mineru Markdown目录", ""), + "raw_text": record.get("原始文本", ""), + } + + +def pg_copy_cast(column_name: str, column_type: str) -> str: + if column_name == "inpatient_no": + return "NULLIF(BTRIM(inpatient_no), '')" + if column_type in {"INTEGER"}: + return f"NULLIF({column_name}, '')::{column_type}" + if column_type.startswith("NUMERIC"): + return f"NULLIF({column_name}, '')::{column_type}" + if column_type in {"DATE", "TIMESTAMP"}: + return f"NULLIF({column_name}, '')::{column_type}" + if column_type.startswith("JSONB"): + return f"COALESCE(NULLIF({column_name}, ''), 'null')::jsonb" + if column_type.startswith("BOOLEAN"): + return f"COALESCE(NULLIF({column_name}, '')::boolean, false)" + return f"NULLIF({column_name}, '')" + + +def write_postgres(records: list[dict[str, Any]], args: argparse.Namespace) -> None: + if not records: + return + if shutil.which("psql") is None: + raise RuntimeError("未找到 psql。请先安装 PostgreSQL 客户端,或取消 --write-postgres。") + + table_name = quote_pg_identifier(args.pg_table) + list_table_name = quote_pg_identifier("Patient_Lists") + trigger_function_name = quote_pg_identifier(f"{args.pg_table}_sync_patient_lists_trigger_fn") + trigger_name = quote_pg_identifier(f"trg_{args.pg_table}_sync_patient_lists") + dedupe_trigger_function_name = quote_pg_identifier(f"{args.pg_table}_dedupe_inpatient_no_trigger_fn") + dedupe_trigger_name = quote_pg_identifier(f"trg_{args.pg_table}_dedupe_inpatient_no") + source_file_constraint = quote_pg_identifier(f"{args.pg_table}_source_file_key") + inpatient_no_constraint = quote_pg_identifier(f"{args.pg_table}_inpatient_no_key") + inpatient_no_check_constraints = [ + quote_pg_identifier(f"ck_{args.pg_table}_inpatient_no_format"), + quote_pg_identifier(f"ck_{args.pg_table.lower()}_inpatient_no_format"), + quote_pg_identifier(f"ck_{args.pg_table}_inpatient_no_required"), + ] + inpatient_no_required_constraint = quote_pg_identifier(f"ck_{args.pg_table}_inpatient_no_required") + list_inpatient_no_check_constraints = [ + quote_pg_identifier("ck_Patient_Lists_inpatient_no_format"), + quote_pg_identifier("ck_patient_lists_inpatient_no_format"), + quote_pg_identifier("ck_patient_lists_inpatient_no_required"), + ] + list_inpatient_no_required_constraint = quote_pg_identifier("ck_patient_lists_inpatient_no_required") + connection_env = os.environ.copy() + connection_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 connection_env.get(name)] + if missing: + raise RuntimeError("缺少 PostgreSQL 连接配置:" + "、".join(missing)) + + with tempfile.TemporaryDirectory(prefix="patient_front_pages_") as tmpdir: + csv_path = Path(tmpdir) / "records.csv" + sql_path = Path(tmpdir) / "import.sql" + with csv_path.open("w", encoding="utf-8", newline="") as fp: + fieldnames = [name for name, _type in PG_COLUMNS] + writer = csv.DictWriter(fp, fieldnames=fieldnames) + writer.writeheader() + for record in records: + writer.writerow(record_to_pg_row(record)) + + escaped_csv = str(csv_path).replace("'", "''") + column_defs = ",\n ".join( + ["id BIGSERIAL PRIMARY KEY", *[f"{name} {column_type}" for name, column_type in PG_COLUMNS], "UNIQUE (inpatient_no)"] + ) + alter_add_columns = "\n".join( + [f"ALTER TABLE {table_name} ADD COLUMN IF NOT EXISTS {name} {column_type};" for name, column_type in PG_COLUMNS] + ) + table_comments = "\n".join( + [ + f"COMMENT ON COLUMN {table_name}.{quote_pg_identifier(column_name)} IS {pg_literal(comment)};" + for column_name, comment in PG_COLUMN_COMMENTS.items() + ] + ) + import_column_defs = ",\n ".join([f"{name} TEXT" for name, _type in PG_COLUMNS]) + import_columns = ", ".join([name for name, _type in PG_COLUMNS]) + select_values = ",\n ".join([pg_copy_cast(name, column_type) for name, column_type in PG_COLUMNS]) + update_values = ",\n ".join([f"{name} = EXCLUDED.{name}" for name, _type in PG_COLUMNS if name != "inpatient_no"]) + dedupe_update_values = ",\n ".join([f"{name} = NEW.{name}" for name, _type in PG_COLUMNS if name != "inpatient_no"]) + drop_inpatient_no_checks = "\n".join( + [f"ALTER TABLE {table_name} DROP CONSTRAINT IF EXISTS {constraint};" for constraint in inpatient_no_check_constraints] + ) + drop_list_inpatient_no_checks = "\n".join( + [ + f"ALTER TABLE {list_table_name} DROP CONSTRAINT IF EXISTS {constraint};" + for constraint in list_inpatient_no_check_constraints + ] + ) + sql_path.write_text( + f""" +\\set ON_ERROR_STOP on +CREATE TABLE IF NOT EXISTS {table_name} ( + {column_defs} +); + +ALTER TABLE {table_name} DROP COLUMN IF EXISTS payload; +ALTER TABLE {table_name} DROP COLUMN IF EXISTS parsed_at; +ALTER TABLE {table_name} DROP COLUMN IF EXISTS created_at; +ALTER TABLE {table_name} DROP COLUMN IF EXISTS updated_at; +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = {args.pg_table!r} AND column_name = 'original_medical_record_no' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = {args.pg_table!r} AND column_name = 'front_page_medical_record_no' + ) THEN + ALTER TABLE {table_name} RENAME COLUMN original_medical_record_no TO front_page_medical_record_no; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = {args.pg_table!r} AND column_name = 'diagnoses' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = {args.pg_table!r} AND column_name = 'discharge_diagnoses' + ) THEN + ALTER TABLE {table_name} RENAME COLUMN diagnoses TO discharge_diagnoses; + END IF; +END $$; +ALTER TABLE {table_name} DROP COLUMN IF EXISTS other_diagnoses; +{alter_add_columns} +UPDATE {table_name} +SET front_page_medical_record_no = RIGHT(LPAD(regexp_replace(front_page_medical_record_no, '\\D', '', 'g'), 10, '0'), 10) +WHERE front_page_medical_record_no IS NOT NULL + AND front_page_medical_record_no <> '' + AND front_page_medical_record_no !~ '^\\d{{10}}$' + AND regexp_replace(front_page_medical_record_no, '\\D', '', 'g') <> ''; +UPDATE {table_name} +SET inpatient_no = + 'ZY' + || COALESCE( + LPAD(admission_count::text, 2, '0'), + substring(source_file from '^ZY([0-9]{{2}})[0-9]{{10}}') + ) + || RIGHT( + LPAD( + COALESCE( + NULLIF(regexp_replace(COALESCE(front_page_medical_record_no, ''), '\\D', '', 'g'), ''), + NULLIF(regexp_replace(COALESCE(medical_record_no, ''), '\\D', '', 'g'), ''), + substring(source_file from '^ZY[0-9]{{2}}([0-9]{{10}})') + ), + 10, + '0' + ), + 10 + ) +WHERE (inpatient_no IS NULL OR BTRIM(inpatient_no) = '') + AND COALESCE( + LPAD(admission_count::text, 2, '0'), + substring(source_file from '^ZY([0-9]{{2}})[0-9]{{10}}') + ) IS NOT NULL + AND COALESCE( + NULLIF(regexp_replace(COALESCE(front_page_medical_record_no, ''), '\\D', '', 'g'), ''), + NULLIF(regexp_replace(COALESCE(medical_record_no, ''), '\\D', '', 'g'), ''), + substring(source_file from '^ZY[0-9]{{2}}([0-9]{{10}})') + ) IS NOT NULL; +ALTER TABLE {table_name} DROP CONSTRAINT IF EXISTS {source_file_constraint}; +{drop_inpatient_no_checks} +DELETE FROM {table_name} +WHERE NULLIF(BTRIM(inpatient_no), '') IS NULL; +UPDATE {table_name} +SET inpatient_no = BTRIM(inpatient_no) +WHERE inpatient_no <> BTRIM(inpatient_no); +WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER (PARTITION BY BTRIM(inpatient_no) ORDER BY id DESC) AS duplicate_rank + FROM {table_name} + WHERE NULLIF(BTRIM(inpatient_no), '') IS NOT NULL +) +DELETE FROM {table_name} p +USING ranked +WHERE p.id = ranked.id + AND ranked.duplicate_rank > 1; +ALTER TABLE {table_name} ALTER COLUMN inpatient_no SET NOT NULL; +ALTER TABLE {table_name} DROP CONSTRAINT IF EXISTS {inpatient_no_constraint}; +ALTER TABLE {table_name} ADD CONSTRAINT {inpatient_no_constraint} UNIQUE (inpatient_no); +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = {pg_literal(f"ck_{args.pg_table}_inpatient_no_required")}) THEN + ALTER TABLE {table_name} ADD CONSTRAINT {inpatient_no_required_constraint} CHECK (NULLIF(BTRIM(inpatient_no), '') IS NOT NULL); + END IF; +END $$; +COMMENT ON TABLE {table_name} IS '患者住院病案首页结构化宽表,由PDF首页解析程序生成并支持人工复核。'; +{table_comments} + +CREATE TABLE IF NOT EXISTS {list_table_name} ( + record_id BIGSERIAL PRIMARY KEY, + batch_name TEXT NOT NULL DEFAULT 'Patient_FrontPages', + major_department TEXT NOT NULL DEFAULT '', + sub_department TEXT NOT NULL DEFAULT '', + source_folder TEXT NOT NULL DEFAULT 'Patient_FrontPages', + image_path TEXT NOT NULL DEFAULT '', + image_name TEXT NOT NULL DEFAULT '', + image_row_no INTEGER NOT NULL DEFAULT 0, + patient_name TEXT NOT NULL DEFAULT '', + 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 DEFAULT '首页自动关联', + review_notes TEXT, + manual_corrected BOOLEAN NOT NULL DEFAULT false, + imported_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE {list_table_name} ADD COLUMN IF NOT EXISTS has_front_page BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE {list_table_name} ADD COLUMN IF NOT EXISTS front_page_id BIGINT; +ALTER TABLE {list_table_name} ADD COLUMN IF NOT EXISTS front_page_source_file TEXT; +{drop_list_inpatient_no_checks} +DELETE FROM {list_table_name} +WHERE NULLIF(BTRIM(inpatient_no), '') IS NULL; +UPDATE {list_table_name} +SET inpatient_no = BTRIM(inpatient_no) +WHERE inpatient_no <> BTRIM(inpatient_no); +WITH ranked AS ( + SELECT + record_id, + ROW_NUMBER() OVER (PARTITION BY BTRIM(inpatient_no) ORDER BY record_id DESC) AS duplicate_rank + FROM {list_table_name} + WHERE NULLIF(BTRIM(inpatient_no), '') IS NOT NULL +) +DELETE FROM {list_table_name} pl +USING ranked +WHERE pl.record_id = ranked.record_id + AND ranked.duplicate_rank > 1; +ALTER TABLE {list_table_name} ALTER COLUMN inpatient_no SET NOT NULL; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ck_patient_lists_inpatient_no_required') THEN + ALTER TABLE {list_table_name} ADD CONSTRAINT {list_inpatient_no_required_constraint} CHECK (NULLIF(BTRIM(inpatient_no), '') IS NOT NULL); + END IF; +END $$; +COMMENT ON COLUMN {list_table_name}.has_front_page IS '是否有患者首页:由Patient_FrontPages按住院号自动联动。'; +COMMENT ON COLUMN {list_table_name}.front_page_id IS '关联的Patient_FrontPages.id。'; +COMMENT ON COLUMN {list_table_name}.front_page_source_file IS '关联患者首页PDF文件名。'; +CREATE UNIQUE INDEX IF NOT EXISTS uq_patient_lists_inpatient_no ON {list_table_name}(inpatient_no); + +CREATE OR REPLACE FUNCTION {dedupe_trigger_function_name}() +RETURNS trigger +LANGUAGE plpgsql +AS $dedupe_trigger$ +DECLARE + existing_id BIGINT; +BEGIN + NEW.inpatient_no := BTRIM(NEW.inpatient_no); + IF NULLIF(NEW.inpatient_no, '') IS NULL THEN + RETURN NEW; + END IF; + + IF TG_OP = 'INSERT' THEN + SELECT id + INTO existing_id + FROM {table_name} + WHERE BTRIM(inpatient_no) = NEW.inpatient_no + ORDER BY id DESC + LIMIT 1; + + IF existing_id IS NOT NULL THEN + DELETE FROM {table_name} + WHERE BTRIM(inpatient_no) = NEW.inpatient_no + AND id <> existing_id; + + UPDATE {table_name} + SET {dedupe_update_values} + WHERE id = existing_id; + + RETURN NULL; + END IF; + END IF; + + DELETE FROM {table_name} + WHERE BTRIM(inpatient_no) = NEW.inpatient_no + AND id <> NEW.id; + + RETURN NEW; +END; +$dedupe_trigger$; + +DROP TRIGGER IF EXISTS {dedupe_trigger_name} ON {table_name}; + +CREATE OR REPLACE FUNCTION {trigger_function_name}() +RETURNS trigger +LANGUAGE plpgsql +AS $trigger$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF NULLIF(BTRIM(OLD.inpatient_no), '') IS NOT NULL THEN + UPDATE {list_table_name} AS pl + SET has_front_page = false, + front_page_id = NULL, + front_page_source_file = NULL, + imported_at = now() + WHERE pl.inpatient_no = BTRIM(OLD.inpatient_no) + AND NOT EXISTS ( + SELECT 1 FROM {table_name} fp + WHERE BTRIM(fp.inpatient_no) = BTRIM(OLD.inpatient_no) + ); + END IF; + RETURN OLD; + END IF; + + IF TG_OP = 'UPDATE' + AND NULLIF(BTRIM(OLD.inpatient_no), '') IS NOT NULL + AND BTRIM(OLD.inpatient_no) IS DISTINCT FROM BTRIM(NEW.inpatient_no) THEN + UPDATE {list_table_name} AS pl + SET has_front_page = false, + front_page_id = NULL, + front_page_source_file = NULL, + imported_at = now() + WHERE pl.inpatient_no = BTRIM(OLD.inpatient_no) + AND NOT EXISTS ( + SELECT 1 FROM {table_name} fp + WHERE BTRIM(fp.inpatient_no) = BTRIM(OLD.inpatient_no) + ); + END IF; + + IF NULLIF(BTRIM(NEW.inpatient_no), '') IS NOT NULL THEN + INSERT INTO {list_table_name} ( + batch_name, major_department, sub_department, source_folder, image_path, image_name, + image_row_no, patient_name, gender, age, inpatient_no, diagnosis, admission_time, + hospital_days, discharge_time, review_status, review_notes, manual_corrected, + has_front_page, front_page_id, front_page_source_file, imported_at + ) + VALUES ( + 'Patient_FrontPages', + COALESCE(NEW.major_department, ''), + COALESCE(NEW.discharge_dept, NEW.admission_dept, ''), + 'Patient_FrontPages', + COALESCE(NEW.source_file, ''), + COALESCE(NEW.source_file, ''), + 0, + COALESCE(NEW.patient_name, ''), + NEW.gender, + NEW.age, + BTRIM(NEW.inpatient_no), + NEW.primary_diagnosis, + to_char(NEW.admission_time, 'YYYY-MM-DD HH24:MI:SS'), + NEW.hospital_days, + to_char(NEW.discharge_time, 'YYYY-MM-DD HH24:MI:SS'), + '首页自动关联', + '由Patient_FrontPages触发器按住院号自动关联', + COALESCE(NEW.manual_corrected, false), + true, + NEW.id, + NEW.source_file, + now() + ) + ON CONFLICT (inpatient_no) DO UPDATE SET + has_front_page = true, + front_page_id = EXCLUDED.front_page_id, + front_page_source_file = EXCLUDED.front_page_source_file, + patient_name = COALESCE(NULLIF(EXCLUDED.patient_name, ''), {list_table_name}.patient_name), + gender = EXCLUDED.gender, + age = EXCLUDED.age, + major_department = EXCLUDED.major_department, + sub_department = EXCLUDED.sub_department, + manual_corrected = EXCLUDED.manual_corrected, + imported_at = now(); + END IF; + + RETURN NEW; +END; +$trigger$; + +DROP TRIGGER IF EXISTS {trigger_name} ON {table_name}; + +CREATE TEMP TABLE patient_front_pages_import ( + {import_column_defs} +); + +\\copy patient_front_pages_import({import_columns}) FROM '{escaped_csv}' WITH (FORMAT csv, HEADER true) + +DELETE FROM patient_front_pages_import +WHERE NULLIF(BTRIM(inpatient_no), '') IS NULL; + +INSERT INTO {table_name} ( + {import_columns} +) +SELECT + {select_values} +FROM ( + SELECT DISTINCT ON (BTRIM(inpatient_no)) * + FROM patient_front_pages_import + WHERE NULLIF(BTRIM(inpatient_no), '') IS NOT NULL + ORDER BY BTRIM(inpatient_no), ctid DESC +) patient_front_pages_import +ON CONFLICT (inpatient_no) DO UPDATE SET + {update_values}; + +WITH front_pages AS ( + SELECT DISTINCT ON (BTRIM(inpatient_no)) + id, + BTRIM(inpatient_no) AS inpatient_no, + source_file, + COALESCE(patient_name, '') AS patient_name, + gender, + age, + COALESCE(major_department, '') AS major_department, + COALESCE(discharge_dept, admission_dept, '') AS sub_department, + primary_diagnosis, + admission_time, + discharge_time, + hospital_days, + manual_corrected + FROM {table_name} + WHERE NULLIF(BTRIM(inpatient_no), '') IS NOT NULL + ORDER BY BTRIM(inpatient_no), id DESC +) +INSERT INTO {list_table_name} ( + batch_name, major_department, sub_department, source_folder, image_path, image_name, + image_row_no, patient_name, gender, age, inpatient_no, diagnosis, admission_time, + hospital_days, discharge_time, review_status, review_notes, manual_corrected, + has_front_page, front_page_id, front_page_source_file, imported_at +) +SELECT + 'Patient_FrontPages', + major_department, + sub_department, + 'Patient_FrontPages', + source_file, + source_file, + 0, + patient_name, + gender, + age, + inpatient_no, + primary_diagnosis, + to_char(admission_time, 'YYYY-MM-DD HH24:MI:SS'), + hospital_days, + to_char(discharge_time, 'YYYY-MM-DD HH24:MI:SS'), + '首页自动关联', + '由Patient_FrontPages按住院号自动关联', + manual_corrected, + true, + id, + source_file, + now() +FROM front_pages +ON CONFLICT (inpatient_no) DO UPDATE SET + has_front_page = true, + front_page_id = EXCLUDED.front_page_id, + front_page_source_file = EXCLUDED.front_page_source_file, + patient_name = COALESCE(NULLIF(EXCLUDED.patient_name, ''), {list_table_name}.patient_name), + gender = EXCLUDED.gender, + age = EXCLUDED.age, + major_department = EXCLUDED.major_department, + sub_department = EXCLUDED.sub_department, + manual_corrected = EXCLUDED.manual_corrected, + imported_at = now(); + +UPDATE {list_table_name} AS pl +SET has_front_page = false, + front_page_id = NULL, + front_page_source_file = NULL, + imported_at = now() +WHERE has_front_page IS TRUE + AND NOT EXISTS ( + SELECT 1 FROM {table_name} fp + WHERE BTRIM(fp.inpatient_no) = pl.inpatient_no + ); + +CREATE TRIGGER {dedupe_trigger_name} +BEFORE INSERT OR UPDATE OF inpatient_no ON {table_name} +FOR EACH ROW EXECUTE FUNCTION {dedupe_trigger_function_name}(); + +CREATE TRIGGER {trigger_name} +AFTER INSERT OR UPDATE OR DELETE ON {table_name} +FOR EACH ROW EXECUTE FUNCTION {trigger_function_name}(); +""".strip() + + "\n", + 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, + ) + if completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or completed.stdout.strip() or "PostgreSQL 写入失败") + print(f"PostgreSQL 写入完成:{len(records)} 条 -> {connection_env['PGHOST']}:{connection_env['PGPORT']}/{connection_env['PGDATABASE']}.{args.pg_table}") + + +def process(input_dir: Path, output_dir: Path, save_text: bool, args: argparse.Namespace) -> int: + pdf_files = sorted(input_dir.glob("*.pdf")) + if not pdf_files: + print(f"未找到 PDF:{input_dir}", file=sys.stderr) + return 2 + + department_rules = load_department_rules(args.department_rules.resolve()) if args.department_rules else {} + if department_rules: + print(f"已加载科室分类规则:{args.department_rules}") + + records: list[dict[str, Any]] = [] + failures: list[dict[str, str]] = [] + for index, pdf_path in enumerate(pdf_files, start=1): + print(f"[{index}/{len(pdf_files)}] 处理 {pdf_path.name}") + try: + record, _text = parse_record(pdf_path, department_rules=department_rules, args=args) + records.append(record) + except Exception as exc: # noqa: BLE001 - 批处理需要继续处理下一份 + failures.append({"源文件": pdf_path.name, "错误": str(exc)}) + + write_outputs(records, output_dir, save_text=save_text) + if args.write_postgres: + write_postgres(records, args) + + if failures: + fail_path = output_dir / "04_复核与人工校正" / "患者首页_处理失败.csv" + with fail_path.open("w", encoding="utf-8-sig", newline="") as fp: + writer = csv.DictWriter(fp, fieldnames=["源文件", "错误"]) + writer.writeheader() + writer.writerows(failures) + + print(f"完成:成功 {len(records)} 份,失败 {len(failures)} 份。结果目录:{output_dir}") + return 1 if failures else 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="批量解析患者住院病案首页 PDF,输出 JSONL/CSV/单份 JSON。") + parser.add_argument("-i", "--input-dir", type=Path, default=DEFAULT_INPUT_DIR, help="PDF 输入目录") + parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="结果输出目录") + parser.add_argument("--no-text", action="store_true", help="不额外保存 pdftotext 抽取出的 txt") + parser.add_argument("--write-postgres", action="store_true", help="同时写入 PostgreSQL 表") + 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_PG_TABLE), help="PostgreSQL 表名") + parser.add_argument("--department-rules", type=Path, default=DEFAULT_DEPARTMENT_RULE_PATH, help="科室到大科室分类规则 JSON") + parser.add_argument( + "--text-source", + choices=["auto", "pdftotext", "mineru"], + default=os.environ.get("PATIENT_FRONT_TEXT_SOURCE", "auto"), + help="PDF文本抽取方式:auto先pdftotext异常时再Mineru,pdftotext只用本地转换,mineru强制PDF转Markdown", + ) + parser.add_argument("--mineru-client", type=Path, default=DEFAULT_MINERU_CLIENT_PATH, help="Mineru PDF转Markdown客户端脚本路径") + parser.add_argument("--mineru-md-dir", type=Path, default=DEFAULT_MINERU_MD_DIR, help="Mineru Markdown输出目录") + parser.add_argument("--mineru-url", default=DEFAULT_MINERU_URL, help="Mineru API服务地址;也可用 MINERU_URL") + parser.add_argument("--mineru-sync", action="store_true", help="调用Mineru时开启同步清理") + return parser + + +def main() -> int: + args = build_parser().parse_args() + return process(args.input_dir.resolve(), args.output_dir.resolve(), save_text=not args.no_text, args=args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/患者首页处理/数据处理工作区/03_人工复核/03_人工复核导出与回写.py b/患者首页处理/数据处理工作区/03_人工复核/03_人工复核导出与回写.py new file mode 100755 index 0000000..0e4406a --- /dev/null +++ b/患者首页处理/数据处理工作区/03_人工复核/03_人工复核导出与回写.py @@ -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) diff --git a/患者首页处理/数据处理工作区/04_质量体检/04_字段核验与数据库体检.py b/患者首页处理/数据处理工作区/04_质量体检/04_字段核验与数据库体检.py new file mode 100755 index 0000000..65c308f --- /dev/null +++ b/患者首页处理/数据处理工作区/04_质量体检/04_字段核验与数据库体检.py @@ -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) diff --git a/患者首页处理/数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py b/患者首页处理/数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py new file mode 100644 index 0000000..ad111f0 --- /dev/null +++ b/患者首页处理/数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py @@ -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检测到用户中断,程序退出。") diff --git a/患者首页处理/数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py b/患者首页处理/数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py new file mode 100644 index 0000000..aea2167 --- /dev/null +++ b/患者首页处理/数据处理工作区/06_图片对照核验/06_PDF转图片与对照核验.py @@ -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 '

    未生成字段核验表。

    ' + + parts = [ + '', + "", + "", + ] + for row in field_rows: + missing_class = " is-missing" if row["missing"] == "是" and row["level"] != "optional" else "" + parts.append( + "" + "" + "".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("
    字段级别首页位置缺项
    {group}{key}{level}{location}{value}{missing}
    ") + 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( + "{source}{name}{mrn}{status}{required}".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( + '
    {alt}
    {caption}
    '.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('

    未生成图片。

    ') + + suggestion_items = "\n".join(f"
  • {html_escape(note)}
  • " 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"
    {html_escape(label)}
    {html_escape(value_preview(value, 160))}
    " for label, value in summary_items + ) + case_sections.append( + """ +
    +
    +
    +

    {source}

    +

    {name} · {mrn} · {status}

    +
    + 返回顶部 +
    +
    +
    {images}
    +
    +
    {summary}
    +

    对照建议

      {suggestions}
    + {field_table} +
    +
    +
    + """.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( + """ + + + + + 患者首页 PDF 图片对照核验 + + + +
    +

    患者首页 PDF 图片对照核验

    +
    + PDF:$total + 核心缺项:$with_required_missing + 需复核状态:$with_review_status + 图片失败:$failed_images +
    +
    +
    +
    + + + $nav_rows +
    源文件姓名病案号复核状态核心缺项
    +
    + $case_sections +
    + + +""" + ) + 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()) diff --git a/患者首页处理/数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py b/患者首页处理/数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py new file mode 100644 index 0000000..0f62c8c --- /dev/null +++ b/患者首页处理/数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py @@ -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)