mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
fix(dashboard): count tool calls from assistant.metadata, not role=tool rows
This commit is contained in:
parent
8030a41125
commit
fe9610dfa6
@ -1,6 +1,8 @@
|
|||||||
package vip.mate.dashboard.service;
|
package vip.mate.dashboard.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -28,6 +30,7 @@ public class DashboardService {
|
|||||||
|
|
||||||
private final MessageMapper messageMapper;
|
private final MessageMapper messageMapper;
|
||||||
private final ConversationMapper conversationMapper;
|
private final ConversationMapper conversationMapper;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取概览统计(今日/本周/本月)— 实时查询
|
* 获取概览统计(今日/本周/本月)— 实时查询
|
||||||
@ -111,33 +114,30 @@ public class DashboardService {
|
|||||||
.ge(MessageEntity::getCreateTime, startTime)
|
.ge(MessageEntity::getCreateTime, startTime)
|
||||||
.le(MessageEntity::getCreateTime, endTime)
|
.le(MessageEntity::getCreateTime, endTime)
|
||||||
.eq(MessageEntity::getDeleted, 0)
|
.eq(MessageEntity::getDeleted, 0)
|
||||||
.select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens);
|
.select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens,
|
||||||
|
MessageEntity::getMetadata);
|
||||||
if (wsConversationIds != null) {
|
if (wsConversationIds != null) {
|
||||||
tokenWrapper.in(MessageEntity::getConversationId, wsConversationIds);
|
tokenWrapper.in(MessageEntity::getConversationId, wsConversationIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MessageEntity> assistantMessages = messageMapper.selectList(tokenWrapper);
|
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) {
|
for (MessageEntity m : assistantMessages) {
|
||||||
int pt = m.getPromptTokens() != null ? m.getPromptTokens() : 0;
|
int pt = m.getPromptTokens() != null ? m.getPromptTokens() : 0;
|
||||||
int ct = m.getCompletionTokens() != null ? m.getCompletionTokens() : 0;
|
int ct = m.getCompletionTokens() != null ? m.getCompletionTokens() : 0;
|
||||||
promptTokens += pt;
|
promptTokens += pt;
|
||||||
completionTokens += ct;
|
completionTokens += ct;
|
||||||
totalTokens += pt + 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<>();
|
Map<String, Object> stats = new LinkedHashMap<>();
|
||||||
stats.put("conversations", conversations);
|
stats.put("conversations", conversations);
|
||||||
stats.put("messages", messages);
|
stats.put("messages", messages);
|
||||||
@ -147,4 +147,51 @@ public class DashboardService {
|
|||||||
stats.put("toolCalls", toolCalls);
|
stats.put("toolCalls", toolCalls);
|
||||||
return stats;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user