doc_id: DDS-202606-001 feature_id: FEAT-202606-001-unified-entry-client type: detailed-design title: 医梦 AI 统一入口客户端技术设计文档 status: approved owner: 医梦研发团队 created_at: 2026-06-17 updated_at: 2026-06-17 reviewers: [] related_docs:
文档定位:统一入口客户端后端 + 前端终端 + Dify Workflow + Card Runtime + MCP/HIS Adapter + 舌诊接口接入的完整技术方案。
适用范围:机器人端、自助机端、导诊大屏端、AI 中台 OpenPlatform、Dify 工作流、设备注册与场景配置、卡片动作闭环、HIS Mock/真实适配、舌诊能力接入。
目标读者:技术负责人、后端工程师、前端工程师、机器人端工程师、产品经理、测试、交付人员。
当前阶段:一期开发计划基准版。
关键修正:本版已修复原《统一入口客户端后端流程设计图谱.md》中第 8、9 节 Mermaid 无法解析的问题。失败原因是节点标签以/files/upload开头时被 Mermaid 误判为特殊节点语法,本版统一改为引号节点,例如J["调用 files upload 上传身份证图片"]。
统一入口客户端不是一个普通聊天页面,也不是现有 Demo 的简单产品化。空海医院会议后,它应被定义为:
部署在机器人、自助机、导诊大屏、诊室门口屏等门诊终端上的患者动线承载端 + 多设备 AI 场景运行时。前端负责把当前动线阶段、下一步服务和业务卡片外显出来;后端负责身份/空间/业务/设备/风险感知、设备上下文、会话任务、Agent 路由、Dify 编排、卡片动作、工具调用、文件治理、审计计量。
一期建设目标不是“大而全 AI 医院”,而是先打穿三条生产级主链路:
机器人端:导诊问答 + 路线导航
自助机端:建档 + 预问诊 + 分诊 + 挂号 Mock + 舌诊
导诊大屏端:FAQ + 科室介绍 + 路线引导 + 轻量分诊
核心技术控制链路固定为:
统一入口客户端
→ 设备注册 / 场景配置
→ 感知事件 / 患者动线阶段
→ 会话与任务状态
→ AgentRouter 后端路由
→ Dify Workflow 场景编排
→ DifyOutputNormalizer 输出校验
→ Card Runtime 卡片实例与动作闭环
→ MCP Tool Service / HIS Adapter / TongueDiagnosisAdapter
→ Audit / Meter / Trace
必须坚持的工程红线:
| 红线 | 说明 |
|---|---|
| 前端不决定调用哪个 Dify App | 前端只传用户输入、设备上下文、文件 ID、卡片动作 |
| Dify 不直接写 HIS | Dify 只做场景编排、追问、结构化输出、解释 |
| 卡片不是普通 UI | 挂号、建档、舌诊提交、导航执行都必须走 Card Runtime |
当前没有 HIS 接口时必须显式使用 HisMockAdapter |
禁止把随机医生、随机号源写在前端、Dify 或 ChatService 中 |
| 导诊大屏不处理患者隐私 | 不允许身份证上传、舌象采集、报告解读、个人挂号确认 |
| 舌诊已有接口也必须纳入中台 | 统一走 File Service + TongueDiagnosisAdapter,不允许前端直连 |
| 动线和感知不由前端推断 | 前端只上报设备事件和用户确认,journeyState 由后端结合 HIS/叫号/任务状态推导 |
甲方认可的核心不是“屏幕会聊天”,而是“任何一个设备面前,设备可以感知到你的动线阶段,并按当前阶段提供智能体服务”。因此统一入口客户端的第一屏不应只围绕对话框设计,而应同时呈现:
| 区域 | 作用 | MVP 规则 |
|---|---|---|
| 顶部状态区 | 设备、院区、场景、身份绑定状态 | 未绑定身份时只显示公共服务 |
| 左侧快捷区 | 高频公共服务、人工服务、无障碍入口 | 不出现该设备不允许的能力 |
| 中央交互区 | AI 对话、虚拟形象、业务卡片 | 卡片动作必须走后端 Card Runtime |
| 右侧动线区 | 当前就医阶段、下一步任务、排队/缴费/检查提醒 | 数据来自后端 journeyState,前端不得自行推断 |
| 底部通知区 | 设备异常、授权提醒、降级入口 | 位置/身份授权失败时必须可扫码或人工处理 |
同一台设备面向不同用户必须有不同能力。例如诊室门口屏对未授权访客只展示科室介绍;对已挂号患者展示签到/候诊;对医护人员展示队列和人工接管。该差异由 sceneProfile + userContext + 服务端 journeyState + allowedAgents/allowedCards 决定。
感知能力按低门槛优先:
| 级别 | 能力 | 一期采用 |
|---|---|---|
| L0 | 扫码、人工确认、终端点选 | 必做 |
| L1 | NFC 碰一碰、刷卡、手机确认 | 推荐 |
| L2 | BLE Beacon / Wi-Fi 区域判断 | 可试点 |
| L3 | 星闪 / 鸿蒙 / UWB 高精度 | 只作为标杆 POC,不作为交付前置 |
第一期只承诺关键节点感知,不承诺全院无感定位、连续轨迹、自动签到、自动缴费和自动诊断。
当前 Demo 工程由三部分组成:
| 模块 | 当前职责 | 生产化判断 |
|---|---|---|
| Vue 前端 | 聊天界面、快捷入口、业务卡片、语音输入、TTS、机器人导航 JSBridge | 可保留并重构为统一入口客户端 |
| Spring Boot Demo 后端 | 意图识别、会话状态、SSE、建档、挂号、报告解读、舌诊流程编排 | 不建议作为生产后端保留,应拆解迁入 AI 中台 |
| Android 机器人壳 | WebView 容器、语音和导航桥接、猎户星空 SDK 对接 | 可保留,定位为 Terminal Bridge 适配层 |
当前 Demo 的主要问题:
| 问题 | 风险 |
|---|---|
ChatService 承担过多职责 |
后续会继续堆 if-else,无法维护 |
| 会话状态以内存 Map 保存 | 服务重启丢失,无法断线恢复 |
| 患者档案写 CSV | 不具备真实 HIS / 患者主索引能力 |
| 医生、排班、号源为随机模拟 | 容易误导为真实业务闭环 |
| 舌诊部分链路可能前端直连 | 权限、审计、计量、文件治理失控 |
| FAQ 本地 txt | 缺少知识库版本、院区差异、来源引用 |
| 无 Card Runtime | 卡片只是 UI,不能承载真实动作状态 |
| 无 Device Registry | 多终端无法统一授权、场景绑定、灰度、监控 |
| 无完整审计与 Trace | 医疗场景不可接受 |
目标工程应拆成两条线:
前端线:emoon-terminal-client
后端线:emoon-ai-platform / emoon-openplatform
前端继续沿用 Vue 3,但要从 Demo 前端改造成正式 Terminal Client;后端能力纳入 AI 中台,不再保留独立 Demo 后端。
统一入口客户端不是凭空设计。公开案例能证明“对话式入口 + 多终端承载 + 后端统一编排”方向可行,但医梦不能照搬大厂完整平台规模,必须按一期 MVP 裁剪。
| 参考对象 | 公开可验证做法 | 对医梦的启发 | 医梦取舍 |
|---|---|---|---|
| 支付宝 AI 健康管家 | 蚂蚁集团公开资料显示其提供找医生、读报告、陪看诊、问医保等 AI 健康服务 | 医疗入口正在从多级菜单转向“用户表达诉求,系统组织服务” | 一期只做导诊、挂号 Mock、舌诊和路线,不做医保、真实支付和全量陪诊 |
| 腾讯云智能导诊 / 腾讯健康智能预问诊 | 腾讯云智能导诊覆盖导诊、智能问答、科普宣教,可应用于微信挂号等场景;腾讯健康智能预问诊支持公众号、小程序、互联网医院、院内终端等多终端部署 | 导诊和预问诊适合做统一入口,但必须服务后续挂号或医生工作流 | 后端统一路由,前端按设备能力裁剪,不把导诊大屏做成隐私办理终端 |
| 卫宁 WiNEX | CHIMA 公开报道显示 WiNEX 覆盖门诊医生站、住院医生站、病区护士站等业务场景,并强调业务/数据/技术中台 | 大厂通过中台支撑多场景,证明共性底座方向可行 | 医梦只沉淀 AI 入口、卡片、设备场景、工具审计,不建设完整医院核心系统 |
| 科大讯飞智慧医疗 / 智医助理 | 讯飞智慧医疗官网公开介绍语音、语义理解、临床决策支持等能力 | 语音交互、多轮追问和辅助决策可以进入医疗工作流 | 输出必须定位为就医引导或辅助分析,不做自动诊断和自动处方 |
| Dify Workflow | Dify 官方文档提供 Workflow/Chatflow 版本管理;Dify 也支持流式应用调用 | Dify 可承担场景编排和版本管理 | 医梦只做 Dify 版本映射、输出校验和卡片归一,不自研 Workflow 平台 |
因此,本方案的原则是:交互形态参考互联网医疗入口,后端治理参考医疗中台,落地范围按创业团队资源只做 P0 闭环。
| 终端 | 一期定位 | 核心能力 | 明确不做 |
|---|---|---|---|
| 机器人端 | 展示性最强的语音导诊入口 | 语音问答、科室推荐、路线导航、舌诊入口引导 | 复杂实名建档、支付闭环、正式挂号确认 |
| 自助机端 | 业务闭环最强的办理入口 | 建档、预问诊、分诊、挂号 Mock、舌诊采集 | 真实医保支付、退号退费、医生站能力 |
| 导诊大屏端 | 公共导流入口 | FAQ、科室介绍、路线引导、轻量分诊 | 身份证上传、舌象采集、报告解读、个人隐私展示 |
| 优先级 | 能力 | 说明 |
|---|---|---|
| P0 | Device Registry MVP | 设备注册、心跳、能力、场景绑定 |
| P0 | Scene Profile | 不同终端加载不同首页、Agent 和卡片权限 |
| P0 | Perception Event MVP | 扫码、NFC、刷卡、设备交互和诊区近场事件接入 |
| P0 | Journey Context MVP | 根据任务状态、设备事件和 HIS/Mock 状态返回当前动线阶段 |
| P0 | Agent Chat Stream | 统一替换 Demo /chat/messages |
| P0 | AgentRouter | 后端统一路由到不同 Dify App |
| P0 | Conversation Service | 会话和消息持久化、断线恢复 |
| P0 | TaskStateService | 挂号、舌诊、导航等任务状态机 |
| P0 | Card Runtime MVP | 卡片定义、实例、动作、状态、幂等 |
| P0 | File Service | 身份证、舌象、音频、报告文件治理 |
| P0 | DifyAgentEngine | 接入四个 Dify Workflow |
| P0 | HisMockAdapter | 科室、医生、排班、建档、挂号 Mock |
| P0 | TongueDiagnosisAdapter | 封装已有舌诊接口 |
| P0 | Audit / Meter / Trace | 审计、用量、链路追踪 |
| P1 | 真实 HIS Adapter | 等你提供真实接口后替换 Mock |
| P1 | 报告解读 | 可复用 File Service + Report Agent |
| P1 | 运营看板 | 会话量、设备在线率、卡片动作成功率、失败原因 |
真实支付
医保结算
退号退费
全院鸿蒙无感定位
患者连续轨迹
医生站插件
住院入口
完整账单中心
多院区域平台
第三方卡片市场
flowchart TB
subgraph Client["统一入口客户端 Terminal Client"]
Robot["机器人端:语音导诊 / 导航 / 问答"]
Kiosk["自助机端:建档 / 挂号 / 舌诊 / 报告"]
Screen["导诊大屏:FAQ / 科室介绍 / 路线引导"]
end
subgraph OpenPlatform["AI 中台 OpenPlatform"]
Auth["鉴权与项目上下文 AuthContextResolver"]
Device["设备上下文 DeviceContextResolver"]
Scene["场景配置 SceneProfileService"]
Perception["感知事件 PerceptionEventService"]
Journey["动线上下文 JourneyContextService"]
Conversation["会话服务 ConversationService"]
Task["任务状态机 TaskStateService"]
Router["AgentRouter:设备策略 + 任务状态 + 规则 + LLM 分类"]
Dispatcher["AgentDispatcher:选择 Dify App / Direct / Mock"]
AgentOrch["AgentActionOrchestrator:卡片动作后的业务编排"]
Card["Card Runtime:卡片定义 / 实例 / 动作 / 状态机"]
File["File Service:身份证 / 舌象 / 报告 / 音频"]
Audit["Audit / Meter / Trace"]
end
subgraph Dify["Dify Workflow Apps"]
Guide["opd-guide-agent:导诊问答 / FAQ / 导航"]
Triage["opd-triage-agent:症状追问 / 科室推荐"]
Register["opd-registration-agent:建档 / 挂号流程"]
Tongue["tongue-diagnosis-agent:舌诊问诊 / 结果解释"]
end
subgraph Tool["MCP / Adapter 工具层"]
MCP["MCP Tool Service:统一工具网关"]
HisMock["HisMockAdapter:科室 / 医生 / 排班 / 挂号 Mock"]
HisReal["RealHisAdapter:后续真实 HIS 接口"]
TongueApi["TongueDiagnosisAdapter:已有舌诊接口"]
DeviceCmd["Device Command:机器人导航 / 设备指令"]
end
Robot --> OpenPlatform
Kiosk --> OpenPlatform
Screen --> OpenPlatform
OpenPlatform --> Auth --> Device --> Scene --> Perception --> Journey --> Conversation --> Task --> Router --> Dispatcher
Dispatcher --> Guide
Dispatcher --> Triage
Dispatcher --> Register
Dispatcher --> Tongue
Dispatcher --> Card
Card --> AgentOrch
AgentOrch --> MCP
File --> TongueApi
MCP --> HisMock
MCP --> HisReal
AgentOrch --> DeviceCmd
Card --> Audit
AgentOrch --> Audit
Dispatcher --> Audit
File --> Audit
Device --> Audit
| 模块 | 负责 | 不负责 |
|---|---|---|
| Terminal Client | 交互、卡片展示、语音/摄像头/扫码/导航 Bridge | 不做最终业务判断 |
| Device Registry | 设备身份、能力、位置、心跳、版本、场景绑定 | 不处理医疗业务 |
| Scene Profile | 根据设备类型返回首页模板、允许 Agent、允许卡片、禁止意图 | 不调用模型 |
| PerceptionEventService | 接收扫码、NFC、刷卡、近场、设备交互和业务状态事件 | 不保存患者连续轨迹,不自建全院定位平台 |
| JourneyContextService | 推导当前动线阶段、下一步服务、可展示卡片和服务找人触发条件 | 不自动签到、自动缴费、自动诊断 |
| Conversation Service | 会话、消息、上下文、断线恢复 | 不做意图判断 |
| TaskStateService | 多轮任务实例、currentStep、任务上下文 | 不调用 Dify |
| AgentRouter | 统一路由到 Dify App | 不生成医学回答 |
| DifyAgentEngine | 调用 Dify Workflow,同步/流式解析 | 不直接写 HIS,不直接建卡片实例 |
| DifyOutputNormalizer | 校验 Dify 输出结构、卡片 schema、风险等级 | 不执行卡片动作 |
| Card Runtime | 卡片实例、动作、状态机、幂等、动作日志 | 不直接生成自然语言回答 |
| MCP Tool Service | HIS/EMR/LIS/PACS/叫号/支付等工具统一出口 | 不处理 UI |
| HisMockAdapter | 无 HIS 接口阶段的显式 Mock | 不伪装成真实业务系统 |
| TongueDiagnosisAdapter | 封装已有舌诊接口 | 不允许前端直连 |
| Audit/Meter/Trace | 审计、计量、链路追踪、排障 | 不参与医疗判断 |
Scene Profile 和 AgentRouter 的边界必须固定:Scene Profile 负责设备/场景级权限裁剪,返回 allowedAgents/allowedCards/homeTemplate/perceptionCapabilities/journeyPolicy;AgentRouter 只能在 allowedAgents 范围内,根据用户输入、任务状态和服务端 journeyState 选择具体 Dify App。前端不得用本地配置覆盖场景白名单。
emoon-terminal-client
├── apps
│ ├── robot-client
│ ├── kiosk-client
│ └── guide-screen-client
│
├── packages
│ ├── api-client
│ ├── medical-cards
│ ├── terminal-bridge
│ ├── terminal-theme
│ └── terminal-core
| 层次 | 共用 | 差异 |
|---|---|---|
| API SDK | 统一调用 OpenPlatform 接口 | 无 |
| 卡片组件 | 科室卡、医生卡、路线卡、舌诊卡、建档卡 | 不同终端允许渲染的卡片不同 |
| Bridge | 统一 JSBridge 抽象 | 机器人有导航,自助机有读卡/打印,导诊大屏能力最少 |
| 主题 | 医梦主题 + 医院主题 | 屏幕尺寸、字体、按钮密度不同 |
| 首页 | 场景入口和聊天主控件可复用 | 三类终端首页布局不同 |
前端只调用:
- /devices/register
- /devices/{deviceId}/heartbeat
- /devices/{deviceId}/scene
- /agent/chat/stream
- /files/upload
- /cards/{cardInstanceId}
- /cards/{cardInstanceId}/actions/{actionName}
- /devices/{deviceId}/events
前端不调用:
- Dify API
- HIS Tool API
- 舌诊底层接口
- 计量账本写接口
sequenceDiagram
autonumber
participant T as 终端客户端
participant D as DeviceRegistry
participant S as SceneProfileService
participant A as AgentCenter
participant C as CardDefinitionService
participant UI as TerminalUI
T->>D: POST /devices/register
alt 设备准入通过
D-->>T: deviceId + deviceSecret + activateStatus=activated
else 待人工准入
D-->>T: activateStatus=pending + reason
T->>UI: 展示待激活状态,不进入核心业务闭环
else 准入拒绝或设备等级为 C/D
D-->>T: activateStatus=rejected + reason
T->>UI: 展示拒绝原因,仅允许健康检查或联系运维
end
T->>D: POST /devices/{deviceId}/heartbeat
D-->>T: online + serverTime + upgradePolicy
T->>S: GET /devices/{deviceId}/scene
S->>A: 查询允许调用的 Agent
A-->>S: allowedAgents + defaultAgent
S->>C: 查询允许渲染的卡片类型
C-->>S: cardTemplates
S-->>T: sceneProfile
T->>UI: 根据 sceneProfile 加载首页
{
"deviceId": "EMOON-KIOSK-001",
"deviceType": "self_service_kiosk",
"sceneCode": "outpatient_kiosk",
"defaultAgent": "opd-guide-agent",
"allowedAgents": [
"opd-guide-agent",
"opd-triage-agent",
"opd-registration-agent",
"tongue-diagnosis-agent"
],
"blockedIntents": [],
"capabilities": [
"touch",
"camera",
"id_card_ocr",
"file_upload",
"printer_reserved"
],
"homeTemplate": "kiosk_home_v1"
}
| 策略 | 机器人 | 自助机 | 导诊大屏 |
|---|---|---|---|
| FAQ | 允许 | 允许 | 允许 |
| 科室推荐 | 允许 | 允许 | 允许 |
| 路线导航 | 允许,且可调用机器人 SDK | 允许展示路线 | 允许展示路线 |
| 建档 | 不主推,引导到自助机 | 允许 | 禁止 |
| 挂号 | 只做引导或轻量演示 | 允许 Mock / 后续真实 | 禁止 |
| 舌象采集 | 可引导,不建议主采集 | 允许 | 禁止 |
| 报告解读 | 后置 | 后置 | 禁止 |
sequenceDiagram
autonumber
participant U as 用户
participant C as 统一入口客户端
participant API as AgentChatController
participant Auth as AuthContextResolver
participant Device as DeviceContextResolver
participant Scene as SceneProfileService
participant Conv as ConversationService
participant Task as TaskStateService
participant Router as AgentRouterService
participant Dispatch as AgentDispatcher
participant Dify as DifyAgentEngine
participant Normalizer as DifyOutputNormalizer
participant Card as CardRuntime
participant Audit as AuditMeterTrace
U->>C: 输入语音、文本或点击快捷入口
C->>API: POST /agent/chat/stream
API->>Auth: 校验项目、医院、终端鉴权
Auth-->>API: projectContext
API->>Device: 解析 deviceId、deviceType、capabilities
Device-->>API: deviceContext
API->>Scene: 获取当前设备场景配置
Scene-->>API: sceneProfile、allowedAgents、blockedIntents
API->>Conv: 创建或加载 conversation
Conv-->>API: conversationContext
API->>Task: 查询 activeTask
Task-->>API: 当前任务状态或空
API->>Router: 路由决策
Router-->>API: RouteDecision
alt 当前设备不允许该能力
API-->>C: SSE 返回 blocked message
else 允许执行
API->>Dispatch: 分发到目标 Agent
Dispatch->>Dify: 调用对应 Dify Workflow
Dify-->>Dispatch: 文本、结构化卡片、taskUpdate
Dispatch->>Normalizer: 校验 Dify 输出
Normalizer-->>Dispatch: NormalizedAgentResponse
alt 返回卡片
Dispatch->>Card: 创建 cardInstance
Card-->>Dispatch: cardInstanceId
end
Dispatch->>Conv: 保存消息和任务上下文
Dispatch->>Audit: 写审计、用量、Trace
Dispatch-->>C: SSE 返回文本、卡片、完成事件
end
POST /api/v1/agent/chat/stream
请求示例:
{
"conversationId": "conv_001",
"message": {
"type": "text",
"content": "我头疼三天,有点恶心,想挂号"
},
"deviceContext": {
"deviceId": "EMOON-KIOSK-001"
},
"attachments": [],
"clientContext": {
"terminalType": "self_service_kiosk",
"locale": "zh-CN"
}
}
SSE 事件示例:
{ "type": "message_delta", "content": "我先了解一下您的头痛情况。" }
{ "type": "message_completed", "messageId": "msg_001" }
{ "type": "card_created", "cardInstanceId": "card_001", "cardKey": "department-selection" }
{ "type": "usage_reported", "traceId": "trace_001" }
{ "type": "completed", "conversationId": "conv_001" }
AgentRouterService 不是简单意图识别。它是一个后端业务分发器,需要综合判断:
设备策略
+ 当前场景
+ 当前动线阶段
+ 当前会话
+ 当前任务状态
+ 等待卡片状态
+ 确定性规则
+ FAQ 命中
+ LLM 意图分类
正式路由顺序:
| 顺序 | 决策层 | 说明 |
|---|---|---|
| 1 | 设备策略 | 当前终端能不能做这件事 |
| 2 | 动线阶段 | 已挂号未签到、待缴费、待检查等状态优先推荐下一步服务 |
| 3 | 当前任务 | 有 activeTask 时优先推进当前任务 |
| 4 | 等待卡片 | 有待操作卡片时优先处理卡片动作或提示 |
| 5 | 确定性规则 | 舌诊、挂号、带我去、我要建档 |
| 6 | FAQ / 知识库 | 公共问答优先走导诊 Agent |
| 7 | LLM 分类 | 规则无法判断时兜底 |
| 8 | 反问澄清 | 低置信度时不强行进入流程 |
一期 MVP 不实现完整规则推理引擎,也不把 deterministicRouter、faqRouter、llmIntentClassifier 做成三套复杂子系统。第一期路由收敛为:
设备策略
→ 动线阶段优先
→ 活跃任务优先
→ 等待卡片优先
→ 少量确定性关键词(挂号 / 舌诊 / 带我去 / 建档 / 查科室)
→ FAQ 精确匹配
→ LLM 兜底分类
→ 低置信度反问
这样做的依据是行业产品通常不会完全依赖自然语言路由。支付宝、腾讯健康等对话式就医入口背后仍然有固定服务入口、业务上下文和确认卡片;医梦一期也应优先让确定性流程稳定,再逐步扩大 LLM 路由范围。
flowchart TD
A["收到用户输入"] --> B["设备策略校验"]
B --> C{"当前设备是否禁止该能力"}
C -->|"是"| D["返回禁止提示"]
C -->|"否"| E["查询当前会话任务"]
E --> F{"是否存在活跃任务"}
F -->|"是"| G{"用户是否明确取消或切换任务"}
G -->|"否"| H["优先推进当前任务"]
G -->|"是"| I["取消或切换任务"]
F -->|"否"| J["查询等待中的卡片"]
I --> K["确定性规则识别"]
J --> L{"是否存在待操作卡片"}
L -->|"是"| M{"用户输入能否映射为卡片动作"}
M -->|"是"| N["转入 AgentActionOrchestrator + CardActionService"]
M -->|"否"| O["提示先完成卡片操作"]
L -->|"否"| K
H --> P["进入任务对应 Agent"]
K --> Q{"规则是否命中"}
Q -->|"是"| R["生成路由决策"]
Q -->|"否"| S["FAQ 或知识库检索"]
S --> T{"FAQ 是否命中"}
T -->|"是"| U["路由到导诊 Agent"]
T -->|"否"| V["LLM 意图分类"]
V --> W{"置信度是否足够"}
W -->|"否"| X["反问澄清"]
W -->|"是"| Y["生成路由决策"]
P --> Y
R --> Y
U --> Y
Y --> Z["AgentDispatcher 调用目标 Dify App"]
{
"routeAgentCode": "opd-triage-agent",
"scenarioCode": "OUTPATIENT_TRIAGE",
"intentCode": "SYMPTOM_TRIAGE",
"confidence": 0.91,
"routeReason": "用户描述症状,且当前无活跃任务",
"taskPolicy": "CREATE_NEW_TASK",
"riskLevel": "L2",
"requiredIdentityLevel": "ANONYMOUS_ALLOWED",
"allowedOnCurrentDevice": true,
"slots": {
"symptom": "头疼三天,有点恶心"
}
}
public RouteDecision route(AgentChatRequest request, ChatContext context) {
DevicePolicyResult devicePolicy = devicePolicyService.check(request, context);
if (devicePolicy.isBlocked()) {
return RouteDecision.blocked(devicePolicy.getMessage());
}
TaskInstance activeTask = taskStateService.getActiveTask(context.getConversationId());
if (activeTask != null && !request.isExplicitInterrupt()) {
return taskRouter.routeByActiveTask(request, context, activeTask);
}
CardInstance waitingCard = cardService.getWaitingCard(context.getConversationId());
if (waitingCard != null && !request.isExplicitCancelOrSwitch()) {
return cardWaitingRouter.route(request, context, waitingCard);
}
// MVP: deterministicRouter 先做少量关键词和场景规则,不建设完整规则引擎。
Optional<RouteDecision> ruleDecision = deterministicRouter.tryRoute(request, context);
if (ruleDecision.isPresent()) {
return ruleDecision.get();
}
// MVP: faqRouter 先做精确命中或知识库召回,不做复杂多轮 FAQ 策略。
Optional<RouteDecision> faqDecision = faqRouter.tryRoute(request, context);
if (faqDecision.isPresent()) {
return faqDecision.get();
}
RouteDecision llmDecision = llmIntentClassifier.classify(request, context);
if (llmDecision.getConfidence() < 0.65) {
return RouteDecision.askClarification();
}
return llmDecision;
}
| Dify App | 场景 | 输入 | 输出 |
|---|---|---|---|
opd-guide-agent |
FAQ、科室介绍、路线、公共导诊 | 用户问题、设备位置、医院知识库 | 文本、路线卡片、导航意图 |
opd-triage-agent |
症状追问、预问诊、科室推荐 | 症状描述、追问历史、患者基础信息 | 追问卡片、科室推荐卡片、风险提示 |
opd-registration-agent |
建档和挂号流程话术编排 | 患者身份、科室、医生、号源、任务状态 | 建档引导、确认卡片、异常解释 |
tongue-diagnosis-agent |
舌诊问诊、结果解释、后续建议 | 主诉、舌诊结果摘要、患者上下文 | 舌象采集卡片、结果解释、调养建议 |
flowchart TD
A["统一入口客户端输入"] --> B["AgentRouterService"]
B --> C{"意图或任务类型"}
C -->|"FAQ / 科室 / 路线"| D["opd-guide-agent"]
C -->|"症状分诊 / 预问诊"| E["opd-triage-agent"]
C -->|"建档 / 挂号"| F["opd-registration-agent"]
C -->|"舌诊"| G["tongue-diagnosis-agent"]
D --> H["DifyOutputNormalizer"]
E --> H
F --> H
G --> H
H --> I["Card Runtime"]
H --> J["ConversationService"]
H --> K["Audit / Meter / Trace"]
{
"projectId": "hospital_demo",
"hospitalId": "H001",
"device": {
"deviceId": "EMOON-KIOSK-001",
"deviceType": "self_service_kiosk",
"location": "门诊大厅一楼",
"capabilities": ["touch", "camera", "id_card_ocr", "printer"]
},
"scene": {
"sceneCode": "outpatient_kiosk",
"allowedActions": ["create_record", "triage", "registration", "tongue_diagnosis"]
},
"conversation": {
"conversationId": "conv_001",
"recentMessages": []
},
"task": {
"taskType": "REGISTRATION",
"currentStep": "COLLECT_SYMPTOM",
"context": {}
},
"patient": {
"identityStatus": "UNKNOWN",
"patientId": null
},
"router": {
"intentCode": "SYMPTOM_TRIAGE",
"slots": {
"symptom": "头疼三天,有点恶心"
}
}
}
Dify 必须输出结构化 JSON。不能只自由输出 Markdown。
{
"schemaVersion": "1.0",
"answer": "根据您的描述,我建议先补充几个问题,以便判断更合适的科室。",
"taskUpdate": {
"taskType": "REGISTRATION",
"currentStep": "COLLECT_SYMPTOM",
"status": "ACTIVE",
"contextPatch": {
"symptom": "头疼三天,有点恶心"
}
},
"cards": [
{
"cardKey": "symptom-followup",
"cardData": {
"questions": [
"头痛是持续性还是阵发性?",
"是否伴随发热或视物模糊?"
]
},
"actions": [
{
"actionName": "submit_symptom_followup",
"label": "提交补充信息"
}
]
}
],
"safety": {
"riskLevel": "L2",
"needHuman": false,
"disclaimer": "本结果仅用于就医引导,不能替代医生诊断。"
}
}
DifyOutputNormalizer 是 DifyAgentEngine 的内部后处理组件,不是独立 Maven 模块,也不属于 emoon-openplatform。它应落在 emoon-ai-agent 的 adapter/application 边界内,职责是把 Dify 的文本、结构化 JSON、SSE 事件和 Workflow 输出归一成平台稳定事件。
| 校验项 | 处理 |
|---|---|
| JSON 不合法 | 降级为普通文本,记录 Dify 输出告警 |
cardKey 不存在 |
不创建卡片 |
cardData 不符合 schema |
不创建卡片,返回平台错误 |
actionName 未在卡片定义中 |
拒绝非法动作 |
| 当前设备不允许该卡片 | 拒绝创建卡片 |
| 风险等级过高 | 强制人工确认或二次确认 |
| 敏感字段进入 Dify 输出 | 拦截并记录安全告警 |
Normalizer 不执行卡片动作、不调用 HIS、不扣能力值。校验不通过时,只能拒绝创建卡片、记录告警并返回可解释降级结果,避免 Dify 输出不稳定影响后续 Card Runtime、审计和计量。
建议新增表:ai_task_instance
| 字段 | 说明 |
|---|---|
id |
任务实例 ID |
conversation_id |
会话 ID |
task_type |
GUIDE / TRIAGE / REGISTRATION / TONGUE_DIAGNOSIS |
status |
ACTIVE / WAITING_CARD_ACTION / PROCESSING / COMPLETED / CANCELLED / FAILED |
current_step |
当前步骤 |
agent_code |
当前绑定 Dify App |
context_json |
任务上下文 |
patient_id |
患者 ID,可为空 |
device_id |
发起设备 |
trace_id |
链路 ID |
created_at |
创建时间 |
updated_at |
更新时间 |
REGISTRATION_START
→ CHECK_PATIENT_IDENTITY
→ NEED_CREATE_RECORD
→ PATIENT_RECORD_CONFIRM
→ COLLECT_SYMPTOM
→ TRIAGE_RECOMMEND_DEPARTMENT
→ WAIT_SELECT_DEPARTMENT
→ QUERY_DOCTOR_SCHEDULE
→ WAIT_SELECT_DOCTOR
→ WAIT_SELECT_TIME_SLOT
→ WAIT_CONFIRM_APPOINTMENT
→ CREATE_APPOINTMENT
→ APPOINTMENT_SUCCESS
一期 Mock 阶段不要求一次落完完整挂号状态机。核心验证步骤收敛为:
COLLECT_SYMPTOM
→ TRIAGE_RECOMMEND_DEPARTMENT
→ WAIT_SELECT_DEPARTMENT
→ QUERY_DOCTOR_SCHEDULE
→ WAIT_SELECT_DOCTOR
→ WAIT_SELECT_TIME_SLOT
→ WAIT_CONFIRM_APPOINTMENT
→ CREATE_APPOINTMENT
→ APPOINTMENT_SUCCESS
CHECK_PATIENT_IDENTITY、NEED_CREATE_RECORD、PATIENT_RECORD_CONFIRM 可以先复用自助机既有身份证/就诊卡能力或使用 Mock 患者上下文。OCR 建档不作为第一期挂号闭环的硬前置,避免把 MVP 卡在硬件和身份接口联调上。
TONGUE_START
→ CHECK_PATIENT_IDENTITY
→ COLLECT_CHIEF_COMPLAINT
→ WAIT_UPLOAD_TONGUE_IMAGE
→ CHECK_IMAGE_QUALITY
→ START_TONGUE_DIAGNOSIS
→ POLL_TONGUE_RESULT
→ SHOW_TONGUE_RESULT
→ COMPLETED
GUIDE_START
→ UNDERSTAND_DESTINATION
→ MATCH_LOCATION
→ SHOW_ROUTE_CARD
→ SEND_ROBOT_NAV_COMMAND
→ WAIT_DEVICE_EVENT
→ NAVIGATION_SUCCESS / NAVIGATION_FAILED
有 activeTask 时,优先推进当前任务。
没有 activeTask 时,再做意图路由。
示例:
当前任务:REGISTRATION
当前步骤:WAIT_SELECT_TIME_SLOT
用户输入:下午
系统行为:解释为选择下午时段
错误行为:重新做意图识别,把“下午”判成未知意图
卡片不是普通 UI 组件,而是确定性医疗业务动作的承载单元。
| 卡片 | 用途 |
|---|---|
idcard-capture |
身份证采集 |
patient-record-confirm |
建档信息确认 |
symptom-followup |
症状追问 |
department-selection |
科室推荐和选择 |
doctor-selection |
医生选择 |
time-slot-selection |
号源时段选择 |
confirm-appointment |
挂号最终确认 |
appointment-success |
挂号结果 |
route-card |
路线展示 / 导航确认 |
tongue-capture |
舌象采集 |
tongue-diagnosis-result |
舌诊结果 |
active
→ submitted
→ processing
→ completed
active
→ expired
processing
→ failed
→ active
flowchart LR
subgraph FE["统一入口客户端"]
A1["展示卡片"]
A2["用户点击按钮"]
end
subgraph CR["Card Runtime"]
B1["校验卡片实例"]
B2["幂等校验"]
B3["记录动作日志"]
B4["更新卡片状态"]
end
subgraph TS["TaskStateService"]
C1["读取任务状态"]
C2["推进当前步骤"]
C3["保存任务上下文"]
end
subgraph Tool["工具与业务执行"]
D1["MCP Tool Service"]
D2["HisMockAdapter 或 RealHisAdapter"]
D3["TongueDiagnosisAdapter"]
D4["DeviceCommandService"]
end
subgraph AI["Dify 或 Agent"]
E1["可选 synthetic message"]
E2["生成下一轮文本或卡片"]
end
subgraph Log["审计与计量"]
F1["AuditLog"]
F2["MeterEvent"]
F3["TraceLog"]
end
A1 --> A2 --> B1 --> B2 --> B3 --> C1 --> C2
C2 --> AG["AgentActionOrchestrator"]
AG --> D1
D1 --> D2
AG --> D3
AG --> D4
D2 --> B4
D3 --> B4
D4 --> B4
B4 --> C3
C3 --> E1 --> E2
B4 --> F1
B4 --> F2
B4 --> F3
POST /api/v1/cards/{cardInstanceId}/actions/{actionName}
请求示例:
{
"conversationId": "conv_001",
"idempotencyKey": "idem_001",
"payload": {
"departmentId": "neurology"
},
"deviceContext": {
"deviceId": "EMOON-KIOSK-001"
}
}
在没有真实 HIS 接口前,必须显式使用 HisMockAdapter。
CardActionController
→ AgentActionOrchestrator
→ CardActionService(状态校验 / 幂等 / 快照)
→ McpToolService
→ HospitalAdapter SPI
→ HisMockAdapter
后续接入真实接口后:
CardActionController
→ AgentActionOrchestrator
→ CardActionService(状态校验 / 幂等 / 快照)
→ McpToolService
→ HospitalAdapter SPI
→ RealHisAdapter
这样保持唯一工具调用链:卡片负责确定性状态,Agent 编排负责业务动作,MCP 负责工具治理。禁止出现 Card Runtime 直接绕过 Agent 编排调用 HIS/舌诊/设备 SDK。
| 工具 | Mock 阶段 | 真实阶段 |
|---|---|---|
queryDepartments |
返回静态科室 | HIS/院内基础数据 |
queryDoctors |
根据科室返回模拟医生 | HIS 医生字典/排班系统 |
querySchedules |
返回模拟排班号源 | HIS 号源接口 |
createPatient |
创建 Mock 患者 ID | 患者主索引 / HIS 建档 |
lockSchedule |
Mock 锁号 | HIS 锁号接口 |
createAppointment |
Mock 挂号结果 | HIS 挂号/预约接口 |
cancelAppointment |
P1 | HIS 退号/取消接口 |
queryQueue |
P1 | 叫号系统 |
禁止 ChatService 直接随机生成医生
禁止前端写死号源
禁止 Dify 直接返回“挂号成功”
禁止 AppointmentService 直接生成二维码冒充真实支付
所有图像、音频、报告、身份证、舌象都必须先上传到 File Service,业务接口只传 fileId。
前端上传文件
→ File Service 保存元数据
→ 返回 fileId
→ Agent / CardAction 使用 fileId
→ Adapter 在后端受控访问文件
| 字段 | 说明 |
|---|---|
file_id |
文件 ID |
project_id |
项目 ID |
hospital_id |
医院 ID |
device_id |
上传设备 |
business_type |
ID_CARD / TONGUE_IMAGE / REPORT_IMAGE / AUDIO |
sha256 |
文件摘要,用于去重和审计 |
storage_path |
存储路径 |
retention_policy |
留存策略 |
sensitive_level |
敏感等级 |
created_at |
创建时间 |
| 方法 | 说明 |
|---|---|
checkImageQuality(fileId) |
校验是否为舌象、清晰度、光照等 |
startDiagnosis(fileId, chiefComplaint, patientContext) |
调用已有舌诊接口启动诊断 |
queryDiagnosisResult(diagnosisId) |
查询结果 |
normalizeResult(rawResult) |
归一化为平台舌诊结果结构 |
| 错误做法 | 正确做法 |
|---|---|
| 前端直接调用舌诊接口 | 后端统一通过 TongueDiagnosisAdapter 调用 |
| base64 直接塞进 Dify | 先上传文件,后续只传 fileId 或摘要 |
| 舌诊结果写成诊断 | 表述为 AI 辅助分析,医生确认优先 |
| 导诊大屏采集舌象 | 禁止 |
| 无文件审计 | 必须记录文件上传、访问、删除、诊断调用 |
flowchart TD
A["用户提出挂号或描述症状"] --> B["调用 agent chat stream"]
B --> C["AgentRouter 路由"]
C --> D{"当前设备是否允许挂号"}
D -->|"导诊大屏"| E["提示前往自助机办理实名挂号"]
D -->|"机器人"| F["机器人先推荐科室并引导到自助机"]
D -->|"自助机"| G["创建 REGISTRATION 任务"]
G --> H{"是否已有患者身份"}
H -->|"否"| I["创建身份证采集卡片"]
I --> J["调用 files upload 上传身份证图片"]
J --> K["OCR 识别身份证"]
K --> L["创建建档确认卡片"]
L --> M["CardAction 确认建档"]
M --> N["HisMockAdapter 创建患者"]
N --> O["患者身份就绪"]
H -->|"是"| O
O --> P{"是否已有症状信息"}
P -->|"否"| Q["registration agent 追问症状"]
Q --> R["用户补充症状"]
R --> S["triage agent 症状分析"]
P -->|"是"| S
S --> T["创建科室推荐卡片"]
T --> U["用户选择科室"]
U --> V["MCP 查询医生"]
V --> W["创建医生选择卡片"]
W --> X["用户选择医生"]
X --> Y["MCP 查询排班"]
Y --> Z["创建时段选择卡片"]
Z --> AA["用户选择时段"]
AA --> AB["MCP 锁定号源"]
AB --> AC["创建确认挂号卡片"]
AC --> AD["用户最终确认"]
AD --> AE["MCP 创建预约"]
AE --> AF["返回挂号成功卡片"]
AF --> AG["写审计 用量 Trace"]
| 步骤 | 模块 | 说明 |
|---|---|---|
| 1 | AgentRouterService |
识别为挂号/症状分诊,并判断设备是否允许 |
| 2 | TaskStateService |
创建 REGISTRATION 任务 |
| 3 | CardRuntime |
创建身份证采集卡 |
| 4 | FileService |
上传身份证图片,返回 fileId |
| 5 | OCRService |
识别身份证信息 |
| 6 | CardRuntime |
创建建档确认卡 |
| 7 | AgentActionOrchestrator + CardActionService |
用户确认后进入建档动作 |
| 8 | HisMockAdapter |
创建 Mock 患者 |
| 9 | opd-triage-agent |
多轮追问与科室推荐 |
| 10 | McpToolService |
查询医生、排班、锁号、创建预约 |
| 11 | CardRuntime |
返回医生卡、时段卡、确认卡、成功卡 |
| 12 | Audit/Meter/Trace |
全链路记录 |
| 验收项 | 标准 |
|---|---|
| 自助机可发起挂号 | 能进入 REGISTRATION 任务 |
| 导诊大屏不可挂号 | 返回明确限制提示 |
| 机器人不做实名挂号 | 可以推荐科室,并引导去自助机 |
| 身份证上传 | 走 File Service,不直接 base64 穿透业务接口 |
| 医生号源 | 全部来自 HisMockAdapter,不写死在前端或 Dify |
| 确认挂号 | 必须走 CardAction,且带幂等键 |
| 结果可恢复 | conversationId 可查历史消息和卡片状态 |
| 审计完整 | 能查到路由、任务、卡片动作、工具调用、traceId |
flowchart TD
A["用户提出舌诊"] --> B["调用 agent chat stream"]
B --> C["AgentRouter 路由到舌诊 Agent"]
C --> D{"当前设备是否允许舌象采集"}
D -->|"导诊大屏"| E["禁止采集隐私图像 提示去自助机"]
D -->|"机器人"| F["机器人讲解并引导到自助机"]
D -->|"自助机"| G["创建 TONGUE_DIAGNOSIS 任务"]
G --> H{"是否需要患者身份"}
H -->|"需要且无身份"| I["创建临时身份或进入建档流程"]
H -->|"已有身份或允许临时"| J["Dify 采集主诉"]
I --> J
J --> K["创建舌象采集卡片"]
K --> L["调用 files upload 上传舌象图片"]
L --> M["FileService 保存 fileId"]
M --> N["CardAction 提交舌象图片"]
N --> O["TongueDiagnosisAdapter 校验图片质量"]
O --> P{"图片质量是否合格"}
P -->|"否"| Q["提示重新拍摄"]
Q --> K
P -->|"是"| R["TongueDiagnosisAdapter 启动诊断"]
R --> S["轮询或查询诊断结果"]
S --> T["返回舌诊结果"]
T --> U["创建舌诊结果卡片"]
U --> V["舌诊 Agent 解释结果"]
V --> W["任务完成"]
W --> X["审计 用量 Trace"]
| 步骤 | 模块 | 说明 |
|---|---|---|
| 1 | AgentRouterService |
识别为舌诊,并做设备策略校验 |
| 2 | TaskStateService |
创建 TONGUE_DIAGNOSIS 任务 |
| 3 | tongue-diagnosis-agent |
收集主诉 |
| 4 | CardRuntime |
创建舌象采集卡 |
| 5 | FileService |
上传舌象图片,返回 fileId |
| 6 | AgentActionOrchestrator + CardActionService |
提交舌象图片动作 |
| 7 | TongueDiagnosisAdapter |
校验图片质量、启动诊断、查询结果 |
| 8 | CardRuntime |
创建舌诊结果卡 |
| 9 | tongue-diagnosis-agent |
对结果做患者可理解解释 |
| 10 | Audit/Meter/Trace |
全链路记录 |
| 验收项 | 标准 |
|---|---|
| 导诊大屏不能舌诊采集 | 返回隐私限制提示 |
| 自助机可以舌诊 | 能上传舌象并获取结果 |
| 机器人只做引导 | 不强依赖机器人摄像头采集质量 |
文件必须有 fileId |
不允许 base64 直接进入 Dify |
| 舌诊结果卡片化 | 结果以 tongue-diagnosis-result 卡展示 |
| 结果表述安全 | 强调 AI 辅助分析,不替代医生诊断 |
| 审计完整 | 文件上传、诊断调用、结果展示均可追踪 |
flowchart TD
A["用户提出路线或带路请求"] --> B["调用 agent chat stream"]
B --> C["AgentRouter 判断为导诊导航"]
C --> D{"当前设备类型"}
D -->|"机器人"| E["允许机器人导航"]
D -->|"导诊大屏"| F["只展示路线图或文字路线"]
D -->|"自助机"| G["展示路线并可打印或扫码带走"]
E --> H["guide agent 理解目标地点"]
F --> H
G --> H
H --> I["LocationService 匹配医院点位"]
I --> J{"是否匹配到地点"}
J -->|"否"| K["反问澄清目标地点"]
J -->|"是"| L["创建路线卡片"]
L --> M{"是否机器人且支持导航"}
M -->|"否"| N["前端展示路线说明"]
M -->|"是"| O["DeviceCommandService 创建导航命令"]
O --> P["机器人端 Robot Bridge 接收命令"]
P --> Q["调用机器人 SDK"]
Q --> R["机器人上报导航事件"]
R --> S["调用 devices events 上报"]
S --> T["更新导航任务状态"]
T --> U["审计 Trace"]
| 能力 | 所属模块 |
|---|---|
| 用户说“带我去检验科” | Terminal Client 收集输入 |
| 判断这是导航请求 | AgentRouter |
| 识别目标地点 | opd-guide-agent + LocationService |
| 判断机器人是否能导航 | Device Policy / capabilities |
| 下发导航命令 | DeviceCommandService |
| 调用机器人 SDK | Robot Bridge |
| 导航成功/失败回传 | DeviceEventService |
一期导航命令采用设备轮询模式(poll),不要求机器人厂商开放公网回调。DeviceCommandService 将命令保存到 ai_device_command,状态为 PENDING,默认 5 分钟过期;设备离线期间不主动推送,下一次心跳或 /devices/{deviceId}/commands/poll 时再拉取。超时未 ack 的命令标记为 EXPIRED,任务状态回到路线卡片提示人工引导或重新发起。
| 模块 | 方法 | 路径 | 用途 |
|---|---|---|---|
| 通用 | POST | /auth/token |
获取 Token,可选 |
| 通用 | GET | /health |
健康检查 |
| 通用 | GET | /capabilities |
查询项目能力 |
| 设备 | POST | /devices/register |
设备注册或激活 |
| 设备 | POST | /devices/{deviceId}/heartbeat |
设备心跳 |
| 设备 | GET | /devices/{deviceId}/scene |
获取设备场景配置 |
| 设备 | POST | /devices/{deviceId}/events |
上报设备事件 |
| Agent | POST | /agent/chat |
同步对话,服务端联调或不支持 SSE 的受控调用 |
| Agent | POST | /agent/chat/stream |
SSE 流式对话,统一入口客户端默认使用 |
| 会话 | POST | /conversations |
创建会话 |
| 会话 | GET | /conversations/{conversationId} |
查询会话 |
| 会话 | GET | /conversations/{conversationId}/messages |
查询消息 |
| 文件 | POST | /files/upload |
上传身份证、舌象、报告、音频 |
| 文件 | GET | /files/{fileId} |
查询文件元数据 |
| 卡片 | GET | /cards/{cardInstanceId} |
查询卡片实例 |
| 卡片 | POST | /cards/{cardInstanceId}/actions/{actionName} |
执行卡片动作 |
| 工具 | POST | /tools/{toolName}/invoke |
内部/受控工具调用 |
| 用量 | GET | /usage/summary |
基础用量汇总 |
以下接口必须携带幂等键:
/cards/{cardInstanceId}/actions/{actionName}
/tools/{toolName}/invoke 写操作
/files/upload 可选 clientFileId + sha256 去重
/devices/{deviceId}/events 高风险事件可加事件 ID
| 表 | 说明 |
|---|---|
ai_agent_app |
运行态 Agent 定义 |
ai_agent_engine_config |
Dify App 配置、baseUrl、apiKeyRef、输出版本 |
ai_agent_route_rule |
路由规则配置 |
ai_agent_route_log |
每次路由决策记录 |
ai_intent_sample |
真实意图样本沉淀 |
| 表 | 说明 |
|---|---|
ai_conversation |
会话主表 |
ai_conversation_message |
消息表 |
ai_task_instance |
任务实例和当前步骤 |
ai_task_event_log |
任务状态流转日志 |
| 表 | 说明 |
|---|---|
ai_card_definition |
卡片定义 |
ai_card_schema |
卡片数据 schema |
ai_card_instance |
卡片实例 |
ai_card_action_log |
卡片动作日志 |
ai_card_snapshot |
卡片快照 |
| 表 | 说明 |
|---|---|
ai_device |
设备主表 |
ai_device_capability |
设备能力 |
ai_device_scene_binding |
设备场景绑定 |
ai_device_event |
设备事件 |
ai_device_command |
设备命令 |
| 表 | 说明 |
|---|---|
ai_file_object |
文件元数据 |
ai_file_access_log |
文件访问日志 |
ai_tool_catalog |
MCP 工具目录 |
ai_tool_invocation_log |
工具调用日志 |
ai_hospital_adapter_config |
HIS Mock / Real Adapter 配置 |
ai_audit_log |
审计日志 |
ai_usage_log |
原始模型调用记录 |
ai_meter_event |
能力值事件 |
| 类 | 职责 |
|---|---|
AgentChatController |
/agent/chat、/agent/chat/stream |
CardActionController |
/cards/{cardInstanceId}/actions/{actionName} |
DeviceController |
设备注册、心跳、场景、事件 |
FileController |
文件上传、文件元数据查询 |
ConversationController |
会话创建、查询、消息查询 |
| 类 | 职责 |
|---|---|
AgentChatApplicationService |
串联鉴权、设备、会话、任务、路由、Dify、卡片、审计 |
AuthContextResolver |
项目/医院/终端鉴权上下文 |
DeviceContextResolver |
解析设备身份、能力、位置 |
SceneProfileService |
返回终端场景配置 |
ConversationService |
会话和消息持久化 |
TaskStateService |
任务状态机 |
AgentRouterService |
决定调用哪个 Dify App |
AgentDispatcher |
根据 RouteDecision 调用目标 Agent |
DifyAgentEngine |
Dify API/SSE 协议适配 |
DifyOutputNormalizer |
校验和归一化 Dify 输出 |
CardDefinitionService |
卡片定义管理 |
CardInstanceService |
创建和查询卡片实例 |
AgentActionOrchestrator |
承接卡片动作后的业务编排,按需调用 MCP、舌诊或设备命令 |
CardActionService |
处理卡片动作校验、幂等、状态推进和快照 |
McpToolService |
工具调用统一入口 |
HospitalAdapter |
HIS 适配 SPI |
HisMockAdapter |
Mock HIS 实现 |
TongueDiagnosisAdapter |
舌诊接口适配 |
DeviceCommandService |
机器人导航等设备命令 |
AuditLogService |
审计 |
MeterEventProducer |
用量事件 |
public class AgentChatApplicationService {
public SseEmitter chatStream(AgentChatRequest request) {
ChatContext context = contextBuilder.build(request);
Conversation conversation = conversationService.createOrLoad(request, context);
RouteDecision route = agentRouterService.route(request, context);
if (route.isBlocked()) {
return sseResponseFactory.blocked(route);
}
AgentDispatchRequest dispatchRequest = dispatchRequestFactory.build(
request,
context,
conversation,
route
);
return agentDispatcher.dispatchStream(dispatchRequest);
}
}
public class AgentDispatcher {
public SseEmitter dispatchStream(AgentDispatchRequest request) {
AgentApp app = agentAppService.getByCode(request.getRouteAgentCode());
AgentEngine engine = agentEngineFactory.getEngine(app.getEngineType());
return engine.chatStream(request, event -> {
NormalizedAgentEvent normalized = outputNormalizer.normalize(event);
if (normalized.hasCard()) {
cardInstanceService.createFromAgentResponse(normalized, request);
}
conversationService.appendEvent(normalized);
auditLogService.record(normalized);
meterEventProducer.produce(normalized);
});
}
}
flowchart TD
A["第 1 步 Router 骨架"] --> B["第 2 步 TaskStateService"]
B --> C["第 3 步 DifyAgentEngine"]
C --> D["第 4 步 Card Runtime MVP"]
D --> E["第 5 步 HisMockAdapter"]
E --> F["第 6 步 TongueDiagnosisAdapter"]
F --> G["第 7 步 三端联调"]
A1["AgentRouterService / RouteDecision / DevicePolicyRouter / TaskRouter / LlmIntentClassifier"] --> A
B1["ai_task_instance / RegistrationTaskStateMachine / TongueTaskStateMachine / GuideTaskStateMachine"] --> B
C1["DifyApiClient 复用 / DifyOutputNormalizer / DifyAppConfig"] --> C
D1["CardDefinition / CardInstance / CardActionLog / IdempotencyService"] --> D
E1["queryDepartments / queryDoctors / querySchedules / createPatient / createAppointment"] --> E
F1["checkImageQuality / startDiagnosis / queryDiagnosisResult"] --> F
G1["机器人导诊导航 / 自助机建档挂号舌诊 / 导诊大屏 FAQ 路线"] --> G
| 阶段 | 时间 | 目标 | 主要产物 |
|---|---|---|---|
| 阶段 1 | 3-5 天 | 协议冻结 | SSE 协议、Card Schema、DeviceContext、RouteDecision、Mock Tool 契约 |
| 阶段 2 | 1 周 | Router + Task 骨架 | AgentRouter、TaskStateService、ai_task_instance、基础路由日志 |
| 阶段 3 | 1 周 | Dify + Conversation | DifyAgentEngine、DifyOutputNormalizer、ConversationService |
| 阶段 4 | 1 周 | Card Runtime | CardDefinition、CardInstance、CardAction、幂等、状态机 |
| 阶段 5 | 1 周 | HisMock + File + 舌诊 | HisMockAdapter、FileService、TongueDiagnosisAdapter |
| 阶段 6 | 1-2 周 | 三端前端改造 | Vue 3 Terminal Client、三端首页、卡片组件、Bridge SDK |
| 阶段 7 | 1 周 | 联调与验收 | 三条主链路、审计、Trace、演示脚本、失败兜底脚本 |
| 编号 | 任务 | 负责人建议 | 备注 |
|---|---|---|---|
| BE-01 | 定义 AgentChatRequest/Response 与 SSE 事件 |
后端 | 所有前端依赖 |
| BE-02 | 实现 DeviceRegistry MVP |
后端 | 三类终端启动依赖 |
| BE-03 | 实现 SceneProfileService |
后端 | 控制不同终端能力 |
| BE-04 | 实现 ConversationService |
后端 | 替代内存 Map |
| BE-05 | 实现 AgentRouterService |
后端核心 | 最关键模块 |
| BE-06 | 实现 TaskStateService |
后端核心 | 防止多轮流程混乱 |
| BE-07 | 实现 DifyAgentEngine |
后端/AI | 复用已有 Dify API 客户端 |
| BE-08 | 实现 DifyOutputNormalizer |
后端/AI | 卡片 schema 校验 |
| BE-09 | 实现 Card Runtime MVP |
后端核心 | 卡片动作闭环 |
| BE-10 | 实现 FileService |
后端 | 舌象、身份证上传依赖 |
| BE-11 | 实现 HisMockAdapter |
后端 | 无真实 HIS 阶段必须显式 Mock |
| BE-12 | 实现 TongueDiagnosisAdapter |
后端/AI | 对接已有舌诊接口 |
| BE-13 | 实现审计、Trace、基础用量 | 后端 | 联调排障必须有 |
| 编号 | 任务 | 备注 |
|---|---|---|
| FE-01 | 从 Demo 抽出 medical-cards |
复用卡片组件,但接入中台 schema |
| FE-02 | 实现 api-client |
替换 Demo /chat/messages |
| FE-03 | 实现三端首页 | robot / kiosk / guide-screen |
| FE-04 | 实现 SSE 消费与断线恢复 | 对接 message、card、error、completed |
| FE-05 | 实现 File Upload | 身份证、舌象 |
| FE-06 | 实现 Card Action 调用 | 所有按钮统一走 card action |
| FE-07 | 实现 Robot Bridge SDK | 导航、语音、状态回调 |
| FE-08 | 终端能力降级 | 浏览器无 Bridge 时不崩溃 |
| 编号 | 任务 | 备注 |
|---|---|---|
| AI-01 | 创建 opd-guide-agent |
FAQ、路线、科室介绍 |
| AI-02 | 创建 opd-triage-agent |
症状追问、科室推荐 |
| AI-03 | 创建 opd-registration-agent |
建档/挂号话术,不直接写 HIS |
| AI-04 | 创建 tongue-diagnosis-agent |
主诉采集、结果解释 |
| AI-05 | 统一输出 JSON schema | 必须可被 Normalizer 校验 |
| AI-06 | 准备失败兜底话术 | HIS Mock 失败、舌诊失败、文件失败 |
| 验收项 | 标准 |
|---|---|
| 设备启动 | 能注册、心跳、获取 sceneProfile |
| 语音问答 | 能进入导诊 Agent,返回文本和 TTS |
| 科室推荐 | 能根据症状推荐科室 |
| 导航 | 能生成路线卡,机器人可执行导航命令 |
| 事件上报 | 导航成功/失败能上报 DeviceEvent |
| 限制策略 | 不能做复杂实名建档和隐私采集 |
| 验收项 | 标准 |
|---|---|
| 建档 | 能采集身份证、识别、确认、生成 Mock 患者 |
| 分诊 | 能追问症状并推荐科室 |
| 挂号 Mock | 能选择科室、医生、时段、确认并返回成功卡 |
| 舌诊 | 能上传舌象、调用舌诊接口、展示结果卡 |
| 卡片动作 | 所有按钮走 CardAction |
| 幂等 | 重复点击不会重复建档/挂号 |
| 断线恢复 | 能恢复会话和卡片状态 |
| 验收项 | 标准 |
|---|---|
| FAQ | 能回答公共导诊问题 |
| 科室介绍 | 能查科室位置、职责、就诊说明 |
| 路线 | 能展示路线说明 |
| 轻量分诊 | 能推荐科室,但不展示隐私 |
| 隐私限制 | 禁止身份证、舌象、报告、个人挂号 |
| 验收项 | 标准 |
|---|---|
| RouteLog | 每次路由都有记录 |
| TaskLog | 每次任务状态流转可查 |
| CardActionLog | 每次卡片动作可查 |
| ToolInvocationLog | 每次 Mock/HIS 工具调用可查 |
| FileAccessLog | 每次文件上传和访问可查 |
| TraceId | 一次用户请求贯穿全链路 |
| MeterEvent | Agent 调用、舌诊、卡片关键动作可生成用量事件 |
| 风险 | 表现 | 控制策略 |
|---|---|---|
| Demo 后端继续膨胀 | ChatService 越写越大 | 直接冻结 Demo 后端新增能力,迁入中台 |
| Dify 输出不稳定 | 卡片 JSON 不合法 | 强制 Normalizer 校验,不合法降级 |
| 前端绕过后端 | 直接调用舌诊/HIS/导航 | API 权限和代码 review 强控 |
| 无 HIS 接口导致假闭环 | 演示像真实挂号 | 明确 HisMockAdapter,文案标注演示/Mock |
| 设备差异被低估 | 三端 UI 和能力混乱 | SceneProfile + DevicePolicy 强控 |
| 舌诊隐私风险 | 大屏或机器人采集敏感图像 | 只允许自助机主采集,机器人引导,大屏禁止 |
| 状态机缺失 | 用户中途切换导致流程错乱 | activeTask 优先,卡片等待优先 |
| Mermaid 再次解析失败 | 节点文本不兼容 | 所有复杂节点使用引号,避免节点标签以 / 开头 |
本项目文档中的 Mermaid 图建议遵守:
1. 节点文本统一使用双引号:A["文本"]。
2. 不要让节点标签以 / 开头,例如不要写 J[/files/upload 上传]。
3. 不要在节点中使用过多 HTML,例如复杂 <br/>。
4. 分支文字使用简单中文,例如 -->|"是"|。
5. 一张图节点过多时拆图,不要硬塞进一张图。
本版已将原来可能失败的写法:
flowchart TD
J[/files/upload 上传身份证图片]
修正为兼容性更好的写法:
flowchart TD
J["调用 files upload 上传身份证图片"]
flowchart TD
A["终端启动"] --> B["设备注册 心跳 场景加载"]
B --> C["前端加载终端首页"]
C --> D["用户输入文本 语音 或点击入口"]
D --> E["调用 agent chat stream"]
E --> F["鉴权"]
F --> G["解析设备上下文"]
G --> H["加载场景配置"]
H --> I["加载会话"]
I --> J["加载任务状态"]
J --> K["AgentRouter 决策"]
K --> L{"路由结果"}
L -->|"导诊"| M["opd-guide-agent"]
L -->|"分诊"| N["opd-triage-agent"]
L -->|"挂号"| O["opd-registration-agent"]
L -->|"舌诊"| P["tongue-diagnosis-agent"]
L -->|"禁止"| Q["返回禁止提示"]
L -->|"低置信度"| R["反问澄清"]
M --> S["Dify 输出归一化"]
N --> S
O --> S
P --> S
S --> T{"是否有卡片"}
T -->|"否"| U["保存消息并 SSE 返回文本"]
T -->|"是"| V["Card Runtime 创建卡片实例"]
V --> W["前端展示卡片"]
W --> X["用户执行卡片动作"]
X --> Y["AgentActionOrchestrator + CardActionService"]
Y --> Z{"动作类型"}
Z -->|"HIS 工具"| AA["MCP 加 HIS Adapter"]
Z -->|"舌诊"| AB["TongueDiagnosisAdapter"]
Z -->|"设备命令"| AC["DeviceCommandService"]
Z -->|"本地状态"| AD["TaskStateService"]
AA --> AE["更新卡片和任务状态"]
AB --> AE
AC --> AE
AD --> AE
AE --> AF["审计 用量 Trace"]
AF --> AG{"是否需要继续对话"}
AG -->|"是"| AH["synthetic message 继续 Dify"]
AG -->|"否"| AI["流程结束"]
AH --> S
这套方案的开发口径可以压缩成一句话:
统一入口客户端一期要把现有 Demo 的交互资产迁移为生产级中台运行时:Vue 3 前端独立成 Terminal Client,后端能力进入 AI 中台,优先打通机器人导诊导航、自助机建档分诊挂号 Mock、自助机舌诊、导诊大屏公共导流四类闭环。
团队实施时不要先追求“像蚂蚁阿福的皮”,而要先建立稳定的控制链路:
设备场景
→ 会话任务
→ 后端路由
→ Dify 编排
→ 卡片动作
→ 工具适配
→ 审计计量