Explorar o código

fix: 回归修复 — AgentChatServiceImplTest 移除CardInstanceFacade/cardKey/cardData残留引用

- 测试文件中移除已删除的 CardInstanceFacade mock 字段和导入
- 移除 AgentResponse builder 中的 .cardKey()/.cardData() 调用(6处)
- 移除 AgentChatResponse 中已不存在的 getCardKey()/getCardData()/getCardInstanceId() 断言
- 删除以卡片创建为核心断言的2个测试方法(chatBindsGeneratedConversationIdToCardInstance、chatStreamKeepsGeneratedConversationId)
- 重写 card_created 相关测试为验证语义结果事件(semantic_result)
- FastGptQueryMetadataService 恢复为孙泽皓的完整实现(rebase --theirs 误用修复)
ligao hai 13 horas
pai
achega
405c579329

+ 347 - 18
emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/application/FastGptQueryMetadataService.java

@@ -1,31 +1,360 @@
 package com.emoon.ai.agent.application;
 
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONUtil;
+import com.emoon.ai.agent.domain.AiResultSnapshotDO;
+import com.emoon.ai.agent.domain.AiTaskInstanceDO;
+import com.emoon.ai.mcp.application.McpToolService;
+import com.emoon.mcp.his.application.HisToolCatalogService;
 import com.emoon.mcp.engine.AgentResponse;
+import com.emoon.mcp.his.domain.HisScheduleSlot;
+import com.emoon.mcp.his.routing.HisRouteContext;
+import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
-/**
- * FastGPT 查询 metadata 应用(阶段一占位)。
- * 当前直接透传,不修改 AgentResponse。
- * 后续接入 TaskState/ResultSnapshot 后补充澄清逻辑。
- */
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
 @Slf4j
 @Service
+@RequiredArgsConstructor
 public class FastGptQueryMetadataService {
 
+    private static final String TOOL_CALLS_KEY = "toolCalls";
+    private static final String QUERY_SCHEDULES_TOOL = "his.querySchedules";
+    private static final String RESULT_TYPE_APPOINTMENT_SLOT_CANDIDATES = "APPOINTMENT_SLOT_CANDIDATES";
+    private static final String REALTIME_QUERY_FALLBACK_MESSAGE = "当前暂时无法获取实时号源,建议稍后重试或转人工/传统入口处理。";
+    private static final String EMPTY_RESULT_DEGRADATION_MESSAGE = "当前条件下暂无可用号源,建议调整查询条件(如更换日期或科室)后再试。";
+
+    private final TaskQueryConstraintService taskQueryConstraintService;
+    private final TaskStateService taskStateService;
+    private final ResultSnapshotService resultSnapshotService;
+    private final McpToolService mcpToolService;
+    private final HisToolCatalogService hisToolCatalogService;
+
     public FastGptQueryMetadataOutcome apply(AgentResponse response,
-                                              String conversationId,
-                                              String taskId,
-                                              String projectId,
-                                              String tenantId,
-                                              String hospitalId,
-                                              String traceId) {
-        log.debug("[QueryMetadata] conversationId={} taskId={} traceId={}", conversationId, taskId, traceId);
-        return null; // Phase 1 透传
-    }
-
-    public record FastGptQueryMetadataOutcome(
-            boolean clarificationRequired,
-            String clarificationMessage) {
+                                             String conversationId,
+                                             String taskId,
+                                             String projectId,
+                                             String tenantId,
+                                             String hospitalId,
+                                             String traceId) {
+        if (response == null || response.getMetadata() == null) {
+            return FastGptQueryMetadataOutcome.noop();
+        }
+        Object toolCallsObj = response.getMetadata().get(TOOL_CALLS_KEY);
+        if (!(toolCallsObj instanceof List<?> toolCalls) || toolCalls.isEmpty()) {
+            return FastGptQueryMetadataOutcome.noop();
+        }
+
+        for (Object toolCallObj : toolCalls) {
+            if (!(toolCallObj instanceof Map<?, ?> rawToolCall)) {
+                continue;
+            }
+            FastGptToolCallMetadata toolCall = parseToolCall(rawToolCall);
+            if (toolCall == null) {
+                continue;
+            }
+            if (hisToolCatalogService.isWriteTool(toolCall.toolName())) {
+                return FastGptQueryMetadataOutcome.clarify("当前阶段只支持查询类工具,请重新描述您的查询条件。");
+            }
+        }
+
+        AiTaskInstanceDO task = resolveTask(taskId, conversationId, projectId, tenantId);
+        if (StrUtil.isNotBlank(taskId) && task == null) {
+            log.warn("[SECURITY] taskId 在当前租户/项目作用域下不可加载 taskId={} projectId={} tenantId={} conversationId={}",
+                taskId, projectId, tenantId, conversationId);
+            return FastGptQueryMetadataOutcome.clarify("当前会话状态已变化,请重新发起查询。");
+        }
+        String effectiveTaskId = task != null && StrUtil.isNotBlank(task.getTaskId())
+            ? task.getTaskId()
+            : taskId;
+        if (!isTaskAccessible(task, taskId, conversationId)) {
+            log.warn("[SECURITY] task 不属于当前会话 taskId={} taskConversationId={} requestConversationId={}",
+                taskId, task.getConversationId(), conversationId);
+            return FastGptQueryMetadataOutcome.clarify("当前会话状态已变化,请重新发起查询。");
+        }
+        if (StrUtil.isBlank(effectiveTaskId)) {
+            return FastGptQueryMetadataOutcome.noop();
+        }
+
+        List<FastGptToolCallMetadata> parsedToolCalls = toolCalls.stream()
+            .filter(Map.class::isInstance)
+            .map(Map.class::cast)
+            .map(this::parseToolCall)
+            .filter(toolCall -> toolCall != null)
+            .toList();
+
+        List<FastGptToolCallMetadata> scheduleToolCalls = parsedToolCalls.stream()
+            .filter(toolCall -> QUERY_SCHEDULES_TOOL.equals(toolCall.toolName()))
+            .toList();
+        if (!scheduleToolCalls.isEmpty()) {
+            return applyQuerySchedules(
+                scheduleToolCalls,
+                conversationId,
+                task,
+                effectiveTaskId,
+                projectId,
+                tenantId,
+                hospitalId,
+                traceId
+            );
+        }
+        return FastGptQueryMetadataOutcome.noop();
+    }
+
+    private boolean isTaskAccessible(AiTaskInstanceDO task, String taskId, String conversationId) {
+        if (StrUtil.isBlank(taskId)) {
+            return true;
+        }
+        if (task == null) {
+            return true;
+        }
+        if (StrUtil.isBlank(conversationId) || StrUtil.isBlank(task.getConversationId())) {
+            return true;
+        }
+        return StrUtil.equals(conversationId, task.getConversationId());
+    }
+
+    private FastGptQueryMetadataOutcome applyQuerySchedules(List<FastGptToolCallMetadata> toolCalls,
+                                                            String conversationId,
+                                                            AiTaskInstanceDO task,
+                                                            String taskId,
+                                                            String projectId,
+                                                            String tenantId,
+                                                            String hospitalId,
+                                                            String traceId) {
+        try {
+            if (StrUtil.isNotBlank(projectId) && StrUtil.isNotBlank(tenantId) && StrUtil.isBlank(hospitalId)) {
+                return FastGptQueryMetadataOutcome.clarify("当前会话缺少医院上下文,暂时无法继续查询。");
+            }
+
+            QuerySchedulesAttempt attempt = null;
+            for (int i = 0; i < toolCalls.size(); i++) {
+                FastGptToolCallMetadata toolCall = toolCalls.get(i);
+                boolean shouldPatchTask = i == 0;
+                TaskQueryConstraintResolution resolution = shouldPatchTask
+                    ? taskQueryConstraintService.validateAndApply(
+                        QUERY_SCHEDULES_TOOL,
+                        toolCall.arguments(),
+                        taskId,
+                        projectId,
+                        tenantId
+                    )
+                    : taskQueryConstraintService.validateWithoutTaskPatch(
+                        QUERY_SCHEDULES_TOOL,
+                        toolCall.arguments(),
+                        taskId,
+                        projectId,
+                        tenantId
+                    );
+                if (!resolution.accepted()) {
+                    return FastGptQueryMetadataOutcome.clarify(resolution.clarificationMessage());
+                }
+
+                String doctorId = stringValue(resolution.normalizedArguments().get("doctorId"));
+                String visitDate = stringValue(resolution.normalizedArguments().get("date"));
+                if (StrUtil.isBlank(doctorId) || StrUtil.isBlank(visitDate)) {
+                    return FastGptQueryMetadataOutcome.clarify("我还需要您补充完整查询条件后再继续。");
+                }
+
+                List<HisScheduleSlot> slots = mcpToolService.querySchedules(
+                    doctorId,
+                    LocalDate.parse(visitDate),
+                    traceId,
+                    routeContext(projectId, tenantId, hospitalId)
+                );
+
+                attempt = new QuerySchedulesAttempt(toolCall, resolution, slots, i > 0);
+                if (!slots.isEmpty()) {
+                    return persistQuerySchedulesSnapshot(
+                        conversationId,
+                        task,
+                        taskId,
+                        projectId,
+                        tenantId,
+                        traceId,
+                        attempt
+                    );
+                }
+            }
+
+            taskStateService.clearActiveResult(taskId, projectId, tenantId);
+            return FastGptQueryMetadataOutcome.clarify(EMPTY_RESULT_DEGRADATION_MESSAGE);
+        } catch (Exception exception) {
+            taskStateService.clearActiveResult(taskId, projectId, tenantId);
+            return FastGptQueryMetadataOutcome.clarify(REALTIME_QUERY_FALLBACK_MESSAGE);
+        }
+    }
+
+    private FastGptQueryMetadataOutcome persistQuerySchedulesSnapshot(String conversationId,
+                                                                      AiTaskInstanceDO task,
+                                                                      String taskId,
+                                                                      String projectId,
+                                                                      String tenantId,
+                                                                      String traceId,
+                                                                      QuerySchedulesAttempt attempt) {
+        String doctorId = stringValue(attempt.resolution().normalizedArguments().get("doctorId"));
+        String visitDate = stringValue(attempt.resolution().normalizedArguments().get("date"));
+
+        Map<String, Object> snapshotPayload = new LinkedHashMap<>();
+        snapshotPayload.put("doctorId", doctorId);
+        snapshotPayload.put("date", visitDate);
+        snapshotPayload.put("slots", attempt.slots().stream()
+            .map(slot -> toSlotPayload(slot, attempt.relaxed()))
+            .toList());
+
+        AiResultSnapshotDO snapshot = resultSnapshotService.createSnapshot(
+            null,
+            RESULT_TYPE_APPOINTMENT_SLOT_CANDIDATES,
+            projectId,
+            tenantId,
+            conversationId,
+            taskId,
+            task.getPatientId(),
+            snapshotPayload,
+            attempt.toolCall().id(),
+            traceId,
+            LocalDateTime.now().plusMinutes(10)
+        );
+        if (snapshot == null) {
+            return FastGptQueryMetadataOutcome.noop();
+        }
+
+        taskStateService.bindActiveResult(
+            taskId,
+            snapshot.getResultRef(),
+            snapshot.getResultVersion(),
+            projectId,
+            tenantId
+        );
+        return FastGptQueryMetadataOutcome.applied(
+            snapshot.getResultRef(),
+            snapshot.getResultVersion(),
+            RESULT_TYPE_APPOINTMENT_SLOT_CANDIDATES,
+            snapshot.getExpiresAt()
+        );
+    }
+
+    private Map<String, Object> toSlotPayload(HisScheduleSlot slot) {
+        return toSlotPayload(slot, false);
+    }
+
+    private Map<String, Object> toSlotPayload(HisScheduleSlot slot, boolean relaxed) {
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("slotId", slot.getSlotId());
+        payload.put("doctorId", slot.getDoctorId());
+        payload.put("date", slot.getDate());
+        payload.put("timePeriod", slot.getTimePeriod());
+        payload.put("startTime", slot.getStartTime());
+        payload.put("endTime", slot.getEndTime());
+        payload.put("remaining", slot.getRemaining());
+        payload.put("fee", slot.getFee());
+        payload.put("mock", slot.isMock());
+        if (relaxed) {
+            payload.put("relaxedConstraint", true);
+        }
+        return payload;
+    }
+
+    private AiTaskInstanceDO loadTask(String taskId, String projectId, String tenantId) {
+        if (StrUtil.isNotBlank(projectId) && StrUtil.isNotBlank(tenantId)) {
+            return taskStateService.getTaskByTaskId(taskId, projectId, tenantId);
+        }
+        return taskStateService.getTaskByTaskId(taskId);
+    }
+
+    private AiTaskInstanceDO resolveTask(String taskId,
+                                         String conversationId,
+                                         String projectId,
+                                         String tenantId) {
+        if (StrUtil.isNotBlank(taskId)) {
+            return loadTask(taskId, projectId, tenantId);
+        }
+        if (StrUtil.isBlank(conversationId)) {
+            return null;
+        }
+        if (StrUtil.isNotBlank(projectId) && StrUtil.isNotBlank(tenantId)) {
+            return taskStateService.getActiveTask(conversationId, projectId, tenantId);
+        }
+        return taskStateService.getActiveTask(conversationId);
+    }
+
+    private HisRouteContext routeContext(String projectId, String tenantId, String hospitalId) {
+        if (StrUtil.hasBlank(projectId, tenantId, hospitalId)) {
+            return HisRouteContext.empty();
+        }
+        return new HisRouteContext(tenantId, projectId, hospitalId);
+    }
+
+    @SuppressWarnings("unchecked")
+    private FastGptToolCallMetadata parseToolCall(Map<?, ?> rawToolCall) {
+        String toolName = stringValue(rawToolCall.get("toolName"));
+        if (StrUtil.isBlank(toolName)) {
+            Object functionObj = rawToolCall.get("function");
+            if (functionObj instanceof Map<?, ?> function) {
+                toolName = stringValue(function.get("name"));
+            }
+        }
+        if (StrUtil.isBlank(toolName)) {
+            return null;
+        }
+
+        Map<String, Object> arguments = Map.of();
+        Object functionObj = rawToolCall.get("function");
+        if (functionObj instanceof Map<?, ?> function) {
+            String argumentsJson = stringValue(function.get("arguments"));
+            if (StrUtil.isNotBlank(argumentsJson) && JSONUtil.isTypeJSON(argumentsJson)) {
+                arguments = JSONUtil.parseObj(argumentsJson);
+            }
+        }
+        return new FastGptToolCallMetadata(
+            stringValue(rawToolCall.get("id")),
+            toolName,
+            arguments
+        );
+    }
+
+    private String stringValue(Object value) {
+        return value != null ? String.valueOf(value) : null;
+    }
+
+    private record FastGptToolCallMetadata(String id, String toolName, Map<String, Object> arguments) {
+    }
+
+    private record QuerySchedulesAttempt(FastGptToolCallMetadata toolCall,
+                                         TaskQueryConstraintResolution resolution,
+                                         List<HisScheduleSlot> slots,
+                                         boolean relaxed) {
+    }
+
+    public record FastGptQueryMetadataOutcome(boolean applied,
+                                              boolean clarificationRequired,
+                                              String clarificationMessage,
+                                              String resultRef,
+                                              Integer resultVersion,
+                                              String resultType,
+                                              LocalDateTime expiresAt) {
+
+        public static FastGptQueryMetadataOutcome noop() {
+            return new FastGptQueryMetadataOutcome(false, false, null, null, null, null, null);
+        }
+
+        public static FastGptQueryMetadataOutcome clarify(String message) {
+            return new FastGptQueryMetadataOutcome(false, true, message, null, null, null, null);
+        }
+
+        public static FastGptQueryMetadataOutcome applied(String resultRef,
+                                                          Integer resultVersion,
+                                                          String resultType,
+                                                          LocalDateTime expiresAt) {
+            return new FastGptQueryMetadataOutcome(
+                true, false, null, resultRef, resultVersion, resultType, expiresAt
+            );
+        }
     }
 }

+ 27 - 168
emoon-openplatform/src/test/java/com/emoon/openplatform/service/impl/AgentChatServiceImplTest.java

@@ -1,9 +1,6 @@
 package com.emoon.openplatform.service.impl;
 
 import com.emoon.ai.agent.application.FastGptQueryMetadataService;
-import com.emoon.ai.card.api.dto.CardInstanceVo;
-import com.emoon.ai.card.api.dto.CreateCardInstanceRequest;
-import com.emoon.ai.card.api.facade.CardInstanceFacade;
 import com.emoon.ai.device.api.DeviceRegistryFacade;
 import com.emoon.mcp.domain.vo.AiConversationVo;
 import com.emoon.mcp.engine.AgentEngine;
@@ -34,7 +31,6 @@ import java.util.Set;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.verify;
@@ -55,8 +51,6 @@ class AgentChatServiceImplTest {
     @Mock
     private IAiConversationService aiConversationService;
     @Mock
-    private CardInstanceFacade cardInstanceFacade;
-    @Mock
     private FastGptQueryMetadataService fastGptQueryMetadataService;
     @Mock
     private DeviceRegistryFacade deviceRegistryFacade;
@@ -298,7 +292,7 @@ class AgentChatServiceImplTest {
     }
 
     @Test
-    void chatDoesNotCreateCardWhenFastGptMetadataRequiresClarification() {
+    void chatDoesNotSetCardFieldsWhenClarificationRequired() {
         AgentChatRequest request = new AgentChatRequest();
         request.setAgentId("agent_fastgpt");
         request.setQuery("帮我查李医生的号");
@@ -335,8 +329,6 @@ class AgentChatServiceImplTest {
             .reply("先给我一个日期。")
             .conversationId("chat_001")
             .messageId("msg_001")
-            .cardKey("time-slot-selection")
-            .cardData("{\"slots\":[]}")
             .finished(true)
             .metadata(Map.of("toolCalls", List.of(Map.of("toolName", "his.querySchedules"))))
             .build());
@@ -355,14 +347,11 @@ class AgentChatServiceImplTest {
         AgentChatResponse response = service.chat(request, 7L, "tenant-h001");
 
         assertThat(response.getReply()).isEqualTo("日期我还没确认清楚,请按 YYYY-MM-DD 的格式告诉我。");
-        assertThat(response.getCardKey()).isNull();
-        assertThat(response.getCardData()).isNull();
-        assertThat(response.getCardInstanceId()).isNull();
-        verify(cardInstanceFacade, never()).create(any(CreateCardInstanceRequest.class));
+        assertThat(response.getResultRefs()).isNull();
     }
 
     @Test
-    void chatDoesNotCreateLegacyCardWhenFastGptMetadataCreatesSnapshot() {
+    void chatReturnsOnlyResultRefsWhenSnapshotCreated() {
         AgentChatRequest request = new AgentChatRequest();
         request.setAgentId("agent_fastgpt");
         request.setQuery("帮我查一下明天李医生的号源");
@@ -399,8 +388,6 @@ class AgentChatServiceImplTest {
             .reply("明天李医生上午还有 1 个号源。")
             .conversationId("chat_001")
             .messageId("msg_001")
-            .cardKey("time-slot-selection")
-            .cardData("{\"slots\":[]}")
             .finished(true)
             .metadata(Map.of("toolCalls", List.of(Map.of("toolName", "his.querySchedules"))))
             .build());
@@ -421,140 +408,15 @@ class AgentChatServiceImplTest {
 
         AgentChatResponse response = service.chat(request, 7L, "tenant-h001");
 
-        assertThat(response.getCardKey()).isNull();
-        assertThat(response.getCardData()).isNull();
-        assertThat(response.getCardInstanceId()).isNull();
         assertThat(response.getResultRefs()).hasSize(1);
-        verify(cardInstanceFacade, never()).create(any(CreateCardInstanceRequest.class));
-    }
-
-    @Test
-    void chatBindsGeneratedConversationIdToCardInstanceForNewConversation() {
-        AgentChatRequest request = new AgentChatRequest();
-        request.setAgentId("agent_fastgpt");
-        request.setQuery("帮我查一下明天李医生的号源");
-        request.setTraceId("trace_001");
-        request.setUserId(99L);
-        request.setDeviceId("KIOSK-001");
-
-        AiAgentApp app = new AiAgentApp();
-        app.setId(1L);
-        app.setStatus("0");
-        app.setEngineConfigId(2L);
-        when(aiOpenPlatformFacade.findAgentAppByAgentId("agent_fastgpt", 7L, "tenant-h001"))
-            .thenReturn(app);
-
-        AiAgentEngineConfig config = new AiAgentEngineConfig();
-        config.setEngineType("fastgpt");
-        config.setConfigJson("{\"baseUrl\":\"http://fastgpt:3000\",\"apiKey\":\"k1\"}");
-        when(aiOpenPlatformFacade.findActiveEngineConfigById(2L, 7L, "tenant-h001"))
-            .thenReturn(config);
-
-        when(agentEngineFactory.getEngine("fastgpt")).thenReturn(fastGptEngine);
-        when(fastGptEngine.chat(any())).thenReturn(AgentResponse.builder()
-            .reply("明天李医生上午还有 1 个号源。")
-            .conversationId("chat_001")
-            .messageId("msg_001")
-            .cardKey("time-slot-selection")
-            .cardData("{\"slots\":[]}")
-            .finished(true)
-            .build());
-        when(aiConversationService.create(
-            eq(7), eq(1L), any(String.class), any(String.class), eq(99L),
-            eq("fastgpt"), eq("chat_001"), eq("tenant-h001"), eq(1), eq(0)))
-            .thenReturn(AiConversationVo.builder().conversationId("unused").build());
-        when(cardInstanceFacade.create(any(CreateCardInstanceRequest.class)))
-            .thenReturn(CardInstanceVo.builder().instanceId("card_inst_001").build());
-
-        AgentChatResponse response = service.chat(request, 7L, "tenant-h001");
-
-        ArgumentCaptor<String> conversationIdCaptor = ArgumentCaptor.forClass(String.class);
-        verify(aiConversationService).create(
-            eq(7), eq(1L), conversationIdCaptor.capture(), any(String.class), eq(99L),
-            eq("fastgpt"), eq("chat_001"), eq("tenant-h001"), eq(1), eq(0));
-        ArgumentCaptor<CreateCardInstanceRequest> cardCaptor =
-            ArgumentCaptor.forClass(CreateCardInstanceRequest.class);
-        verify(cardInstanceFacade).create(cardCaptor.capture());
-
-        assertThat(response.getConversationId()).isEqualTo(conversationIdCaptor.getValue());
-        assertThat(cardCaptor.getValue().getConversationId()).isEqualTo(conversationIdCaptor.getValue());
+        assertThat(response.getResultRefs().get(0).getResultRef()).isEqualTo("result_001");
     }
 
     @Test
-    void chatStreamKeepsGeneratedConversationIdConsistentForNewConversation() {
+    void chatStreamEmitsSemanticResultWhenFastGptMetadataCreatesSnapshot() {
         AgentChatRequest request = new AgentChatRequest();
         request.setAgentId("agent_fastgpt");
         request.setQuery("帮我查一下明天李医生的号源");
-        request.setTraceId("trace_001");
-        request.setUserId(99L);
-        request.setDeviceId("KIOSK-001");
-        request.setInputs(Map.of("taskId", "task_001"));
-
-        AiAgentApp app = new AiAgentApp();
-        app.setId(1L);
-        app.setStatus("0");
-        app.setEngineConfigId(2L);
-        when(aiOpenPlatformFacade.findAgentAppByAgentId("agent_fastgpt", 7L, "tenant-h001"))
-            .thenReturn(app);
-
-        AiAgentEngineConfig config = new AiAgentEngineConfig();
-        config.setEngineType("fastgpt");
-        config.setConfigJson("{\"baseUrl\":\"http://fastgpt:3000\",\"apiKey\":\"k1\"}");
-        when(aiOpenPlatformFacade.findActiveEngineConfigById(2L, 7L, "tenant-h001"))
-            .thenReturn(config);
-
-        when(agentEngineFactory.getEngine("fastgpt")).thenReturn(fastGptEngine);
-        doAnswer(invocation -> {
-            var callback = invocation.getArgument(1, java.util.function.Consumer.class);
-            callback.accept(AgentResponse.builder()
-                .reply("明天李医生上午还有 1 个号源。")
-                .conversationId("chat_001")
-                .messageId("msg_001")
-                .cardKey("time-slot-selection")
-                .cardData("{\"slots\":[]}")
-                .finished(true)
-                .metadata(Map.of("toolCalls", List.of()))
-                .build());
-            return null;
-        }).when(fastGptEngine).chatStream(any(), any());
-        when(deviceRegistryFacade.hospitalId("7", "KIOSK-001")).thenReturn("hospital-a");
-        when(aiConversationService.create(
-            eq(7), eq(1L), any(String.class), any(String.class), eq(99L),
-            eq("fastgpt"), eq("chat_001"), eq("tenant-h001"), eq(1), eq(0)))
-            .thenReturn(AiConversationVo.builder().conversationId("unused").build());
-        when(fastGptQueryMetadataService.apply(
-            any(AgentResponse.class), any(String.class), eq("task_001"),
-            eq("7"), eq("tenant-h001"), eq("hospital-a"), eq("trace_001")))
-            .thenReturn(FastGptQueryMetadataService.FastGptQueryMetadataOutcome.noop());
-        when(cardInstanceFacade.create(any(CreateCardInstanceRequest.class)))
-            .thenReturn(CardInstanceVo.builder().instanceId("card_inst_001").build());
-
-        service.chatStream(request, 7L, "tenant-h001", new SseEmitter(5_000L));
-
-        ArgumentCaptor<String> conversationIdCaptor = ArgumentCaptor.forClass(String.class);
-        verify(aiConversationService).create(
-            eq(7), eq(1L), conversationIdCaptor.capture(), any(String.class), eq(99L),
-            eq("fastgpt"), eq("chat_001"), eq("tenant-h001"), eq(1), eq(0));
-        verify(fastGptQueryMetadataService).apply(
-            any(AgentResponse.class),
-            eq(conversationIdCaptor.getValue()),
-            eq("task_001"),
-            eq("7"),
-            eq("tenant-h001"),
-            eq("hospital-a"),
-            eq("trace_001")
-        );
-        ArgumentCaptor<CreateCardInstanceRequest> cardCaptor =
-            ArgumentCaptor.forClass(CreateCardInstanceRequest.class);
-        verify(cardInstanceFacade).create(cardCaptor.capture());
-        assertThat(cardCaptor.getValue().getConversationId()).isEqualTo(conversationIdCaptor.getValue());
-    }
-
-    @Test
-    void chatStreamDoesNotCreateCardWhenFastGptMetadataRequiresClarification() {
-        AgentChatRequest request = new AgentChatRequest();
-        request.setAgentId("agent_fastgpt");
-        request.setQuery("帮我查李医生的号");
         request.setConversationId("conv_001");
         request.setTraceId("trace_001");
         request.setInputs(Map.of("taskId", "task_001"));
@@ -586,11 +448,9 @@ class AgentChatServiceImplTest {
         doAnswer(invocation -> {
             var callback = invocation.getArgument(1, java.util.function.Consumer.class);
             callback.accept(AgentResponse.builder()
-                .reply("先给我一个日期。")
+                .reply("明天李医生上午还有 1 个号源。")
                 .conversationId("chat_001")
                 .messageId("msg_001")
-                .cardKey("time-slot-selection")
-                .cardData("{\"slots\":[]}")
                 .finished(true)
                 .metadata(Map.of("toolCalls", List.of(Map.of("toolName", "his.querySchedules"))))
                 .build());
@@ -604,17 +464,26 @@ class AgentChatServiceImplTest {
             eq("tenant-h001"),
             eq("hospital-a"),
             eq("trace_001")
-        )).thenReturn(FastGptQueryMetadataService.FastGptQueryMetadataOutcome.clarify(
-            "日期我还没确认清楚,请按 YYYY-MM-DD 的格式告诉我。"
+        )).thenReturn(FastGptQueryMetadataService.FastGptQueryMetadataOutcome.applied(
+            "result_001",
+            1,
+            "APPOINTMENT_SLOT_CANDIDATES",
+            LocalDateTime.of(2026, 7, 6, 14, 30)
         ));
 
-        service.chatStream(request, 7L, "tenant-h001", new SseEmitter(5_000L));
+        CapturingSseEmitter emitter = new CapturingSseEmitter();
+        service.chatStream(request, 7L, "tenant-h001", emitter);
 
-        verify(cardInstanceFacade, never()).create(any(CreateCardInstanceRequest.class));
+        String semanticPayload = emitter.payloadFor("semantic_result");
+        assertThat(semanticPayload).contains("\"turnId\":\"msg_001\"");
+        assertThat(semanticPayload).contains("\"answer\":\"明天李医生上午还有 1 个号源。\"");
+        assertThat(semanticPayload).contains("\"resultRef\":\"result_001\"");
+        assertThat(semanticPayload).contains("\"version\":1");
+        assertThat(semanticPayload).contains("\"resultType\":\"APPOINTMENT_SLOT_CANDIDATES\"");
     }
 
     @Test
-    void chatStreamEmitsSemanticResultWhenFastGptMetadataCreatesSnapshot() {
+    void chatStreamDoesNotEmitCardCreatedEvent() {
         AgentChatRequest request = new AgentChatRequest();
         request.setAgentId("agent_fastgpt");
         request.setQuery("帮我查一下明天李医生的号源");
@@ -675,19 +544,15 @@ class AgentChatServiceImplTest {
         CapturingSseEmitter emitter = new CapturingSseEmitter();
         service.chatStream(request, 7L, "tenant-h001", emitter);
 
-        String semanticPayload = emitter.payloadFor("semantic_result");
-        assertThat(semanticPayload).contains("\"turnId\":\"msg_001\"");
-        assertThat(semanticPayload).contains("\"answer\":\"明天李医生上午还有 1 个号源。\"");
-        assertThat(semanticPayload).contains("\"resultRef\":\"result_001\"");
-        assertThat(semanticPayload).contains("\"version\":1");
-        assertThat(semanticPayload).contains("\"resultType\":\"APPOINTMENT_SLOT_CANDIDATES\"");
+        assertThat(emitter.hasEvent("card_created")).isFalse();
+        assertThat(emitter.hasEvent("semantic_result")).isTrue();
     }
 
     @Test
-    void chatStreamDoesNotEmitLegacyCardEventWhenFastGptMetadataCreatesSnapshot() {
+    void chatStreamDoesNotEmitCardEventWhenClarificationRequired() {
         AgentChatRequest request = new AgentChatRequest();
         request.setAgentId("agent_fastgpt");
-        request.setQuery("帮我查一下明天李医生的号");
+        request.setQuery("帮我查李医生的号");
         request.setConversationId("conv_001");
         request.setTraceId("trace_001");
         request.setInputs(Map.of("taskId", "task_001"));
@@ -719,11 +584,9 @@ class AgentChatServiceImplTest {
         doAnswer(invocation -> {
             var callback = invocation.getArgument(1, java.util.function.Consumer.class);
             callback.accept(AgentResponse.builder()
-                .reply("明天李医生上午还有 1 个号源。")
+                .reply("先给我一个日期。")
                 .conversationId("chat_001")
                 .messageId("msg_001")
-                .cardKey("time-slot-selection")
-                .cardData("{\"slots\":[]}")
                 .finished(true)
                 .metadata(Map.of("toolCalls", List.of(Map.of("toolName", "his.querySchedules"))))
                 .build());
@@ -737,18 +600,14 @@ class AgentChatServiceImplTest {
             eq("tenant-h001"),
             eq("hospital-a"),
             eq("trace_001")
-        )).thenReturn(FastGptQueryMetadataService.FastGptQueryMetadataOutcome.applied(
-            "result_001",
-            1,
-            "APPOINTMENT_SLOT_CANDIDATES",
-            LocalDateTime.of(2026, 7, 6, 14, 30)
+        )).thenReturn(FastGptQueryMetadataService.FastGptQueryMetadataOutcome.clarify(
+            "日期我还没确认清楚,请按 YYYY-MM-DD 的格式告诉我。"
         ));
 
         CapturingSseEmitter emitter = new CapturingSseEmitter();
         service.chatStream(request, 7L, "tenant-h001", emitter);
 
         assertThat(emitter.hasEvent("card_created")).isFalse();
-        verify(cardInstanceFacade, never()).create(any(CreateCardInstanceRequest.class));
     }
 
     @Test