From 6972bb97602591a5705b47e5130839da28c40bbc Mon Sep 17 00:00:00 2001 From: wanghao <1562495626@qq.com> Date: Wed, 8 Jul 2026 15:38:00 +0800 Subject: [PATCH 1/3] feat(sight): add grader dashboard controls Add conversation-list controls, latest-run badges, and a detail panel for manually running and inspecting grader results. Keep the frontend split from the Rust API implementation so the dashboard surface can be reviewed independently. Assisted-by: Codex:GPT-5 Signed-off-by: wanghao <1562495626@qq.com> --- .../src/components/EvaluationBadge.tsx | 32 ++ .../src/components/EvaluationPanel.tsx | 336 ++++++++++++++++++ .../dashboard/src/pages/AtifViewerPage.tsx | 27 +- .../dashboard/src/pages/ConversationList.tsx | 95 ++++- .../src/test/AtifViewerPage.test.tsx | 16 +- .../src/test/ConversationList.test.tsx | 67 ++++ .../src/test/EvaluationBadge.test.tsx | 27 ++ .../src/test/EvaluationPanel.test.tsx | 134 +++++++ .../dashboard/src/test/apiClient.test.ts | 55 +++ .../dashboard/src/utils/apiClient.ts | 132 +++++++ 10 files changed, 908 insertions(+), 13 deletions(-) create mode 100644 src/agentsight/dashboard/src/components/EvaluationBadge.tsx create mode 100644 src/agentsight/dashboard/src/components/EvaluationPanel.tsx create mode 100644 src/agentsight/dashboard/src/test/EvaluationBadge.test.tsx create mode 100644 src/agentsight/dashboard/src/test/EvaluationPanel.test.tsx diff --git a/src/agentsight/dashboard/src/components/EvaluationBadge.tsx b/src/agentsight/dashboard/src/components/EvaluationBadge.tsx new file mode 100644 index 000000000..383aacb42 --- /dev/null +++ b/src/agentsight/dashboard/src/components/EvaluationBadge.tsx @@ -0,0 +1,32 @@ +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; + + return ( + + {LABEL_BY_VERDICT[result.verdict]} + {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..020316a32 --- /dev/null +++ b/src/agentsight/dashboard/src/components/EvaluationPanel.tsx @@ -0,0 +1,336 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + EvaluationNotReadyError, + EvaluationRef, + EvaluationResult, + 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); + + 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) => { + 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) => ( +
+
+ + {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]; +} + +function rootCauseLabel(value: EvaluationResult['root_cause']): 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]; +} + +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] ?? 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..4cf81c7e1 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 = { @@ -392,7 +407,14 @@ 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() }; + if (searchParams.get('id') === i.trim()) { + const highlightCallId = searchParams.get('highlight_call_id'); + const interruptionId = searchParams.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 +428,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) @@ -417,7 +440,7 @@ export const AtifViewerPage: React.FC = () => { } finally { setLoading(false); } - }, [queryType, queryId, setSearchParams]); + }, [queryType, queryId, searchParams, setSearchParams]); // Auto-load from URL on mount useEffect(() => { diff --git a/src/agentsight/dashboard/src/pages/ConversationList.tsx b/src/agentsight/dashboard/src/pages/ConversationList.tsx index 393f3cf4b..c5b17e27b 100644 --- a/src/agentsight/dashboard/src/pages/ConversationList.tsx +++ b/src/agentsight/dashboard/src/pages/ConversationList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { LineChart, Line, BarChart, Bar, @@ -6,6 +6,8 @@ import { } from 'recharts'; import { InterruptionBadge } from '../components/InterruptionBadge'; import { InterruptionPanel, ResolvedEventInfo } from '../components/InterruptionPanel'; +import { EvaluationBadge } from '../components/EvaluationBadge'; +import { EvaluationPanel } from '../components/EvaluationPanel'; import { DateTimePicker } from '../components/DateTimePicker'; import { SessionIdHelp } from '../components/SessionIdHelp'; import { @@ -18,6 +20,7 @@ import { fetchInterruptionStats, fetchInterruptionSessionCounts, fetchInterruptionConversationCounts, + fetchLatestEvaluation, fetchTokenSavings, SessionSummary, TraceSummary, @@ -28,6 +31,7 @@ import { InterruptionTypeStat, SessionInterruptionCount, ConversationInterruptionCount, + EvaluationResult, INTERRUPTION_TYPE_CN, } from '../utils/apiClient'; @@ -316,16 +320,63 @@ const TraceSubTable: React.FC = ({ 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()); useEffect(() => { setLoading(true); setPage(0); + setEvaluations(new Map()); + setEvaluationLookupDone(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) + ); + if (missing.length === 0) return; + + let cancelled = false; + Promise.all( + missing.map((trace) => + fetchLatestEvaluation(trace.conversation_id) + .then((result) => result ? [trace.conversation_id, result] as const : null) + .catch(() => null) + ) + ).then((entries) => { + if (cancelled) return; + setEvaluations((prev) => { + const next = new Map(prev); + for (const entry of entries) { + if (entry) next.set(entry[0], entry[1]); + } + return next; + }); + setEvaluationLookupDone((prev) => { + const next = new Set(prev); + for (const trace of missing) next.add(trace.conversation_id); + return next; + }); + }); + + return () => { + cancelled = true; + }; + }, [evaluationLookupDone, loading, pageTraces]); + if (loading) return ( @@ -343,21 +394,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 +424,7 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn -
+
{/* Col 1: Conversation ID */}
@@ -416,6 +465,20 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn 详情
+
+ +
{(() => { const ic = conversationInterruptionCounts.get(tr.conversation_id); @@ -432,8 +495,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..c8e4318ab 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, @@ -274,6 +273,17 @@ describe('AtifViewerPage', () => { expect(mockFetchAtifBySession).toHaveBeenCalledWith('sess-from-url'); }); + 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 show Token savings comparison card when savings data exists', async () => { mockFetchAtifBySession.mockResolvedValue(mockAtifDoc); mockFetchSessionSavings.mockResolvedValue({ diff --git a/src/agentsight/dashboard/src/test/ConversationList.test.tsx b/src/agentsight/dashboard/src/test/ConversationList.test.tsx index a125a3fa9..1503432ec 100644 --- a/src/agentsight/dashboard/src/test/ConversationList.test.tsx +++ b/src/agentsight/dashboard/src/test/ConversationList.test.tsx @@ -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,52 @@ 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 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..a6d46d7d9 --- /dev/null +++ b/src/agentsight/dashboard/src/test/EvaluationBadge.test.tsx @@ -0,0 +1,27 @@ +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(); + }); +}); 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..2de8068fb --- /dev/null +++ b/src/agentsight/dashboard/src/test/EvaluationPanel.test.tsx @@ -0,0 +1,134 @@ +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('reveals dimensions and findings', () => { + renderPanel(); + fireEvent.click(screen.getByText('查看详情')); + expect(screen.getByText('完成度')).toBeInTheDocument(); + expect(screen.getAllByText('快照未完成').length).toBeGreaterThanOrEqual(2); + expect(screen.getByText('助手输出')).toBeInTheDocument(); + }); + + 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..6a0ff6f9d 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, @@ -137,6 +140,58 @@ 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' }, + }); + 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); + }); + }); + describe('fetchAgentNames', () => { it('should fetch agent names', async () => { mockFetch.mockResolvedValueOnce(mockJsonResponse(['agent-a', 'agent-b'])); diff --git a/src/agentsight/dashboard/src/utils/apiClient.ts b/src/agentsight/dashboard/src/utils/apiClient.ts index d883aa147..be4b9481a 100644 --- a/src/agentsight/dashboard/src/utils/apiClient.ts +++ b/src/agentsight/dashboard/src/utils/apiClient.ts @@ -142,6 +142,138 @@ 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 { + const res = await fetch(`${API_BASE}/api/grader/evaluate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + target_type: 'conversation', + target_id: conversationId, + force, + }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + if (res.status === 409 && body?.error === 'conversation_not_ready') { + throw new EvaluationNotReadyError( + body.message ?? 'Conversation still has pending LLM calls.', + Number(body.pending_call_count ?? 0), + ); + } + throw new Error(`POST /api/grader/evaluate -> ${res.status}: ${body?.message ?? res.statusText}`); + } + return body as EvaluationResponse; +} + // ─── Agent-name & time-series APIs ─────────────────────────────────────────── /** From d8bc922d62caf4aea15708bd0d7bf0301678814b Mon Sep 17 00:00:00 2001 From: wanghao <1562495626@qq.com> Date: Thu, 9 Jul 2026 19:55:06 +0800 Subject: [PATCH 2/3] fix(sight): add grader dashboard fallbacks Only successful latest-run lookups mark conversations as fetched. Failed lookups now surface a retryable badge state instead of hiding errors. Unknown backend verdict and root-cause values fall back to raw labels and server actions. This keeps the dashboard compatible with small grader API enum extensions. Assisted-by: Codex:GPT-5 Signed-off-by: wanghao <1562495626@qq.com> --- .../src/components/EvaluationBadge.tsx | 11 ++- .../src/components/EvaluationPanel.tsx | 10 +- .../dashboard/src/pages/ConversationList.tsx | 95 +++++++++++++++---- .../src/test/ConversationList.test.tsx | 58 ++++++++++- .../src/test/EvaluationBadge.test.tsx | 7 ++ .../src/test/EvaluationPanel.test.tsx | 16 ++++ 6 files changed, 170 insertions(+), 27 deletions(-) diff --git a/src/agentsight/dashboard/src/components/EvaluationBadge.tsx b/src/agentsight/dashboard/src/components/EvaluationBadge.tsx index 383aacb42..43882c564 100644 --- a/src/agentsight/dashboard/src/components/EvaluationBadge.tsx +++ b/src/agentsight/dashboard/src/components/EvaluationBadge.tsx @@ -20,12 +20,19 @@ const LABEL_BY_VERDICT = { 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_BY_VERDICT[result.verdict]} + {label} {Math.round(result.score * 100)} ); diff --git a/src/agentsight/dashboard/src/components/EvaluationPanel.tsx b/src/agentsight/dashboard/src/components/EvaluationPanel.tsx index 020316a32..b4d64c3d9 100644 --- a/src/agentsight/dashboard/src/components/EvaluationPanel.tsx +++ b/src/agentsight/dashboard/src/components/EvaluationPanel.tsx @@ -207,7 +207,7 @@ function recommendedActionText(result: EvaluationResult): string { return '暂无需要立即处理的动作。'; } - const actions: Record = { + const actions: Record = { none: '复核告警项和支撑证据。', no_final_answer: '检查最后一次 LLM 调用和服务端响应解析。', interrupted_main_call: '检查中断证据,修复运行稳定性后再重试会话。', @@ -220,11 +220,11 @@ function recommendedActionText(result: EvaluationResult): string { partial_snapshot: '等待 pending 调用完成,或保留强制评估标记。', }; - return actions[result.root_cause]; + return actions[result.root_cause] ?? result.recommended_action ?? result.root_cause; } -function rootCauseLabel(value: EvaluationResult['root_cause']): string { - const labels: Record = { +function rootCauseLabel(value: string): string { + const labels: Record = { none: '未发现明确根因', no_final_answer: '未生成最终回答', interrupted_main_call: '主调用被中断', @@ -237,7 +237,7 @@ function rootCauseLabel(value: EvaluationResult['root_cause']): string { partial_snapshot: '快照未完成', }; - return labels[value]; + return labels[value] ?? value; } function dimensionLabel(value: string): string { diff --git a/src/agentsight/dashboard/src/pages/ConversationList.tsx b/src/agentsight/dashboard/src/pages/ConversationList.tsx index c5b17e27b..92ec23b95 100644 --- a/src/agentsight/dashboard/src/pages/ConversationList.tsx +++ b/src/agentsight/dashboard/src/pages/ConversationList.tsx @@ -323,12 +323,14 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn 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)) @@ -345,7 +347,9 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn if (loading || pageTraces.length === 0) return; const missing = pageTraces.filter( - (trace) => !evaluationLookupDone.has(trace.conversation_id) + (trace) => + !evaluationLookupDone.has(trace.conversation_id) && + !evaluationLookupFailed.has(trace.conversation_id) ); if (missing.length === 0) return; @@ -353,29 +357,58 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn Promise.all( missing.map((trace) => fetchLatestEvaluation(trace.conversation_id) - .then((result) => result ? [trace.conversation_id, result] as const : null) - .catch(() => null) + .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) next.set(entry[0], entry[1]); + if (entry.ok && entry.result) { + next.set(entry.conversationId, entry.result); + } } return next; }); setEvaluationLookupDone((prev) => { const next = new Set(prev); - for (const trace of missing) next.add(trace.conversation_id); - return next; + 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, loading, pageTraces]); + }, [evaluationLookupDone, evaluationLookupFailed, loading, pageTraces]); if (loading) return ( @@ -466,18 +499,42 @@ const TraceSubTable: React.FC = ({ sessionId, conversationIn
- + {(() => { + const evaluation = evaluations.get(tr.conversation_id); + const lookupFailed = evaluationLookupFailed.has(tr.conversation_id); + return ( + + ); + })()}
{(() => { diff --git a/src/agentsight/dashboard/src/test/ConversationList.test.tsx b/src/agentsight/dashboard/src/test/ConversationList.test.tsx index 1503432ec..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 @@ -318,6 +318,62 @@ describe('ConversationList', () => { 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 index a6d46d7d9..5db3d73ff 100644 --- a/src/agentsight/dashboard/src/test/EvaluationBadge.test.tsx +++ b/src/agentsight/dashboard/src/test/EvaluationBadge.test.tsx @@ -24,4 +24,11 @@ describe('EvaluationBadge', () => { 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 index 2de8068fb..2c3df0931 100644 --- a/src/agentsight/dashboard/src/test/EvaluationPanel.test.tsx +++ b/src/agentsight/dashboard/src/test/EvaluationPanel.test.tsx @@ -98,6 +98,22 @@ describe('EvaluationPanel', () => { expect(screen.getByText('等待 pending 调用完成,或保留强制评估标记。')).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('查看详情')); From 6dcfb9a16a06818e2dc217e676c709e78fb95954 Mon Sep 17 00:00:00 2001 From: wanghao <1562495626@qq.com> Date: Fri, 10 Jul 2026 14:21:28 +0800 Subject: [PATCH 3/3] fix(sight): address grader review feedback Reuse the authenticated API client for evaluation POST requests so grader actions carry session credentials and share 401 handling. Synchronize incoming results, translate interruption findings, and use unique list keys. Keep ATIF URL loading single-shot and tolerate missing step arrays. The dashboard API contract and endpoint shapes remain unchanged. Assisted-by: Codex:GPT-5 Signed-off-by: wanghao <1562495626@qq.com> --- .../src/components/EvaluationPanel.tsx | 17 +++-- .../dashboard/src/pages/AtifViewerPage.tsx | 27 ++++--- .../src/test/AtifViewerPage.test.tsx | 16 ++++ .../src/test/EvaluationPanel.test.tsx | 74 +++++++++++++++++++ .../dashboard/src/test/apiClient.test.ts | 39 ++++++++-- .../dashboard/src/utils/apiClient.ts | 63 +++++++++++----- 6 files changed, 196 insertions(+), 40 deletions(-) diff --git a/src/agentsight/dashboard/src/components/EvaluationPanel.tsx b/src/agentsight/dashboard/src/components/EvaluationPanel.tsx index b4d64c3d9..552aecdbd 100644 --- a/src/agentsight/dashboard/src/components/EvaluationPanel.tsx +++ b/src/agentsight/dashboard/src/components/EvaluationPanel.tsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react'; +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'; @@ -26,6 +27,10 @@ export const EvaluationPanel: React.FC = ({ const [pendingCount, setPendingCount] = useState(null); const [error, setError] = useState(null); + useEffect(() => { + setResult(initialResult); + }, [initialResult]); + const runEvaluation = async (force: boolean) => { setLoading(true); setError(null); @@ -50,11 +55,11 @@ export const EvaluationPanel: React.FC = ({ return (
- {refs.slice(0, 3).map((ref) => { + {refs.slice(0, 3).map((ref, index) => { const path = evidencePath(ref); return (