20 KiB
app AI 入口与患者上下文绑定审计(2026-08-21)
1. 范围与结论
本次只读审计覆盖当前工作树中的 app/src/doctor_workstation,重点追踪所有 AI 对话、结构化分析、诊断报告、患者纵向报告和处方库 AI 解释入口,向下核对到 DoctorRepository / RemoteDoctorRepository 的实际 HTTP 请求。为判断“服务端全量上下文”是否真实存在,额外只读核对了相应 server/app/adminapi 实现;没有修改生产代码或测试。
结论:
- 没有发现生产环境下只携带
prompt、不携带任何资源 ID 的 HTTP 请求,也没有发现桌面端直连 OpenAI、千问、Dify 或携带 provider key/base URL 的路径。 所有患者相关生成请求至少携带diagnosis_id(线上字段名id)或patient_id;处方库解释携带template_id(线上字段名id)。 - ID 绑定总体正确,但强度不一致。 患者级报告链路对当前选择、请求和响应中的
patient_id做了最严格的精确校验;AI 完整对话工作区也会用服务端诊单详情反查diagnosis_id/patient_id并过滤错归属数据。诊单报告和诊单结构化分析主要依赖“请求关联 + 服务端授权/DataScope”,桌面端不能从响应再次核对diagnosis_id。 - 并非所有患者 AI 入口都走服务端“患者纵向全量上下文”。 只有
patientAiReports/generatePatientAiReport是服务端按patient_id聚合历次诊单、医生备注、跟踪、血糖、饮食、运动、IM/微信、通话和转写的纵向链路。aiAssistant(Stream)、aiAnalysis、诊单generateAiReports都是按单个diagnosis_id构造诊单表字段摘要。 - 存在明确的本地拼 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 行为。
本次执行:
211 collected tests across the 9 files above
211 passed
命令使用 PYTHONDONTWRITEBYTECODE=1 和 -p no:cacheprovider,未写入生产代码或测试。
未覆盖/覆盖不足
- 没有测试“工作区仍在加载时立即发送”或“工作区失败后发送”时,断言请求退化为原始问题并验证产品是否允许。
- 当前测试
test_ask_prepends_patient_context_to_the_ai_prompt固化了本地拼 prompt 行为;没有相反的架构契约测试,确保患者上下文只能由服务端组装。 - 没有诊单/处方库报告响应
diagnosis_id/prescription_id缺失或错配时 fail closed 的测试。 - 没有预约行“缺少 diagnosis_id、但 patient_id 是真实患者 ID”时拒绝打开 AI 的测试;现有测试只覆盖显式 501/301 分离。
- 诊单级
aiAnalysis/assistant 响应本身不返回diagnosis_id,因此目前无法写真正的响应归属断言;只能测试异步请求关联。 - app 测试证明请求 ID 和 UI 并发安全,但没有端到端断言服务器实际采用了患者纵向 source summary。该契约目前只在 server 侧测试/实现中可见。
8. 建议验收标准
- 所有患者对话接口只接受
diagnosis_id/patient_id+ 原始用户问题/任务,不接受桌面端病例 envelope。 - 服务端返回
context_scope(diagnosis或patient_longitudinal)、context_version、source_diagnosis_ids、source_summary;桌面展示该信息并验证 owner。 - “患者纵向分析”必须传
patient_id,服务端按 DataScope 聚合;“本诊单分析”必须显式标注,并只传diagnosis_id。 - 所有 AI response DTO 都回显 owner;app 对 owner 缺失、类型不精确、错配统一 fail closed。
- 预约、诊单、患者三种 ID 在 DTO 层分离;UI 禁止
patient_id -> diagnosis_id语义回退。 - 新增上述六项测试缺口,并保留现有迟到响应、A/B/A 和单飞测试。