fix(dashboard): count tool calls from assistant.metadata, not role=tool rows

This commit is contained in:
matevip 2026-05-24 22:58:02 +08:00
parent 8030a41125
commit fe9610dfa6
2 changed files with 135 additions and 13 deletions

View File

@ -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<MessageEntity> 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<MessageEntity> toolWrapper = new LambdaQueryWrapper<MessageEntity>()
.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<String, Object> 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.
* <p>
* 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;
}
}

View File

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