From 20bb57df17c05707f226af453bc4011c57fef719 Mon Sep 17 00:00:00 2001 From: pnoker Date: Sat, 16 May 2026 16:23:48 +0800 Subject: [PATCH] refactor(agentic): split chat orchestration and structure tool results --- .../api/center/data/point_value.proto | 3 + .../agentic/config/ChatClientConfig.java | 6 +- .../entity/request/ChatCompletionRequest.java | 6 + .../entity/request/DirectQueryRequest.java | 87 ++ .../chat/AgenticChatRequestPreparer.java | 391 ++++++++ .../chat/AgenticChatResponseCodec.java | 250 +++++ .../service/chat/AgenticMessageRecorder.java | 146 +++ .../chat/AgenticPreparedChatRequest.java | 45 + .../service/chat/AgenticPromptBuilder.java | 99 ++ .../service/chat/AgenticStreamDelta.java | 38 + .../direct/AgenticDirectBackendService.java | 90 ++ .../DataMonitorDirectBackendProvider.java | 385 ++++++++ .../DeviceQueryDirectBackendProvider.java | 95 ++ .../agentic/service/direct/DirectAnswer.java | 74 ++ .../service/direct/DirectAnswerRenderer.java | 124 +++ .../service/direct/DirectBackendResult.java | 40 + .../service/impl/AgenticChatServiceImpl.java | 886 ++---------------- .../agentic/tool/AgenticToolResult.java | 52 + .../common/agentic/tool/AuthToolSet.java | 14 +- .../common/agentic/tool/DataToolSet.java | 183 ++-- .../common/agentic/tool/ManagerToolSet.java | 180 ++-- .../common/agentic/tool/PlatformToolSet.java | 174 ++-- .../main/resources/skills/data-monitor.yml | 2 +- .../main/resources/skills/device-query.yml | 2 +- .../DataMonitorDirectBackendProviderTest.java | 168 ++++ .../data/grpc/server/PointValueServer.java | 1 + .../facade/entity/bo/FacadePointValueBO.java | 5 + .../builder/FacadeGrpcPointValueBuilder.java | 1 + 28 files changed, 2434 insertions(+), 1113 deletions(-) create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/entity/request/DirectQueryRequest.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticChatRequestPreparer.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticChatResponseCodec.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticMessageRecorder.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticPreparedChatRequest.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticPromptBuilder.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticStreamDelta.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/AgenticDirectBackendService.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DataMonitorDirectBackendProvider.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DeviceQueryDirectBackendProvider.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectAnswer.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectAnswerRenderer.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectBackendResult.java create mode 100644 dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/AgenticToolResult.java create mode 100644 dc3-common/dc3-common-agentic/src/test/java/io/github/pnoker/common/agentic/service/direct/DataMonitorDirectBackendProviderTest.java diff --git a/dc3-api/dc3-api-data/src/main/protobuf/api/center/data/point_value.proto b/dc3-api/dc3-api-data/src/main/protobuf/api/center/data/point_value.proto index 16338a6db..ad99818da 100644 --- a/dc3-api/dc3-api-data/src/main/protobuf/api/center/data/point_value.proto +++ b/dc3-api/dc3-api-data/src/main/protobuf/api/center/data/point_value.proto @@ -82,6 +82,9 @@ message GrpcPointValueDTO { // Storage timestamp int64 create_time = 6; + + // Numeric projection of value; populated when value parses cleanly as a double + double num_value = 7; } // Request structure for querying historical point values diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/config/ChatClientConfig.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/config/ChatClientConfig.java index 79c223bf7..d2da588ec 100644 --- a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/config/ChatClientConfig.java +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/config/ChatClientConfig.java @@ -52,14 +52,14 @@ public class ChatClientConfig { public static final String SYSTEM_PROMPT = """ You are an intelligent assistant for the IoT DC3 platform. - + You can help users manage IoT devices, query real-time and historical data, and perform device operations. You have access to the following capabilities: - + - **Auth tools**: Read the current low-sensitivity tenant and user context. - **Manager tools**: Query devices, drivers, and data points (metrics). - **Data tools**: Read real-time point values, query historical data, and send read/write commands to devices. - + Guidelines: - Always confirm before sending write commands to physical devices. - Present data in a clear, structured format. diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/entity/request/ChatCompletionRequest.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/entity/request/ChatCompletionRequest.java index 16c26bd75..91808a308 100644 --- a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/entity/request/ChatCompletionRequest.java +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/entity/request/ChatCompletionRequest.java @@ -91,6 +91,12 @@ public class ChatCompletionRequest { */ private Boolean confirmActions; + /** + * Structured deterministic query. When present, the backend may answer directly + * from DC3 data without asking the model to infer parameters from natural language. + */ + private DirectQueryRequest directQuery; + public boolean isStream() { return Boolean.TRUE.equals(stream); } diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/entity/request/DirectQueryRequest.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/entity/request/DirectQueryRequest.java new file mode 100644 index 000000000..fcdb649fb --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/entity/request/DirectQueryRequest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.entity.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.commons.lang3.StringUtils; + +import java.util.Objects; + +/** + * Explicit deterministic query payload for server-side agentic lookups. + *

+ * This object is intentionally separate from the free-form chat message. Direct backend + * lookups only run when clients provide structured selectors here; natural language is + * handled by the normal model/tool path. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class DirectQueryRequest { + + public static final String TYPE_POINT_VALUE = "point-value"; + + /** + * Query type. Currently supports {@code point-value}. + */ + private String type; + + private Long deviceId; + + private String deviceName; + + private String deviceCode; + + private Long pointId; + + private String pointName; + + private String pointCode; + + /** + * Number of latest values to return. Clamped to 1..50 by the backend. + */ + private Integer limit; + + public boolean isPointValueQuery() { + return TYPE_POINT_VALUE.equalsIgnoreCase(StringUtils.trimToEmpty(type)); + } + + public boolean hasDeviceSelector() { + return Objects.nonNull(deviceId) || StringUtils.isNotBlank(deviceName) || StringUtils.isNotBlank(deviceCode); + } + + public boolean hasPointSelector() { + return Objects.nonNull(pointId) || StringUtils.isNotBlank(pointName) || StringUtils.isNotBlank(pointCode); + } + + public int normalizedLimit() { + if (Objects.isNull(limit)) { + return 1; + } + return Math.max(1, Math.min(limit, 50)); + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticChatRequestPreparer.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticChatRequestPreparer.java new file mode 100644 index 000000000..c5e6f9838 --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticChatRequestPreparer.java @@ -0,0 +1,391 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.chat; + +import io.github.pnoker.common.agentic.config.AgenticProperties; +import io.github.pnoker.common.agentic.config.ChatClientConfig; +import io.github.pnoker.common.agentic.config.ChatClientFactory; +import io.github.pnoker.common.agentic.context.AgenticRequestContext; +import io.github.pnoker.common.agentic.entity.bo.MessageBO; +import io.github.pnoker.common.agentic.entity.model.AgenticMessageContent; +import io.github.pnoker.common.agentic.entity.model.SessionExt; +import io.github.pnoker.common.agentic.entity.request.ChatCompletionRequest; +import io.github.pnoker.common.agentic.entity.request.ChatMessageDTO; +import io.github.pnoker.common.agentic.entity.request.DirectQueryRequest; +import io.github.pnoker.common.agentic.service.AttachmentService; +import io.github.pnoker.common.agentic.service.MessageService; +import io.github.pnoker.common.agentic.service.SessionService; +import io.github.pnoker.common.agentic.service.direct.AgenticDirectBackendService; +import io.github.pnoker.common.agentic.service.direct.DirectAnswerRenderer; +import io.github.pnoker.common.agentic.service.direct.DirectBackendResult; +import io.github.pnoker.common.agentic.skill.SkillDefinition; +import io.github.pnoker.common.agentic.skill.SkillRegistry; +import io.github.pnoker.common.agentic.util.AgenticConversationIds; +import io.github.pnoker.common.agentic.util.AgenticTokenEstimator; +import io.github.pnoker.common.constant.service.AgenticConstant; +import io.github.pnoker.common.entity.common.RequestHeader; +import io.github.pnoker.common.exception.RequestException; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Converts an API chat request into validated, tenant-scoped orchestration state. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +@Slf4j +@Component +public class AgenticChatRequestPreparer { + + private final ChatClientFactory chatClientFactory; + + private final SkillRegistry skillRegistry; + + private final SessionService sessionService; + + private final MessageService messageService; + + private final AttachmentService attachmentService; + + private final AgenticDirectBackendService directBackendService; + + private final DirectAnswerRenderer directAnswerRenderer; + + private final AgenticProperties properties; + + public AgenticChatRequestPreparer(ChatClientFactory chatClientFactory, SkillRegistry skillRegistry, + SessionService sessionService, MessageService messageService, + AttachmentService attachmentService, + AgenticDirectBackendService directBackendService, + DirectAnswerRenderer directAnswerRenderer, + AgenticProperties properties) { + this.chatClientFactory = chatClientFactory; + this.skillRegistry = skillRegistry; + this.sessionService = sessionService; + this.messageService = messageService; + this.attachmentService = attachmentService; + this.directBackendService = directBackendService; + this.directAnswerRenderer = directAnswerRenderer; + this.properties = properties; + } + + public AgenticPreparedChatRequest prepare(ChatCompletionRequest request, RequestHeader.UserHeader userHeader, + String mode) { + validateRequest(request); + + String rawUserMessage = extractLastUserMessage(request); + List attachments = normalizeAttachments(request); + String attachmentContext = attachmentService.summarize(attachments, userHeader); + String conversationId = resolveConversationId(request); + String scopedConversationId = AgenticConversationIds.scope(userHeader.getTenantId(), userHeader.getUserId(), + conversationId); + SkillDefinition skill = resolveSkill(request.getSkill(), rawUserMessage, request.getDirectQuery()); + String effectiveSkillName = Objects.isNull(skill) ? null : skill.getName(); + List toolNames = Objects.isNull(skill) ? List.of() : skillRegistry.getEnabledToolNames(skill.getName()); + String skillSystemPrompt = Objects.isNull(skill) ? null : buildSkillSystemPrompt(skill); + String model = chatClientFactory.resolveModel(request.getModel()); + boolean toolCallingEnabled = properties.isToolCallingEnabled() && chatClientFactory.supportsToolCall(model); + Queue toolEvents = new ConcurrentLinkedQueue<>(); + Map toolContext = buildToolContext(request, userHeader, scopedConversationId, toolEvents); + + DirectBackendResult directBackendResult = directBackendService.build(effectiveSkillName, + request.getDirectQuery(), userHeader, toolEvents); + String directContext = Objects.isNull(directBackendResult) ? null : directBackendResult.context(); + String directAnswer = Objects.isNull(directBackendResult) + ? null + : directAnswerRenderer.render(directBackendResult.answer()); + List contexts = buildContexts(attachmentContext, directContext); + String requestSystemContext = buildRequestSystemContext(contexts); + List memoryHistory = loadMemoryHistory(scopedConversationId); + AgenticRequestContext.setMemoryHistory(scopedConversationId, memoryHistory); + log.debug("Agentic memory loaded, scopedConversationId={}, memoryEnabled={}, count={}", + scopedConversationId, properties.isMemoryEnabled(), memoryHistory.size()); + AgenticMessageContent.Tokens inputTokens = buildInputTokens(rawUserMessage, skillSystemPrompt, + requestSystemContext, contexts, memoryHistory); + + log.debug( + "Agentic chat request received, mode={}, model={}, messageCount={}, conversationIdPresent={}, skill={}, tenantId={}, userId={}", + mode, model, request.getMessages().size(), StringUtils.isNotBlank(request.getConversationId()), + Objects.isNull(skill) ? null : skill.getName(), userHeader.getTenantId(), userHeader.getUserId()); + + touchSession(scopedConversationId, conversationId, userHeader, model, buildSessionExt(request, model)); + + return new AgenticPreparedChatRequest(rawUserMessage, scopedConversationId, skillSystemPrompt, + requestSystemContext, normalizeToolNames(toolNames), model, effectiveSkillName, toolContext, + request.getTemperature(), request.getMaxTokens(), skill, toolEvents, + toolCallingEnabled, Boolean.TRUE.equals(request.getReasoning()), + StringUtils.isNotBlank(directContext) || StringUtils.isNotBlank(directAnswer), + attachments, contexts, inputTokens, new ArrayList<>(), + directAnswer); + } + + private Map buildToolContext(ChatCompletionRequest request, RequestHeader.UserHeader userHeader, + String scopedConversationId, + Queue toolEvents) { + Map toolContext = new HashMap<>(); + toolContext.put(AgenticConstant.ToolContextKey.TENANT_ID, userHeader.getTenantId()); + toolContext.put(AgenticConstant.ToolContextKey.USER_ID, userHeader.getUserId()); + toolContext.put(AgenticConstant.ToolContextKey.CONVERSATION_ID, scopedConversationId); + toolContext.put(AgenticConstant.ToolContextKey.CONFIRM_ACTIONS, + !Boolean.FALSE.equals(request.getConfirmActions())); + toolContext.put(AgenticConstant.ToolContextKey.TOOL_EVENTS, toolEvents); + return toolContext; + } + + private void validateRequest(ChatCompletionRequest request) { + if (Objects.isNull(request)) { + throw new RequestException("Chat completion request is required"); + } + if (Objects.isNull(request.getMessages()) || request.getMessages().isEmpty()) { + throw new RequestException("Chat messages are required"); + } + if (Objects.nonNull(request.getTemperature()) + && (request.getTemperature() < 0.0 || request.getTemperature() > 2.0)) { + throw new RequestException("Temperature must be between 0.0 and 2.0"); + } + if (Objects.nonNull(request.getMaxTokens()) && request.getMaxTokens() < 1) { + throw new RequestException("Max tokens must be greater than 0"); + } + } + + private String extractLastUserMessage(ChatCompletionRequest request) { + return request.getMessages() + .stream() + .filter(message -> Objects.nonNull(message) && "user".equals(message.getRole())) + .map(ChatMessageDTO::getContent) + .filter(StringUtils::isNotBlank) + .reduce((first, second) -> second) + .orElseThrow(() -> new RequestException("A non-empty user message is required")); + } + + private String resolveConversationId(ChatCompletionRequest request) { + String conversationId = StringUtils.trimToNull(request.getConversationId()); + if (Objects.isNull(conversationId)) { + throw new RequestException("conversationId is required - clients must generate and reuse " + + "a stable conversationId so chat memory can be replayed across turns."); + } + return conversationId; + } + + private SkillDefinition resolveSkill(String skillName, String userMessage, DirectQueryRequest directQuery) { + String normalizedSkillName = StringUtils.trimToNull(skillName); + if (Objects.isNull(normalizedSkillName)) { + normalizedSkillName = inferSkillName(userMessage, directQuery); + } + if (Objects.isNull(normalizedSkillName)) { + return null; + } + SkillDefinition skill = skillRegistry.get(normalizedSkillName); + if (Objects.isNull(skill)) { + log.warn("Agentic skill not found, skill={}", normalizedSkillName); + throw new RequestException("Agentic skill does not exist: {}", normalizedSkillName); + } + log.debug("Agentic skill activated, skill={}, toolNames={}", skill.getName(), skill.getTools()); + return skill; + } + + private String inferSkillName(String userMessage, DirectQueryRequest directQuery) { + if (Objects.nonNull(directQuery)) { + return "data-monitor"; + } + String text = normalizeInferenceText(userMessage); + if (StringUtils.containsAny(text, "write", "control", "command", "set ", "read ", "写入", "控制", "命令", + "下发", "读取")) { + return "device-control"; + } + if (StringUtils.containsAny(text, "value", "history", "trend", "event", "alarm", "monitor", "data", "point", + "值", "历史", "趋势", "事件", "告警", "报警", "监控", "数据", "位号")) { + return "data-monitor"; + } + if (StringUtils.containsAny(text, "device", "driver", "profile", "status", "list", "search", "设备", "驱动", + "模板", "状态", "列表", "查询", "搜索")) { + return "device-query"; + } + return null; + } + + private String normalizeInferenceText(String userMessage) { + String text = StringUtils.defaultString(userMessage).toLowerCase(Locale.ROOT); + int confirmationIndex = text.indexOf("before executing any write"); + if (confirmationIndex >= 0) { + text = text.substring(0, confirmationIndex); + } + int attachmentIndex = text.indexOf("attached files available"); + if (attachmentIndex >= 0) { + text = text.substring(0, attachmentIndex); + } + return text; + } + + private List normalizeToolNames(List toolNames) { + if (Objects.isNull(toolNames) || toolNames.isEmpty()) { + return List.of(); + } + return toolNames.stream().filter(StringUtils::isNotBlank).distinct().toList(); + } + + private String buildSkillSystemPrompt(SkillDefinition skill) { + List sections = new ArrayList<>(); + if (StringUtils.isNotBlank(skill.getSystemPromptAddition())) { + sections.add(skill.getSystemPromptAddition().trim()); + } + if (Objects.nonNull(skill.getExamples()) && !skill.getExamples().isEmpty()) { + StringBuilder examples = new StringBuilder("Examples:"); + for (SkillDefinition.SkillExample example : skill.getExamples()) { + if (Objects.isNull(example) || StringUtils.isAnyBlank(example.getUser(), example.getAssistant())) { + continue; + } + examples.append("\n- User: ").append(example.getUser().trim()) + .append("\n Assistant: ").append(example.getAssistant().trim()); + } + sections.add(examples.toString()); + } + return sections.isEmpty() ? null : String.join("\n\n", sections); + } + + private List buildContexts(String attachmentContext, String directContext) { + List contexts = new ArrayList<>(); + if (StringUtils.isNotBlank(attachmentContext)) { + contexts.add(AgenticMessageContent.Context.of("attachment", attachmentContext.trim())); + } + if (StringUtils.isNotBlank(directContext)) { + contexts.add(AgenticMessageContent.Context.of("backend", directContext.trim())); + } + return contexts; + } + + private String buildRequestSystemContext(List contexts) { + List sections = new ArrayList<>(); + for (AgenticMessageContent.Context context : contexts) { + if (Objects.isNull(context) || StringUtils.isBlank(context.getContent())) { + continue; + } + if ("backend".equals(context.getType())) { + sections.add("Backend context:\n" + context.getContent().trim() + + "\n\nThe backend context above is returned by server-side DC3 queries."); + } else if ("attachment".equals(context.getType())) { + sections.add("Attachment context:\n" + context.getContent().trim() + + "\n\nUse only the metadata above unless a future multimodal model endpoint provides file contents."); + } else { + sections.add(context.getContent().trim()); + } + } + return sections.isEmpty() ? null : String.join("\n\n", sections); + } + + private AgenticMessageContent.Tokens buildInputTokens(String userMessage, String skillSystemPrompt, + String requestSystemContext, + List contexts, + List memoryHistory) { + int textTokens = AgenticTokenEstimator.estimate(userMessage); + int contextTokens = contexts.stream() + .map(AgenticMessageContent.Context::getContent) + .mapToInt(AgenticTokenEstimator::estimate) + .sum(); + int systemTokens = AgenticTokenEstimator.estimate(ChatClientConfig.SYSTEM_PROMPT) + + AgenticTokenEstimator.estimate(skillSystemPrompt) + + AgenticTokenEstimator.estimate(systemInstructions(requestSystemContext, contexts)); + int memoryTokens = estimateMemoryTokens(memoryHistory); + return AgenticMessageContent.Tokens.of(textTokens + contextTokens + systemTokens + memoryTokens, 0, + textTokens, contextTokens, systemTokens, memoryTokens); + } + + private String systemInstructions(String requestSystemContext, List contexts) { + if (StringUtils.isBlank(requestSystemContext)) { + return ""; + } + List instructions = new ArrayList<>(); + if (contexts.stream().anyMatch(context -> "backend".equals(context.getType()))) { + instructions.add("Backend context is returned by server-side DC3 queries."); + } + if (contexts.stream().anyMatch(context -> "attachment".equals(context.getType()))) { + instructions.add("Use attachment metadata only unless a future multimodal model endpoint provides file contents."); + } + return String.join("\n", instructions); + } + + private List loadMemoryHistory(String scopedConversationId) { + if (!properties.isMemoryEnabled()) { + return List.of(); + } + try { + return messageService.loadHistory(scopedConversationId, properties.getHistoryWindowSize()); + } catch (Exception e) { + log.debug("Agentic memory history load failed, conversationId={}", scopedConversationId, e); + return List.of(); + } + } + + private int estimateMemoryTokens(List history) { + if (!properties.isMemoryEnabled() || Objects.isNull(history) || history.isEmpty()) { + return 0; + } + return history.stream() + .map(message -> Objects.nonNull(message.getContent()) ? message.getContent().getText() : null) + .map(StringUtils::defaultString) + .filter(StringUtils::isNotBlank) + .mapToInt(AgenticTokenEstimator::estimate) + .sum(); + } + + private SessionExt buildSessionExt(ChatCompletionRequest request, String model) { + if (Objects.isNull(request.getReasoning()) && Objects.isNull(request.getTemperature()) + && Objects.isNull(request.getMaxTokens()) && Objects.isNull(request.getConfirmActions()) + && StringUtils.isBlank(model)) { + return null; + } + SessionExt sessionExt = new SessionExt(); + sessionExt.setModel(model); + sessionExt.setReasoningEnabled(request.getReasoning()); + sessionExt.setTemperature(request.getTemperature()); + sessionExt.setMaxTokens(request.getMaxTokens()); + sessionExt.setRequireConfirmation(request.getConfirmActions()); + return sessionExt; + } + + private void touchSession(String scopedConversationId, String conversationId, RequestHeader.UserHeader userHeader, + String model, SessionExt sessionExt) { + try { + sessionService.touch(scopedConversationId, userHeader.getTenantId(), userHeader.getUserId(), model, + sessionExt); + } catch (Exception e) { + log.warn( + "Agentic session touch failed, tenantId={}, userId={}, conversationId={}", + userHeader.getTenantId(), userHeader.getUserId(), conversationId, e); + } + } + + private List normalizeAttachments(ChatCompletionRequest request) { + if (Objects.isNull(request.getAttachments()) || request.getAttachments().isEmpty()) { + return List.of(); + } + return request.getAttachments().stream().filter(Objects::nonNull).distinct().toList(); + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticChatResponseCodec.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticChatResponseCodec.java new file mode 100644 index 000000000..0b9a3f627 --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticChatResponseCodec.java @@ -0,0 +1,250 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.chat; + +import com.openai.core.JsonValue; +import com.openai.models.chat.completions.ChatCompletionChunk; +import io.github.pnoker.common.agentic.context.AgenticRequestContext; +import io.github.pnoker.common.agentic.entity.response.ChatCompletionChunkResponse; +import io.github.pnoker.common.agentic.entity.response.ChatCompletionResponse; +import io.github.pnoker.common.agentic.skill.SkillDefinition; +import io.github.pnoker.common.agentic.util.AgenticTokenEstimator; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.stereotype.Component; +import tools.jackson.databind.DatabindException; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Encodes agentic chat responses and server-sent events. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +@Slf4j +@Component +public class AgenticChatResponseCodec { + + private final ObjectMapper objectMapper; + + public AgenticChatResponseCodec(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public ChatCompletionResponse blockingResponse(AgenticPreparedChatRequest prepared, String content, + String finishReason) { + int completionTokens = AgenticTokenEstimator.estimate(content); + int promptTokens = Objects.nonNull(prepared.inputTokens()) ? prepared.inputTokens().getInput() : 0; + return ChatCompletionResponse.builder() + .id(newChatId()) + .object("chat.completion") + .created(Instant.now().getEpochSecond()) + .model(prepared.model()) + .choices(List.of(ChatCompletionResponse.Choice.builder() + .index(0) + .message(new ChatCompletionResponse.Message("assistant", content)) + .finishReason(finishReason) + .build())) + .usage(new ChatCompletionResponse.Usage(promptTokens, completionTokens, + promptTokens + completionTokens)) + .build(); + } + + public String assistantContent(ChatResponse chatResponse) { + return Objects.nonNull(chatResponse) && Objects.nonNull(chatResponse.getResult()) + && Objects.nonNull(chatResponse.getResult().getOutput()) + ? StringUtils.defaultString(chatResponse.getResult().getOutput().getText()) + : ""; + } + + public String finishReason(ChatResponse chatResponse) { + String finishReason = Objects.nonNull(chatResponse) && Objects.nonNull(chatResponse.getResult()) + && Objects.nonNull(chatResponse.getResult().getMetadata()) + ? chatResponse.getResult().getMetadata().getFinishReason() + : null; + return normalizeFinishReason(finishReason); + } + + public String newChatId() { + return "chatcmpl-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24); + } + + public String formatFinalChunk(String id, long created, String model, String finishReason) { + ChatCompletionChunkResponse chunk = ChatCompletionChunkResponse.builder() + .id(id) + .object("chat.completion.chunk") + .created(created) + .model(model) + .choices(List.of(ChatCompletionChunkResponse.ChunkChoice.builder() + .index(0) + .delta(new ChatCompletionChunkResponse.Delta(null, null, null)) + .finishReason(finishReason) + .build())) + .build(); + return toJson(chunk); + } + + public void rememberFinishReason(ChatResponse response, AtomicReference sink) { + if (Objects.isNull(response) || Objects.isNull(response.getResult()) + || Objects.isNull(response.getResult().getMetadata())) { + return; + } + String reason = response.getResult().getMetadata().getFinishReason(); + if (StringUtils.isNotBlank(reason)) { + sink.set(reason); + } + } + + public String normalizeFinishReason(String reason) { + if (StringUtils.isBlank(reason)) { + return "stop"; + } + return reason.toLowerCase(Locale.ROOT); + } + + public List> initialEvents(AgenticPreparedChatRequest prepared) { + List> events = new ArrayList<>(); + SkillDefinition skillDefinition = prepared.skillDefinition(); + String skillName = Objects.nonNull(skillDefinition) ? skillDefinition.getName() : "general"; + String skillDescription = Objects.nonNull(skillDefinition) ? skillDefinition.getDescription() + : "General assistant mode"; + events.add(ServerSentEvent.builder() + .data(formatEvent("skill", "Auto skill", skillDescription, skillName)) + .build()); + if (StringUtils.isBlank(prepared.directAnswer()) && prepared.toolCallingEnabled() + && !prepared.toolNames().isEmpty()) { + events.add(ServerSentEvent.builder() + .data(formatEvent("tools", "Available tools", String.join(", ", prepared.toolNames()), skillName)) + .build()); + } + if (prepared.directContextProvided()) { + events.add(ServerSentEvent.builder() + .data(formatEvent("tool", "Backend context loaded", "Queried DC3 backend before response", + skillName)) + .build()); + } + if (prepared.reasoning()) { + events.add(ServerSentEvent.builder() + .data(formatEvent("reasoning", "Thinking", "Reasoning mode requested for this model.", skillName)) + .build()); + } + return events; + } + + public List> chunkEvents(AgenticPreparedChatRequest prepared, String chatId, long created, + AgenticStreamDelta streamDelta) { + List> events = new ArrayList<>(); + AgenticRequestContext.ToolEvent event = prepared.toolEvents().poll(); + while (Objects.nonNull(event)) { + prepared.toolTraceEvents().add(event); + events.add(ServerSentEvent.builder() + .data(formatEvent("tool", event.description(), event.domain(), event.toolName())) + .build()); + event = prepared.toolEvents().poll(); + } + events.add(ServerSentEvent.builder() + .data(formatChunk(chatId, created, prepared.model(), streamDelta)) + .build()); + return events; + } + + public AgenticStreamDelta extractStreamDelta(ChatResponse response) { + if (Objects.isNull(response) || Objects.isNull(response.getResult())) { + return AgenticStreamDelta.empty(); + } + Generation generation = response.getResult(); + String content = Objects.nonNull(generation.getOutput()) ? generation.getOutput().getText() : null; + if (log.isDebugEnabled()) { + log.debug("Agentic stream chunk, contentLen={}, hasReasoning={}", + Objects.isNull(content) ? 0 : content.length(), + Objects.nonNull(extractReasoningContent(generation))); + } + return new AgenticStreamDelta(StringUtils.defaultString(content), extractReasoningContent(generation)); + } + + public String formatEvent(String type, String title, String detail, String name) { + Map event = new HashMap<>(); + event.put("object", "agentic.event"); + event.put("type", type); + event.put("title", StringUtils.defaultString(title)); + event.put("detail", StringUtils.defaultString(detail)); + event.put("name", StringUtils.defaultString(name)); + event.put("created", Instant.now().getEpochSecond()); + return toJson(event); + } + + private String formatChunk(String id, long created, String model, AgenticStreamDelta streamDelta) { + ChatCompletionChunkResponse chunk = ChatCompletionChunkResponse.builder() + .id(id) + .object("chat.completion.chunk") + .created(created) + .model(model) + .choices(List.of(ChatCompletionChunkResponse.ChunkChoice.builder() + .index(0) + .delta(new ChatCompletionChunkResponse.Delta(null, streamDelta.content(), + streamDelta.reasoningContent())) + .finishReason(null) + .build())) + .build(); + return toJson(chunk); + } + + private String extractReasoningContent(Generation generation) { + if (Objects.isNull(generation) || Objects.isNull(generation.getOutput())) { + return null; + } + Object chunkChoice = generation.getOutput().getMetadata().get("chunkChoice"); + if (Objects.isNull(chunkChoice)) { + return null; + } + + if (chunkChoice instanceof ChatCompletionChunk.Choice openAiChunkChoice) { + Object rawValue = openAiChunkChoice.delta()._additionalProperties().get("reasoning_content"); + if (!(rawValue instanceof JsonValue value)) { + return null; + } + Optional reasoningContent = value.asString(); + return reasoningContent.orElse(null); + } + return null; + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (DatabindException e) { + log.error("Agentic response serialization failed, responseType={}", obj.getClass().getSimpleName(), e); + return "{}"; + } + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticMessageRecorder.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticMessageRecorder.java new file mode 100644 index 000000000..53ab21e18 --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticMessageRecorder.java @@ -0,0 +1,146 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.chat; + +import io.github.pnoker.common.agentic.context.AgenticRequestContext; +import io.github.pnoker.common.agentic.entity.model.AgenticMessageContent; +import io.github.pnoker.common.agentic.skill.SkillDefinition; +import io.github.pnoker.common.agentic.service.MessageService; +import io.github.pnoker.common.agentic.util.AgenticTokenEstimator; +import io.github.pnoker.common.entity.common.RequestHeader; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Persists user and assistant messages for the agentic chat pipeline. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +@Component +public class AgenticMessageRecorder { + + private final MessageService messageService; + + public AgenticMessageRecorder(MessageService messageService) { + this.messageService = messageService; + } + + public void persistUserMessage(AgenticPreparedChatRequest prepared, RequestHeader.UserHeader userHeader) { + messageService.save(prepared.scopedConversationId(), "user", buildUserContent(prepared), prepared.model(), + userHeader); + } + + public void persistAssistantMessage(AgenticPreparedChatRequest prepared, String content, + RequestHeader.UserHeader userHeader) { + if (StringUtils.isBlank(content)) { + return; + } + messageService.save(prepared.scopedConversationId(), "assistant", buildAssistantContent(prepared, content), + prepared.model(), userHeader); + } + + private AgenticMessageContent buildUserContent(AgenticPreparedChatRequest prepared) { + AgenticMessageContent content = AgenticMessageContent.ofText(prepared.userMessage()); + if (!prepared.attachments().isEmpty()) { + content.setAttachments(prepared.attachments()); + } + return content; + } + + private AgenticMessageContent buildAssistantContent(AgenticPreparedChatRequest prepared, String text) { + List toolEvents = drainToolEvents(prepared); + List tools = toolEvents.stream() + .filter(event -> !"agentic".equals(event.domain())) + .map(AgenticRequestContext.ToolEvent::toolName) + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + + AgenticMessageContent content = AgenticMessageContent.ofText(text); + content.setFormat("markdown"); + content.setSkills(skillNames(prepared)); + content.setTools(tools); + content.setTraces(buildTraceEvents(prepared, toolEvents)); + content.setReasoning(prepared.reasoning()); + content.setDirectContextProvided(prepared.directContextProvided()); + content.setContexts(prepared.contexts()); + content.setTokens(outputTokens(prepared.inputTokens(), text)); + return content; + } + + private List buildTraceEvents(AgenticPreparedChatRequest prepared, + List toolEvents) { + List traces = new ArrayList<>(); + long created = Instant.now().getEpochSecond(); + SkillDefinition skillDefinition = prepared.skillDefinition(); + String skillName = Objects.nonNull(skillDefinition) ? skillDefinition.getName() : "general"; + String skillDescription = Objects.nonNull(skillDefinition) ? skillDefinition.getDescription() + : "General assistant mode"; + traces.add(AgenticMessageContent.Trace.of("skill", "Auto skill", skillDescription, skillName, created)); + if (StringUtils.isBlank(prepared.directAnswer()) && prepared.toolCallingEnabled() + && !prepared.toolNames().isEmpty()) { + traces.add(AgenticMessageContent.Trace.of("tools", "Available tools", + String.join(", ", prepared.toolNames()), skillName, created)); + } + if (prepared.directContextProvided()) { + traces.add(AgenticMessageContent.Trace.of("tool", "Backend context loaded", + "Queried DC3 backend before response", skillName, created)); + } + if (prepared.reasoning()) { + traces.add(AgenticMessageContent.Trace.of("reasoning", "Thinking", + "Reasoning mode requested for this model.", skillName, created)); + } + for (AgenticRequestContext.ToolEvent event : toolEvents) { + traces.add(AgenticMessageContent.Trace.of("tool", event.description(), event.domain(), event.toolName(), + event.timestamp() / 1000)); + } + return traces; + } + + private List drainToolEvents(AgenticPreparedChatRequest prepared) { + AgenticRequestContext.ToolEvent event = prepared.toolEvents().poll(); + while (Objects.nonNull(event)) { + prepared.toolTraceEvents().add(event); + event = prepared.toolEvents().poll(); + } + return prepared.toolTraceEvents(); + } + + private AgenticMessageContent.Tokens outputTokens(AgenticMessageContent.Tokens inputTokens, String assistantText) { + int outputTokens = AgenticTokenEstimator.estimate(assistantText); + AgenticMessageContent.Tokens tokens = new AgenticMessageContent.Tokens(); + tokens.setInput(inputTokens.getInput()); + tokens.setOutput(outputTokens); + tokens.setText(inputTokens.getText()); + tokens.setContext(inputTokens.getContext()); + tokens.setSystem(inputTokens.getSystem()); + tokens.setMemory(inputTokens.getMemory()); + return tokens; + } + + private List skillNames(AgenticPreparedChatRequest prepared) { + return StringUtils.isBlank(prepared.skill()) ? List.of() : List.of(prepared.skill()); + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticPreparedChatRequest.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticPreparedChatRequest.java new file mode 100644 index 000000000..0a68ead73 --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticPreparedChatRequest.java @@ -0,0 +1,45 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.chat; + +import io.github.pnoker.common.agentic.context.AgenticRequestContext; +import io.github.pnoker.common.agentic.entity.model.AgenticMessageContent; +import io.github.pnoker.common.agentic.skill.SkillDefinition; + +import java.util.List; +import java.util.Map; +import java.util.Queue; + +/** + * Immutable request state shared by the chat orchestration pipeline. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +public record AgenticPreparedChatRequest(String userMessage, String scopedConversationId, String skillSystemPrompt, + String requestSystemContext, List toolNames, String model, + String skill, Map toolContext, Double temperature, + Integer maxTokens, SkillDefinition skillDefinition, + Queue toolEvents, + boolean toolCallingEnabled, boolean reasoning, + boolean directContextProvided, List attachments, + List contexts, + AgenticMessageContent.Tokens inputTokens, + List toolTraceEvents, + String directAnswer) { +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticPromptBuilder.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticPromptBuilder.java new file mode 100644 index 000000000..7dc62a300 --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticPromptBuilder.java @@ -0,0 +1,99 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.chat; + +import io.github.pnoker.common.agentic.config.ChatClientConfig; +import io.github.pnoker.common.agentic.config.ChatClientFactory; +import org.apache.commons.lang3.StringUtils; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Builds Spring AI chat prompts from prepared request state. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +@Component +public class AgenticPromptBuilder { + + private final ChatClientFactory chatClientFactory; + + private final ToolCallbackProvider toolCallbackProvider; + + public AgenticPromptBuilder(ChatClientFactory chatClientFactory, + @Qualifier("agenticToolCallbackProvider") ToolCallbackProvider toolCallbackProvider) { + this.chatClientFactory = chatClientFactory; + this.toolCallbackProvider = toolCallbackProvider; + } + + public ChatClient.ChatClientRequestSpec build(AgenticPreparedChatRequest prepared) { + ChatClient chatClient = chatClientFactory.getOrCreate(prepared.model()); + ChatClient.ChatClientRequestSpec promptSpec = chatClient.prompt() + .user(prepared.userMessage()) + .toolContext(prepared.toolContext()) + .advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, prepared.scopedConversationId())); + + String systemPrompt = buildSystemPrompt(prepared); + if (StringUtils.isNotBlank(systemPrompt)) { + promptSpec = promptSpec.system(systemPrompt); + } + promptSpec = applyToolCallbacks(promptSpec, prepared); + promptSpec = applyRequestOptions(promptSpec, prepared.model(), prepared.temperature(), prepared.maxTokens()); + return promptSpec; + } + + private ChatClient.ChatClientRequestSpec applyToolCallbacks(ChatClient.ChatClientRequestSpec promptSpec, + AgenticPreparedChatRequest prepared) { + if (!prepared.toolCallingEnabled()) { + return promptSpec; + } + promptSpec = promptSpec.toolCallbacks(toolCallbackProvider); + if (!prepared.toolNames().isEmpty()) { + promptSpec = promptSpec.toolNames(prepared.toolNames().toArray(String[]::new)); + } + return promptSpec; + } + + private ChatClient.ChatClientRequestSpec applyRequestOptions(ChatClient.ChatClientRequestSpec promptSpec, + String model, Double temperature, Integer maxTokens) { + ChatOptions.Builder optionsBuilder = chatClientFactory.buildChatOptionsBuilder(model, temperature, maxTokens); + return Objects.nonNull(optionsBuilder) ? promptSpec.options(optionsBuilder) : promptSpec; + } + + private String buildSystemPrompt(AgenticPreparedChatRequest prepared) { + List sections = new ArrayList<>(); + sections.add(ChatClientConfig.SYSTEM_PROMPT); + if (StringUtils.isNotBlank(prepared.skillSystemPrompt())) { + sections.add(prepared.skillSystemPrompt().trim()); + } + if (StringUtils.isNotBlank(prepared.requestSystemContext())) { + sections.add(prepared.requestSystemContext().trim()); + } + return String.join("\n\n", sections); + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticStreamDelta.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticStreamDelta.java new file mode 100644 index 000000000..babf93346 --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/chat/AgenticStreamDelta.java @@ -0,0 +1,38 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.chat; + +import org.apache.commons.lang3.StringUtils; + +/** + * Content emitted by one streaming model frame. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +public record AgenticStreamDelta(String content, String reasoningContent) { + + public static AgenticStreamDelta empty() { + return new AgenticStreamDelta("", null); + } + + public boolean hasContent() { + return StringUtils.isNotEmpty(content) || StringUtils.isNotEmpty(reasoningContent); + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/AgenticDirectBackendService.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/AgenticDirectBackendService.java new file mode 100644 index 000000000..a05e8c51c --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/AgenticDirectBackendService.java @@ -0,0 +1,90 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.direct; + +import io.github.pnoker.common.agentic.context.AgenticRequestContext; +import io.github.pnoker.common.agentic.entity.request.DirectQueryRequest; +import io.github.pnoker.common.entity.common.RequestHeader; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Objects; +import java.util.Queue; + +/** + * Routes deterministic backend lookups that can be resolved without involving the model. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +@Slf4j +@Service +public class AgenticDirectBackendService { + + private final DeviceQueryDirectBackendProvider deviceQueryProvider; + + private final DataMonitorDirectBackendProvider dataMonitorProvider; + + public AgenticDirectBackendService(DeviceQueryDirectBackendProvider deviceQueryProvider, + DataMonitorDirectBackendProvider dataMonitorProvider) { + this.deviceQueryProvider = deviceQueryProvider; + this.dataMonitorProvider = dataMonitorProvider; + } + + public DirectBackendResult build(String skillName, DirectQueryRequest directQuery, RequestHeader.UserHeader userHeader, + Queue toolEvents) { + try { + if (Objects.nonNull(directQuery)) { + if (directQuery.isPointValueQuery()) { + return dataMonitorProvider.build(directQuery, userHeader, toolEvents); + } + return DirectBackendResult.direct(DirectAnswer.message("查询失败", + "不支持的确定性查询类型:" + StringUtils.defaultString(directQuery.getType()))); + } + if (StringUtils.isBlank(skillName)) { + return null; + } + if ("device-query".equals(skillName)) { + return deviceQueryProvider.build(userHeader, toolEvents); + } + if ("data-monitor".equals(skillName)) { + return dataMonitorProvider.build(directQuery, userHeader, toolEvents); + } + } catch (Exception e) { + log.warn("Agentic direct backend lookup failed, skill={}, tenantId={}, userId={}", skillName, + userHeader.getTenantId(), userHeader.getUserId(), e); + offerToolEvent(toolEvents, "directContext", "agentic", + "Backend context query failed: " + e.getMessage()); + if (dataMonitorProvider.isResolvedPointValueRequest(directQuery)) { + return dataMonitorProvider.failedQueryResult(); + } + } + return null; + } + + private void offerToolEvent(Queue toolEvents, String toolName, String domain, + String description) { + if (Objects.nonNull(toolEvents)) { + toolEvents.offer(new AgenticRequestContext.ToolEvent(toolName, domain, description, + Instant.now().toEpochMilli())); + } + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DataMonitorDirectBackendProvider.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DataMonitorDirectBackendProvider.java new file mode 100644 index 000000000..c30b61d57 --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DataMonitorDirectBackendProvider.java @@ -0,0 +1,385 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.direct; + +import io.github.pnoker.common.agentic.context.AgenticRequestContext; +import io.github.pnoker.common.agentic.entity.request.DirectQueryRequest; +import io.github.pnoker.common.entity.base.BaseBO; +import io.github.pnoker.common.entity.common.Pages; +import io.github.pnoker.common.entity.common.RequestHeader; +import io.github.pnoker.common.facade.api.DeviceFacade; +import io.github.pnoker.common.facade.api.DriverFacade; +import io.github.pnoker.common.facade.api.PointFacade; +import io.github.pnoker.common.facade.api.PointValueFacade; +import io.github.pnoker.common.facade.entity.bo.FacadeDeviceBO; +import io.github.pnoker.common.facade.entity.bo.FacadeDriverBO; +import io.github.pnoker.common.facade.entity.bo.FacadePointBO; +import io.github.pnoker.common.facade.entity.bo.FacadePointValueBO; +import io.github.pnoker.common.facade.entity.common.FacadePage; +import io.github.pnoker.common.facade.entity.query.FacadeDeviceQuery; +import io.github.pnoker.common.facade.entity.query.FacadeDriverQuery; +import io.github.pnoker.common.facade.entity.query.FacadePointQuery; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Queue; + +/** + * Resolves deterministic data-monitor requests directly through the DC3 facade layer. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +@Component +public class DataMonitorDirectBackendProvider { + + private final DeviceFacade deviceFacade; + + private final DriverFacade driverFacade; + + private final PointFacade pointFacade; + + private final PointValueFacade pointValueFacade; + + public DataMonitorDirectBackendProvider(DeviceFacade deviceFacade, DriverFacade driverFacade, + PointFacade pointFacade, PointValueFacade pointValueFacade) { + this.deviceFacade = deviceFacade; + this.driverFacade = driverFacade; + this.pointFacade = pointFacade; + this.pointValueFacade = pointValueFacade; + } + + public DirectBackendResult build(DirectQueryRequest directQuery, RequestHeader.UserHeader userHeader, + Queue toolEvents) { + if (Objects.nonNull(directQuery)) { + if (directQuery.isPointValueQuery()) { + return buildResolvedPointValueResult(directQuery, userHeader, toolEvents); + } + return DirectBackendResult.direct(DirectAnswer.message("查询失败", + "不支持的确定性查询类型:" + StringUtils.defaultString(directQuery.getType()))); + } + + offerToolEvent(toolEvents, "getMonitoringSnapshot", "agentic", "Load monitoring snapshot"); + FacadeDeviceQuery deviceQuery = new FacadeDeviceQuery(); + deviceQuery.setTenantId(userHeader.getTenantId()); + deviceQuery.setPage(page(1, 10)); + FacadePage devices = deviceFacade.selectByPage(deviceQuery); + FacadeDriverQuery driverQuery = new FacadeDriverQuery(); + driverQuery.setTenantId(userHeader.getTenantId()); + driverQuery.setPage(page(1, 10)); + FacadePage drivers = driverFacade.selectByPage(driverQuery); + FacadePointQuery pointQuery = new FacadePointQuery(); + pointQuery.setTenantId(userHeader.getTenantId()); + pointQuery.setPage(page(1, 10)); + FacadePage points = pointFacade.selectByPage(pointQuery); + String context = "Monitoring snapshot:\n" + + "- devices total: " + total(devices) + "\n" + + "- drivers total: " + total(drivers) + "\n" + + "- points total: " + total(points) + "\n" + + "Sample devices: " + sampleDeviceNames(devices); + return DirectBackendResult.contextOnly(context); + } + + public boolean isResolvedPointValueRequest(DirectQueryRequest directQuery) { + return Objects.nonNull(directQuery) && directQuery.isPointValueQuery(); + } + + public DirectBackendResult failedQueryResult() { + return DirectBackendResult.direct(DirectAnswer.message("查询失败", + "后端数据查询执行失败,请稍后重试或检查查询条件。")); + } + + private DirectBackendResult buildResolvedPointValueResult(DirectQueryRequest directQuery, + RequestHeader.UserHeader userHeader, + Queue toolEvents) { + if (!directQuery.hasDeviceSelector() || !directQuery.hasPointSelector()) { + return DirectBackendResult.direct(DirectAnswer.message("查询失败", + "确定性位号查询需要明确的设备选择器和位号选择器。请通过 directQuery.deviceId/deviceName/deviceCode " + + "与 directQuery.pointId/pointName/pointCode 传入结构化参数。")); + } + + Long tenantId = userHeader.getTenantId(); + FacadeDeviceBO device = resolveDevice(tenantId, directQuery); + if (Objects.isNull(device)) { + offerToolEvent(toolEvents, "searchDevices", "manager", + "Device lookup returned no unique match for " + deviceSelectorText(directQuery)); + return DirectBackendResult.direct(DirectAnswer.message("查询失败", + "当前租户下没有找到唯一匹配的设备 " + deviceSelectorText(directQuery) + + "。请检查设备 ID、名称或编码后重试。")); + } + offerToolEvent(toolEvents, "searchDevices", "manager", + "Resolved device " + device.getDeviceName() + " (" + device.getId() + ")"); + + FacadePointBO point = resolvePoint(tenantId, device.getId(), directQuery); + if (Objects.isNull(point)) { + offerToolEvent(toolEvents, "searchPoints", "manager", + "Point lookup returned no unique match for " + pointSelectorText(directQuery)); + return DirectBackendResult.direct(DirectAnswer.table("查询失败", + "已找到设备 " + device.getDeviceName() + ",但没有找到唯一匹配的位号 " + + pointSelectorText(directQuery) + "。", + List.of( + new DirectAnswer.Field("设备名称", device.getDeviceName()), + new DirectAnswer.Field("设备编码", device.getDeviceCode()), + new DirectAnswer.Field("设备ID", String.valueOf(device.getId())) + ), + List.of(), + List.of())); + } + offerToolEvent(toolEvents, "searchPoints", "manager", + "Resolved point " + point.getPointName() + " (" + point.getId() + ")"); + + FacadePointValueBO latestValue = pointValueFacade.lastValue(tenantId, device.getId(), point.getId()); + offerToolEvent(toolEvents, "getLatestPointValue", "data", "Loaded latest point value"); + + List history = List.of(); + int count = directQuery.normalizedLimit(); + if (count > 1) { + history = pointValueFacade.history(tenantId, device.getId(), point.getId(), count); + offerToolEvent(toolEvents, "getPointValueHistory", "data", + "Loaded latest " + count + " history values"); + } + + List fields = new ArrayList<>(); + fields.add(new DirectAnswer.Field("设备", + device.getDeviceName() + " (id=" + device.getId() + ", code=" + device.getDeviceCode() + ")")); + fields.add(new DirectAnswer.Field("位号", + point.getPointName() + " (id=" + point.getId() + ", code=" + point.getPointCode() + ")")); + fields.add(new DirectAnswer.Field("单位", point.getUnit())); + fields.add(new DirectAnswer.Field("数据类型", String.valueOf(point.getPointTypeFlag()))); + fields.add(new DirectAnswer.Field("读写标识", String.valueOf(point.getRwFlag()))); + if (Objects.nonNull(latestValue)) { + fields.add(new DirectAnswer.Field("最新值", latestValue.getValue() + " (raw=" + + latestValue.getRawValue() + ", time=" + latestValue.getCreateTime() + ")")); + } else { + fields.add(new DirectAnswer.Field("最新值", "未查询到最新值")); + } + + List tables = new ArrayList<>(); + List charts = new ArrayList<>(); + String message = null; + if (!history.isEmpty()) { + String title = "最新 " + history.size() + " 条历史值"; + if (history.size() == count) { + title += "(后端返回顺序:新到旧)"; + } + List> rows = new ArrayList<>(); + for (int i = 0; i < history.size(); i++) { + rows.add(List.of(String.valueOf(i + 1), history.get(i), StringUtils.defaultString(point.getUnit()))); + } + tables.add(new DirectAnswer.Table(title, List.of("#", "值", "单位"), rows)); + DirectAnswer.Chart chart = buildHistoryChart(device.getDeviceName(), point.getPointName(), point.getUnit(), + history); + if (Objects.nonNull(chart)) { + charts.add(chart); + } + } else if (count > 1) { + message = "后端没有返回请求的 " + count + " 条历史值。"; + } + + return DirectBackendResult.direct(DirectAnswer.table("位号数据查询结果", message, fields, tables, charts)); + } + + private FacadeDeviceBO resolveDevice(Long tenantId, DirectQueryRequest directQuery) { + if (Objects.nonNull(directQuery.getDeviceId())) { + FacadeDeviceBO device = deviceFacade.selectById(tenantId, directQuery.getDeviceId()); + return deviceMatchesSelector(device, directQuery) ? device : null; + } + + List candidates = new ArrayList<>(); + if (StringUtils.isNotBlank(directQuery.getDeviceName())) { + FacadeDeviceQuery nameQuery = new FacadeDeviceQuery(); + nameQuery.setTenantId(tenantId); + nameQuery.setDeviceName(StringUtils.trim(directQuery.getDeviceName())); + nameQuery.setPage(page(1, 10)); + addRecords(candidates, deviceFacade.selectByPage(nameQuery)); + } + if (StringUtils.isNotBlank(directQuery.getDeviceCode())) { + FacadeDeviceQuery codeQuery = new FacadeDeviceQuery(); + codeQuery.setTenantId(tenantId); + codeQuery.setDeviceCode(StringUtils.trim(directQuery.getDeviceCode())); + codeQuery.setPage(page(1, 10)); + addRecords(candidates, deviceFacade.selectByPage(codeQuery)); + } + + return uniqueDeviceMatch(candidates, directQuery); + } + + private FacadePointBO resolvePoint(Long tenantId, Long deviceId, DirectQueryRequest directQuery) { + if (Objects.nonNull(directQuery.getPointId())) { + FacadePointBO point = pointFacade.selectById(tenantId, directQuery.getPointId()); + return pointMatchesSelector(point, directQuery) ? point : null; + } + + List candidates = new ArrayList<>(); + if (StringUtils.isNotBlank(directQuery.getPointName())) { + FacadePointQuery nameQuery = new FacadePointQuery(); + nameQuery.setTenantId(tenantId); + nameQuery.setDeviceId(deviceId); + nameQuery.setPointName(StringUtils.trim(directQuery.getPointName())); + nameQuery.setPage(page(1, 10)); + addRecords(candidates, pointFacade.selectByPage(nameQuery)); + } + if (StringUtils.isNotBlank(directQuery.getPointCode())) { + FacadePointQuery codeQuery = new FacadePointQuery(); + codeQuery.setTenantId(tenantId); + codeQuery.setDeviceId(deviceId); + codeQuery.setPointCode(StringUtils.trim(directQuery.getPointCode())); + codeQuery.setPage(page(1, 10)); + addRecords(candidates, pointFacade.selectByPage(codeQuery)); + } + + return uniquePointMatch(candidates, directQuery); + } + + private void addRecords(List target, FacadePage page) { + if (Objects.nonNull(page) && Objects.nonNull(page.getRecords())) { + target.addAll(page.getRecords()); + } + } + + private FacadeDeviceBO uniqueDeviceMatch(List candidates, DirectQueryRequest directQuery) { + List matches = candidates.stream() + .filter(Objects::nonNull) + .filter(device -> deviceMatchesSelector(device, directQuery)) + .toList(); + return uniqueById(matches); + } + + private FacadePointBO uniquePointMatch(List candidates, DirectQueryRequest directQuery) { + List matches = candidates.stream() + .filter(Objects::nonNull) + .filter(point -> pointMatchesSelector(point, directQuery)) + .toList(); + return uniqueById(matches); + } + + private boolean deviceMatchesSelector(FacadeDeviceBO device, DirectQueryRequest directQuery) { + if (Objects.isNull(device)) { + return false; + } + return (Objects.isNull(directQuery.getDeviceId()) || Objects.equals(device.getId(), directQuery.getDeviceId())) + && (StringUtils.isBlank(directQuery.getDeviceName()) + || equalsNormalized(device.getDeviceName(), directQuery.getDeviceName())) + && (StringUtils.isBlank(directQuery.getDeviceCode()) + || equalsNormalized(device.getDeviceCode(), directQuery.getDeviceCode())); + } + + private boolean pointMatchesSelector(FacadePointBO point, DirectQueryRequest directQuery) { + if (Objects.isNull(point)) { + return false; + } + return (Objects.isNull(directQuery.getPointId()) || Objects.equals(point.getId(), directQuery.getPointId())) + && (StringUtils.isBlank(directQuery.getPointName()) + || equalsNormalized(point.getPointName(), directQuery.getPointName())) + && (StringUtils.isBlank(directQuery.getPointCode()) + || equalsNormalized(point.getPointCode(), directQuery.getPointCode())); + } + + private boolean equalsNormalized(String value, String expected) { + return StringUtils.isNotBlank(value) && StringUtils.isNotBlank(expected) + && value.trim().equalsIgnoreCase(expected.trim()); + } + + private T uniqueById(List candidates) { + Map unique = new LinkedHashMap<>(); + for (T candidate : candidates) { + if (Objects.nonNull(candidate.getId())) { + unique.putIfAbsent(candidate.getId(), candidate); + } + } + return unique.size() == 1 ? unique.values().iterator().next() : null; + } + + private String deviceSelectorText(DirectQueryRequest directQuery) { + if (Objects.nonNull(directQuery.getDeviceId())) { + return "deviceId=" + directQuery.getDeviceId(); + } + if (StringUtils.isNotBlank(directQuery.getDeviceName())) { + return "deviceName=" + directQuery.getDeviceName(); + } + return "deviceCode=" + directQuery.getDeviceCode(); + } + + private String pointSelectorText(DirectQueryRequest directQuery) { + if (Objects.nonNull(directQuery.getPointId())) { + return "pointId=" + directQuery.getPointId(); + } + if (StringUtils.isNotBlank(directQuery.getPointName())) { + return "pointName=" + directQuery.getPointName(); + } + return "pointCode=" + directQuery.getPointCode(); + } + + private DirectAnswer.Chart buildHistoryChart(String deviceName, String pointName, String unit, + List history) { + List> dataPoints = new ArrayList<>(); + int rendered = 0; + for (int i = history.size() - 1; i >= 0; i--) { + try { + double value = Double.parseDouble(StringUtils.trimToEmpty(history.get(i))); + dataPoints.add(List.of(rendered, value)); + rendered++; + } catch (NumberFormatException ignored) { + // Non-numeric point values are still shown in the table; only chart data skips them. + } + } + if (rendered == 0) { + return null; + } + return new DirectAnswer.Chart("line", deviceName + " / " + pointName, unit, "index (oldest to newest)", + "linear", List.of(new DirectAnswer.Series("value", dataPoints))); + } + + private void offerToolEvent(Queue toolEvents, String toolName, String domain, + String description) { + if (Objects.nonNull(toolEvents)) { + toolEvents.offer(new AgenticRequestContext.ToolEvent(toolName, domain, description, + Instant.now().toEpochMilli())); + } + } + + private Pages page(long current, long size) { + Pages page = new Pages(); + page.setCurrent(current); + page.setSize(size); + return page; + } + + private long total(FacadePage page) { + return Objects.isNull(page) ? 0 : page.getTotal(); + } + + private String sampleDeviceNames(FacadePage page) { + if (Objects.isNull(page) || page.getRecords().isEmpty()) { + return "none"; + } + return page.getRecords().stream() + .limit(5) + .map(FacadeDeviceBO::getDeviceName) + .filter(StringUtils::isNotBlank) + .toList() + .toString(); + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DeviceQueryDirectBackendProvider.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DeviceQueryDirectBackendProvider.java new file mode 100644 index 000000000..86cb34f5c --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DeviceQueryDirectBackendProvider.java @@ -0,0 +1,95 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.direct; + +import io.github.pnoker.common.agentic.context.AgenticRequestContext; +import io.github.pnoker.common.entity.common.Pages; +import io.github.pnoker.common.entity.common.RequestHeader; +import io.github.pnoker.common.facade.api.DeviceFacade; +import io.github.pnoker.common.facade.entity.bo.FacadeDeviceBO; +import io.github.pnoker.common.facade.entity.common.FacadePage; +import io.github.pnoker.common.facade.entity.query.FacadeDeviceQuery; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Queue; + +/** + * Builds deterministic tenant-scoped device answers for device-query requests. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +@Component +public class DeviceQueryDirectBackendProvider { + + private final DeviceFacade deviceFacade; + + public DeviceQueryDirectBackendProvider(DeviceFacade deviceFacade) { + this.deviceFacade = deviceFacade; + } + + public DirectBackendResult build(RequestHeader.UserHeader userHeader, + Queue toolEvents) { + offerToolEvent(toolEvents, "searchDevices", "manager", "Load tenant device snapshot"); + FacadeDeviceQuery query = new FacadeDeviceQuery(); + query.setTenantId(userHeader.getTenantId()); + query.setPage(page(1, 50)); + FacadePage page = deviceFacade.selectByPage(query); + if (Objects.isNull(page) || Objects.isNull(page.getRecords()) || page.getRecords().isEmpty()) { + return DirectBackendResult.direct(DirectAnswer.message("设备查询结果", "当前租户下没有查询到设备。")); + } + List> rows = page.getRecords().stream() + .limit(50) + .map(device -> List.of( + String.valueOf(device.getId()), + Objects.toString(device.getDeviceName(), ""), + Objects.toString(device.getDeviceCode(), ""), + Objects.toString(device.getDriverId(), ""), + Objects.toString(device.getEnableFlag(), ""), + Objects.toString(device.getProfileIds(), "") + )) + .toList(); + return DirectBackendResult.direct(DirectAnswer.table("设备查询结果", null, + List.of( + new DirectAnswer.Field("页码", page.getCurrent() + "/" + page.getPages()), + new DirectAnswer.Field("总数", String.valueOf(page.getTotal())) + ), + List.of(new DirectAnswer.Table("设备列表", List.of("ID", "Name", "Code", "Driver ID", "Enabled", + "Profiles"), rows)), + List.of())); + } + + private void offerToolEvent(Queue toolEvents, String toolName, String domain, + String description) { + if (Objects.nonNull(toolEvents)) { + toolEvents.offer(new AgenticRequestContext.ToolEvent(toolName, domain, description, + Instant.now().toEpochMilli())); + } + } + + private Pages page(long current, long size) { + Pages page = new Pages(); + page.setCurrent(current); + page.setSize(size); + return page; + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectAnswer.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectAnswer.java new file mode 100644 index 000000000..7600beeee --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectAnswer.java @@ -0,0 +1,74 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.direct; + +import java.util.List; + +/** + * Structured direct response for deterministic backend answers. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +public record DirectAnswer(String title, String message, List fields, List tables, List charts) { + + public DirectAnswer { + fields = List.copyOf(fields == null ? List.of() : fields); + tables = List.copyOf(tables == null ? List.of() : tables); + charts = List.copyOf(charts == null ? List.of() : charts); + } + + public static DirectAnswer message(String title, String message) { + return new DirectAnswer(title, message, List.of(), List.of(), List.of()); + } + + public static DirectAnswer table(String title, String message, List fields, List
tables, + List charts) { + return new DirectAnswer(title, message, fields, tables, charts); + } + + public record Field(String name, String value) { + } + + public record Table(String title, List headers, List> rows) { + + public Table { + headers = List.copyOf(headers == null ? List.of() : headers); + rows = List.copyOf(rows == null ? List.of() : rows); + } + + } + + public record Chart(String type, String title, String unit, String xLabel, String xType, + List series) { + + public Chart { + series = List.copyOf(series == null ? List.of() : series); + } + + } + + public record Series(String name, List> data) { + + public Series { + data = List.copyOf(data == null ? List.of() : data); + } + + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectAnswerRenderer.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectAnswerRenderer.java new file mode 100644 index 000000000..75044edf4 --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectAnswerRenderer.java @@ -0,0 +1,124 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.direct; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; +import tools.jackson.databind.DatabindException; +import tools.jackson.databind.ObjectMapper; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Renders structured direct answers into the chat text surface. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +@Slf4j +@Component +public class DirectAnswerRenderer { + + private final ObjectMapper objectMapper; + + public DirectAnswerRenderer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public String render(DirectAnswer answer) { + if (Objects.isNull(answer)) { + return null; + } + StringBuilder builder = new StringBuilder(); + if (StringUtils.isNotBlank(answer.title())) { + builder.append("### ").append(answer.title().trim()).append("\n\n"); + } + if (StringUtils.isNotBlank(answer.message())) { + builder.append(answer.message().trim()).append("\n\n"); + } + if (!answer.fields().isEmpty()) { + builder.append("| 字段 | 值 |\n"); + builder.append("| --- | --- |\n"); + for (DirectAnswer.Field field : answer.fields()) { + builder.append("| ").append(tableText(field.name())).append(" | ") + .append(tableText(field.value())).append(" |\n"); + } + builder.append('\n'); + } + for (DirectAnswer.Table table : answer.tables()) { + appendTable(builder, table); + } + for (DirectAnswer.Chart chart : answer.charts()) { + appendChart(builder, chart); + } + return builder.toString().trim(); + } + + private void appendTable(StringBuilder builder, DirectAnswer.Table table) { + if (Objects.isNull(table) || table.headers().isEmpty()) { + return; + } + if (StringUtils.isNotBlank(table.title())) { + builder.append(table.title().trim()).append(":\n"); + } + builder.append("| "); + builder.append(String.join(" | ", table.headers().stream().map(this::tableText).toList())); + builder.append(" |\n| "); + builder.append(String.join(" | ", table.headers().stream().map(ignored -> "---").toList())); + builder.append(" |\n"); + for (List row : table.rows()) { + builder.append("| "); + builder.append(String.join(" | ", row.stream().map(this::tableText).toList())); + builder.append(" |\n"); + } + builder.append('\n'); + } + + private void appendChart(StringBuilder builder, DirectAnswer.Chart chart) { + if (Objects.isNull(chart) || chart.series().isEmpty()) { + return; + } + Map payload = new LinkedHashMap<>(); + payload.put("title", chart.title()); + payload.put("unit", chart.unit()); + payload.put("xLabel", chart.xLabel()); + payload.put("xType", chart.xType()); + payload.put("series", chart.series()); + builder.append("```chart:").append(StringUtils.defaultIfBlank(chart.type(), "line")).append('\n'); + builder.append(toJson(payload)).append('\n'); + builder.append("```\n\n"); + } + + private String tableText(String value) { + return StringUtils.defaultString(value).replace("|", "\\|").replace("\n", " "); + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (DatabindException e) { + log.warn("Direct answer chart serialization failed", e); + return "{}"; + } + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectBackendResult.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectBackendResult.java new file mode 100644 index 000000000..d05144ee0 --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/direct/DirectBackendResult.java @@ -0,0 +1,40 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.service.direct; + +/** + * Backend data prepared before the model call. + *

+ * {@code context} is appended to the model prompt for soft, non-deterministic flows. + * {@code answer} bypasses the model and is rendered by {@link DirectAnswerRenderer} + * when a deterministic platform query can be fully resolved by the backend. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +public record DirectBackendResult(String context, DirectAnswer answer) { + + public static DirectBackendResult contextOnly(String context) { + return new DirectBackendResult(context, null); + } + + public static DirectBackendResult direct(DirectAnswer answer) { + return new DirectBackendResult(null, answer); + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/impl/AgenticChatServiceImpl.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/impl/AgenticChatServiceImpl.java index 680e91597..de78a453a 100644 --- a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/impl/AgenticChatServiceImpl.java +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/service/impl/AgenticChatServiceImpl.java @@ -16,69 +16,30 @@ */ package io.github.pnoker.common.agentic.service.impl; -import com.openai.core.JsonValue; -import com.openai.models.chat.completions.ChatCompletionChunk; -import io.github.pnoker.common.agentic.config.AgenticProperties; -import io.github.pnoker.common.agentic.config.ChatClientConfig; -import io.github.pnoker.common.agentic.config.ChatClientFactory; import io.github.pnoker.common.agentic.context.AgenticRequestContext; -import io.github.pnoker.common.agentic.entity.bo.MessageBO; -import io.github.pnoker.common.agentic.entity.model.AgenticMessageContent; -import io.github.pnoker.common.agentic.entity.model.SessionExt; import io.github.pnoker.common.agentic.entity.request.ChatCompletionRequest; -import io.github.pnoker.common.agentic.entity.request.ChatMessageDTO; -import io.github.pnoker.common.agentic.entity.response.ChatCompletionChunkResponse; import io.github.pnoker.common.agentic.entity.response.ChatCompletionResponse; import io.github.pnoker.common.agentic.service.AgenticChatService; -import io.github.pnoker.common.agentic.service.AttachmentService; -import io.github.pnoker.common.agentic.service.MessageService; -import io.github.pnoker.common.agentic.service.SessionService; -import io.github.pnoker.common.agentic.skill.SkillDefinition; -import io.github.pnoker.common.agentic.skill.SkillRegistry; -import io.github.pnoker.common.agentic.util.AgenticConversationIds; -import io.github.pnoker.common.agentic.util.AgenticTokenEstimator; -import io.github.pnoker.common.constant.service.AgenticConstant; -import io.github.pnoker.common.entity.common.Pages; +import io.github.pnoker.common.agentic.service.chat.AgenticChatRequestPreparer; +import io.github.pnoker.common.agentic.service.chat.AgenticChatResponseCodec; +import io.github.pnoker.common.agentic.service.chat.AgenticMessageRecorder; +import io.github.pnoker.common.agentic.service.chat.AgenticPreparedChatRequest; +import io.github.pnoker.common.agentic.service.chat.AgenticPromptBuilder; +import io.github.pnoker.common.agentic.service.chat.AgenticStreamDelta; import io.github.pnoker.common.entity.common.RequestHeader; -import io.github.pnoker.common.exception.RequestException; -import io.github.pnoker.common.facade.api.DeviceFacade; -import io.github.pnoker.common.facade.api.DriverFacade; -import io.github.pnoker.common.facade.api.PointFacade; -import io.github.pnoker.common.facade.entity.bo.FacadeDeviceBO; -import io.github.pnoker.common.facade.entity.bo.FacadeDriverBO; -import io.github.pnoker.common.facade.entity.bo.FacadePointBO; -import io.github.pnoker.common.facade.entity.common.FacadePage; -import io.github.pnoker.common.facade.entity.query.FacadeDeviceQuery; -import io.github.pnoker.common.facade.entity.query.FacadeDriverQuery; -import io.github.pnoker.common.facade.entity.query.FacadePointQuery; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.model.Generation; -import org.springframework.ai.chat.prompt.ChatOptions; -import org.springframework.ai.tool.ToolCallbackProvider; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.codec.ServerSentEvent; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; -import tools.jackson.databind.DatabindException; -import tools.jackson.databind.ObjectMapper; import java.time.Instant; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Queue; -import java.util.UUID; -import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicReference; /** @@ -92,68 +53,47 @@ import java.util.concurrent.atomic.AtomicReference; @Service public class AgenticChatServiceImpl implements AgenticChatService { - private final ChatClientFactory chatClientFactory; + private final AgenticChatRequestPreparer requestPreparer; - private final SkillRegistry skillRegistry; + private final AgenticPromptBuilder promptBuilder; - private final SessionService sessionService; + private final AgenticChatResponseCodec responseCodec; - private final MessageService messageService; + private final AgenticMessageRecorder messageRecorder; - private final AttachmentService attachmentService; - - private final DeviceFacade deviceFacade; - - private final DriverFacade driverFacade; - - private final PointFacade pointFacade; - - private final AgenticProperties properties; - - private final ToolCallbackProvider toolCallbackProvider; - - private final ObjectMapper objectMapper; - - public AgenticChatServiceImpl(ChatClientFactory chatClientFactory, SkillRegistry skillRegistry, SessionService sessionService, - MessageService messageService, AttachmentService attachmentService, - DeviceFacade deviceFacade, DriverFacade driverFacade, PointFacade pointFacade, - AgenticProperties properties, - @Qualifier("agenticToolCallbackProvider") ToolCallbackProvider toolCallbackProvider, - ObjectMapper objectMapper) { - this.chatClientFactory = chatClientFactory; - this.skillRegistry = skillRegistry; - this.sessionService = sessionService; - this.messageService = messageService; - this.attachmentService = attachmentService; - this.deviceFacade = deviceFacade; - this.driverFacade = driverFacade; - this.pointFacade = pointFacade; - this.properties = properties; - this.toolCallbackProvider = toolCallbackProvider; - this.objectMapper = objectMapper; + public AgenticChatServiceImpl(AgenticChatRequestPreparer requestPreparer, AgenticPromptBuilder promptBuilder, + AgenticChatResponseCodec responseCodec, AgenticMessageRecorder messageRecorder) { + this.requestPreparer = requestPreparer; + this.promptBuilder = promptBuilder; + this.responseCodec = responseCodec; + this.messageRecorder = messageRecorder; } @Override public Flux> streamChatCompletion(ChatCompletionRequest request, RequestHeader.UserHeader userHeader) { return Flux.defer(() -> { - PreparedChatRequest prepared = prepare(request, userHeader, "stream"); - ChatClient.ChatClientRequestSpec promptSpec = buildPrompt(prepared); - persistUserMessage(prepared, userHeader); + AgenticPreparedChatRequest prepared = requestPreparer.prepare(request, userHeader, "stream"); + messageRecorder.persistUserMessage(prepared, userHeader); + if (StringUtils.isNotBlank(prepared.directAnswer())) { + return streamDirectAnswer(prepared, userHeader); + } - String chatId = newChatId(); + ChatClient.ChatClientRequestSpec promptSpec = promptBuilder.build(prepared); + + String chatId = responseCodec.newChatId(); long created = Instant.now().getEpochSecond(); StringBuilder assistantContent = new StringBuilder(); AtomicReference lastFinishReason = new AtomicReference<>(); - Flux contentFlux = promptSpec.stream().chatResponse() + Flux contentFlux = promptSpec.stream().chatResponse() .doOnSubscribe(subscription -> AgenticRequestContext.set(userHeader)) - .doOnNext(response -> rememberFinishReason(response, lastFinishReason)) - .map(this::extractStreamDelta) - .filter(StreamDelta::hasContent) + .doOnNext(response -> responseCodec.rememberFinishReason(response, lastFinishReason)) + .map(responseCodec::extractStreamDelta) + .filter(AgenticStreamDelta::hasContent) .doOnNext(delta -> assistantContent.append(delta.content())) .doOnComplete(() -> { - persistAssistantMessage(prepared, assistantContent.toString(), userHeader); + messageRecorder.persistAssistantMessage(prepared, assistantContent.toString(), userHeader); log.info( "Agentic stream complete, conversationId={}, model={}, contentLen={}, finishReason={}", prepared.scopedConversationId(), prepared.model(), assistantContent.length(), @@ -161,767 +101,73 @@ public class AgenticChatServiceImpl implements AgenticChatService { }) .doFinally(signalType -> AgenticRequestContext.clear()); - Flux> initialEvents = Flux.fromIterable(initialEvents(prepared)); + Flux> initialEvents = Flux.fromIterable(responseCodec.initialEvents(prepared)); Flux> responseEvents = contentFlux - .flatMap(chunk -> Flux.fromIterable(chunkEvents(prepared, chatId, created, chunk))) + .flatMap(chunk -> Flux.fromIterable(responseCodec.chunkEvents(prepared, chatId, created, chunk))) .onErrorResume(error -> { log.warn("Agentic stream chat failed, conversationId={}, model={}", prepared.scopedConversationId(), prepared.model(), error); return Flux.just(ServerSentEvent.builder() - .data(formatEvent("error", "Request failed", error.getMessage(), "agentic")) + .data(responseCodec.formatEvent("error", "Request failed", error.getMessage(), + "agentic")) .build()); }); return initialEvents .concatWith(responseEvents) .concatWith(Mono.defer(() -> Mono.just(ServerSentEvent.builder() - .data(formatFinalChunk(chatId, created, prepared.model(), - normalizeFinishReason(lastFinishReason.get()))) + .data(responseCodec.formatFinalChunk(chatId, created, prepared.model(), + responseCodec.normalizeFinishReason(lastFinishReason.get()))) .build()))) .concatWith(Mono.just(ServerSentEvent.builder().data("[DONE]").build())); }).doFinally(signalType -> AgenticRequestContext.clear()).subscribeOn(Schedulers.boundedElastic()); } + private Flux> streamDirectAnswer(AgenticPreparedChatRequest prepared, + RequestHeader.UserHeader userHeader) { + String chatId = responseCodec.newChatId(); + long created = Instant.now().getEpochSecond(); + String content = prepared.directAnswer(); + List> events = new ArrayList<>(); + events.addAll(responseCodec.initialEvents(prepared)); + events.addAll(responseCodec.chunkEvents(prepared, chatId, created, new AgenticStreamDelta(content, null))); + events.add(ServerSentEvent.builder() + .data(responseCodec.formatFinalChunk(chatId, created, prepared.model(), "stop")) + .build()); + events.add(ServerSentEvent.builder().data("[DONE]").build()); + messageRecorder.persistAssistantMessage(prepared, content, userHeader); + log.info("Agentic direct answer complete, conversationId={}, model={}, contentLen={}", + prepared.scopedConversationId(), prepared.model(), content.length()); + return Flux.fromIterable(events); + } + @Override public Mono chatCompletion(ChatCompletionRequest request, RequestHeader.UserHeader userHeader) { return Mono.fromCallable(() -> { try { - PreparedChatRequest prepared = prepare(request, userHeader, "blocking"); - ChatClient.ChatClientRequestSpec promptSpec = buildPrompt(prepared); - persistUserMessage(prepared, userHeader); + AgenticPreparedChatRequest prepared = requestPreparer.prepare(request, userHeader, "blocking"); + messageRecorder.persistUserMessage(prepared, userHeader); + if (StringUtils.isNotBlank(prepared.directAnswer())) { + String content = prepared.directAnswer(); + messageRecorder.persistAssistantMessage(prepared, content, userHeader); + return responseCodec.blockingResponse(prepared, content, "stop"); + } + + ChatClient.ChatClientRequestSpec promptSpec = promptBuilder.build(prepared); AgenticRequestContext.set(userHeader); ChatResponse chatResponse = promptSpec.call().chatResponse(); - String content = Objects.nonNull(chatResponse) && Objects.nonNull(chatResponse.getResult()) - && Objects.nonNull(chatResponse.getResult().getOutput()) - ? StringUtils.defaultString(chatResponse.getResult().getOutput().getText()) - : ""; - String finishReason = normalizeFinishReason( - Objects.nonNull(chatResponse) && Objects.nonNull(chatResponse.getResult()) - && Objects.nonNull(chatResponse.getResult().getMetadata()) - ? chatResponse.getResult().getMetadata().getFinishReason() - : null); - persistAssistantMessage(prepared, content, userHeader); + String content = responseCodec.assistantContent(chatResponse); + String finishReason = responseCodec.finishReason(chatResponse); + messageRecorder.persistAssistantMessage(prepared, content, userHeader); log.info("Agentic blocking complete, conversationId={}, model={}, contentLen={}, finishReason={}", prepared.scopedConversationId(), prepared.model(), content.length(), finishReason); - return ChatCompletionResponse.builder() - .id(newChatId()) - .object("chat.completion") - .created(Instant.now().getEpochSecond()) - .model(prepared.model()) - .choices(List.of(ChatCompletionResponse.Choice.builder() - .index(0) - .message(new ChatCompletionResponse.Message("assistant", content)) - .finishReason(finishReason) - .build())) - .usage(new ChatCompletionResponse.Usage(0, 0, 0)) - .build(); + return responseCodec.blockingResponse(prepared, content, finishReason); } finally { AgenticRequestContext.clear(); } }).subscribeOn(Schedulers.boundedElastic()); } - private PreparedChatRequest prepare(ChatCompletionRequest request, RequestHeader.UserHeader userHeader, String mode) { - validateRequest(request); - - String rawUserMessage = extractLastUserMessage(request); - List attachments = normalizeAttachments(request); - String attachmentContext = attachmentService.summarize(attachments, userHeader); - String conversationId = resolveConversationId(request); - String scopedConversationId = AgenticConversationIds.scope(userHeader.getTenantId(), userHeader.getUserId(), - conversationId); - SkillDefinition skill = resolveSkill(request.getSkill(), rawUserMessage); - String effectiveSkillName = Objects.isNull(skill) ? null : skill.getName(); - List toolNames = Objects.isNull(skill) ? List.of() : skillRegistry.getEnabledToolNames(skill.getName()); - String skillSystemPrompt = Objects.isNull(skill) ? null : buildSkillSystemPrompt(skill); - String model = chatClientFactory.resolveModel(request.getModel()); - boolean toolCallingEnabled = properties.isToolCallingEnabled() && chatClientFactory.supportsToolCall(model); - Queue toolEvents = new ConcurrentLinkedQueue<>(); - Map toolContext = new HashMap<>(); - toolContext.put(AgenticConstant.ToolContextKey.TENANT_ID, userHeader.getTenantId()); - toolContext.put(AgenticConstant.ToolContextKey.USER_ID, userHeader.getUserId()); - toolContext.put(AgenticConstant.ToolContextKey.CONVERSATION_ID, scopedConversationId); - toolContext.put(AgenticConstant.ToolContextKey.CONFIRM_ACTIONS, - !Boolean.FALSE.equals(request.getConfirmActions())); - toolContext.put(AgenticConstant.ToolContextKey.TOOL_EVENTS, toolEvents); - String directContext = buildDirectContext(effectiveSkillName, userHeader, toolEvents); - List contexts = buildContexts(attachmentContext, directContext); - String requestSystemContext = buildRequestSystemContext(contexts); - List memoryHistory = loadMemoryHistory(scopedConversationId); - AgenticRequestContext.setMemoryHistory(scopedConversationId, memoryHistory); - // TODO(diagnostic): remove once multi-turn memory loss is confirmed fixed. - // Surfaces the count actually loaded for this scoped conversation so we can - // tell whether memory is failing because of an empty SQL load, a scoping - // mismatch, or because the advisor strips it later. - log.info("Agentic memory loaded, scopedConversationId={}, memoryEnabled={}, count={}", - scopedConversationId, properties.isMemoryEnabled(), memoryHistory.size()); - AgenticMessageContent.Tokens inputTokens = buildInputTokens(rawUserMessage, skillSystemPrompt, - requestSystemContext, contexts, memoryHistory); - - log.debug( - "Agentic chat request received, mode={}, model={}, messageCount={}, conversationIdPresent={}, skill={}, tenantId={}, userId={}", - mode, model, request.getMessages().size(), StringUtils.isNotBlank(request.getConversationId()), - Objects.isNull(skill) ? null : skill.getName(), userHeader.getTenantId(), userHeader.getUserId()); - - touchSession(scopedConversationId, conversationId, userHeader, model, buildSessionExt(request, model)); - - return new PreparedChatRequest(rawUserMessage, scopedConversationId, skillSystemPrompt, - requestSystemContext, normalizeToolNames(toolNames), model, effectiveSkillName, toolContext, - request.getTemperature(), request.getMaxTokens(), skill, toolEvents, - toolCallingEnabled, Boolean.TRUE.equals(request.getReasoning()), StringUtils.isNotBlank(directContext), - attachments, contexts, inputTokens, new ArrayList<>()); - } - - private void validateRequest(ChatCompletionRequest request) { - if (Objects.isNull(request)) { - throw new RequestException("Chat completion request is required"); - } - if (Objects.isNull(request.getMessages()) || request.getMessages().isEmpty()) { - throw new RequestException("Chat messages are required"); - } - if (Objects.nonNull(request.getTemperature()) && (request.getTemperature() < 0.0 || request.getTemperature() > 2.0)) { - throw new RequestException("Temperature must be between 0.0 and 2.0"); - } - if (Objects.nonNull(request.getMaxTokens()) && request.getMaxTokens() < 1) { - throw new RequestException("Max tokens must be greater than 0"); - } - } - - private String extractLastUserMessage(ChatCompletionRequest request) { - return request.getMessages() - .stream() - .filter(message -> Objects.nonNull(message) && "user".equals(message.getRole())) - .map(ChatMessageDTO::getContent) - .filter(StringUtils::isNotBlank) - .reduce((first, second) -> second) - .orElseThrow(() -> new RequestException("A non-empty user message is required")); - } - - private String resolveConversationId(ChatCompletionRequest request) { - String conversationId = StringUtils.trimToNull(request.getConversationId()); - if (Objects.isNull(conversationId)) { - throw new RequestException("conversationId is required — clients must generate and reuse " - + "a stable conversationId so chat memory can be replayed across turns."); - } - return conversationId; - } - - private SkillDefinition resolveSkill(String skillName, String userMessage) { - String normalizedSkillName = StringUtils.trimToNull(skillName); - if (Objects.isNull(normalizedSkillName)) { - normalizedSkillName = inferSkillName(userMessage); - } - if (Objects.isNull(normalizedSkillName)) { - return null; - } - SkillDefinition skill = skillRegistry.get(normalizedSkillName); - if (Objects.isNull(skill)) { - log.warn("Agentic skill not found, skill={}", normalizedSkillName); - throw new RequestException("Agentic skill does not exist: {}", normalizedSkillName); - } - log.debug("Agentic skill activated, skill={}, toolNames={}", skill.getName(), skill.getTools()); - return skill; - } - - private String inferSkillName(String userMessage) { - String text = normalizeInferenceText(userMessage); - if (StringUtils.containsAny(text, "write", "control", "command", "set ", "read ", "写入", "控制", "命令", - "下发", "读取")) { - return "device-control"; - } - if (StringUtils.containsAny(text, "value", "history", "trend", "event", "alarm", "monitor", "data", "point", - "值", "历史", "趋势", "事件", "告警", "报警", "监控", "数据", "位号")) { - return "data-monitor"; - } - if (StringUtils.containsAny(text, "device", "driver", "profile", "status", "list", "search", "设备", "驱动", - "模板", "状态", "列表", "查询", "搜索")) { - return "device-query"; - } - return null; - } - - private String normalizeInferenceText(String userMessage) { - String text = StringUtils.defaultString(userMessage).toLowerCase(Locale.ROOT); - int confirmationIndex = text.indexOf("before executing any write"); - if (confirmationIndex >= 0) { - text = text.substring(0, confirmationIndex); - } - int attachmentIndex = text.indexOf("attached files available"); - if (attachmentIndex >= 0) { - text = text.substring(0, attachmentIndex); - } - return text; - } - - private List normalizeToolNames(List toolNames) { - if (Objects.isNull(toolNames) || toolNames.isEmpty()) { - return List.of(); - } - return toolNames.stream().filter(StringUtils::isNotBlank).distinct().toList(); - } - - private String buildSkillSystemPrompt(SkillDefinition skill) { - List sections = new ArrayList<>(); - if (StringUtils.isNotBlank(skill.getSystemPromptAddition())) { - sections.add(skill.getSystemPromptAddition().trim()); - } - if (Objects.nonNull(skill.getExamples()) && !skill.getExamples().isEmpty()) { - StringBuilder examples = new StringBuilder("Examples:"); - for (SkillDefinition.SkillExample example : skill.getExamples()) { - if (Objects.isNull(example) || StringUtils.isAnyBlank(example.getUser(), example.getAssistant())) { - continue; - } - examples.append("\n- User: ").append(example.getUser().trim()) - .append("\n Assistant: ").append(example.getAssistant().trim()); - } - sections.add(examples.toString()); - } - return sections.isEmpty() ? null : String.join("\n\n", sections); - } - - private List buildContexts(String attachmentContext, String directContext) { - List contexts = new ArrayList<>(); - if (StringUtils.isNotBlank(attachmentContext)) { - contexts.add(AgenticMessageContent.Context.of("attachment", attachmentContext.trim())); - } - if (StringUtils.isNotBlank(directContext)) { - contexts.add(AgenticMessageContent.Context.of("backend", directContext.trim())); - } - return contexts; - } - - private String buildRequestSystemContext(List contexts) { - List sections = new ArrayList<>(); - for (AgenticMessageContent.Context context : contexts) { - if (Objects.isNull(context) || StringUtils.isBlank(context.getContent())) { - continue; - } - if ("backend".equals(context.getType())) { - sections.add("Backend context:\n" + context.getContent().trim() - + "\n\nUse the backend context above as the source of truth. Format the answer as Markdown."); - } else if ("attachment".equals(context.getType())) { - sections.add("Attachment context:\n" + context.getContent().trim() - + "\n\nUse only the metadata above unless a future multimodal model endpoint provides file contents."); - } else { - sections.add(context.getContent().trim()); - } - } - return sections.isEmpty() ? null : String.join("\n\n", sections); - } - - private AgenticMessageContent.Tokens buildInputTokens(String userMessage, String skillSystemPrompt, - String requestSystemContext, - List contexts, - List memoryHistory) { - int textTokens = AgenticTokenEstimator.estimate(userMessage); - int contextTokens = contexts.stream() - .map(AgenticMessageContent.Context::getContent) - .mapToInt(AgenticTokenEstimator::estimate) - .sum(); - int systemTokens = AgenticTokenEstimator.estimate(ChatClientConfig.SYSTEM_PROMPT) - + AgenticTokenEstimator.estimate(skillSystemPrompt) - + AgenticTokenEstimator.estimate(systemInstructions(requestSystemContext, contexts)); - int memoryTokens = estimateMemoryTokens(memoryHistory); - return AgenticMessageContent.Tokens.of(textTokens + contextTokens + systemTokens + memoryTokens, 0, - textTokens, contextTokens, systemTokens, memoryTokens); - } - - private String systemInstructions(String requestSystemContext, List contexts) { - if (StringUtils.isBlank(requestSystemContext)) { - return ""; - } - List instructions = new ArrayList<>(); - if (contexts.stream().anyMatch(context -> "backend".equals(context.getType()))) { - instructions.add("Use the backend context as the source of truth. Format the answer as Markdown."); - } - if (contexts.stream().anyMatch(context -> "attachment".equals(context.getType()))) { - instructions.add("Use attachment metadata only unless a future multimodal model endpoint provides file contents."); - } - return String.join("\n", instructions); - } - - private List loadMemoryHistory(String scopedConversationId) { - if (!properties.isMemoryEnabled()) { - return List.of(); - } - try { - return messageService.loadHistory(scopedConversationId, properties.getHistoryWindowSize()); - } catch (Exception e) { - log.debug("Agentic memory history load failed, conversationId={}", scopedConversationId, e); - return List.of(); - } - } - - private int estimateMemoryTokens(List history) { - if (!properties.isMemoryEnabled() || Objects.isNull(history) || history.isEmpty()) { - return 0; - } - return history.stream() - .map(message -> Objects.nonNull(message.getContent()) ? message.getContent().getText() : null) - .map(StringUtils::defaultString) - .filter(StringUtils::isNotBlank) - .mapToInt(AgenticTokenEstimator::estimate) - .sum(); - } - - private ChatClient.ChatClientRequestSpec buildPrompt(PreparedChatRequest prepared) { - ChatClient chatClient = chatClientFactory.getOrCreate(prepared.model()); - ChatClient.ChatClientRequestSpec promptSpec = chatClient.prompt() - .user(prepared.userMessage()) - .toolContext(prepared.toolContext()) - .advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, prepared.scopedConversationId())); - - String systemPrompt = buildSystemPrompt(prepared); - if (StringUtils.isNotBlank(systemPrompt)) { - promptSpec = promptSpec.system(systemPrompt); - } - promptSpec = applyToolCallbacks(promptSpec, prepared); - promptSpec = applyRequestOptions(promptSpec, prepared.model(), prepared.temperature(), prepared.maxTokens()); - return promptSpec; - } - - private ChatClient.ChatClientRequestSpec applyToolCallbacks(ChatClient.ChatClientRequestSpec promptSpec, - PreparedChatRequest prepared) { - if (!prepared.toolCallingEnabled()) { - return promptSpec; - } - promptSpec = promptSpec.toolCallbacks(toolCallbackProvider); - if (!prepared.toolNames().isEmpty()) { - promptSpec = promptSpec.toolNames(prepared.toolNames().toArray(String[]::new)); - } - return promptSpec; - } - - private String buildSystemPrompt(PreparedChatRequest prepared) { - List sections = new ArrayList<>(); - sections.add(ChatClientConfig.SYSTEM_PROMPT); - if (StringUtils.isNotBlank(prepared.skillSystemPrompt())) { - sections.add(prepared.skillSystemPrompt().trim()); - } - if (StringUtils.isNotBlank(prepared.requestSystemContext())) { - sections.add(prepared.requestSystemContext().trim()); - } - return String.join("\n\n", sections); - } - - private ChatClient.ChatClientRequestSpec applyRequestOptions(ChatClient.ChatClientRequestSpec promptSpec, - String model, Double temperature, Integer maxTokens) { - ChatOptions.Builder optionsBuilder = chatClientFactory.buildChatOptionsBuilder(model, temperature, maxTokens); - return Objects.nonNull(optionsBuilder) ? promptSpec.options(optionsBuilder) : promptSpec; - } - - private String formatChunk(String id, long created, String model, StreamDelta streamDelta) { - ChatCompletionChunkResponse chunk = ChatCompletionChunkResponse.builder() - .id(id) - .object("chat.completion.chunk") - .created(created) - .model(model) - .choices(List.of(ChatCompletionChunkResponse.ChunkChoice.builder() - .index(0) - .delta(new ChatCompletionChunkResponse.Delta(null, streamDelta.content(), - streamDelta.reasoningContent())) - .finishReason(null) - .build())) - .build(); - return toJson(chunk); - } - - private String formatFinalChunk(String id, long created, String model, String finishReason) { - ChatCompletionChunkResponse chunk = ChatCompletionChunkResponse.builder() - .id(id) - .object("chat.completion.chunk") - .created(created) - .model(model) - .choices(List.of(ChatCompletionChunkResponse.ChunkChoice.builder() - .index(0) - .delta(new ChatCompletionChunkResponse.Delta(null, null, null)) - .finishReason(finishReason) - .build())) - .build(); - return toJson(chunk); - } - - /** - * Capture the LLM's finish reason from each streaming chunk. Spring AI emits the - * reason on the final {@link Generation} (one of {@code STOP}, {@code LENGTH}, - * {@code TOOL_CALLS}, {@code CONTENT_FILTER}, …); earlier chunks have it null. - * Latest non-null wins so the UI sees the actual termination cause and can warn - * the user when the answer was truncated. - */ - private void rememberFinishReason(ChatResponse response, AtomicReference sink) { - if (Objects.isNull(response) || Objects.isNull(response.getResult()) - || Objects.isNull(response.getResult().getMetadata())) { - return; - } - String reason = response.getResult().getMetadata().getFinishReason(); - if (StringUtils.isNotBlank(reason)) { - sink.set(reason); - } - } - - /** - * Normalize the provider's finish reason to the OpenAI-compatible lowercase form - * the frontend expects ({@code stop}/{@code length}/{@code tool_calls}/ - * {@code content_filter}). Falls back to {@code stop} when nothing was captured - * so older clients do not break. - */ - private String normalizeFinishReason(String reason) { - if (StringUtils.isBlank(reason)) { - return "stop"; - } - return reason.toLowerCase(Locale.ROOT); - } - - private List> initialEvents(PreparedChatRequest prepared) { - List> events = new ArrayList<>(); - String skillName = Objects.nonNull(prepared.skillDefinition()) ? prepared.skillDefinition().getName() : "general"; - String skillDescription = Objects.nonNull(prepared.skillDefinition()) ? prepared.skillDefinition().getDescription() - : "General assistant mode"; - events.add(ServerSentEvent.builder() - .data(formatEvent("skill", "Auto skill", skillDescription, skillName)) - .build()); - if (prepared.toolCallingEnabled() && !prepared.toolNames().isEmpty()) { - events.add(ServerSentEvent.builder() - .data(formatEvent("tools", "Available tools", String.join(", ", prepared.toolNames()), skillName)) - .build()); - } - if (prepared.directContextProvided()) { - events.add(ServerSentEvent.builder() - .data(formatEvent("tool", "Backend context loaded", "Queried DC3 backend before model response", - skillName)) - .build()); - } - if (prepared.reasoning()) { - events.add(ServerSentEvent.builder() - .data(formatEvent("reasoning", "Thinking", "Reasoning mode requested for this model.", skillName)) - .build()); - } - return events; - } - - private List> chunkEvents(PreparedChatRequest prepared, String chatId, long created, - StreamDelta streamDelta) { - List> events = new ArrayList<>(); - AgenticRequestContext.ToolEvent event = prepared.toolEvents().poll(); - while (Objects.nonNull(event)) { - prepared.toolTraceEvents().add(event); - events.add(ServerSentEvent.builder() - .data(formatEvent("tool", event.description(), event.domain(), event.toolName())) - .build()); - event = prepared.toolEvents().poll(); - } - events.add(ServerSentEvent.builder() - .data(formatChunk(chatId, created, prepared.model(), streamDelta)) - .build()); - return events; - } - - private StreamDelta extractStreamDelta(ChatResponse response) { - if (Objects.isNull(response) || Objects.isNull(response.getResult())) { - return StreamDelta.empty(); - } - Generation generation = response.getResult(); - String content = Objects.nonNull(generation.getOutput()) ? generation.getOutput().getText() : null; - // TODO(diagnostic): remove once token-level streaming parity is verified. - // Confirms whether Spring AI's stream pipeline emits per-token chunks (small - // content lengths, hundreds of frames) or batches the final answer in a - // single chunk after tool execution completes. - if (log.isDebugEnabled()) { - log.debug("Agentic stream chunk, contentLen={}, hasReasoning={}", - Objects.isNull(content) ? 0 : content.length(), - Objects.nonNull(extractReasoningContent(generation))); - } - return new StreamDelta(StringUtils.defaultString(content), extractReasoningContent(generation)); - } - - private String extractReasoningContent(Generation generation) { - if (Objects.isNull(generation) || Objects.isNull(generation.getOutput())) { - return null; - } - Object chunkChoice = generation.getOutput().getMetadata().get("chunkChoice"); - if (Objects.isNull(chunkChoice)) { - return null; - } - - if (chunkChoice instanceof ChatCompletionChunk.Choice openAiChunkChoice) { - Object rawValue = openAiChunkChoice.delta()._additionalProperties().get("reasoning_content"); - if (!(rawValue instanceof JsonValue value)) { - return null; - } - Optional reasoningContent = value.asString(); - return reasoningContent.orElse(null); - } - return null; - } - - private String formatEvent(String type, String title, String detail, String name) { - Map event = new HashMap<>(); - event.put("object", "agentic.event"); - event.put("type", type); - event.put("title", StringUtils.defaultString(title)); - event.put("detail", StringUtils.defaultString(detail)); - event.put("name", StringUtils.defaultString(name)); - event.put("created", Instant.now().getEpochSecond()); - return toJson(event); - } - - private String toJson(Object obj) { - try { - return objectMapper.writeValueAsString(obj); - } catch (DatabindException e) { - log.error("Agentic response serialization failed, responseType={}", obj.getClass().getSimpleName(), e); - return "{}"; - } - } - - private String newChatId() { - return "chatcmpl-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24); - } - - private String buildDirectContext(String skillName, RequestHeader.UserHeader userHeader, - Queue toolEvents) { - if (StringUtils.isBlank(skillName)) { - return null; - } - try { - if ("device-query".equals(skillName)) { - return buildDeviceQueryContext(userHeader, toolEvents); - } - if ("data-monitor".equals(skillName)) { - return buildDataMonitorContext(userHeader, toolEvents); - } - } catch (Exception e) { - log.warn("Agentic direct context failed, skill={}, tenantId={}, userId={}", skillName, - userHeader.getTenantId(), userHeader.getUserId(), e); - toolEvents.offer(new AgenticRequestContext.ToolEvent("directContext", "agentic", - "Backend context query failed: " + e.getMessage(), Instant.now().toEpochMilli())); - } - return null; - } - - private String buildDeviceQueryContext(RequestHeader.UserHeader userHeader, - Queue toolEvents) { - FacadeDeviceQuery query = new FacadeDeviceQuery(); - query.setTenantId(userHeader.getTenantId()); - query.setPage(page(1, 50)); - FacadePage page = deviceFacade.selectByPage(query); - if (Objects.isNull(page) || page.getRecords().isEmpty()) { - return "No devices found."; - } - StringBuilder builder = new StringBuilder(); - builder.append("Device page ").append(page.getCurrent()).append('/').append(page.getPages()) - .append(", total=").append(page.getTotal()).append('\n'); - builder.append("| ID | Name | Code | Driver ID | Enabled | Profiles |\n"); - builder.append("| --- | --- | --- | --- | --- | --- |\n"); - page.getRecords().stream().limit(50).forEach(device -> builder.append("| ") - .append(device.getId()).append(" | ") - .append(escapeTable(device.getDeviceName())).append(" | ") - .append(escapeTable(device.getDeviceCode())).append(" | ") - .append(device.getDriverId()).append(" | ") - .append(device.getEnableFlag()).append(" | ") - .append(device.getProfileIds()).append(" |\n")); - return builder.toString(); - } - - private String buildDataMonitorContext(RequestHeader.UserHeader userHeader, - Queue toolEvents) { - FacadeDeviceQuery deviceQuery = new FacadeDeviceQuery(); - deviceQuery.setTenantId(userHeader.getTenantId()); - deviceQuery.setPage(page(1, 10)); - FacadePage devices = deviceFacade.selectByPage(deviceQuery); - FacadeDriverQuery driverQuery = new FacadeDriverQuery(); - driverQuery.setTenantId(userHeader.getTenantId()); - driverQuery.setPage(page(1, 10)); - FacadePage drivers = driverFacade.selectByPage(driverQuery); - FacadePointQuery pointQuery = new FacadePointQuery(); - pointQuery.setTenantId(userHeader.getTenantId()); - pointQuery.setPage(page(1, 10)); - FacadePage points = pointFacade.selectByPage(pointQuery); - return "Monitoring snapshot:\n" - + "- devices total: " + total(devices) + "\n" - + "- drivers total: " + total(drivers) + "\n" - + "- points total: " + total(points) + "\n" - + "Sample devices: " + sampleDeviceNames(devices); - } - - private Pages page(long current, long size) { - Pages page = new Pages(); - page.setCurrent(current); - page.setSize(size); - return page; - } - - private long total(FacadePage page) { - return Objects.isNull(page) ? 0 : page.getTotal(); - } - - private String sampleDeviceNames(FacadePage page) { - if (Objects.isNull(page) || page.getRecords().isEmpty()) { - return "none"; - } - return page.getRecords().stream() - .limit(5) - .map(FacadeDeviceBO::getDeviceName) - .filter(StringUtils::isNotBlank) - .toList() - .toString(); - } - - private String escapeTable(String value) { - return StringUtils.defaultString(value).replace("|", "\\|").replace("\n", " "); - } - - private SessionExt buildSessionExt(ChatCompletionRequest request, String model) { - if (Objects.isNull(request.getReasoning()) && Objects.isNull(request.getTemperature()) - && Objects.isNull(request.getMaxTokens()) && Objects.isNull(request.getConfirmActions()) - && StringUtils.isBlank(model)) { - return null; - } - SessionExt sessionExt = new SessionExt(); - sessionExt.setModel(model); - sessionExt.setReasoningEnabled(request.getReasoning()); - sessionExt.setTemperature(request.getTemperature()); - sessionExt.setMaxTokens(request.getMaxTokens()); - sessionExt.setRequireConfirmation(request.getConfirmActions()); - return sessionExt; - } - - private void touchSession(String scopedConversationId, String conversationId, RequestHeader.UserHeader userHeader, - String model, SessionExt sessionExt) { - try { - sessionService.touch(scopedConversationId, userHeader.getTenantId(), userHeader.getUserId(), model, - sessionExt); - } catch (Exception e) { - log.warn( - "Agentic session touch failed, tenantId={}, userId={}, conversationId={}", - userHeader.getTenantId(), userHeader.getUserId(), conversationId, e); - } - } - - private void persistUserMessage(PreparedChatRequest prepared, RequestHeader.UserHeader userHeader) { - messageService.save(prepared.scopedConversationId(), "user", buildUserContent(prepared), prepared.model(), - userHeader); - } - - private void persistAssistantMessage(PreparedChatRequest prepared, String content, RequestHeader.UserHeader userHeader) { - if (StringUtils.isBlank(content)) { - return; - } - messageService.save(prepared.scopedConversationId(), "assistant", buildAssistantContent(prepared, content), prepared.model(), - userHeader); - } - - private AgenticMessageContent buildUserContent(PreparedChatRequest prepared) { - AgenticMessageContent content = AgenticMessageContent.ofText(prepared.userMessage()); - if (!prepared.attachments().isEmpty()) { - content.setAttachments(prepared.attachments()); - } - return content; - } - - private AgenticMessageContent buildAssistantContent(PreparedChatRequest prepared, String text) { - List toolEvents = drainToolEvents(prepared); - List tools = toolEvents.stream() - .filter(event -> !"agentic".equals(event.domain())) - .map(AgenticRequestContext.ToolEvent::toolName) - .filter(StringUtils::isNotBlank) - .distinct() - .toList(); - - AgenticMessageContent content = AgenticMessageContent.ofText(text); - content.setFormat("markdown"); - content.setSkills(skillNames(prepared)); - content.setTools(tools); - content.setTraces(buildTraceEvents(prepared, toolEvents)); - content.setReasoning(prepared.reasoning()); - content.setDirectContextProvided(prepared.directContextProvided()); - content.setContexts(prepared.contexts()); - content.setTokens(outputTokens(prepared.inputTokens(), text)); - return content; - } - - private List buildTraceEvents(PreparedChatRequest prepared, - List toolEvents) { - List traces = new ArrayList<>(); - long created = Instant.now().getEpochSecond(); - String skillName = Objects.nonNull(prepared.skillDefinition()) ? prepared.skillDefinition().getName() : "general"; - String skillDescription = Objects.nonNull(prepared.skillDefinition()) ? prepared.skillDefinition().getDescription() - : "General assistant mode"; - traces.add(AgenticMessageContent.Trace.of("skill", "Auto skill", skillDescription, skillName, created)); - if (prepared.toolCallingEnabled() && !prepared.toolNames().isEmpty()) { - traces.add(AgenticMessageContent.Trace.of("tools", "Available tools", String.join(", ", prepared.toolNames()), - skillName, created)); - } - if (prepared.directContextProvided()) { - traces.add(AgenticMessageContent.Trace.of("tool", "Backend context loaded", - "Queried DC3 backend before model response", skillName, created)); - } - if (prepared.reasoning()) { - traces.add(AgenticMessageContent.Trace.of("reasoning", "Thinking", - "Reasoning mode requested for this model.", skillName, created)); - } - for (AgenticRequestContext.ToolEvent event : toolEvents) { - traces.add(AgenticMessageContent.Trace.of("tool", event.description(), event.domain(), event.toolName(), - event.timestamp() / 1000)); - } - return traces; - } - - private List drainToolEvents(PreparedChatRequest prepared) { - AgenticRequestContext.ToolEvent event = prepared.toolEvents().poll(); - while (Objects.nonNull(event)) { - prepared.toolTraceEvents().add(event); - event = prepared.toolEvents().poll(); - } - return prepared.toolTraceEvents(); - } - - private AgenticMessageContent.Tokens outputTokens(AgenticMessageContent.Tokens inputTokens, String assistantText) { - int outputTokens = AgenticTokenEstimator.estimate(assistantText); - AgenticMessageContent.Tokens tokens = new AgenticMessageContent.Tokens(); - tokens.setInput(inputTokens.getInput()); - tokens.setOutput(outputTokens); - tokens.setText(inputTokens.getText()); - tokens.setContext(inputTokens.getContext()); - tokens.setSystem(inputTokens.getSystem()); - tokens.setMemory(inputTokens.getMemory()); - return tokens; - } - - private List skillNames(PreparedChatRequest prepared) { - return StringUtils.isBlank(prepared.skill()) ? List.of() : List.of(prepared.skill()); - } - - private List normalizeAttachments(ChatCompletionRequest request) { - if (Objects.isNull(request.getAttachments()) || request.getAttachments().isEmpty()) { - return List.of(); - } - return request.getAttachments().stream().filter(Objects::nonNull).distinct().toList(); - } - - private record PreparedChatRequest(String userMessage, String scopedConversationId, String skillSystemPrompt, - String requestSystemContext, List toolNames, String model, String skill, - Map toolContext, Double temperature, Integer maxTokens, - SkillDefinition skillDefinition, - Queue toolEvents, boolean toolCallingEnabled, - boolean reasoning, boolean directContextProvided, List attachments, - List contexts, - AgenticMessageContent.Tokens inputTokens, - List toolTraceEvents) { - } - - private record StreamDelta(String content, String reasoningContent) { - - static StreamDelta empty() { - return new StreamDelta("", null); - } - - boolean hasContent() { - return StringUtils.isNotEmpty(content) || StringUtils.isNotEmpty(reasoningContent); - } - - } - } diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/AgenticToolResult.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/AgenticToolResult.java new file mode 100644 index 000000000..04efc2914 --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/AgenticToolResult.java @@ -0,0 +1,52 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.pnoker.common.agentic.tool; + +/** + * Structured return envelope for agentic tool calls. + * + * @author pnoker + * @version 2026.5.16 + * @since 2022.1.0 + */ +public record AgenticToolResult(boolean success, String code, String message, T data) { + + public static AgenticToolResult ok(String message, T data) { + return new AgenticToolResult<>(true, "OK", message, data); + } + + public static AgenticToolResult empty(String message, T data) { + return new AgenticToolResult<>(true, "EMPTY", message, data); + } + + public static AgenticToolResult invalid(String message) { + return new AgenticToolResult<>(false, "INVALID_ARGUMENT", message, null); + } + + public static AgenticToolResult notFound(String message) { + return new AgenticToolResult<>(false, "NOT_FOUND", message, null); + } + + public static AgenticToolResult unavailable(String message) { + return new AgenticToolResult<>(false, "UNAVAILABLE", message, null); + } + + public static AgenticToolResult error(String message) { + return new AgenticToolResult<>(false, "ERROR", message, null); + } + +} diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/AuthToolSet.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/AuthToolSet.java index 6ce67ad82..ce66ae782 100644 --- a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/AuthToolSet.java +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/AuthToolSet.java @@ -23,6 +23,8 @@ import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; import org.springframework.stereotype.Component; +import java.util.Map; + /** * Auth-domain tools exposed to the LLM via Spring AI @Tool. *

@@ -39,21 +41,23 @@ import org.springframework.stereotype.Component; public class AuthToolSet { @Tool(description = "Get the current tenant context. Returns only the current tenant ID.") - public String getCurrentTenantInfo(ToolContext toolContext) { + public AgenticToolResult> getCurrentTenantInfo(ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}", "getCurrentTenantInfo", tenantId); recordTool(toolContext, "getCurrentTenantInfo", "Read current tenant context"); - return String.format("CurrentTenant: tenantId=%d", tenantId); + return AgenticToolResult.ok("Current tenant context loaded", Map.of("tenantId", tenantId)); } @Tool(description = "Get the current user profile. Returns only user ID, username, and nickname.") - public String getCurrentUserProfile(ToolContext toolContext) { + public AgenticToolResult> getCurrentUserProfile(ToolContext toolContext) { RequestHeader.UserHeader header = AgenticRequestContext.requireUserHeader(); Long userId = AgenticRequestContext.requireUserId(toolContext); log.debug("Agentic tool invoked, tool={}, userId={}", "getCurrentUserProfile", userId); recordTool(toolContext, "getCurrentUserProfile", "Read current user profile"); - return String.format("CurrentUser: userId=%d, username=%s, nickname=%s", userId, header.getUserName(), - header.getNickName()); + return AgenticToolResult.ok("Current user profile loaded", Map.of( + "userId", userId, + "username", header.getUserName(), + "nickname", header.getNickName())); } private void recordTool(ToolContext toolContext, String toolName, String description) { diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/DataToolSet.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/DataToolSet.java index 3934afde4..262001610 100644 --- a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/DataToolSet.java +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/DataToolSet.java @@ -35,6 +35,7 @@ import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -73,9 +74,10 @@ public class DataToolSet { } @Tool(description = "Get the latest point value for a specific device and point. Returns the current value.") - public String getLatestPointValue(@ToolParam(description = "The device ID") Long deviceId, - @ToolParam(description = "The point (metric) ID") Long pointId, - ToolContext toolContext) { + public AgenticToolResult getLatestPointValue( + @ToolParam(description = "The device ID") Long deviceId, + @ToolParam(description = "The point (metric) ID") Long pointId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}, pointId={}", "getLatestPointValue", tenantId, deviceId, pointId); @@ -83,52 +85,47 @@ public class DataToolSet { try { FacadePointValueBO value = pointValueFacade.lastValue(tenantId, deviceId, pointId); if (Objects.isNull(value)) { - return "No latest value found for device " + deviceId + " point " + pointId; + return AgenticToolResult.empty("No latest value found for device " + deviceId + " point " + pointId, + null); } - return String.format("Device %d / Point %d: value=%s, rawValue=%s, time=%d", value.getDeviceId(), - value.getPointId(), value.getValue(), value.getRawValue(), value.getCreateTime()); + return AgenticToolResult.ok("Latest point value loaded", value); } catch (Exception e) { log.warn("Agentic tool failed, tool={}, tenantId={}, deviceId={}, pointId={}", "getLatestPointValue", tenantId, deviceId, pointId, e); - return "Error retrieving latest value: " + e.getMessage(); + return AgenticToolResult.error("Error retrieving latest value: " + e.getMessage()); } } - @Tool(description = "Get historical point values for a specific device and point. Returns a list of value strings together with a chart-renderable JSON block.") - public String getPointValueHistory(@ToolParam(description = "The device ID") Long deviceId, - @ToolParam(description = "The point (metric) ID") Long pointId, - @ToolParam(description = "Number of historical records to retrieve") int count, - ToolContext toolContext) { + @Tool(description = "Get historical point values for a specific device and point. Returns raw values and chart-ready numeric points as structured data.") + public AgenticToolResult getPointValueHistory( + @ToolParam(description = "The device ID") Long deviceId, + @ToolParam(description = "The point (metric) ID") Long pointId, + @ToolParam(description = "Number of historical records to retrieve") int count, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}, pointId={}, count={}", "getPointValueHistory", tenantId, deviceId, pointId, count); recordTool(toolContext, "getPointValueHistory", "Get point value history"); + int size = Math.max(1, Math.min(count, 200)); try { - List history = pointValueFacade.history(tenantId, deviceId, pointId, count); + List history = pointValueFacade.history(tenantId, deviceId, pointId, size); if (Objects.isNull(history) || history.isEmpty()) { - return "No history data found for device " + deviceId + " point " + pointId; + return AgenticToolResult.empty("No history data found for device " + deviceId + " point " + pointId, + new PointValueHistory(deviceId, pointId, size, List.of(), null)); } - String summary = "History values (" + history.size() + " records): " + String.join(", ", history); - String chart = buildHistoryChartFence(deviceId, pointId, history); - return chart.isEmpty() ? summary : summary + "\n\n" + chart; + PointValueHistory result = new PointValueHistory(deviceId, pointId, size, history, + buildHistoryChart(deviceId, pointId, history)); + return AgenticToolResult.ok("Point value history loaded", result); } catch (Exception e) { log.warn("Agentic tool failed, tool={}, tenantId={}, deviceId={}, pointId={}, count={}", - "getPointValueHistory", tenantId, deviceId, pointId, count, e); - return "Error retrieving history: " + e.getMessage(); + "getPointValueHistory", tenantId, deviceId, pointId, size, e); + return AgenticToolResult.error("Error retrieving history: " + e.getMessage()); } } - /** - * Render a {@code ```chart:line``` } fence so the assistant frontend can plot the - * history without an extra tool round-trip. The facade currently returns values - * only (newest → oldest), so we use the array index as the x axis. Non-numeric - * entries are dropped; if nothing remains we return an empty string and the - * caller skips the fence. - */ - private String buildHistoryChartFence(Long deviceId, Long pointId, List history) { - StringBuilder dataPoints = new StringBuilder(); + private HistoryChart buildHistoryChart(Long deviceId, Long pointId, List history) { + List> dataPoints = new ArrayList<>(); int rendered = 0; - // Reverse so x=0 is the oldest sample — easier to read left-to-right. for (int i = history.size() - 1; i >= 0; i--) { String raw = history.get(i); if (Objects.isNull(raw)) { @@ -136,30 +133,24 @@ public class DataToolSet { } try { double value = Double.parseDouble(raw.trim()); - if (rendered > 0) { - dataPoints.append(','); - } - dataPoints.append('[').append(rendered).append(',').append(value).append(']'); + dataPoints.add(List.of(rendered, value)); rendered++; } catch (NumberFormatException ignored) { - // skip non-numeric entries + // Keep non-numeric values in the raw history; only chart data skips them. } } if (rendered == 0) { - return ""; + return null; } - return "```chart:line\n" - + "{\"title\":\"Device " + deviceId + " / Point " + pointId + "\"," - + "\"xLabel\":\"index (oldest → newest)\"," - + "\"xType\":\"linear\"," - + "\"series\":[{\"name\":\"value\",\"data\":[" + dataPoints + "]}]}\n" - + "```"; + return new HistoryChart("line", "Device " + deviceId + " / Point " + pointId, "index (oldest to newest)", + "linear", List.of(new ChartSeries("value", dataPoints))); } @Tool(description = "Get a latest-value snapshot for points bound to a device. Returns point metadata and latest values for up to the requested limit.") - public String getDeviceLatestPointValues(@ToolParam(description = "The device ID") Long deviceId, - @ToolParam(description = "Maximum number of points to include") int limit, - ToolContext toolContext) { + public AgenticToolResult getDeviceLatestPointValues( + @ToolParam(description = "The device ID") Long deviceId, + @ToolParam(description = "Maximum number of points to include") int limit, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); int size = Math.max(1, Math.min(limit, 50)); log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}, limit={}", @@ -168,7 +159,7 @@ public class DataToolSet { try { FacadeDeviceBO device = deviceFacade.selectById(tenantId, deviceId); if (Objects.isNull(device)) { - return "Device not found for ID: " + deviceId; + return AgenticToolResult.notFound("Device not found for ID: " + deviceId); } FacadePointQuery query = new FacadePointQuery(); @@ -180,57 +171,53 @@ public class DataToolSet { query.setPage(page); FacadePage points = pointFacade.selectByPage(query); if (Objects.isNull(points) || points.getRecords().isEmpty()) { - return "No points found for device " + deviceId; + return AgenticToolResult.empty("No points found for device " + deviceId, + new DeviceLatestPointValues(device, List.of())); } - StringBuilder builder = new StringBuilder(); - builder.append("Latest values for device ").append(deviceId).append(" (") - .append(device.getDeviceName()).append("):\n"); - builder.append("| Point ID | Point Name | Code | Value | Raw Value | Unit | Time |\n"); - builder.append("| --- | --- | --- | --- | --- | --- | --- |\n"); + List values = new ArrayList<>(); for (FacadePointBO point : points.getRecords()) { FacadePointValueBO value = pointValueFacade.lastValue(tenantId, deviceId, point.getId()); - builder.append("| ") - .append(point.getId()).append(" | ") - .append(escape(point.getPointName())).append(" | ") - .append(escape(point.getPointCode())).append(" | ") - .append(Objects.isNull(value) ? "" : escape(value.getValue())).append(" | ") - .append(Objects.isNull(value) ? "" : escape(value.getRawValue())).append(" | ") - .append(escape(point.getUnit())).append(" | ") - .append(Objects.isNull(value) ? "" : value.getCreateTime()).append(" |\n"); + values.add(new PointLatestValue(point, value)); } - return builder.toString(); + return AgenticToolResult.ok("Device latest point values loaded", + new DeviceLatestPointValues(device, values)); } catch (Exception e) { log.warn("Agentic tool failed, tool={}, tenantId={}, deviceId={}, limit={}", "getDeviceLatestPointValues", tenantId, deviceId, size, e); - return "Error retrieving device latest values: " + e.getMessage(); + return AgenticToolResult.error("Error retrieving device latest values: " + e.getMessage()); } } @Tool(description = "Send a read command to a device for a specific point. The driver will read the current value from the physical device.") - public String readPointValue(@ToolParam(description = "The device ID") Long deviceId, - @ToolParam(description = "The point (metric) ID to read") Long pointId, - ToolContext toolContext) { + public AgenticToolResult readPointValue( + @ToolParam(description = "The device ID") Long deviceId, + @ToolParam(description = "The point (metric) ID to read") Long pointId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}, pointId={}", "readPointValue", tenantId, deviceId, pointId); recordTool(toolContext, "readPointValue", "Send point read command"); try { boolean success = pointValueCommandFacade.read(tenantId, deviceId, pointId); - return success ? "Read command sent successfully for device " + deviceId + " point " + pointId - : "Read command failed for device " + deviceId + " point " + pointId; + PointCommandResult result = new PointCommandResult(deviceId, pointId, null, success, false, null); + if (success) { + return AgenticToolResult.ok("Read command sent", result); + } + return AgenticToolResult.error("Read command failed for device " + deviceId + " point " + pointId); } catch (Exception e) { log.warn("Agentic tool failed, tool={}, tenantId={}, deviceId={}, pointId={}", "readPointValue", tenantId, deviceId, pointId, e); - return "Error sending read command: " + e.getMessage(); + return AgenticToolResult.error("Error sending read command: " + e.getMessage()); } } @Tool(description = "Send a write command to a device for a specific point. Sets the point to the specified value on the physical device.") - public String writePointValue(@ToolParam(description = "The device ID") Long deviceId, - @ToolParam(description = "The point (metric) ID to write") Long pointId, - @ToolParam(description = "The value to write (as a string)") String value, - ToolContext toolContext) { + public AgenticToolResult writePointValue( + @ToolParam(description = "The device ID") Long deviceId, + @ToolParam(description = "The point (metric) ID to write") Long pointId, + @ToolParam(description = "The value to write (as a string)") String value, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}, pointId={}, valueLength={}", "writePointValue", tenantId, deviceId, pointId, Objects.isNull(value) ? 0 : value.length()); @@ -241,17 +228,19 @@ public class DataToolSet { String conversationId = AgenticRequestContext.requireConversationId(toolContext); String actionId = actionService.createWritePointValueAction(conversationId, deviceId, pointId, value, header); - return "Write command is pending user confirmation. actionId=" + actionId - + ". Ask the user to confirm before executing it."; + return AgenticToolResult.ok("Write command is pending user confirmation", + new PointCommandResult(deviceId, pointId, value, false, true, actionId)); } boolean success = pointValueCommandFacade.write(tenantId, deviceId, pointId, value); - return success - ? "Write command sent successfully for device " + deviceId + " point " + pointId + " value=" + value - : "Write command failed for device " + deviceId + " point " + pointId; + PointCommandResult result = new PointCommandResult(deviceId, pointId, value, success, false, null); + if (success) { + return AgenticToolResult.ok("Write command sent", result); + } + return AgenticToolResult.error("Write command failed for device " + deviceId + " point " + pointId); } catch (Exception e) { log.warn("Agentic tool failed, tool={}, tenantId={}, deviceId={}, pointId={}", "writePointValue", tenantId, deviceId, pointId, e); - return "Error sending write command: " + e.getMessage(); + return AgenticToolResult.error("Error sending write command: " + e.getMessage()); } } @@ -259,8 +248,44 @@ public class DataToolSet { AgenticRequestContext.recordToolInvocation(toolContext, toolName, "data", description); } - private String escape(String value) { - return Objects.toString(value, "").replace("|", "\\|").replace("\n", " "); + public record PointValueHistory(Long deviceId, Long pointId, int requestedCount, List values, + HistoryChart chart) { + + public PointValueHistory { + values = List.copyOf(Objects.requireNonNullElse(values, List.of())); + } + + } + + public record HistoryChart(String type, String title, String xLabel, String xType, List series) { + + public HistoryChart { + series = List.copyOf(Objects.requireNonNullElse(series, List.of())); + } + + } + + public record ChartSeries(String name, List> data) { + + public ChartSeries { + data = List.copyOf(Objects.requireNonNullElse(data, List.of())); + } + + } + + public record DeviceLatestPointValues(FacadeDeviceBO device, List points) { + + public DeviceLatestPointValues { + points = List.copyOf(Objects.requireNonNullElse(points, List.of())); + } + + } + + public record PointLatestValue(FacadePointBO point, FacadePointValueBO value) { + } + + public record PointCommandResult(Long deviceId, Long pointId, String value, boolean sent, + boolean pendingConfirmation, String actionId) { } } diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/ManagerToolSet.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/ManagerToolSet.java index 4cbe94059..fd4adf4b3 100644 --- a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/ManagerToolSet.java +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/ManagerToolSet.java @@ -36,7 +36,6 @@ import org.springframework.stereotype.Component; import java.util.List; import java.util.Objects; -import java.util.stream.Collectors; /** * Manager-domain tools exposed to the LLM via Spring AI @Tool. @@ -67,37 +66,38 @@ public class ManagerToolSet { // ==================== Device Tools ==================== @Tool(description = "Look up a device by its numeric ID. Returns device name, code, driver ID, enable status, and profile IDs.") - public String lookupDeviceById(@ToolParam(description = "The numeric device ID") Long deviceId, - ToolContext toolContext) { + public AgenticToolResult lookupDeviceById(@ToolParam(description = "The numeric device ID") Long deviceId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}", "lookupDeviceById", tenantId, deviceId); recordTool(toolContext, "lookupDeviceById", "Query device by ID"); FacadeDeviceBO bo = deviceFacade.selectById(tenantId, deviceId); if (Objects.isNull(bo)) { - return "Device not found for ID: " + deviceId; + return AgenticToolResult.notFound("Device not found for ID: " + deviceId); } - return formatDevice(bo); + return AgenticToolResult.ok("Device loaded", bo); } @Tool(description = "Batch look up devices by numeric IDs. Returns up to 50 tenant-scoped devices.") - public String lookupDevicesByIds(@ToolParam(description = "The numeric device IDs") List deviceIds, - ToolContext toolContext) { + public AgenticToolResult> lookupDevicesByIds( + @ToolParam(description = "The numeric device IDs") List deviceIds, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); List ids = normalizeIds(deviceIds); log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceIds={}", "lookupDevicesByIds", tenantId, ids); recordTool(toolContext, "lookupDevicesByIds", "Batch query devices by IDs"); if (ids.isEmpty()) { - return "No valid device IDs provided."; + return AgenticToolResult.invalid("No valid device IDs provided."); } List devices = deviceFacade.selectByIds(tenantId, ids); if (devices.isEmpty()) { - return "No devices found for IDs: " + ids; + return AgenticToolResult.empty("No devices found for IDs: " + ids, devices); } - return devices.stream().map(this::formatDevice).collect(Collectors.joining("\n")); + return AgenticToolResult.ok("Devices loaded", devices); } @Tool(description = "Search for devices with optional filters. Supports filtering by device name, code, or driver ID. Returns a paginated list of devices.") - public String searchDevices( + public AgenticToolResult> searchDevices( @ToolParam(description = "Device name filter (partial match), or null to skip") String deviceName, @ToolParam(description = "Device code filter, or null to skip") String deviceCode, @ToolParam(description = "Driver ID filter, or null to skip") Long driverId, @@ -121,87 +121,93 @@ public class ManagerToolSet { query.setPage(p); FacadePage result = deviceFacade.selectByPage(query); - return formatDevicePage(result); + if (Objects.isNull(result) || result.getRecords().isEmpty()) { + return AgenticToolResult.empty("No devices found.", result); + } + return AgenticToolResult.ok("Device page loaded", result); } @Tool(description = "List all devices attached to a given driver ID.") - public String listDevicesByDriverId(@ToolParam(description = "The driver ID") Long driverId, - ToolContext toolContext) { + public AgenticToolResult> listDevicesByDriverId( + @ToolParam(description = "The driver ID") Long driverId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, driverId={}", "listDevicesByDriverId", tenantId, driverId); recordTool(toolContext, "listDevicesByDriverId", "List devices by driver"); List devices = deviceFacade.selectByDriverId(tenantId, driverId); if (devices.isEmpty()) { - return "No devices found for driver ID: " + driverId; + return AgenticToolResult.empty("No devices found for driver ID: " + driverId, devices); } - return "Devices for driver " + driverId + ":\n" - + devices.stream().map(this::formatDevice).collect(Collectors.joining("\n")); + return AgenticToolResult.ok("Devices loaded for driver " + driverId, devices); } @Tool(description = "List all devices that use a given profile (device template) ID.") - public String listDevicesByProfileId(@ToolParam(description = "The profile ID") Long profileId, - ToolContext toolContext) { + public AgenticToolResult> listDevicesByProfileId( + @ToolParam(description = "The profile ID") Long profileId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, profileId={}", "listDevicesByProfileId", tenantId, profileId); recordTool(toolContext, "listDevicesByProfileId", "List devices by profile"); List devices = deviceFacade.selectByProfileId(tenantId, profileId); if (devices.isEmpty()) { - return "No devices found for profile ID: " + profileId; + return AgenticToolResult.empty("No devices found for profile ID: " + profileId, devices); } - return "Devices for profile " + profileId + ":\n" - + devices.stream().map(this::formatDevice).collect(Collectors.joining("\n")); + return AgenticToolResult.ok("Devices loaded for profile " + profileId, devices); } // ==================== Driver Tools ==================== @Tool(description = "Look up a driver by its numeric ID. Returns driver name, code, service name, host, type, and enable status.") - public String lookupDriverById(@ToolParam(description = "The numeric driver ID") Long driverId, - ToolContext toolContext) { + public AgenticToolResult lookupDriverById( + @ToolParam(description = "The numeric driver ID") Long driverId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, driverId={}", "lookupDriverById", tenantId, driverId); recordTool(toolContext, "lookupDriverById", "Query driver by ID"); FacadeDriverBO bo = driverFacade.selectById(tenantId, driverId); if (Objects.isNull(bo)) { - return "Driver not found for ID: " + driverId; + return AgenticToolResult.notFound("Driver not found for ID: " + driverId); } - return formatDriver(bo); + return AgenticToolResult.ok("Driver loaded", bo); } @Tool(description = "Batch look up drivers by numeric IDs. Returns up to 50 tenant-scoped drivers.") - public String lookupDriversByIds(@ToolParam(description = "The numeric driver IDs") List driverIds, - ToolContext toolContext) { + public AgenticToolResult> lookupDriversByIds( + @ToolParam(description = "The numeric driver IDs") List driverIds, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); List ids = normalizeIds(driverIds); log.debug("Agentic tool invoked, tool={}, tenantId={}, driverIds={}", "lookupDriversByIds", tenantId, ids); recordTool(toolContext, "lookupDriversByIds", "Batch query drivers by IDs"); if (ids.isEmpty()) { - return "No valid driver IDs provided."; + return AgenticToolResult.invalid("No valid driver IDs provided."); } List drivers = driverFacade.selectByIds(tenantId, ids); if (drivers.isEmpty()) { - return "No drivers found for IDs: " + ids; + return AgenticToolResult.empty("No drivers found for IDs: " + ids, drivers); } - return drivers.stream().map(this::formatDriver).collect(Collectors.joining("\n")); + return AgenticToolResult.ok("Drivers loaded", drivers); } @Tool(description = "Resolve the driver that owns a given device. Returns the driver details.") - public String lookupDriverByDeviceId(@ToolParam(description = "The device ID") Long deviceId, - ToolContext toolContext) { + public AgenticToolResult lookupDriverByDeviceId( + @ToolParam(description = "The device ID") Long deviceId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}", "lookupDriverByDeviceId", tenantId, deviceId); recordTool(toolContext, "lookupDriverByDeviceId", "Query device driver"); FacadeDriverBO bo = driverFacade.selectByDeviceId(tenantId, deviceId); if (Objects.isNull(bo)) { - return "No driver found for device ID: " + deviceId; + return AgenticToolResult.notFound("No driver found for device ID: " + deviceId); } - return formatDriver(bo); + return AgenticToolResult.ok("Driver loaded for device " + deviceId, bo); } @Tool(description = "Search for drivers with optional name filter. Returns a paginated list.") - public String searchDrivers( + public AgenticToolResult> searchDrivers( @ToolParam(description = "Driver name filter (partial match), or null to skip") String driverName, @ToolParam(description = "Page number (1-based)") int page, @ToolParam(description = "Page size") int size, @@ -220,43 +226,48 @@ public class ManagerToolSet { query.setPage(p); FacadePage result = driverFacade.selectByPage(query); - return formatDriverPage(result); + if (Objects.isNull(result) || result.getRecords().isEmpty()) { + return AgenticToolResult.empty("No drivers found.", result); + } + return AgenticToolResult.ok("Driver page loaded", result); } // ==================== Point Tools ==================== @Tool(description = "Look up a point (data point / metric) by its numeric ID. Returns point name, code, type, read/write flag, unit, base value, and multiplier.") - public String lookupPointById(@ToolParam(description = "The numeric point ID") Long pointId, - ToolContext toolContext) { + public AgenticToolResult lookupPointById( + @ToolParam(description = "The numeric point ID") Long pointId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, pointId={}", "lookupPointById", tenantId, pointId); recordTool(toolContext, "lookupPointById", "Query point by ID"); FacadePointBO bo = pointFacade.selectById(tenantId, pointId); if (Objects.isNull(bo)) { - return "Point not found for ID: " + pointId; + return AgenticToolResult.notFound("Point not found for ID: " + pointId); } - return formatPoint(bo); + return AgenticToolResult.ok("Point loaded", bo); } @Tool(description = "Batch look up points by numeric IDs. Returns up to 50 tenant-scoped points.") - public String lookupPointsByIds(@ToolParam(description = "The numeric point IDs") List pointIds, - ToolContext toolContext) { + public AgenticToolResult> lookupPointsByIds( + @ToolParam(description = "The numeric point IDs") List pointIds, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); List ids = normalizeIds(pointIds); log.debug("Agentic tool invoked, tool={}, tenantId={}, pointIds={}", "lookupPointsByIds", tenantId, ids); recordTool(toolContext, "lookupPointsByIds", "Batch query points by IDs"); if (ids.isEmpty()) { - return "No valid point IDs provided."; + return AgenticToolResult.invalid("No valid point IDs provided."); } List points = pointFacade.selectByIds(tenantId, ids); if (points.isEmpty()) { - return "No points found for IDs: " + ids; + return AgenticToolResult.empty("No points found for IDs: " + ids, points); } - return points.stream().map(this::formatPoint).collect(Collectors.joining("\n")); + return AgenticToolResult.ok("Points loaded", points); } @Tool(description = "Search for points with optional filters. Returns a paginated list.") - public String searchPoints( + public AgenticToolResult> searchPoints( @ToolParam(description = "Point name filter (partial match), or null to skip") String pointName, @ToolParam(description = "Profile ID filter, or null to skip") Long profileId, @ToolParam(description = "Page number (1-based)") int page, @@ -277,14 +288,18 @@ public class ManagerToolSet { query.setPage(p); FacadePage result = pointFacade.selectByPage(query); - return formatPointPage(result); + if (Objects.isNull(result) || result.getRecords().isEmpty()) { + return AgenticToolResult.empty("No points found.", result); + } + return AgenticToolResult.ok("Point page loaded", result); } @Tool(description = "List points bound to a specific device ID. Use this before reading or writing values when the user knows the device but not the point ID.") - public String listPointsByDeviceId(@ToolParam(description = "The device ID") Long deviceId, - @ToolParam(description = "Page number (1-based)") int page, - @ToolParam(description = "Page size") int size, - ToolContext toolContext) { + public AgenticToolResult> listPointsByDeviceId( + @ToolParam(description = "The device ID") Long deviceId, + @ToolParam(description = "Page number (1-based)") int page, + @ToolParam(description = "Page size") int size, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}, page={}, size={}", "listPointsByDeviceId", tenantId, deviceId, page, size); @@ -299,14 +314,18 @@ public class ManagerToolSet { query.setPage(p); FacadePage result = pointFacade.selectByPage(query); - return formatPointPage(result); + if (Objects.isNull(result) || result.getRecords().isEmpty()) { + return AgenticToolResult.empty("No points found for device ID: " + deviceId, result); + } + return AgenticToolResult.ok("Point page loaded for device " + deviceId, result); } @Tool(description = "List points under a specific profile/template ID. Use this when the user wants all metrics defined by a template.") - public String listPointsByProfileId(@ToolParam(description = "The profile/template ID") Long profileId, - @ToolParam(description = "Page number (1-based)") int page, - @ToolParam(description = "Page size") int size, - ToolContext toolContext) { + public AgenticToolResult> listPointsByProfileId( + @ToolParam(description = "The profile/template ID") Long profileId, + @ToolParam(description = "Page number (1-based)") int page, + @ToolParam(description = "Page size") int size, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, profileId={}, page={}, size={}", "listPointsByProfileId", tenantId, profileId, page, size); @@ -321,51 +340,10 @@ public class ManagerToolSet { query.setPage(p); FacadePage result = pointFacade.selectByPage(query); - return formatPointPage(result); - } - - // ==================== Formatting Helpers ==================== - - private String formatDevice(FacadeDeviceBO d) { - return String.format("Device[id=%d, name=%s, code=%s, driverId=%d, enabled=%s, profileIds=%s]", d.getId(), - d.getDeviceName(), d.getDeviceCode(), d.getDriverId(), d.getEnableFlag(), d.getProfileIds()); - } - - private String formatDevicePage(FacadePage page) { - if (Objects.isNull(page) || page.getRecords().isEmpty()) { - return "No devices found."; + if (Objects.isNull(result) || result.getRecords().isEmpty()) { + return AgenticToolResult.empty("No points found for profile ID: " + profileId, result); } - String items = page.getRecords().stream().map(this::formatDevice).collect(Collectors.joining("\n")); - return String.format("Page %d/%d (total %d):\n%s", page.getCurrent(), page.getPages(), page.getTotal(), items); - } - - private String formatDriver(FacadeDriverBO d) { - return String.format("Driver[id=%d, name=%s, code=%s, serviceName=%s, host=%s, type=%s, enabled=%s]", d.getId(), - d.getDriverName(), d.getDriverCode(), d.getServiceName(), d.getServiceHost(), d.getDriverTypeFlag(), - d.getEnableFlag()); - } - - private String formatDriverPage(FacadePage page) { - if (Objects.isNull(page) || page.getRecords().isEmpty()) { - return "No drivers found."; - } - String items = page.getRecords().stream().map(this::formatDriver).collect(Collectors.joining("\n")); - return String.format("Page %d/%d (total %d):\n%s", page.getCurrent(), page.getPages(), page.getTotal(), items); - } - - private String formatPoint(FacadePointBO p) { - return String.format( - "Point[id=%d, name=%s, code=%s, type=%s, rw=%s, unit=%s, base=%s, multiple=%s, profileId=%d]", - p.getId(), p.getPointName(), p.getPointCode(), p.getPointTypeFlag(), p.getRwFlag(), p.getUnit(), - p.getBaseValue(), p.getMultiple(), p.getProfileId()); - } - - private String formatPointPage(FacadePage page) { - if (Objects.isNull(page) || page.getRecords().isEmpty()) { - return "No points found."; - } - String items = page.getRecords().stream().map(this::formatPoint).collect(Collectors.joining("\n")); - return String.format("Page %d/%d (total %d):\n%s", page.getCurrent(), page.getPages(), page.getTotal(), items); + return AgenticToolResult.ok("Point page loaded for profile " + profileId, result); } private void recordTool(ToolContext toolContext, String toolName, String description) { diff --git a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/PlatformToolSet.java b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/PlatformToolSet.java index 08247d893..bba6fb50a 100644 --- a/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/PlatformToolSet.java +++ b/dc3-common/dc3-common-agentic/src/main/java/io/github/pnoker/common/agentic/tool/PlatformToolSet.java @@ -19,7 +19,6 @@ package io.github.pnoker.common.agentic.tool; import io.github.pnoker.common.agentic.context.AgenticRequestContext; import io.github.pnoker.common.entity.common.Pages; -import io.github.pnoker.common.enums.ProfileShareFlagEnum; import io.github.pnoker.common.enums.ProfileTypeFlagEnum; import io.github.pnoker.common.facade.api.ProfileFacade; import io.github.pnoker.common.facade.api.StatusHealthFacade; @@ -38,7 +37,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.stream.Collectors; /** * Platform-level tools for profiles/templates, fleet status, and health summaries. @@ -65,43 +63,48 @@ public class PlatformToolSet { } @Tool(description = "Look up a profile/template by its numeric ID. Returns template name, code, type, share flag, enable status, and version.") - public String lookupProfileById(@ToolParam(description = "The numeric profile/template ID") Long profileId, - ToolContext toolContext) { + public AgenticToolResult lookupProfileById( + @ToolParam(description = "The numeric profile/template ID") Long profileId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, profileId={}", "lookupProfileById", tenantId, profileId); recordTool(toolContext, "lookupProfileById", "Query profile by ID"); ProfileFacade facade = profileFacade.orElse(null); if (Objects.isNull(facade)) { - return PROFILE_UNAVAILABLE; + return AgenticToolResult.unavailable(PROFILE_UNAVAILABLE); } FacadeProfileBO profile = facade.selectById(tenantId, profileId); - return Objects.isNull(profile) ? "Profile not found for ID: " + profileId : formatProfile(profile); + if (Objects.isNull(profile)) { + return AgenticToolResult.notFound("Profile not found for ID: " + profileId); + } + return AgenticToolResult.ok("Profile loaded", profile); } @Tool(description = "Batch look up profiles/templates by numeric IDs. Returns up to 50 tenant-scoped templates.") - public String lookupProfilesByIds(@ToolParam(description = "The numeric profile/template IDs") List profileIds, - ToolContext toolContext) { + public AgenticToolResult> lookupProfilesByIds( + @ToolParam(description = "The numeric profile/template IDs") List profileIds, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); List ids = normalizeIds(profileIds); log.debug("Agentic tool invoked, tool={}, tenantId={}, profileIds={}", "lookupProfilesByIds", tenantId, ids); recordTool(toolContext, "lookupProfilesByIds", "Batch query profiles by IDs"); ProfileFacade facade = profileFacade.orElse(null); if (Objects.isNull(facade)) { - return PROFILE_UNAVAILABLE; + return AgenticToolResult.unavailable(PROFILE_UNAVAILABLE); } if (ids.isEmpty()) { - return "No valid profile IDs provided."; + return AgenticToolResult.invalid("No valid profile IDs provided."); } List profiles = facade.selectByIds(tenantId, ids); - if (profiles.isEmpty()) { - return "No profiles found for IDs: " + ids; + if (Objects.isNull(profiles) || profiles.isEmpty()) { + return AgenticToolResult.empty("No profiles found for IDs: " + ids, List.of()); } - return profiles.stream().map(this::formatProfile).collect(Collectors.joining("\n")); + return AgenticToolResult.ok("Profiles loaded", profiles); } @Tool(description = "Search profiles/templates with optional filters. profileType accepts system, driver, user, or their enum names.") - public String searchProfiles( + public AgenticToolResult> searchProfiles( @ToolParam(description = "Profile/template name filter (partial match), or null to skip") String profileName, @ToolParam(description = "Profile/template code filter, or null to skip") String profileCode, @ToolParam(description = "Profile type filter: system, driver, user, SYSTEM, DRIVER, USER, or null to skip") String profileType, @@ -115,7 +118,7 @@ public class PlatformToolSet { recordTool(toolContext, "searchProfiles", "Search profiles"); ProfileFacade facade = profileFacade.orElse(null); if (Objects.isNull(facade)) { - return PROFILE_UNAVAILABLE; + return AgenticToolResult.unavailable(PROFILE_UNAVAILABLE); } FacadeProfileQuery query = new FacadeProfileQuery(); @@ -127,155 +130,135 @@ public class PlatformToolSet { p.setCurrent(page); p.setSize(size); query.setPage(p); - return formatProfilePage(facade.selectByPage(query)); + FacadePage result = facade.selectByPage(query); + if (Objects.isNull(result) || Objects.isNull(result.getRecords()) || result.getRecords().isEmpty()) { + return AgenticToolResult.empty("No profiles found.", result); + } + return AgenticToolResult.ok("Profile page loaded", result); } @Tool(description = "List profiles/templates bound to a specific device ID.") - public String listProfilesByDeviceId(@ToolParam(description = "The device ID") Long deviceId, - ToolContext toolContext) { + public AgenticToolResult> listProfilesByDeviceId( + @ToolParam(description = "The device ID") Long deviceId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}", "listProfilesByDeviceId", tenantId, deviceId); recordTool(toolContext, "listProfilesByDeviceId", "List profiles by device"); ProfileFacade facade = profileFacade.orElse(null); if (Objects.isNull(facade)) { - return PROFILE_UNAVAILABLE; + return AgenticToolResult.unavailable(PROFILE_UNAVAILABLE); } List profiles = facade.selectByDeviceId(tenantId, deviceId); - if (profiles.isEmpty()) { - return "No profiles found for device ID: " + deviceId; + if (Objects.isNull(profiles) || profiles.isEmpty()) { + return AgenticToolResult.empty("No profiles found for device ID: " + deviceId, List.of()); } - return profiles.stream().map(this::formatProfile).collect(Collectors.joining("\n")); + return AgenticToolResult.ok("Profiles loaded for device " + deviceId, profiles); } @Tool(description = "Get device online/offline statuses for device IDs. Returns up to 50 tenant-scoped statuses.") - public String getDeviceStatusesByIds(@ToolParam(description = "The numeric device IDs") List deviceIds, - ToolContext toolContext) { + public AgenticToolResult> getDeviceStatusesByIds( + @ToolParam(description = "The numeric device IDs") List deviceIds, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); List ids = normalizeIds(deviceIds); log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceIds={}", "getDeviceStatusesByIds", tenantId, ids); recordTool(toolContext, "getDeviceStatusesByIds", "Get device statuses"); StatusHealthFacade facade = statusHealthFacade.orElse(null); if (Objects.isNull(facade)) { - return STATUS_UNAVAILABLE; + return AgenticToolResult.unavailable(STATUS_UNAVAILABLE); } if (ids.isEmpty()) { - return "No valid device IDs provided."; + return AgenticToolResult.invalid("No valid device IDs provided."); } - return formatStatusMap("Device statuses", facade.selectDeviceStatusesByIds(tenantId, ids)); + Map statuses = facade.selectDeviceStatusesByIds(tenantId, ids); + if (Objects.isNull(statuses) || statuses.isEmpty()) { + return AgenticToolResult.empty("No device statuses found.", Map.of()); + } + return AgenticToolResult.ok("Device statuses loaded", statuses); } @Tool(description = "Get device online/offline statuses for devices bound to a profile/template.") - public String getDeviceStatusesByProfileId(@ToolParam(description = "The profile/template ID") Long profileId, - ToolContext toolContext) { + public AgenticToolResult> getDeviceStatusesByProfileId( + @ToolParam(description = "The profile/template ID") Long profileId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, profileId={}", "getDeviceStatusesByProfileId", tenantId, profileId); recordTool(toolContext, "getDeviceStatusesByProfileId", "Get device statuses by profile"); StatusHealthFacade facade = statusHealthFacade.orElse(null); if (Objects.isNull(facade)) { - return STATUS_UNAVAILABLE; + return AgenticToolResult.unavailable(STATUS_UNAVAILABLE); } - return formatStatusMap("Device statuses for profile " + profileId, - facade.selectDeviceStatusesByProfileId(tenantId, profileId)); + Map statuses = facade.selectDeviceStatusesByProfileId(tenantId, profileId); + if (Objects.isNull(statuses) || statuses.isEmpty()) { + return AgenticToolResult.empty("No device statuses found for profile ID: " + profileId, Map.of()); + } + return AgenticToolResult.ok("Device statuses loaded for profile " + profileId, statuses); } @Tool(description = "Get driver online/offline statuses for driver IDs. Returns up to 50 tenant-scoped statuses.") - public String getDriverStatusesByIds(@ToolParam(description = "The numeric driver IDs") List driverIds, - ToolContext toolContext) { + public AgenticToolResult> getDriverStatusesByIds( + @ToolParam(description = "The numeric driver IDs") List driverIds, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); List ids = normalizeIds(driverIds); log.debug("Agentic tool invoked, tool={}, tenantId={}, driverIds={}", "getDriverStatusesByIds", tenantId, ids); recordTool(toolContext, "getDriverStatusesByIds", "Get driver statuses"); StatusHealthFacade facade = statusHealthFacade.orElse(null); if (Objects.isNull(facade)) { - return STATUS_UNAVAILABLE; + return AgenticToolResult.unavailable(STATUS_UNAVAILABLE); } if (ids.isEmpty()) { - return "No valid driver IDs provided."; + return AgenticToolResult.invalid("No valid driver IDs provided."); } - return formatStatusMap("Driver statuses", facade.selectDriverStatusesByIds(tenantId, ids)); + Map statuses = facade.selectDriverStatusesByIds(tenantId, ids); + if (Objects.isNull(statuses) || statuses.isEmpty()) { + return AgenticToolResult.empty("No driver statuses found.", Map.of()); + } + return AgenticToolResult.ok("Driver statuses loaded", statuses); } @Tool(description = "Get the online/offline device count summary under a driver.") - public String getDriverDeviceStatusSummary(@ToolParam(description = "The driver ID") Long driverId, - ToolContext toolContext) { + public AgenticToolResult> getDriverDeviceStatusSummary( + @ToolParam(description = "The driver ID") Long driverId, + ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}, driverId={}", "getDriverDeviceStatusSummary", tenantId, driverId); recordTool(toolContext, "getDriverDeviceStatusSummary", "Get driver device status summary"); StatusHealthFacade facade = statusHealthFacade.orElse(null); if (Objects.isNull(facade)) { - return STATUS_UNAVAILABLE; + return AgenticToolResult.unavailable(STATUS_UNAVAILABLE); } Map summary = facade.getDriverDeviceStatusSummary(tenantId, driverId); - if (summary.isEmpty()) { - return "No driver device status summary found for driver ID: " + driverId; + if (Objects.isNull(summary) || summary.isEmpty()) { + return AgenticToolResult.empty("No driver device status summary found for driver ID: " + driverId, + Map.of()); } - return summary.entrySet().stream() - .map(entry -> entry.getKey() + "=" + entry.getValue()) - .collect(Collectors.joining(", ")); + return AgenticToolResult.ok("Driver device status summary loaded", summary); } @Tool(description = "Get a system health snapshot: center services, infrastructure, driver fleet, and device fleet.") - public String getSystemHealth(ToolContext toolContext) { + public AgenticToolResult getSystemHealth(ToolContext toolContext) { Long tenantId = AgenticRequestContext.requireTenantId(toolContext); log.debug("Agentic tool invoked, tool={}, tenantId={}", "getSystemHealth", tenantId); recordTool(toolContext, "getSystemHealth", "Get system health"); StatusHealthFacade facade = statusHealthFacade.orElse(null); if (Objects.isNull(facade)) { - return STATUS_UNAVAILABLE; + return AgenticToolResult.unavailable(STATUS_UNAVAILABLE); } FacadeSystemHealthBO health = facade.systemHealth(tenantId); if (Objects.isNull(health)) { - return "System health snapshot is unavailable."; + return AgenticToolResult.unavailable("System health snapshot is unavailable."); } - return formatHealth(health); + return AgenticToolResult.ok("System health loaded", health); } private void recordTool(ToolContext toolContext, String toolName, String description) { AgenticRequestContext.recordToolInvocation(toolContext, toolName, "platform", description); } - private String formatProfile(FacadeProfileBO profile) { - return String.format( - "Profile[id=%d, name=%s, code=%s, share=%s, type=%s, enabled=%s, version=%s]", - profile.getId(), profile.getProfileName(), profile.getProfileCode(), profile.getProfileShareFlag(), - profile.getProfileTypeFlag(), profile.getEnableFlag(), profile.getVersion()); - } - - private String formatProfilePage(FacadePage page) { - if (Objects.isNull(page) || page.getRecords().isEmpty()) { - return "No profiles found."; - } - String items = page.getRecords().stream().map(this::formatProfile).collect(Collectors.joining("\n")); - return String.format("Page %d/%d (total %d):\n%s", page.getCurrent(), page.getPages(), page.getTotal(), items); - } - - private String formatStatusMap(String title, Map statuses) { - if (Objects.isNull(statuses) || statuses.isEmpty()) { - return title + ": no statuses found."; - } - String items = statuses.entrySet().stream() - .map(entry -> entry.getKey() + "=" + entry.getValue()) - .collect(Collectors.joining(", ")); - return title + ": " + items; - } - - private String formatHealth(FacadeSystemHealthBO health) { - return String.format("Health[center=%s, infra=%s, drivers=%s/%s online, devices=%s/%s online]", - health.getCenter(), health.getInfra(), online(health.getDrivers()), total(health.getDrivers()), - online(health.getDevices()), total(health.getDevices())); - } - - private int total(FacadeSystemHealthBO.FleetSummary summary) { - return Objects.nonNull(summary) ? summary.getTotal() : 0; - } - - private int online(FacadeSystemHealthBO.FleetSummary summary) { - return Objects.nonNull(summary) ? summary.getOnline() : 0; - } - private List normalizeIds(List ids) { if (Objects.isNull(ids) || ids.isEmpty()) { return List.of(); @@ -297,19 +280,4 @@ public class PlatformToolSet { return Objects.nonNull(byCode) ? byCode : ProfileTypeFlagEnum.ofName(trimmed.toUpperCase()); } - @SuppressWarnings("unused") - private ProfileShareFlagEnum parseProfileShare(String value) { - if (StringUtils.isBlank(value)) { - return null; - } - String trimmed = value.trim(); - try { - return ProfileShareFlagEnum.ofIndex(Byte.valueOf(trimmed)); - } catch (NumberFormatException ignored) { - // Continue with code/name lookup. - } - ProfileShareFlagEnum byCode = ProfileShareFlagEnum.ofCode(trimmed.toLowerCase()); - return Objects.nonNull(byCode) ? byCode : ProfileShareFlagEnum.ofName(trimmed.toUpperCase()); - } - } diff --git a/dc3-common/dc3-common-agentic/src/main/resources/skills/data-monitor.yml b/dc3-common/dc3-common-agentic/src/main/resources/skills/data-monitor.yml index 14fe3ffd4..c33983ee6 100644 --- a/dc3-common/dc3-common-agentic/src/main/resources/skills/data-monitor.yml +++ b/dc3-common/dc3-common-agentic/src/main/resources/skills/data-monitor.yml @@ -19,4 +19,4 @@ tools: - getSystemHealth examples: - user: "Show the latest data for device 1" - assistant: "Fetching real-time data for device 1..." + assistant: "Show the device, point, value, timestamp, and unit returned by the backend." diff --git a/dc3-common/dc3-common-agentic/src/main/resources/skills/device-query.yml b/dc3-common/dc3-common-agentic/src/main/resources/skills/device-query.yml index 75133741b..30544e1e4 100644 --- a/dc3-common/dc3-common-agentic/src/main/resources/skills/device-query.yml +++ b/dc3-common/dc3-common-agentic/src/main/resources/skills/device-query.yml @@ -30,4 +30,4 @@ tools: - getSystemHealth examples: - user: "Show devices under driver 1" - assistant: "Querying the device list under driver 1..." + assistant: "Show the device rows returned by the backend in a concise table." diff --git a/dc3-common/dc3-common-agentic/src/test/java/io/github/pnoker/common/agentic/service/direct/DataMonitorDirectBackendProviderTest.java b/dc3-common/dc3-common-agentic/src/test/java/io/github/pnoker/common/agentic/service/direct/DataMonitorDirectBackendProviderTest.java new file mode 100644 index 000000000..4a208e033 --- /dev/null +++ b/dc3-common/dc3-common-agentic/src/test/java/io/github/pnoker/common/agentic/service/direct/DataMonitorDirectBackendProviderTest.java @@ -0,0 +1,168 @@ +/* + * Copyright 2016-present the IoT DC3 original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.github.pnoker.common.agentic.service.direct; + +import io.github.pnoker.common.agentic.context.AgenticRequestContext; +import io.github.pnoker.common.agentic.entity.request.DirectQueryRequest; +import io.github.pnoker.common.entity.common.RequestHeader; +import io.github.pnoker.common.facade.api.DeviceFacade; +import io.github.pnoker.common.facade.api.DriverFacade; +import io.github.pnoker.common.facade.api.PointFacade; +import io.github.pnoker.common.facade.api.PointValueFacade; +import io.github.pnoker.common.facade.entity.bo.FacadeDeviceBO; +import io.github.pnoker.common.facade.entity.bo.FacadeDriverBO; +import io.github.pnoker.common.facade.entity.bo.FacadePointBO; +import io.github.pnoker.common.facade.entity.bo.FacadePointValueBO; +import io.github.pnoker.common.facade.entity.common.FacadePage; +import io.github.pnoker.common.facade.entity.query.FacadeDeviceQuery; +import io.github.pnoker.common.facade.entity.query.FacadeDriverQuery; +import io.github.pnoker.common.facade.entity.query.FacadePointQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DataMonitorDirectBackendProviderTest { + + @Mock + private DeviceFacade deviceFacade; + + @Mock + private DriverFacade driverFacade; + + @Mock + private PointFacade pointFacade; + + @Mock + private PointValueFacade pointValueFacade; + + private DataMonitorDirectBackendProvider provider; + + private RequestHeader.UserHeader header; + + @BeforeEach + void setUp() { + provider = new DataMonitorDirectBackendProvider(deviceFacade, driverFacade, pointFacade, pointValueFacade); + header = new RequestHeader.UserHeader(); + header.setTenantId(1L); + header.setUserId(2L); + } + + @Test + void freeFormChatDoesNotTriggerPointValueDirectLookup() { + FacadeDeviceBO device = device(10L, "East Pump 01", "PUMP-E01"); + when(deviceFacade.selectByPage(any(FacadeDeviceQuery.class))).thenReturn(page(List.of(device))); + when(driverFacade.selectByPage(any(FacadeDriverQuery.class))).thenReturn(page(List.of(new FacadeDriverBO()))); + when(pointFacade.selectByPage(any(FacadePointQuery.class))).thenReturn(page(List.of(point(20L, "temperature", + "TEMP")))); + + Queue events = new ConcurrentLinkedQueue<>(); + DirectBackendResult result = provider.build(null, header, events); + + assertThat(result.answer()).isNull(); + assertThat(result.context()).contains("Monitoring snapshot").contains("East Pump 01"); + assertThat(events).extracting(AgenticRequestContext.ToolEvent::toolName) + .containsExactly("getMonitoringSnapshot"); + verify(pointValueFacade, never()).lastValue(any(), any(), any()); + verify(pointValueFacade, never()).history(any(), any(), any(), anyInt()); + } + + @Test + void structuredPointValueQueryUsesExplicitIds() { + DirectQueryRequest query = new DirectQueryRequest(); + query.setType(DirectQueryRequest.TYPE_POINT_VALUE); + query.setDeviceId(10L); + query.setPointId(20L); + query.setLimit(3); + + when(deviceFacade.selectById(1L, 10L)).thenReturn(device(10L, "East Pump 01", "PUMP-E01")); + when(pointFacade.selectById(1L, 20L)).thenReturn(point(20L, "temperature", "TEMP")); + when(pointValueFacade.lastValue(1L, 10L, 20L)).thenReturn(FacadePointValueBO.builder() + .deviceId(10L) + .pointId(20L) + .value("36.8") + .rawValue("36.82") + .createTime(1_779_000_000L) + .build()); + when(pointValueFacade.history(1L, 10L, 20L, 3)).thenReturn(List.of("36.8", "36.7", "36.5")); + + DirectBackendResult result = provider.build(query, header, new ConcurrentLinkedQueue<>()); + + assertThat(result.answer().title()).isEqualTo("位号数据查询结果"); + assertThat(result.answer().fields()) + .extracting(DirectAnswer.Field::value) + .anySatisfy(value -> assertThat(value).contains("East Pump 01")) + .anySatisfy(value -> assertThat(value).contains("temperature")) + .anySatisfy(value -> assertThat(value).contains("36.8")); + assertThat(result.answer().tables()).hasSize(1); + assertThat(result.answer().tables().getFirst().title()).contains("最新 3 条历史值"); + assertThat(result.answer().charts()).hasSize(1); + verify(deviceFacade).selectById(1L, 10L); + verify(pointFacade).selectById(1L, 20L); + } + + @Test + void structuredPointValueQueryFailsClosedWhenSelectorIsMissing() { + DirectQueryRequest query = new DirectQueryRequest(); + query.setType(DirectQueryRequest.TYPE_POINT_VALUE); + query.setDeviceId(10L); + + DirectBackendResult result = provider.build(query, header, new ConcurrentLinkedQueue<>()); + + assertThat(result.answer().message()).contains("确定性位号查询需要明确的设备选择器和位号选择器"); + verifyNoInteractions(deviceFacade, driverFacade, pointFacade, pointValueFacade); + } + + private FacadeDeviceBO device(Long id, String name, String code) { + FacadeDeviceBO device = new FacadeDeviceBO(); + device.setId(id); + device.setTenantId(1L); + device.setDeviceName(name); + device.setDeviceCode(code); + return device; + } + + private FacadePointBO point(Long id, String name, String code) { + FacadePointBO point = new FacadePointBO(); + point.setId(id); + point.setTenantId(1L); + point.setPointName(name); + point.setPointCode(code); + point.setUnit("C"); + return point; + } + + private FacadePage page(List records) { + return new FacadePage<>(1L, records.size(), records.size(), 1L, records); + } + +} diff --git a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/grpc/server/PointValueServer.java b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/grpc/server/PointValueServer.java index c18e6c0c7..940c0f368 100644 --- a/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/grpc/server/PointValueServer.java +++ b/dc3-common/dc3-common-data/src/main/java/io/github/pnoker/common/data/grpc/server/PointValueServer.java @@ -97,6 +97,7 @@ public class PointValueServer extends PointValueApiGrpc.PointValueApiImplBase { .setPointId(Objects.nonNull(bo.getPointId()) ? bo.getPointId() : 0) .setValue(Objects.nonNull(bo.getCalValue()) ? bo.getCalValue() : "") .setRawValue(Objects.nonNull(bo.getRawValue()) ? bo.getRawValue() : "") + .setNumValue(Objects.nonNull(bo.getNumValue()) ? bo.getNumValue() : 0d) .setCreateTime( Objects.nonNull(bo.getCreateTime()) ? bo.getCreateTime().toEpochSecond(java.time.ZoneOffset.UTC) : 0) .build()); diff --git a/dc3-common/dc3-common-facade/dc3-common-facade-api/src/main/java/io/github/pnoker/common/facade/entity/bo/FacadePointValueBO.java b/dc3-common/dc3-common-facade/dc3-common-facade-api/src/main/java/io/github/pnoker/common/facade/entity/bo/FacadePointValueBO.java index 103552804..5e247c4f4 100644 --- a/dc3-common/dc3-common-facade/dc3-common-facade-api/src/main/java/io/github/pnoker/common/facade/entity/bo/FacadePointValueBO.java +++ b/dc3-common/dc3-common-facade/dc3-common-facade-api/src/main/java/io/github/pnoker/common/facade/entity/bo/FacadePointValueBO.java @@ -63,6 +63,11 @@ public class FacadePointValueBO { */ private String rawValue; + /** + * Numeric projection of {@link #value}; null when value is non-numeric + */ + private Double numValue; + /** * Storage timestamp (epoch seconds) */ diff --git a/dc3-common/dc3-common-facade/dc3-common-facade-grpc/src/main/java/io/github/pnoker/common/facade/grpc/builder/FacadeGrpcPointValueBuilder.java b/dc3-common/dc3-common-facade/dc3-common-facade-grpc/src/main/java/io/github/pnoker/common/facade/grpc/builder/FacadeGrpcPointValueBuilder.java index 7662cea10..4e3d2420f 100644 --- a/dc3-common/dc3-common-facade/dc3-common-facade-grpc/src/main/java/io/github/pnoker/common/facade/grpc/builder/FacadeGrpcPointValueBuilder.java +++ b/dc3-common/dc3-common-facade/dc3-common-facade-grpc/src/main/java/io/github/pnoker/common/facade/grpc/builder/FacadeGrpcPointValueBuilder.java @@ -59,6 +59,7 @@ public class FacadeGrpcPointValueBuilder { } StringOptional.ofNullable(dto.getValue()).ifPresent(bo::setValue); StringOptional.ofNullable(dto.getRawValue()).ifPresent(bo::setRawValue); + bo.setNumValue(dto.getNumValue()); return bo; }