refactor(agentic): split chat orchestration and structure tool results

This commit is contained in:
pnoker
2026-05-16 16:23:48 +08:00
parent e5a9088704
commit 20bb57df17
28 changed files with 2434 additions and 1113 deletions
@@ -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
@@ -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.
@@ -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);
}
@@ -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));
}
}
@@ -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();
}
}
@@ -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 "{}";
}
}
}
@@ -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());
}
}
@@ -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) {
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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()));
}
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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 "{}";
}
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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) {
@@ -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) {
}
}
@@ -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) {
@@ -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."
@@ -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);
}
}
@@ -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());
@@ -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)
*/
@@ -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;
}