sync: bug fixes + eval system from private repo

- Fix chunk_status: pass chunk_status/chunk/metadata to context builder
- Fix vector search: scope by node_id BEFORE similarity search
- Add eval logging system (RAH_EVALS_LOG=1)
- Add eval dashboard at /evals
- Add vitest for testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2025-12-28 16:09:25 +11:00
co-authored by Claude Opus 4.5
parent e15f223ed8
commit 2f2ef10ec9
29 changed files with 1489 additions and 50 deletions
+376
View File
@@ -0,0 +1,376 @@
'use client';
import { useMemo, useState } from 'react';
import type { EvalTrace } from '@/services/evals/evalsStore';
type Props = {
traces: EvalTrace[];
scenarioList: Array<{
id: string;
name: string;
description?: string;
tools?: string[];
enabled?: boolean;
notes?: string;
}>;
};
function formatPreview(text: string | null, max = 80) {
if (!text) return '';
const compact = text.replace(/\s+/g, ' ').trim();
return compact.length > max ? `${compact.slice(0, max)}...` : compact;
}
function statusLabel(success: number | null) {
if (success === null) return 'n/a';
return success ? 'success' : 'fail';
}
function badgeStyle(kind: 'success' | 'fail' | 'neutral') {
const base = { display: 'inline-block', padding: '2px 8px', borderRadius: 999, fontSize: 12 };
if (kind === 'success') return { ...base, background: '#e6f4ea', color: '#146c2e' };
if (kind === 'fail') return { ...base, background: '#fdecea', color: '#b42318' };
return { ...base, background: '#f2f2f2', color: '#333' };
}
function prettyJson(value: string | null) {
if (!value) return '';
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
export default function EvalsClient({ traces, scenarioList }: Props) {
const [openTraceId, setOpenTraceId] = useState<string | null>(traces[0]?.chat.trace_id || null);
const [comments, setComments] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
traces.forEach((trace) => {
if (trace.comment) {
initial[trace.chat.trace_id] = trace.comment;
}
});
return initial;
});
const [sourceFilter, setSourceFilter] = useState<string>('all'); // 'all' | 'live' | 'scenario'
const [scenarioFilter, setScenarioFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [searchFilter, setSearchFilter] = useState<string>('');
const rows = useMemo(() => {
return traces.map((trace) => {
const { chat, toolCalls } = trace;
const status = statusLabel(chat.success);
const isLive = !chat.scenario_id;
return {
trace,
id: chat.trace_id,
source: isLive ? 'live' : 'scenario',
scenario: chat.scenario_id || '—',
model: chat.model || 'n/a',
latency: chat.latency_ms ?? 'n/a',
tokens: `${chat.input_tokens ?? 0}/${chat.output_tokens ?? 0} (${chat.total_tokens ?? 0})`,
cost: chat.estimated_cost_usd ?? null,
cache: chat.cache_hit == null ? 'n/a' : chat.cache_hit ? 'hit' : 'miss',
cacheTokens: `${chat.cache_read_tokens ?? 0}/${chat.cache_write_tokens ?? 0}`,
toolCount: toolCalls.length,
status,
userPreview: formatPreview(chat.user_message),
timestamp: chat.ts,
mode: chat.mode || 'n/a',
workflow: chat.workflow_key || '—',
};
});
}, [traces]);
const filteredRows = useMemo(() => {
return rows.filter((row) => {
if (sourceFilter !== 'all' && row.source !== sourceFilter) return false;
if (scenarioFilter !== 'all' && row.scenario !== scenarioFilter) return false;
if (statusFilter !== 'all' && row.status !== statusFilter) return false;
if (searchFilter.trim()) {
const needle = searchFilter.toLowerCase();
if (!row.userPreview.toLowerCase().includes(needle)) return false;
}
return true;
});
}, [rows, sourceFilter, scenarioFilter, statusFilter, searchFilter]);
const openTrace = traces.find((trace) => trace.chat.trace_id === openTraceId) || traces[0];
return (
<div>
<div style={{ marginBottom: 24, border: '1px solid #ddd', borderRadius: 8, background: '#fff', padding: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>Scenario Set</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid #ddd' }}>
<th style={{ padding: '6px 6px' }}>ID</th>
<th style={{ padding: '6px 6px' }}>Name</th>
<th style={{ padding: '6px 6px' }}>Description</th>
<th style={{ padding: '6px 6px' }}>Tools</th>
<th style={{ padding: '6px 6px' }}>Enabled</th>
<th style={{ padding: '6px 6px' }}>Notes</th>
</tr>
</thead>
<tbody>
{scenarioList.map((scenario) => (
<tr key={scenario.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '6px 6px' }}>{scenario.id}</td>
<td style={{ padding: '6px 6px' }}>{scenario.name}</td>
<td style={{ padding: '6px 6px', color: '#555' }}>{scenario.description || '—'}</td>
<td style={{ padding: '6px 6px', color: '#555' }}>{scenario.tools?.join(', ') || '—'}</td>
<td style={{ padding: '6px 6px' }}>{scenario.enabled === false ? 'no' : 'yes'}</td>
<td style={{ padding: '6px 6px', color: '#555' }}>{scenario.notes || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div style={{ overflowX: 'auto' }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', marginBottom: 12 }}>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span>Source</span>
<select
value={sourceFilter}
onChange={(event) => setSourceFilter(event.target.value)}
style={{ padding: '4px 6px', borderRadius: 6, border: '1px solid #ddd', fontWeight: 500 }}
>
<option value="all">All</option>
<option value="live">🟢 Live Runs</option>
<option value="scenario">🔬 Scenarios</option>
</select>
</label>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span>Scenario</span>
<select
value={scenarioFilter}
onChange={(event) => setScenarioFilter(event.target.value)}
style={{ padding: '4px 6px', borderRadius: 6, border: '1px solid #ddd' }}
>
<option value="all">All</option>
{Array.from(new Set(rows.filter(row => row.scenario !== '—').map(row => row.scenario))).map((scenario) => (
<option key={scenario} value={scenario}>{scenario}</option>
))}
</select>
</label>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span>Status</span>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
style={{ padding: '4px 6px', borderRadius: 6, border: '1px solid #ddd' }}
>
<option value="all">All</option>
<option value="success">Success</option>
<option value="fail">Fail</option>
<option value="n/a">N/A</option>
</select>
</label>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span>Search</span>
<input
value={searchFilter}
onChange={(event) => setSearchFilter(event.target.value)}
placeholder="User input contains..."
style={{ padding: '4px 6px', borderRadius: 6, border: '1px solid #ddd', minWidth: 240 }}
/>
</label>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid #ddd' }}>
<th style={{ padding: '8px 6px' }}>Source</th>
<th style={{ padding: '8px 6px' }}>Scenario</th>
<th style={{ padding: '8px 6px' }}>Status</th>
<th style={{ padding: '8px 6px' }}>Latency</th>
<th style={{ padding: '8px 6px' }}>Tokens</th>
<th style={{ padding: '8px 6px' }}>Cost</th>
<th style={{ padding: '8px 6px' }}>Cache</th>
<th style={{ padding: '8px 6px' }}>Tools</th>
<th style={{ padding: '8px 6px' }}>Model</th>
<th style={{ padding: '8px 6px' }}>Mode</th>
<th style={{ padding: '8px 6px' }}>Workflow</th>
<th style={{ padding: '8px 6px' }}>User Input</th>
<th style={{ padding: '8px 6px' }}>Time</th>
<th style={{ padding: '8px 6px' }}>Comment</th>
</tr>
</thead>
<tbody>
{filteredRows.map((row) => {
const isOpen = row.id === openTraceId;
const statusKind = row.status === 'success' ? 'success' : row.status === 'fail' ? 'fail' : 'neutral';
const commentValue = comments[row.id] || '';
return (
<tr
key={row.id}
onClick={() => setOpenTraceId(row.id)}
style={{
cursor: 'pointer',
background: isOpen ? '#eef4ff' : '#fff',
borderBottom: '1px solid #eee',
}}
>
<td style={{ padding: '8px 6px' }}>
<span style={{
...badgeStyle(row.source === 'live' ? 'success' : 'neutral'),
fontSize: 11,
}}>
{row.source === 'live' ? '🟢 Live' : '🔬 Scenario'}
</span>
</td>
<td style={{ padding: '8px 6px' }}>{row.scenario}</td>
<td style={{ padding: '8px 6px' }}><span style={badgeStyle(statusKind)}>{row.status}</span></td>
<td style={{ padding: '8px 6px' }}>{row.latency} ms</td>
<td style={{ padding: '8px 6px' }}>{row.tokens}</td>
<td style={{ padding: '8px 6px' }}>{row.cost == null ? 'n/a' : `$${row.cost.toFixed(4)}`}</td>
<td style={{ padding: '8px 6px' }}>{row.cache} ({row.cacheTokens})</td>
<td style={{ padding: '8px 6px' }}>{row.toolCount}</td>
<td style={{ padding: '8px 6px' }}>{row.model}</td>
<td style={{ padding: '8px 6px' }}>{row.mode}</td>
<td style={{ padding: '8px 6px' }}>{row.workflow}</td>
<td style={{ padding: '8px 6px', color: '#555' }}>{row.userPreview}</td>
<td style={{ padding: '8px 6px', color: '#666' }}>{row.timestamp}</td>
<td style={{ padding: '8px 6px' }}>
<textarea
value={commentValue}
onChange={(event) => {
const nextValue = event.target.value;
setComments((prev) => ({ ...prev, [row.id]: nextValue }));
}}
onBlur={async (event) => {
const nextValue = event.target.value;
try {
await fetch('/api/evals/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
traceId: row.id,
scenarioId: row.scenario,
comment: nextValue,
}),
});
} catch {
// Ignore failures in the UI, keep local state.
}
}}
onClick={(event) => event.stopPropagation()}
rows={2}
style={{
width: 220,
resize: 'vertical',
fontFamily: 'inherit',
fontSize: 12,
padding: 6,
borderRadius: 6,
border: '1px solid #ddd',
}}
placeholder="Add notes..."
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{openTrace ? (
<div style={{ marginTop: 24, border: '1px solid #ddd', borderRadius: 8, background: '#fff', padding: 16 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 12 }}>
<div><strong>Source:</strong> {openTrace.chat.scenario_id ? '🔬 Scenario' : '🟢 Live Run'}</div>
<div><strong>Scenario:</strong> {openTrace.chat.scenario_id || '—'}</div>
<div><strong>Trace:</strong> {openTrace.chat.trace_id}</div>
<div><strong>Helper:</strong> {openTrace.chat.helper_name || 'n/a'}</div>
<div><strong>Model:</strong> {openTrace.chat.model || 'n/a'}</div>
<div><strong>Mode:</strong> {openTrace.chat.mode || 'n/a'}</div>
<div><strong>Workflow:</strong> {openTrace.chat.workflow_key || '—'}</div>
<div><strong>Latency:</strong> {openTrace.chat.latency_ms ?? 'n/a'} ms</div>
<div><strong>Tokens:</strong> {openTrace.chat.input_tokens ?? 0}/{openTrace.chat.output_tokens ?? 0} (total {openTrace.chat.total_tokens ?? 0})</div>
<div><strong>Cost:</strong> {openTrace.chat.estimated_cost_usd == null ? 'n/a' : `$${openTrace.chat.estimated_cost_usd.toFixed(4)}`}</div>
<div><strong>Cache:</strong> {openTrace.chat.cache_hit == null ? 'n/a' : openTrace.chat.cache_hit ? 'hit' : 'miss'} (read {openTrace.chat.cache_read_tokens ?? 0} / write {openTrace.chat.cache_write_tokens ?? 0})</div>
<div><strong>Tools:</strong> {openTrace.toolCalls.length}</div>
</div>
<div style={{ marginBottom: 12 }}>
<div><strong>Comment:</strong></div>
<div style={{ color: '#555' }}>{comments[openTrace.chat.trace_id] || '—'}</div>
</div>
<details open>
<summary style={{ cursor: 'pointer', marginBottom: 8 }}><strong>System Message</strong></summary>
<pre style={{ whiteSpace: 'pre-wrap' }}>{openTrace.chat.system_message || 'n/a'}</pre>
</details>
<div style={{ marginTop: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>Trace Steps</div>
{[
{
type: 'chat',
ts: openTrace.chat.ts,
title: 'LLM Chat',
spanId: openTrace.chat.span_id,
parentSpanId: null,
latency: openTrace.chat.latency_ms,
success: openTrace.chat.success,
content: (
<>
<div><strong>User</strong></div>
<pre style={{ whiteSpace: 'pre-wrap' }}>{openTrace.chat.user_message}</pre>
<div><strong>Assistant</strong></div>
<pre style={{ whiteSpace: 'pre-wrap' }}>{openTrace.chat.assistant_message}</pre>
</>
),
},
...openTrace.toolCalls.map((tool) => ({
type: 'tool',
ts: tool.ts,
title: `Tool: ${tool.tool_name}`,
spanId: tool.span_id,
parentSpanId: tool.parent_span_id,
latency: tool.latency_ms,
success: tool.success,
content: (
<>
<div><strong>Args</strong></div>
<pre style={{ whiteSpace: 'pre-wrap' }}>{prettyJson(tool.args_json)}</pre>
<div><strong>Result</strong></div>
<pre style={{ whiteSpace: 'pre-wrap' }}>{prettyJson(tool.result_json)}</pre>
{tool.error ? (
<>
<div><strong>Error</strong></div>
<pre style={{ whiteSpace: 'pre-wrap' }}>{tool.error}</pre>
</>
) : null}
</>
),
})),
].sort((a, b) => a.ts.localeCompare(b.ts)).map((step, index) => {
const stepStatus = statusLabel(step.success);
const stepKind = stepStatus === 'success' ? 'success' : stepStatus === 'fail' ? 'fail' : 'neutral';
return (
<div key={`${openTrace.chat.trace_id}-${step.type}-${index}`} style={{ borderTop: '1px solid #eee', paddingTop: 12, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<div><strong>Step {index + 1}:</strong> {step.title}</div>
<span style={badgeStyle(stepKind)}>{stepStatus}</span>
<div style={{ color: '#666' }}>{step.ts}</div>
</div>
<div style={{ marginTop: 6, color: '#555' }}>
<div><strong>Span:</strong> {step.spanId || 'n/a'}</div>
{step.parentSpanId ? <div><strong>Parent Span:</strong> {step.parentSpanId}</div> : null}
<div><strong>Latency:</strong> {step.latency ?? 'n/a'} ms</div>
</div>
<div style={{ marginTop: 8 }}>{step.content}</div>
</div>
);
})}
</div>
</div>
) : null}
</div>
);
}