Files
zyt/app/research/ai_app_entry_audit_20260821.md
2026-08-22 08:51:35 +08:00

174 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 closedA/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 都回显 ownerapp 对 owner 缺失、类型不精确、错配统一 fail closed。
5. 预约、诊单、患者三种 ID 在 DTO 层分离;UI 禁止 `patient_id -> diagnosis_id` 语义回退。
6. 新增上述六项测试缺口,并保留现有迟到响应、A/B/A 和单飞测试。