From 5d96c1de6d4a8550b5829cfe1de623fa06da5ea6 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Wed, 27 May 2026 00:21:16 +0800 Subject: [PATCH] Add per-action AI privacy mode --- 患者首页处理/.env.example | 21 + 患者首页处理/.gitignore | 22 +- 患者首页处理/README.md | 53 +- 患者首页处理/工作流_Gitea版.md | 71 +- 患者首页处理/数据可视化网页端/.env.example | 14 +- 患者首页处理/数据可视化网页端/README.md | 9 +- 患者首页处理/数据可视化网页端/app/main.py | 1077 ++++++++++++++--- .../数据可视化网页端/app/static/app.js | 620 ++++++++-- .../数据可视化网页端/app/static/index.html | 106 +- .../数据可视化网页端/app/static/styles.css | 313 ++++- .../数据可视化网页端/docker-compose.yml | 2 +- .../02_解析入库/02_患者首页PDF解析与入库.py | 6 +- .../03_人工复核/03_人工复核导出与回写.py | 4 +- .../04_质量体检/04_字段核验与数据库体检.py | 6 +- .../05_备用读取/05_备用PDF转Markdown_Mineru.py | 9 +- 15 files changed, 1993 insertions(+), 340 deletions(-) create mode 100644 患者首页处理/.env.example diff --git a/患者首页处理/.env.example b/患者首页处理/.env.example new file mode 100644 index 0000000..57698eb --- /dev/null +++ b/患者首页处理/.env.example @@ -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 diff --git a/患者首页处理/.gitignore b/患者首页处理/.gitignore index 4c55e29..d19aa92 100644 --- a/患者首页处理/.gitignore +++ b/患者首页处理/.gitignore @@ -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 diff --git a/患者首页处理/README.md b/患者首页处理/README.md index ca289ed..3f1ced3 100644 --- a/患者首页处理/README.md +++ b/患者首页处理/README.md @@ -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` 的默认住院号,后续格式问题交由患者目录核验网页端处理。 diff --git a/患者首页处理/工作流_Gitea版.md b/患者首页处理/工作流_Gitea版.md index 03ba9c9..4fae15c 100644 --- a/患者首页处理/工作流_Gitea版.md +++ b/患者首页处理/工作流_Gitea版.md @@ -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 ``` diff --git a/患者首页处理/数据可视化网页端/.env.example b/患者首页处理/数据可视化网页端/.env.example index de4e941..b83bced 100644 --- a/患者首页处理/数据可视化网页端/.env.example +++ b/患者首页处理/数据可视化网页端/.env.example @@ -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 diff --git a/患者首页处理/数据可视化网页端/README.md b/患者首页处理/数据可视化网页端/README.md index 0ff9f0a..3d46904 100644 --- a/患者首页处理/数据可视化网页端/README.md +++ b/患者首页处理/数据可视化网页端/README.md @@ -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 核验是否上传敏感区域由对应按钮的隐私模式控制。 diff --git a/患者首页处理/数据可视化网页端/app/main.py b/患者首页处理/数据可视化网页端/app/main.py index b909ea3..616dfbc 100644 --- a/患者首页处理/数据可视化网页端/app/main.py +++ b/患者首页处理/数据可视化网页端/app/main.py @@ -24,7 +24,7 @@ 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 pydantic import BaseModel, Field from psycopg2 import sql @@ -40,13 +40,13 @@ def env(name: str, default: str = "") -> str: DB_CONFIG = { - "host": env("PGHOST", "DB_HOST"), + "host": env("PGHOST"), "port": int(env("PGPORT", "5432")), - "dbname": env("PGDATABASE", "DB_NAME"), - "user": env("PGUSER", "DB_USER"), + "dbname": env("PGDATABASE"), + "user": env("PGUSER"), "password": env("PGPASSWORD"), } -PGTABLE = env("PGTABLE", "Patient_FrontPages") +PGTABLE = env("PGTABLE") 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() @@ -208,12 +208,20 @@ class KimiSettingsPayload(BaseModel): enabled: bool = True model: str = "" api_base: str = "" + api_key: str = "" concurrency: int = 3 + thinking_enabled: bool = False + ai_scope_mode: str = "all" + ai_action_modes: dict[str, str] = Field(default_factory=dict) + ai_action_privacy_modes: dict[str, bool] = Field(default_factory=dict) class AiReviewPayload(BaseModel): scope: str = "current" record_id: int | None = None + model: str = "" + thinking_enabled: bool | None = None + privacy_mode: bool | None = None app = FastAPI(title="Patient Front Page Visual Review") @@ -225,6 +233,7 @@ WORKFLOW_READY = False AI_JOB_LOCK = threading.Lock() AI_REVIEW_JOB: dict[str, Any] = { "running": False, + "cancel_requested": False, "scope": "", "total": 0, "processed": 0, @@ -237,12 +246,17 @@ AI_REVIEW_JOB: dict[str, Any] = { "started_at": "", "finished_at": "", "last_record_id": None, + "privacy_mode": True, } 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_OK_STATUS = "auto_pass" +AI_PROBLEM_STATUS = "AI已处理-不OK" +AI_NO_ISSUE_STATUS = AI_OK_STATUS +AI_PENDING_STATUS = AI_PROBLEM_STATUS +LEGACY_AI_OK_STATUS = "AI复核-无问题" +LEGACY_AI_PENDING_STATUS = "AI复核-待确认" AI_CONFIRMED_PROBLEM_KEYWORDS = ( "缺少", "缺失", @@ -260,6 +274,40 @@ AI_CONFIRMED_PROBLEM_KEYWORDS = ( "待确认", ) AI_CONFIRMED_PROBLEM_QUALIFIERS = ("确实", "证实", "属实", "仍", "依然", "存在", "需要", "需人工", "待确认") +AI_UNRESOLVED_PROBLEM_MARKERS = ( + "需人工", + "需要人工", + "待确认", + "需确认", + "需补录", + "需补充", + "建议补录", + "建议补充", + "漏填", + "缺失需", + "空白需", + "截断需", + "需补全", + "不清晰", + "不一致", +) +AI_FORCE_PROBLEM_MARKERS = ( + "需人工", + "需要人工", + "待确认", + "需确认", + "需补录", + "需补充", + "建议补录", + "建议补充", + "漏填", + "缺失需", + "空白需", + "截断需", + "需补全", +) +AI_FIXED_MARKERS = ("已修正", "已补齐", "已更正", "已改为", "已填入", "已补全") +AI_NO_REVIEW_MARKERS = ("无需", "无须", "不需", "不用", "无需补录", "无须补录", "无需处理", "原貌") AI_STOP_ERROR_MARKERS = ( "exceeded_current_quota_error", "insufficient_quota", @@ -270,6 +318,32 @@ AI_STOP_ERROR_MARKERS = ( "额度", ) AI_JOB_ERROR_LIMIT = 50 +AI_SAFE_MODULE_NAMES = {"诊断表格", "手术表格", "离院费用"} +AI_REDACTED = "[已脱敏]" +AI_REDACT_PATTERNS = ( + (re.compile(r"ZY\d{6,}", re.IGNORECASE), "[住院号]"), + (re.compile(r"\b\d{15,17}[\dXx]\b"), "[身份证号]"), + (re.compile(r"(? sql.Composable: def connect(): + missing = [name for name, value in { + "PGHOST": DB_CONFIG["host"], + "PGDATABASE": DB_CONFIG["dbname"], + "PGUSER": DB_CONFIG["user"], + "PGPASSWORD": DB_CONFIG["password"], + "PGTABLE": PGTABLE, + }.items() if not value] + if missing: + raise RuntimeError("缺少 PostgreSQL 连接配置:" + "、".join(missing)) return psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.RealDictCursor) @@ -492,7 +575,10 @@ def normalize_status_check_time(value: str) -> str: return f"{int(match.group(1)):02d}:{match.group(2)}" -def kimi_api_key() -> str: +def kimi_api_key(kimi: dict[str, Any] | None = None) -> str: + local_key = str((kimi or {}).get("api_key") or "").strip() + if local_key: + return local_key return env("MOONSHOT_API_KEY") or env("KIMI_API_KEY") @@ -504,12 +590,112 @@ def normalize_kimi_concurrency(value: Any) -> int: return max(1, min(concurrency, 6)) +def normalize_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {"1", "true", "yes", "on", "启用", "是"} + + +def normalize_kimi_ai_scope_mode(value: Any, default: str = "all") -> str: + aliases = { + "off": "off", + "none": "off", + "disabled": "off", + "关闭": "off", + "current": "current", + "single": "current", + "仅当前项": "current", + "five": "five", + "5": "five", + "current_five": "five", + "当前项和后5项": "five", + "all": "all", + "全部": "all", + } + mode = aliases.get(str(value or "").strip().lower(), "") + return mode or (default if default in {"off", "current", "five", "all"} else "all") + + +def normalize_ai_action_mode(value: Any, default: str = "default") -> str: + aliases = { + "off": "off", + "关闭": "off", + "disabled": "off", + "default": "default", + "follow": "default", + "跟随默认模型": "default", + "k25": "k25", + "kimi-k2.5": "k25", + "k25_thinking": "k25_thinking", + "kimi-k2.5-thinking": "k25_thinking", + "k26": "k26", + "kimi-k2.6": "k26", + "k26_thinking": "k26_thinking", + "kimi-k2.6-thinking": "k26_thinking", + } + mode = aliases.get(str(value or "").strip().lower(), "") + return mode or (default if default in {"off", "default", "k25", "k25_thinking", "k26", "k26_thinking"} else "default") + + +def default_ai_action_modes() -> dict[str, str]: + return {"current": "default", "five": "default", "all": "default"} + + +def normalize_ai_action_modes(value: Any) -> dict[str, str]: + defaults = default_ai_action_modes() + if not isinstance(value, dict): + return defaults + return {scope: normalize_ai_action_mode(value.get(scope), defaults[scope]) for scope in defaults} + + +def default_ai_action_privacy_modes() -> dict[str, bool]: + return {"current": True, "five": True, "all": True} + + +def normalize_ai_action_privacy_modes(value: Any) -> dict[str, bool]: + defaults = default_ai_action_privacy_modes() + if not isinstance(value, dict): + return defaults + return {scope: normalize_bool(value.get(scope), defaults[scope]) for scope in defaults} + + +def ai_action_mode_to_override(mode: str) -> dict[str, Any]: + normalized = normalize_ai_action_mode(mode) + if normalized == "k25": + return {"model": "kimi-k2.5", "thinking_enabled": False} + if normalized == "k25_thinking": + return {"model": "kimi-k2.5", "thinking_enabled": True} + if normalized == "k26": + return {"model": "kimi-k2.6", "thinking_enabled": False} + if normalized == "k26_thinking": + return {"model": "kimi-k2.6", "thinking_enabled": True} + return {} + + +def ai_scope_allowed(mode: str, scope: str) -> bool: + mode = normalize_kimi_ai_scope_mode(mode) + if mode == "all": + return scope in {"current", "five", "fifty", "all", "ai_pending"} + if mode == "five": + return scope in {"current", "five"} + if mode == "current": + return scope == "current" + return False + + def default_kimi_settings() -> dict[str, Any]: return { "enabled": bool(kimi_api_key()), "api_base": DEFAULT_KIMI_API_BASE, + "api_key": "", "model": DEFAULT_KIMI_MODEL, "concurrency": normalize_kimi_concurrency(env("KIMI_CONCURRENCY", "3")), + "thinking_enabled": normalize_bool(env("KIMI_THINKING_ENABLED", "false")), + "ai_scope_mode": normalize_kimi_ai_scope_mode(env("KIMI_AI_SCOPE_MODE", "all")), + "ai_action_modes": default_ai_action_modes(), + "ai_action_privacy_modes": default_ai_action_privacy_modes(), } @@ -520,18 +706,33 @@ def normalize_kimi_settings(kimi: dict[str, Any]) -> dict[str, Any]: return { "enabled": bool(kimi.get("enabled", defaults["enabled"])), "api_base": api_base or DEFAULT_KIMI_API_BASE, + "api_key": str(kimi.get("api_key") or defaults.get("api_key") or "").strip(), "model": model or DEFAULT_KIMI_MODEL, "concurrency": normalize_kimi_concurrency(kimi.get("concurrency", defaults["concurrency"])), + "thinking_enabled": normalize_bool(kimi.get("thinking_enabled"), defaults["thinking_enabled"]), + "ai_scope_mode": normalize_kimi_ai_scope_mode(kimi.get("ai_scope_mode"), defaults["ai_scope_mode"]), + "ai_action_modes": normalize_ai_action_modes(kimi.get("ai_action_modes") or defaults["ai_action_modes"]), + "ai_action_privacy_modes": normalize_ai_action_privacy_modes( + kimi.get("ai_action_privacy_modes") or defaults["ai_action_privacy_modes"] + ), } 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()) + local_key_configured = bool(settings.get("api_key")) + env_key_configured = bool(kimi_api_key()) + settings.pop("api_key", None) + settings["api_key_configured"] = local_key_configured or env_key_configured + settings["api_key_source"] = "settings" if local_key_configured else "env" if env_key_configured else "" settings["available"] = settings["enabled"] and settings["api_key_configured"] return settings +def model_supports_thinking(model: str) -> bool: + return str(model or "").startswith("kimi-k2.") + + def status_now() -> datetime: return datetime.now(APP_TIMEZONE) @@ -572,6 +773,7 @@ def default_status_snapshot() -> dict[str, Any]: "pdf_dir": str(PDF_DIR), "pdf_count": None, "total": None, + "workbench_total": None, "review_needed": None, "needs_review": None, "auto_passed": None, @@ -605,11 +807,18 @@ def compute_status_snapshot(source: str = "manual") -> dict[str, Any]: """ SELECT count(*) AS total, - count(*) FILTER (WHERE review_status IN ('needs_review', 'AI复核-待确认')) AS review_needed, + count(*) FILTER (WHERE review_status IN ('needs_review', 'reviewed', 'AI已处理-OK', 'AI已处理-不OK', 'AI复核-无问题', 'AI复核-待确认') OR ( + review_status = 'auto_pass' + AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI')) + )) AS workbench_total, + count(*) FILTER (WHERE review_status IN ('needs_review', 'AI已处理-不OK', '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 = 'auto_pass' + AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI')) + ) OR review_status IN ('AI已处理-OK', 'AI复核-无问题')) AS ai_passed, + count(*) FILTER (WHERE review_status IN ('AI已处理-不OK', '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 @@ -1484,6 +1693,61 @@ def insert_review_log( ) +def json_changed_fields(field: str, old_value: Any, new_value: Any) -> list[dict[str, Any]]: + label = FIELD_META[field]["label"] + if isinstance(old_value, list) or isinstance(new_value, list): + old_rows = old_value if isinstance(old_value, list) else [] + new_rows = new_value if isinstance(new_value, list) else [] + columns: list[str] = [] + for row in [*old_rows, *new_rows]: + if isinstance(row, dict): + for key in row: + if key not in columns: + columns.append(str(key)) + entries: list[dict[str, Any]] = [] + for index in range(max(len(old_rows), len(new_rows))): + old_row = old_rows[index] if index < len(old_rows) and isinstance(old_rows[index], dict) else {} + new_row = new_rows[index] if index < len(new_rows) and isinstance(new_rows[index], dict) else {} + for column in columns: + old_cell = old_row.get(column, "") + new_cell = new_row.get(column, "") + if comparable(old_cell) == comparable(new_cell): + continue + entries.append( + { + "field": f"{field}[{index}].{column}", + "label": f"{label}[{index + 1}].{column}", + "old": json_ready_deep(old_cell), + "new": json_ready_deep(new_cell), + } + ) + return entries + + if isinstance(old_value, dict) or isinstance(new_value, dict): + old_dict = old_value if isinstance(old_value, dict) else {} + new_dict = new_value if isinstance(new_value, dict) else {} + keys = list(dict.fromkeys([*old_dict.keys(), *new_dict.keys()])) + return [ + { + "field": f"{field}.{key}", + "label": f"{label}.{key}", + "old": json_ready_deep(old_dict.get(key, "")), + "new": json_ready_deep(new_dict.get(key, "")), + } + for key in keys + if comparable(old_dict.get(key, "")) != comparable(new_dict.get(key, "")) + ] + + return [ + { + "field": field, + "label": label, + "old": json_ready_deep(old_value), + "new": json_ready_deep(new_value), + } + ] + + def build_record_updates( record_id: int, fields: dict[str, Any], @@ -1528,6 +1792,9 @@ def build_record_updates( old_value = before.get(field) if comparable(old_value) == comparable(value): continue + if field in JSON_FIELDS: + changed_fields.extend(json_changed_fields(field, old_value, value)) + continue changed_fields.append( { "field": field, @@ -1624,15 +1891,10 @@ def parse_json_content(content: str) -> Any: 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], - } + raise ValueError(f"AI 返回 JSON 无法解析:{exc.msg}") -def render_pdf_page_data_url(pdf_path: Path, dpi: int = 96, page_index: int = 0, clip: Any | None = None) -> str: +def render_pdf_page_png(pdf_path: Path, dpi: int = 96, page_index: int = 0, clip: Any | None = None) -> bytes: try: import fitz # type: ignore[import-not-found] except ImportError as exc: @@ -1644,7 +1906,11 @@ def render_pdf_page_data_url(pdf_path: Path, dpi: int = 96, page_index: int = 0, 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 pixmap.tobytes("png") + + +def render_pdf_page_data_url(pdf_path: Path, dpi: int = 96, page_index: int = 0, clip: Any | None = None) -> str: + encoded = base64.b64encode(render_pdf_page_png(pdf_path, dpi=dpi, page_index=page_index, clip=clip)).decode("utf-8") return f"data:image/png;base64,{encoded}" @@ -1663,16 +1929,131 @@ def record_review_text(record: dict[str, Any]) -> str: return ";".join(parts) -def relevant_pdf_modules(record: dict[str, Any]) -> list[dict[str, Any]]: +def relevant_pdf_modules(record: dict[str, Any], privacy_mode: bool = True) -> 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 privacy_mode and module["name"] not in AI_SAFE_MODULE_NAMES: + continue 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] + default_names = {"诊断表格", "手术表格"} if privacy_mode else {module["name"] for module in PDF_MODULE_DEFINITIONS} + selected = [module for module in PDF_MODULE_DEFINITIONS if module["name"] in default_names] + return selected[: 4 if privacy_mode else 5] + + +def redact_text_for_ai(text: str) -> str: + redacted = str(text or "") + for pattern, replacement in AI_REDACT_PATTERNS: + redacted = pattern.sub(replacement, redacted) + return redacted + + +def redact_for_ai(value: Any) -> Any: + if isinstance(value, str): + return redact_text_for_ai(value) + if isinstance(value, list): + return [redact_for_ai(item) for item in value] + if isinstance(value, dict): + return {str(key): redact_for_ai(item) for key, item in value.items()} + return value + + +def upload_text_for_ai(text: str, privacy_mode: bool = True) -> str: + return redact_text_for_ai(text) if privacy_mode else str(text or "") + + +def upload_value_for_ai(value: Any, privacy_mode: bool = True) -> Any: + return redact_for_ai(value) if privacy_mode else json_ready_deep(value) + + +def privacy_excluded_review(record: dict[str, Any]) -> bool: + normalized = normalize_pdf_text(record_review_text(record)) + excluded_modules = [module for module in PDF_MODULE_DEFINITIONS if module["name"] not in AI_SAFE_MODULE_NAMES] + has_excluded = any( + normalize_pdf_text(keyword) in normalized + for module in excluded_modules + for keyword in module["note_keywords"] + ) + has_safe = any( + normalize_pdf_text(keyword) in normalized + for module in PDF_MODULE_DEFINITIONS + if module["name"] in AI_SAFE_MODULE_NAMES + for keyword in module["note_keywords"] + ) + return has_excluded and not has_safe + + +def admission_path_crop_y(blocks: list[dict[str, Any]], page_index: int) -> float | None: + candidates: list[float] = [] + for block in blocks: + if block["page_index"] != page_index: + continue + normalized = normalize_pdf_text(block["text"]) + has_admission_path = "入院途径" in normalized + has_path_options = "急诊" in normalized and "门诊" in normalized and "其他医疗机构转入" in normalized + if has_admission_path or has_path_options: + candidates.append(float(block["rect"].y0)) + return min(candidates) if candidates else None + + +def apply_ai_image_privacy_clip(page: Any, clip: Any, blocks: list[dict[str, Any]], page_index: int) -> Any | None: + if page_index != 0: + return clip + crop_y = admission_path_crop_y(blocks, page_index) + if crop_y is None: + return None + protected_y0 = max(float(clip.y0), crop_y - 16) + if float(clip.y1) - protected_y0 < 80: + return None + return type(clip)(clip.x0, protected_y0, clip.x1, clip.y1) + + +def fallback_privacy_page_context( + pdf_path: Path, + document: Any, + text_blocks: list[dict[str, Any]], + crop_blocks: list[dict[str, Any]], +) -> dict[str, Any] | None: + if document.page_count < 1: + return None + page_index = 0 + page = document.load_page(page_index) + crop_y = admission_path_crop_y(crop_blocks, page_index) + if crop_y is None: + return None + clip = type(page.rect)( + max(page.rect.x0, page.rect.x0 + 18), + max(page.rect.y0, crop_y - 16), + min(page.rect.x1, page.rect.x1 - 18), + page.rect.y1, + ) + if float(clip.y1) - float(clip.y0) < 120: + return None + return { + "name": "隐私裁剪首页", + "page": 1, + "bbox": [round(clip.x0, 1), round(clip.y0, 1), round(clip.x1, 1), round(clip.y1, 1)], + "text": redact_text_for_ai(clip_text_from_blocks(text_blocks, page_index, clip)), + "image_url": render_pdf_page_data_url(pdf_path, dpi=120, page_index=page_index, clip=clip), + } + + +def fallback_unrestricted_page_context(pdf_path: Path, document: Any, text_blocks: list[dict[str, Any]]) -> dict[str, Any] | None: + if document.page_count < 1: + return None + page_index = 0 + page = document.load_page(page_index) + clip = page.rect + return { + "name": "整页首页", + "page": 1, + "bbox": [round(clip.x0, 1), round(clip.y0, 1), round(clip.x1, 1), round(clip.y1, 1)], + "text": clip_text_from_blocks(text_blocks, page_index, clip), + "image_url": render_pdf_page_data_url(pdf_path, dpi=110, page_index=page_index, clip=clip), + } def clip_text_from_blocks(blocks: list[dict[str, Any]], page_index: int, clip: Any) -> str: @@ -1689,12 +2070,39 @@ def clip_text_from_blocks(blocks: list[dict[str, Any]], page_index: int, clip: A return joined[:2200] +def page_line_blocks(page: Any, page_index: int) -> list[dict[str, Any]]: + lines: list[dict[str, Any]] = [] + page_dict = page.get_text("dict") + for block in page_dict.get("blocks", []): + for line in block.get("lines", []): + spans = line.get("spans") or [] + text = "".join(str(span.get("text") or "") for span in spans).strip() + bbox = line.get("bbox") + if not text or not bbox: + continue + lines.append( + { + "page_index": page_index, + "rect": type(page.rect)(bbox), + "text": text, + "normalized": normalize_pdf_text(text), + } + ) + return lines + + 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) + for segment in re.split(r"[、,,;;\s]+", text): + if len(segment) >= 4: + keywords.append(segment) + compact = normalize_pdf_text(text) + if len(compact) >= 12: + keywords.extend([compact[:10], compact[-10:]]) def record_module_keywords(record: dict[str, Any], module_name: str) -> list[str]: @@ -1727,18 +2135,22 @@ def record_module_keywords(record: dict[str, Any], module_name: str) -> list[str return keywords -def extract_pdf_module_context(pdf_path: Path, record: dict[str, Any]) -> dict[str, Any]: +def extract_pdf_module_context(pdf_path: Path, record: dict[str, Any], privacy_mode: bool = True) -> 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) + modules = relevant_pdf_modules(record, privacy_mode=privacy_mode) + privacy_skipped = privacy_mode and privacy_excluded_review(record) contexts: list[dict[str, Any]] = [] all_blocks: list[dict[str, Any]] = [] + all_lines: list[dict[str, Any]] = [] + full_text_excerpt = "" with fitz.open(str(pdf_path)) as document: for page_index in range(document.page_count): page = document.load_page(page_index) + all_lines.extend(page_line_blocks(page, page_index)) for block in page.get_text("blocks"): if len(block) < 5: continue @@ -1775,102 +2187,105 @@ def extract_pdf_module_context(pdf_path: Path, record: dict[str, Any]) -> dict[s min(page.rect.x1, page.rect.x1 - 18), min(page.rect.y1, max(max_y + tail, min_y + 220)), ) + if privacy_mode: + clip = apply_ai_image_privacy_clip(page, clip, all_lines, page_index) + if clip is None: + continue 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), + "text": upload_text_for_ai(clip_text_from_blocks(all_blocks, page_index, clip), privacy_mode=privacy_mode), "image_url": render_pdf_page_data_url(pdf_path, dpi=120, page_index=page_index, clip=clip), } ) + if privacy_mode and not contexts and not privacy_skipped: + fallback_context = fallback_privacy_page_context(pdf_path, document, all_blocks, all_lines) + if fallback_context: + contexts.append(fallback_context) + if not privacy_mode: + full_text_excerpt = "\n".join(" ".join(str(block["text"]).split()) for block in all_blocks)[:9000] + if not contexts: + fallback_context = fallback_unrestricted_page_context(pdf_path, document, all_blocks) + if fallback_context: + contexts.append(fallback_context) - full_text = "\n".join(" ".join(str(block["text"]).split()) for block in all_blocks) return { "modules": contexts, - "full_text_excerpt": full_text[:3500], + "full_text_excerpt": full_text_excerpt, + "privacy_skipped": privacy_skipped, } -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 ai_record_snapshot(record: dict[str, Any], privacy_mode: bool = True) -> dict[str, Any]: + if privacy_mode: + return {key: upload_value_for_ai(record.get(key), privacy_mode=True) for key in AI_UPLOAD_FIELDS} + snapshot: dict[str, Any] = {} + for group in FIELD_GROUPS: + for name, _label, _field_type, _options in group["fields"]: + snapshot[name] = upload_value_for_ai(record.get(name), privacy_mode=False) + for key in ("review_status", "quality_status", "quality_notes", "review_notes", "auto_corrections"): + snapshot[key] = upload_value_for_ai(record.get(key), privacy_mode=False) + return snapshot -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) +def build_ai_prompt(record: dict[str, Any], pdf_context: dict[str, Any], privacy_mode: bool = True) -> str: + snapshot = json.dumps(ai_record_snapshot(record, privacy_mode=privacy_mode), ensure_ascii=False, indent=2) context_for_prompt = { "modules": [ { "name": item["name"], "page": item["page"], "bbox": item["bbox"], - "pdf_text": item["text"], + "pdf_text": upload_text_for_ai(item["text"], privacy_mode=privacy_mode), } for item in pdf_context.get("modules", []) ], - "full_text_excerpt": pdf_context.get("full_text_excerpt", ""), + "full_text_excerpt": upload_text_for_ai(pdf_context.get("full_text_excerpt", ""), privacy_mode=privacy_mode), + "privacy_mode": "on" if privacy_mode else "off", + "excluded": ( + "姓名、住院号、病案号、身份证、电话、地址、基本信息、地址联系人、整页截图、全文摘录均不上传" + if privacy_mode + else "未启用隐私模式;可上传基本信息、地址联系人、相关局部截图和PDF文本摘录用于核验" + ), } context_json = json.dumps(context_for_prompt, ensure_ascii=False, indent=2) + mode_instruction = ( + "当前为隐私模式:只上传诊断表格、手术表格、离院费用的脱敏文本和局部截图;不上传基本信息、地址联系人、整页截图或 PDF 全文摘录。" + if privacy_mode + else "当前未启用隐私模式:允许上传基本信息、地址联系人、诊断、手术、离院费用等相关 PDF 文本和局部截图;可对所有结构化字段提出修正。" + ) return f""" 请对这份住院病案首页做视觉核验,只返回 JSON,不要输出 Markdown。 -核验目标: -1. 优先核对“PDF定位文本”和对应的局部截图,局部截图是按 PDF 中文本特征截取出来的区域。 +任务目标: +1. {mode_instruction} 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。 +3. 如果 PDF 能读出明确值且结构化字段缺失、截断或错填,请直接给出 suggested_updates;系统会先按你的建议修改,再归类。 +4. 修改后如果 AI 认为没有必须复核的问题,classification 返回 "ok";即使编码栏空白、某些字段为空,只要 PDF 证实首页本来如此且无需处理,也返回 "ok"。 +5. 只有仍需要复核或纠正的问题,classification 返回 "problem",并写入 remaining_issues。 +6. 如果手术表格中能看到“手术及操作编码”列但对应单元格为空,写“编码栏可见但为空白”,不要写“编码区域未在首页显示”;单元格本来为空且无需补录时不要因此判为 problem。 +7. 手术操作名称可能因为换行被结构化解析截断;如果 PDF定位文本或局部截图中显示完整多行名称,请把完整名称放入 suggested_updates。 +8. 门急诊诊断编码只能用于 outpatient_diagnosis_code;不要把门急诊诊断编码复制到 primary_diagnosis_code 或 discharge_diagnoses[].疾病编码,除非出院诊断表格对应“疾病编码”单元格本身清楚显示该编码。 +9. remaining_issues 只写当前文档复核人应该特别注意的内容;不要写如何修改,不要重复 suggested_updates,不要写“无需补录/无需处理/首页原貌”这类已判定无问题的说明。 +10. 不要编造 PDF 中看不见的内容,不要输出置信度。 必须返回这个 JSON 结构: {{ - "decision": "ok 或 confirm", - "confidence": 0.0, - "issue_resolution": "false_positive/confirmed_problem/uncertain/update_suggested", - "confirmed_issue": false, + "classification": "ok 或 problem", "summary": "一句话结论,60字以内", - "method": "Kimi视觉核验:PDF文本定位+局部截图,对照复核定位和结构化字段", + "method": "AI视觉核验:PDF文本定位+局部截图,对照复核定位和结构化字段", + "suggested_updates": [ + {{"field": "字段名或路径,例如 primary_diagnosis_code 或 operations[0].麻醉方式", "value": "PDF中应写入的值", "reason": "20字以内"}} + ], + "remaining_issues": ["AI觉得有必要复核的内容,最多3条;无则返回空数组"], "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 返回 []。 +如果没有明确修正值,suggested_updates 返回 []。如果 suggested_updates 已经修正主要问题,remaining_issues 可以返回 [] 并把 classification 归为 "ok"。 结构化字段: {snapshot} @@ -1880,63 +2295,112 @@ PDF定位文本: """.strip() -def call_kimi_ai_review(record: dict[str, Any]) -> dict[str, Any]: - settings = public_kimi_settings(load_local_settings().get("kimi") or {}) +def local_privacy_ai_result(settings: dict[str, Any], pdf_context: dict[str, Any], reason: str) -> dict[str, Any]: + parsed = normalize_ai_parsed( + { + "classification": "problem", + "summary": "隐私模式未上传敏感区域,需人工复核", + "method": "本地隐私保护:未调用外部AI", + "suggested_updates": [], + "remaining_issues": [reason], + "evidence": [], + } + ) + return { + "model": settings.get("model"), + "thinking_enabled": bool(settings.get("thinking_enabled")), + "privacy_mode": True, + "checked_at": datetime.now().isoformat(timespec="seconds"), + "ai_question": "隐私模式本地判定:未上传基本信息、地址联系人、整页截图或PDF全文摘录。", + "pdf_context": {"modules": [], "full_text_excerpt": ""}, + "raw_response": "", + "parsed": parsed, + "usage": {}, + } + + +def merge_kimi_override(kimi: dict[str, Any], override: dict[str, Any] | None = None) -> dict[str, Any]: + if not override: + return kimi + merged = dict(kimi) + if override.get("model"): + merged["model"] = str(override["model"]).strip() + if override.get("thinking_enabled") is not None: + merged["thinking_enabled"] = bool(override["thinking_enabled"]) + if override.get("privacy_mode") is not None: + merged["privacy_mode"] = bool(override["privacy_mode"]) + return merged + + +def call_kimi_ai_review(record: dict[str, Any], kimi_override: dict[str, Any] | None = None) -> dict[str, Any]: + local_kimi_settings = load_local_settings().get("kimi") or {} + local_kimi_settings = merge_kimi_override(local_kimi_settings, kimi_override) + settings = public_kimi_settings(local_kimi_settings) + api_key = kimi_api_key(local_kimi_settings) + privacy_mode = normalize_bool(local_kimi_settings.get("privacy_mode"), True) if not settings["available"]: - raise HTTPException(status_code=400, detail="Kimi AI 核验未启用或未配置 API Key") + raise HTTPException(status_code=400, detail="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]: + pdf_context = extract_pdf_module_context(pdf_path, record, privacy_mode=privacy_mode) + if privacy_mode and pdf_context.get("privacy_skipped"): + return local_privacy_ai_result(settings, pdf_context, "复核定位疑似基本信息或地址联系人;隐私模式不上传该区域,请人工复核。") + if privacy_mode and not pdf_context.get("modules"): + return local_privacy_ai_result(settings, pdf_context, "未定位到可脱敏上传的诊断/手术/费用局部区域;未上传整页截图,请人工复核。") + ai_question = build_ai_prompt(record, pdf_context, privacy_mode=privacy_mode) + content_parts: list[dict[str, Any]] = [{"type": "text", "text": ai_question}] + for item in pdf_context.get("modules", [])[: 4 if privacy_mode else 5]: 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)}}) - + supports_thinking = model_supports_thinking(settings["model"]) + thinking_enabled = supports_thinking and bool(settings.get("thinking_enabled")) payload = { "model": settings["model"], - "temperature": 0.6, - "max_tokens": 1600, - "thinking": {"type": "disabled"}, + "temperature": 1.0 if thinking_enabled else 0.6, + "max_tokens": 16000 if thinking_enabled else 3200, "response_format": {"type": "json_object"}, "messages": [ - {"role": "system", "content": "你是严谨的病案首页视觉核验助手,只输出 JSON。"}, + {"role": "system", "content": "你是严谨的病案首页视觉核验助手,只输出合法 JSON;字符串里的引号必须转义。"}, { "role": "user", "content": content_parts, }, ], } + if supports_thinking: + payload["thinking"] = {"type": "enabled" if thinking_enabled else "disabled"} 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()}", + "Authorization": f"Bearer {api_key}", }, method="POST", ) try: - with urllib.request.urlopen(request, timeout=90) as response: + with urllib.request.urlopen(request, timeout=180 if thinking_enabled else 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 + raise HTTPException(status_code=502, detail=f"AI API 返回错误 {exc.code}: {detail}") from exc except urllib.error.URLError as exc: - raise HTTPException(status_code=502, detail=f"Kimi API 调用失败:{exc}") from exc + raise HTTPException(status_code=502, detail=f"AI 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} + parsed = {"decision": "confirm", "summary": "AI 返回 JSON 不是对象", "raw_response": content} + parsed = normalize_ai_parsed(parsed) return { "model": settings["model"], + "thinking_enabled": thinking_enabled, + "privacy_mode": privacy_mode, "checked_at": datetime.now().isoformat(timespec="seconds"), + "ai_question": ai_question, "pdf_context": { "modules": [ {key: item[key] for key in ("name", "page", "bbox", "text")} @@ -1946,6 +2410,7 @@ def call_kimi_ai_review(record: dict[str, Any]) -> dict[str, Any]: }, "raw_response": content, "parsed": parsed, + "usage": data.get("usage") or {}, } @@ -1967,6 +2432,23 @@ def ai_join_text(value: Any) -> str: return str(value or "") +def ai_needs_review_text(value: Any) -> bool: + text = str(value or "").strip() + return bool(text) and not any(marker in text for marker in AI_NO_REVIEW_MARKERS) + + +def normalize_ai_parsed(parsed: dict[str, Any]) -> dict[str, Any]: + normalized = dict(parsed) + remaining = normalized.get("remaining_issues") + if isinstance(remaining, list): + normalized["remaining_issues"] = [item for item in remaining if ai_needs_review_text(item)] + elif remaining and ai_needs_review_text(remaining): + normalized["remaining_issues"] = [remaining] + else: + normalized["remaining_issues"] = [] + return normalized + + 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")): @@ -2003,72 +2485,227 @@ def ai_has_confirmed_problem(parsed: dict[str, Any]) -> bool: return bool(has_problem_word and has_qualifier) +def ai_remaining_issues(parsed: dict[str, Any]) -> list[Any]: + issues = parsed.get("remaining_issues") + if isinstance(issues, list): + return [item for item in issues if str(item or "").strip()] + if issues: + return [issues] + return [] + + +def ai_has_unresolved_problem(parsed: dict[str, Any]) -> bool: + if ai_remaining_issues(parsed): + return True + + text = ai_join_text( + { + "summary": parsed.get("summary"), + "evidence": parsed.get("evidence"), + "decision": parsed.get("decision"), + } + ) + compact = re.sub(r"\s+", "", text) + has_fixed_marker = any(marker in compact for marker in AI_FIXED_MARKERS) + has_force_problem_marker = any(marker in compact for marker in AI_FORCE_PROBLEM_MARKERS) + has_unresolved_marker = any(marker in compact for marker in AI_UNRESOLVED_PROBLEM_MARKERS) + if has_unresolved_marker: + if has_fixed_marker and not has_force_problem_marker: + return False + return True + + if has_fixed_marker: + return False + + unresolved_code_pattern = re.compile( + r"(主要诊断编码|手术及操作编码|手术操作编码|手术编码|疾病编码).{0,16}(空白|缺失|未填|未填写|漏填|为空)" + ) + return bool(unresolved_code_pattern.search(compact)) + + +def ai_classification(parsed: dict[str, Any]) -> str: + classification = str(parsed.get("classification") or parsed.get("category") or "").strip().lower() + decision = str(parsed.get("decision") or "").strip().lower() + resolution = str(parsed.get("issue_resolution") or "").strip().lower() + ok_values = {"ok", "pass", "passed", "no_issue", "no issue", "无问题", "通过", "已通过"} + problem_values = {"problem", "not_ok", "not ok", "confirm", "不ok", "不通过", "需确认", "待确认", "需复核"} + if classification in ok_values or decision in ok_values or resolution in {"false_positive", "ok", "no_issue", "误报", "无问题", "通过"}: + return AI_OK_STATUS + if classification in problem_values or decision in problem_values or resolution in {"confirmed_problem", "uncertain", "problem", "待确认", "已证实"}: + return AI_PROBLEM_STATUS + if ai_remaining_issues(parsed) or ai_has_unresolved_problem(parsed): + return AI_PROBLEM_STATUS + suggested_updates = parsed.get("suggested_updates") + if isinstance(suggested_updates, list) and suggested_updates: + return AI_OK_STATUS + 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 AI_PROBLEM_STATUS + return AI_OK_STATUS + + 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 + return ai_classification(parsed) 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() + verdict = "AI判断通过" if status == AI_OK_STATUS else "AI建议复核" + return f"AI视觉核验({datetime.now().strftime('%Y-%m-%d %H:%M:%S')}): {verdict}。{summary}".strip() -def apply_ai_review(record_id: int) -> dict[str, Any]: +def blank_ai_value(value: Any) -> bool: + text = str(value or "").strip() + if not text: + return True + return any(marker in text for marker in ("空白", "未填写", "无内容", "可见但为空", "编码栏可见但为空", "null")) + + +def suggested_update_value(item: dict[str, Any]) -> Any: + for key in ("value", "new", "new_value", "pdf_value", "suggested_value"): + if key in item: + return item.get(key) + return None + + +def ai_outpatient_code_leak(path: tuple[str, int | None, str | None], value: Any, before: dict[str, Any], _current_value: Any) -> bool: + outpatient_code = str(before.get("outpatient_diagnosis_code") or "").strip() + if not outpatient_code or str(value or "").strip() != outpatient_code: + return False + field, _index, key = path + target_is_diagnosis_code = field == "primary_diagnosis_code" or (field == "discharge_diagnoses" and key == "疾病编码") + return bool(target_is_diagnosis_code) + + +def ai_update_path(field_text: str, item: dict[str, Any]) -> tuple[str, int | None, str | None] | None: + text = str(field_text or "").strip() + if text in EDITABLE_FIELDS: + return (text, None, None) + for name, meta in FIELD_META.items(): + if text == meta["label"]: + return (name, None, None) + + match = re.match(r"^(operations|discharge_diagnoses|fee_details)\[(\d+)\][.。.]?(.*)$", text) + if match: + return (match.group(1), int(match.group(2)), match.group(3).strip() or None) + + row_index = item.get("row_index") + try: + index = int(row_index) if row_index not in {None, ""} else 0 + except (TypeError, ValueError): + index = 0 + + operation_columns = {"手术操作编码", "手术操作日期", "手术级别", "手术操作名称", "术者", "I助", "II助", "切口愈合等级", "麻醉方式", "麻醉医师", "原始内容"} + diagnosis_columns = {"诊断类别", "出院诊断", "疾病编码", "入院病情"} + for column in operation_columns: + if column in text: + return ("operations", index, column) + for column in diagnosis_columns: + if column in text: + return ("discharge_diagnoses", index, column) + return None + + +def ai_suggested_updates(result: dict[str, Any], before: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]: + parsed = result.get("parsed") if isinstance(result.get("parsed"), dict) else {} + items = parsed.get("suggested_updates") + if not isinstance(items, list): + return {}, [] + + updates: dict[str, Any] = {} + changed_fields: list[dict[str, Any]] = [] + working = {key: json_ready_deep(before.get(key)) for key in EDITABLE_FIELDS} + + for item in items: + if not isinstance(item, dict): + continue + value = suggested_update_value(item) + if blank_ai_value(value): + continue + path = ai_update_path(str(item.get("field") or item.get("target") or ""), item) + if not path: + continue + field, index, key = path + if field not in EDITABLE_FIELDS: + continue + old_value = working.get(field) + if index is None: + if ai_outpatient_code_leak(path, value, before, old_value): + continue + try: + new_value = parse_field_value(field, value) + except Exception: + continue + label = FIELD_META[field]["label"] + compare_old = old_value + else: + if field not in JSON_FIELDS or key is None: + continue + rows = old_value if isinstance(old_value, list) else [] + rows = [dict(row) if isinstance(row, dict) else {} for row in rows] + if index < 0 or index >= len(rows): + continue + compare_old = rows[index].get(key) + if ai_outpatient_code_leak(path, value, before, compare_old): + continue + if comparable(compare_old) == comparable(value): + continue + rows[index][key] = value + new_value = rows + label = f"{FIELD_META[field]['label']}[{index + 1}].{key}" + + if comparable(compare_old) == comparable(value): + continue + working[field] = new_value + updates[field] = new_value + changed_fields.append( + { + "field": field if index is None else f"{field}[{index}].{key}", + "label": label, + "old": json_ready_deep(compare_old), + "new": json_ready_deep(value), + } + ) + + return updates, changed_fields + + +def apply_ai_review(record_id: int, kimi_override: dict[str, Any] | None = None) -> dict[str, Any]: before = fetch_record(record_id) - result = call_kimi_ai_review(before) + result = call_kimi_ai_review(before, kimi_override) + ai_updates, changed_fields = ai_suggested_updates(result, 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), - }, - ] + assignments = [sql.SQL("review_status = %s")] + values: list[Any] = [new_status] + for field, value in ai_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) 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), + sql.SQL("UPDATE {table} SET {assignments} WHERE id = %s").format( + table=table_identifier(), + assignments=sql.SQL(", ").join(assignments), + ), + [*values, record_id], ) - insert_review_log(cur, record_id, before.get("source_file", ""), changed_fields, note, changed_by="Kimi AI", ai_result=result) + insert_review_log(cur, record_id, before.get("source_file", ""), changed_fields, note, changed_by="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]: +def apply_ai_review_with_retry(record_id: int, attempts: int = 3, kimi_override: dict[str, Any] | None = None) -> dict[str, Any]: last_exc: Exception | None = None for attempt in range(attempts): try: - return apply_ai_review(record_id) + return apply_ai_review(record_id, kimi_override) except Exception as exc: # noqa: BLE001 last_exc = exc if attempt + 1 < attempts: @@ -2079,12 +2716,18 @@ def apply_ai_review_with_retry(record_id: int, attempts: int = 2) -> dict[str, A 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: + if scope not in {"current", "five", "fifty", "all", "ai_pending"}: + raise HTTPException(status_code=400, detail="AI核验范围只能是 current/five/fifty/all/ai_pending") + if scope in {"current", "five", "fifty"} and not record_id: raise HTTPException(status_code=400, detail="缺少当前记录 ID") if scope == "current": return [int(record_id)] + if scope == "ai_pending": + query = sql.SQL("SELECT id FROM {table} WHERE review_status = %s ORDER BY id").format(table=table_identifier()) + with connect() as conn, conn.cursor() as cur: + cur.execute(query, (AI_PENDING_STATUS,)) + rows = cur.fetchall() + return [int(row["id"]) for row in rows] where = sql.SQL("review_status = 'needs_review'") params: list[Any] = [] @@ -2092,6 +2735,10 @@ def ai_target_ids(scope: str, record_id: int | None) -> list[int]: where = sql.SQL("review_status = 'needs_review' AND id > %s") params.append(record_id) limit_sql = sql.SQL("LIMIT 5") + elif scope == "fifty": + where = sql.SQL("review_status = 'needs_review' AND id > %s") + params.append(record_id) + limit_sql = sql.SQL("LIMIT 50") else: if record_id: where = sql.SQL("review_status = 'needs_review' AND id > %s") @@ -2126,12 +2773,19 @@ def append_ai_job_error(record_id: int, message: str) -> None: del errors[: len(errors) - AI_JOB_ERROR_LIMIT] -def run_ai_review_job(scope: str, ids: list[int]) -> None: +def ai_cancel_requested() -> bool: + with AI_JOB_LOCK: + return bool(AI_REVIEW_JOB.get("cancel_requested")) + + +def run_ai_review_job(scope: str, ids: list[int], kimi_override: dict[str, Any] | None = None) -> 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))) + privacy_mode = normalize_bool((kimi_override or {}).get("privacy_mode"), True) stop_event = threading.Event() update_ai_job( running=True, + cancel_requested=False, scope=scope, total=len(ids), processed=0, @@ -2139,11 +2793,14 @@ def run_ai_review_job(scope: str, ids: list[int]) -> None: pending=0, failed=0, concurrency=concurrency, + model=str((kimi_override or {}).get("model") or settings.get("model") or ""), + thinking_enabled=bool((kimi_override or {}).get("thinking_enabled") if (kimi_override or {}).get("thinking_enabled") is not None else settings.get("thinking_enabled")), message="AI核验中", errors=[], started_at=datetime.now().isoformat(timespec="seconds"), finished_at="", last_record_id=None, + privacy_mode=privacy_mode, ) record_queue: Queue[int] = Queue() for record_id in ids: @@ -2152,12 +2809,17 @@ def run_ai_review_job(scope: str, ids: list[int]) -> None: def worker() -> None: try: while not stop_event.is_set(): + if ai_cancel_requested(): + with AI_JOB_LOCK: + AI_REVIEW_JOB["message"] = "AI核验正在中断..." + stop_event.set() + return try: record_id = record_queue.get_nowait() except Empty: return try: - item = apply_ai_review_with_retry(record_id) + item = apply_ai_review_with_retry(record_id, kimi_override=kimi_override) status = item.get("status") with AI_JOB_LOCK: AI_REVIEW_JOB["processed"] += 1 @@ -2194,18 +2856,19 @@ def run_ai_review_job(scope: str, ids: list[int]) -> None: refresh_status_snapshot(source="ai") with AI_JOB_LOCK: failed = int(AI_REVIEW_JOB.get("failed") or 0) + cancelled = bool(AI_REVIEW_JOB.get("cancel_requested")) 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} 条"), + message="AI核验已中断" if cancelled else (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')}): 确认为已人工复核" + note = f"批量确认历史AI已通过({datetime.now().strftime('%Y-%m-%d %H:%M:%S')}): 确认为已人工复核" query = sql.SQL( """ UPDATE {table} @@ -2225,7 +2888,7 @@ def mark_ai_no_issue_reviewed() -> int: ) ) ) - WHERE review_status = 'AI复核-无问题' + WHERE review_status IN ('AI已处理-OK', 'AI复核-无问题') """ ).format(table=table_identifier()) with connect() as conn, conn.cursor() as cur: @@ -2360,10 +3023,18 @@ def status(): """ SELECT count(*) AS total, - count(*) FILTER (WHERE review_status IN ('needs_review', 'AI复核-待确认')) AS review_needed, + count(*) FILTER (WHERE review_status IN ('needs_review', 'reviewed', 'AI已处理-OK', 'AI已处理-不OK', 'AI复核-无问题', 'AI复核-待确认') OR ( + review_status = 'auto_pass' + AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI')) + )) AS workbench_total, + count(*) FILTER (WHERE review_status IN ('needs_review', 'AI已处理-不OK', '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 = 'auto_pass') AS auto_passed, + count(*) FILTER (WHERE ( + review_status = 'auto_pass' + AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI')) + ) OR review_status IN ('AI已处理-OK', 'AI复核-无问题')) AS ai_passed, + count(*) FILTER (WHERE review_status IN ('AI已处理-不OK', '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 @@ -2393,10 +3064,16 @@ def overview(): """ SELECT count(*) AS total, - count(*) FILTER (WHERE review_status IN ('needs_review', 'AI复核-待确认')) AS review_queue, + count(*) FILTER (WHERE review_status IN ('needs_review', 'reviewed', 'AI已处理-OK', 'AI已处理-不OK', 'AI复核-无问题', 'AI复核-待确认') OR ( + review_status = 'auto_pass' + AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', '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_pass' + AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI')) + ) OR review_status IN ('AI已处理-OK', 'AI复核-无问题')) AS ai_passed, + count(*) FILTER (WHERE review_status IN ('AI已处理-不OK', '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, @@ -2426,10 +3103,12 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30 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复核-待确认')") + clauses.append("(review_status IN ('needs_review', 'reviewed', 'AI已处理-OK', 'AI已处理-不OK', 'AI复核-无问题', 'AI复核-待确认') OR (review_status = 'auto_pass' AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI'))))") elif status_filter != "all": if status_filter == "reviewed": clauses.append("review_status = 'reviewed'") + elif status_filter in {"ai_passed", "AI已处理-OK", "AI复核-无问题"}: + clauses.append("(review_status IN ('AI已处理-OK', 'AI复核-无问题') OR (review_status = 'auto_pass' AND COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI'))))") else: clauses.append("review_status = %s") params.append(status_filter) @@ -2443,6 +3122,7 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30 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, + COALESCE(review_logs, '[]'::jsonb) @> jsonb_build_array(jsonb_build_object('changed_by', 'AI')) AS ai_reviewed, NULLIF(review_logs->-1->>'changed_at', '')::timestamp AS last_activity_at FROM {table} {where} @@ -2450,8 +3130,11 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30 last_activity_at DESC NULLS LAST, CASE review_status WHEN 'needs_review' THEN 1 + WHEN 'AI已处理-不OK' THEN 2 WHEN 'AI复核-待确认' THEN 2 + WHEN 'AI已处理-OK' THEN 3 WHEN 'AI复核-无问题' THEN 3 + WHEN 'auto_pass' THEN 3 WHEN 'auto_corrected' THEN 4 WHEN 'reviewed' THEN 5 WHEN '已提交' THEN 6 @@ -2477,6 +3160,39 @@ def get_record(record_id: int): return {"record": fetch_record(record_id)} +@app.get("/api/records/{record_id}/ai-question-image/{log_index}/{module_index}") +def ai_question_image(record_id: int, log_index: int, module_index: int): + if log_index < 0 or module_index < 0: + raise HTTPException(status_code=400, detail="AI提问图片索引无效") + record = fetch_record(record_id) + pdf_path = get_pdf_path(record.get("source_file") or "") + if not pdf_path: + raise HTTPException(status_code=404, detail="PDF 文件不存在") + ai_logs = [log for log in fetch_review_logs(record_id, limit=100) if log.get("ai_result")] + if log_index >= len(ai_logs): + raise HTTPException(status_code=404, detail="AI提问记录不存在") + result = ai_logs[log_index].get("ai_result") or {} + modules = (result.get("pdf_context") or {}).get("modules") or [] + if module_index >= len(modules): + raise HTTPException(status_code=404, detail="AI提问图片不存在") + module = modules[module_index] or {} + bbox = module.get("bbox") or [] + if not isinstance(bbox, list) or len(bbox) != 4: + raise HTTPException(status_code=400, detail="AI提问图片定位无效") + try: + import fitz # type: ignore[import-not-found] + + clip = fitz.Rect([float(value) for value in bbox]) + page_index = max(0, int(module.get("page") or 1) - 1) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail="AI提问图片定位无法解析") from exc + return Response( + content=render_pdf_page_png(pdf_path, dpi=120, page_index=page_index, clip=clip), + media_type="image/png", + headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"}, + ) + + @app.post("/api/records/{record_id}") def update_record(record_id: int, payload: UpdatePayload): updates, changed_fields, before = build_record_updates( @@ -2623,12 +3339,19 @@ def update_system_settings(payload: SystemSettingsPayload): @app.post("/api/settings/kimi") def update_kimi_settings(payload: KimiSettingsPayload): data = load_local_settings() + current_kimi = data.get("kimi") or {} + api_key = payload.api_key.strip() or str(current_kimi.get("api_key") or "").strip() data["kimi"] = normalize_kimi_settings( { "enabled": payload.enabled, "model": payload.model, - "api_base": payload.api_base, + "api_base": payload.api_base or current_kimi.get("api_base") or DEFAULT_KIMI_API_BASE, + "api_key": api_key, "concurrency": payload.concurrency, + "thinking_enabled": payload.thinking_enabled, + "ai_scope_mode": payload.ai_scope_mode, + "ai_action_modes": payload.ai_action_modes, + "ai_action_privacy_modes": payload.ai_action_privacy_modes, } ) save_local_settings(data) @@ -2647,6 +3370,15 @@ def ai_review_status(): return dict(AI_REVIEW_JOB) +@app.post("/api/ai/review/cancel") +def cancel_ai_review(): + with AI_JOB_LOCK: + if AI_REVIEW_JOB.get("running"): + AI_REVIEW_JOB["cancel_requested"] = True + AI_REVIEW_JOB["message"] = "AI核验正在中断..." + return dict(AI_REVIEW_JOB) + + @app.post("/api/ai/review/approve-no-issue") def approve_ai_no_issue(): count = mark_ai_no_issue_reviewed() @@ -2655,16 +3387,35 @@ def approve_ai_no_issue(): @app.post("/api/ai/review") def start_ai_review(payload: AiReviewPayload): - settings = public_kimi_settings(load_local_settings().get("kimi") or {}) + local_kimi_settings = load_local_settings().get("kimi") or {} + settings = public_kimi_settings(local_kimi_settings) if not settings["available"]: - raise HTTPException(status_code=400, detail="Kimi AI 核验未启用或未配置 API Key") + raise HTTPException(status_code=400, detail="AI 核验未启用或未配置 API Key") + if not ai_scope_allowed(str(settings.get("ai_scope_mode") or "all"), payload.scope): + raise HTTPException(status_code=403, detail="当前设置未开放这个 AI 处理范围") 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) + privacy_modes = normalize_ai_action_privacy_modes(local_kimi_settings.get("ai_action_privacy_modes")) + privacy_mode = ( + normalize_bool(payload.privacy_mode, privacy_modes.get(payload.scope, True)) + if payload.privacy_mode is not None + else privacy_modes.get(payload.scope, True) + ) + kimi_override = { + "model": payload.model, + "thinking_enabled": payload.thinking_enabled, + "privacy_mode": privacy_mode, + } + thread = threading.Thread( + target=run_ai_review_job, + args=(payload.scope, ids, kimi_override), + name="kimi-ai-review", + daemon=True, + ) thread.start() time.sleep(0.1) with AI_JOB_LOCK: diff --git a/患者首页处理/数据可视化网页端/app/static/app.js b/患者首页处理/数据可视化网页端/app/static/app.js index 7d1b8c4..0bdbe56 100644 --- a/患者首页处理/数据可视化网页端/app/static/app.js +++ b/患者首页处理/数据可视化网页端/app/static/app.js @@ -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 `${label}`; } +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 `${labels[status] || status || "待抽查"}`; } +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) { `).join(""); $("overviewQueues").innerHTML = ` -