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

20 KiB
Raw Permalink Blame History

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_idsource_patient_id,不允许 patient ID 回退为 diagnosis IDai_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_iddiagnosis_id/idpatient_idsource_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_idpatient_id 同上;发送前不要求本地上下文已完成
6 接诊台 AI 问诊助手快捷问题/输入框 ui/pages/reception.py:8620-8648DiagnosisAiAssistantDialog 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 的 iddiagnosis_id 服务端单诊单报告
10 诊单详情内“AI 报告” ui/dialogs/diagnosis.py:1897-1912 同上 当前详情的 _diagnosis_id 同时写入 iddiagnosis_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.pyservices/remote_repository.py 只是兼容导出;未发现其他 AI HTTP 实现。

Repository 方法 HTTP 请求体/查询 患者标识 本地校验
list_ai_patient_options GET tcm.diagnosis/aiPatientOptions page_no,page_size,keyword 返回独立 diagnosis_idsource_patient_id DTO 清洗;入口再分离两种 IDrepository.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 正数患者 IDrepository.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_idai_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_idai_consult.py:1546-1564,3769-3781)。
  • 每次发送最终都把当前 self.diagnosis_id 交给 _AiStreamWorkerai_consult.py:5171-5230)。因此即使本地患者上下文为空,也不是 prompt-only 请求。

4.2 接诊台患者级报告

这是 app 中最强的绑定实现:

  • 请求前同时锁定 generation、appointment 和当前选择的 patient_idreception.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_analysisreception.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_idprescription_ai.py:856-891);该层完全信任服务端返回与请求 ID 对应。

5. 上下文路径判定

5.1 真正的服务端患者纵向全量路径

接诊台优先使用患者级报告。服务端 PatientAiReportLogic 明确按 patient_id 查询当前数据域内的全部有效诊单,并聚合诊单、doctor notes、tracking notes、blood、diet、exercise、IM、微信、通话和 transcript segmentsserver/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.pypatient-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,未写入生产代码或测试。

未覆盖/覆盖不足

  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_scopediagnosispatient_longitudinal)、context_versionsource_diagnosis_idssource_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 和单飞测试。