174 lines
20 KiB
Markdown
174 lines
20 KiB
Markdown
# app AI 入口与患者上下文绑定审计(2026-08-21)
|
||
|
||
## 1. 范围与结论
|
||
|
||
本次只读审计覆盖当前工作树中的 `app/src/doctor_workstation`,重点追踪所有 AI 对话、结构化分析、诊断报告、患者纵向报告和处方库 AI 解释入口,向下核对到 `DoctorRepository` / `RemoteDoctorRepository` 的实际 HTTP 请求。为判断“服务端全量上下文”是否真实存在,额外只读核对了相应 `server/app/adminapi` 实现;没有修改生产代码或测试。
|
||
|
||
结论:
|
||
|
||
1. **没有发现生产环境下只携带 `prompt`、不携带任何资源 ID 的 HTTP 请求,也没有发现桌面端直连 OpenAI、千问、Dify 或携带 provider key/base URL 的路径。** 所有患者相关生成请求至少携带 `diagnosis_id`(线上字段名 `id`)或 `patient_id`;处方库解释携带 `template_id`(线上字段名 `id`)。
|
||
2. **ID 绑定总体正确,但强度不一致。** 患者级报告链路对当前选择、请求和响应中的 `patient_id` 做了最严格的精确校验;AI 完整对话工作区也会用服务端诊单详情反查 `diagnosis_id`/`patient_id` 并过滤错归属数据。诊单报告和诊单结构化分析主要依赖“请求关联 + 服务端授权/DataScope”,桌面端不能从响应再次核对 `diagnosis_id`。
|
||
3. **并非所有患者 AI 入口都走服务端“患者纵向全量上下文”。** 只有 `patientAiReports` / `generatePatientAiReport` 是服务端按 `patient_id` 聚合历次诊单、医生备注、跟踪、血糖、饮食、运动、IM/微信、通话和转写的纵向链路。`aiAssistant(Stream)`、`aiAnalysis`、诊单 `generateAiReports` 都是按单个 `diagnosis_id` 构造诊单表字段摘要。
|
||
4. **存在明确的本地拼 prompt 路径。** `AiConsultDialog` 从多个桌面端请求结果中摘取最多 320 字的血糖、舌脉、视频转写、历史 AI 摘要、处方标题,拼到医生问题前,再受 500 字总限制截断。工作区未完成或加载失败时仍可发送,此时退化为“`diagnosis_id` + 原始问题”。该请求仍会进入第一方服务端并由服务端补入单诊单摘要,因此不是无权限的裸模型调用;但它不满足“患者上下文只能由服务端统一、全量组装”的要求。
|
||
|
||
## 2. 入口清单
|
||
|
||
| # | 可见入口 | 入口代码 | 最终 repository 方法 | 绑定键 | 上下文结论 |
|
||
|---|---|---|---|---|---|
|
||
| 1 | 主壳全局“AI 助手” | `ui/shell.py:1551-1565` → 全局患者诊单选择器 `ui/dialogs/ai_consult_picker.py:390-421` | `list_ai_patient_options` 后进入 `stream_diagnosis_ai` / `analyze_diagnosis_ai` | 选择器独立保存 `diagnosis_id`、`source_patient_id`,不允许 patient ID 回退为 diagnosis ID(`ai_consult_picker.py:72-119`) | 诊单级服务端摘要 + 桌面端局部上下文 |
|
||
| 2 | “问诊列表/预约”工具栏“AI 分析” | `ui/pages/appointments.py:1578-1598` | 同上 | `diagnosis_id`;展示用 `patient_id` 只取 `source_patient_id` | 同上;存在旧字段兼容回退风险,见缺口 G3 |
|
||
| 3 | “问诊列表/诊单”行操作“AI 分析” | `ui/pages/consultations.py:2980-3002` | 同上 | `diagnosis_id` 取 `diagnosis_id/id`,`patient_id` 取 `source_patient_id/patient_id` | 同上 |
|
||
| 4 | “我的患者”行操作/按钮“AI 分析” | `ui/pages/patients.py:2706-2708,2732-2759` | 同上 | 明确禁止从 `patient_id` 回退为诊单;诊单取 `diagnosis_id/id` | 同上 |
|
||
| 5 | 接诊台“AI 分析”对话工作区 | `ui/pages/reception.py:8650-8668` | 同上 | 从已加载详情的选择上下文取 `diagnosis_id`、`patient_id` | 同上;发送前不要求本地上下文已完成 |
|
||
| 6 | 接诊台 AI 问诊助手快捷问题/输入框 | `ui/pages/reception.py:8620-8648` → `DiagnosisAiAssistantDialog` | `analyze_diagnosis_ai` | 仅 `diagnosis_id` + `prompt` + `task`;无 `patient_id` | 服务端单诊单摘要;无桌面端患者纵向上下文 |
|
||
| 7 | 接诊台“AI 智能分析”自动加载、模型切换、重试、重新分析 | `ui/pages/reception.py:5265-6142,6954-6991` | 优先 `list_patient_ai_reports` / `generate_patient_ai_report`;权限/能力不足时回退 `get_diagnosis_ai_analysis` | 优先链路只传 `patient_id`;回退链路只传 `diagnosis_id` | 优先链路是服务端患者纵向全量;回退链路是单诊单摘要 |
|
||
| 8 | 接诊台“AI 报告” | `ui/pages/reception.py:8680-8721` | `list_diagnosis_ai_reports` / `generate_diagnosis_ai_reports` / `edit_diagnosis_ai_report` | `diagnosis_id` | 服务端单诊单报告,不是患者纵向报告 |
|
||
| 9 | 预约页“AI 报告” | `ui/pages/appointments.py:1559-1576` | 同上 | 页面先把解析出的诊单号覆盖写入 payload 的 `id` 和 `diagnosis_id` | 服务端单诊单报告 |
|
||
| 10 | 诊单详情内“AI 报告” | `ui/dialogs/diagnosis.py:1897-1912` | 同上 | 当前详情的 `_diagnosis_id` 同时写入 `id`、`diagnosis_id` | 服务端单诊单报告 |
|
||
| 11 | 处方库“AI解释” | `ui/pages/prescription_library.py:734-740` | `list_prescription_template_ai_reports` / `generate_prescription_template_ai_reports` / `edit_prescription_template_ai_report` | `template_id`,线上字段 `id` | 非患者入口;服务端只分析模板药材组合 |
|
||
| 12 | AI 分析历史详情弹窗 | `ui/pages/reception.py:6140-6153` | 不发请求 | 使用已校验缓存 | 纯展示,无新增上下文风险 |
|
||
|
||
补充:AI 对话中“开个处方”等明确指令会被 `_handle_local_action` 拦截,重新读取当前诊单详情并打开处方编辑器,不会发 AI 请求(`ui/dialogs/ai_consult.py:4881-4935`)。
|
||
|
||
## 3. Repository 请求矩阵
|
||
|
||
生产实现集中在 `services/repository.py`,`services/remote_repository.py` 只是兼容导出;未发现其他 AI HTTP 实现。
|
||
|
||
| Repository 方法 | HTTP | 请求体/查询 | 患者标识 | 本地校验 |
|
||
|---|---|---|---|---|
|
||
| `list_ai_patient_options` | GET `tcm.diagnosis/aiPatientOptions` | `page_no,page_size,keyword` | 返回独立 `diagnosis_id`、`source_patient_id` | DTO 清洗;入口再分离两种 ID(`repository.py:2029-2044`) |
|
||
| `list_prescription_template_ai_reports` | GET `tcm.prescriptionLibrary/aiReports` | `id=template_id` | 不适用 | repository 未显式正数校验(`repository.py:1267-1277`) |
|
||
| `generate_prescription_template_ai_reports` | POST `tcm.prescriptionLibrary/generateAiReports` | `id=template_id` | 不适用 | 同上(`repository.py:1279-1289`) |
|
||
| `edit_prescription_template_ai_report` | POST `tcm.prescriptionLibrary/editAiReport` | `id,report_id,content` | 不适用 | 同上(`repository.py:1291-1307`) |
|
||
| `list_diagnosis_ai_reports` | GET `tcm.diagnosis/aiReports` | `id=diagnosis_id` | `diagnosis_id` | repository 未显式正数校验(`repository.py:1309-1319`) |
|
||
| `generate_diagnosis_ai_reports` | POST `tcm.diagnosis/generateAiReports` | `id=diagnosis_id` | `diagnosis_id` | repository 未显式正数校验(`repository.py:1321-1331`) |
|
||
| `edit_diagnosis_ai_report` | POST `tcm.diagnosis/editAiReport` | `id,report_id,content` | `diagnosis_id` | repository 未显式正数校验(`repository.py:1333-1349`) |
|
||
| `analyze_diagnosis_ai` | POST `tcm.diagnosis/aiAssistant` | `id, prompt, task` | `diagnosis_id` | 要求正数诊单、非空且 ≤500 字问题、任务白名单(`repository.py:1351-1371,2803-2823`) |
|
||
| `stream_diagnosis_ai` | SSE POST `tcm.diagnosis/aiAssistantStream` | `id, prompt, task` | `diagnosis_id` | 同上;首个 delta 前失败时最多回退一次非流式助手(`repository.py:1373-1428`) |
|
||
| `get_diagnosis_ai_analysis` | POST `tcm.diagnosis/aiAnalysis` | `id,model` | `diagnosis_id` | 正数诊单、模型白名单(`repository.py:1430-1450`) |
|
||
| `list_patient_ai_reports` | GET `tcm.diagnosis/patientAiReports` | `patient_id` | `patient_id` | 正数患者 ID(`repository.py:1452-1464`) |
|
||
| `generate_patient_ai_report` | POST `tcm.diagnosis/generatePatientAiReport` | `patient_id,model` | `patient_id` | 正数患者 ID、模型白名单(`repository.py:1466-1488`) |
|
||
|
||
安全边界:助手请求体只有 `id/prompt/task`,结构化分析只有 `id/model`,患者报告只有 `patient_id/model`;未携带 `key/api_key/base_url/provider/model` 等上游配置(模型键仅出现在固定白名单分析/报告接口)。
|
||
|
||
## 4. ID 绑定与归属校验
|
||
|
||
### 4.1 AI 完整对话工作区
|
||
|
||
- `present_ai_consult` 拒绝非正数 `diagnosis_id`(`ai_consult.py:5353-5378`)。
|
||
- 打开后先按该诊单请求只读详情,再从详情中解析权威 `patient_id`。如果详情返回的诊单 ID 不完全等于当前诊单,整个详情及关联备注、处方、跟踪资料被过滤;如果入口传入的 patient ID 与详情不一致,停止患者报告请求(`ai_consult.py:3703-3802,3833-3907`)。
|
||
- IM 消息、备注、处方要求每行明确携带当前 `diagnosis_id`;跟踪记录也必须声明当前诊单归属(`ai_consult.py:1514-1543,3866-3907`)。
|
||
- 患者报告响应会递归检查所有已声明的 `patient_id`(`ai_consult.py:1546-1564,3769-3781`)。
|
||
- 每次发送最终都把当前 `self.diagnosis_id` 交给 `_AiStreamWorker`(`ai_consult.py:5171-5230`)。因此即使本地患者上下文为空,也不是 prompt-only 请求。
|
||
|
||
### 4.2 接诊台患者级报告
|
||
|
||
这是 app 中最强的绑定实现:
|
||
|
||
- 请求前同时锁定 generation、appointment 和当前选择的 `patient_id`(`reception.py:5062-5073,5265-5352`)。
|
||
- GET 响应要求顶层和每条 report 的 `patient_id` 都是精确正数且等于请求值;POST 还要求 `generated_report.id` 为正数、`model_key` 与请求一致(`reception.py:1865-1993`)。
|
||
- A→B、A→B→A、迟到响应、旧 GET 覆盖新 POST 等并发情况都有单飞、取消和 mutation epoch 保护(`reception.py:5135-5164,5376-5452,5503-5731`)。
|
||
- 该接口按患者聚合,因此请求不带单一 `diagnosis_id` 是正确契约,不是遗漏。服务端会记录全部来源诊单集合和最新诊单 ID。
|
||
|
||
### 4.3 接诊台诊单级回退分析
|
||
|
||
- 只有在患者报告权限/方法/患者 ID 条件不满足时才走 `get_diagnosis_ai_analysis`(`reception.py:5788-5853`)。
|
||
- 请求前后都校验 generation、appointment、当前选择的 diagnosis,且响应模型必须与请求模型相同(`reception.py:5049-5060,5855-6028`)。
|
||
- 响应契约没有返回 `diagnosis_id`,因此桌面端只能依赖异步请求关联,无法做响应所有者复核。
|
||
|
||
### 4.4 报告弹窗
|
||
|
||
`PrescriptionAiReportDialog` 通过 `AiReportKind` 把诊单、模板分别路由到正确 repository 方法,生成和读取只使用实体 ID(`prescription_ai.py:453-510,780-828,909-929`)。但 `_apply_reports` 直接接受报告数组和 capabilities,不核对响应中的 `diagnosis_id`/`prescription_id`(`prescription_ai.py:856-891`);该层完全信任服务端返回与请求 ID 对应。
|
||
|
||
## 5. 上下文路径判定
|
||
|
||
### 5.1 真正的服务端患者纵向全量路径
|
||
|
||
接诊台优先使用患者级报告。服务端 `PatientAiReportLogic` 明确按 `patient_id` 查询当前数据域内的全部有效诊单,并聚合诊单、doctor notes、tracking notes、blood、diet、exercise、IM、微信、通话和 transcript segments(`server/app/adminapi/logic/tcm/PatientAiReportLogic.php:17-22,254-297,335-395`)。生成请求只接受 `patient_id` 和固定模型,完整来源快照留在服务端(同文件 `126-218`)。
|
||
|
||
### 5.2 诊单级服务端上下文
|
||
|
||
`aiAssistant(Stream)`、`aiAnalysis`、诊单报告都先按 `diagnosis_id` 做权限和 DataScope 校验,再由服务端构建脱敏病例摘要;不是裸 prompt 调模型(`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:320-378,470-515,551-590`)。但是其 `buildCaseContext` 只整理当前诊单表中的生命体征和 `CASE_FIELDS`,不会查询 doctor notes、tracking、血糖历史、饮食、运动、IM/微信、通话/转写等关联表(同文件 `211-253,881-953`)。因此它是“单诊单完整字段”,不是患者纵向全量。
|
||
|
||
### 5.3 桌面端本地拼 prompt
|
||
|
||
`AiConsultDialog` 的本地 envelope 明确存在:
|
||
|
||
- 上限 320 字,来源只有每日血糖摘要、舌苔/脉象、第一条视频转写、历史 AI 报告摘要和最多三条处方标题(`ai_consult.py:2687-2694,2703-2898`)。
|
||
- envelope 与医生问题拼成最多 500 字的 `prompt`,超限时优先保留上下文、截断医生问题(`ai_consult.py:2901-2928`)。
|
||
- 工作区加载失败时 UI 明示“可先根据已有信息提问”,此时 `_patient_ai_context` 可能为空(`ai_consult.py:3810-3831`);`_compose_ai_prompt` 在上下文为空时直接返回问题(`ai_consult.py:2915-2919`)。
|
||
- 每次发送仍携带当前 `diagnosis_id`,并由服务端再次加入单诊单摘要(`ai_consult.py:5171-5230`)。
|
||
|
||
判定:这不是绕过第一方服务端或无 ID 调用,但**确实绕过了“由服务端作为唯一来源统一组装患者纵向上下文”的架构要求**。本地 envelope 是不完整、截断且可能暂时为空的第二套上下文实现;它还把本可用于医生问题的 500 字预算占掉。
|
||
|
||
### 5.4 仅原始问题的 UI 路径
|
||
|
||
接诊台轻量 `DiagnosisAiAssistantDialog` 直接发送 `diagnosis_id + prompt + task`,不拼患者报告或桌面工作区上下文(`prescription_ai.py:1427-1454`)。这是“仅原始问题 + diagnosis ID”,不是“仅 prompt”。其安全性依赖服务端按 diagnosis ID 补入单诊单摘要;如果产品要求患者纵向资料,则该路径不达标。
|
||
|
||
## 6. 缺口与风险
|
||
|
||
### G1 — 高:患者对话上下文存在第二套桌面端拼装,且不是全量
|
||
|
||
AI 完整对话把最多 320 字的局部资料拼入问题;快速发送、加载失败或最小病历时可退化为空。本地实现与服务端 `DiagnosisAiLogic::buildCaseContext` 并存,两者字段、更新时机和截断规则不同,容易产生遗漏或矛盾。若目标是“每次患者发消息均由服务端使用完整、权威上下文”,当前实现不满足。
|
||
|
||
建议:服务端提供唯一的 diagnosis/patient-scoped assistant context 聚合器;桌面只发送 `diagnosis_id`(必要时另传经验证的 `patient_id`)和原始医生问题。返回可审计的 `context_version/source_summary/source_diagnosis_ids`,UI 展示服务端声明而不是展示客户端自拼文本。
|
||
|
||
### G2 — 中高:轻量助手和诊单级分析/报告不是患者纵向全量
|
||
|
||
轻量助手、诊单分析、诊单报告都正确绑定 `diagnosis_id`,也经过服务端授权;但上下文只来自单个诊单字段。接诊台有患者报告权限时会优先使用真正的患者纵向报告,缺少该权限时则回退为单诊单分析。产品若把这些入口统称为“患者分析”,应显式区分“本诊单分析”与“患者纵向分析”,或统一到患者级聚合服务。
|
||
|
||
### G3 — 中:预约页仍把 `patient_id` 当作诊单 ID 的兼容回退
|
||
|
||
`appointments.py:382-394` 在缺少显式 `diagnosis_id` 时把 `patient_id` 作为 diagnosis ID,同时真正患者 ID 只接受 `source_patient_id`。这符合旧 admin 预约行的历史语义,但与规范化 DTO 中 `patient_id` 表示真实患者的常见语义冲突。如果未来接口只返回真实 `patient_id` 而漏掉 `diagnosis_id`,可能把患者 ID 当诊单 ID 发给 AI;若数值恰好命中另一个可访问诊单,仅靠正数/权限校验无法识别语义错绑。
|
||
|
||
建议:AI 入口必须要求显式 `diagnosis_id`;旧接口兼容应在 repository DTO 适配层一次性完成,并用契约版本或独立字段证明,不要在 UI 回退。
|
||
|
||
### G4 — 中:诊单/处方库 AI 报告弹窗不校验响应所有者
|
||
|
||
患者级报告会严格核对响应中的 `patient_id`,AI 工作区也过滤错诊单数据;但通用报告弹窗直接接收 reports。服务端当前会返回 `diagnosis_id`/`prescription_id`,桌面端应拒绝缺失或不匹配的所有者,并在生成、编辑后同样校验,避免代理缓存、服务端回归或测试替身把 A 的报告显示在 B 上。
|
||
|
||
### G5 — 低:部分报告 repository 方法缺少一致的正数 ID 前置校验
|
||
|
||
助手、结构化分析、患者报告均在 repository 层验证正数 ID;诊单/处方库报告的 list/generate/edit 没有同级校验。UI 通常会拦截无 ID,因此当前主要是防御一致性和未来非 UI 调用风险。
|
||
|
||
## 7. 现有测试覆盖与缺口
|
||
|
||
### 已覆盖
|
||
|
||
- `test_ai_patient_options_repository.py`:专用选择器 endpoint、分页、脱敏 DTO、diagnosis/patient ID 分离。
|
||
- `test_ai_consult_picker_ui.py`:选择器不自动选中、搜索/迟到响应、接受后才打开、最小脱敏 seed。
|
||
- `test_ai_consult_workspace_ui.py`:四个资料页均使用选中诊单;seed 不可替换权威 patient ID;错详情/错 patient report/无 owner 的备注、处方、IM、tracking 均 fail closed;A/B 迟到结果隔离。
|
||
- `test_ai_consult_ui.py`:四个页面入口传递 501/301;完整对话流式顺序、取消;本地上下文的来源过滤、320/500 字截断和拼接;处方本地动作重新核对当前诊单。
|
||
- `test_patient_ai_report_desktop.py`:patient-only HTTP 契约、精确顶层/行 patient ID 校验、POST 新快照校验、权限组合、A/B/A 单飞与旧 GET/新 POST 并发保护。
|
||
- `test_prescription_ai_ui.py`:诊单/处方库报告方法路由、权限、生成/编辑;轻量助手精确发送 `diagnosis_id/prompt/task`。
|
||
- `test_repository_parity.py`:所有 AI endpoint 和 DTO、无 provider 配置、SSE 正常化与单次回退、诊单分析模型白名单。
|
||
- `test_reception_parity_ui.py`:诊单分析自动加载、Qwen→OpenAI 顺序、迟到结果丢弃、详情失败停止 AI、患者报告完成态和权限回退。
|
||
- `test_api_client.py`:SSE 请求体、事件顺序、HTTP 行为。
|
||
|
||
本次执行:
|
||
|
||
```text
|
||
211 collected tests across the 9 files above
|
||
211 passed
|
||
```
|
||
|
||
命令使用 `PYTHONDONTWRITEBYTECODE=1` 和 `-p no:cacheprovider`,未写入生产代码或测试。
|
||
|
||
### 未覆盖/覆盖不足
|
||
|
||
1. 没有测试“工作区仍在加载时立即发送”或“工作区失败后发送”时,断言请求退化为原始问题并验证产品是否允许。
|
||
2. 当前测试 `test_ask_prepends_patient_context_to_the_ai_prompt` 固化了本地拼 prompt 行为;没有相反的架构契约测试,确保患者上下文只能由服务端组装。
|
||
3. 没有诊单/处方库报告响应 `diagnosis_id`/`prescription_id` 缺失或错配时 fail closed 的测试。
|
||
4. 没有预约行“缺少 diagnosis_id、但 patient_id 是真实患者 ID”时拒绝打开 AI 的测试;现有测试只覆盖显式 501/301 分离。
|
||
5. 诊单级 `aiAnalysis`/assistant 响应本身不返回 `diagnosis_id`,因此目前无法写真正的响应归属断言;只能测试异步请求关联。
|
||
6. app 测试证明请求 ID 和 UI 并发安全,但没有端到端断言服务器实际采用了患者纵向 source summary。该契约目前只在 server 侧测试/实现中可见。
|
||
|
||
## 8. 建议验收标准
|
||
|
||
1. 所有患者对话接口只接受 `diagnosis_id`/`patient_id` + 原始用户问题/任务,不接受桌面端病例 envelope。
|
||
2. 服务端返回 `context_scope`(`diagnosis` 或 `patient_longitudinal`)、`context_version`、`source_diagnosis_ids`、`source_summary`;桌面展示该信息并验证 owner。
|
||
3. “患者纵向分析”必须传 `patient_id`,服务端按 DataScope 聚合;“本诊单分析”必须显式标注,并只传 `diagnosis_id`。
|
||
4. 所有 AI response DTO 都回显 owner;app 对 owner 缺失、类型不精确、错配统一 fail closed。
|
||
5. 预约、诊单、患者三种 ID 在 DTO 层分离;UI 禁止 `patient_id -> diagnosis_id` 语义回退。
|
||
6. 新增上述六项测试缺口,并保留现有迟到响应、A/B/A 和单飞测试。
|