Ver código fonte

feat: AgentChatV2ServiceImpl 从Mock切换到真实后端,完成阶段二集成

- 引擎切换:DEFAULT_ENGINE mock → fastgpt,新增引擎配置解析
- 接入 FastGptQueryMetadataService:替换 buildMockSlotCandidates/buildMockDoctorList,处理 tool_calls metadata 生成真实 ResultSnapshot
- 接入 CommandFacade:替换 createCommand Mock,结构化交互直接走 commandFacade.prepare(LOCK_APPOINTMENT_SLOT → REGISTER_APPOINTMENT)
- 接入 TaskStateService:替换硬编码 TaskSummary,新增 ensureActiveTask 和 task_updated SSE 事件
- hospitalId 上下文:新增 DeviceRegistryFacade 解析,传入 FastGptQueryMetadataService 和会话持久化
ligao 2 dias atrás
pai
commit
a0b40d6d84

+ 392 - 225
emoon-openplatform/src/main/java/com/emoon/openplatform/service/v2/impl/AgentChatV2ServiceImpl.java

@@ -1,25 +1,38 @@
 package com.emoon.openplatform.service.v2.impl;
 
 import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONObject;
 import cn.hutool.json.JSONUtil;
 import com.emoon.ai.agent.application.ConversationTerminalService;
+import com.emoon.ai.agent.application.FastGptQueryMetadataService;
 import com.emoon.ai.agent.application.ResultSnapshotService;
-import com.emoon.ai.agent.domain.AiResultSnapshotDO;
+import com.emoon.ai.agent.application.TaskStateService;
+import com.emoon.ai.agent.domain.AiTaskInstanceDO;
+import com.emoon.ai.command.api.dto.CommandDetailDto;
+import com.emoon.ai.command.api.dto.CommandPrepareRequest;
+import com.emoon.ai.command.api.facade.CommandFacade;
+import com.emoon.ai.device.api.DeviceRegistryFacade;
 import com.emoon.common.core.exception.exception.ServiceException;
 import com.emoon.mcp.domain.AiConversation;
 import com.emoon.mcp.engine.AgentEngine;
 import com.emoon.mcp.engine.AgentEngineFactory;
 import com.emoon.mcp.engine.AgentRequest;
+import com.emoon.mcp.engine.AgentResponse;
 import com.emoon.openplatform.constant.V2ErrorCode;
 import com.emoon.openplatform.controller.v2.TurnRecoveryController;
 import com.emoon.openplatform.domain.dto.v2.AgentInteractionRequest;
 import com.emoon.openplatform.domain.dto.v2.AvailableAction;
+import com.emoon.openplatform.domain.dto.v2.Command;
 import com.emoon.openplatform.domain.dto.v2.ResultReference;
 import com.emoon.openplatform.domain.dto.v2.SemanticResponse;
 import com.emoon.openplatform.domain.dto.v2.TaskSummary;
-import com.emoon.openplatform.domain.dto.v2.Command;
 import com.emoon.openplatform.service.v2.IAgentChatV2Service;
 import com.emoon.openplatform.service.v2.ICommandV2Service;
+import com.emoon.system.domain.vo.AiAgentAppVo;
+import com.emoon.system.domain.vo.AiAgentEngineConfigVo;
+import com.emoon.system.service.IAiAgentAppService;
+import com.emoon.system.service.IAiAgentEngineConfigService;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -29,24 +42,27 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 import java.io.IOException;
 import java.time.LocalDateTime;
-import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
 import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.UUID;
 
 /**
- * v2 Agent 语义交互服务实现(Phase 1 查询链,纯文本,无卡片)。
+ * v2 Agent 语义交互服务实现。
+ *
+ * <p>Phase 2:接入真实 FastGPT 引擎、FastGptQueryMetadataService 和 CommandFacade。
  *
  * <p>职责:
  * <ol>
  *   <li>会话管理(委托 ConversationTerminalService)</li>
- *   <li>选择 Agent 引擎(当前 mock,后续 fastgpt)</li>
- *   <li>调用引擎流式对话</li>
- *   <li>发送 v2 SSE 事件:message_delta / semantic_result / error / completed</li>
+ *   <li>选择 Agent 引擎(fastgpt)</li>
+ *   <li>调用引擎流式对话,处理 tool_calls metadata 生成 ResultSnapshot</li>
+ *   <li>结构化交互直接转入 CommandFacade 命令链路</li>
+ *   <li>发送 v2 SSE 事件</li>
  * </ol>
- *
- * <p>Phase 1 不做:Presentation 生成、Command 准备/确认、CardInstance 创建</p>
  */
 @Slf4j
 @Service
@@ -58,16 +74,19 @@ public class AgentChatV2ServiceImpl implements IAgentChatV2Service {
     private final TurnRecoveryController turnRecoveryController;
     private final ResultSnapshotService resultSnapshotService;
     private final ICommandV2Service commandV2Service;
-
-    /** 阶段一默认使用 mock 引擎;FastGptAgentEngine 就绪后切换为 "fastgpt" */
-    private static final String DEFAULT_ENGINE = "mock";
+    private final IAiAgentAppService agentAppService;
+    private final IAiAgentEngineConfigService engineConfigService;
+    private final FastGptQueryMetadataService fastGptQueryMetadataService;
+    private final TaskStateService taskStateService;
+    private final CommandFacade commandFacade;
+    private final DeviceRegistryFacade deviceRegistryFacade;
+
+    private static final String DEFAULT_ENGINE = "fastgpt";
     private static final String DEFAULT_AGENT_ID = "agent_v2_query";
 
-    /** debug: 模拟引擎超时(毫秒),0 表示不模拟;通过 application.yml v2.debug.timeout-ms 配置 */
     @Value("${v2.debug.timeout-ms:0}")
     private long debugTimeoutMs;
 
-    /** debug: 是否启用调试模式(丰富的 Mock 数据) */
     @Value("${v2.debug.enabled:false}")
     private boolean debugEnabled;
 
@@ -76,15 +95,14 @@ public class AgentChatV2ServiceImpl implements IAgentChatV2Service {
                            Long projectId, String tenantId, String partnerId,
                            SseEmitter emitter, HttpServletResponse httpResponse) {
 
-        String idempotencyKey = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
         String traceId = "trace_" + IdUtil.fastSimpleUUID().substring(0, 12);
         String turnId = "turn_" + IdUtil.fastSimpleUUID().substring(0, 12);
 
-        log.info("[v2 入口] IN | projectId={} tenantId={} deviceId={} isText={} traceId={} debugTimeoutMs={}",
-                projectId, tenantId, request.getDeviceId(), request.isTextMessage(), traceId, debugTimeoutMs);
+        log.info("[v2 入口] IN | projectId={} tenantId={} deviceId={} isText={} isInteraction={} traceId={}",
+                projectId, tenantId, request.getDeviceId(),
+                request.isTextMessage(), request.isStructuredInteraction(), traceId);
 
         try {
-            // ---- 模拟超时(debug) ----
             if (debugTimeoutMs > 0) {
                 log.warn("[v2 debug] 模拟引擎超时 {}ms traceId={}", debugTimeoutMs, traceId);
                 Thread.sleep(debugTimeoutMs);
@@ -93,28 +111,56 @@ public class AgentChatV2ServiceImpl implements IAgentChatV2Service {
                 return;
             }
 
-            // ---- 1. 会话管理 ----
+            // ---- 1. 解析 hospitalId ----
+            String resolvedHospitalId = resolveHospitalId(projectId, request.getDeviceId());
+
+            // ---- 2. 会话管理 ----
             AiConversation conversation = conversationTerminalService.createOrLoad(
                     projectId != null ? projectId.intValue() : null,
                     request.getConversationId(),
                     request.getDeviceId(),
-                    null,   // hospitalId — Phase 1 暂不绑定
+                    resolvedHospitalId,
                     DEFAULT_AGENT_ID,
-                    null,   // userId
+                    null,
                     tenantId);
 
             String conversationId = conversation.getConversationId();
-            log.info("[v2 会话] conversationId={}", conversationId);
 
-            // ---- 2. 构建引擎请求 ----
-            String query = request.isTextMessage()
-                    ? request.getMessage().getText()
-                    : buildInteractionQuery(request);
+            // 如果 conversation 已有 hospitalId 则优先使用
+            String hospitalId = StrUtil.isNotBlank(conversation.getHospitalId())
+                    ? conversation.getHospitalId()
+                    : resolvedHospitalId;
+            log.info("[v2 会话] conversationId={} hospitalId={}", conversationId, hospitalId);
+
+            // ---- 2. 结构化交互 → 直接走 Command 链路 ----
+            if (request.isStructuredInteraction()) {
+                handleStructuredInteraction(request, turnId, conversationId,
+                        String.valueOf(projectId), tenantId, hospitalId, emitter, traceId);
+                return;
+            }
+
+            // ---- 3. 文本消息:构建引擎请求 ----
+            String query = request.getMessage().getText();
+
+            // 解析引擎配置
+            JSONObject engineConfig = resolveEngineConfig(DEFAULT_AGENT_ID);
+            String engineConfigJson = engineConfig != null ? engineConfig.toString() : null;
+            if (engineConfigJson == null) {
+                log.warn("[v2 引擎] 未找到 agent_v2_query 的引擎配置,回退文本降级");
+                String fallback = "当前服务暂不可用,请稍后重试或转人工挂号。";
+                sendSse(emitter, "message_delta", Map.of("turnId", turnId, "text", fallback));
+                sendSse(emitter, "semantic_result",
+                        SemanticResponse.builder().turnId(turnId).answer(fallback).build());
+                sendSse(emitter, "completed", Map.of("conversationId", conversationId, "turnId", turnId));
+                emitter.complete();
+                return;
+            }
 
             AgentRequest agentRequest = AgentRequest.builder()
                     .agentId(DEFAULT_AGENT_ID)
                     .conversationId(conversationId)
                     .query(query)
+                    .engineConfig(engineConfigJson)
                     .inputs(Map.of(
                             "traceId", traceId,
                             "turnId", turnId,
@@ -125,7 +171,11 @@ public class AgentChatV2ServiceImpl implements IAgentChatV2Service {
                     ))
                     .build();
 
-            // ---- 3. 调用引擎 ----
+            // ---- 4. 获取或创建 TaskState ----
+            String taskId = ensureActiveTask(conversationId,
+                    String.valueOf(projectId), tenantId, traceId);
+
+            // ---- 5. 调用 FastGPT 引擎 ----
             AgentEngine engine = agentEngineFactory.getEngine(DEFAULT_ENGINE);
             StringBuilder fullReply = new StringBuilder();
 
@@ -145,27 +195,32 @@ public class AgentChatV2ServiceImpl implements IAgentChatV2Service {
 
                         String answer = fullReply.toString();
 
-                        // 发送 semantic_result(含持久化的 resultRefs)
-                        SemanticResponse semantic = buildSemanticResponse(
-                                turnId, answer, conversationId, query,
-                                String.valueOf(projectId), tenantId, traceId);
+                        // ---- 处理 metadata 生成 ResultSnapshot ----
+                        SemanticResponse semantic = buildSemanticFromMetadata(
+                                turnId, answer, chunk, conversationId, taskId,
+                                String.valueOf(projectId), tenantId, hospitalId, traceId);
+
                         sendSse(emitter, "semantic_result", semantic);
 
-                        // 缓存语义响应(断线恢复用)
                         turnRecoveryController.cacheTurn(conversationId, turnId, semantic);
 
-                        // 保存助手消息
                         conversationTerminalService.appendMessage(
                                 conversationId, "assistant", answer,
                                 JSONUtil.toJsonStr(semantic), traceId);
 
-                        // ---- 结构化交互 → Command 流程 ----
-                        if (request.isStructuredInteraction()) {
-                            handleInteractionCommand(request, turnId, conversationId,
-                                    String.valueOf(projectId), tenantId, emitter);
+                        // 发送 task_updated(如有)
+                        AiTaskInstanceDO updatedTask = taskStateService.getTaskByTaskId(
+                                taskId, String.valueOf(projectId), tenantId);
+                        if (updatedTask != null) {
+                            sendSse(emitter, "task_updated", Map.of(
+                                    "turnId", turnId,
+                                    "taskId", updatedTask.getTaskId(),
+                                    "status", updatedTask.getStatus(),
+                                    "currentStep", updatedTask.getCurrentStep() != null
+                                            ? updatedTask.getCurrentStep() : "",
+                                    "version", updatedTask.getVersion()));
                         }
 
-                        // 发送 completed
                         sendSse(emitter, "completed",
                                 Map.of("conversationId", conversationId, "turnId", turnId));
 
@@ -191,229 +246,341 @@ public class AgentChatV2ServiceImpl implements IAgentChatV2Service {
         }
     }
 
-    // ---- private helpers ----
+    // ---- Structured Interaction ----
 
     /**
-     * 构建 SemanticResponse,根据查询关键词动态生成并持久化 resultRefs。
+     * 结构化交互直接进入 Command 链路,不再经过 FastGPT 引擎。
+     *
+     * <p>SELECT_CANDIDATE 流程:
+     * <ol>
+     *   <li>校验 active task 和 resultRef 版本</li>
+     *   <li>prepare L1 LOCK_APPOINTMENT_SLOT(自动执行锁号)</li>
+     *   <li>prepare L2 REGISTER_APPOINTMENT(等待确认)</li>
+     * </ol>
      */
-    private SemanticResponse buildSemanticResponse(String turnId, String answer,
-                                                    String conversationId, String query,
-                                                    String projectId, String tenantId, String traceId) {
-        String taskId = "task_v2_" + turnId;
-        List<ResultReference> refs = buildAndPersistResultRefs(
-                query, conversationId, taskId, projectId, tenantId, traceId);
-        List<AvailableAction> actions = refs.isEmpty()
-                ? List.of()
-                : List.of(AvailableAction.builder()
-                        .actionType("SELECT_CANDIDATE")
-                        .riskLevel("L1")
-                        .confirmationRequired(false)
-                        .build());
+    private void handleStructuredInteraction(AgentInteractionRequest request,
+                                             String turnId, String conversationId,
+                                             String projectId, String tenantId,
+                                             String hospitalId,
+                                             SseEmitter emitter, String traceId) throws IOException {
+        String interactionType = request.getInteraction().getInteractionType();
+        String candidateId = request.getInteraction().getCandidateId();
+        String resultRef = request.getInteraction().getResultRef();
+        Integer resultVersion = request.getInteraction().getResultVersion();
 
-        return SemanticResponse.builder()
-                .turnId(turnId)
-                .answer(answer)
-                .task(TaskSummary.builder()
-                        .taskId(taskId)
-                        .taskType(refs.isEmpty() ? "QUERY" : "REGISTRATION")
-                        .status("ACTIVE")
-                        .currentStage(refs.isEmpty() ? "QUERYING" : "CANDIDATE_SELECTION")
-                        .version(1)
-                        .build())
-                .resultRefs(refs)
-                .availableActions(actions)
-                .build();
-    }
+        log.info("[v2 Interaction] IN type={} candidateId={} resultRef={} resultVersion={}",
+                interactionType, candidateId, resultRef, resultVersion);
 
-    /**
-     * 按关键词匹配结果类型,调用 ResultSnapshotService 持久化并返回真实 ResultReference。
-     */
-    private List<ResultReference> buildAndPersistResultRefs(String query,
-                                                             String conversationId, String taskId,
-                                                             String projectId, String tenantId,
-                                                             String traceId) {
-        if (query == null || query.isEmpty()) {
-            return List.of();
+        if (!"SELECT_CANDIDATE".equals(interactionType) || candidateId == null) {
+            safeSendError(emitter, "INVALID_INTERACTION",
+                    "当前仅支持 SELECT_CANDIDATE 交互", traceId);
+            return;
         }
 
-        String q = query.toLowerCase();
-        boolean isRegistration = containsAny(q, "挂号", "预约", "号源", "门诊", "就诊", "appointment", "register");
-        boolean isDoctorQuery = containsAny(q, "医生", "主任", "专家", "doctor");
-        boolean isDepartmentQuery = containsAny(q, "科室", "呼吸科", "心内科", "神经内科", "消化内科", "儿科", "妇产科", "department");
+        // 获取 active task
+        AiTaskInstanceDO task = taskStateService.getActiveTask(
+                conversationId, projectId, tenantId);
+        if (task == null) {
+            safeSendError(emitter, "INVALID_INTERACTION",
+                    "当前没有进行中的挂号任务,请重新查询", traceId);
+            return;
+        }
+
+        String answer = "正在处理您的选择,请稍候...";
+        sendSse(emitter, "message_delta", Map.of("turnId", turnId, "text", answer));
+
+        try {
+            // ---- L1 锁号 ----
+            CommandDetailDto lockCmd = commandFacade.prepare(CommandPrepareRequest.builder()
+                    .commandType("LOCK_APPOINTMENT_SLOT")
+                    .taskId(task.getTaskId())
+                    .taskVersion(task.getVersion())
+                    .projectId(projectId)
+                    .tenantId(tenantId)
+                    .conversationId(conversationId)
+                    .patientBindingId(task.getPatientId())
+                    .sourceResultRef(resultRef != null ? resultRef : task.getActiveResultRef())
+                    .sourceResultVersion(resultVersion != null
+                            ? resultVersion : task.getActiveResultVersion())
+                    .candidateId(candidateId)
+                    .traceId(traceId)
+                    .build());
+
+            Command v2LockCmd = toV2Command(lockCmd);
+            sendSse(emitter, "command_prepared", Map.of("turnId", turnId, "command", v2LockCmd));
+
+            if ("SUCCEEDED".equals(lockCmd.getStatus())) {
+                sendSse(emitter, "command_completed", Map.of(
+                        "turnId", turnId,
+                        "commandId", lockCmd.getCommandId(),
+                        "status", "SUCCEEDED",
+                        "result", lockCmd.getResult()));
+                log.info("[v2 Command] L1 锁号完成 commandId={} candidateId={}",
+                        lockCmd.getCommandId(), candidateId);
+            } else if ("UNKNOWN".equals(lockCmd.getStatus())) {
+                sendSse(emitter, "command_completed", Map.of(
+                        "turnId", turnId,
+                        "commandId", lockCmd.getCommandId(),
+                        "status", "UNKNOWN",
+                        "needRequery", true));
+                log.warn("[v2 Command] L1 锁号状态未知 commandId={}", lockCmd.getCommandId());
+            } else {
+                // 锁号失败
+                String failMsg = "号源选择失败,请重新查询可用号源。";
+                sendSse(emitter, "command_completed", Map.of(
+                        "turnId", turnId,
+                        "commandId", lockCmd.getCommandId(),
+                        "status", lockCmd.getStatus()));
+                safeSendError(emitter, "CANDIDATE_NOT_FOUND", failMsg, traceId);
+                return;
+            }
+
+            // 刷新 task(锁号后 version 已更新)
+            task = taskStateService.getTaskByTaskId(
+                    task.getTaskId(), projectId, tenantId);
+            int taskVersion = task != null && task.getVersion() != null
+                    ? task.getVersion() : lockCmd.getTaskVersion() + 1;
+
+            // ---- L2 挂号 ----
+            String effectiveResultRef = resultRef != null ? resultRef : task.getActiveResultRef();
+            Integer effectiveResultVersion = resultVersion != null
+                    ? resultVersion : task.getActiveResultVersion();
+
+            CommandDetailDto regCmd = commandFacade.prepare(CommandPrepareRequest.builder()
+                    .commandType("REGISTER_APPOINTMENT")
+                    .taskId(task != null ? task.getTaskId() : lockCmd.getTaskId())
+                    .taskVersion(taskVersion)
+                    .projectId(projectId)
+                    .tenantId(tenantId)
+                    .conversationId(conversationId)
+                    .patientBindingId(task != null ? task.getPatientId() : null)
+                    .sourceResultRef(effectiveResultRef)
+                    .sourceResultVersion(effectiveResultVersion)
+                    .candidateId(candidateId)
+                    .traceId(traceId)
+                    .build());
+
+            Command v2RegCmd = toV2Command(regCmd);
+            sendSse(emitter, "command_prepared", Map.of("turnId", turnId, "command", v2RegCmd));
+            log.info("[v2 Command] L2 command_prepared regCommandId={} status={}",
+                    regCmd.getCommandId(), regCmd.getStatus());
+
+            // 发送 task_updated
+            AiTaskInstanceDO finalTask = taskStateService.getTaskByTaskId(
+                    regCmd.getTaskId(), projectId, tenantId);
+            if (finalTask != null) {
+                sendSse(emitter, "task_updated", Map.of(
+                        "turnId", turnId,
+                        "taskId", finalTask.getTaskId(),
+                        "status", finalTask.getStatus(),
+                        "currentStep", finalTask.getCurrentStep() != null
+                                ? finalTask.getCurrentStep() : "",
+                        "version", finalTask.getVersion()));
+            }
+
+            // 发送 semantic_result
+            SemanticResponse semantic = SemanticResponse.builder()
+                    .turnId(turnId)
+                    .answer(answer)
+                    .task(buildTaskSummary(task != null ? task : finalTask))
+                    .resultRefs(resultRef != null ? List.of(ResultReference.builder()
+                            .resultRef(resultRef)
+                            .resultType("APPOINTMENT_SLOT_CANDIDATES")
+                            .build()) : List.of())
+                    .availableActions(List.of(
+                            AvailableAction.builder()
+                                    .actionType("CONFIRM_REGISTRATION")
+                                    .riskLevel("L2")
+                                    .confirmationRequired(true)
+                                    .build()))
+                    .build();
+            sendSse(emitter, "semantic_result", semantic);
+            turnRecoveryController.cacheTurn(conversationId, turnId, semantic);
+
+            sendSse(emitter, "completed",
+                    Map.of("conversationId", conversationId, "turnId", turnId));
+            emitter.complete();
 
-        if (isRegistration || (isDoctorQuery && isDepartmentQuery)) {
-            return List.of(persistSnapshot("APPOINTMENT_SLOT_CANDIDATES",
-                    buildMockSlotCandidates(),
-                    conversationId, taskId, projectId, tenantId, traceId));
+        } catch (Exception e) {
+            log.error("[v2 Command] 交互处理失败 candidateId={} err={}", candidateId, e.getMessage());
+            safeSendError(emitter, "INTERNAL_ERROR",
+                    "处理选择失败,请重新查询号源", traceId);
         }
+    }
+
+    // ---- Metadata → SemanticResponse ----
 
-        if (isDoctorQuery) {
-            return List.of(persistSnapshot("DOCTOR_LIST",
-                    buildMockDoctorList(),
-                    conversationId, taskId, projectId, tenantId, traceId));
+    /**
+     * 从 FastGPT 引擎返回的 metadata 中提取 tool_calls,通过
+     * FastGptQueryMetadataService 生成真实 ResultSnapshot,构建 SemanticResponse。
+     */
+    private SemanticResponse buildSemanticFromMetadata(String turnId, String answer,
+                                                       AgentResponse finalChunk,
+                                                       String conversationId, String taskId,
+                                                       String projectId, String tenantId,
+                                                       String hospitalId, String traceId) {
+        // 调用 FastGptQueryMetadataService 处理 tool_calls
+        FastGptQueryMetadataService.FastGptQueryMetadataOutcome outcome =
+                fastGptQueryMetadataService.apply(
+                        finalChunk, conversationId, taskId,
+                        projectId, tenantId, hospitalId, traceId);
+
+        List<ResultReference> refs = new ArrayList<>();
+        List<AvailableAction> actions = new ArrayList<>();
+
+        if (outcome.clarificationRequired()) {
+            // FastGPT 参数校验失败/需要澄清 → 用澄清消息作为 answer
+            answer = outcome.clarificationMessage();
+            log.info("[v2 metadata] 需要澄清 taskId={} msg={}", taskId, answer);
+        } else if (outcome.applied()) {
+            // 查询成功,ResultSnapshot 已落库并绑定 TaskState
+            refs.add(ResultReference.builder()
+                    .resultRef(outcome.resultRef())
+                    .version(outcome.resultVersion())
+                    .resultType(outcome.resultType())
+                    .expiresAt(outcome.expiresAt() != null
+                            ? outcome.expiresAt().atOffset(ZoneOffset.ofHours(8))
+                                    .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
+                            : null)
+                    .build());
+            actions.add(AvailableAction.builder()
+                    .actionType("SELECT_CANDIDATE")
+                    .riskLevel("L1")
+                    .confirmationRequired(false)
+                    .build());
+            log.info("[v2 metadata] 查询落快照 resultRef={} version={} type={}",
+                    outcome.resultRef(), outcome.resultVersion(), outcome.resultType());
         }
+        // outcome.noop() → 纯文本回答,无 resultRefs
 
-        return List.of();
-    }
+        // 读取真实 TaskState
+        AiTaskInstanceDO task = taskStateService.getTaskByTaskId(
+                taskId, projectId, tenantId);
 
-    private ResultReference persistSnapshot(String resultType, Map<String, Object> resultData,
-                                            String conversationId, String taskId,
-                                            String projectId, String tenantId, String traceId) {
-        AiResultSnapshotDO snapshot = resultSnapshotService.createSnapshot(
-                null, resultType, projectId, tenantId, conversationId, taskId,
-                null, resultData, null, traceId,
-                LocalDateTime.now().plusMinutes(5));
-
-        return ResultReference.builder()
-                .resultRef(snapshot.getResultRef())
-                .version(snapshot.getResultVersion())
-                .resultType(resultType)
-                .expiresAt(snapshot.getExpiresAt()
-                        .atOffset(java.time.ZoneOffset.ofHours(8))
-                        .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
+        return SemanticResponse.builder()
+                .turnId(turnId)
+                .answer(answer)
+                .task(buildTaskSummary(task))
+                .resultRefs(refs.isEmpty() ? null : refs)
+                .availableActions(actions.isEmpty() ? null : actions)
                 .build();
     }
 
-    private Map<String, Object> buildMockSlotCandidates() {
-        return Map.of("candidates", List.of(
-                Map.of("id", "slot_001", "department", "呼吸科", "doctorName", "张明",
-                       "doctorTitle", "主任医师", "date", "2026-07-08", "timePeriod", "上午",
-                       "fee", "50.00"),
-                Map.of("id", "slot_002", "department", "呼吸科", "doctorName", "李华",
-                       "doctorTitle", "主任医师", "date", "2026-07-08", "timePeriod", "上午",
-                       "fee", "50.00"),
-                Map.of("id", "slot_003", "department", "呼吸科", "doctorName", "王芳",
-                       "doctorTitle", "主任医师", "date", "2026-07-08", "timePeriod", "下午",
-                       "fee", "50.00")),
-                "alternatives", List.of(
-                        Map.of("id", "slot_004", "department", "呼吸科", "doctorName", "赵敏",
-                               "doctorTitle", "副主任医师", "date", "2026-07-08", "timePeriod", "上午",
-                               "fee", "30.00", "relaxedConstraint", "放宽职称至副主任医师"),
-                        Map.of("id", "slot_005", "department", "呼吸科", "doctorName", "张明",
-                               "doctorTitle", "主任医师", "date", "2026-07-09", "timePeriod", "上午",
-                               "fee", "50.00", "relaxedConstraint", "放宽日期至次日"),
-                        Map.of("id", "slot_006", "department", "心内科", "doctorName", "陈强",
-                               "doctorTitle", "副主任医师", "date", "2026-07-08", "timePeriod", "上午",
-                               "fee", "30.00", "relaxedConstraint", "放宽科室至心内科")));
-    }
+    // ---- TaskState helpers ----
 
-    private Map<String, Object> buildMockDoctorList() {
-        return Map.of("doctors", List.of(
-                Map.of("id", "doc_001", "name", "张明", "title", "主任医师", "department", "呼吸科"),
-                Map.of("id", "doc_002", "name", "李华", "title", "主任医师", "department", "呼吸科"),
-                Map.of("id", "doc_003", "name", "王芳", "title", "主任医师", "department", "呼吸科")));
+    private String ensureActiveTask(String conversationId,
+                                    String projectId, String tenantId, String traceId) {
+        AiTaskInstanceDO existing = taskStateService.getActiveTask(
+                conversationId, projectId, tenantId);
+        if (existing != null) {
+            return existing.getTaskId();
+        }
+        AiTaskInstanceDO created = taskStateService.createTask(
+                conversationId, projectId, tenantId,
+                "REGISTRATION", DEFAULT_AGENT_ID, "QUERYING",
+                null, null, null, traceId);
+        log.info("[v2 Task] 新建任务 taskId={} conversationId={}", created.getTaskId(), conversationId);
+        return created.getTaskId();
     }
 
-    private boolean containsAny(String text, String... keywords) {
-        for (String kw : keywords) {
-            if (text.contains(kw)) return true;
+    private TaskSummary buildTaskSummary(AiTaskInstanceDO task) {
+        if (task == null) {
+            return null;
         }
-        return false;
+        return TaskSummary.builder()
+                .taskId(task.getTaskId())
+                .taskType(task.getTaskType())
+                .status(task.getStatus())
+                .currentStage(task.getCurrentStep())
+                .version(task.getVersion())
+                .build();
     }
 
-    private String buildInteractionQuery(AgentInteractionRequest request) {
-        return "interaction: " + request.getInteraction().getInteractionType()
-               + " candidateId=" + request.getInteraction().getCandidateId();
-    }
+    // ---- Engine config ----
 
-    private void sendSse(SseEmitter emitter, String eventName, Object data) throws IOException {
-        emitter.send(SseEmitter.event().name(eventName).data(data));
+    private JSONObject resolveEngineConfig(String agentId) {
+        try {
+            AiAgentAppVo agent = agentAppService.queryByAgentId(agentId);
+            if (agent == null || agent.getEngineConfigId() == null) {
+                log.warn("[v2 引擎配置] agentId={} 不存在或未绑定引擎配置", agentId);
+                return null;
+            }
+            AiAgentEngineConfigVo cfg = engineConfigService.queryRawById(agent.getEngineConfigId());
+            if (cfg == null || StrUtil.isBlank(cfg.getConfigJson())) {
+                log.warn("[v2 引擎配置] engineConfigId={} 不存在或configJson为空",
+                        agent.getEngineConfigId());
+                return null;
+            }
+            JSONObject json = JSONUtil.parseObj(cfg.getConfigJson());
+            String baseUrl = json.getStr("baseUrl");
+            String secretKey = json.getStr("secretKey");
+            if (StrUtil.isBlank(baseUrl) || StrUtil.isBlank(secretKey)) {
+                log.warn("[v2 引擎配置] 缺少 baseUrl 或 secretKey");
+                return null;
+            }
+            log.info("[v2 引擎配置] 解析成功 agentId={} baseUrl={}", agentId, baseUrl);
+            return new JSONObject().set("baseUrl", baseUrl).set("secretKey", secretKey);
+        } catch (Exception e) {
+            log.warn("[v2 引擎配置] 解析失败 agentId={}: {}", agentId, e.getMessage());
+            return null;
+        }
     }
 
-    /**
-     * 处理结构化交互的 Command 流程。
-     *
-     * <p>SELECT_CANDIDATE 分两步 Mock:
-     * <ol>
-     *   <li>创建 LOCK_APPOINTMENT_SLOT L1 → 自动执行 → command_prepared + command_completed</li>
-     *   <li>创建 REGISTER_APPOINTMENT L2 → 等待确认 → command_prepared</li>
-     * </ol>
-     */
-    private void handleInteractionCommand(AgentInteractionRequest request,
-                                           String turnId, String conversationId,
-                                           String projectId, String tenantId,
-                                           SseEmitter emitter) throws IOException {
-        String interactionType = request.getInteraction().getInteractionType();
-        String candidateId = request.getInteraction().getCandidateId();
+    // ---- hospitalId ----
 
-        if (!"SELECT_CANDIDATE".equals(interactionType) || candidateId == null) {
-            return;
+    private String resolveHospitalId(Long projectId, String deviceId) {
+        if (projectId == null || StrUtil.isBlank(deviceId)) {
+            return null;
+        }
+        try {
+            return deviceRegistryFacade.hospitalId(String.valueOf(projectId), deviceId);
+        } catch (Exception e) {
+            log.warn("[v2] 根据 deviceId 解析 hospitalId 失败 projectId={} deviceId={}: {}",
+                    projectId, deviceId, e.getMessage());
+            return null;
         }
-
-        Map<String, Object> summary = buildCandidateSummary(candidateId);
-        String taskId = "task_v2_" + turnId;
-        Long projId = projectId != null ? Long.parseLong(projectId) : null;
-
-        // 1. L1 锁号 — 自动执行
-        Command lockCmd = commandV2Service.createCommand(
-                "LOCK_APPOINTMENT_SLOT", "L1", "PREPARED",
-                summary, taskId, 1, projId, tenantId);
-
-        sendSse(emitter, "command_prepared", Map.of("turnId", turnId, "command", lockCmd));
-
-        // 自动执行锁号 → SUCCEEDED
-        lockCmd.setStatus("SUCCEEDED");
-        lockCmd.setResult(Map.of(
-                "lockId", "lock_" + IdUtil.fastSimpleUUID().substring(0, 8),
-                "slotId", candidateId,
-                "expiresAt", OffsetDateTime.now().plusMinutes(5)
-                        .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)));
-        sendSse(emitter, "command_completed", Map.of(
-                "turnId", turnId, "commandId", lockCmd.getCommandId(),
-                "status", "SUCCEEDED", "result", lockCmd.getResult()));
-
-        log.info("[v2 Command] L1 锁号完成 commandId={} candidateId={}", lockCmd.getCommandId(), candidateId);
-
-        // 2. L2 挂号 — 等待确认
-        Map<String, Object> regSummary = new java.util.LinkedHashMap<>(summary);
-        regSummary.put("lockId", lockCmd.getResult().get("lockId"));
-        regSummary.put("lockExpiresAt", lockCmd.getResult().get("expiresAt"));
-
-        Command regCmd = commandV2Service.createCommand(
-                "REGISTER_APPOINTMENT", "L2", "AWAITING_CONFIRMATION",
-                regSummary, taskId, 2, projId, tenantId);
-
-        sendSse(emitter, "command_prepared", Map.of("turnId", turnId, "command", regCmd));
-        log.info("[v2 Command] SSE command_prepared regCommandId={} candidateId={}", regCmd.getCommandId(), candidateId);
     }
 
-    /** 按 candidateId 匹配 Mock 候选人摘要 */
-    private Map<String, Object> buildCandidateSummary(String candidateId) {
-        return switch (candidateId) {
-            case "slot_001" -> Map.of(
-                    "department", "呼吸科", "doctor", "张明 主任医师",
-                    "visitTime", "2026-07-08 上午", "feeYuan", "50.00");
-            case "slot_002" -> Map.of(
-                    "department", "呼吸科", "doctor", "李华 主任医师",
-                    "visitTime", "2026-07-08 上午", "feeYuan", "50.00");
-            case "slot_003" -> Map.of(
-                    "department", "呼吸科", "doctor", "王芳 主任医师",
-                    "visitTime", "2026-07-08 下午", "feeYuan", "50.00");
-            case "slot_004" -> Map.of(
-                    "department", "呼吸科", "doctor", "赵敏 副主任医师",
-                    "visitTime", "2026-07-08 上午", "feeYuan", "30.00",
-                    "note", "替代方案:放宽职称");
-            case "slot_005" -> Map.of(
-                    "department", "呼吸科", "doctor", "张明 主任医师",
-                    "visitTime", "2026-07-09 上午", "feeYuan", "50.00",
-                    "note", "替代方案:放宽日期");
-            case "slot_006" -> Map.of(
-                    "department", "心内科", "doctor", "陈强 副主任医师",
-                    "visitTime", "2026-07-08 上午", "feeYuan", "30.00",
-                    "note", "替代方案:放宽科室");
-            default -> Map.of(
-                    "department", "呼吸科", "doctor", candidateId,
-                    "visitTime", "待定", "feeYuan", "50.00");
-        };
+    // ---- SSE helpers ----
+
+    private void sendSse(SseEmitter emitter, String eventName, Object data) throws IOException {
+        emitter.send(SseEmitter.event().name(eventName).data(data));
     }
 
     private void safeSendError(SseEmitter emitter, String code, String message, String traceId) {
         try {
             sendSse(emitter, "error",
-                    Map.of("code", code, "message", message, "traceId", traceId != null ? traceId : "",
-                           "retryable", V2ErrorCode.isRetryable(code)));
+                    Map.of("code", code, "message", message, "traceId",
+                            traceId != null ? traceId : "",
+                            "retryable", V2ErrorCode.isRetryable(code)));
             emitter.complete();
         } catch (IOException ignored) {
         }
     }
+
+    // ---- DTO conversion ----
+
+    private Command toV2Command(CommandDetailDto detail) {
+        if (detail == null) {
+            return null;
+        }
+        return Command.builder()
+                .commandId(detail.getCommandId())
+                .commandVersion(detail.getCommandVersion())
+                .commandType(detail.getCommandType())
+                .riskLevel(detail.getRiskLevel())
+                .status(detail.getStatus())
+                .taskId(detail.getTaskId())
+                .taskVersion(detail.getTaskVersion())
+                .summary(detail.getSummary())
+                .result(detail.getResult())
+                .error(detail.getError() != null
+                        ? Command.ErrorBody.builder()
+                                .code(detail.getError().getCode())
+                                .message(detail.getError().getMessage())
+                                .build()
+                        : null)
+                .expiresAt(detail.getExpiresAt())
+                .build();
+    }
 }