feat(trace): improve session workbench review

This commit is contained in:
zhuyongxin
2026-07-07 19:06:18 +08:00
parent 3b62a8940c
commit 0ee27eb523
9 changed files with 895 additions and 104 deletions
@@ -0,0 +1,19 @@
package com.superbiz.agent.controller;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import java.time.Duration;
@Controller
public class FaviconController {
@GetMapping("/favicon.ico")
public ResponseEntity<Void> favicon() {
return ResponseEntity.noContent()
.cacheControl(CacheControl.maxAge(Duration.ofDays(1)))
.build();
}
}
@@ -17,6 +17,11 @@ public interface AgentStepRepository extends JpaRepository<AgentStep, Long> {
*/
List<AgentStep> findBySessionIdOrderByStepIndex(String sessionId);
/**
* 根据会话ID查询所有步骤,调用方负责按具体视图语义排序。
*/
List<AgentStep> findBySessionId(String sessionId);
/**
* 统计某个会话的步骤数
*/
@@ -13,6 +13,7 @@ import com.superbiz.agent.repository.ToolInvocationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@@ -31,7 +32,7 @@ public class DiagnosisTraceService {
public DiagnosisTraceResponse getTrace(String sessionId) {
DiagnosisSession session = diagnosisSessionRepository.findBySessionId(sessionId)
.orElseThrow(() -> new SessionNotFoundException(sessionId));
List<AgentStep> steps = agentStepRepository.findBySessionIdOrderByStepIndex(sessionId);
List<AgentStep> steps = orderStepsForTrace(agentStepRepository.findBySessionId(sessionId));
List<ToolInvocation> toolInvocations = toolInvocationRepository.findBySessionIdOrderByIdAsc(sessionId);
return DiagnosisTraceResponse.builder()
@@ -42,6 +43,15 @@ public class DiagnosisTraceService {
.build();
}
private List<AgentStep> orderStepsForTrace(List<AgentStep> steps) {
return steps.stream()
.sorted(Comparator
.comparing(AgentStep::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(AgentStep::getId, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(AgentStep::getStepIndex, Comparator.nullsLast(Comparator.naturalOrder())))
.toList();
}
private DiagnosisTraceResponse.SessionTrace toSessionTrace(DiagnosisSession session) {
return DiagnosisTraceResponse.SessionTrace.builder()
.id(session.getId())
+50 -2
View File
@@ -8,6 +8,7 @@ class SuperBizAgentApp {
this.currentChatHistory = []; // 当前对话的消息历史
this.chatHistories = this.loadChatHistories(); // 所有历史对话
this.isCurrentChatFromHistory = false; // 标记当前对话是否是从历史记录加载的
this.lastTraceSessionId = this.loadLastTraceSessionId();
this.initializeElements();
this.bindEvents();
@@ -15,6 +16,7 @@ class SuperBizAgentApp {
this.initMarkdown();
this.checkAndSetCentered();
this.renderChatHistory();
this.updateTraceWorkbenchLink();
}
// 初始化Markdown配置
@@ -116,6 +118,7 @@ class SuperBizAgentApp {
this.chatContainer = document.querySelector('.chat-container');
this.welcomeGreeting = document.getElementById('welcomeGreeting');
this.chatHistoryList = document.getElementById('chatHistoryList');
this.traceWorkbenchLink = document.getElementById('traceWorkbenchLink');
// 初始化时检查是否需要居中
this.checkAndSetCentered();
@@ -265,6 +268,7 @@ class SuperBizAgentApp {
// 生成新的会话ID
this.sessionId = this.generateSessionId();
this.updateTraceWorkbenchLink();
// 重置模式为快速
this.currentMode = 'quick';
@@ -312,6 +316,7 @@ class SuperBizAgentApp {
// 添加到历史记录列表的开头
this.chatHistories.unshift(chatHistory);
this.rememberTraceSessionId(this.sessionId);
// 限制历史记录数量(最多保存50条)
if (this.chatHistories.length > 50) {
@@ -439,6 +444,7 @@ class SuperBizAgentApp {
// 加载历史对话
this.sessionId = history.id;
this.rememberTraceSessionId(this.sessionId);
this.currentChatHistory = [...history.messages];
this.isCurrentChatFromHistory = true; // 标记为从历史记录加载
@@ -468,10 +474,45 @@ class SuperBizAgentApp {
this.chatMessages.innerHTML = '';
}
this.sessionId = this.generateSessionId();
this.updateTraceWorkbenchLink();
this.checkAndSetCentered();
}
}
loadLastTraceSessionId() {
try {
return localStorage.getItem('lastTraceSessionId') || '';
} catch (e) {
return '';
}
}
rememberTraceSessionId(sessionId) {
if (!sessionId) {
return;
}
this.lastTraceSessionId = sessionId;
try {
localStorage.setItem('lastTraceSessionId', sessionId);
} catch (e) {
// localStorage may be unavailable in private or restricted contexts.
}
this.updateTraceWorkbenchLink();
}
updateTraceWorkbenchLink() {
if (!this.traceWorkbenchLink) {
return;
}
const recentHistory = this.chatHistories.find(history => history && history.id);
const sessionId = this.lastTraceSessionId
|| (this.currentChatHistory.length > 0 ? this.sessionId : '')
|| (recentHistory ? recentHistory.id : '');
this.traceWorkbenchLink.href = sessionId
? `trace.html?sessionId=${encodeURIComponent(sessionId)}`
: 'trace.html';
}
// 切换模式下拉菜单
toggleModeDropdown() {
if (this.modeSelectorBtn && this.modeDropdown) {
@@ -637,6 +678,7 @@ class SuperBizAgentApp {
// 保存后端返回的 sessionId,用于 feedback 提交
if (chatResponse.sessionId) {
this.lastSessionId = chatResponse.sessionId;
this.rememberTraceSessionId(chatResponse.sessionId);
}
// 成功:添加实际响应消息(即使 answer 为空也显示)
const answer = chatResponse.answer || '(无回复内容)';
@@ -1231,7 +1273,10 @@ class SuperBizAgentApp {
for (const jsonStr of matches) {
try {
const sseMessage = JSON.parse(jsonStr);
if (sseMessage.type === 'content') {
if (sseMessage.type === 'session') {
this.lastSessionId = sseMessage.data;
this.rememberTraceSessionId(sseMessage.data);
} else if (sseMessage.type === 'content') {
fullResponse += sseMessage.data || '';
} else if (sseMessage.type === 'done') {
console.log('AI Ops 流完成,最终内容长度:', fullResponse.length);
@@ -1261,7 +1306,10 @@ class SuperBizAgentApp {
try {
const sseMessage = JSON.parse(rawData);
if (sseMessage && sseMessage.type) {
if (sseMessage.type === 'content') {
if (sseMessage.type === 'session') {
this.lastSessionId = sseMessage.data;
this.rememberTraceSessionId(sseMessage.data);
} else if (sseMessage.type === 'content') {
fullResponse += sseMessage.data || '';
if (loadingMessageElement) {
this.updateAIOpsStreamContent(loadingMessageElement, fullResponse);
+4 -3
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>智能OnCall助手</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="styles.css">
<!-- Markdown 渲染库 -->
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
@@ -34,12 +35,12 @@
<span style="font-size: 14px; font-weight: 500;">文档管理</span>
</a>
<a href="trace.html" class="sidebar-btn" style="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 12px; color: #202124; text-decoration: none; transition: background 0.3s ease; margin-top: 8px;">
<a href="trace.html" id="traceWorkbenchLink" class="sidebar-btn" style="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 12px; color: #202124; text-decoration: none; transition: background 0.3s ease; margin-top: 8px;">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 20px; height: 20px; flex-shrink: 0;">
<path d="M4 19V5M4 5H20M4 12H16M4 19H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 5V19M16 5V19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span style="font-size: 14px; font-weight: 500;">Trace Workbench</span>
<span style="font-size: 14px; font-weight: 500;">链路检查台</span>
</a>
<div class="chat-history-section">
@@ -142,6 +143,6 @@
</div>
</div>
<script src="app.js"></script>
<script src="app.js?v=20260707-session-picker"></script>
</body>
</html>
+221 -3
View File
@@ -123,11 +123,16 @@ svg {
gap: 8px;
}
.session-picker {
position: relative;
min-width: 0;
}
.session-control input {
width: 100%;
min-width: 0;
height: 42px;
padding: 0 12px;
padding: 0 46px 0 12px;
border: 1px solid #4b5563;
border-radius: 6px;
background: #0b1220;
@@ -135,7 +140,76 @@ svg {
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
}
.session-control button {
.session-picker-button {
position: absolute;
top: 4px;
right: 4px;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid #334155;
border-radius: 5px;
background: #111827;
color: #cbd5e1;
}
.session-options {
position: absolute;
z-index: 20;
top: 48px;
left: 0;
right: 0;
max-height: 320px;
overflow: auto;
padding: 6px;
border: 1px solid #4b5563;
border-radius: 8px;
background: #0b1220;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.32);
}
.session-option {
width: 100%;
display: grid;
gap: 4px;
padding: 10px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--white);
text-align: left;
}
.session-option:hover,
.session-option.active {
border-color: #2f8375;
background: rgba(31, 122, 107, 0.18);
}
.session-option-title {
font-size: 13px;
font-weight: 720;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-option-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: #94a3b8;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 11px;
}
.session-option-empty {
padding: 12px;
color: #94a3b8;
font-size: 13px;
}
.session-control > button {
height: 42px;
display: inline-flex;
align-items: center;
@@ -149,7 +223,7 @@ svg {
font-weight: 700;
}
.session-control button:disabled {
.session-control > button:disabled {
opacity: 0.62;
cursor: wait;
}
@@ -176,6 +250,132 @@ svg {
background: var(--evidence-soft);
}
.outcome-panel {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 0;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: var(--white);
}
.outcome-main {
min-width: 0;
padding: 20px;
border-left: 7px solid var(--steel);
}
.outcome-panel.ok .outcome-main {
border-left-color: var(--evidence);
}
.outcome-panel.warn .outcome-main {
border-left-color: var(--warn);
}
.outcome-panel.bad .outcome-main {
border-left-color: var(--reject);
}
.outcome-eyebrow {
margin: 0 0 8px;
color: var(--steel);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 12px;
text-transform: uppercase;
}
.outcome-main h2 {
margin: 0;
font-size: clamp(24px, 3vw, 38px);
line-height: 1.08;
letter-spacing: 0;
}
.outcome-main > p:not(.outcome-eyebrow) {
margin: 10px 0 0;
color: var(--steel);
line-height: 1.5;
}
.outcome-answer {
margin-top: 16px;
padding: 14px 16px;
max-height: 180px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 6px;
background: #fffaf1;
color: #172033;
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.outcome-side {
display: grid;
grid-template-rows: auto 1fr;
border-left: 1px solid var(--line);
background: #fbf7ef;
}
.outcome-verdict {
padding: 18px;
border-bottom: 1px solid var(--line);
}
.outcome-verdict span,
.outcome-insights h3 {
color: var(--steel);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 12px;
text-transform: uppercase;
}
.outcome-verdict strong {
display: block;
margin-top: 6px;
font-size: 30px;
line-height: 1.05;
}
.outcome-verdict p {
margin: 10px 0 0;
color: var(--ink-soft);
line-height: 1.5;
}
.outcome-insights {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.outcome-insights > div {
min-width: 0;
padding: 16px 18px;
}
.outcome-insights > div + div {
border-left: 1px solid var(--line);
}
.outcome-insights h3 {
margin: 0 0 10px;
}
.outcome-insights ul {
margin: 0;
padding-left: 18px;
}
.outcome-insights li {
margin-bottom: 8px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(7, minmax(120px, 1fr));
@@ -547,6 +747,15 @@ pre,
}
@media (max-width: 1180px) {
.outcome-panel {
grid-template-columns: 1fr;
}
.outcome-side {
border-left: 0;
border-top: 1px solid var(--line);
}
.summary-strip {
grid-template-columns: repeat(4, minmax(120px, 1fr));
}
@@ -581,6 +790,15 @@ pre,
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.outcome-insights {
grid-template-columns: 1fr;
}
.outcome-insights > div + div {
border-left: 0;
border-top: 1px solid var(--line);
}
.summary-cell,
.summary-cell:nth-child(4) {
border-right: 1px solid var(--line);
+65 -30
View File
@@ -1,67 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trace Workbench - SuperBizAgent</title>
<link rel="stylesheet" href="trace.css">
<title>诊断链路检查台 - SuperBizAgent</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="trace.css?v=20260707-outcome">
</head>
<body>
<main class="trace-app">
<header class="trace-header">
<div>
<a class="trace-back-link" href="index.html" aria-label="Back to chat">
<a class="trace-back-link" href="index.html" aria-label="返回聊天">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M19 12H5M12 19L5 12L12 5" />
</svg>
<span>Chat</span>
<span>返回聊天</span>
</a>
<h1>Trace workbench</h1>
<h1>诊断链路检查台</h1>
</div>
<form class="session-form" id="sessionForm">
<label for="sessionIdInput">Session id</label>
<label for="sessionIdInput">会话 ID</label>
<div class="session-control">
<input id="sessionIdInput" name="sessionId" autocomplete="off" spellcheck="false" placeholder="e2e-mysql-skill-rag-20260706-2355">
<div class="session-picker">
<input id="sessionIdInput" name="sessionId" autocomplete="off" spellcheck="false" placeholder="e2e-mysql-skill-rag-20260706-2355" aria-controls="sessionOptions" aria-expanded="false">
<button class="session-picker-button" id="sessionPickerButton" type="button" aria-label="选择最近会话">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 9L12 15L18 9" />
</svg>
</button>
<div class="session-options" id="sessionOptions" role="listbox" aria-label="最近会话" hidden></div>
</div>
<button type="submit" id="loadButton">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 12H19M13 6L19 12L13 18" />
</svg>
<span>Load</span>
<span>加载</span>
</button>
</div>
</form>
</header>
<section class="state-line" id="stateLine" role="status">Enter a session id to load a persisted diagnosis trace.</section>
<section class="state-line" id="stateLine" role="status">输入会话 ID,加载已持久化的诊断链路。</section>
<section class="summary-strip" id="summaryStrip" aria-label="Trace summary">
<section class="outcome-panel" id="outcomePanel" aria-label="会话结果">
<div class="outcome-main">
<p class="outcome-eyebrow">本次会话结果</p>
<h2 id="outcomeTitle">尚未加载会话</h2>
<p id="outcomeSubtitle">选择一个会话后,这里会先展示最终结论和证据状态。</p>
<div class="outcome-answer" id="outcomeAnswerSummary">-</div>
</div>
<div class="outcome-side">
<div class="outcome-verdict">
<span>校验判断</span>
<strong id="outcomeVerdict">-</strong>
<p id="outcomeEvidenceState">-</p>
</div>
<div class="outcome-insights">
<div>
<h3>已确认</h3>
<ul id="outcomeConfirmed"></ul>
</div>
<div>
<h3>仍需关注</h3>
<ul id="outcomeGaps"></ul>
</div>
</div>
</div>
</section>
<section class="summary-strip" id="summaryStrip" aria-label="诊断链路汇总">
<div class="summary-cell">
<span>Status</span>
<span>状态</span>
<strong id="summaryStatus">-</strong>
</div>
<div class="summary-cell">
<span>Verdict</span>
<span>校验结论</span>
<strong id="summaryVerdict">-</strong>
</div>
<div class="summary-cell">
<span>Groundedness</span>
<span>证据分</span>
<strong id="summaryScore">-</strong>
</div>
<div class="summary-cell">
<span>Steps</span>
<span>步骤</span>
<strong id="summarySteps">-</strong>
</div>
<div class="summary-cell">
<span>Tools</span>
<span>工具</span>
<strong id="summaryTools">-</strong>
</div>
<div class="summary-cell">
<span>Duration</span>
<span>耗时</span>
<strong id="summaryDuration">-</strong>
</div>
<div class="summary-cell">
<span>Tokens</span>
<span>令牌</span>
<strong id="summaryTokens">-</strong>
</div>
</section>
@@ -70,8 +105,8 @@
<section class="trace-pane agent-pane" aria-labelledby="agentPaneTitle">
<div class="pane-heading">
<div>
<p>Agent rail</p>
<h2 id="agentPaneTitle">Persisted steps</h2>
<p>智能体轨迹</p>
<h2 id="agentPaneTitle">持久化步骤</h2>
</div>
<span class="pane-count" id="stepCount">0</span>
</div>
@@ -81,8 +116,8 @@
<section class="trace-pane evidence-pane" aria-labelledby="evidencePaneTitle">
<div class="pane-heading">
<div>
<p>Evidence ledger</p>
<h2 id="evidencePaneTitle">Tool invocations</h2>
<p>证据账本</p>
<h2 id="evidencePaneTitle">工具调用</h2>
</div>
<span class="pane-count" id="toolCount">0</span>
</div>
@@ -93,30 +128,30 @@
<section class="trace-pane inspector-pane" aria-labelledby="inspectorPaneTitle">
<div class="pane-heading">
<div>
<p>Inspector</p>
<h2 id="inspectorPaneTitle">Verifier and RAG</h2>
<p>检查器</p>
<h2 id="inspectorPaneTitle">校验与检索增强</h2>
</div>
<span class="pane-count" id="inspectorMode">idle</span>
<span class="pane-count" id="inspectorMode">空闲</span>
</div>
<div class="inspector-stack">
<section class="inspector-section" aria-labelledby="skillBoundaryTitle">
<h3 id="skillBoundaryTitle">Skill boundary</h3>
<h3 id="skillBoundaryTitle">技能边界</h3>
<div class="boundary-grid" id="skillBoundary"></div>
</section>
<section class="inspector-section" aria-labelledby="verifierTitle">
<h3 id="verifierTitle">Verifier facts</h3>
<h3 id="verifierTitle">校验事实</h3>
<div id="verifierFacts"></div>
</section>
<section class="inspector-section" aria-labelledby="toolInspectorTitle">
<h3 id="toolInspectorTitle">Selected tool</h3>
<h3 id="toolInspectorTitle">选中工具</h3>
<div id="toolInspector"></div>
</section>
<section class="inspector-section" aria-labelledby="answerTitle">
<h3 id="answerTitle">Final answer</h3>
<h3 id="answerTitle">最终回答</h3>
<div class="answer-box" id="finalAnswer">-</div>
</section>
</div>
@@ -124,6 +159,6 @@
</section>
</main>
<script src="trace.js"></script>
<script src="trace.js?v=20260707-outcome"></script>
</body>
</html>
+477 -64
View File
@@ -6,6 +6,8 @@ class TraceWorkbench {
this.sessionForm = document.getElementById('sessionForm');
this.sessionIdInput = document.getElementById('sessionIdInput');
this.sessionPickerButton = document.getElementById('sessionPickerButton');
this.sessionOptions = document.getElementById('sessionOptions');
this.loadButton = document.getElementById('loadButton');
this.stateLine = document.getElementById('stateLine');
this.stepsRail = document.getElementById('stepsRail');
@@ -15,6 +17,17 @@ class TraceWorkbench {
this.verifierFacts = document.getElementById('verifierFacts');
this.toolInspector = document.getElementById('toolInspector');
this.finalAnswer = document.getElementById('finalAnswer');
this.outcomePanel = document.getElementById('outcomePanel');
this.outcomeTitle = document.getElementById('outcomeTitle');
this.outcomeSubtitle = document.getElementById('outcomeSubtitle');
this.outcomeVerdict = document.getElementById('outcomeVerdict');
this.outcomeEvidenceState = document.getElementById('outcomeEvidenceState');
this.outcomeAnswerSummary = document.getElementById('outcomeAnswerSummary');
this.outcomeConfirmed = document.getElementById('outcomeConfirmed');
this.outcomeGaps = document.getElementById('outcomeGaps');
this.sessionCandidates = [];
this.filteredSessionCandidates = [];
this.activeSessionIndex = 0;
this.bindEvents();
this.bootstrapFromUrl();
@@ -25,11 +38,34 @@ class TraceWorkbench {
event.preventDefault();
this.loadTrace(this.sessionIdInput.value.trim());
});
this.sessionIdInput.addEventListener('focus', () => {
this.renderSessionOptions(this.sessionIdInput.value);
this.openSessionOptions();
});
this.sessionIdInput.addEventListener('input', () => {
this.renderSessionOptions(this.sessionIdInput.value);
this.openSessionOptions();
});
this.sessionIdInput.addEventListener('keydown', (event) => this.handleSessionInputKeydown(event));
this.sessionPickerButton.addEventListener('click', () => {
this.renderSessionOptions(this.sessionIdInput.value);
this.toggleSessionOptions();
});
document.addEventListener('click', (event) => {
if (!this.sessionForm.contains(event.target)) {
this.closeSessionOptions();
}
});
}
bootstrapFromUrl() {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('sessionId');
const sessionId = params.get('sessionId') || this.getLastTraceSessionId();
if (sessionId) {
this.sessionIdInput.value = sessionId;
this.loadTrace(sessionId);
@@ -38,13 +74,204 @@ class TraceWorkbench {
this.renderEmpty();
}
async loadTrace(sessionId) {
if (!sessionId) {
this.setState('Enter a session id before loading.', 'error');
getLastTraceSessionId() {
try {
const remembered = localStorage.getItem('lastTraceSessionId');
if (remembered) {
return remembered;
}
const histories = JSON.parse(localStorage.getItem('chatHistories') || '[]');
const recent = Array.isArray(histories) ? histories.find(history => history && history.id) : null;
return recent ? recent.id : '';
} catch (error) {
return '';
}
}
loadSessionCandidates() {
const candidates = [];
const seen = new Set();
const addCandidate = (candidate) => {
if (!candidate || !candidate.id || seen.has(candidate.id)) {
return;
}
seen.add(candidate.id);
candidates.push(candidate);
};
try {
const remembered = localStorage.getItem('lastTraceSessionId');
const histories = JSON.parse(localStorage.getItem('chatHistories') || '[]');
if (Array.isArray(histories)) {
histories.forEach((history) => addCandidate({
id: history.id,
title: history.title || '未命名对话',
updatedAt: history.updatedAt || history.createdAt || '',
source: 'history'
}));
}
if (remembered) {
const existing = candidates.find(candidate => candidate.id === remembered);
if (existing) {
existing.source = 'recent';
} else {
addCandidate({
id: remembered,
title: '最近查看的会话',
updatedAt: '',
source: 'recent'
});
}
}
} catch (error) {
return candidates;
}
return candidates
.sort((left, right) => {
if (left.source === 'recent' && right.source !== 'recent') {
return -1;
}
if (right.source === 'recent' && left.source !== 'recent') {
return 1;
}
return this.timestampOf(right.updatedAt) - this.timestampOf(left.updatedAt);
})
.slice(0, 20);
}
timestampOf(value) {
if (!value) {
return 0;
}
const timestamp = new Date(value).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
renderSessionOptions(query = '') {
this.sessionCandidates = this.loadSessionCandidates();
const keyword = query.trim().toLowerCase();
this.filteredSessionCandidates = keyword
? this.sessionCandidates.filter((candidate) => {
return candidate.id.toLowerCase().includes(keyword)
|| candidate.title.toLowerCase().includes(keyword);
})
: this.sessionCandidates;
this.activeSessionIndex = 0;
if (!this.filteredSessionCandidates.length) {
this.sessionOptions.innerHTML = '<div class="session-option-empty">没有匹配的最近会话</div>';
return;
}
this.setState(`Loading ${sessionId}...`, 'loading');
this.sessionOptions.innerHTML = this.filteredSessionCandidates.map((candidate, index) => `
<button class="session-option ${index === this.activeSessionIndex ? 'active' : ''}" type="button" role="option" data-session-id="${this.escape(candidate.id)}">
<span class="session-option-title">${this.escape(candidate.title)}</span>
<span class="session-option-meta">
<span>${this.escape(this.shortSessionId(candidate.id))}</span>
<span>${this.escape(this.formatOptionDate(candidate.updatedAt))}</span>
</span>
</button>
`).join('');
this.sessionOptions.querySelectorAll('.session-option').forEach((button) => {
button.addEventListener('click', () => this.chooseSession(button.dataset.sessionId));
});
}
openSessionOptions() {
this.sessionOptions.hidden = false;
this.sessionIdInput.setAttribute('aria-expanded', 'true');
}
closeSessionOptions() {
this.sessionOptions.hidden = true;
this.sessionIdInput.setAttribute('aria-expanded', 'false');
}
toggleSessionOptions() {
if (this.sessionOptions.hidden) {
this.openSessionOptions();
this.sessionIdInput.focus();
return;
}
this.closeSessionOptions();
}
handleSessionInputKeydown(event) {
if (event.key === 'Escape') {
this.closeSessionOptions();
return;
}
if (this.sessionOptions.hidden || !this.filteredSessionCandidates.length) {
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
this.activeSessionIndex = Math.min(this.activeSessionIndex + 1, this.filteredSessionCandidates.length - 1);
this.markActiveSessionOption();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.activeSessionIndex = Math.max(this.activeSessionIndex - 1, 0);
this.markActiveSessionOption();
} else if (event.key === 'Enter') {
event.preventDefault();
const candidate = this.filteredSessionCandidates[this.activeSessionIndex];
if (candidate) {
this.chooseSession(candidate.id);
}
}
}
markActiveSessionOption() {
this.sessionOptions.querySelectorAll('.session-option').forEach((button, index) => {
button.classList.toggle('active', index === this.activeSessionIndex);
if (index === this.activeSessionIndex) {
button.scrollIntoView({ block: 'nearest' });
}
});
}
chooseSession(sessionId) {
this.sessionIdInput.value = sessionId;
this.closeSessionOptions();
this.loadTrace(sessionId);
}
shortSessionId(sessionId) {
if (!sessionId || sessionId.length <= 34) {
return sessionId || '-';
}
return `${sessionId.slice(0, 18)}...${sessionId.slice(-10)}`;
}
formatOptionDate(value) {
if (!value) {
return '最近';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '最近';
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
async loadTrace(sessionId) {
if (!sessionId) {
this.setState('请先输入会话 ID。', 'error');
return;
}
this.setState(`正在加载 ${sessionId}...`, 'loading');
this.updateUrl(sessionId);
this.loadButton.disabled = true;
try {
@@ -55,9 +282,13 @@ class TraceWorkbench {
? data.toolInvocations[0].id
: null;
this.activeFilter = 'all';
this.updateUrl(sessionId);
try {
localStorage.setItem('lastTraceSessionId', sessionId);
} catch (error) {
// ignore storage failures
}
this.renderTrace();
this.setState(`Loaded ${sessionId}.`, '');
this.setState(`已加载 ${sessionId}。`, '');
} catch (error) {
this.trace = null;
this.renderEmpty();
@@ -72,16 +303,27 @@ class TraceWorkbench {
try {
payload = await response.json();
} catch (error) {
throw new Error(`HTTP ${response.status}: response is not JSON`);
throw new Error(`HTTP ${response.status}: 响应不是 JSON`);
}
if (!response.ok || payload.code !== 200) {
throw new Error(payload.message || `HTTP ${response.status}: trace request failed`);
throw new Error(this.localizeError(payload.message) || `HTTP ${response.status}: 诊断链路请求失败`);
}
return payload.data;
}
localizeError(message) {
if (!message) {
return '';
}
const value = String(message);
if (value.startsWith('Session not found:')) {
return value.replace('Session not found:', '未找到会话:');
}
return value;
}
updateUrl(sessionId) {
const url = new URL(window.location.href);
url.searchParams.set('sessionId', sessionId);
@@ -95,8 +337,8 @@ class TraceWorkbench {
const steps = this.trace.steps || [];
const tools = this.trace.toolInvocations || [];
this.text('summaryStatus', session.status || '-');
this.text('summaryVerdict', verifier.verdict || '-');
this.text('summaryStatus', this.displayStatus(session.status));
this.text('summaryVerdict', this.displayVerdict(verifier.verdict));
this.text('summaryScore', this.formatScore(verifier.groundedness_score));
this.text('summarySteps', `${summary.returnedStepCount ?? steps.length}/${summary.persistedStepCount ?? session.stepCount ?? '-'}`);
this.text('summaryTools', `${summary.returnedToolCallCount ?? tools.length}/${summary.persistedToolCallCount ?? session.toolCallCount ?? '-'}`);
@@ -104,15 +346,16 @@ class TraceWorkbench {
this.text('summaryTokens', this.formatNumber(session.totalTokenCount));
this.text('stepCount', steps.length);
this.text('toolCount', tools.length);
this.text('inspectorMode', tools.length ? 'tool' : 'trace');
this.text('inspectorMode', tools.length ? '工具' : '链路');
this.renderOutcome(session, verifier, steps, tools);
this.renderSteps(steps);
this.renderToolFilters(tools);
this.renderTools(tools);
this.renderSkillBoundary();
this.renderVerifier(verifier);
this.renderToolInspector();
this.finalAnswer.textContent = session.answer || 'No final answer persisted.';
this.finalAnswer.textContent = session.answer || '未持久化最终回答。';
}
renderEmpty() {
@@ -125,25 +368,150 @@ class TraceWorkbench {
this.text('summaryTokens', '-');
this.text('stepCount', '0');
this.text('toolCount', '0');
this.text('inspectorMode', 'idle');
this.text('inspectorMode', '空闲');
this.stepsRail.innerHTML = '<div class="empty-panel">No trace loaded.</div>';
this.renderOutcome({}, {}, [], []);
this.stepsRail.innerHTML = '<div class="empty-panel">尚未加载诊断链路。</div>';
this.toolFilters.innerHTML = '';
this.toolLedger.innerHTML = '<div class="empty-panel">No tool invocations loaded.</div>';
this.toolLedger.innerHTML = '<div class="empty-panel">尚未加载工具调用。</div>';
this.skillBoundary.innerHTML = this.renderBoundaryItems({
selectedSkill: '-',
plannerReadSkill: 0,
executorReadSkill: 0,
verifierReadSkill: 0
});
this.verifierFacts.innerHTML = '<div class="empty-panel">No verifier evaluation loaded.</div>';
this.toolInspector.innerHTML = '<div class="empty-panel">Select a tool invocation after loading a trace.</div>';
this.verifierFacts.innerHTML = '<div class="empty-panel">尚未加载校验结果。</div>';
this.toolInspector.innerHTML = '<div class="empty-panel">加载诊断链路后选择一个工具调用。</div>';
this.finalAnswer.textContent = '-';
}
renderOutcome(session, verifier, steps, tools) {
const status = String(session.status || '').toUpperCase();
const verdict = String(verifier.verdict || '').toUpperCase();
const title = this.outcomeTitleFor(status, verdict);
const checked = Array.isArray(verifier.facts_checked) ? verifier.facts_checked : [];
const traceSummary = Array.isArray(verifier.tool_trace_summary) ? verifier.tool_trace_summary : [];
const tracedFacts = checked.filter(fact => Array.isArray(fact.evidence_refs) && fact.evidence_refs.length).length;
const score = this.formatScore(verifier.groundedness_score);
const answer = session.answer || '未持久化最终回答。';
const subtitleParts = [];
if (session.sessionId) {
subtitleParts.push(`会话 ${session.sessionId}`);
}
if (session.status) {
subtitleParts.push(`状态 ${this.displayStatus(session.status)}`);
}
if (steps.length || tools.length) {
subtitleParts.push(`${steps.length} 个步骤 / ${tools.length} 次工具调用`);
}
if (session.totalDurationMs !== undefined && session.totalDurationMs !== null) {
subtitleParts.push(`耗时 ${this.formatDuration(session.totalDurationMs)}`);
}
this.outcomePanel.className = `outcome-panel ${this.outcomeClass(status, verdict)}`.trim();
this.outcomeTitle.textContent = title;
this.outcomeSubtitle.textContent = subtitleParts.length
? subtitleParts.join(' · ')
: '选择一个会话后,这里会先展示最终结论和证据状态。';
this.outcomeVerdict.textContent = this.displayVerdict(verifier.verdict);
this.outcomeEvidenceState.textContent = checked.length
? `证据分 ${score},检查 ${checked.length} 个事实,${tracedFacts} 个事实带引用,${traceSummary.length} 条证据摘要。`
: '暂无结构化校验事实。';
this.outcomeAnswerSummary.textContent = this.summarizeAnswer(answer);
this.renderOutcomeList(this.outcomeConfirmed, this.confirmedFacts(checked), '暂无直接确认的关键事实。');
this.renderOutcomeList(this.outcomeGaps, this.evidenceGaps(checked, verdict), '没有明显证据缺口。');
}
outcomeTitleFor(status, verdict) {
if (!status && !verdict) {
return '尚未加载会话';
}
if (status === 'FAILED') {
return '会话执行失败';
}
if (verdict.includes('REJECT')) {
return '结论被校验器拒绝';
}
if (verdict.includes('LOW_CONFID')) {
return '结论低置信,需要复核';
}
if (verdict.includes('PASS')) {
return '结论已通过校验';
}
if (status === 'SUCCESS') {
return '会话已完成,暂无明确校验结论';
}
return '会话仍在处理中';
}
outcomeClass(status, verdict) {
if (status === 'FAILED' || verdict.includes('REJECT')) {
return 'bad';
}
if (verdict.includes('LOW_CONFID') || status === 'RUNNING' || status === 'PENDING') {
return 'warn';
}
if (status === 'SUCCESS' || verdict.includes('PASS')) {
return 'ok';
}
return '';
}
summarizeAnswer(answer) {
const text = String(answer || '').trim();
if (!text) {
return '-';
}
const compact = text
.replace(/\r/g, '')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n');
return compact.length > 620 ? `${compact.slice(0, 620)}...` : compact;
}
confirmedFacts(facts) {
return facts
.filter(fact => {
const verification = String(fact.verification || '').toUpperCase();
return fact.is_critical && (verification.includes('DIRECT') || verification.includes('SUPPORTED'));
})
.map(fact => fact.fact || fact.detail)
.filter(Boolean)
.slice(0, 4);
}
evidenceGaps(facts, verdict) {
const gaps = facts
.filter(fact => {
const verification = String(fact.verification || '').toUpperCase();
return fact.is_critical && (
verification.includes('NO_EVIDENCE')
|| verification.includes('UNSUPPORTED')
|| verification.includes('CONTRADICT')
|| verification.includes('INDIRECT')
);
})
.map(fact => fact.detail ? `${fact.fact}:${fact.detail}` : fact.fact)
.filter(Boolean)
.slice(0, 4);
if (!gaps.length && String(verdict || '').toUpperCase().includes('LOW_CONFID')) {
return ['校验器给出低置信结论,建议人工复核关键事实和证据引用。'];
}
return gaps;
}
renderOutcomeList(node, items, emptyText) {
node.innerHTML = (items.length ? items : [emptyText]).map(item => `
<li>${this.escape(item)}</li>
`).join('');
}
renderSteps(steps) {
if (!steps.length) {
this.stepsRail.innerHTML = '<div class="empty-panel">No persisted agent steps.</div>';
this.stepsRail.innerHTML = '<div class="empty-panel">没有持久化智能体步骤。</div>';
return;
}
@@ -153,18 +521,19 @@ class TraceWorkbench {
return `
<article class="step-entry ${cls}" data-index="${this.escape(String(index + 1).padStart(2, '0'))}">
<div class="step-title">
<h3>${this.escape(step.agentName || 'agent')}</h3>
${this.renderBadge(step.hasToolCall ? 'tool' : 'model', step.hasToolCall ? 'warn' : 'ok')}
<h3>${this.escape(step.agentName || '智能体')}</h3>
${this.renderBadge(step.hasToolCall ? '工具' : '模型', step.hasToolCall ? 'warn' : 'ok')}
</div>
<div class="meta-line">
<span class="meta-pill">step ${this.escape(step.stepIndex ?? index)}</span>
<span class="meta-pill">执行序号 ${this.escape(String(index + 1).padStart(2, '0'))}</span>
<span class="meta-pill">内部步 ${this.escape(step.stepIndex ?? index)}</span>
<span class="meta-pill">${this.escape(this.formatDuration(step.durationMs))}</span>
<span class="meta-pill">${this.escape(this.formatNumber(step.tokenCount))} tokens</span>
<span class="meta-pill">${this.escape(this.formatNumber(step.tokenCount))} 令牌</span>
<span class="meta-pill">${this.escape(this.formatDate(step.createdAt))}</span>
</div>
${this.renderTextDetail('Model input', step.modelInput)}
${this.renderTextDetail('Model output', step.modelOutput)}
${this.renderTextDetail('Thought', step.thought)}
${this.renderTextDetail('模型输入', step.modelInput)}
${this.renderTextDetail('模型输出', step.modelOutput)}
${this.renderTextDetail('思考过程', step.thought)}
</article>
`;
}).join('');
@@ -178,9 +547,9 @@ class TraceWorkbench {
const groups = this.groupByTool(tools);
const filters = [
{ key: 'all', label: `all ${tools.length}` },
{ key: 'success', label: `success ${tools.filter(tool => tool.success !== false).length}` },
{ key: 'failed', label: `failed ${tools.filter(tool => tool.success === false).length}` },
{ key: 'all', label: `全部 ${tools.length}` },
{ key: 'success', label: `成功 ${tools.filter(tool => tool.success !== false).length}` },
{ key: 'failed', label: `失败 ${tools.filter(tool => tool.success === false).length}` },
...Object.entries(groups).map(([name, count]) => ({ key: `tool:${name}`, label: `${name} ${count}` }))
];
@@ -202,7 +571,7 @@ class TraceWorkbench {
renderTools(tools) {
const filtered = tools.filter(tool => this.matchesFilter(tool));
if (!filtered.length) {
this.toolLedger.innerHTML = '<div class="empty-panel">No tool invocations match this filter.</div>';
this.toolLedger.innerHTML = '<div class="empty-panel">当前筛选下没有工具调用。</div>';
return;
}
@@ -212,13 +581,13 @@ class TraceWorkbench {
return `
<button class="tool-row ${failed ? 'failed' : ''} ${selected ? 'selected' : ''}" data-tool-id="${this.escape(tool.id)}">
<div class="tool-title">
<h3>${this.escape(tool.toolName || 'tool')}</h3>
${this.renderBadge(failed ? 'failed' : 'ok', failed ? 'bad' : 'ok')}
<h3>${this.escape(tool.toolName || '工具')}</h3>
${this.renderBadge(failed ? '失败' : '正常', failed ? 'bad' : 'ok')}
</div>
<div class="tool-meta">
<span>#${index + 1}</span>
<span>id ${this.escape(tool.id ?? '-')}</span>
<span>step ${this.escape(tool.stepId ?? '-')}</span>
<span>ID ${this.escape(tool.id ?? '-')}</span>
<span>步骤 ${this.escape(tool.stepId ?? '-')}</span>
<span>${this.escape(this.formatDuration(tool.durationMs))}</span>
${tool.retrievalLayer ? `<span>${this.escape(tool.retrievalLayer)}</span>` : ''}
${tool.relevanceLevel ? `<span>${this.escape(tool.relevanceLevel)}</span>` : ''}
@@ -251,19 +620,19 @@ class TraceWorkbench {
renderBoundaryItems(counts) {
return `
<div class="boundary-item">
<span class="field-label">Selected skill</span>
<span class="field-label">选中技能</span>
<strong>${this.escape(counts.selectedSkill)}</strong>
</div>
<div class="boundary-item">
<span class="field-label">Planner mentions</span>
<span class="field-label">规划器提及</span>
<strong>${this.escape(counts.plannerReadSkill)}</strong>
</div>
<div class="boundary-item">
<span class="field-label">Executor mentions</span>
<span class="field-label">执行器提及</span>
<strong>${this.escape(counts.executorReadSkill)}</strong>
</div>
<div class="boundary-item">
<span class="field-label">Verifier mentions</span>
<span class="field-label">校验器提及</span>
<strong>${this.escape(counts.verifierReadSkill)}</strong>
</div>
`;
@@ -271,7 +640,7 @@ class TraceWorkbench {
renderVerifier(verifier) {
if (!verifier || !Object.keys(verifier).length) {
this.verifierFacts.innerHTML = '<div class="empty-panel">No verifier evaluation in selfEvaluation.</div>';
this.verifierFacts.innerHTML = '<div class="empty-panel">自评数据中没有校验结果。</div>';
return;
}
@@ -279,15 +648,15 @@ class TraceWorkbench {
const traceSummary = Array.isArray(verifier.tool_trace_summary) ? verifier.tool_trace_summary : [];
const header = `
<div class="meta-line">
${this.renderBadge(verifier.verdict || 'verdict -', this.verdictClass(verifier.verdict))}
<span class="meta-pill">score ${this.escape(this.formatScore(verifier.groundedness_score))}</span>
<span class="meta-pill">facts ${facts.length}</span>
<span class="meta-pill">trace refs ${traceSummary.length}</span>
${this.renderBadge(this.displayVerdict(verifier.verdict), this.verdictClass(verifier.verdict))}
<span class="meta-pill">证据分 ${this.escape(this.formatScore(verifier.groundedness_score))}</span>
<span class="meta-pill">事实 ${facts.length}</span>
<span class="meta-pill">引用 ${traceSummary.length}</span>
</div>
`;
if (!facts.length) {
this.verifierFacts.innerHTML = header + '<div class="empty-panel">Verifier returned no facts_checked rows.</div>';
this.verifierFacts.innerHTML = header + '<div class="empty-panel">校验器没有返回 facts_checked 明细。</div>';
return;
}
@@ -297,9 +666,9 @@ class TraceWorkbench {
<article class="fact-row">
<p>${this.escape(fact.fact || '-')}</p>
<div class="meta-line">
${this.renderBadge(fact.verification || '-', this.factClass(fact.verification))}
<span class="meta-pill">${fact.is_critical ? 'critical' : 'supporting'}</span>
<span class="meta-pill">refs ${refs.length}</span>
${this.renderBadge(this.displayFactVerification(fact.verification), this.factClass(fact.verification))}
<span class="meta-pill">${fact.is_critical ? '关键事实' : '辅助事实'}</span>
<span class="meta-pill">引用 ${refs.length}</span>
</div>
${fact.detail ? `<pre>${this.escape(fact.detail)}</pre>` : ''}
${refs.length ? `<pre>${this.escape(this.prettyJson(refs))}</pre>` : ''}
@@ -312,33 +681,33 @@ class TraceWorkbench {
const tools = this.trace ? (this.trace.toolInvocations || []) : [];
const tool = tools.find(item => String(item.id) === String(this.selectedToolId));
if (!tool) {
this.toolInspector.innerHTML = '<div class="empty-panel">No tool invocation selected.</div>';
this.toolInspector.innerHTML = '<div class="empty-panel">尚未选择工具调用。</div>';
return;
}
const retrieval = tool.retrievalDetails || {};
const ragFields = [
['Input params', tool.inputParams || this.parseJson(tool.inputParamsRaw)],
['Output preview', tool.outputPreview || '-'],
['Retrieval layer', tool.retrievalLayer || '-'],
['L0 matches', tool.l0MatchCount ?? '-'],
['L1 matches', tool.l1MatchCount ?? '-'],
['Relevance', tool.relevanceLevel || '-'],
['Dedup reason', tool.dedupReason || '-'],
['Query transform', retrieval.query_transform],
['Retrieval trace', retrieval.retrieval_trace],
['Context pack', retrieval.context_pack_summary],
['Rerank trace', retrieval.rerank_trace],
['Evidence blocks', retrieval.evidence_blocks]
['输入参数', tool.inputParams || this.parseJson(tool.inputParamsRaw)],
['输出预览', tool.outputPreview || '-'],
['检索层级', tool.retrievalLayer || '-'],
['L0 命中数', tool.l0MatchCount ?? '-'],
['L1 命中数', tool.l1MatchCount ?? '-'],
['相关性', tool.relevanceLevel || '-'],
['去重原因', tool.dedupReason || '-'],
['查询改写', retrieval.query_transform],
['检索轨迹', retrieval.retrieval_trace],
['上下文打包', retrieval.context_pack_summary],
['重排轨迹', retrieval.rerank_trace],
['证据块', retrieval.evidence_blocks]
];
this.toolInspector.innerHTML = `
<div class="meta-line">
${this.renderBadge(tool.success === false ? 'failed' : 'ok', tool.success === false ? 'bad' : 'ok')}
<span class="meta-pill">${this.escape(tool.toolName || 'tool')}</span>
${this.renderBadge(tool.success === false ? '失败' : '正常', tool.success === false ? 'bad' : 'ok')}
<span class="meta-pill">${this.escape(tool.toolName || '工具')}</span>
<span class="meta-pill">${this.escape(this.formatDuration(tool.durationMs))}</span>
<span class="meta-pill">output ${this.escape(this.formatNumber(tool.outputLength))}</span>
${tool.truncated ? '<span class="meta-pill">truncated</span>' : ''}
<span class="meta-pill">输出 ${this.escape(this.formatNumber(tool.outputLength))}</span>
${tool.truncated ? '<span class="meta-pill">已截断</span>' : ''}
</div>
${tool.errorMessage ? `<pre>${this.escape(tool.errorMessage)}</pre>` : ''}
<div class="field-grid">
@@ -373,6 +742,50 @@ class TraceWorkbench {
return `<span class="badge ${this.escape(cls || 'ok')}">${this.escape(text)}</span>`;
}
displayStatus(status) {
if (!status) {
return '-';
}
const statuses = {
SUCCESS: '成功',
FAILED: '失败',
RUNNING: '运行中',
PENDING: '等待中'
};
return statuses[String(status).toUpperCase()] || String(status);
}
displayVerdict(verdict) {
if (!verdict) {
return '-';
}
const verdicts = {
PASS: '通过',
REJECT: '拒绝',
LOW_CONFID: '低置信',
LOW_CONFIDENCE: '低置信',
REVIEW: '需复核'
};
return verdicts[String(verdict).toUpperCase()] || String(verdict);
}
displayFactVerification(verification) {
if (!verification) {
return '-';
}
const verifications = {
DIRECT_EVIDENCE: '直接证据',
INDIRECT_SUPPORT: '间接支持',
NO_EVIDENCE: '无证据',
UNSUPPORTED: '未支持',
CONTRADICTED: '有矛盾',
SUPPORTED: '已支持',
PASS: '通过',
REJECT: '拒绝'
};
return verifications[String(verification).toUpperCase()] || String(verification);
}
getVerifier() {
const evaluation = (this.trace && this.trace.session && this.trace.session.selfEvaluation) || {};
if (evaluation.verifier_evaluation) {
@@ -84,7 +84,7 @@ class DiagnosisTraceServiceTest {
.build();
when(diagnosisSessionRepository.findBySessionId(sessionId)).thenReturn(Optional.of(session));
when(agentStepRepository.findBySessionIdOrderByStepIndex(sessionId)).thenReturn(List.of(step));
when(agentStepRepository.findBySessionId(sessionId)).thenReturn(List.of(step));
when(toolInvocationRepository.findBySessionIdOrderByIdAsc(sessionId)).thenReturn(List.of(invocation));
DiagnosisTraceResponse response = service.getTrace(sessionId);
@@ -107,6 +107,38 @@ class DiagnosisTraceServiceTest {
assertTrue(response.getSummary().isHasFeedback());
}
@Test
void getTraceOrdersStepsByCreationTimeAndIdNotPerAgentStepIndex() {
String sessionId = "trace-session-ordered";
LocalDateTime base = LocalDateTime.of(2026, 7, 7, 0, 0);
DiagnosisSession session = DiagnosisSession.builder()
.id(1L)
.sessionId(sessionId)
.query("mysql pool issue")
.status("SUCCESS")
.stepCount(4)
.toolCallCount(0)
.createdAt(base)
.updatedAt(base)
.build();
AgentStep planner = step(1L, sessionId, 0, "planner", base.plusSeconds(1));
AgentStep executor0 = step(2L, sessionId, 0, "executor", base.plusSeconds(2));
AgentStep executor1 = step(3L, sessionId, 1, "executor", base.plusSeconds(3));
AgentStep verifier = step(4L, sessionId, 0, "verifier", base.plusSeconds(4));
when(diagnosisSessionRepository.findBySessionId(sessionId)).thenReturn(Optional.of(session));
when(agentStepRepository.findBySessionId(sessionId))
.thenReturn(List.of(planner, executor0, verifier, executor1));
when(toolInvocationRepository.findBySessionIdOrderByIdAsc(sessionId)).thenReturn(List.of());
DiagnosisTraceResponse response = service.getTrace(sessionId);
assertEquals(List.of("planner", "executor", "executor", "verifier"),
response.getSteps().stream().map(DiagnosisTraceResponse.AgentStepTrace::getAgentName).toList());
assertEquals(List.of(0, 0, 1, 0),
response.getSteps().stream().map(DiagnosisTraceResponse.AgentStepTrace::getStepIndex).toList());
}
@Test
void getTraceThrowsWhenSessionMissing() {
String sessionId = "missing-session";
@@ -117,4 +149,14 @@ class DiagnosisTraceServiceTest {
verify(diagnosisSessionRepository).findBySessionId(sessionId);
verifyNoInteractions(agentStepRepository, toolInvocationRepository);
}
private AgentStep step(Long id, String sessionId, int stepIndex, String agentName, LocalDateTime createdAt) {
return AgentStep.builder()
.id(id)
.sessionId(sessionId)
.stepIndex(stepIndex)
.agentName(agentName)
.createdAt(createdAt)
.build();
}
}