Add per-action AI privacy mode
This commit is contained in:
21
患者首页处理/.env.example
Normal file
21
患者首页处理/.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
PGHOST=your-db-host
|
||||
PGPORT=5432
|
||||
PGDATABASE=your-db-name
|
||||
PGUSER=your-db-user
|
||||
PGPASSWORD=your-db-password
|
||||
PGTABLE=your-front-page-table
|
||||
|
||||
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
|
||||
PDF_DIR_HOST=../已处理-患者首页PDF
|
||||
|
||||
MOONSHOT_API_KEY=your-kimi-api-key
|
||||
KIMI_MODEL=kimi-k2.6
|
||||
KIMI_CONCURRENCY=3
|
||||
KIMI_THINKING_ENABLED=false
|
||||
KIMI_AI_SCOPE_MODE=all
|
||||
22
患者首页处理/.gitignore
vendored
22
患者首页处理/.gitignore
vendored
@@ -1,8 +1,25 @@
|
||||
# 原始 PDF 与处理结果不进入 Gitea
|
||||
# 原始 PDF、软链接与处理结果不进入 Gitea
|
||||
待处理-患者首页PDF
|
||||
待处理-患者首页PDF/
|
||||
已处理-患者首页PDF
|
||||
已处理-患者首页PDF/
|
||||
数据处理结果区
|
||||
数据处理结果区/
|
||||
|
||||
# 患者数据、数据库导出和大体量运行产物
|
||||
*.pdf
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.csv
|
||||
*.xlsx
|
||||
*.xls
|
||||
*.jsonl
|
||||
*.dump
|
||||
*.backup
|
||||
*.sql.gz
|
||||
*.zip
|
||||
|
||||
# 旧参考程序不作为本次工作流代码提交
|
||||
参考-过去很烂的程序/
|
||||
参考-病案首页图片/
|
||||
@@ -15,6 +32,9 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.pgpass
|
||||
*.db
|
||||
*.sqlite
|
||||
数据可视化网页端/review_settings.local.json
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
数据可视化网页端/.env.example
|
||||
```
|
||||
|
||||
`.env` 也可配置 `MOONSHOT_API_KEY` 和 `KIMI_MODEL`。涉及患者信息的图片不会自动上传,Kimi 视觉步骤只在人工运行 `07_Kimi图片识别辅助.py` 时调用。
|
||||
`.env` 也可配置 `MOONSHOT_API_KEY` 和 `KIMI_MODEL`。网页端 AI 核验默认按按钮开启隐私模式;如需核验基本信息或地址联系人,可在设置页分别关闭“AI当前项”“AI后5项”“AI后全部”的隐私模式。
|
||||
|
||||
## 快速运行
|
||||
|
||||
@@ -45,7 +45,7 @@ python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.
|
||||
```bash
|
||||
python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py \
|
||||
--text-source mineru \
|
||||
--mineru-url "http://MINERU_HOST:4000/extract"
|
||||
--mineru-url "$MINERU_URL"
|
||||
```
|
||||
|
||||
单独运行 PDF 转 Markdown:
|
||||
@@ -54,20 +54,20 @@ python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.
|
||||
python3 数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py \
|
||||
-s 待处理-患者首页PDF \
|
||||
-t 数据处理结果区/06_Mineru_MD \
|
||||
-u "http://MINERU_HOST:4000/extract"
|
||||
-u "$MINERU_URL"
|
||||
```
|
||||
|
||||
正式流程建议同时写入 PostgreSQL:
|
||||
|
||||
```bash
|
||||
export PGPASSWORD='请填写数据库密码'
|
||||
export PGPASSWORD='从安全渠道获取数据库密码'
|
||||
python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py \
|
||||
--write-postgres \
|
||||
--pg-host DB_HOST \
|
||||
--pg-host "$PGHOST" \
|
||||
--pg-port 5432 \
|
||||
--pg-database DB_NAME \
|
||||
--pg-user DB_USER \
|
||||
--pg-table Patient_FrontPages
|
||||
--pg-database "$PGDATABASE" \
|
||||
--pg-user "$PGUSER" \
|
||||
--pg-table "$PGTABLE"
|
||||
unset PGPASSWORD
|
||||
```
|
||||
|
||||
@@ -103,7 +103,7 @@ docker compose -f 数据可视化网页端/docker-compose.yml up -d --build
|
||||
http://localhost:8501
|
||||
```
|
||||
|
||||
首次进入会显示登录界面;默认管理员为 `admin`,默认密码为 `change-me`。设置页创建的本地用户会同步作为登录账号,并按权限控制各页面。
|
||||
首次进入会显示登录界面;默认管理员为 `admin`,默认密码请在本地 `.env` 中通过 `REVIEW_ADMIN_PASSWORD` 设置。设置页创建的本地用户会同步作为登录账号,并按权限控制各页面。
|
||||
|
||||
网页端会显示数据库连接情况、记录数、需复核数、PDF 数量;人工修改右侧字段并点击“复核并保存”后,会同步更新 PostgreSQL,并把 `manual_corrected` 标记为 `true`。
|
||||
|
||||
@@ -113,6 +113,7 @@ http://localhost:8501
|
||||
- “复核并保存”后,复核状态由后端自动标记为 `reviewed`,复核状态和质控状态只读显示在 PDF 下方。
|
||||
- 诊断、手术、费用等 JSON 字段在右侧以表格方式编辑,保存时再写回 JSONB。
|
||||
- 手术操作按编码、日期、级别、名称、术者、I助、II助、切口愈合等级、麻醉方式、麻醉医师拆分展示。
|
||||
- 设置页的 AI 核验按钮可分别设置模型/Thinking 和隐私模式;隐私模式默认开启,关闭后对应按钮会允许上传基本信息、地址联系人等相关局部内容用于核验和修正。
|
||||
|
||||
可选 Kimi 视觉兜底:
|
||||
|
||||
@@ -129,13 +130,13 @@ python3 数据处理工作区/07_Kimi视觉兜底/07_Kimi图片识别辅助.py \
|
||||
从 PostgreSQL 导出待复核表:
|
||||
|
||||
```bash
|
||||
export PGPASSWORD='请填写数据库密码'
|
||||
export PGPASSWORD='从安全渠道获取数据库密码'
|
||||
python3 数据处理工作区/03_人工复核/03_人工复核导出与回写.py export \
|
||||
--pg-host DB_HOST \
|
||||
--pg-host "$PGHOST" \
|
||||
--pg-port 5432 \
|
||||
--pg-database DB_NAME \
|
||||
--pg-user DB_USER \
|
||||
--pg-table Patient_FrontPages
|
||||
--pg-database "$PGDATABASE" \
|
||||
--pg-user "$PGUSER" \
|
||||
--pg-table "$PGTABLE"
|
||||
unset PGPASSWORD
|
||||
```
|
||||
|
||||
@@ -152,26 +153,26 @@ unset PGPASSWORD
|
||||
回写 PostgreSQL:
|
||||
|
||||
```bash
|
||||
export PGPASSWORD='请填写数据库密码'
|
||||
export PGPASSWORD='从安全渠道获取数据库密码'
|
||||
python3 数据处理工作区/03_人工复核/03_人工复核导出与回写.py import \
|
||||
--pg-host DB_HOST \
|
||||
--pg-host "$PGHOST" \
|
||||
--pg-port 5432 \
|
||||
--pg-database DB_NAME \
|
||||
--pg-user DB_USER \
|
||||
--pg-table Patient_FrontPages
|
||||
--pg-database "$PGDATABASE" \
|
||||
--pg-user "$PGUSER" \
|
||||
--pg-table "$PGTABLE"
|
||||
unset PGPASSWORD
|
||||
```
|
||||
|
||||
回写后建议做数据库体检:
|
||||
|
||||
```bash
|
||||
export PGPASSWORD='请填写数据库密码'
|
||||
export PGPASSWORD='从安全渠道获取数据库密码'
|
||||
python3 数据处理工作区/04_质量体检/04_字段核验与数据库体检.py \
|
||||
--pg-host DB_HOST \
|
||||
--pg-host "$PGHOST" \
|
||||
--pg-port 5432 \
|
||||
--pg-database DB_NAME \
|
||||
--pg-user DB_USER \
|
||||
--pg-table Patient_FrontPages
|
||||
--pg-database "$PGDATABASE" \
|
||||
--pg-user "$PGUSER" \
|
||||
--pg-table "$PGTABLE"
|
||||
unset PGPASSWORD
|
||||
```
|
||||
|
||||
@@ -196,11 +197,11 @@ unset PGPASSWORD
|
||||
|
||||
## PostgreSQL 表结构
|
||||
|
||||
`--write-postgres` 会写入 `public."Patient_FrontPages"` 宽表,不再使用 `payload` 总 JSON 字段,也不写入 `parsed_at/created_at/updated_at`。
|
||||
`--write-postgres` 会写入本地 `PGTABLE` 指向的宽表,不再使用 `payload` 总 JSON 字段,也不写入 `parsed_at/created_at/updated_at`。
|
||||
|
||||
入库时会同步写入表注释和字段注释,便于在数据库客户端中查看每列含义。
|
||||
|
||||
网页端会按需把人工复核和抽查记录写入主表 JSONB 列:`review_logs` 记录人工保存时间、修改字段和备注;`audit_logs` 记录抽查来源、结论和备注。历史辅助表 `"Patient_FrontPages_review_logs"`、`"Patient_FrontPages_audit_logs"` 会自动迁移并删除,数据库最终只保留 `"Patient_FrontPages"` 一张业务表。
|
||||
网页端会按需把人工复核和抽查记录写入主表 JSONB 列:`review_logs` 记录人工保存时间、修改字段和备注;`audit_logs` 记录抽查来源、结论和备注。历史辅助日志表会自动迁移并删除,数据库最终只保留 `PGTABLE` 指向的一张业务表。
|
||||
|
||||
患者号/住院号 `inpatient_no` 是首页与患者列表联动唯一键,也是本程序唯一强制校验条件:不能为空。程序不校验编号格式,同一非空 `inpatient_no` 只保留一条首页记录;迁移时会删除历史重复住院号并保留最新记录,后续重复入库或网页写入会按 `inpatient_no` 覆盖旧首页。若 PDF 文件名和首页病案号可推导,程序仍会自动生成类似 `ZY020001447443` 的默认住院号,后续格式问题交由患者目录核验网页端处理。
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
仓库地址:
|
||||
|
||||
```text
|
||||
https://gitea.huijutec.cn/admin/HIS_Front_Page.git
|
||||
https://gitea.huijutec.cn/admin/HIS.git
|
||||
```
|
||||
|
||||
仓库只保存程序、文档和配置说明。不要提交原始 PDF、处理结果、明文密码、`.env` 文件,也不再提交加密本地工作流压缩包。
|
||||
@@ -31,17 +31,18 @@ README.md
|
||||
|
||||
## PostgreSQL 存储约定
|
||||
|
||||
目标数据库:
|
||||
数据库连接信息只放在本地 `.env` 或运行环境变量中,不写入 Gitea。需要配置:
|
||||
|
||||
```text
|
||||
主机/IP: DB_HOST
|
||||
端口: 5432
|
||||
数据库名: DB_NAME
|
||||
用户名: DB_USER
|
||||
表名: Patient_FrontPages
|
||||
PGHOST
|
||||
PGPORT
|
||||
PGDATABASE
|
||||
PGUSER
|
||||
PGPASSWORD
|
||||
PGTABLE
|
||||
```
|
||||
|
||||
脚本会在首次写入时自动创建或迁移表 `"Patient_FrontPages"`。表是宽表结构,`payload` 中的顶层信息会展开为独立列;不再保存 `payload`、`parsed_at`、`created_at`、`updated_at`。
|
||||
脚本会在首次写入时自动创建或迁移 `PGTABLE` 指向的宽表,`payload` 中的顶层信息会展开为独立列;不再保存 `payload`、`parsed_at`、`created_at`、`updated_at`。
|
||||
|
||||
脚本会为表和全部字段写入 PostgreSQL 注释,数据库客户端中可直接查看每列含义。
|
||||
|
||||
@@ -50,7 +51,7 @@ README.md
|
||||
- `review_logs`:记录人工保存时间、修改字段、修改前后值和人工备注。
|
||||
- `audit_logs`:记录抽查来源、抽查状态、抽查备注和更新时间。
|
||||
|
||||
历史辅助表 `"Patient_FrontPages_review_logs"`、`"Patient_FrontPages_audit_logs"` 会自动迁移进主表并删除,最终只保留 `"Patient_FrontPages"` 一张业务表。
|
||||
历史辅助日志表会自动迁移进主表并删除,最终只保留 `PGTABLE` 指向的一张业务表。
|
||||
|
||||
核心列包括:
|
||||
|
||||
@@ -85,8 +86,8 @@ README.md
|
||||
1. 克隆仓库:
|
||||
|
||||
```bash
|
||||
git clone https://gitea.huijutec.cn/admin/HIS_Front_Page.git
|
||||
cd HIS_Front_Page
|
||||
git clone https://gitea.huijutec.cn/admin/HIS.git
|
||||
cd HIS
|
||||
```
|
||||
|
||||
2. 准备目录:
|
||||
@@ -112,7 +113,7 @@ README.md
|
||||
```bash
|
||||
python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py \
|
||||
--text-source mineru \
|
||||
--mineru-url "http://MINERU_HOST:4000/extract"
|
||||
--mineru-url "$MINERU_URL"
|
||||
```
|
||||
|
||||
也可以先单独转 Markdown:
|
||||
@@ -121,20 +122,20 @@ README.md
|
||||
python3 数据处理工作区/05_备用读取/05_备用PDF转Markdown_Mineru.py \
|
||||
-s 待处理-患者首页PDF \
|
||||
-t 数据处理结果区/06_Mineru_MD \
|
||||
-u "http://MINERU_HOST:4000/extract"
|
||||
-u "$MINERU_URL"
|
||||
```
|
||||
|
||||
5. 正式处理并写入 PostgreSQL:
|
||||
|
||||
```bash
|
||||
export PGPASSWORD='请填写数据库密码'
|
||||
export PGPASSWORD='从安全渠道获取数据库密码'
|
||||
python3 数据处理工作区/02_解析入库/02_患者首页PDF解析与入库.py \
|
||||
--write-postgres \
|
||||
--pg-host DB_HOST \
|
||||
--pg-host "$PGHOST" \
|
||||
--pg-port 5432 \
|
||||
--pg-database DB_NAME \
|
||||
--pg-user DB_USER \
|
||||
--pg-table Patient_FrontPages
|
||||
--pg-database "$PGDATABASE" \
|
||||
--pg-user "$PGUSER" \
|
||||
--pg-table "$PGTABLE"
|
||||
unset PGPASSWORD
|
||||
```
|
||||
|
||||
@@ -166,12 +167,14 @@ README.md
|
||||
http://localhost:8501
|
||||
```
|
||||
|
||||
首次进入会显示登录界面;默认管理员为 `admin`,默认密码为 `change-me`。设置页创建的本地用户会同步作为登录账号,并按权限控制各页面。
|
||||
首次进入会显示登录界面;默认管理员为 `admin`,默认密码请在本地 `.env` 中通过 `REVIEW_ADMIN_PASSWORD` 设置。设置页创建的本地用户会同步作为登录账号,并按权限控制各页面。
|
||||
|
||||
页面顶部显示数据库连接状态、总记录数、需复核数、PDF 数量;顶部页签包括概览、复核、抽查、抽查一览、设置,设置内合并用户与权限、数据库状态和目录信息。复核页默认进入“全部复核数据”,包含需复核、自动修正、已人工复核/已人工修改;左侧选择病例,中间查看 PDF,右侧以表格方式编辑摘录字段。点击“复核并保存”后会同步写回 PostgreSQL,把 `manual_corrected` 标记为 `true`,自动把 `review_status` 标记为 `reviewed`;`Patient_Lists.has_front_page/front_page_id/front_page_source_file` 由数据库触发器按住院号自动维护。
|
||||
|
||||
复核提示会定位到右侧模块和字段:需要核对的位置标红,无关模块默认收起。人工备注下方会显示修改记录表,主表 `review_logs` 记录修改时间、修改字段、修改前后值和人工备注。
|
||||
|
||||
设置页的 AI 核验按钮可分别配置模型/Thinking 和隐私模式。“AI当前项”“AI后5项”“AI后全部”默认均开启隐私模式;关闭某个按钮的隐私模式后,该按钮对应任务会允许上传基本信息、地址联系人等相关局部内容用于核验和修正。
|
||||
|
||||
抽查页可从已人工复核或自动通过记录中随机抽样;右侧和复核页一样显示并可编辑全部结构化信息,下方有人工备注和修改记录。随机抽取不会写入“待抽查”记录,只有点击“归类通过并保存”“归类异常并保存”“归类不确定并保存”后,才把通过、异常、不确定记录写入主表 `audit_logs`;抽查一览展示修改内容、人工备注和更新时间。
|
||||
|
||||
PDF 以 `inline` 方式展示,不再作为附件触发保存。浏览器内核不能彻底禁止用户保存已展示内容。
|
||||
@@ -210,13 +213,13 @@ sed -n '1,50p' 数据处理结果区/04_复核与人工校正/患者首页_复
|
||||
1. 导出待复核表:
|
||||
|
||||
```bash
|
||||
export PGPASSWORD='请填写数据库密码'
|
||||
export PGPASSWORD='从安全渠道获取数据库密码'
|
||||
python3 数据处理工作区/03_人工复核/03_人工复核导出与回写.py export \
|
||||
--pg-host DB_HOST \
|
||||
--pg-host "$PGHOST" \
|
||||
--pg-port 5432 \
|
||||
--pg-database DB_NAME \
|
||||
--pg-user DB_USER \
|
||||
--pg-table Patient_FrontPages
|
||||
--pg-database "$PGDATABASE" \
|
||||
--pg-user "$PGUSER" \
|
||||
--pg-table "$PGTABLE"
|
||||
unset PGPASSWORD
|
||||
```
|
||||
|
||||
@@ -237,13 +240,13 @@ sed -n '1,50p' 数据处理结果区/04_复核与人工校正/患者首页_复
|
||||
3. 回写 PostgreSQL:
|
||||
|
||||
```bash
|
||||
export PGPASSWORD='请填写数据库密码'
|
||||
export PGPASSWORD='从安全渠道获取数据库密码'
|
||||
python3 数据处理工作区/03_人工复核/03_人工复核导出与回写.py import \
|
||||
--pg-host DB_HOST \
|
||||
--pg-host "$PGHOST" \
|
||||
--pg-port 5432 \
|
||||
--pg-database DB_NAME \
|
||||
--pg-user DB_USER \
|
||||
--pg-table Patient_FrontPages
|
||||
--pg-database "$PGDATABASE" \
|
||||
--pg-user "$PGUSER" \
|
||||
--pg-table "$PGTABLE"
|
||||
unset PGPASSWORD
|
||||
```
|
||||
|
||||
@@ -252,13 +255,13 @@ sed -n '1,50p' 数据处理结果区/04_复核与人工校正/患者首页_复
|
||||
4. 做数据库体检:
|
||||
|
||||
```bash
|
||||
export PGPASSWORD='请填写数据库密码'
|
||||
export PGPASSWORD='从安全渠道获取数据库密码'
|
||||
python3 数据处理工作区/04_质量体检/04_字段核验与数据库体检.py \
|
||||
--pg-host DB_HOST \
|
||||
--pg-host "$PGHOST" \
|
||||
--pg-port 5432 \
|
||||
--pg-database DB_NAME \
|
||||
--pg-user DB_USER \
|
||||
--pg-table Patient_FrontPages
|
||||
--pg-database "$PGDATABASE" \
|
||||
--pg-user "$PGUSER" \
|
||||
--pg-table "$PGTABLE"
|
||||
unset PGPASSWORD
|
||||
```
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
PGHOST=DB_HOST
|
||||
PGHOST=your-db-host
|
||||
PGPORT=5432
|
||||
PGDATABASE=DB_NAME
|
||||
PGUSER=DB_USER
|
||||
PGPASSWORD=请填写数据库密码
|
||||
PGTABLE=Patient_FrontPages
|
||||
PGDATABASE=your-db-name
|
||||
PGUSER=your-db-user
|
||||
PGPASSWORD=your-db-password
|
||||
PGTABLE=your-front-page-table
|
||||
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8501
|
||||
@@ -13,5 +13,7 @@ REVIEW_STATUS_CHECK_TIME=03:00
|
||||
|
||||
PDF_DIR=/data/pdfs
|
||||
|
||||
MOONSHOT_API_KEY=请填写 Kimi API Key
|
||||
MOONSHOT_API_KEY=your-kimi-api-key
|
||||
KIMI_MODEL=kimi-k2.6
|
||||
KIMI_THINKING_ENABLED=false
|
||||
KIMI_AI_SCOPE_MODE=all
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 患者首页可视化对照网页端
|
||||
|
||||
这个网页端用于人工复核患者首页:概览看批次状态,复核页只处理需复核记录,中间查看 PDF,右侧编辑结构化摘录信息;保存后直接同步到 PostgreSQL 表 `"Patient_FrontPages"`。
|
||||
这个网页端用于人工复核患者首页:概览看批次状态,复核页只处理需复核记录,中间查看 PDF,右侧编辑结构化摘录信息;保存后直接同步到 `.env` 中 `PGTABLE` 指向的 PostgreSQL 表。
|
||||
|
||||
## 启动
|
||||
|
||||
@@ -16,11 +16,11 @@ docker compose -f 数据可视化网页端/docker-compose.yml up -d --build
|
||||
http://localhost:8501
|
||||
```
|
||||
|
||||
首次进入会显示登录界面。默认管理员为 `admin`,默认密码为 `change-me`;可在 `.env` 中通过 `REVIEW_ADMIN_USER`、`REVIEW_ADMIN_PASSWORD` 修改。设置页创建的本地用户会同步作为登录账号,并按权限控制概览、复核、抽查、抽查一览、设置页面。
|
||||
首次进入会显示登录界面。管理员用户名和密码请在本地 `.env` 中通过 `REVIEW_ADMIN_USER`、`REVIEW_ADMIN_PASSWORD` 设置。设置页创建的本地用户会同步作为登录账号,并按权限控制概览、复核、抽查、抽查一览、设置页面。
|
||||
|
||||
## 目录挂载
|
||||
|
||||
- `已处理-患者首页PDF/2026_5_25_处理/` 挂载为容器内 `/data/pdfs`,只读。
|
||||
- 本地 `PDF_DIR_HOST` 挂载为容器内 `/data/pdfs`,只读。
|
||||
- 网页端只预览 PDF,不再挂载或依赖 PDF 转图片目录。
|
||||
|
||||
## 保存规则
|
||||
@@ -48,6 +48,7 @@ http://localhost:8501
|
||||
- 复核状态、质控状态显示在 PDF 下方,只读。
|
||||
- 诊断、手术、费用等 JSONB 字段以表格方式展示和编辑。
|
||||
- 手术操作按编码、日期、级别、名称、术者、I助、II助、切口愈合等级、麻醉方式、麻醉医师拆分。
|
||||
- 设置页可分别为“AI当前项”“AI后5项”“AI后全部”配置使用模型和隐私模式;隐私模式默认开启,关闭后对应按钮会允许上传基本信息、地址联系人等相关局部内容用于核验和修正。
|
||||
|
||||
## 抽查规则
|
||||
|
||||
@@ -55,4 +56,4 @@ http://localhost:8501
|
||||
- 抽查页右侧和复核页一样展示并可编辑全部结构化信息,下方有人工备注和修改记录。
|
||||
- 随机抽取不会写入“待抽查”记录;只有点击“归类通过并保存”“归类异常并保存”“归类不确定并保存”后,才写入主表 `audit_logs` JSONB 列。
|
||||
- 抽查一览只展示已归类的通过、异常、不确定记录,并显示修改内容、人工备注和更新时间。
|
||||
- 涉及患者资料的视觉识别仍需人工运行 `07_Kimi图片识别辅助.py`,不会由网页端自动上传。
|
||||
- 可选的 `07_Kimi图片识别辅助.py` 仍只在人工运行时上传指定图片;网页端 AI 核验是否上传敏感区域由对应按钮的隐私模式控制。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,8 @@ const state = {
|
||||
auditLogs: [],
|
||||
settings: null,
|
||||
ai: null,
|
||||
aiJob: null,
|
||||
aiJobOwned: false,
|
||||
aiPolling: null,
|
||||
currentUser: null,
|
||||
authenticated: false,
|
||||
@@ -28,6 +30,19 @@ const PDF_MIN_ZOOM = 55;
|
||||
const PDF_MAX_ZOOM = 220;
|
||||
const OPERATION_COLUMNS = ["手术操作编码", "手术操作日期", "手术级别", "手术操作名称", "术者", "I助", "II助", "切口愈合等级", "麻醉方式", "麻醉医师", "原始内容"];
|
||||
const DIAGNOSIS_COLUMNS = ["诊断类别", "出院诊断", "疾病编码", "入院病情"];
|
||||
const AI_ACTION_MODE_OPTIONS = [
|
||||
["off", "关闭"],
|
||||
["default", "跟随默认模型"],
|
||||
["k25", "K2.5 关 Thinking"],
|
||||
["k25_thinking", "K2.5 开 Thinking"],
|
||||
["k26", "K2.6 关 Thinking"],
|
||||
["k26_thinking", "K2.6 开 Thinking"],
|
||||
];
|
||||
const AI_ACTION_SCOPE_LABELS = {
|
||||
current: "AI当前项",
|
||||
five: "AI后5项",
|
||||
all: "AI后全部",
|
||||
};
|
||||
|
||||
const GROUP_KEYWORDS = {
|
||||
基本信息: ["住院号", "病案号", "首页病案号", "姓名", "性别", "出生", "年龄", "身份证", "婚姻", "健康卡", "入院", "出院", "科别", "科室", "病房", "住院天数"],
|
||||
@@ -67,8 +82,10 @@ function statusTag(status) {
|
||||
auto_corrected: ["自动修正", "auto_corrected"],
|
||||
needs_review: ["需复核", "needs_review"],
|
||||
reviewed: ["已复核", "reviewed"],
|
||||
"AI复核-无问题": ["AI复核-无问题", "ai_passed"],
|
||||
"AI复核-待确认": ["AI复核-待确认", "ai_pending"],
|
||||
"AI已处理-OK": ["AI已通过", "ai_passed"],
|
||||
"AI已处理-不OK": ["AI建议复核", "ai_pending"],
|
||||
"AI复核-无问题": ["AI已通过", "ai_passed"],
|
||||
"AI复核-待确认": ["AI建议复核", "ai_pending"],
|
||||
"已提交": ["已提交", "submitted"],
|
||||
pending: ["待处理", "pending"],
|
||||
};
|
||||
@@ -76,11 +93,73 @@ function statusTag(status) {
|
||||
return `<span class="tag ${className}">${label}</span>`;
|
||||
}
|
||||
|
||||
function hasAiReview(record) {
|
||||
return Boolean(record?.ai_reviewed) || (record?.review_logs || []).some((log) => isAiSource(log?.changed_by));
|
||||
}
|
||||
|
||||
function displayStatusValue(record) {
|
||||
if (record?.review_status === "auto_pass" && hasAiReview(record)) return "AI已处理-OK";
|
||||
return record?.review_status;
|
||||
}
|
||||
|
||||
function auditTag(status) {
|
||||
const labels = { pending: "待抽查", passed: "抽查通过", failed: "抽查异常", unsure: "不确定" };
|
||||
return `<span class="tag audit-${status || "pending"}">${labels[status] || status || "待抽查"}</span>`;
|
||||
}
|
||||
|
||||
function displayText(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("Kimi AI", "AI")
|
||||
.replaceAll("Kimi视觉核验", "AI视觉核验")
|
||||
.replaceAll("Kimi", "AI")
|
||||
.replaceAll("AI判断不OK", "AI建议复核")
|
||||
.replaceAll("AI判断OK", "AI判断通过")
|
||||
.replaceAll("需要人工", "需要复核")
|
||||
.replaceAll("需人工", "需复核")
|
||||
.replaceAll("人工确认", "复核")
|
||||
.replaceAll("人工核对", "复核")
|
||||
.replaceAll("人工重点看", "复核");
|
||||
}
|
||||
|
||||
function isAiSource(value) {
|
||||
const text = String(value || "");
|
||||
return text.includes("AI") || text.includes("Kimi");
|
||||
}
|
||||
|
||||
function displaySource(value) {
|
||||
return isAiSource(value) ? "AI" : displayText(value || "web");
|
||||
}
|
||||
|
||||
function isReviewNeededText(value) {
|
||||
const text = String(value || "").trim();
|
||||
return Boolean(text) && !["无需", "无须", "不需", "不用", "无需补录", "无须补录", "无需处理", "原貌"].some((marker) => text.includes(marker));
|
||||
}
|
||||
|
||||
function aiAttentionSegments(value) {
|
||||
return displayText(value)
|
||||
.split(/[,,;;。]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(isReviewNeededText);
|
||||
}
|
||||
|
||||
function locateTargetsFromNotes(notes) {
|
||||
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 { fieldAlerts, groupAlerts };
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
let response;
|
||||
try {
|
||||
@@ -201,6 +280,11 @@ function changePdfZoom(delta) {
|
||||
|
||||
function switchPage(page) {
|
||||
if (!state.authenticated || !canOpenPage(page)) return;
|
||||
if (page !== "review" && state.aiJob?.running && state.aiJobOwned) {
|
||||
const leave = window.confirm("AI正在处理。离开复核页会中断本次AI处理,确认离开?");
|
||||
if (!leave) return;
|
||||
cancelAiReview({ silent: true }).catch(() => null);
|
||||
}
|
||||
state.activePage = page;
|
||||
document.querySelectorAll(".page-view").forEach((el) => el.classList.add("is-hidden"));
|
||||
$(`${page}Page`)?.classList.remove("is-hidden");
|
||||
@@ -218,13 +302,14 @@ async function loadStatus() {
|
||||
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 ?? "--";
|
||||
$("dbTotal").textContent = data.workbench_total ?? data.review_needed ?? "--";
|
||||
$("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;
|
||||
renderFilterActions();
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -238,10 +323,10 @@ function renderOverview(data) {
|
||||
const summary = data.summary || {};
|
||||
const metrics = [
|
||||
["总记录", summary.total],
|
||||
["复核队列", summary.review_queue],
|
||||
["待复核总数", summary.review_queue],
|
||||
["需复核", summary.needs_review],
|
||||
["AI待确认", summary.ai_pending],
|
||||
["AI无问题", summary.ai_passed],
|
||||
["AI建议复核", summary.ai_pending],
|
||||
["AI已通过", summary.ai_passed],
|
||||
["已复核", summary.reviewed],
|
||||
["已提交", summary.submitted],
|
||||
["自动通过", summary.auto_passed],
|
||||
@@ -255,11 +340,11 @@ function renderOverview(data) {
|
||||
</article>
|
||||
`).join("");
|
||||
$("overviewQueues").innerHTML = `
|
||||
<div class="queue-line"><span>复核工作台默认</span><strong>全部数据:需复核 ${summary.needs_review || 0} / AI待确认 ${summary.ai_pending || 0} / 已复核 ${summary.reviewed || 0}</strong></div>
|
||||
<div class="queue-line"><span>复核工作台默认</span><strong>全部数据:需复核 ${summary.needs_review || 0} / AI已通过 ${summary.ai_passed || 0} / AI建议复核 ${summary.ai_pending || 0} / 已复核 ${summary.reviewed || 0}</strong></div>
|
||||
<div class="queue-line"><span>抽查候选池</span><strong>已复核 ${summary.reviewed || 0} 条 / 自动通过 ${summary.auto_passed || 0} 条</strong></div>
|
||||
<div class="queue-line"><span>日志存储</span><strong>人工复核和抽查记录均写入主表 JSONB 列</strong></div>
|
||||
`;
|
||||
$("overviewRecentLogs").innerHTML = renderLogTable(data.recent_logs || []);
|
||||
$("overviewRecentLogs").innerHTML = renderLogTable(logChangeRows(data.recent_logs || []));
|
||||
}
|
||||
|
||||
async function loadSchema() {
|
||||
@@ -281,6 +366,7 @@ async function loadRecords({ append = false } = {}) {
|
||||
state.recordHasMore = Boolean(data.has_more);
|
||||
if (!append && !state.records.some((record) => record.id === state.selectedId)) {
|
||||
state.selectedId = null;
|
||||
state.selectedRecord = null;
|
||||
}
|
||||
renderRecords();
|
||||
renderFilterActions();
|
||||
@@ -316,7 +402,7 @@ function renderRecords() {
|
||||
<button class="record-item${active}" type="button" data-id="${record.id}">
|
||||
<div class="record-main">
|
||||
<span class="record-name">${escapeHtml(record.patient_name || "未命名")}</span>
|
||||
${statusTag(record.review_status)}
|
||||
${statusTag(displayStatusValue(record))}
|
||||
</div>
|
||||
<div class="record-sub">
|
||||
${escapeHtml(record.inpatient_no || record.medical_record_no || "")} · ${escapeHtml(record.major_department || record.discharge_dept || "")}${manual}<br>
|
||||
@@ -335,7 +421,12 @@ function renderRecords() {
|
||||
function renderFilterActions() {
|
||||
const button = $("approveAiNoIssueBtn");
|
||||
if (!button) return;
|
||||
button.classList.toggle("is-hidden", $("statusFilter").value !== "AI复核-无问题");
|
||||
const filter = $("statusFilter")?.value || "review_all";
|
||||
const aiPassedCount = Number(state.status?.ai_passed || 0);
|
||||
const shouldShow = ["review_all", "ai_passed"].includes(filter);
|
||||
button.classList.toggle("is-hidden", !shouldShow);
|
||||
button.disabled = !shouldShow || aiPassedCount <= 0;
|
||||
button.textContent = aiPassedCount > 0 ? `通过所有AI已通过(${aiPassedCount})` : "通过所有AI已通过";
|
||||
}
|
||||
|
||||
function scrollSelectedRecordIntoView() {
|
||||
@@ -372,11 +463,16 @@ function recordListIndex() {
|
||||
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;
|
||||
return ["needs_review", "reviewed", "AI已处理-OK", "AI已处理-不OK", "AI复核-无问题", "AI复核-待确认"].includes(record.review_status)
|
||||
|| (record.review_status === "auto_pass" && hasAiReview(record))
|
||||
|| record.manual_corrected === true;
|
||||
}
|
||||
if (status === "needs_review") {
|
||||
return record.review_status === "needs_review";
|
||||
}
|
||||
if (status === "ai_passed") {
|
||||
return ["AI已处理-OK", "AI复核-无问题"].includes(record.review_status) || (record.review_status === "auto_pass" && hasAiReview(record));
|
||||
}
|
||||
if (status === "reviewed") {
|
||||
return record.review_status === "reviewed";
|
||||
}
|
||||
@@ -444,6 +540,7 @@ function renderSelectedRecord() {
|
||||
logTableId: "changeLogTable",
|
||||
});
|
||||
renderChangeLogs(record.review_logs || [], "changeLogCount", "changeLogTable");
|
||||
renderAiQuestionLogs(record.review_logs || []);
|
||||
setView(state.view);
|
||||
}
|
||||
|
||||
@@ -462,7 +559,7 @@ async function refreshSelectedRecord() {
|
||||
}
|
||||
}
|
||||
|
||||
function reviewNotes(record) {
|
||||
function baseReviewNotes(record) {
|
||||
const notes = [];
|
||||
["review_notes", "quality_notes", "auto_corrections"].forEach((key) => {
|
||||
const value = record[key];
|
||||
@@ -472,31 +569,49 @@ function reviewNotes(record) {
|
||||
notes.push(String(value));
|
||||
}
|
||||
});
|
||||
return notes.filter(Boolean);
|
||||
return notes.map(displayText).filter(Boolean);
|
||||
}
|
||||
|
||||
function aiAttentionNotes(record) {
|
||||
const notes = [];
|
||||
(record.review_logs || []).forEach((log) => {
|
||||
const parsed = log.ai_result?.parsed;
|
||||
if (!parsed) return;
|
||||
if (Array.isArray(parsed.remaining_issues)) {
|
||||
parsed.remaining_issues.forEach((item) => {
|
||||
if (isReviewNeededText(item)) notes.push(item);
|
||||
});
|
||||
}
|
||||
const classification = String(parsed.classification || parsed.decision || "").toLowerCase();
|
||||
if (!notes.length && ["problem", "not_ok", "not ok", "confirm", "需复核", "待确认"].includes(classification)) {
|
||||
notes.push(...aiAttentionSegments(parsed.summary));
|
||||
}
|
||||
});
|
||||
return notes.map(displayText).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 };
|
||||
const notes = baseReviewNotes(record);
|
||||
const aiNotes = aiAttentionNotes(record);
|
||||
const baseTargets = locateTargetsFromNotes(notes);
|
||||
const aiTargets = locateTargetsFromNotes(aiNotes);
|
||||
const fieldAlerts = new Set([...baseTargets.fieldAlerts, ...aiTargets.fieldAlerts]);
|
||||
const groupAlerts = new Set([...baseTargets.groupAlerts, ...aiTargets.groupAlerts]);
|
||||
const hasAiNotes = (record.review_logs || []).some((log) => Boolean(log.ai_result));
|
||||
return {
|
||||
notes,
|
||||
aiNotes,
|
||||
fieldAlerts,
|
||||
groupAlerts,
|
||||
baseGroupAlerts: baseTargets.groupAlerts,
|
||||
aiGroupAlerts: aiTargets.groupAlerts,
|
||||
hasAiNotes,
|
||||
};
|
||||
}
|
||||
|
||||
function renderReviewStrip(record) {
|
||||
$("reviewStrip").innerHTML = `
|
||||
<div class="strip-item"><span>复核状态</span>${statusTag(record.review_status)}</div>
|
||||
<div class="strip-item"><span>复核状态</span>${statusTag(displayStatusValue(record))}</div>
|
||||
<label class="strip-item strip-note"><span>本次人工备注</span><input id="manualNote" type="text" placeholder="填写修正原因或核验说明"></label>
|
||||
`;
|
||||
}
|
||||
@@ -507,11 +622,12 @@ function renderForm(record, options = {}) {
|
||||
const noteId = options.noteId || "manualNote";
|
||||
const form = $(formId);
|
||||
const targets = reviewTargets(record);
|
||||
const sourceMap = fieldSourceMap(record.review_logs || []);
|
||||
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 fields = group.fields.map(([name, label, type, options]) => renderField(record, name, label, type, options, targets.fieldAlerts.has(name), sourceMap)).join("");
|
||||
const open = groupAlert || (!hasTarget && index === 0);
|
||||
return `
|
||||
<details class="field-group${groupAlert ? " is-alert" : ""}" data-group="${escapeHtml(group.name)}" ${open ? "open" : ""}>
|
||||
@@ -524,21 +640,37 @@ function renderForm(record, options = {}) {
|
||||
}
|
||||
|
||||
function renderTargetSummary(targets, targetId = "targetSummary", formId = "detailForm") {
|
||||
const groups = [...targets.groupAlerts];
|
||||
const notesHtml = targets.notes.length
|
||||
const baseGroups = [...targets.baseGroupAlerts];
|
||||
const aiGroups = [...targets.aiGroupAlerts];
|
||||
const baseNotesHtml = targets.notes.length
|
||||
? targets.notes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")
|
||||
: "<li>暂无复核提示</li>";
|
||||
: "<li>暂无原始复核定位提示</li>";
|
||||
const aiNotesHtml = targets.aiNotes.length
|
||||
? targets.aiNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")
|
||||
: "<li>AI未提出需要复核的内容</li>";
|
||||
$(targetId).innerHTML = `
|
||||
<details class="collapsible-panel target-panel" open>
|
||||
<summary>
|
||||
<strong>复核定位</strong>
|
||||
<span>${groups.length ? `${groups.length} 个模块` : "未定位"}</span>
|
||||
<span>${baseGroups.length ? `${baseGroups.length} 个模块` : "未定位"}</span>
|
||||
</summary>
|
||||
<div class="target-chips">
|
||||
${groups.length ? groups.map((group) => `<button type="button" data-target-group="${escapeHtml(group)}">${escapeHtml(group)}</button>`).join("") : "<span>未定位到具体模块,先核对基本信息</span>"}
|
||||
${baseGroups.length ? baseGroups.map((group) => `<button type="button" data-target-group="${escapeHtml(group)}">${escapeHtml(group)}</button>`).join("") : "<span>原始复核信息未定位到具体模块</span>"}
|
||||
</div>
|
||||
<ul>${notesHtml}</ul>
|
||||
<ul>${baseNotesHtml}</ul>
|
||||
</details>
|
||||
${targets.hasAiNotes ? `
|
||||
<details class="collapsible-panel target-panel is-ai-target" open>
|
||||
<summary>
|
||||
<strong>AI建议复核点</strong>
|
||||
<span>${aiGroups.length ? `${aiGroups.length} 个模块` : "无待看点"}</span>
|
||||
</summary>
|
||||
<div class="target-chips is-ai-target">
|
||||
${aiGroups.length ? aiGroups.map((group) => `<button type="button" data-target-group="${escapeHtml(group)}">${escapeHtml(group)}</button>`).join("") : "<span>AI未标出需要特别关注的模块</span>"}
|
||||
</div>
|
||||
<ul class="ai-note-list">${aiNotesHtml}</ul>
|
||||
</details>
|
||||
` : ""}
|
||||
`;
|
||||
$(targetId).querySelectorAll("[data-target-group]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
@@ -551,23 +683,37 @@ function renderTargetSummary(targets, targetId = "targetSummary", formId = "deta
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(record, name, label, type, options, alerted = false) {
|
||||
function fieldSourceMap(logs) {
|
||||
const map = {};
|
||||
logs.forEach((log) => {
|
||||
const source = isAiSource(log.changed_by) ? "ai" : "manual";
|
||||
rightSideChangedFields(log).forEach((field) => {
|
||||
const path = String(field.field || "");
|
||||
if (path) map[path] = source;
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function renderField(record, name, label, type, options, alerted = false, sourceMap = {}) {
|
||||
const value = record[name];
|
||||
const wide = type === "json" || String(value || "").length > 28 ? " is-wide" : "";
|
||||
const alertClass = alerted ? " is-alert" : "";
|
||||
const source = type === "json" ? "" : sourceMap[name] || "";
|
||||
const sourceClass = source === "ai" ? " is-ai-modified" : source === "manual" ? " is-manual-modified" : "";
|
||||
let control = "";
|
||||
if (type === "select") {
|
||||
control = `<select data-field="${name}" data-type="${type}">
|
||||
${(options || []).map((option) => `<option value="${escapeHtml(option)}" ${option === value ? "selected" : ""}>${escapeHtml(option)}</option>`).join("")}
|
||||
</select>`;
|
||||
} else if (type === "json") {
|
||||
control = renderJsonEditor(name, value);
|
||||
control = renderJsonEditor(name, value, sourceMap);
|
||||
} else {
|
||||
const inputType = type === "date" ? "date" : "text";
|
||||
const placeholder = type === "datetime" ? "YYYY-MM-DD HH:MM:SS" : "";
|
||||
control = `<input data-field="${name}" data-type="${type}" type="${inputType}" value="${escapeHtml(value ?? "")}" placeholder="${placeholder}">`;
|
||||
}
|
||||
return `<label class="field${wide}${alertClass}"><span>${escapeHtml(label)}${alerted ? "<em>需核对</em>" : ""}</span>${control}</label>`;
|
||||
return `<label class="field${wide}${alertClass}${sourceClass}"><span>${escapeHtml(label)}${source === "ai" ? "<em>AI修改</em>" : source === "manual" ? "<em>人工修改</em>" : alerted ? "<em>需核对</em>" : ""}</span>${control}</label>`;
|
||||
}
|
||||
|
||||
function preferredColumns(fieldName, value) {
|
||||
@@ -588,11 +734,11 @@ function preferredColumns(fieldName, value) {
|
||||
return ["值"];
|
||||
}
|
||||
|
||||
function renderJsonEditor(fieldName, value) {
|
||||
function renderJsonEditor(fieldName, value, sourceMap = {}) {
|
||||
const columns = preferredColumns(fieldName, value);
|
||||
const rows = normalizeJsonRows(value, columns);
|
||||
const header = columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("");
|
||||
const body = rows.map((row) => renderJsonRow(columns, row)).join("");
|
||||
const body = rows.map((row, rowIndex) => renderJsonRow(columns, row, fieldName, rowIndex, sourceMap)).join("");
|
||||
return `
|
||||
<div class="json-editor" data-json-field="${fieldName}" data-kind="${Array.isArray(value) ? "array" : "object"}">
|
||||
<div class="json-toolbar">
|
||||
@@ -602,7 +748,7 @@ function renderJsonEditor(fieldName, value) {
|
||||
<div class="json-table-wrap">
|
||||
<table class="json-table" data-columns="${escapeHtml(JSON.stringify(columns))}">
|
||||
<thead><tr>${header}<th>操作</th></tr></thead>
|
||||
<tbody>${body || renderJsonRow(columns, {})}</tbody>
|
||||
<tbody>${body || renderJsonRow(columns, {}, fieldName, 0, sourceMap)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -623,10 +769,14 @@ function normalizeJsonRows(value, columns) {
|
||||
return [{ [columns[0] || "值"]: value }];
|
||||
}
|
||||
|
||||
function renderJsonRow(columns, row) {
|
||||
function renderJsonRow(columns, row, fieldName = "", rowIndex = 0, sourceMap = {}) {
|
||||
const cells = columns.map((column) => {
|
||||
const value = row[column] ?? "";
|
||||
return `<td><input data-json-key="${escapeHtml(column)}" value="${escapeHtml(value)}"></td>`;
|
||||
const path = `${fieldName}[${rowIndex}].${column}`;
|
||||
const source = sourceMap[path] || "";
|
||||
const sourceClass = source === "ai" ? " is-ai-cell" : source === "manual" ? " is-manual-cell" : "";
|
||||
const title = source === "ai" ? "AI修改" : source === "manual" ? "人工修改" : "";
|
||||
return `<td class="${sourceClass}" title="${escapeHtml(title)}"><input data-json-key="${escapeHtml(column)}" value="${escapeHtml(value)}"></td>`;
|
||||
}).join("");
|
||||
return `<tr>${cells}<td><button type="button" data-json-delete>删除</button></td></tr>`;
|
||||
}
|
||||
@@ -733,6 +883,7 @@ function compactRecord(record) {
|
||||
medical_record_no: record.medical_record_no,
|
||||
patient_name: record.patient_name,
|
||||
review_status: record.review_status,
|
||||
ai_reviewed: record.ai_reviewed || hasAiReview(record),
|
||||
manual_corrected: record.manual_corrected,
|
||||
major_department: record.major_department,
|
||||
discharge_dept: record.discharge_dept,
|
||||
@@ -750,38 +901,160 @@ function setView(view) {
|
||||
}
|
||||
|
||||
function renderChangeLogs(logs, countId = "changeLogCount", tableId = "changeLogTable") {
|
||||
$(countId).textContent = `${logs.length} 条`;
|
||||
$(tableId).innerHTML = renderLogTable(logs);
|
||||
const rows = logChangeRows(logs);
|
||||
$(countId).textContent = `${rows.length} 项`;
|
||||
$(tableId).innerHTML = renderLogTable(rows);
|
||||
}
|
||||
|
||||
function renderLogTable(logs) {
|
||||
if (!logs.length) return `<div class="empty small">暂无修改记录</div>`;
|
||||
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(";")
|
||||
: "仅记录人工备注或状态确认";
|
||||
function rightSideChangedFields(log) {
|
||||
const ignored = new Set(["review_status", "review_notes", "manual_corrected", "ai_method"]);
|
||||
const fields = Array.isArray(log.changed_fields) ? log.changed_fields : [];
|
||||
return fields.flatMap((field) => expandChangedField(field)).filter((field) => {
|
||||
const root = String(field.field || "").split("[")[0].split(".")[0];
|
||||
return root && !ignored.has(root);
|
||||
});
|
||||
}
|
||||
|
||||
function expandChangedField(field) {
|
||||
const path = String(field.field || "");
|
||||
const root = path.split("[")[0].split(".")[0];
|
||||
if (!root || path.includes("[") || path.includes(".")) return [field];
|
||||
const oldValue = field.old;
|
||||
const newValue = field.new;
|
||||
if (!Array.isArray(oldValue) && !Array.isArray(newValue)) return [field];
|
||||
|
||||
const columns = preferredColumns(root, Array.isArray(newValue) ? newValue : oldValue);
|
||||
const oldRows = normalizeJsonRows(oldValue, columns);
|
||||
const newRows = normalizeJsonRows(newValue, columns);
|
||||
const maxRows = Math.max(oldRows.length, newRows.length);
|
||||
const expanded = [];
|
||||
for (let rowIndex = 0; rowIndex < maxRows; rowIndex += 1) {
|
||||
columns.forEach((column) => {
|
||||
const oldCell = oldRows[rowIndex]?.[column] ?? "";
|
||||
const newCell = newRows[rowIndex]?.[column] ?? "";
|
||||
if (comparableValue(oldCell) === comparableValue(newCell)) return;
|
||||
expanded.push({
|
||||
field: `${root}[${rowIndex}].${column}`,
|
||||
label: `${fieldLabel(root)}[${rowIndex + 1}].${column}`,
|
||||
old: oldCell,
|
||||
new: newCell,
|
||||
});
|
||||
});
|
||||
}
|
||||
return expanded.length ? expanded : [field];
|
||||
}
|
||||
|
||||
function fieldLabel(fieldName) {
|
||||
for (const group of state.schema || []) {
|
||||
for (const [name, label] of group.fields || []) {
|
||||
if (name === fieldName) return label;
|
||||
}
|
||||
}
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
function comparableValue(value) {
|
||||
return JSON.stringify(value ?? "");
|
||||
}
|
||||
|
||||
function logChangeRows(logs) {
|
||||
return logs.flatMap((log) => rightSideChangedFields(log).map((field) => ({ log, field })));
|
||||
}
|
||||
|
||||
function renderLogTable(rows) {
|
||||
if (!rows.length) return `<div class="empty small">暂无修改记录</div>`;
|
||||
const html = rows.map(({ log, field }) => {
|
||||
const sourceClass = isAiSource(log.changed_by) ? " log-ai" : " log-manual";
|
||||
return `
|
||||
<tr>
|
||||
<tr class="${sourceClass}">
|
||||
<td>${escapeHtml(formatTime(log.changed_at))}</td>
|
||||
<td>${escapeHtml(log.changed_by || "web")}</td>
|
||||
<td>${escapeHtml(summary)}</td>
|
||||
<td>${escapeHtml(log.manual_note || "")}</td>
|
||||
<td>${escapeHtml(displaySource(log.changed_by))}</td>
|
||||
<td>${escapeHtml(field.label || field.field || "")}</td>
|
||||
<td>${escapeHtml(shortValue(field.old))}</td>
|
||||
<td>${escapeHtml(shortValue(field.new))}</td>
|
||||
<td>${escapeHtml(displayText(log.manual_note || ""))}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
return `
|
||||
<table class="data-table">
|
||||
<thead><tr><th>修改时间</th><th>来源</th><th>修改内容</th><th>备注</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<thead><tr><th>修改时间</th><th>来源</th><th>字段</th><th>修改前</th><th>修改后</th><th>备注</th></tr></thead>
|
||||
<tbody>${html}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAiQuestionLogs(logs) {
|
||||
const box = $("aiQuestionTable");
|
||||
const count = $("aiQuestionCount");
|
||||
if (!box || !count) return;
|
||||
const aiLogs = logs.filter((log) => log.ai_result);
|
||||
count.textContent = `${aiLogs.length} 条`;
|
||||
if (!aiLogs.length) {
|
||||
box.innerHTML = `<div class="empty small">暂无AI提问记录</div>`;
|
||||
return;
|
||||
}
|
||||
const recordId = state.selectedRecord?.id;
|
||||
box.innerHTML = aiLogs.map((log, logIndex) => {
|
||||
const result = log.ai_result || {};
|
||||
const parsed = result.parsed || {};
|
||||
const modules = result.pdf_context?.modules || [];
|
||||
const moduleText = modules.map((item) => `${item.name || ""}P${item.page || ""}`).filter(Boolean).join(";") || "未定位";
|
||||
const question = result.ai_question || "";
|
||||
const imageGrid = modules.length && recordId ? `
|
||||
<div>
|
||||
<strong>传入图片</strong>
|
||||
<div class="ai-question-images">
|
||||
${modules.map((item, moduleIndex) => `
|
||||
<figure class="ai-question-figure">
|
||||
<div class="ai-question-image-frame">
|
||||
<img
|
||||
data-ai-src="/api/records/${encodeURIComponent(recordId)}/ai-question-image/${logIndex}/${moduleIndex}"
|
||||
alt="${escapeHtml(`${item.name || "局部截图"} 第${item.page || ""}页`)}"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
<figcaption>${escapeHtml(item.name || "局部截图")} · P${escapeHtml(item.page || "")} · ${escapeHtml((item.bbox || []).join(", "))}</figcaption>
|
||||
</figure>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
` : `<div><strong>传入图片</strong><p>本次未上传图片或图片定位未保存</p></div>`;
|
||||
return `
|
||||
<details class="ai-question-item">
|
||||
<summary>
|
||||
<span>${escapeHtml(formatTime(log.changed_at))}</span>
|
||||
<strong>${escapeHtml(displayText(parsed.classification || parsed.decision || ""))}</strong>
|
||||
<em>${escapeHtml(moduleText)}</em>
|
||||
</summary>
|
||||
<div class="ai-question-body">
|
||||
<div><strong>结论</strong><p>${escapeHtml(displayText(parsed.summary || log.manual_note || ""))}</p></div>
|
||||
<div><strong>定位</strong><p>${escapeHtml(moduleText)}</p></div>
|
||||
${imageGrid}
|
||||
<div><strong>提问</strong><pre>${escapeHtml(displayText(question).slice(0, 8000))}</pre></div>
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}).join("");
|
||||
wireAiQuestionImages(box);
|
||||
}
|
||||
|
||||
function wireAiQuestionImages(box) {
|
||||
box.querySelectorAll(".ai-question-item").forEach((item) => {
|
||||
item.addEventListener("toggle", () => {
|
||||
if (!item.open) return;
|
||||
item.querySelectorAll("img[data-ai-src]").forEach((image) => {
|
||||
image.src = image.dataset.aiSrc;
|
||||
image.removeAttribute("data-ai-src");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
return text.length > 90 ? `${text.slice(0, 90)}...` : text;
|
||||
}
|
||||
|
||||
async function sampleAudit() {
|
||||
@@ -854,7 +1127,7 @@ function renderAuditCurrent() {
|
||||
$("auditDetailMeta").textContent = `${record.source_file || ""} · ${record.major_department || ""}`;
|
||||
setPdfFrame("auditPdfFrame", record.pdf_url);
|
||||
$("auditReviewStrip").innerHTML = `
|
||||
<div class="strip-item"><span>复核状态</span>${statusTag(record.review_status)}</div>
|
||||
<div class="strip-item"><span>复核状态</span>${statusTag(displayStatusValue(record))}</div>
|
||||
<div class="strip-item"><span>抽查状态</span>${auditTag(item.audit_status || "pending")}</div>
|
||||
<div class="strip-item wide"><span>主要诊断</span><strong>${escapeHtml(record.primary_diagnosis || "无")}</strong></div>
|
||||
<div class="strip-item wide"><span>抽查来源</span><strong>${escapeHtml(item.audit_source || "")}</strong></div>
|
||||
@@ -958,10 +1231,107 @@ function aiAvailable() {
|
||||
return Boolean(state.ai?.available);
|
||||
}
|
||||
|
||||
function aiActionModes() {
|
||||
return {
|
||||
current: "default",
|
||||
five: "default",
|
||||
all: "default",
|
||||
...(state.ai?.ai_action_modes || {}),
|
||||
};
|
||||
}
|
||||
|
||||
function aiActionPrivacyModes() {
|
||||
const modes = {
|
||||
current: true,
|
||||
five: true,
|
||||
all: true,
|
||||
...(state.ai?.ai_action_privacy_modes || {}),
|
||||
};
|
||||
return {
|
||||
current: modes.current !== false,
|
||||
five: modes.five !== false,
|
||||
all: modes.all !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function aiAllowedScopes() {
|
||||
const modes = aiActionModes();
|
||||
return Object.keys(modes).filter((scope) => modes[scope] !== "off");
|
||||
}
|
||||
|
||||
function aiActionModeLabel(mode) {
|
||||
return (AI_ACTION_MODE_OPTIONS.find(([value]) => value === mode) || AI_ACTION_MODE_OPTIONS[1])[1];
|
||||
}
|
||||
|
||||
function aiActionOverride(scope) {
|
||||
const mode = aiActionModes()[scope] || "default";
|
||||
const override = { privacy_mode: aiActionPrivacyModes()[scope] !== false };
|
||||
if (mode === "k25") return { ...override, model: "kimi-k2.5", thinking_enabled: false };
|
||||
if (mode === "k25_thinking") return { ...override, model: "kimi-k2.5", thinking_enabled: true };
|
||||
if (mode === "k26") return { ...override, model: "kimi-k2.6", thinking_enabled: false };
|
||||
if (mode === "k26_thinking") return { ...override, model: "kimi-k2.6", thinking_enabled: true };
|
||||
return override;
|
||||
}
|
||||
|
||||
function renderAiActionModeOptions() {
|
||||
document.querySelectorAll("[data-ai-action-mode]").forEach((select) => {
|
||||
if (select.options.length) return;
|
||||
select.innerHTML = AI_ACTION_MODE_OPTIONS.map(([value, label]) => `<option value="${value}">${label}</option>`).join("");
|
||||
});
|
||||
}
|
||||
|
||||
function renderAiActionTable(kimi = state.ai || {}) {
|
||||
renderAiActionModeOptions();
|
||||
const modes = { current: "default", five: "default", all: "default", ...(kimi.ai_action_modes || {}) };
|
||||
const privacyModes = { current: true, five: true, all: true, ...(kimi.ai_action_privacy_modes || {}) };
|
||||
[
|
||||
["current", "aiActionModeCurrent", "aiActionPrivacyCurrent", "aiActionPreviewCurrent"],
|
||||
["five", "aiActionModeFive", "aiActionPrivacyFive", "aiActionPreviewFive"],
|
||||
["all", "aiActionModeAll", "aiActionPrivacyAll", "aiActionPreviewAll"],
|
||||
].forEach(([scope, selectId, privacyId, previewId]) => {
|
||||
const select = $(selectId);
|
||||
if (select) select.value = modes[scope] || "default";
|
||||
const privacy = $(privacyId);
|
||||
if (privacy) privacy.checked = privacyModes[scope] !== false;
|
||||
const preview = $(previewId);
|
||||
if (preview) {
|
||||
const mode = modes[scope] || "default";
|
||||
const privacyLabel = privacyModes[scope] === false ? "隐私关" : "隐私开";
|
||||
preview.textContent = mode === "off"
|
||||
? "复核页隐藏"
|
||||
: `${AI_ACTION_SCOPE_LABELS[scope]} 使用 ${aiActionModeLabel(mode)} · ${privacyLabel}`;
|
||||
}
|
||||
});
|
||||
renderAiPrivacyNote(kimi);
|
||||
}
|
||||
|
||||
function renderAiPrivacyNote(kimi = state.ai || {}) {
|
||||
const note = $("aiPrivacyNote");
|
||||
if (!note) return;
|
||||
const modes = { current: true, five: true, all: true, ...(kimi.ai_action_privacy_modes || {}) };
|
||||
const disabled = Object.entries(modes)
|
||||
.filter(([, enabled]) => enabled === false)
|
||||
.map(([scope]) => AI_ACTION_SCOPE_LABELS[scope] || scope);
|
||||
note.classList.toggle("is-warning", disabled.length > 0);
|
||||
note.textContent = disabled.length
|
||||
? `隐私模式按按钮生效:${disabled.join("、")} 已关闭,AI会上传基本信息、地址联系人等相关内容用于核验。`
|
||||
: "隐私模式全部开启:AI上传内容会排除基本信息、地址联系人、整页截图和PDF全文摘录。";
|
||||
}
|
||||
|
||||
function renderAiActions() {
|
||||
const actions = $("aiActions");
|
||||
if (!actions) return;
|
||||
actions.classList.toggle("is-hidden", !aiAvailable());
|
||||
const allowed = aiAvailable() ? aiAllowedScopes() : [];
|
||||
actions.classList.toggle("is-hidden", allowed.length === 0);
|
||||
[
|
||||
["current", "aiCurrentBtn"],
|
||||
["five", "aiFiveBtn"],
|
||||
["all", "aiAllBtn"],
|
||||
].forEach(([scope, id]) => {
|
||||
const button = $(id);
|
||||
if (!button) return;
|
||||
button.classList.toggle("is-hidden", !allowed.includes(scope));
|
||||
});
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
@@ -982,9 +1352,13 @@ function renderSettings() {
|
||||
$("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 || "";
|
||||
$("kimiApiKey").value = "";
|
||||
$("kimiApiKey").placeholder = kimi.api_key_configured ? "已配置,留空不变" : "粘贴 API Key";
|
||||
$("kimiConcurrency").value = kimi.concurrency || 3;
|
||||
$("kimiMeta").textContent = `${kimi.api_key_configured ? "API Key 已从 .env 读取" : "未配置 API Key"} · ${kimi.available ? "AI核验已启用" : "AI核验已关闭"} · 并发 ${kimi.concurrency || 3}`;
|
||||
$("kimiThinkingEnabled").checked = Boolean(kimi.thinking_enabled);
|
||||
const keySource = kimi.api_key_source === "settings" ? "设置" : kimi.api_key_source === "env" ? ".env" : "未配置";
|
||||
$("kimiMeta").textContent = `API Key:${keySource} · ${kimi.available ? "AI核验已启用" : "AI核验已关闭"} · 并发 ${kimi.concurrency || 3} · 默认Thinking ${kimi.thinking_enabled ? "开" : "关"}`;
|
||||
renderAiActionTable(kimi);
|
||||
$("settingsUserCount").textContent = `${settings.users.length} 个用户`;
|
||||
renderPermissionControls("newUserPermissions", settings.permission_labels || {}, {});
|
||||
$("userList").innerHTML = settings.users.map((user) => renderUserCard(user, settings.permission_labels || {})).join("");
|
||||
@@ -1007,8 +1381,19 @@ async function saveKimiSettings(event) {
|
||||
body: JSON.stringify({
|
||||
enabled: $("kimiEnabled").checked,
|
||||
model: $("kimiModel").value.trim(),
|
||||
api_base: $("kimiApiBase").value.trim(),
|
||||
api_key: $("kimiApiKey").value.trim(),
|
||||
concurrency: Number($("kimiConcurrency").value) || 3,
|
||||
ai_action_modes: {
|
||||
current: $("aiActionModeCurrent").value,
|
||||
five: $("aiActionModeFive").value,
|
||||
all: $("aiActionModeAll").value,
|
||||
},
|
||||
ai_action_privacy_modes: {
|
||||
current: $("aiActionPrivacyCurrent").checked,
|
||||
five: $("aiActionPrivacyFive").checked,
|
||||
all: $("aiActionPrivacyAll").checked,
|
||||
},
|
||||
thinking_enabled: $("kimiThinkingEnabled").checked,
|
||||
}),
|
||||
});
|
||||
state.settings = data;
|
||||
@@ -1019,6 +1404,7 @@ async function saveKimiSettings(event) {
|
||||
$("kimiMeta").textContent = error.message;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
renderFilterActions();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1028,17 +1414,51 @@ function setAiButtonsDisabled(disabled) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderAiProgress(job = state.aiJob) {
|
||||
const panel = $("aiProgressPanel");
|
||||
if (!panel) return;
|
||||
if (!job || (!job.running && !job.total && !job.message)) {
|
||||
panel.classList.add("is-hidden");
|
||||
return;
|
||||
}
|
||||
const total = Number(job.total || 0);
|
||||
const processed = Number(job.processed || 0);
|
||||
const percent = total ? Math.min(100, Math.round((processed / total) * 100)) : 0;
|
||||
panel.classList.remove("is-hidden");
|
||||
$("aiProgressTitle").textContent = job.running ? "AI处理中" : displayText(job.message || "AI处理完成");
|
||||
$("aiProgressText").textContent = total ? `${processed}/${total}` : "--";
|
||||
$("aiProgressBar").style.width = `${percent}%`;
|
||||
$("aiProgressMeta").textContent = `OK ${job.ok || 0} · 不OK ${job.pending || 0} · 失败 ${job.failed || 0} · 并发 ${job.concurrency || 1}`;
|
||||
$("aiCancelBtn").disabled = !job.running;
|
||||
}
|
||||
|
||||
async function cancelAiReview({ silent = false } = {}) {
|
||||
const job = await api("/api/ai/review/cancel", { method: "POST" });
|
||||
state.aiJob = job;
|
||||
state.aiJobOwned = false;
|
||||
renderAiProgress(job);
|
||||
setAiButtonsDisabled(Boolean(job.running));
|
||||
if (!silent) showMessage(displayText(job.message || "AI核验正在中断..."));
|
||||
return job;
|
||||
}
|
||||
|
||||
async function pollAiReviewJob() {
|
||||
const job = await api("/api/ai/review/status");
|
||||
state.aiJob = job;
|
||||
renderAiProgress(job);
|
||||
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}`, "");
|
||||
showMessage(`AI处理中:${processed}/${total},并发 ${job.concurrency || 1},OK ${job.ok || 0},不OK ${job.pending || 0},失败 ${job.failed || 0}`, "");
|
||||
if (job.running) {
|
||||
if (state.aiJobOwned && !job.cancel_requested && state.activePage !== "review") switchPage("review");
|
||||
setAiButtonsDisabled(true);
|
||||
state.aiPolling = setTimeout(() => pollAiReviewJob().catch((error) => showMessage(error.message, "error")), 2000);
|
||||
return;
|
||||
}
|
||||
state.aiPolling = null;
|
||||
state.aiJobOwned = false;
|
||||
setAiButtonsDisabled(false);
|
||||
showMessage(`AI核验完成:无问题 ${job.ok || 0},待确认 ${job.pending || 0},失败 ${job.failed || 0}`, job.failed ? "error" : "ok");
|
||||
showMessage(displayText(`${job.message || "AI处理完成"}:OK ${job.ok || 0},不OK ${job.pending || 0},失败 ${job.failed || 0}`), job.failed ? "error" : "ok");
|
||||
await loadStatus().catch(() => null);
|
||||
await loadOverview().catch(() => null);
|
||||
await loadRecords();
|
||||
@@ -1047,7 +1467,11 @@ async function pollAiReviewJob() {
|
||||
|
||||
async function runAiReview(scope) {
|
||||
if (!aiAvailable()) {
|
||||
showMessage("Kimi AI 核验未启用,请先在设置中开启", "error");
|
||||
showMessage("AI 核验未启用,请先在设置中开启", "error");
|
||||
return;
|
||||
}
|
||||
if (!aiAllowedScopes().includes(scope)) {
|
||||
showMessage("当前设置未开放这个 AI 处理范围", "error");
|
||||
return;
|
||||
}
|
||||
if ((scope === "current" || scope === "five") && !state.selectedId) return;
|
||||
@@ -1056,10 +1480,14 @@ async function runAiReview(scope) {
|
||||
setAiButtonsDisabled(true);
|
||||
showMessage("正在启动 AI 核验...");
|
||||
try {
|
||||
const override = aiActionOverride(scope);
|
||||
const job = await api("/api/ai/review", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ scope, record_id: state.selectedId }),
|
||||
body: JSON.stringify({ scope, record_id: state.selectedId, ...override }),
|
||||
});
|
||||
state.aiJob = job;
|
||||
state.aiJobOwned = true;
|
||||
renderAiProgress(job);
|
||||
showMessage(`AI核验已启动:0/${job.total || 0}`);
|
||||
await pollAiReviewJob();
|
||||
} catch (error) {
|
||||
@@ -1069,14 +1497,14 @@ async function runAiReview(scope) {
|
||||
}
|
||||
|
||||
async function approveAiNoIssueRecords() {
|
||||
if (!window.confirm("确认将所有“AI复核-无问题”批量标记为已人工复核?")) return;
|
||||
if (!window.confirm("确认将所有历史“AI已通过”批量标记为已人工复核?")) return;
|
||||
const button = $("approveAiNoIssueBtn");
|
||||
button.disabled = true;
|
||||
showMessage("正在批量确认 AI 无问题记录...");
|
||||
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");
|
||||
showMessage(`已批量确认 ${data.updated || 0} 条历史 AI 已通过记录`, "ok");
|
||||
await loadStatus().catch(() => null);
|
||||
await loadOverview().catch(() => null);
|
||||
await loadRecords();
|
||||
@@ -1347,6 +1775,7 @@ async function boot() {
|
||||
$("aiCurrentBtn").addEventListener("click", () => runAiReview("current"));
|
||||
$("aiFiveBtn").addEventListener("click", () => runAiReview("five"));
|
||||
$("aiAllBtn").addEventListener("click", () => runAiReview("all"));
|
||||
$("aiCancelBtn").addEventListener("click", () => cancelAiReview().catch((error) => showMessage(error.message, "error")));
|
||||
$("approveAiNoIssueBtn").addEventListener("click", approveAiNoIssueRecords);
|
||||
$("pdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP));
|
||||
$("pdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP));
|
||||
@@ -1365,8 +1794,44 @@ async function boot() {
|
||||
$("userForm").addEventListener("submit", createUser);
|
||||
$("systemSettingsForm").addEventListener("submit", saveSystemSettings);
|
||||
$("kimiSettingsForm").addEventListener("submit", saveKimiSettings);
|
||||
document.querySelectorAll("[data-ai-action-mode]").forEach((select) => {
|
||||
select.addEventListener("change", () => renderAiActionTable({
|
||||
...(state.ai || {}),
|
||||
ai_action_modes: {
|
||||
current: $("aiActionModeCurrent").value,
|
||||
five: $("aiActionModeFive").value,
|
||||
all: $("aiActionModeAll").value,
|
||||
},
|
||||
ai_action_privacy_modes: {
|
||||
current: $("aiActionPrivacyCurrent").checked,
|
||||
five: $("aiActionPrivacyFive").checked,
|
||||
all: $("aiActionPrivacyAll").checked,
|
||||
},
|
||||
}));
|
||||
});
|
||||
document.querySelectorAll("[data-ai-action-privacy]").forEach((input) => {
|
||||
input.addEventListener("change", () => renderAiActionTable({
|
||||
...(state.ai || {}),
|
||||
ai_action_modes: {
|
||||
current: $("aiActionModeCurrent").value,
|
||||
five: $("aiActionModeFive").value,
|
||||
all: $("aiActionModeAll").value,
|
||||
},
|
||||
ai_action_privacy_modes: {
|
||||
current: $("aiActionPrivacyCurrent").checked,
|
||||
five: $("aiActionPrivacyFive").checked,
|
||||
all: $("aiActionPrivacyAll").checked,
|
||||
},
|
||||
}));
|
||||
});
|
||||
$("submitReviewedBtn").addEventListener("click", submitReviewedRecords);
|
||||
$("statusCheckBtn").addEventListener("click", runStatusCheck);
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (!state.aiJob?.running || !state.aiJobOwned) return;
|
||||
fetch("/api/ai/review/cancel", { method: "POST", keepalive: true }).catch(() => null);
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
});
|
||||
wireJsonEditorButtons();
|
||||
try {
|
||||
const session = await api("/api/auth/me");
|
||||
@@ -1386,8 +1851,15 @@ async function loadWorkspace() {
|
||||
await loadStatus();
|
||||
await loadSchema();
|
||||
await loadAiConfig().catch(() => null);
|
||||
const job = await api("/api/ai/review/status").catch(() => null);
|
||||
if (job) {
|
||||
state.aiJob = job;
|
||||
state.aiJobOwned = false;
|
||||
renderAiProgress(job);
|
||||
}
|
||||
const page = canOpenPage(state.activePage) ? state.activePage : firstAllowedPage();
|
||||
switchPage(page);
|
||||
switchPage(job?.running && state.aiJobOwned && canOpenPage("review") ? "review" : page);
|
||||
if (job?.running) pollAiReviewJob().catch((error) => showMessage(error.message, "error"));
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
@@ -43,10 +43,10 @@
|
||||
</nav>
|
||||
<div class="status-grid" id="statusGrid">
|
||||
<div class="status-card"><span>数据库</span><strong id="dbState">检查中</strong></div>
|
||||
<div class="status-card"><span>记录总数</span><strong id="dbTotal">--</strong></div>
|
||||
<div class="status-card"><span>仅需复核</span><strong id="dbNeedsReview">--</strong></div>
|
||||
<div class="status-card"><span>AI无问题</span><strong id="dbAiPassed">--</strong></div>
|
||||
<div class="status-card"><span>AI待确认</span><strong id="dbAiPending">--</strong></div>
|
||||
<div class="status-card"><span>待复核总数</span><strong id="dbTotal">--</strong></div>
|
||||
<div class="status-card"><span>需复核</span><strong id="dbNeedsReview">--</strong></div>
|
||||
<div class="status-card"><span>AI已通过</span><strong id="dbAiPassed">--</strong></div>
|
||||
<div class="status-card"><span>AI建议复核</span><strong id="dbAiPending">--</strong></div>
|
||||
<div class="status-card"><span>已人工复核</span><strong id="dbReviewed">--</strong></div>
|
||||
</div>
|
||||
<div class="session-box">
|
||||
@@ -78,9 +78,9 @@
|
||||
<input id="searchInput" type="search" placeholder="搜索住院号、病案号、姓名、诊断">
|
||||
<select id="statusFilter">
|
||||
<option value="review_all">全部数据</option>
|
||||
<option value="needs_review">仅需复核</option>
|
||||
<option value="AI复核-无问题">仅AI复核-无问题</option>
|
||||
<option value="AI复核-待确认">仅AI复核-待确认</option>
|
||||
<option value="needs_review">需复核</option>
|
||||
<option value="ai_passed">AI已通过</option>
|
||||
<option value="AI已处理-不OK">AI建议复核</option>
|
||||
<option value="reviewed">已人工复核</option>
|
||||
</select>
|
||||
<div id="aiActions" class="ai-actions is-hidden">
|
||||
@@ -88,7 +88,16 @@
|
||||
<button id="aiFiveBtn" type="button">AI后5项</button>
|
||||
<button id="aiAllBtn" type="button">AI后全部</button>
|
||||
</div>
|
||||
<button id="approveAiNoIssueBtn" class="bulk-action is-hidden" type="button">复核并保存所有项</button>
|
||||
<div id="aiProgressPanel" class="ai-progress is-hidden">
|
||||
<div class="ai-progress-head">
|
||||
<strong id="aiProgressTitle">AI处理中</strong>
|
||||
<span id="aiProgressText">0/0</span>
|
||||
</div>
|
||||
<div class="ai-progress-track"><span id="aiProgressBar"></span></div>
|
||||
<div id="aiProgressMeta" class="ai-progress-meta">等待开始</div>
|
||||
<button id="aiCancelBtn" type="button">中止AI</button>
|
||||
</div>
|
||||
<button id="approveAiNoIssueBtn" class="bulk-action is-hidden" type="button">通过所有AI已通过</button>
|
||||
</div>
|
||||
<div class="record-list" id="recordList" tabindex="0"></div>
|
||||
</aside>
|
||||
@@ -131,6 +140,13 @@
|
||||
</summary>
|
||||
<div id="changeLogTable" class="data-table-wrap"></div>
|
||||
</details>
|
||||
<details class="review-log-panel collapsible-panel ai-question-panel">
|
||||
<summary class="section-head compact">
|
||||
<h2>AI提问问题</h2>
|
||||
<span id="aiQuestionCount">0 条</span>
|
||||
</summary>
|
||||
<div id="aiQuestionTable" class="data-table-wrap"></div>
|
||||
</details>
|
||||
<div class="save-message" id="saveMessage"></div>
|
||||
</aside>
|
||||
</main>
|
||||
@@ -243,17 +259,6 @@
|
||||
</form>
|
||||
</div>
|
||||
<div><span>检查计划</span><strong id="statusCheckMeta">尚未检查</strong></div>
|
||||
<div>
|
||||
<span>Kimi AI</span>
|
||||
<form id="kimiSettingsForm" class="status-check-form">
|
||||
<label class="toggle-field"><input id="kimiEnabled" type="checkbox">启用</label>
|
||||
<input id="kimiModel" type="text" placeholder="模型">
|
||||
<input id="kimiApiBase" type="url" placeholder="API Base">
|
||||
<input id="kimiConcurrency" type="number" min="1" max="6" value="3" aria-label="AI并发数">
|
||||
<button id="saveKimiSettingsBtn" type="submit">保存AI</button>
|
||||
</form>
|
||||
</div>
|
||||
<div><span>Kimi API</span><strong id="kimiMeta">未检查</strong></div>
|
||||
<div><span>复核工作台</span><strong>默认显示全部复核数据,仅使用 PDF 预览</strong></div>
|
||||
<div><span>保存动作</span><strong>写入字段、修改日志、reviewed 状态</strong></div>
|
||||
<div>
|
||||
@@ -264,6 +269,69 @@
|
||||
<div><span>日志表</span><strong>复核和抽查日志并入主表 JSONB</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="page-band settings-ai">
|
||||
<div class="section-head ai-section-head">
|
||||
<div class="ai-title-row">
|
||||
<h2>AI设置</h2>
|
||||
<label class="toggle-field ai-enable-toggle"><input id="kimiEnabled" type="checkbox">启用AI核验</label>
|
||||
</div>
|
||||
<span id="kimiMeta">未检查</span>
|
||||
</div>
|
||||
<form id="kimiSettingsForm" class="ai-settings-form">
|
||||
<label>
|
||||
<span>默认模型</span>
|
||||
<select id="kimiModel" aria-label="默认模型">
|
||||
<option value="kimi-k2.5">kimi-k2.5</option>
|
||||
<option value="kimi-k2.6">kimi-k2.6</option>
|
||||
<option value="moonshot-v1-32k-vision-preview">moonshot-v1-32k-vision-preview</option>
|
||||
<option value="moonshot-v1-128k-vision-preview">moonshot-v1-128k-vision-preview</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>API Key</span>
|
||||
<input id="kimiApiKey" type="password" autocomplete="new-password" placeholder="留空不变">
|
||||
</label>
|
||||
<label>
|
||||
<span>并发</span>
|
||||
<input id="kimiConcurrency" type="number" min="1" max="6" value="3" aria-label="AI并发数">
|
||||
</label>
|
||||
<label class="toggle-field"><input id="kimiThinkingEnabled" type="checkbox">Thinking</label>
|
||||
<button id="saveKimiSettingsBtn" type="submit">保存AI</button>
|
||||
</form>
|
||||
<div class="ai-action-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>复核页按钮</th>
|
||||
<th>使用方式</th>
|
||||
<th>隐私模式</th>
|
||||
<th>当前效果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>AI当前项</td>
|
||||
<td><select id="aiActionModeCurrent" data-ai-action-mode="current"></select></td>
|
||||
<td><label class="ai-privacy-toggle"><input id="aiActionPrivacyCurrent" type="checkbox" data-ai-action-privacy="current">开启</label></td>
|
||||
<td id="aiActionPreviewCurrent">--</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>AI后5项</td>
|
||||
<td><select id="aiActionModeFive" data-ai-action-mode="five"></select></td>
|
||||
<td><label class="ai-privacy-toggle"><input id="aiActionPrivacyFive" type="checkbox" data-ai-action-privacy="five">开启</label></td>
|
||||
<td id="aiActionPreviewFive">--</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>AI后全部</td>
|
||||
<td><select id="aiActionModeAll" data-ai-action-mode="all"></select></td>
|
||||
<td><label class="ai-privacy-toggle"><input id="aiActionPrivacyAll" type="checkbox" data-ai-action-privacy="all">开启</label></td>
|
||||
<td id="aiActionPreviewAll">--</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="aiPrivacyNote" class="ai-privacy-note">隐私模式全部开启:AI上传内容会排除基本信息、地址联系人、整页截图和PDF全文摘录。</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -524,6 +524,57 @@ textarea {
|
||||
background: #eef1f4;
|
||||
}
|
||||
|
||||
.ai-progress {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
border: 1px solid #9db7d8;
|
||||
border-radius: 7px;
|
||||
background: #f4f9ff;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ai-progress-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-progress-head strong {
|
||||
color: #174a83;
|
||||
}
|
||||
|
||||
.ai-progress-head span,
|
||||
.ai-progress-meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-progress-track {
|
||||
height: 9px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #dbe6f3;
|
||||
}
|
||||
|
||||
.ai-progress-track span {
|
||||
display: block;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #2474bd;
|
||||
transition: width .2s ease;
|
||||
}
|
||||
|
||||
.ai-progress button {
|
||||
min-height: 30px;
|
||||
border: 1px solid #9f6d20;
|
||||
border-radius: 6px;
|
||||
background: #fff4df;
|
||||
color: #744500;
|
||||
}
|
||||
|
||||
#pdfSubtitle {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
@@ -707,6 +758,13 @@ textarea {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.target-chips.is-ai-target button,
|
||||
.target-chips.is-ai-target span {
|
||||
border-color: #9bbff0;
|
||||
background: #f3f8ff;
|
||||
color: #1f5f9e;
|
||||
}
|
||||
|
||||
.target-summary ul {
|
||||
margin: 0;
|
||||
padding: 0 10px 10px 28px;
|
||||
@@ -715,6 +773,10 @@ textarea {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.target-summary ul.ai-note-list {
|
||||
color: #1f5f9e;
|
||||
}
|
||||
|
||||
#saveBtn,
|
||||
.login-panel button,
|
||||
.session-box button,
|
||||
@@ -830,6 +892,14 @@ textarea {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.field.is-ai-modified > span em {
|
||||
color: #1769c2;
|
||||
}
|
||||
|
||||
.field.is-manual-modified > span em {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.field.is-alert input,
|
||||
.field.is-alert select,
|
||||
.field.is-alert textarea,
|
||||
@@ -838,6 +908,34 @@ textarea {
|
||||
background: var(--focus-bg);
|
||||
}
|
||||
|
||||
.field.is-ai-modified input,
|
||||
.field.is-ai-modified select,
|
||||
.field.is-ai-modified textarea,
|
||||
.field.is-ai-modified .json-editor {
|
||||
border-color: #78aee8;
|
||||
background: #f2f8ff;
|
||||
}
|
||||
|
||||
.field.is-manual-modified input,
|
||||
.field.is-manual-modified select,
|
||||
.field.is-manual-modified textarea,
|
||||
.field.is-manual-modified .json-editor {
|
||||
border-color: #8bc8a8;
|
||||
background: #f2fbf6;
|
||||
}
|
||||
|
||||
.json-table td.is-ai-cell input {
|
||||
border-color: #5d9ee7;
|
||||
background: #edf6ff;
|
||||
box-shadow: inset 3px 0 0 #2d7ecb;
|
||||
}
|
||||
|
||||
.json-table td.is-manual-cell input {
|
||||
border-color: #6fbd91;
|
||||
background: #effaf4;
|
||||
box-shadow: inset 3px 0 0 #2b9a57;
|
||||
}
|
||||
|
||||
.json-editor {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
@@ -899,6 +997,106 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table tr.log-ai td {
|
||||
background: #f3f8ff;
|
||||
}
|
||||
|
||||
.data-table tr.log-manual td {
|
||||
background: #f3fbf6;
|
||||
}
|
||||
|
||||
.ai-question-item {
|
||||
border-top: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ai-question-item summary {
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
grid-template-columns: 140px 110px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 7px 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ai-question-item summary strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ai-question-item summary em {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.ai-question-body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.ai-question-body p {
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
|
||||
.ai-question-body pre {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
margin: 4px 0 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ai-question-images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.ai-question-figure {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-question-image-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 120px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.ai-question-image-frame img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ai-question-figure figcaption {
|
||||
padding: 6px 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.json-table input {
|
||||
min-width: 120px;
|
||||
border-color: #c8d0d8;
|
||||
@@ -929,6 +1127,9 @@ textarea {
|
||||
.review-log-panel {
|
||||
max-height: 230px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.review-log-panel.collapsible-panel {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -1025,6 +1226,29 @@ textarea {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-ai {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ai-section-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ai-title-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ai-enable-toggle {
|
||||
min-height: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) minmax(360px, 2fr) auto;
|
||||
@@ -1040,7 +1264,91 @@ textarea {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-check-form input {
|
||||
.ai-settings-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(170px, 1fr) minmax(260px, 1.4fr) 82px auto auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.ai-settings-form > label:not(.toggle-field) {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ai-settings-form > label > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-action-table {
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ai-action-table table {
|
||||
width: 100%;
|
||||
min-width: 760px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.ai-action-table th,
|
||||
.ai-action-table td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ai-action-table th {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.ai-action-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ai-action-table select {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.ai-privacy-toggle {
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-privacy-toggle input {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-privacy-note {
|
||||
margin-top: 8px;
|
||||
border: 1px solid #b6dec9;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
color: var(--ok);
|
||||
background: var(--ok-bg);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-privacy-note.is-warning {
|
||||
border-color: #e9c26f;
|
||||
color: var(--warn);
|
||||
background: var(--warn-bg);
|
||||
}
|
||||
|
||||
.status-check-form input,
|
||||
.status-check-form select,
|
||||
.ai-settings-form input,
|
||||
.ai-settings-form select {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
@@ -1174,7 +1482,8 @@ textarea {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.status-check-form {
|
||||
.status-check-form,
|
||||
.ai-settings-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
ports:
|
||||
- "8501:8501"
|
||||
volumes:
|
||||
- ../已处理-患者首页PDF/2026_5_25_处理:/data/pdfs:ro
|
||||
- ${PDF_DIR_HOST:-../已处理-患者首页PDF}:/data/pdfs:ro
|
||||
- review-settings:/app/settings
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -26,11 +26,11 @@ 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_PG_TABLE = os.environ.get("PGTABLE") or os.environ.get("PG_PATIENT_TABLE", "")
|
||||
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")
|
||||
DEFAULT_MINERU_URL = os.environ.get("MINERU_URL", "")
|
||||
|
||||
|
||||
CSV_COLUMNS = [
|
||||
@@ -507,6 +507,8 @@ 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
|
||||
if not args.mineru_url:
|
||||
raise RuntimeError("缺少 Mineru API 服务地址:请设置 MINERU_URL 或传入 --mineru-url")
|
||||
client_path = args.mineru_client.resolve()
|
||||
if not client_path.exists():
|
||||
raise RuntimeError(f"未找到 Mineru 客户端:{client_path}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从 PostgreSQL 导出患者首页复核表,并把人工复核结果回写到 Patient_FrontPages。
|
||||
从 PostgreSQL 导出患者首页复核表,并把人工复核结果回写到目标首页表。
|
||||
|
||||
依赖系统命令 psql;数据库密码通过 PGPASSWORD 环境变量或 --pg-password 传入。
|
||||
"""
|
||||
@@ -20,7 +20,7 @@ from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_OUTPUT = PROJECT_ROOT / "数据处理结果区" / "04_复核与人工校正" / "患者首页_人工复核表.csv"
|
||||
DEFAULT_TABLE = "Patient_FrontPages"
|
||||
DEFAULT_TABLE = os.environ.get("PGTABLE") or os.environ.get("PG_PATIENT_TABLE", "")
|
||||
|
||||
|
||||
def quote_pg_identifier(identifier: str) -> str:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
对 Patient_FrontPages 做字段级体检。
|
||||
对目标首页表做字段级体检。
|
||||
|
||||
输出:
|
||||
- 数据处理结果区/05_质量体检/患者首页_数据库体检报告.txt
|
||||
@@ -23,7 +23,7 @@ from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "数据处理结果区" / "05_质量体检"
|
||||
DEFAULT_TABLE = "Patient_FrontPages"
|
||||
DEFAULT_TABLE = os.environ.get("PGTABLE") or os.environ.get("PG_PATIENT_TABLE", "")
|
||||
|
||||
|
||||
def quote_pg_identifier(identifier: str) -> str:
|
||||
@@ -151,7 +151,7 @@ WHERE n.nspname = 'public'
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="核验 Patient_FrontPages 字段注释、空值和疑似错位记录。")
|
||||
parser = argparse.ArgumentParser(description="核验目标首页表字段注释、空值和疑似错位记录。")
|
||||
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")
|
||||
|
||||
@@ -17,15 +17,18 @@ def main():
|
||||
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("-u", "--url", type=str, default=os.environ.get("MINERU_URL", ""),
|
||||
help="API 服务端地址,也可用 MINERU_URL")
|
||||
parser.add_argument("--sync", action="store_true",
|
||||
help="任务完成后是否开启反向同步清理(删除多余的输出文件夹)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 使用解析后的参数
|
||||
url = args.url
|
||||
url = args.url
|
||||
if not url:
|
||||
print("错误: 缺少 API 服务端地址,请设置 MINERU_URL 或传入 --url")
|
||||
exit(1)
|
||||
source_dir = args.source
|
||||
target_dir = args.target
|
||||
|
||||
|
||||
Reference in New Issue
Block a user