diff --git a/src/agentsight/dashboard/src/components/EvaluationBadge.tsx b/src/agentsight/dashboard/src/components/EvaluationBadge.tsx new file mode 100644 index 000000000..43882c564 --- /dev/null +++ b/src/agentsight/dashboard/src/components/EvaluationBadge.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { EvaluationResult } from '../utils/apiClient'; + +interface EvaluationBadgeProps { + result: Pick | null; +} + +const STYLE_BY_VERDICT = { + pass: 'bg-green-50 text-green-700 border-green-200', + warn: 'bg-amber-50 text-amber-700 border-amber-200', + fail: 'bg-red-50 text-red-700 border-red-200', +} as const; + +const LABEL_BY_VERDICT = { + pass: '通过', + warn: '需复核', + fail: '未通过', +} as const; + +export const EvaluationBadge: React.FC = ({ result }) => { + if (!result) return null; + + const style = + STYLE_BY_VERDICT[result.verdict as keyof typeof STYLE_BY_VERDICT] ?? + 'bg-gray-50 text-gray-700 border-gray-200'; + const label = + LABEL_BY_VERDICT[result.verdict as keyof typeof LABEL_BY_VERDICT] ?? + result.verdict; + + return ( + + {label} + {Math.round(result.score * 100)} + + ); +}; diff --git a/src/agentsight/dashboard/src/components/EvaluationPanel.tsx b/src/agentsight/dashboard/src/components/EvaluationPanel.tsx new file mode 100644 index 000000000..552aecdbd --- /dev/null +++ b/src/agentsight/dashboard/src/components/EvaluationPanel.tsx @@ -0,0 +1,341 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + EvaluationNotReadyError, + EvaluationRef, + EvaluationResult, + INTERRUPTION_TYPE_CN, + evaluateConversation, +} from '../utils/apiClient'; +import { EvaluationBadge } from './EvaluationBadge'; + +interface EvaluationPanelProps { + conversationId: string; + initialResult: EvaluationResult | null; + onResult?: (result: EvaluationResult) => void; +} + +export const EvaluationPanel: React.FC = ({ + conversationId, + initialResult, + onResult, +}) => { + const navigate = useNavigate(); + const [result, setResult] = useState(initialResult); + const [expanded, setExpanded] = useState(false); + const [loading, setLoading] = useState(false); + const [pendingCount, setPendingCount] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + setResult(initialResult); + }, [initialResult]); + + const runEvaluation = async (force: boolean) => { + setLoading(true); + setError(null); + try { + const response = await evaluateConversation(conversationId, force); + setResult(response.result); + setPendingCount(null); + onResult?.(response.result); + } catch (err) { + if (err instanceof EvaluationNotReadyError) { + setPendingCount(err.pendingCallCount); + } else { + setError(err instanceof Error ? err.message : '质量评估失败'); + } + } finally { + setLoading(false); + } + }; + + const renderEvidenceLinks = (refs: EvaluationRef[]) => { + if (refs.length === 0) return null; + + return ( +
+ {refs.slice(0, 3).map((ref, index) => { + const path = evidencePath(ref); + return ( + + ); + })} + {refs.length > 3 && +{refs.length - 3}} +
+ ); + }; + + return ( +
+
+
+
+ 质量评估 + +
+ {result ? ( +
+

{summaryText(result)}

+

+ 根因:{rootCauseLabel(result.root_cause)} +

+

{recommendedActionText(result)}

+
+ ) : ( +

暂无质量评估结果。

+ )} +
+ +
+ + {pendingCount !== null && ( +
+ {pendingCount} 个 LLM 调用仍未完成。 + +
+ )} + + {result?.metadata.evaluated_with_pending && ( +
+ 评估时仍有 {result.metadata.pending_call_count} 个 LLM 调用未完成。 +
+ )} + + {error && ( +
+ {error} +
+ )} + + {result && ( +
+ + {expanded && ( +
+
+

评估维度

+
+ {result.dimensions.map((dimension) => ( +
+
+ {dimensionLabel(dimension.name)} + + {Math.round(dimension.score * 100)} + +
+

{reasonText(dimension.reason)}

+ {renderEvidenceLinks(dimension.evidence_refs)} +
+ ))} +
+
+
+

问题发现

+
+ {result.findings.length === 0 ? ( +

未发现问题。

+ ) : ( + result.findings.map((finding, index) => ( +
+
+ + {findingLabel(finding.code)} + + {severityLabel(finding.severity)} +
+

{findingMessageText(finding.message)}

+ {renderEvidenceLinks(finding.evidence_refs)} +
+ )) + )} +
+
+
+ )} +
+ )} +
+ ); +}; + +function evidencePath(ref: EvaluationRef): string | null { + if (!ref.deeplink) return null; + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(ref.deeplink.query ?? {})) { + if (value !== null && value !== undefined) { + params.set(key, String(value)); + } + } + const query = params.toString(); + return query ? `${ref.deeplink.route}?${query}` : ref.deeplink.route; +} + +function summaryText(result: EvaluationResult): string { + if (result.verdict === 'pass') { + return '会话已完成,未发现确定性的质量问题。'; + } + if (result.verdict === 'warn') { + return `当前会话可用,但需要复核:${rootCauseLabel(result.root_cause)}。`; + } + return `质量评估未通过,主要原因:${rootCauseLabel(result.root_cause)}。`; +} + +function recommendedActionText(result: EvaluationResult): string { + if (result.verdict === 'pass') { + return '暂无需要立即处理的动作。'; + } + + const actions: Record = { + none: '复核告警项和支撑证据。', + no_final_answer: '检查最后一次 LLM 调用和服务端响应解析。', + interrupted_main_call: '检查中断证据,修复运行稳定性后再重试会话。', + agent_crash: '重试前先检查 Agent 健康状态和崩溃诊断。', + runtime_error: '检查模型服务错误、网络稳定性和重试行为。', + tool_failure: '检查失败的工具调用和工具响应解析。', + safety_risk: '重新运行 Agent 前先复核安全相关发现。', + loop_detected: '检查重复调用并收紧停止条件。', + excessive_cost: '复核提示词、工具输出和 Token 节省空间。', + partial_snapshot: '等待 pending 调用完成,或保留强制评估标记。', + }; + + return actions[result.root_cause] ?? result.recommended_action ?? result.root_cause; +} + +function rootCauseLabel(value: string): string { + const labels: Record = { + none: '未发现明确根因', + no_final_answer: '未生成最终回答', + interrupted_main_call: '主调用被中断', + agent_crash: 'Agent 崩溃', + runtime_error: '运行时错误', + tool_failure: '工具调用失败', + safety_risk: '安全风险', + loop_detected: '疑似循环调用', + excessive_cost: '成本过高', + partial_snapshot: '快照未完成', + }; + + return labels[value] ?? value; +} + +function dimensionLabel(value: string): string { + const labels: Record = { + completion: '完成度', + runtime_health: '运行健康', + tool_use: '工具使用', + efficiency: '效率', + safety: '安全', + }; + + return labels[value] ?? value; +} + +function reasonText(value: string): string { + const labels: Record = { + 'No usable assistant output was captured.': '未捕获到可用的助手输出。', + 'A usable output exists.': '已捕获可用输出。', + 'A usable output exists, but the snapshot still has pending calls.': '已捕获可用输出,但快照仍有未完成调用。', + 'A usable assistant output was captured.': '已捕获可用的助手输出。', + 'One or more LLM calls were interrupted.': '一个或多个 LLM 调用被中断。', + 'Unresolved interruption signals were captured for this conversation.': '当前会话存在未解决的中断信号。', + 'The snapshot contains pending calls and may still change.': '快照包含未完成调用,结果仍可能变化。', + 'No runtime interruption was detected.': '未检测到运行时中断。', + 'Tool output contains deterministic error signals.': '工具输出包含确定性错误信号。', + 'The conversation required an unusually large number of LLM calls.': '当前会话的 LLM 调用次数异常偏高。', + 'No deterministic tool failure was detected.': '未检测到确定性工具故障。', + 'Token usage or call count is unusually high for a single conversation.': '单个会话的 Token 用量或调用次数异常偏高。', + 'Token usage or call count is elevated for a single conversation.': '单个会话的 Token 用量或调用次数偏高。', + 'Token usage and call count are within normal bounds.': 'Token 用量和调用次数处于正常范围。', + 'Safety-related interruption signal was captured.': '捕获到安全相关中断信号。', + 'No safety-specific signal was available or triggered.': '未发现安全专项信号触发。', + }; + + return labels[value] ?? value; +} + +function findingLabel(value: string): string { + const labels: Record = { + no_final_answer: '未生成最终回答', + interrupted_main_call: '主调用被中断', + partial_snapshot: '快照未完成', + tool_failure: '工具调用失败', + loop_detected: '疑似循环调用', + llm_error: 'LLM 错误', + sse_truncated: 'SSE 流截断', + network_timeout: '网络超时', + service_unavailable: '服务不可用', + agent_crash: 'Agent 崩溃', + }; + + return labels[value] ?? INTERRUPTION_TYPE_CN[value] ?? value; +} + +function findingMessageText(value: string): string { + const labels: Record = { + 'The conversation has no usable assistant output.': '会话没有可用的助手输出。', + 'An LLM call was interrupted before normal completion.': 'LLM 调用在正常完成前被中断。', + 'Evaluation was forced while LLM calls were still pending.': '仍有 LLM 调用未完成时执行了强制评估。', + 'Evaluation was forced while calls were pending.': '仍有调用未完成时执行了强制评估。', + 'An unresolved interruption was recorded for this conversation.': '当前会话存在未解决的中断记录。', + 'Tool output contains an error-like signal.': '工具输出包含疑似错误信号。', + 'The conversation used many LLM calls and may need loop inspection.': '会话使用了较多 LLM 调用,可能需要检查循环行为。', + }; + + return labels[value] ?? value; +} + +function severityLabel(value: string): string { + const labels: Record = { + critical: '严重', + high: '高', + medium: '中', + low: '低', + }; + + return labels[value] ?? value; +} + +function evidenceLabel(value: string): string { + const labels: Record = { + 'Assistant output': '助手输出', + 'No output': '无输出', + 'Interrupted LLM call': '中断的 LLM 调用', + 'Interrupted call': '中断调用', + 'Pending call': '未完成调用', + 'Tool failure signal': '工具故障信号', + 'Repeated calls': '重复调用', + 'High cost': '高成本', + 'Elevated cost': '成本偏高', + 'Pending snapshot': '未完成快照', + 'Tool failure': '工具故障', + }; + + return labels[value] ?? findingLabel(value); +} diff --git a/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx b/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx index 8e44b016b..e090e3f72 100644 --- a/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx +++ b/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx @@ -28,6 +28,21 @@ function shortId(id: string, len = 20): string { return id.length > len ? id.slice(0, len) + '\u2026' : id; } +function highlightedSections(doc: AtifDocument, callId: string | null): Set { + const sections = new Set(); + if (!callId) return sections; + + for (const step of doc.steps ?? []) { + if (step.tool_calls?.some((toolCall) => toolCall.tool_call_id === callId)) { + sections.add(`${step.step_id}-toolcalls`); + } + if (step.observation?.results.some((result) => result.source_call_id === callId)) { + sections.add(`${step.step_id}-observation`); + } + } + return sections; +} + // ─── Strategy label config (shared with TokenSavingsPage) ──────────────────── const STRATEGY_LABELS: Record = { @@ -354,6 +369,11 @@ const MetricCard: React.FC<{ label: string; value: string; color: string; sub?: export const AtifViewerPage: React.FC = () => { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); + const searchParamsRef = useRef(searchParams); + + useEffect(() => { + searchParamsRef.current = searchParams; + }, [searchParams]); // Input state const [queryType, setQueryType] = useState<'session' | 'conversation'>( @@ -392,7 +412,15 @@ export const AtifViewerPage: React.FC = () => { const i = id ?? queryId; if (!i.trim()) return; - setSearchParams({ type: t, id: i.trim() }, { replace: true }); + const nextParams: Record = { type: t, id: i.trim() }; + const currentSearchParams = searchParamsRef.current; + if (currentSearchParams.get('id') === i.trim()) { + const highlightCallId = currentSearchParams.get('highlight_call_id'); + const interruptionId = currentSearchParams.get('interruption_id'); + if (highlightCallId) nextParams.highlight_call_id = highlightCallId; + if (interruptionId) nextParams.interruption_id = interruptionId; + } + setSearchParams(nextParams, { replace: true }); setLoading(true); setError(null); setDoc(null); @@ -406,6 +434,7 @@ export const AtifViewerPage: React.FC = () => { data = await fetchAtifBySession(i.trim()); } setDoc(data); + setExpandedSections(highlightedSections(data, nextParams.highlight_call_id ?? null)); // Fetch savings data for the session if (data.session_id) { fetchSessionSavings(data.session_id) @@ -467,10 +496,11 @@ export const AtifViewerPage: React.FC = () => { }, [doc]); // Compute metrics (fallback when final_metrics is partial) + const steps = doc?.steps ?? []; const computedMetrics = doc ? (() => { const fm = doc.final_metrics; let promptSum = 0, completionSum = 0, cachedSum = 0; - for (const s of doc.steps) { + for (const s of steps) { if (s.metrics) { promptSum += s.metrics.prompt_tokens ?? 0; completionSum += s.metrics.completion_tokens ?? 0; @@ -478,7 +508,7 @@ export const AtifViewerPage: React.FC = () => { } } return { - steps: fm?.total_steps ?? doc.steps.length, + steps: fm?.total_steps ?? steps.length, prompt: fm?.total_prompt_tokens ?? promptSum, completion: fm?.total_completion_tokens ?? completionSum, cached: fm?.total_cached_tokens ?? cachedSum, @@ -683,11 +713,11 @@ export const AtifViewerPage: React.FC = () => {

交互轨迹 - 共 {doc.steps.length} 步 + 共 {steps.length} 步

- {doc.steps.length === 0 ? ( + {steps.length === 0 ? (

--

该轨迹暂无步骤数据

@@ -697,7 +727,7 @@ export const AtifViewerPage: React.FC = () => { {/* Vertical line */}
- {doc.steps.map(step => ( + {steps.map(step => ( = ({ sessionId, conversationIn const [error, setError] = useState(null); const [page, setPage] = useState(0); // 0-based const [expandedTracePanel, setExpandedTracePanel] = useState(null); + const [expandedEvaluationPanel, setExpandedEvaluationPanel] = useState(null); + const [evaluations, setEvaluations] = useState>(new Map()); + const [evaluationLookupDone, setEvaluationLookupDone] = useState>(new Set()); + const [evaluationLookupFailed, setEvaluationLookupFailed] = useState>(new Set()); useEffect(() => { setLoading(true); setPage(0); + setEvaluations(new Map()); + setEvaluationLookupDone(new Set()); + setEvaluationLookupFailed(new Set()); fetchTraces(sessionId, startNs, endNs) .then(setTraces) .catch((e: Error) => setError(e.message)) .finally(() => setLoading(false)); }, [sessionId, startNs, endNs]); + const totalPages = Math.max(1, Math.ceil(traces.length / PAGE_SIZE)); + const pageTraces = useMemo( + () => traces.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE), + [page, traces] + ); + + useEffect(() => { + if (loading || pageTraces.length === 0) return; + + const missing = pageTraces.filter( + (trace) => + !evaluationLookupDone.has(trace.conversation_id) && + !evaluationLookupFailed.has(trace.conversation_id) + ); + if (missing.length === 0) return; + + let cancelled = false; + Promise.all( + missing.map((trace) => + fetchLatestEvaluation(trace.conversation_id) + .then((result) => ({ + conversationId: trace.conversation_id, + result, + ok: true, + })) + .catch(() => ({ + conversationId: trace.conversation_id, + result: null, + ok: false, + })) + ) + ).then((entries) => { + if (cancelled) return; + setEvaluations((prev) => { + const next = new Map(prev); + for (const entry of entries) { + if (entry.ok && entry.result) { + next.set(entry.conversationId, entry.result); + } + } + return next; + }); + setEvaluationLookupDone((prev) => { + const next = new Set(prev); + let changed = false; + for (const entry of entries) { + if (entry.ok && !next.has(entry.conversationId)) { + next.add(entry.conversationId); + changed = true; + } + } + return changed ? next : prev; + }); + setEvaluationLookupFailed((prev) => { + const next = new Set(prev); + let changed = false; + for (const entry of entries) { + if (entry.ok) { + if (next.delete(entry.conversationId)) changed = true; + } else if (!next.has(entry.conversationId)) { + next.add(entry.conversationId); + changed = true; + } + } + return changed ? next : prev; + }); + }); + + return () => { + cancelled = true; + }; + }, [evaluationLookupDone, evaluationLookupFailed, loading, pageTraces]); + if (loading) return ( @@ -343,21 +427,19 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn ); - const totalPages = Math.max(1, Math.ceil(traces.length / PAGE_SIZE)); - const pageTraces = traces.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); - return ( <> {/* Sub-header */} -
+
Conversation ID
用户请求
输入 Token
输出 Token
开始时间
操作
+
质量评估
中断
@@ -375,7 +457,7 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn -
+
{/* Col 1: Conversation ID */}
@@ -416,6 +498,44 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn 详情
+
+ {(() => { + const evaluation = evaluations.get(tr.conversation_id); + const lookupFailed = evaluationLookupFailed.has(tr.conversation_id); + return ( + + ); + })()} +
{(() => { const ic = conversationInterruptionCounts.get(tr.conversation_id); @@ -432,8 +552,24 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn })()}
- - + + + {/* Trace evaluation panel */} + {expandedEvaluationPanel === tr.conversation_id && ( + + + setEvaluations((prev) => { + const next = new Map(prev); + next.set(tr.conversation_id, result); + return next; + })} + /> + + + )} {/* Trace interruption panel */} {expandedTracePanel === tr.trace_id && ( diff --git a/src/agentsight/dashboard/src/test/AtifViewerPage.test.tsx b/src/agentsight/dashboard/src/test/AtifViewerPage.test.tsx index 226611816..608e37306 100644 --- a/src/agentsight/dashboard/src/test/AtifViewerPage.test.tsx +++ b/src/agentsight/dashboard/src/test/AtifViewerPage.test.tsx @@ -55,13 +55,12 @@ const mockAtifDoc = { tool_calls: [ { tool_call_id: 'tc-1', - tool_name: 'search', + function_name: 'search', arguments: { query: 'greeting' }, - result: 'found: hello', }, ], observation: { - results: [{ output: 'search result' }], + results: [{ source_call_id: 'tc-1', content: 'search result' }], }, metrics: { prompt_tokens: 100, @@ -272,6 +271,33 @@ describe('AtifViewerPage', () => { renderPage('/atif?type=session&id=sess-from-url'); }); expect(mockFetchAtifBySession).toHaveBeenCalledWith('sess-from-url'); + expect(mockFetchAtifBySession).toHaveBeenCalledTimes(1); + }); + + it('should expand highlighted evidence sections from URL params', async () => { + mockFetchAtifByConversation.mockResolvedValue(mockAtifDoc); + await act(async () => { + renderPage('/atif?type=conversation&id=conv-1&highlight_call_id=tc-1'); + }); + + expect(mockFetchAtifByConversation).toHaveBeenCalledWith('conv-1'); + expect(screen.getByText('search')).toBeInTheDocument(); + expect(screen.getByText('search result')).toBeInTheDocument(); + }); + + it('should render an empty timeline when steps are missing', async () => { + mockFetchAtifBySession.mockResolvedValue({ + ...mockAtifDoc, + steps: undefined, + final_metrics: undefined, + }); + + await act(async () => { + renderPage('/atif?type=session&id=sess-without-steps'); + }); + + expect(screen.getByText('共 0 步')).toBeInTheDocument(); + expect(screen.getByText('该轨迹暂无步骤数据')).toBeInTheDocument(); }); it('should show Token savings comparison card when savings data exists', async () => { diff --git a/src/agentsight/dashboard/src/test/ConversationList.test.tsx b/src/agentsight/dashboard/src/test/ConversationList.test.tsx index a125a3fa9..b1e515338 100644 --- a/src/agentsight/dashboard/src/test/ConversationList.test.tsx +++ b/src/agentsight/dashboard/src/test/ConversationList.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, act } from '@testing-library/react'; +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; // Mock recharts @@ -29,6 +29,16 @@ vi.mock('../utils/apiClient', () => ({ fetchInterruptionSessionCounts: vi.fn(), fetchInterruptionConversationCounts: vi.fn(), fetchTokenSavings: vi.fn(), + fetchLatestEvaluation: vi.fn(), + evaluateConversation: vi.fn(), + EvaluationNotReadyError: class EvaluationNotReadyError extends Error { + pendingCallCount: number; + constructor(message: string, pendingCallCount: number) { + super(message); + this.name = 'EvaluationNotReadyError'; + this.pendingCallCount = pendingCallCount; + } + }, INTERRUPTION_TYPE_CN: { llm_error: 'LLM 错误', sse_truncated: 'SSE 中断', @@ -54,6 +64,14 @@ vi.mock('../components/InterruptionPanel', () => ({ ResolvedEventInfo: undefined, })); +vi.mock('../components/EvaluationBadge', () => ({ + EvaluationBadge: ({ result }: any) => result ? {result.verdict} : null, +})); + +vi.mock('../components/EvaluationPanel', () => ({ + EvaluationPanel: ({ conversationId }: any) =>
质量评估 {conversationId}
, +})); + import { fetchSessions, fetchAgentNames, @@ -64,6 +82,7 @@ import { fetchInterruptionConversationCounts, fetchTokenSavings, fetchTraces, + fetchLatestEvaluation, } from '../utils/apiClient'; import { ConversationList } from '../pages/ConversationList'; @@ -76,6 +95,7 @@ const mockFetchInterruptionSessionCounts = fetchInterruptionSessionCounts as Ret const mockFetchInterruptionConversationCounts = fetchInterruptionConversationCounts as ReturnType; const mockFetchTokenSavings = fetchTokenSavings as ReturnType; const mockFetchTraces = fetchTraces as ReturnType; +const mockFetchLatestEvaluation = fetchLatestEvaluation as ReturnType; function setupMocks() { mockFetchAgentNames.mockResolvedValue(['agent-a', 'agent-b']); @@ -87,6 +107,7 @@ function setupMocks() { mockFetchInterruptionConversationCounts.mockResolvedValue([]); mockFetchTokenSavings.mockResolvedValue({ sessions: [], summary: null, stats_available: false }); mockFetchTraces.mockResolvedValue([]); + mockFetchLatestEvaluation.mockResolvedValue(null); } function renderPage(route = '/') { @@ -251,6 +272,108 @@ describe('ConversationList', () => { expect(screen.getByText('Conversation ID')).toBeInTheDocument(); }); + it('should show latest grader badge and panel for a conversation', async () => { + mockFetchSessions.mockResolvedValue([ + { + session_id: 'sess-graded', + agent_name: 'GradeAgent', + model: 'gpt-4o', + conversation_count: 1, + total_input_tokens: 500, + total_output_tokens: 300, + last_seen_ns: Date.now() * 1_000_000, + }, + ]); + mockFetchTraces.mockResolvedValue([ + { + trace_id: 'trace-graded', + conversation_id: 'conv-graded', + user_query: 'grade this', + total_input_tokens: 300, + total_output_tokens: 200, + start_ns: Date.now() * 1_000_000, + end_ns: Date.now() * 1_000_000, + model: 'gpt-4o', + }, + ]); + mockFetchLatestEvaluation.mockResolvedValue({ + target_id: 'conv-graded', + verdict: 'warn', + score: 0.7, + }); + + await act(async () => { renderPage(); }); + await act(async () => { + fireEvent.click(screen.getByText('查询')); + }); + await act(async () => { + fireEvent.click(screen.getByText('GradeAgent').closest('tr')!); + }); + + expect(mockFetchLatestEvaluation).toHaveBeenCalledWith('conv-graded'); + const badge = await screen.findByTestId('evaluation-badge'); + expect(badge).toHaveTextContent('warn'); + + fireEvent.click(badge.closest('button')!); + expect(screen.getByTestId('evaluation-panel')).toHaveTextContent('conv-graded'); + }); + + it('should surface failed grader badge lookups and retry from the error state', async () => { + mockFetchSessions.mockResolvedValue([ + { + session_id: 'sess-retry', + agent_name: 'RetryAgent', + model: 'gpt-4o', + conversation_count: 11, + total_input_tokens: 1100, + total_output_tokens: 550, + last_seen_ns: Date.now() * 1_000_000, + }, + ]); + mockFetchTraces.mockResolvedValue( + Array.from({ length: 11 }, (_, i) => ({ + trace_id: `trace-${i}`, + conversation_id: i === 0 ? 'conv-retry' : `conv-${i}`, + user_query: `query ${i}`, + total_input_tokens: 100, + total_output_tokens: 50, + start_ns: Date.now() * 1_000_000 + i, + end_ns: Date.now() * 1_000_000 + i + 1, + model: 'gpt-4o', + })) + ); + mockFetchLatestEvaluation.mockImplementation((conversationId: string) => { + if (conversationId === 'conv-retry') { + return Promise.reject(new Error('temporary outage')); + } + return Promise.resolve(null); + }); + + await act(async () => { renderPage(); }); + await act(async () => { + fireEvent.click(screen.getByText('查询')); + }); + await act(async () => { + fireEvent.click(screen.getByText('RetryAgent').closest('tr')!); + }); + + expect(await screen.findByText('加载失败')).toBeInTheDocument(); + await waitFor(() => { + expect( + mockFetchLatestEvaluation.mock.calls.filter(([id]) => id === 'conv-retry') + ).toHaveLength(1); + }); + + await act(async () => { + fireEvent.click(screen.getByText('加载失败')); + }); + await waitFor(() => { + expect( + mockFetchLatestEvaluation.mock.calls.filter(([id]) => id === 'conv-retry') + ).toHaveLength(2); + }); + }); + it('should show agent dropdown with loaded names', async () => { await act(async () => { renderPage(); }); expect(screen.getByText('全部 Agent')).toBeInTheDocument(); diff --git a/src/agentsight/dashboard/src/test/EvaluationBadge.test.tsx b/src/agentsight/dashboard/src/test/EvaluationBadge.test.tsx new file mode 100644 index 000000000..5db3d73ff --- /dev/null +++ b/src/agentsight/dashboard/src/test/EvaluationBadge.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { EvaluationBadge } from '../components/EvaluationBadge'; + +describe('EvaluationBadge', () => { + it('renders nothing when result is null', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('renders pass verdict with score', () => { + render(); + expect(screen.getByText('通过')).toBeInTheDocument(); + expect(screen.getByText('93')).toBeInTheDocument(); + }); + + it('renders warn verdict', () => { + render(); + expect(screen.getByText('需复核')).toBeInTheDocument(); + }); + + it('renders fail verdict', () => { + render(); + expect(screen.getByText('未通过')).toBeInTheDocument(); + }); + + it('falls back to the raw verdict for unknown backend variants', () => { + render(); + + expect(screen.getByText('skipped')).toBeInTheDocument(); + expect(screen.getByText('50')).toBeInTheDocument(); + }); +}); diff --git a/src/agentsight/dashboard/src/test/EvaluationPanel.test.tsx b/src/agentsight/dashboard/src/test/EvaluationPanel.test.tsx new file mode 100644 index 000000000..8fb7b3785 --- /dev/null +++ b/src/agentsight/dashboard/src/test/EvaluationPanel.test.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('../utils/apiClient', async () => { + const actual = await vi.importActual('../utils/apiClient'); + return { + ...actual, + evaluateConversation: vi.fn(), + }; +}); + +import { EvaluationNotReadyError, evaluateConversation, EvaluationResult } from '../utils/apiClient'; +import { EvaluationPanel } from '../components/EvaluationPanel'; + +const mockEvaluate = evaluateConversation as ReturnType; + +const result: EvaluationResult = { + target_type: 'conversation', + target_id: 'conv-1', + run_id: 'run-1', + input_hash: 'hash-1', + verdict: 'warn', + score: 0.72, + summary: 'Conversation is usable but needs review.', + root_cause: 'partial_snapshot', + recommended_action: 'Wait for pending calls to complete.', + dimensions: [ + { + name: 'completion', + score: 0.85, + verdict: 'pass', + reason: 'A usable output exists.', + evidence_refs: [ + { + type: 'genai_event', + id: 'call-1', + label: 'Assistant output', + target: { + conversation_id: 'conv-1', + call_id: 'call-1', + }, + deeplink: { + route: '/atif', + query: { + type: 'conversation', + id: 'conv-1', + highlight_call_id: 'call-1', + }, + }, + metadata: null, + }, + ], + }, + ], + findings: [ + { + code: 'partial_snapshot', + severity: 'medium', + message: 'Evaluation was forced while calls were pending.', + evidence_refs: [], + }, + ], + metadata: { + evaluated_with_pending: true, + pending_call_count: 1, + input_event_count: 2, + grader_type: 'rule', + grader_version: 'rule-v1', + rubric_version: null, + judge_model: null, + prompt_hash: null, + confidence: null, + }, +}; + +beforeEach(() => { + mockEvaluate.mockReset(); +}); + +function renderPanel(ui: React.ReactElement) { + return render({ui}); +} + +describe('EvaluationPanel', () => { + it('renders evaluate button when no result exists', () => { + renderPanel(); + expect(screen.getByText('开始评估')).toBeInTheDocument(); + }); + + it('renders compact summary and pending warning', () => { + renderPanel(); + expect(screen.getByText('需复核')).toBeInTheDocument(); + expect(screen.getByText('72')).toBeInTheDocument(); + expect(screen.getByText('当前会话可用,但需要复核:快照未完成。')).toBeInTheDocument(); + expect(screen.getByText('评估时仍有 1 个 LLM 调用未完成。')).toBeInTheDocument(); + expect(screen.getByText('等待 pending 调用完成,或保留强制评估标记。')).toBeInTheDocument(); + }); + + it('updates the displayed result when initialResult changes', () => { + const view = renderPanel(); + + view.rerender( + + + + ); + + expect(screen.getByText('需复核')).toBeInTheDocument(); + expect(screen.getByText('72')).toBeInTheDocument(); + }); + + it('falls back for unknown root causes from newer backends', () => { + renderPanel( + + ); + + expect(screen.getByText('当前会话可用,但需要复核:provider_backoff。')).toBeInTheDocument(); + expect(screen.getByText('Apply provider-specific backoff.')).toBeInTheDocument(); + }); + + it('reveals dimensions and findings', () => { + renderPanel(); + fireEvent.click(screen.getByText('查看详情')); + expect(screen.getByText('完成度')).toBeInTheDocument(); + expect(screen.getAllByText('快照未完成').length).toBeGreaterThanOrEqual(2); + expect(screen.getByText('助手输出')).toBeInTheDocument(); + }); + + it('translates every interruption type used by grader findings and evidence', () => { + const interruptionTypes = [ + 'auth_error', + 'context_overflow', + 'dead_loop', + 'rate_limit', + 'retry_storm', + 'safety_filter', + 'token_limit', + ]; + const translatedResult: EvaluationResult = { + ...result, + findings: interruptionTypes.map((code, index) => ({ + code, + severity: 'high', + message: `Interruption ${index}`, + evidence_refs: [{ + type: 'interruption', + id: `interruption-${index}`, + label: code, + target: { conversation_id: 'conv-1' }, + }], + })), + }; + + renderPanel(); + fireEvent.click(screen.getByText('查看详情')); + + for (const label of ['鉴权错误', '上下文溢出', '死循环', '速率限制', '重试风暴', '安全过滤', 'Token 超限']) { + expect(screen.getAllByText(label)).toHaveLength(2); + } + }); + + it('uses unique keys for repeated findings and evidence refs', () => { + const duplicateFinding = result.findings[0]; + const duplicateRef = result.dimensions[0].evidence_refs[0]; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + renderPanel( + + ); + fireEvent.click(screen.getByText('查看详情')); + + const consoleOutput = consoleError.mock.calls.flat().join(' '); + expect(consoleOutput).not.toContain('Encountered two children with the same key'); + } finally { + consoleError.mockRestore(); + } + }); + + it('runs evaluation and emits the new result', async () => { + const onResult = vi.fn(); + mockEvaluate.mockResolvedValue({ result, reused_existing_run: false }); + renderPanel(); + + fireEvent.click(screen.getByText('开始评估')); + + await waitFor(() => expect(mockEvaluate).toHaveBeenCalledWith('conv-1', false)); + await waitFor(() => expect(onResult).toHaveBeenCalledWith(result)); + expect(screen.getByText('需复核')).toBeInTheDocument(); + }); + + it('shows force action after pending conflict', async () => { + mockEvaluate + .mockRejectedValueOnce(new EvaluationNotReadyError('pending', 2)) + .mockResolvedValueOnce({ result, reused_existing_run: false }); + renderPanel(); + + fireEvent.click(screen.getByText('开始评估')); + await waitFor(() => expect(screen.getByText(/2 个 LLM 调用仍未完成/)).toBeInTheDocument()); + + fireEvent.click(screen.getByText('强制评估')); + await waitFor(() => expect(mockEvaluate).toHaveBeenLastCalledWith('conv-1', true)); + expect(await screen.findByText('需复核')).toBeInTheDocument(); + }); +}); diff --git a/src/agentsight/dashboard/src/test/apiClient.test.ts b/src/agentsight/dashboard/src/test/apiClient.test.ts index 7b8a6fe56..8845e8907 100644 --- a/src/agentsight/dashboard/src/test/apiClient.test.ts +++ b/src/agentsight/dashboard/src/test/apiClient.test.ts @@ -20,6 +20,9 @@ import { fetchAgentHealth, deleteAgentHealth, restartAgentHealth, + fetchLatestEvaluation, + evaluateConversation, + EvaluationNotReadyError, INTERRUPTION_TYPE_CN, fetchSkillMetrics, fetchSecurityStatus, @@ -58,6 +61,11 @@ function mockErrorResponse(status: number, text: string) { beforeEach(() => { mockFetch.mockReset(); + window.location.hash = ''; +}); + +afterEach(() => { + window.location.hash = ''; }); describe('apiClient', () => { @@ -81,7 +89,10 @@ describe('apiClient', () => { mockFetch.mockResolvedValueOnce(mockJsonResponse([])); const result = await fetchSessions(); expect(result).toEqual([]); - expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/sessions')); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/sessions'), + expect.objectContaining({ credentials: 'same-origin' }), + ); }); it('should add start_ns and end_ns params', async () => { @@ -137,6 +148,67 @@ describe('apiClient', () => { }); }); + describe('Grader APIs', () => { + it('fetchLatestEvaluation should fetch latest conversation evaluation', async () => { + mockFetch.mockResolvedValueOnce(mockJsonResponse(null)); + const result = await fetchLatestEvaluation('conv-1'); + + expect(result).toBeNull(); + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('/api/grader/latest'); + expect(url).toContain('target_type=conversation'); + expect(url).toContain('target_id=conv-1'); + }); + + it('evaluateConversation should post a manual evaluation request', async () => { + const response = { result: { target_id: 'conv-1', verdict: 'pass' }, reused_existing_run: false }; + mockFetch.mockResolvedValueOnce(mockJsonResponse(response)); + const result = await evaluateConversation('conv-1', true); + + expect(result).toEqual(response); + expect(mockFetch.mock.calls[0][0]).toContain('/api/grader/evaluate'); + expect(mockFetch.mock.calls[0][1]).toMatchObject({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + }); + expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toEqual({ + target_type: 'conversation', + target_id: 'conv-1', + force: true, + }); + }); + + it('evaluateConversation should expose pending conflicts as EvaluationNotReadyError', async () => { + mockFetch.mockResolvedValueOnce(mockJsonResponse({ + error: 'conversation_not_ready', + pending_call_count: 2, + message: 'pending', + }, 409)); + + let caught: unknown; + try { + await evaluateConversation('conv-1'); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + name: 'EvaluationNotReadyError', + pendingCallCount: 2, + }); + expect(caught).toBeInstanceOf(EvaluationNotReadyError); + }); + + it('evaluateConversation should redirect to login after an unauthorized response', async () => { + mockFetch.mockResolvedValueOnce(mockJsonResponse({}, 401)); + + await expect(evaluateConversation('conv-1')).rejects.toThrow('Authentication required'); + + expect(window.location.hash).toBe('#/login'); + }); + }); + describe('fetchAgentNames', () => { it('should fetch agent names', async () => { mockFetch.mockResolvedValueOnce(mockJsonResponse(['agent-a', 'agent-b'])); @@ -264,7 +336,10 @@ describe('apiClient', () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, text: () => Promise.resolve('') }); await resolveInterruption('int-1'); expect(mockFetch.mock.calls[0][0]).toContain('/api/interruptions/int-1/resolve'); - expect(mockFetch.mock.calls[0][1]).toEqual({ method: 'POST' }); + expect(mockFetch.mock.calls[0][1]).toEqual({ + method: 'POST', + credentials: 'same-origin', + }); }); it('should throw on error', async () => { @@ -285,7 +360,10 @@ describe('apiClient', () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, text: () => Promise.resolve('') }); await deleteAgentHealth(1234); expect(mockFetch.mock.calls[0][0]).toContain('/api/agent-health/1234'); - expect(mockFetch.mock.calls[0][1]).toEqual({ method: 'DELETE' }); + expect(mockFetch.mock.calls[0][1]).toEqual({ + method: 'DELETE', + credentials: 'same-origin', + }); }); it('deleteAgentHealth error', async () => { @@ -302,7 +380,10 @@ describe('apiClient', () => { }); const result = await restartAgentHealth(1234); expect(result).toEqual(body); - expect(mockFetch.mock.calls[0][1]).toEqual({ method: 'POST' }); + expect(mockFetch.mock.calls[0][1]).toEqual({ + method: 'POST', + credentials: 'same-origin', + }); }); it('restartAgentHealth error', async () => { @@ -447,7 +528,10 @@ describe('apiClient', () => { mockFetch.mockResolvedValueOnce(mockJsonResponse(mockReport)); const result = await fetchSkillMetrics(); expect(result).toEqual(mockReport); - expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/skill-metrics')); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/skill-metrics'), + expect.objectContaining({ credentials: 'same-origin' }), + ); }); it('should add start_ns and end_ns query params', async () => { diff --git a/src/agentsight/dashboard/src/utils/apiClient.ts b/src/agentsight/dashboard/src/utils/apiClient.ts index d883aa147..b98e9c545 100644 --- a/src/agentsight/dashboard/src/utils/apiClient.ts +++ b/src/agentsight/dashboard/src/utils/apiClient.ts @@ -73,8 +73,20 @@ export interface TraceEventDetail { // ─── Internal helpers ──────────────────────────────────────────────────────── -async function apiFetch(url: string): Promise { - const res = await fetch(url, { credentials: 'same-origin' }); +class ApiRequestError extends Error { + readonly status: number; + readonly body: Record | null; + + constructor(url: string, status: number, text: string, body: Record | null) { + super(`API ${url} -> ${status}: ${text}`); + this.name = 'ApiRequestError'; + this.status = status; + this.body = body; + } +} + +async function apiFetch(url: string, init: RequestInit = {}): Promise { + const res = await fetch(url, { ...init, credentials: 'same-origin' }); if (res.status === 401) { // Session expired or invalid — redirect to login window.location.hash = '#/login'; @@ -82,7 +94,16 @@ async function apiFetch(url: string): Promise { } if (!res.ok) { const text = await res.text().catch(() => res.statusText); - throw new Error(`API ${url} -> ${res.status}: ${text}`); + let body: Record | null = null; + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + body = parsed as Record; + } + } catch { + // Error responses may be plain text. + } + throw new ApiRequestError(url, res.status, text, body); } return res.json() as Promise; } @@ -142,6 +163,142 @@ export async function fetchConversationDetail(conversationId: string): Promise; + } | null; + metadata?: Record | null; +} + +export interface EvaluationDimension { + name: string; + score: number; + verdict: EvaluationVerdict; + reason: string; + evidence_refs: EvaluationRef[]; +} + +export interface EvaluationFinding { + code: string; + severity: string; + message: string; + evidence_refs: EvaluationRef[]; +} + +export interface EvaluationMetadata { + evaluated_with_pending: boolean; + pending_call_count: number; + input_event_count: number; + grader_type: 'rule' | 'llm' | 'agent'; + grader_version: string; + rubric_version: string | null; + judge_model: string | null; + prompt_hash: string | null; + confidence: number | null; +} + +export interface EvaluationResult { + target_type: 'conversation'; + target_id: string; + run_id: string; + input_hash: string; + verdict: EvaluationVerdict; + score: number; + summary: string; + root_cause: EvaluationRootCause; + recommended_action: string; + dimensions: EvaluationDimension[]; + findings: EvaluationFinding[]; + metadata: EvaluationMetadata; +} + +export interface EvaluationResponse { + result: EvaluationResult; + reused_existing_run: boolean; +} + +export class EvaluationNotReadyError extends Error { + readonly pendingCallCount: number; + + constructor(message: string, pendingCallCount: number) { + super(message); + this.name = 'EvaluationNotReadyError'; + this.pendingCallCount = pendingCallCount; + } +} + +/** + * Fetch the latest persisted evaluation for a conversation. + */ +export async function fetchLatestEvaluation(conversationId: string): Promise { + const params = new URLSearchParams({ + target_type: 'conversation', + target_id: conversationId, + }); + return apiFetch(`${API_BASE}/api/grader/latest?${params.toString()}`); +} + +/** + * Manually evaluate a conversation with the rule-based grader. + */ +export async function evaluateConversation( + conversationId: string, + force = false, +): Promise { + try { + return await apiFetch(`${API_BASE}/api/grader/evaluate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + target_type: 'conversation', + target_id: conversationId, + force, + }), + }); + } catch (error) { + if ( + error instanceof ApiRequestError && + error.status === 409 && + error.body?.error === 'conversation_not_ready' + ) { + const message = error.body.message; + throw new EvaluationNotReadyError( + typeof message === 'string' ? message : 'Conversation still has pending LLM calls.', + Number(error.body.pending_call_count ?? 0), + ); + } + throw error; + } +} + // ─── Agent-name & time-series APIs ─────────────────────────────────────────── /**