|
|
@@ -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();
|
|
|
+ }
|
|
|
}
|