Răsfoiți Sursa

feat(ai-terminal): wire sse pipeline e2e with card creation

Task 11: SSE pipeline end-to-end with multi-intent support

SSE pipeline enhancements:
- REGISTRATION: auto-create department-selection card on first intent
- TONGUE_DIAGNOSIS: auto-create tongue-capture card
- ROUTE_NAVIGATION: auto-create route-card + device command for robot
- GUIDE intent narrowed: only ROUTE_NAVIGATION creates route-card
- MeterEventProducer fire-and-forget on AGENT_CHAT_COMPLETED

CardActionController fixes:
- Duplicate requests skip orchestrator (idempotency safe)
- conversationId/taskId resolved from cardInstance.contextJson (server trust)

SQL: tongue-diagnosis-result card seed + kiosk device scope
WangKang 2 săptămâni în urmă
părinte
comite
9890c13652

+ 43 - 15
emoon-openplatform/src/main/java/com/emoon/openplatform/controller/v1/CardActionController.java

@@ -1,5 +1,7 @@
 package com.emoon.openplatform.controller.v1;
 
+import cn.hutool.json.JSONUtil;
+import com.emoon.ai.agent.application.AgentActionOrchestrator;
 import com.emoon.ai.card.application.CardActionService;
 import com.emoon.ai.card.application.CardInstanceService;
 import com.emoon.common.core.domain.R;
@@ -11,12 +13,9 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.HashMap;
 import java.util.Map;
 
-/**
- * Card instance query and action endpoints for unified terminal clients.
- * Uses new HMAC/Bearer auth.
- */
 @Slf4j
 @RestController
 @RequestMapping("/api/v1")
@@ -25,18 +24,15 @@ public class CardActionController {
 
     private final CardInstanceService instanceService;
     private final CardActionService actionService;
+    private final AgentActionOrchestrator orchestrator;
 
     @GetMapping("/cards/{cardInstanceId}")
     public R<Map<String, Object>> getCard(
             HttpServletRequest httpRequest,
             @PathVariable String cardInstanceId) {
-        OpenPlatformAuthContext auth = AuthContextHolder.require(httpRequest);
-
+        AuthContextHolder.require(httpRequest);
         AiCardInstance inst = instanceService.findByInstanceId(cardInstanceId);
-        if (inst == null) {
-            return R.fail("CARD_NOT_FOUND");
-        }
-
+        if (inst == null) return R.fail("CARD_NOT_FOUND");
         return R.ok(Map.of(
             "cardInstanceId", inst.getInstanceId(),
             "cardKey", inst.getCardKey(),
@@ -48,24 +44,56 @@ public class CardActionController {
     }
 
     @PostMapping("/cards/{cardInstanceId}/actions/{actionName}")
-    public R<CardActionService.CardActionResult> cardAction(
+    public R<Map<String, Object>> cardAction(
             HttpServletRequest httpRequest,
             @PathVariable String cardInstanceId,
             @PathVariable String actionName,
             @RequestBody Map<String, Object> body) {
         AuthContextHolder.require(httpRequest);
 
+        // 1. Load card instance to resolve conversation/task (server-side, not client-trusted)
+        AiCardInstance inst = instanceService.findByInstanceId(cardInstanceId);
+        if (inst == null) return R.fail("CARD_NOT_FOUND");
+
+        String conversationId = inst.getConversationId();
+        Map<String, Object> context = inst.getContextJson() != null
+                ? JSONUtil.parseObj(inst.getContextJson()) : Map.of();
+        String taskId = (String) context.get("taskId");
+
         String idempotencyKey = (String) body.get("idempotencyKey");
         boolean confirmed = Boolean.TRUE.equals(body.get("confirm"));
         @SuppressWarnings("unchecked")
         Map<String, Object> payload = (Map<String, Object>) body.get("payload");
+        String traceId = "trace_" + java.util.UUID.randomUUID().toString().substring(0, 12);
 
-        log.info("[CardAction] instanceId={} action={} confirmed={}", cardInstanceId, actionName, confirmed);
-
-        CardActionService.CardActionResult result = actionService.submit(
+        // 2. Validate via CardActionService (state machine + idempotency + action log)
+        CardActionService.CardActionResult submitted = actionService.submit(
                 cardInstanceId, actionName, idempotencyKey,
-                payload != null ? payload : Map.of(), confirmed, null);
+                payload != null ? payload : Map.of(), confirmed, traceId);
+
+        // 3. If duplicate — return first result immediately, skip business orchestration
+        if ("duplicate".equals(submitted.status())) {
+            HashMap<String, Object> result = new HashMap<>();
+            result.put("status", "duplicate");
+            result.put("message", submitted.data().get("message"));
+            return R.ok(result);
+        }
 
+        // 4. Execute business logic via AgentActionOrchestrator
+        AgentActionOrchestrator.OrchestrationResult orchestrated = orchestrator.execute(
+                actionName, payload != null ? payload : Map.of(),
+                conversationId, taskId, traceId);
+
+        // 5. Mark card complete
+        actionService.complete(cardInstanceId, orchestrated.nextCard());
+
+        // 6. Return result with next card
+        HashMap<String, Object> result = new HashMap<>();
+        result.put("status", "completed");
+        result.put("actionId", submitted.data().get("actionId"));
+        if (orchestrated.nextCard() != null) {
+            result.put("nextCard", orchestrated.nextCard());
+        }
         return R.ok(result);
     }
 }

+ 104 - 76
emoon-openplatform/src/main/java/com/emoon/openplatform/service/impl/AgentChatApplicationServiceImpl.java

@@ -11,7 +11,12 @@ import com.emoon.ai.agent.domain.RouteDecision;
 import com.emoon.ai.card.application.CardInstanceService;
 import com.emoon.ai.device.api.DeviceRegistryFacade;
 import com.emoon.ai.device.api.SceneProfileResult;
+import com.emoon.ai.device.application.DeviceCommandService;
+import com.emoon.ai.mcp.application.McpToolService;
+import com.emoon.ai.meter.application.MeterEventProducer;
+import com.emoon.mcp.domain.AiCardInstance;
 import com.emoon.mcp.domain.AiConversation;
+import com.emoon.mcp.his.domain.HisDepartment;
 import com.emoon.openplatform.service.IAgentChatApplicationService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -36,6 +41,9 @@ public class AgentChatApplicationServiceImpl implements IAgentChatApplicationSer
     private final TaskStateService taskStateService;
     private final CardInstanceService cardInstanceService;
     private final DeviceRegistryFacade deviceRegistryFacade;
+    private final DeviceCommandService deviceCommandService;
+    private final McpToolService mcpToolService;
+    private final MeterEventProducer meterProducer;
     private final Environment env;
 
     private static final List<String> FULL_AGENTS = List.of(
@@ -46,43 +54,39 @@ public class AgentChatApplicationServiceImpl implements IAgentChatApplicationSer
     @Override
     public void chatStream(AgentChatCommand command, SseEmitter emitter) {
         String traceId = "trace_" + UUID.randomUUID().toString().substring(0, 12);
-        Integer projectId = command.projectId() != null
-                ? Integer.valueOf(command.projectId()) : 1;
+        Integer projectId = command.projectId() != null ? Integer.valueOf(command.projectId()) : 1;
         String projectIdStr = String.valueOf(projectId);
+        String intentCode = "UNKNOWN";
+        String taskId = null;
 
         try {
             AiConversation conv = conversationService.createOrLoad(
-                    projectId, command.conversationId(),
-                    command.deviceId(), command.hospitalId(),
-                    command.agentId(), command.userId());
+                    projectId, command.conversationId(), command.deviceId(),
+                    command.hospitalId(), command.agentId(), command.userId());
             String conversationId = conv.getConversationId();
 
             HashMap<String, String> userMeta = new HashMap<>();
-            userMeta.put("clientMessageId",
-                    command.clientMessageId() != null ? command.clientMessageId() : "");
-            conversationService.appendMessage(conversationId, "user",
-                    command.text(), JSONUtil.toJsonStr(userMeta), traceId);
-
-            // Resolve device scene — strict in non-dev profiles
-            List<String> allowedAgents = resolveAllowedAgents(
-                    projectIdStr, command.deviceId());
-            RouterContext ctx = new RouterContext(
-                    conversationId, command.text(),
+            userMeta.put("clientMessageId", command.clientMessageId() != null ? command.clientMessageId() : "");
+            conversationService.appendMessage(conversationId, "user", command.text(),
+                    JSONUtil.toJsonStr(userMeta), traceId);
+
+            List<String> allowedAgents = resolveAllowedAgents(projectIdStr, command.deviceId());
+            RouterContext ctx = new RouterContext(conversationId, command.text(),
                     command.deviceType(), allowedAgents, projectIdStr);
             RouteDecision decision = routerService.route(ctx);
 
             if (decision.blocked()) {
-                HashMap<String, String> blocked = new HashMap<>();
-                blocked.put("text", decision.blockedMessage());
-                emitter.send(event("message_delta", blocked));
-                HashMap<String, String> done = new HashMap<>();
-                done.put("conversationId", conversationId);
-                emitter.send(event("completed", done));
+                sendSSE(emitter, "message_delta", map("text", decision.blockedMessage()));
+                sendSSE(emitter, "completed", map("conversationId", conversationId));
                 emitter.complete();
                 return;
             }
 
-            String taskId = null;
+            intentCode = decision.intentCode() != null ? decision.intentCode() : "UNKNOWN";
+            String replyText = replyTemplates.reply(intentCode);
+            String messageId = "msg_" + UUID.randomUUID().toString().substring(0, 8);
+
+            // ——— Task & Card & Command creation ———
             if ("CREATE_NEW_TASK".equals(decision.taskPolicy()) && decision.taskType() != null) {
                 String currentStep = switch (decision.taskType()) {
                     case "REGISTRATION" -> "COLLECT_SYMPTOM";
@@ -92,90 +96,114 @@ public class AgentChatApplicationServiceImpl implements IAgentChatApplicationSer
                 };
                 var task = taskStateService.createTask(conversationId, projectIdStr,
                         decision.taskType(), decision.routeAgentCode(),
-                        currentStep, decision.slots(),
-                        null, command.deviceId(), traceId);
+                        currentStep, decision.slots(), null, command.deviceId(), traceId);
                 taskId = task.getTaskId();
-                HashMap<String, String> taskEvent = new HashMap<>();
-                taskEvent.put("taskId", taskId);
-                taskEvent.put("taskType", decision.taskType());
-                taskEvent.put("currentStep", currentStep);
-                emitter.send(event("task_updated", taskEvent));
+                sendSSE(emitter, "task_updated", map("taskId", taskId,
+                        "taskType", decision.taskType(), "currentStep", currentStep));
+
+                switch (decision.taskType()) {
+                    case "REGISTRATION" -> {
+                        List<HisDepartment> departments = mcpToolService.queryDepartments();
+                        var deptList = departments.stream()
+                                .map(d -> Map.<String, Object>of("deptId", d.getDepartmentId(), "name", d.getDepartmentName()))
+                                .collect(java.util.stream.Collectors.toList());
+                        AiCardInstance card = cardInstanceService.createCard(
+                                conversationId, taskId, "department-selection", "1.0",
+                                Map.of("departments", deptList), traceId);
+                        if (card != null) sendSSE(emitter, "card_created",
+                                map("cardInstanceId", card.getInstanceId(), "cardKey", "department-selection"));
+                    }
+                    case "TONGUE_DIAGNOSIS" -> {
+                        AiCardInstance card = cardInstanceService.createCard(
+                                conversationId, taskId, "tongue-capture", "1.0",
+                                Map.of("uploadField", "请拍摄舌象图片"), traceId);
+                        if (card != null) sendSSE(emitter, "card_created",
+                                map("cardInstanceId", card.getInstanceId(), "cardKey", "tongue-capture"));
+                    }
+                    case "GUIDE" -> {
+                        // Only ROUTE_NAVIGATION creates route-card + device command
+                        if (!"ROUTE_NAVIGATION".equals(decision.intentCode())) break;
+                        AiCardInstance card = cardInstanceService.createCard(
+                                conversationId, taskId, "route-card", "1.0",
+                                Map.of("routeText", replyText), traceId);
+                        if (card != null) sendSSE(emitter, "card_created",
+                                map("cardInstanceId", card.getInstanceId(), "cardKey", "route-card"));
+
+                        if ("robot".equals(command.deviceType()) && decision.slots() != null) {
+                            String location = (String) decision.slots().getOrDefault("targetLocation", "未知地点");
+                            deviceCommandService.createCommand(command.deviceId(),
+                                    "NAVIGATE_TO_LOCATION",
+                                    Map.of("locationId", location, "routeText", replyText), traceId);
+                            meterProducer.produce("DEVICE_COMMAND_CREATED", projectIdStr, traceId,
+                                    Map.of("commandType", "NAVIGATE_TO_LOCATION", "deviceId", command.deviceId()));
+                        }
+                    }
+                }
             }
 
-            String intentCode = decision.intentCode() != null ? decision.intentCode() : "UNKNOWN";
-            String replyText = replyTemplates.reply(intentCode);
-            String messageId = "msg_" + UUID.randomUUID().toString().substring(0, 8);
-
-            HashMap<String, String> delta = new HashMap<>();
-            delta.put("text", replyText);
-            emitter.send(event("message_delta", delta));
-
-            HashMap<String, String> completed = new HashMap<>();
-            completed.put("conversationId", conversationId);
-            completed.put("messageId", messageId);
-            emitter.send(event("message_completed", completed));
+            // ——— SSE reply ———
+            sendSSE(emitter, "message_delta", map("text", replyText));
+            sendSSE(emitter, "message_completed", map("conversationId", conversationId, "messageId", messageId));
 
             HashMap<String, String> assistantMeta = new HashMap<>();
             assistantMeta.put("intentCode", intentCode);
             assistantMeta.put("routeAgentCode", decision.routeAgentCode());
             assistantMeta.put("taskId", taskId != null ? taskId : "");
-            conversationService.appendMessage(conversationId, "assistant",
-                    replyText, JSONUtil.toJsonStr(assistantMeta), traceId);
-
-            HashMap<String, String> fin = new HashMap<>();
-            fin.put("conversationId", conversationId);
-            if (taskId != null) fin.put("taskId", taskId);
-            fin.put("traceId", traceId);
-            emitter.send(event("completed", fin));
+            conversationService.appendMessage(conversationId, "assistant", replyText,
+                    JSONUtil.toJsonStr(assistantMeta), traceId);
+
+            sendSSE(emitter, "completed", map("conversationId", conversationId,
+                    "taskId", taskId, "traceId", traceId));
             emitter.complete();
 
+            meterProducer.produce("AGENT_CHAT_COMPLETED", projectIdStr, traceId,
+                    Map.of("intentCode", intentCode, "taskId", taskId != null ? taskId : ""));
+
         } catch (IOException e) {
             log.error("[AgentChat SSE] send error", e);
             emitter.completeWithError(e);
         } catch (Exception e) {
             log.error("[AgentChat SSE] pipeline error traceId={}", traceId, e);
             try {
-                HashMap<String, String> err = new HashMap<>();
-                err.put("message", "系统处理异常,请稍后重试");
-                emitter.send(event("error", err));
+                sendSSE(emitter, "error", map("message", "系统处理异常,请稍后重试"));
                 emitter.complete();
-            } catch (IOException ex) {
-                emitter.completeWithError(ex);
-            }
+            } catch (IOException ex) { emitter.completeWithError(ex); }
         }
     }
 
-    /**
-     * Resolve allowed agents from device scene profile.
-     * Dev profile: fall back to full agents. Non-dev: restricted to guide-agent only.
-     */
     List<String> resolveAllowedAgents(String projectId, String deviceId) {
         if (deviceId == null) return restrictedFallback();
-
         try {
             SceneProfileResult scene = deviceRegistryFacade.scene(projectId, deviceId);
-            if (scene != null && scene.allowedAgents() != null
-                    && !scene.allowedAgents().isEmpty()) {
+            if (scene != null && scene.allowedAgents() != null && !scene.allowedAgents().isEmpty())
                 return scene.allowedAgents();
-            }
-        } catch (Exception e) {
-            log.warn("[AgentChat] failed to resolve scene deviceId={}", deviceId, e);
-        }
-
+        } catch (Exception e) { log.warn("[AgentChat] scene resolve failed deviceId={}", deviceId, e); }
         return restrictedFallback();
     }
 
     private List<String> restrictedFallback() {
-        boolean isDev = env != null && env.matchesProfiles("dev", "local");
-        if (isDev) {
-            log.debug("[AgentChat] dev fallback: full agents");
-            return FULL_AGENTS;
-        }
-        log.warn("[AgentChat] non-dev: scene not resolved, restricting to guide-agent only");
-        return RESTRICTED_AGENTS;
+        return (env != null && env.matchesProfiles("dev", "local")) ? FULL_AGENTS : RESTRICTED_AGENTS;
+    }
+
+    @SafeVarargs
+    private static <K, V> Map<K, V> map(Map.Entry<K, V>... entries) {
+        HashMap<K, V> m = new HashMap<>();
+        for (Map.Entry<K, V> e : entries) if (e.getValue() != null) m.put(e.getKey(), e.getValue());
+        return m;
+    }
+
+    private static <K, V> Map<K, V> map(K k1, V v1) {
+        if (v1 == null) return Map.of();
+        HashMap<K, V> m = new HashMap<>(); m.put(k1, v1); return m;
+    }
+    private static <K, V> Map<K, V> map(K k1, V v1, K k2, V v2) {
+        HashMap<K, V> m = new HashMap<>(); m.put(k1, v1); if (v2 != null) m.put(k2, v2); return m;
+    }
+    private static <K, V> Map<K, V> map(K k1, V v1, K k2, V v2, K k3, V v3) {
+        HashMap<K, V> m = new HashMap<>(); m.put(k1, v1); if (v2 != null) m.put(k2, v2); if (v3 != null) m.put(k3, v3); return m;
     }
 
-    private SseEmitter.SseEventBuilder event(String name, Object data) {
-        return SseEmitter.event().name(name).data(JSONUtil.toJsonStr(data));
+    private void sendSSE(SseEmitter emitter, String name, Object data) throws IOException {
+        emitter.send(SseEmitter.event().name(name).data(JSONUtil.toJsonStr(data)));
     }
 }

+ 3 - 2
script/sql/ai-terminal-mvp.sql

@@ -248,7 +248,8 @@ VALUES
 ('confirm-appointment', '1.0', '挂号确认', JSON_OBJECT('required', JSON_ARRAY('summary')), JSON_ARRAY(JSON_OBJECT('actionName', 'confirm_appointment', 'requiredConfirm', TRUE)), '0', '1', TRUE),
 ('appointment-success', '1.0', '挂号成功', JSON_OBJECT('required', JSON_ARRAY('appointmentNo')), JSON_ARRAY(), '0', '1', TRUE),
 ('route-card', '1.0', '路线导航', JSON_OBJECT('required', JSON_ARRAY('routeText')), JSON_ARRAY(JSON_OBJECT('actionName', 'start_navigation')), '0', '1', TRUE),
-('tongue-capture', '1.0', '舌象采集', JSON_OBJECT('required', JSON_ARRAY('uploadField')), JSON_ARRAY(JSON_OBJECT('actionName', 'submit_tongue_image')), '0', '1', TRUE)
+('tongue-capture', '1.0', '舌象采集', JSON_OBJECT('required', JSON_ARRAY('uploadField')), JSON_ARRAY(JSON_OBJECT('actionName', 'submit_tongue_image')), '0', '1', TRUE),
+('tongue-diagnosis-result', '1.0', '舌诊结果', JSON_OBJECT('required', JSON_ARRAY('summary')), JSON_ARRAY(), '0', '1', TRUE)
 ON DUPLICATE KEY UPDATE
   name = VALUES(name),
   schema_json = VALUES(schema_json),
@@ -267,4 +268,4 @@ VALUES ('EMOON-KIOSK-001', 'hospital_demo', 'H001', 'self_service_kiosk', 'activ
 INSERT IGNORE INTO ai_device_scene_binding(device_id, scene_code, ui_template, agent_bindings_json, card_scopes_json)
 VALUES ('EMOON-KIOSK-001', 'outpatient_kiosk', 'kiosk_home_v1',
         JSON_OBJECT('defaultAgent','opd-guide-agent','allowedAgents',JSON_ARRAY('opd-guide-agent','opd-triage-agent','opd-registration-agent','tongue-diagnosis-agent')),
-        JSON_ARRAY('department-selection','doctor-selection','time-slot-selection','confirm-appointment','appointment-success','tongue-capture'));
+        JSON_ARRAY('department-selection','doctor-selection','time-slot-selection','confirm-appointment','appointment-success','tongue-capture','tongue-diagnosis-result'));