mirror of
https://gitee.com/pnoker/iot-dc3.git
synced 2026-08-30 17:19:58 +08:00
refactor(agentic): split chat orchestration and structure tool results
This commit is contained in:
@@ -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
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
+87
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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.
|
||||
* <p>
|
||||
* 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));
|
||||
}
|
||||
|
||||
}
|
||||
+391
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Long> 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<String> 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<AgenticRequestContext.ToolEvent> toolEvents = new ConcurrentLinkedQueue<>();
|
||||
Map<String, Object> 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<AgenticMessageContent.Context> contexts = buildContexts(attachmentContext, directContext);
|
||||
String requestSystemContext = buildRequestSystemContext(contexts);
|
||||
List<MessageBO> 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<String, Object> buildToolContext(ChatCompletionRequest request, RequestHeader.UserHeader userHeader,
|
||||
String scopedConversationId,
|
||||
Queue<AgenticRequestContext.ToolEvent> toolEvents) {
|
||||
Map<String, Object> 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<String> normalizeToolNames(List<String> toolNames) {
|
||||
if (Objects.isNull(toolNames) || toolNames.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return toolNames.stream().filter(StringUtils::isNotBlank).distinct().toList();
|
||||
}
|
||||
|
||||
private String buildSkillSystemPrompt(SkillDefinition skill) {
|
||||
List<String> 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<AgenticMessageContent.Context> buildContexts(String attachmentContext, String directContext) {
|
||||
List<AgenticMessageContent.Context> 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<AgenticMessageContent.Context> contexts) {
|
||||
List<String> 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<AgenticMessageContent.Context> contexts,
|
||||
List<MessageBO> 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<AgenticMessageContent.Context> contexts) {
|
||||
if (StringUtils.isBlank(requestSystemContext)) {
|
||||
return "";
|
||||
}
|
||||
List<String> 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<MessageBO> 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<MessageBO> 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<Long> normalizeAttachments(ChatCompletionRequest request) {
|
||||
if (Objects.isNull(request.getAttachments()) || request.getAttachments().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return request.getAttachments().stream().filter(Objects::nonNull).distinct().toList();
|
||||
}
|
||||
|
||||
}
|
||||
+250
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String> 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<ServerSentEvent<String>> initialEvents(AgenticPreparedChatRequest prepared) {
|
||||
List<ServerSentEvent<String>> 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.<String>builder()
|
||||
.data(formatEvent("skill", "Auto skill", skillDescription, skillName))
|
||||
.build());
|
||||
if (StringUtils.isBlank(prepared.directAnswer()) && prepared.toolCallingEnabled()
|
||||
&& !prepared.toolNames().isEmpty()) {
|
||||
events.add(ServerSentEvent.<String>builder()
|
||||
.data(formatEvent("tools", "Available tools", String.join(", ", prepared.toolNames()), skillName))
|
||||
.build());
|
||||
}
|
||||
if (prepared.directContextProvided()) {
|
||||
events.add(ServerSentEvent.<String>builder()
|
||||
.data(formatEvent("tool", "Backend context loaded", "Queried DC3 backend before response",
|
||||
skillName))
|
||||
.build());
|
||||
}
|
||||
if (prepared.reasoning()) {
|
||||
events.add(ServerSentEvent.<String>builder()
|
||||
.data(formatEvent("reasoning", "Thinking", "Reasoning mode requested for this model.", skillName))
|
||||
.build());
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
public List<ServerSentEvent<String>> chunkEvents(AgenticPreparedChatRequest prepared, String chatId, long created,
|
||||
AgenticStreamDelta streamDelta) {
|
||||
List<ServerSentEvent<String>> events = new ArrayList<>();
|
||||
AgenticRequestContext.ToolEvent event = prepared.toolEvents().poll();
|
||||
while (Objects.nonNull(event)) {
|
||||
prepared.toolTraceEvents().add(event);
|
||||
events.add(ServerSentEvent.<String>builder()
|
||||
.data(formatEvent("tool", event.description(), event.domain(), event.toolName()))
|
||||
.build());
|
||||
event = prepared.toolEvents().poll();
|
||||
}
|
||||
events.add(ServerSentEvent.<String>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<String, Object> 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<String> 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 "{}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+146
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<AgenticRequestContext.ToolEvent> toolEvents = drainToolEvents(prepared);
|
||||
List<String> 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<AgenticMessageContent.Trace> buildTraceEvents(AgenticPreparedChatRequest prepared,
|
||||
List<AgenticRequestContext.ToolEvent> toolEvents) {
|
||||
List<AgenticMessageContent.Trace> 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<AgenticRequestContext.ToolEvent> 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<String> skillNames(AgenticPreparedChatRequest prepared) {
|
||||
return StringUtils.isBlank(prepared.skill()) ? List.of() : List.of(prepared.skill());
|
||||
}
|
||||
|
||||
}
|
||||
+45
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String> toolNames, String model,
|
||||
String skill, Map<String, Object> toolContext, Double temperature,
|
||||
Integer maxTokens, SkillDefinition skillDefinition,
|
||||
Queue<AgenticRequestContext.ToolEvent> toolEvents,
|
||||
boolean toolCallingEnabled, boolean reasoning,
|
||||
boolean directContextProvided, List<Long> attachments,
|
||||
List<AgenticMessageContent.Context> contexts,
|
||||
AgenticMessageContent.Tokens inputTokens,
|
||||
List<AgenticRequestContext.ToolEvent> toolTraceEvents,
|
||||
String directAnswer) {
|
||||
}
|
||||
+99
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String> 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);
|
||||
}
|
||||
|
||||
}
|
||||
+38
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+90
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<AgenticRequestContext.ToolEvent> 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<AgenticRequestContext.ToolEvent> toolEvents, String toolName, String domain,
|
||||
String description) {
|
||||
if (Objects.nonNull(toolEvents)) {
|
||||
toolEvents.offer(new AgenticRequestContext.ToolEvent(toolName, domain, description,
|
||||
Instant.now().toEpochMilli()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+385
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<AgenticRequestContext.ToolEvent> 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<FacadeDeviceBO> devices = deviceFacade.selectByPage(deviceQuery);
|
||||
FacadeDriverQuery driverQuery = new FacadeDriverQuery();
|
||||
driverQuery.setTenantId(userHeader.getTenantId());
|
||||
driverQuery.setPage(page(1, 10));
|
||||
FacadePage<FacadeDriverBO> drivers = driverFacade.selectByPage(driverQuery);
|
||||
FacadePointQuery pointQuery = new FacadePointQuery();
|
||||
pointQuery.setTenantId(userHeader.getTenantId());
|
||||
pointQuery.setPage(page(1, 10));
|
||||
FacadePage<FacadePointBO> 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<AgenticRequestContext.ToolEvent> 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<String> 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<DirectAnswer.Field> 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<DirectAnswer.Table> tables = new ArrayList<>();
|
||||
List<DirectAnswer.Chart> charts = new ArrayList<>();
|
||||
String message = null;
|
||||
if (!history.isEmpty()) {
|
||||
String title = "最新 " + history.size() + " 条历史值";
|
||||
if (history.size() == count) {
|
||||
title += "(后端返回顺序:新到旧)";
|
||||
}
|
||||
List<List<String>> 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<FacadeDeviceBO> 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<FacadePointBO> 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 <T> void addRecords(List<T> target, FacadePage<T> page) {
|
||||
if (Objects.nonNull(page) && Objects.nonNull(page.getRecords())) {
|
||||
target.addAll(page.getRecords());
|
||||
}
|
||||
}
|
||||
|
||||
private FacadeDeviceBO uniqueDeviceMatch(List<FacadeDeviceBO> candidates, DirectQueryRequest directQuery) {
|
||||
List<FacadeDeviceBO> matches = candidates.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(device -> deviceMatchesSelector(device, directQuery))
|
||||
.toList();
|
||||
return uniqueById(matches);
|
||||
}
|
||||
|
||||
private FacadePointBO uniquePointMatch(List<FacadePointBO> candidates, DirectQueryRequest directQuery) {
|
||||
List<FacadePointBO> 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 extends BaseBO> T uniqueById(List<T> candidates) {
|
||||
Map<Long, T> 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<String> history) {
|
||||
List<List<Number>> 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<AgenticRequestContext.ToolEvent> 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<FacadeDeviceBO> page) {
|
||||
if (Objects.isNull(page) || page.getRecords().isEmpty()) {
|
||||
return "none";
|
||||
}
|
||||
return page.getRecords().stream()
|
||||
.limit(5)
|
||||
.map(FacadeDeviceBO::getDeviceName)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.toList()
|
||||
.toString();
|
||||
}
|
||||
|
||||
}
|
||||
+95
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<AgenticRequestContext.ToolEvent> toolEvents) {
|
||||
offerToolEvent(toolEvents, "searchDevices", "manager", "Load tenant device snapshot");
|
||||
FacadeDeviceQuery query = new FacadeDeviceQuery();
|
||||
query.setTenantId(userHeader.getTenantId());
|
||||
query.setPage(page(1, 50));
|
||||
FacadePage<FacadeDeviceBO> page = deviceFacade.selectByPage(query);
|
||||
if (Objects.isNull(page) || Objects.isNull(page.getRecords()) || page.getRecords().isEmpty()) {
|
||||
return DirectBackendResult.direct(DirectAnswer.message("设备查询结果", "当前租户下没有查询到设备。"));
|
||||
}
|
||||
List<List<String>> 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<AgenticRequestContext.ToolEvent> 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;
|
||||
}
|
||||
|
||||
}
|
||||
+74
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Field> fields, List<Table> tables, List<Chart> 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<Field> fields, List<Table> tables,
|
||||
List<Chart> charts) {
|
||||
return new DirectAnswer(title, message, fields, tables, charts);
|
||||
}
|
||||
|
||||
public record Field(String name, String value) {
|
||||
}
|
||||
|
||||
public record Table(String title, List<String> headers, List<List<String>> 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> series) {
|
||||
|
||||
public Chart {
|
||||
series = List.copyOf(series == null ? List.of() : series);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public record Series(String name, List<List<Number>> data) {
|
||||
|
||||
public Series {
|
||||
data = List.copyOf(data == null ? List.of() : data);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+124
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String> 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<String, Object> 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 "{}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+40
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package io.github.pnoker.common.agentic.service.direct;
|
||||
|
||||
/**
|
||||
* Backend data prepared before the model call.
|
||||
* <p>
|
||||
* {@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);
|
||||
}
|
||||
|
||||
}
|
||||
+66
-820
@@ -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<ServerSentEvent<String>> 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<String> lastFinishReason = new AtomicReference<>();
|
||||
|
||||
Flux<StreamDelta> contentFlux = promptSpec.stream().chatResponse()
|
||||
Flux<AgenticStreamDelta> 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<ServerSentEvent<String>> initialEvents = Flux.fromIterable(initialEvents(prepared));
|
||||
Flux<ServerSentEvent<String>> initialEvents = Flux.fromIterable(responseCodec.initialEvents(prepared));
|
||||
Flux<ServerSentEvent<String>> 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.<String>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.<String>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.<String>builder().data("[DONE]").build()));
|
||||
}).doFinally(signalType -> AgenticRequestContext.clear()).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private Flux<ServerSentEvent<String>> streamDirectAnswer(AgenticPreparedChatRequest prepared,
|
||||
RequestHeader.UserHeader userHeader) {
|
||||
String chatId = responseCodec.newChatId();
|
||||
long created = Instant.now().getEpochSecond();
|
||||
String content = prepared.directAnswer();
|
||||
List<ServerSentEvent<String>> events = new ArrayList<>();
|
||||
events.addAll(responseCodec.initialEvents(prepared));
|
||||
events.addAll(responseCodec.chunkEvents(prepared, chatId, created, new AgenticStreamDelta(content, null)));
|
||||
events.add(ServerSentEvent.<String>builder()
|
||||
.data(responseCodec.formatFinalChunk(chatId, created, prepared.model(), "stop"))
|
||||
.build());
|
||||
events.add(ServerSentEvent.<String>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<ChatCompletionResponse> 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<Long> 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<String> 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<AgenticRequestContext.ToolEvent> toolEvents = new ConcurrentLinkedQueue<>();
|
||||
Map<String, Object> 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<AgenticMessageContent.Context> contexts = buildContexts(attachmentContext, directContext);
|
||||
String requestSystemContext = buildRequestSystemContext(contexts);
|
||||
List<MessageBO> 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<String> normalizeToolNames(List<String> toolNames) {
|
||||
if (Objects.isNull(toolNames) || toolNames.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return toolNames.stream().filter(StringUtils::isNotBlank).distinct().toList();
|
||||
}
|
||||
|
||||
private String buildSkillSystemPrompt(SkillDefinition skill) {
|
||||
List<String> 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<AgenticMessageContent.Context> buildContexts(String attachmentContext, String directContext) {
|
||||
List<AgenticMessageContent.Context> 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<AgenticMessageContent.Context> contexts) {
|
||||
List<String> 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<AgenticMessageContent.Context> contexts,
|
||||
List<MessageBO> 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<AgenticMessageContent.Context> contexts) {
|
||||
if (StringUtils.isBlank(requestSystemContext)) {
|
||||
return "";
|
||||
}
|
||||
List<String> 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<MessageBO> 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<MessageBO> 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<String> 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<String> 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<ServerSentEvent<String>> initialEvents(PreparedChatRequest prepared) {
|
||||
List<ServerSentEvent<String>> 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.<String>builder()
|
||||
.data(formatEvent("skill", "Auto skill", skillDescription, skillName))
|
||||
.build());
|
||||
if (prepared.toolCallingEnabled() && !prepared.toolNames().isEmpty()) {
|
||||
events.add(ServerSentEvent.<String>builder()
|
||||
.data(formatEvent("tools", "Available tools", String.join(", ", prepared.toolNames()), skillName))
|
||||
.build());
|
||||
}
|
||||
if (prepared.directContextProvided()) {
|
||||
events.add(ServerSentEvent.<String>builder()
|
||||
.data(formatEvent("tool", "Backend context loaded", "Queried DC3 backend before model response",
|
||||
skillName))
|
||||
.build());
|
||||
}
|
||||
if (prepared.reasoning()) {
|
||||
events.add(ServerSentEvent.<String>builder()
|
||||
.data(formatEvent("reasoning", "Thinking", "Reasoning mode requested for this model.", skillName))
|
||||
.build());
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private List<ServerSentEvent<String>> chunkEvents(PreparedChatRequest prepared, String chatId, long created,
|
||||
StreamDelta streamDelta) {
|
||||
List<ServerSentEvent<String>> events = new ArrayList<>();
|
||||
AgenticRequestContext.ToolEvent event = prepared.toolEvents().poll();
|
||||
while (Objects.nonNull(event)) {
|
||||
prepared.toolTraceEvents().add(event);
|
||||
events.add(ServerSentEvent.<String>builder()
|
||||
.data(formatEvent("tool", event.description(), event.domain(), event.toolName()))
|
||||
.build());
|
||||
event = prepared.toolEvents().poll();
|
||||
}
|
||||
events.add(ServerSentEvent.<String>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<String> reasoningContent = value.asString();
|
||||
return reasoningContent.orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String formatEvent(String type, String title, String detail, String name) {
|
||||
Map<String, Object> 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<AgenticRequestContext.ToolEvent> 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<AgenticRequestContext.ToolEvent> toolEvents) {
|
||||
FacadeDeviceQuery query = new FacadeDeviceQuery();
|
||||
query.setTenantId(userHeader.getTenantId());
|
||||
query.setPage(page(1, 50));
|
||||
FacadePage<FacadeDeviceBO> 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<AgenticRequestContext.ToolEvent> toolEvents) {
|
||||
FacadeDeviceQuery deviceQuery = new FacadeDeviceQuery();
|
||||
deviceQuery.setTenantId(userHeader.getTenantId());
|
||||
deviceQuery.setPage(page(1, 10));
|
||||
FacadePage<FacadeDeviceBO> devices = deviceFacade.selectByPage(deviceQuery);
|
||||
FacadeDriverQuery driverQuery = new FacadeDriverQuery();
|
||||
driverQuery.setTenantId(userHeader.getTenantId());
|
||||
driverQuery.setPage(page(1, 10));
|
||||
FacadePage<FacadeDriverBO> drivers = driverFacade.selectByPage(driverQuery);
|
||||
FacadePointQuery pointQuery = new FacadePointQuery();
|
||||
pointQuery.setTenantId(userHeader.getTenantId());
|
||||
pointQuery.setPage(page(1, 10));
|
||||
FacadePage<FacadePointBO> 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<FacadeDeviceBO> 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<AgenticRequestContext.ToolEvent> toolEvents = drainToolEvents(prepared);
|
||||
List<String> 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<AgenticMessageContent.Trace> buildTraceEvents(PreparedChatRequest prepared,
|
||||
List<AgenticRequestContext.ToolEvent> toolEvents) {
|
||||
List<AgenticMessageContent.Trace> 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<AgenticRequestContext.ToolEvent> 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<String> skillNames(PreparedChatRequest prepared) {
|
||||
return StringUtils.isBlank(prepared.skill()) ? List.of() : List.of(prepared.skill());
|
||||
}
|
||||
|
||||
private List<Long> 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<String> toolNames, String model, String skill,
|
||||
Map<String, Object> toolContext, Double temperature, Integer maxTokens,
|
||||
SkillDefinition skillDefinition,
|
||||
Queue<AgenticRequestContext.ToolEvent> toolEvents, boolean toolCallingEnabled,
|
||||
boolean reasoning, boolean directContextProvided, List<Long> attachments,
|
||||
List<AgenticMessageContent.Context> contexts,
|
||||
AgenticMessageContent.Tokens inputTokens,
|
||||
List<AgenticRequestContext.ToolEvent> toolTraceEvents) {
|
||||
}
|
||||
|
||||
private record StreamDelta(String content, String reasoningContent) {
|
||||
|
||||
static StreamDelta empty() {
|
||||
return new StreamDelta("", null);
|
||||
}
|
||||
|
||||
boolean hasContent() {
|
||||
return StringUtils.isNotEmpty(content) || StringUtils.isNotEmpty(reasoningContent);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+52
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<T>(boolean success, String code, String message, T data) {
|
||||
|
||||
public static <T> AgenticToolResult<T> ok(String message, T data) {
|
||||
return new AgenticToolResult<>(true, "OK", message, data);
|
||||
}
|
||||
|
||||
public static <T> AgenticToolResult<T> empty(String message, T data) {
|
||||
return new AgenticToolResult<>(true, "EMPTY", message, data);
|
||||
}
|
||||
|
||||
public static <T> AgenticToolResult<T> invalid(String message) {
|
||||
return new AgenticToolResult<>(false, "INVALID_ARGUMENT", message, null);
|
||||
}
|
||||
|
||||
public static <T> AgenticToolResult<T> notFound(String message) {
|
||||
return new AgenticToolResult<>(false, "NOT_FOUND", message, null);
|
||||
}
|
||||
|
||||
public static <T> AgenticToolResult<T> unavailable(String message) {
|
||||
return new AgenticToolResult<>(false, "UNAVAILABLE", message, null);
|
||||
}
|
||||
|
||||
public static <T> AgenticToolResult<T> error(String message) {
|
||||
return new AgenticToolResult<>(false, "ERROR", message, null);
|
||||
}
|
||||
|
||||
}
|
||||
+9
-5
@@ -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.
|
||||
* <p>
|
||||
@@ -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<Map<String, Long>> 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<Map<String, Object>> 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) {
|
||||
|
||||
+104
-79
@@ -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<FacadePointValueBO> 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<PointValueHistory> 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<String> history = pointValueFacade.history(tenantId, deviceId, pointId, count);
|
||||
List<String> 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<String> history) {
|
||||
StringBuilder dataPoints = new StringBuilder();
|
||||
private HistoryChart buildHistoryChart(Long deviceId, Long pointId, List<String> history) {
|
||||
List<List<Number>> 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<DeviceLatestPointValues> 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<FacadePointBO> 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<PointLatestValue> 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<PointCommandResult> 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<PointCommandResult> 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<String> 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<ChartSeries> series) {
|
||||
|
||||
public HistoryChart {
|
||||
series = List.copyOf(Objects.requireNonNullElse(series, List.of()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public record ChartSeries(String name, List<List<Number>> data) {
|
||||
|
||||
public ChartSeries {
|
||||
data = List.copyOf(Objects.requireNonNullElse(data, List.of()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public record DeviceLatestPointValues(FacadeDeviceBO device, List<PointLatestValue> 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) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+79
-101
@@ -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<FacadeDeviceBO> 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<Long> deviceIds,
|
||||
ToolContext toolContext) {
|
||||
public AgenticToolResult<List<FacadeDeviceBO>> lookupDevicesByIds(
|
||||
@ToolParam(description = "The numeric device IDs") List<Long> deviceIds,
|
||||
ToolContext toolContext) {
|
||||
Long tenantId = AgenticRequestContext.requireTenantId(toolContext);
|
||||
List<Long> 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<FacadeDeviceBO> 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<FacadePage<FacadeDeviceBO>> 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<FacadeDeviceBO> 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<List<FacadeDeviceBO>> 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<FacadeDeviceBO> 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<List<FacadeDeviceBO>> 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<FacadeDeviceBO> 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<FacadeDriverBO> 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<Long> driverIds,
|
||||
ToolContext toolContext) {
|
||||
public AgenticToolResult<List<FacadeDriverBO>> lookupDriversByIds(
|
||||
@ToolParam(description = "The numeric driver IDs") List<Long> driverIds,
|
||||
ToolContext toolContext) {
|
||||
Long tenantId = AgenticRequestContext.requireTenantId(toolContext);
|
||||
List<Long> 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<FacadeDriverBO> 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<FacadeDriverBO> 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<FacadePage<FacadeDriverBO>> 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<FacadeDriverBO> 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<FacadePointBO> 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<Long> pointIds,
|
||||
ToolContext toolContext) {
|
||||
public AgenticToolResult<List<FacadePointBO>> lookupPointsByIds(
|
||||
@ToolParam(description = "The numeric point IDs") List<Long> pointIds,
|
||||
ToolContext toolContext) {
|
||||
Long tenantId = AgenticRequestContext.requireTenantId(toolContext);
|
||||
List<Long> 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<FacadePointBO> 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<FacadePage<FacadePointBO>> 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<FacadePointBO> 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<FacadePage<FacadePointBO>> 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<FacadePointBO> 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<FacadePage<FacadePointBO>> 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<FacadePointBO> 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<FacadeDeviceBO> 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<FacadeDriverBO> 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<FacadePointBO> 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) {
|
||||
|
||||
+71
-103
@@ -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<FacadeProfileBO> 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<Long> profileIds,
|
||||
ToolContext toolContext) {
|
||||
public AgenticToolResult<List<FacadeProfileBO>> lookupProfilesByIds(
|
||||
@ToolParam(description = "The numeric profile/template IDs") List<Long> profileIds,
|
||||
ToolContext toolContext) {
|
||||
Long tenantId = AgenticRequestContext.requireTenantId(toolContext);
|
||||
List<Long> 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<FacadeProfileBO> 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<FacadePage<FacadeProfileBO>> 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<FacadeProfileBO> 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<List<FacadeProfileBO>> 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<FacadeProfileBO> 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<Long> deviceIds,
|
||||
ToolContext toolContext) {
|
||||
public AgenticToolResult<Map<Long, String>> getDeviceStatusesByIds(
|
||||
@ToolParam(description = "The numeric device IDs") List<Long> deviceIds,
|
||||
ToolContext toolContext) {
|
||||
Long tenantId = AgenticRequestContext.requireTenantId(toolContext);
|
||||
List<Long> 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<Long, String> 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<Map<Long, String>> 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<Long, String> 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<Long> driverIds,
|
||||
ToolContext toolContext) {
|
||||
public AgenticToolResult<Map<Long, String>> getDriverStatusesByIds(
|
||||
@ToolParam(description = "The numeric driver IDs") List<Long> driverIds,
|
||||
ToolContext toolContext) {
|
||||
Long tenantId = AgenticRequestContext.requireTenantId(toolContext);
|
||||
List<Long> 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<Long, String> 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<Map<String, String>> 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<String, String> 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<FacadeSystemHealthBO> 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<FacadeProfileBO> 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<Long, String> 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<Long> normalizeIds(List<Long> 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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
+168
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<AgenticRequestContext.ToolEvent> 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 <T> FacadePage<T> page(List<T> records) {
|
||||
return new FacadePage<>(1L, records.size(), records.size(), 1L, records);
|
||||
}
|
||||
|
||||
}
|
||||
+1
@@ -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());
|
||||
|
||||
+5
@@ -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)
|
||||
*/
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user