From fe9610dfa6c00974de806414905d3f0dda5cfb64 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 24 May 2026 22:58:02 +0800 Subject: [PATCH] fix(dashboard): count tool calls from assistant.metadata, not role=tool rows --- .../dashboard/service/DashboardService.java | 73 ++++++++++++++---- .../DashboardServiceToolCallCountTest.java | 75 +++++++++++++++++++ 2 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/dashboard/service/DashboardServiceToolCallCountTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/service/DashboardService.java b/mateclaw-server/src/main/java/vip/mate/dashboard/service/DashboardService.java index 68e054c1..452b99ba 100644 --- a/mateclaw-server/src/main/java/vip/mate/dashboard/service/DashboardService.java +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/service/DashboardService.java @@ -1,6 +1,8 @@ package vip.mate.dashboard.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -28,6 +30,7 @@ public class DashboardService { private final MessageMapper messageMapper; private final ConversationMapper conversationMapper; + private final ObjectMapper objectMapper; /** * 获取概览统计(今日/本周/本月)— 实时查询 @@ -111,33 +114,30 @@ public class DashboardService { .ge(MessageEntity::getCreateTime, startTime) .le(MessageEntity::getCreateTime, endTime) .eq(MessageEntity::getDeleted, 0) - .select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens); + .select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens, + MessageEntity::getMetadata); if (wsConversationIds != null) { tokenWrapper.in(MessageEntity::getConversationId, wsConversationIds); } List assistantMessages = messageMapper.selectList(tokenWrapper); - long totalTokens = 0, promptTokens = 0, completionTokens = 0; + // Tool calls are not stored as standalone role="tool" rows; each agent + // turn records them inside the assistant message's metadata JSON + // (metadata.toolCalls, with metadata.segments[type=tool_call] as the + // streaming-timeline fallback). Counting role="tool" therefore always + // returned 0. We reuse the assistant messages already loaded for token + // accounting and sum the tool-call entries from each row's metadata. + long totalTokens = 0, promptTokens = 0, completionTokens = 0, toolCalls = 0; for (MessageEntity m : assistantMessages) { int pt = m.getPromptTokens() != null ? m.getPromptTokens() : 0; int ct = m.getCompletionTokens() != null ? m.getCompletionTokens() : 0; promptTokens += pt; completionTokens += ct; totalTokens += pt + ct; + toolCalls += countToolCalls(m.getMetadata()); } - // Tool 调用数(role = tool 的消息) - LambdaQueryWrapper toolWrapper = new LambdaQueryWrapper() - .eq(MessageEntity::getRole, "tool") - .ge(MessageEntity::getCreateTime, startTime) - .le(MessageEntity::getCreateTime, endTime) - .eq(MessageEntity::getDeleted, 0); - if (wsConversationIds != null) { - toolWrapper.in(MessageEntity::getConversationId, wsConversationIds); - } - long toolCalls = messageMapper.selectCount(toolWrapper); - Map stats = new LinkedHashMap<>(); stats.put("conversations", conversations); stats.put("messages", messages); @@ -147,4 +147,51 @@ public class DashboardService { stats.put("toolCalls", toolCalls); return stats; } + + /** + * Count tool invocations recorded on a single assistant message. + *

+ * Tool calls live in the message's {@code metadata} JSON, not as separate + * rows. The canonical list is {@code metadata.toolCalls}; older messages may + * only carry the streaming timeline, so we fall back to counting + * {@code metadata.segments} entries whose {@code type} is {@code tool_call}. + * Parsing failures are treated as zero so a malformed row never breaks the + * dashboard. + */ + long countToolCalls(String metadataJson) { + if (metadataJson == null || metadataJson.isBlank() || "{}".equals(metadataJson.trim())) { + return 0; + } + try { + // H2's JSON column hands the value back as a quoted JSON string + // literal (double-encoded); MySQL returns the object directly. Mirror + // MessageVO.parseMetadataToObject and unwrap one string layer first, + // otherwise readTree yields a TextNode and toolCalls is never found. + String json = metadataJson.trim(); + if (json.startsWith("\"") && json.endsWith("\"")) { + json = objectMapper.readValue(json, String.class); + } + if (json.isBlank() || "{}".equals(json)) { + return 0; + } + JsonNode root = objectMapper.readTree(json); + JsonNode toolCalls = root.get("toolCalls"); + if (toolCalls != null && toolCalls.isArray() && !toolCalls.isEmpty()) { + return toolCalls.size(); + } + JsonNode segments = root.get("segments"); + if (segments != null && segments.isArray()) { + long count = 0; + for (JsonNode seg : segments) { + if ("tool_call".equals(seg.path("type").asText())) { + count++; + } + } + return count; + } + } catch (Exception e) { + log.debug("Failed to parse message metadata for tool-call count: {}", e.getMessage()); + } + return 0; + } } diff --git a/mateclaw-server/src/test/java/vip/mate/dashboard/service/DashboardServiceToolCallCountTest.java b/mateclaw-server/src/test/java/vip/mate/dashboard/service/DashboardServiceToolCallCountTest.java new file mode 100644 index 00000000..422d1230 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/dashboard/service/DashboardServiceToolCallCountTest.java @@ -0,0 +1,75 @@ +package vip.mate.dashboard.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tool calls are persisted inside an assistant message's {@code metadata} JSON, + * never as standalone {@code role="tool"} rows. These tests pin the metadata + * parsing that feeds the dashboard's {@code toolCalls} metric. + */ +class DashboardServiceToolCallCountTest { + + private final DashboardService service = + new DashboardService(null, null, new ObjectMapper()); + + @Test + @DisplayName("Counts entries from metadata.toolCalls.") + void countsFromToolCallsArray() { + String metadata = "{\"toolCalls\":[" + + "{\"name\":\"search\",\"status\":\"completed\"}," + + "{\"name\":\"load_skill\",\"status\":\"completed\"}," + + "{\"name\":\"execute_shell_command\",\"status\":\"completed\"}" + + "],\"currentPhase\":\"reasoning\",\"finishReason\":\"normal\"}"; + assertEquals(3, service.countToolCalls(metadata)); + } + + @Test + @DisplayName("Unwraps H2's quoted JSON-string-literal form before counting.") + void unwrapsH2QuotedLiteral() throws Exception { + // H2's JSON column returns the value double-encoded: a quoted string + // literal whose body is the escaped JSON object. + String inner = "{\"toolCalls\":[{\"name\":\"a\"},{\"name\":\"b\"}]}"; + String h2Wrapped = new ObjectMapper().writeValueAsString(inner); // -> "\"{\\\"toolCalls\\\":...}\"" + assertEquals(2, service.countToolCalls(h2Wrapped)); + } + + @Test + @DisplayName("Falls back to segments[type=tool_call] when toolCalls is absent.") + void fallsBackToSegments() { + String metadata = "{\"segments\":[" + + "{\"type\":\"text\"}," + + "{\"type\":\"tool_call\",\"toolName\":\"a\"}," + + "{\"type\":\"thinking\"}," + + "{\"type\":\"tool_call\",\"toolName\":\"b\"}" + + "]}"; + assertEquals(2, service.countToolCalls(metadata)); + } + + @Test + @DisplayName("Prefers toolCalls over segments (no double counting).") + void prefersToolCallsOverSegments() { + String metadata = "{\"toolCalls\":[{\"name\":\"x\"}]," + + "\"segments\":[{\"type\":\"tool_call\"},{\"type\":\"tool_call\"}]}"; + assertEquals(1, service.countToolCalls(metadata)); + } + + @Test + @DisplayName("Returns 0 for null, blank, empty-object, or assistant text-only metadata.") + void zeroForNoToolCalls() { + assertEquals(0, service.countToolCalls(null)); + assertEquals(0, service.countToolCalls("")); + assertEquals(0, service.countToolCalls("{}")); + assertEquals(0, service.countToolCalls("{\"finishReason\":\"normal\"}")); + assertEquals(0, service.countToolCalls("{\"toolCalls\":[]}")); + } + + @Test + @DisplayName("Malformed JSON degrades to 0 instead of throwing.") + void malformedJsonIsZero() { + assertEquals(0, service.countToolCalls("{not valid json")); + } +}