feat(trace): add diagnosis trace workbench

This commit is contained in:
aruo
2026-07-07 01:24:56 +08:00
parent b3315ead52
commit aa035b828c
9 changed files with 1505 additions and 2 deletions
@@ -0,0 +1,70 @@
# Trace UI workbench design
## Subject and audience
Subject: a single-session diagnosis trace audit workbench.
Audience: backend and AIOps engineers reviewing an MVP diagnosis run after a
demo, incident drill, or regression check.
Single job: turn one trace response into an inspectable ledger that exposes
agent flow, evidence, verifier judgement, and skill boundaries without reading
raw JSON first.
## Frontend approach
- Static `trace.html`, `trace.css`, and `trace.js`.
- No dependency on a package manager, bundler, external fonts, or remote icons.
- Fetches `/api/diagnosis/{sessionId}/trace` and renders client-side.
- Accepts `?sessionId=...` and keeps the loaded session id in the URL.
## Visual direction
Palette:
- `#111827` ink rail for the trace frame.
- `#f7f3ea` warm ledger surface.
- `#1f7a6b` evidence green.
- `#b5472f` rejection red.
- `#c58a19` warning amber.
- `#5b6472` operational gray.
Type:
- UI/body: `Inter, ui-sans-serif, system-ui`.
- Data and labels: `ui-monospace, SFMono-Regular, Consolas`.
Layout:
```text
+--------------------------------------------------------------------+
| Session id input | Load | status / verdict / counts strip |
+------------------+----------------------+--------------------------+
| Agent trace rail | Evidence tool ledger | Inspector |
| ordered steps | filters + calls | verifier + RAG + skill |
+------------------+----------------------+--------------------------+
```
Signature element: a trace rail that treats each agent step as a ledger entry
with ordered markers, duration, token count, and expandable raw model excerpts.
## Data mapping
- `data.session`: summary strip, final answer, self-evaluation source.
- `data.steps`: ordered trace rail.
- `data.toolInvocations`: evidence ledger and selected tool inspector.
- `data.session.selfEvaluation.verifier_evaluation`: verdict, groundedness,
facts table, and verifier `tool_trace_summary`.
- `data.toolInvocations[*].retrievalDetails`: RAG query transform, retrieval
trace, context pack, rerank trace, and evidence blocks.
- `steps[*].modelOutput`: best-effort extraction of `selected_skill`.
- `steps[*].modelInput/modelOutput` and tool names: best-effort skill boundary
checks for `read_skill`.
## Accessibility and states
- Keyboard-focusable controls.
- Loading, missing session id, API error, empty steps, empty tools, and missing
verifier states.
- Responsive three-pane desktop layout that stacks on narrow screens.
- Respects `prefers-reduced-motion`.
@@ -0,0 +1,37 @@
# Trace UI workbench
## Why
The MVP already persists diagnosis trace data and exposes it through
`GET /api/diagnosis/{sessionId}/trace`, but reviewers still need to inspect raw
JSON to answer basic audit questions:
- Which agents ran, in what order, and with what persisted counts?
- Which evidence tools executed, how many times, and did they succeed?
- Which facts did the Verifier check, and which evidence refs support them?
- Did Planner only select skill metadata while Executor handled skill loading?
- What RAG retrieval details were used by `lookup_knowledge`?
This slows down demo review and makes trace quality issues harder to spot.
## What changes
- Add a static Trace workbench page served by Spring Boot static resources.
- Load a diagnosis trace by session id through the existing read-only Trace API.
- Render session summary, agent timeline, evidence tool ledger, verifier facts,
skill boundary checks, and RAG retrieval details.
- Add a navigation entry from the existing chat page to the Trace workbench.
## Non-goals
- No new backend endpoint.
- No mutation of diagnosis sessions, agent steps, tool invocations, or feedback.
- No new frontend framework or build pipeline.
- No change to the persisted trace schema.
## Impact
- Frontend-only runtime surface under `src/main/resources/static`.
- Uses the existing `Result<T>` API response contract.
- Works with existing trace records, including sessions that lack verifier or RAG
detail fields.
@@ -0,0 +1,36 @@
## ADDED Requirements
### Requirement: MVP demo SHALL provide a browser trace workbench
The MVP demo SHALL provide a browser-accessible static page for inspecting one
diagnosis trace by session id using the existing read-only Trace API.
#### Scenario: Existing trace renders in the workbench
- **WHEN** a reviewer opens the Trace workbench with a session id that exists
- **THEN** the page SHALL request `GET /api/diagnosis/{sessionId}/trace`
- **AND** it SHALL render session summary, ordered agent steps, ordered tool
invocations, verifier evaluation, and final answer when present
#### Scenario: Trace workbench handles missing or failed traces
- **WHEN** the Trace API returns an error or the session id is empty
- **THEN** the page SHALL show a clear error or empty state without mutating any
diagnosis data
### Requirement: Trace workbench SHALL expose evidence and skill boundaries
The Trace workbench SHALL make tool evidence and skill-loading boundaries visible
without requiring raw JSON inspection first.
#### Scenario: Tool and verifier evidence are inspectable
- **WHEN** a trace includes tool invocations and verifier facts
- **THEN** the page SHALL show tool invocation counts, success status, tool
filters, verifier facts, and evidence references
#### Scenario: RAG details are inspectable for lookup knowledge calls
- **WHEN** a `lookup_knowledge` invocation includes retrieval details
- **THEN** the page SHALL show query transform, retrieval trace, context pack,
rerank trace, and evidence block data where available
#### Scenario: Skill boundary checks are visible
- **WHEN** a trace includes planner, executor, or verifier steps
- **THEN** the page SHALL show best-effort indicators for selected skill,
planner `read_skill` text mentions, executor `read_skill` text mentions, and
verifier `read_skill` text mentions
@@ -0,0 +1,9 @@
# Tasks
- [x] 1. Add OpenSpec delta for the Trace UI workbench.
- [x] 2. Add static Trace workbench HTML/CSS/JS.
- [x] 3. Add a chat-page navigation entry to the Trace workbench.
- [x] 4. Verify Java compilation and static page syntax.
- [x] 5. Validate the page against a real trace endpoint when a local service is available.
- [x] 6. Archive the OpenSpec change after validation.
- [x] 7. Commit the completed change.
@@ -1,9 +1,7 @@
## Purpose
Provide a repeatable MVP demo flow that can run a chat diagnosis, expose its persisted execution trace, and submit feedback for the same session id.
## Requirements
### Requirement: Diagnosis trace can be queried by session id
The system SHALL expose a read-only HTTP endpoint `GET /api/diagnosis/{sessionId}/trace` that returns the persisted diagnosis trace for the requested session id.
@@ -64,3 +62,39 @@ The MVP demo SHALL document which trace fields to inspect for evidence, verifier
#### Scenario: Checklist maps fields to interview claims
- **WHEN** a developer reviews a trace response
- **THEN** the checklist SHALL map concrete JSON paths to the claims made in the interview walkthrough
### Requirement: MVP demo SHALL provide a browser trace workbench
The MVP demo SHALL provide a browser-accessible static page for inspecting one
diagnosis trace by session id using the existing read-only Trace API.
#### Scenario: Existing trace renders in the workbench
- **WHEN** a reviewer opens the Trace workbench with a session id that exists
- **THEN** the page SHALL request `GET /api/diagnosis/{sessionId}/trace`
- **AND** it SHALL render session summary, ordered agent steps, ordered tool
invocations, verifier evaluation, and final answer when present
#### Scenario: Trace workbench handles missing or failed traces
- **WHEN** the Trace API returns an error or the session id is empty
- **THEN** the page SHALL show a clear error or empty state without mutating any
diagnosis data
### Requirement: Trace workbench SHALL expose evidence and skill boundaries
The Trace workbench SHALL make tool evidence and skill-loading boundaries visible
without requiring raw JSON inspection first.
#### Scenario: Tool and verifier evidence are inspectable
- **WHEN** a trace includes tool invocations and verifier facts
- **THEN** the page SHALL show tool invocation counts, success status, tool
filters, verifier facts, and evidence references
#### Scenario: RAG details are inspectable for lookup knowledge calls
- **WHEN** a `lookup_knowledge` invocation includes retrieval details
- **THEN** the page SHALL show query transform, retrieval trace, context pack,
rerank trace, and evidence block data where available
#### Scenario: Skill boundary checks are visible
- **WHEN** a trace includes planner, executor, or verifier steps
- **THEN** the page SHALL show best-effort indicators for selected skill,
planner `read_skill` text mentions, executor `read_skill` text mentions, and
verifier `read_skill` text mentions
+8
View File
@@ -34,6 +34,14 @@
<span style="font-size: 14px; font-weight: 500;">文档管理</span>
</a>
<a href="trace.html" class="sidebar-btn" style="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 12px; color: #202124; text-decoration: none; transition: background 0.3s ease; margin-top: 8px;">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 20px; height: 20px; flex-shrink: 0;">
<path d="M4 19V5M4 5H20M4 12H16M4 19H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 5V19M16 5V19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span style="font-size: 14px; font-weight: 500;">Trace Workbench</span>
</a>
<div class="chat-history-section">
<div class="history-header">
<span>近期对话</span>
+606
View File
@@ -0,0 +1,606 @@
:root {
--ink: #111827;
--ink-soft: #263241;
--paper: #f7f3ea;
--line: #d6cbbb;
--evidence: #1f7a6b;
--evidence-soft: #dfeee9;
--reject: #b5472f;
--reject-soft: #f4dfd8;
--warn: #c58a19;
--warn-soft: #f5ead1;
--steel: #5b6472;
--white: #fffdf8;
--shadow: 0 18px 48px rgba(17, 24, 39, 0.16);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
button:focus-visible,
input:focus-visible,
a:focus-visible,
.tool-row:focus-visible {
outline: 3px solid rgba(31, 122, 107, 0.34);
outline-offset: 2px;
}
svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.trace-app {
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 22px;
gap: 14px;
}
.trace-header {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(340px, 560px);
gap: 18px;
align-items: end;
padding: 18px 20px;
background: var(--ink);
color: var(--white);
border-radius: 8px;
box-shadow: var(--shadow);
}
.trace-back-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: #cbd5e1;
text-decoration: none;
font-size: 13px;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
}
.trace-header h1 {
margin: 8px 0 0;
font-size: clamp(28px, 4vw, 52px);
line-height: 0.95;
font-weight: 760;
letter-spacing: 0;
}
.session-form {
display: grid;
gap: 8px;
}
.session-form label,
.summary-cell span,
.pane-heading p,
.tool-meta,
.state-line,
.field-label {
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 12px;
text-transform: uppercase;
color: var(--steel);
}
.session-form label {
color: #cbd5e1;
}
.session-control {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.session-control input {
width: 100%;
min-width: 0;
height: 42px;
padding: 0 12px;
border: 1px solid #4b5563;
border-radius: 6px;
background: #0b1220;
color: var(--white);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
}
.session-control button {
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 14px;
border: 0;
border-radius: 6px;
color: var(--white);
background: var(--evidence);
font-weight: 700;
}
.session-control button:disabled {
opacity: 0.62;
cursor: wait;
}
.state-line {
min-height: 34px;
display: flex;
align-items: center;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--white);
}
.state-line.error {
color: var(--reject);
border-color: #dfb2a5;
background: var(--reject-soft);
}
.state-line.loading {
color: var(--evidence);
border-color: #a9d2c8;
background: var(--evidence-soft);
}
.summary-strip {
display: grid;
grid-template-columns: repeat(7, minmax(120px, 1fr));
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: var(--white);
}
.summary-cell {
min-width: 0;
padding: 13px 14px;
border-right: 1px solid var(--line);
}
.summary-cell:last-child {
border-right: 0;
}
.summary-cell strong {
display: block;
margin-top: 5px;
font-size: 21px;
line-height: 1.1;
overflow-wrap: anywhere;
}
.trace-grid {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: minmax(300px, 1.05fr) minmax(300px, 0.95fr) minmax(340px, 1.1fr);
gap: 14px;
}
.trace-pane {
min-height: 620px;
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--white);
overflow: hidden;
}
.pane-heading {
min-height: 74px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
background: #fbf7ef;
}
.pane-heading p,
.pane-heading h2 {
margin: 0;
}
.pane-heading h2 {
margin-top: 3px;
font-size: 18px;
}
.pane-count {
min-width: 38px;
min-height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 9px;
border-radius: 999px;
background: var(--ink);
color: var(--white);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.trace-rail,
.tool-ledger,
.inspector-stack {
overflow: auto;
}
.trace-rail {
position: relative;
padding: 18px 16px 24px 34px;
}
.trace-rail::before {
content: "";
position: absolute;
left: 23px;
top: 0;
bottom: 0;
width: 3px;
background: var(--ink);
}
.step-entry {
position: relative;
margin-bottom: 14px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fffaf1;
}
.step-entry::before {
content: attr(data-index);
position: absolute;
left: -30px;
top: 12px;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 2px solid var(--ink);
border-radius: 50%;
background: var(--paper);
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 11px;
font-weight: 800;
}
.step-entry.planner {
border-left: 5px solid var(--warn);
}
.step-entry.executor {
border-left: 5px solid var(--evidence);
}
.step-entry.verifier {
border-left: 5px solid var(--reject);
}
.step-title,
.tool-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.step-title h3,
.tool-title h3 {
margin: 0;
font-size: 16px;
overflow-wrap: anywhere;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 22px;
padding: 3px 8px;
border-radius: 999px;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.badge.ok {
color: #0b4f44;
background: var(--evidence-soft);
}
.badge.warn {
color: #754d00;
background: var(--warn-soft);
}
.badge.bad {
color: #7c2414;
background: var(--reject-soft);
}
.meta-line {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 9px;
}
.meta-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 7px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--white);
color: var(--steel);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 11px;
}
details {
margin-top: 10px;
}
summary {
color: var(--ink-soft);
font-weight: 700;
cursor: pointer;
}
pre,
.answer-box {
margin: 8px 0 0;
padding: 10px;
max-height: 280px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 6px;
background: #fdf8ee;
color: #172033;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tool-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: #fffaf1;
}
.tool-filter {
min-height: 30px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--white);
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.tool-filter.active {
border-color: var(--ink);
background: var(--ink);
color: var(--white);
}
.tool-ledger {
padding: 12px 14px 18px;
}
.tool-row {
width: 100%;
display: block;
margin-bottom: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--white);
color: inherit;
text-align: left;
}
.tool-row:hover,
.tool-row.selected {
border-color: var(--evidence);
background: #f5fbf8;
}
.tool-row.failed {
border-color: #e1b4aa;
}
.tool-meta {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 8px;
color: var(--steel);
text-transform: none;
}
.inspector-stack {
padding: 14px;
}
.inspector-section {
padding: 0 0 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--line);
}
.inspector-section:last-child {
margin-bottom: 0;
border-bottom: 0;
}
.inspector-section h3 {
margin: 0 0 10px;
font-size: 15px;
}
.boundary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.boundary-item,
.fact-row,
.field-row {
border: 1px solid var(--line);
border-radius: 6px;
background: #fffaf1;
}
.boundary-item {
padding: 10px;
}
.boundary-item strong {
display: block;
margin-top: 4px;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
font-size: 14px;
overflow-wrap: anywhere;
}
.fact-row {
padding: 10px;
margin-bottom: 8px;
}
.fact-row p {
margin: 0 0 8px;
line-height: 1.45;
}
.field-grid {
display: grid;
gap: 8px;
}
.field-row {
padding: 10px;
}
.field-label {
display: block;
margin-bottom: 5px;
color: var(--steel);
}
.empty-panel {
padding: 14px;
border: 1px dashed var(--line);
border-radius: 6px;
color: var(--steel);
background: #fffaf1;
}
@media (max-width: 1180px) {
.summary-strip {
grid-template-columns: repeat(4, minmax(120px, 1fr));
}
.summary-cell:nth-child(4) {
border-right: 0;
}
.trace-grid {
grid-template-columns: 1fr;
}
.trace-pane {
min-height: 420px;
}
}
@media (max-width: 760px) {
.trace-app {
padding: 12px;
}
.trace-header {
grid-template-columns: 1fr;
}
.session-control {
grid-template-columns: 1fr;
}
.summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-cell,
.summary-cell:nth-child(4) {
border-right: 1px solid var(--line);
}
.summary-cell:nth-child(2n) {
border-right: 0;
}
.boundary-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition: none !important;
animation: none !important;
}
}
+129
View File
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trace Workbench - SuperBizAgent</title>
<link rel="stylesheet" href="trace.css">
</head>
<body>
<main class="trace-app">
<header class="trace-header">
<div>
<a class="trace-back-link" href="index.html" aria-label="Back to chat">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M19 12H5M12 19L5 12L12 5" />
</svg>
<span>Chat</span>
</a>
<h1>Trace workbench</h1>
</div>
<form class="session-form" id="sessionForm">
<label for="sessionIdInput">Session id</label>
<div class="session-control">
<input id="sessionIdInput" name="sessionId" autocomplete="off" spellcheck="false" placeholder="e2e-mysql-skill-rag-20260706-2355">
<button type="submit" id="loadButton">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 12H19M13 6L19 12L13 18" />
</svg>
<span>Load</span>
</button>
</div>
</form>
</header>
<section class="state-line" id="stateLine" role="status">Enter a session id to load a persisted diagnosis trace.</section>
<section class="summary-strip" id="summaryStrip" aria-label="Trace summary">
<div class="summary-cell">
<span>Status</span>
<strong id="summaryStatus">-</strong>
</div>
<div class="summary-cell">
<span>Verdict</span>
<strong id="summaryVerdict">-</strong>
</div>
<div class="summary-cell">
<span>Groundedness</span>
<strong id="summaryScore">-</strong>
</div>
<div class="summary-cell">
<span>Steps</span>
<strong id="summarySteps">-</strong>
</div>
<div class="summary-cell">
<span>Tools</span>
<strong id="summaryTools">-</strong>
</div>
<div class="summary-cell">
<span>Duration</span>
<strong id="summaryDuration">-</strong>
</div>
<div class="summary-cell">
<span>Tokens</span>
<strong id="summaryTokens">-</strong>
</div>
</section>
<section class="trace-grid">
<section class="trace-pane agent-pane" aria-labelledby="agentPaneTitle">
<div class="pane-heading">
<div>
<p>Agent rail</p>
<h2 id="agentPaneTitle">Persisted steps</h2>
</div>
<span class="pane-count" id="stepCount">0</span>
</div>
<div class="trace-rail" id="stepsRail"></div>
</section>
<section class="trace-pane evidence-pane" aria-labelledby="evidencePaneTitle">
<div class="pane-heading">
<div>
<p>Evidence ledger</p>
<h2 id="evidencePaneTitle">Tool invocations</h2>
</div>
<span class="pane-count" id="toolCount">0</span>
</div>
<div class="tool-filters" id="toolFilters"></div>
<div class="tool-ledger" id="toolLedger"></div>
</section>
<section class="trace-pane inspector-pane" aria-labelledby="inspectorPaneTitle">
<div class="pane-heading">
<div>
<p>Inspector</p>
<h2 id="inspectorPaneTitle">Verifier and RAG</h2>
</div>
<span class="pane-count" id="inspectorMode">idle</span>
</div>
<div class="inspector-stack">
<section class="inspector-section" aria-labelledby="skillBoundaryTitle">
<h3 id="skillBoundaryTitle">Skill boundary</h3>
<div class="boundary-grid" id="skillBoundary"></div>
</section>
<section class="inspector-section" aria-labelledby="verifierTitle">
<h3 id="verifierTitle">Verifier facts</h3>
<div id="verifierFacts"></div>
</section>
<section class="inspector-section" aria-labelledby="toolInspectorTitle">
<h3 id="toolInspectorTitle">Selected tool</h3>
<div id="toolInspector"></div>
</section>
<section class="inspector-section" aria-labelledby="answerTitle">
<h3 id="answerTitle">Final answer</h3>
<div class="answer-box" id="finalAnswer">-</div>
</section>
</div>
</section>
</section>
</main>
<script src="trace.js"></script>
</body>
</html>
+574
View File
@@ -0,0 +1,574 @@
class TraceWorkbench {
constructor() {
this.trace = null;
this.selectedToolId = null;
this.activeFilter = 'all';
this.sessionForm = document.getElementById('sessionForm');
this.sessionIdInput = document.getElementById('sessionIdInput');
this.loadButton = document.getElementById('loadButton');
this.stateLine = document.getElementById('stateLine');
this.stepsRail = document.getElementById('stepsRail');
this.toolLedger = document.getElementById('toolLedger');
this.toolFilters = document.getElementById('toolFilters');
this.skillBoundary = document.getElementById('skillBoundary');
this.verifierFacts = document.getElementById('verifierFacts');
this.toolInspector = document.getElementById('toolInspector');
this.finalAnswer = document.getElementById('finalAnswer');
this.bindEvents();
this.bootstrapFromUrl();
}
bindEvents() {
this.sessionForm.addEventListener('submit', (event) => {
event.preventDefault();
this.loadTrace(this.sessionIdInput.value.trim());
});
}
bootstrapFromUrl() {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('sessionId');
if (sessionId) {
this.sessionIdInput.value = sessionId;
this.loadTrace(sessionId);
return;
}
this.renderEmpty();
}
async loadTrace(sessionId) {
if (!sessionId) {
this.setState('Enter a session id before loading.', 'error');
return;
}
this.setState(`Loading ${sessionId}...`, 'loading');
this.loadButton.disabled = true;
try {
const response = await fetch(`/api/diagnosis/${encodeURIComponent(sessionId)}/trace`);
const data = await this.handleResponse(response);
this.trace = data;
this.selectedToolId = data.toolInvocations && data.toolInvocations.length
? data.toolInvocations[0].id
: null;
this.activeFilter = 'all';
this.updateUrl(sessionId);
this.renderTrace();
this.setState(`Loaded ${sessionId}.`, '');
} catch (error) {
this.trace = null;
this.renderEmpty();
this.setState(error.message, 'error');
} finally {
this.loadButton.disabled = false;
}
}
async handleResponse(response) {
let payload = null;
try {
payload = await response.json();
} catch (error) {
throw new Error(`HTTP ${response.status}: response is not JSON`);
}
if (!response.ok || payload.code !== 200) {
throw new Error(payload.message || `HTTP ${response.status}: trace request failed`);
}
return payload.data;
}
updateUrl(sessionId) {
const url = new URL(window.location.href);
url.searchParams.set('sessionId', sessionId);
window.history.replaceState({}, '', url.toString());
}
renderTrace() {
const session = this.trace.session || {};
const verifier = this.getVerifier();
const summary = this.trace.summary || {};
const steps = this.trace.steps || [];
const tools = this.trace.toolInvocations || [];
this.text('summaryStatus', session.status || '-');
this.text('summaryVerdict', verifier.verdict || '-');
this.text('summaryScore', this.formatScore(verifier.groundedness_score));
this.text('summarySteps', `${summary.returnedStepCount ?? steps.length}/${summary.persistedStepCount ?? session.stepCount ?? '-'}`);
this.text('summaryTools', `${summary.returnedToolCallCount ?? tools.length}/${summary.persistedToolCallCount ?? session.toolCallCount ?? '-'}`);
this.text('summaryDuration', this.formatDuration(session.totalDurationMs));
this.text('summaryTokens', this.formatNumber(session.totalTokenCount));
this.text('stepCount', steps.length);
this.text('toolCount', tools.length);
this.text('inspectorMode', tools.length ? 'tool' : 'trace');
this.renderSteps(steps);
this.renderToolFilters(tools);
this.renderTools(tools);
this.renderSkillBoundary();
this.renderVerifier(verifier);
this.renderToolInspector();
this.finalAnswer.textContent = session.answer || 'No final answer persisted.';
}
renderEmpty() {
this.text('summaryStatus', '-');
this.text('summaryVerdict', '-');
this.text('summaryScore', '-');
this.text('summarySteps', '-');
this.text('summaryTools', '-');
this.text('summaryDuration', '-');
this.text('summaryTokens', '-');
this.text('stepCount', '0');
this.text('toolCount', '0');
this.text('inspectorMode', 'idle');
this.stepsRail.innerHTML = '<div class="empty-panel">No trace loaded.</div>';
this.toolFilters.innerHTML = '';
this.toolLedger.innerHTML = '<div class="empty-panel">No tool invocations loaded.</div>';
this.skillBoundary.innerHTML = this.renderBoundaryItems({
selectedSkill: '-',
plannerReadSkill: 0,
executorReadSkill: 0,
verifierReadSkill: 0
});
this.verifierFacts.innerHTML = '<div class="empty-panel">No verifier evaluation loaded.</div>';
this.toolInspector.innerHTML = '<div class="empty-panel">Select a tool invocation after loading a trace.</div>';
this.finalAnswer.textContent = '-';
}
renderSteps(steps) {
if (!steps.length) {
this.stepsRail.innerHTML = '<div class="empty-panel">No persisted agent steps.</div>';
return;
}
this.stepsRail.innerHTML = steps.map((step, index) => {
const agent = (step.agentName || 'agent').toLowerCase();
const cls = this.agentClass(agent);
return `
<article class="step-entry ${cls}" data-index="${this.escape(String(index + 1).padStart(2, '0'))}">
<div class="step-title">
<h3>${this.escape(step.agentName || 'agent')}</h3>
${this.renderBadge(step.hasToolCall ? 'tool' : 'model', step.hasToolCall ? 'warn' : 'ok')}
</div>
<div class="meta-line">
<span class="meta-pill">step ${this.escape(step.stepIndex ?? index)}</span>
<span class="meta-pill">${this.escape(this.formatDuration(step.durationMs))}</span>
<span class="meta-pill">${this.escape(this.formatNumber(step.tokenCount))} tokens</span>
<span class="meta-pill">${this.escape(this.formatDate(step.createdAt))}</span>
</div>
${this.renderTextDetail('Model input', step.modelInput)}
${this.renderTextDetail('Model output', step.modelOutput)}
${this.renderTextDetail('Thought', step.thought)}
</article>
`;
}).join('');
}
renderToolFilters(tools) {
if (!tools.length) {
this.toolFilters.innerHTML = '';
return;
}
const groups = this.groupByTool(tools);
const filters = [
{ key: 'all', label: `all ${tools.length}` },
{ key: 'success', label: `success ${tools.filter(tool => tool.success !== false).length}` },
{ key: 'failed', label: `failed ${tools.filter(tool => tool.success === false).length}` },
...Object.entries(groups).map(([name, count]) => ({ key: `tool:${name}`, label: `${name} ${count}` }))
];
this.toolFilters.innerHTML = filters.map(filter => `
<button class="tool-filter ${filter.key === this.activeFilter ? 'active' : ''}" data-filter="${this.escape(filter.key)}">
${this.escape(filter.label)}
</button>
`).join('');
this.toolFilters.querySelectorAll('button').forEach((button) => {
button.addEventListener('click', () => {
this.activeFilter = button.dataset.filter;
this.renderToolFilters(this.trace.toolInvocations || []);
this.renderTools(this.trace.toolInvocations || []);
});
});
}
renderTools(tools) {
const filtered = tools.filter(tool => this.matchesFilter(tool));
if (!filtered.length) {
this.toolLedger.innerHTML = '<div class="empty-panel">No tool invocations match this filter.</div>';
return;
}
this.toolLedger.innerHTML = filtered.map((tool, index) => {
const failed = tool.success === false;
const selected = String(tool.id) === String(this.selectedToolId);
return `
<button class="tool-row ${failed ? 'failed' : ''} ${selected ? 'selected' : ''}" data-tool-id="${this.escape(tool.id)}">
<div class="tool-title">
<h3>${this.escape(tool.toolName || 'tool')}</h3>
${this.renderBadge(failed ? 'failed' : 'ok', failed ? 'bad' : 'ok')}
</div>
<div class="tool-meta">
<span>#${index + 1}</span>
<span>id ${this.escape(tool.id ?? '-')}</span>
<span>step ${this.escape(tool.stepId ?? '-')}</span>
<span>${this.escape(this.formatDuration(tool.durationMs))}</span>
${tool.retrievalLayer ? `<span>${this.escape(tool.retrievalLayer)}</span>` : ''}
${tool.relevanceLevel ? `<span>${this.escape(tool.relevanceLevel)}</span>` : ''}
</div>
</button>
`;
}).join('');
this.toolLedger.querySelectorAll('.tool-row').forEach((row) => {
row.addEventListener('click', () => {
this.selectedToolId = row.dataset.toolId;
this.renderTools(this.trace.toolInvocations || []);
this.renderToolInspector();
});
});
}
renderSkillBoundary() {
const steps = this.trace.steps || [];
const selectedSkill = this.extractSelectedSkill(steps);
const counts = {
selectedSkill: selectedSkill || '-',
plannerReadSkill: this.countReadSkill(steps, 'planner'),
executorReadSkill: this.countReadSkill(steps, 'executor'),
verifierReadSkill: this.countReadSkill(steps, 'verifier')
};
this.skillBoundary.innerHTML = this.renderBoundaryItems(counts);
}
renderBoundaryItems(counts) {
return `
<div class="boundary-item">
<span class="field-label">Selected skill</span>
<strong>${this.escape(counts.selectedSkill)}</strong>
</div>
<div class="boundary-item">
<span class="field-label">Planner mentions</span>
<strong>${this.escape(counts.plannerReadSkill)}</strong>
</div>
<div class="boundary-item">
<span class="field-label">Executor mentions</span>
<strong>${this.escape(counts.executorReadSkill)}</strong>
</div>
<div class="boundary-item">
<span class="field-label">Verifier mentions</span>
<strong>${this.escape(counts.verifierReadSkill)}</strong>
</div>
`;
}
renderVerifier(verifier) {
if (!verifier || !Object.keys(verifier).length) {
this.verifierFacts.innerHTML = '<div class="empty-panel">No verifier evaluation in selfEvaluation.</div>';
return;
}
const facts = Array.isArray(verifier.facts_checked) ? verifier.facts_checked : [];
const traceSummary = Array.isArray(verifier.tool_trace_summary) ? verifier.tool_trace_summary : [];
const header = `
<div class="meta-line">
${this.renderBadge(verifier.verdict || 'verdict -', this.verdictClass(verifier.verdict))}
<span class="meta-pill">score ${this.escape(this.formatScore(verifier.groundedness_score))}</span>
<span class="meta-pill">facts ${facts.length}</span>
<span class="meta-pill">trace refs ${traceSummary.length}</span>
</div>
`;
if (!facts.length) {
this.verifierFacts.innerHTML = header + '<div class="empty-panel">Verifier returned no facts_checked rows.</div>';
return;
}
this.verifierFacts.innerHTML = header + facts.map((fact) => {
const refs = Array.isArray(fact.evidence_refs) ? fact.evidence_refs : [];
return `
<article class="fact-row">
<p>${this.escape(fact.fact || '-')}</p>
<div class="meta-line">
${this.renderBadge(fact.verification || '-', this.factClass(fact.verification))}
<span class="meta-pill">${fact.is_critical ? 'critical' : 'supporting'}</span>
<span class="meta-pill">refs ${refs.length}</span>
</div>
${fact.detail ? `<pre>${this.escape(fact.detail)}</pre>` : ''}
${refs.length ? `<pre>${this.escape(this.prettyJson(refs))}</pre>` : ''}
</article>
`;
}).join('');
}
renderToolInspector() {
const tools = this.trace ? (this.trace.toolInvocations || []) : [];
const tool = tools.find(item => String(item.id) === String(this.selectedToolId));
if (!tool) {
this.toolInspector.innerHTML = '<div class="empty-panel">No tool invocation selected.</div>';
return;
}
const retrieval = tool.retrievalDetails || {};
const ragFields = [
['Input params', tool.inputParams || this.parseJson(tool.inputParamsRaw)],
['Output preview', tool.outputPreview || '-'],
['Retrieval layer', tool.retrievalLayer || '-'],
['L0 matches', tool.l0MatchCount ?? '-'],
['L1 matches', tool.l1MatchCount ?? '-'],
['Relevance', tool.relevanceLevel || '-'],
['Dedup reason', tool.dedupReason || '-'],
['Query transform', retrieval.query_transform],
['Retrieval trace', retrieval.retrieval_trace],
['Context pack', retrieval.context_pack_summary],
['Rerank trace', retrieval.rerank_trace],
['Evidence blocks', retrieval.evidence_blocks]
];
this.toolInspector.innerHTML = `
<div class="meta-line">
${this.renderBadge(tool.success === false ? 'failed' : 'ok', tool.success === false ? 'bad' : 'ok')}
<span class="meta-pill">${this.escape(tool.toolName || 'tool')}</span>
<span class="meta-pill">${this.escape(this.formatDuration(tool.durationMs))}</span>
<span class="meta-pill">output ${this.escape(this.formatNumber(tool.outputLength))}</span>
${tool.truncated ? '<span class="meta-pill">truncated</span>' : ''}
</div>
${tool.errorMessage ? `<pre>${this.escape(tool.errorMessage)}</pre>` : ''}
<div class="field-grid">
${ragFields.map(([label, value]) => this.renderField(label, value)).join('')}
</div>
`;
}
renderField(label, value) {
const empty = value === undefined || value === null || value === '';
return `
<div class="field-row">
<span class="field-label">${this.escape(label)}</span>
<pre>${this.escape(empty ? '-' : this.stringifyValue(value))}</pre>
</div>
`;
}
renderTextDetail(label, text) {
if (!text) {
return '';
}
return `
<details>
<summary>${this.escape(label)}</summary>
<pre>${this.escape(text)}</pre>
</details>
`;
}
renderBadge(text, cls) {
return `<span class="badge ${this.escape(cls || 'ok')}">${this.escape(text)}</span>`;
}
getVerifier() {
const evaluation = (this.trace && this.trace.session && this.trace.session.selfEvaluation) || {};
if (evaluation.verifier_evaluation) {
return evaluation.verifier_evaluation;
}
if (evaluation.verdict || evaluation.groundedness_score || evaluation.facts_checked) {
return evaluation;
}
return {};
}
extractSelectedSkill(steps) {
const plannerText = steps
.filter(step => (step.agentName || '').toLowerCase().includes('planner'))
.map(step => `${step.modelOutput || ''}\n${step.thought || ''}`)
.join('\n');
const allText = steps
.map(step => `${step.modelInput || ''}\n${step.modelOutput || ''}\n${step.thought || ''}`)
.join('\n');
const sourceText = `${plannerText}\n${allText}`;
const direct = sourceText.match(/"selected_skill"\s*:\s*"([^"]+)"/);
if (direct) {
return direct[1];
}
const skillName = sourceText.match(/"skill_name"\s*:\s*"([^"]+)"/);
if (skillName) {
return skillName[1];
}
const loose = sourceText.match(/selected_skill\s*[:=]\s*([A-Za-z0-9_.-]+)/);
return loose ? loose[1] : '';
}
countReadSkill(steps, agentName) {
const text = steps
.filter(step => (step.agentName || '').toLowerCase().includes(agentName))
.map(step => `${step.modelInput || ''}\n${step.modelOutput || ''}\n${step.thought || ''}`)
.join('\n');
const matches = text.match(/\bread_skill\b/g);
return matches ? matches.length : 0;
}
matchesFilter(tool) {
if (this.activeFilter === 'all') {
return true;
}
if (this.activeFilter === 'success') {
return tool.success !== false;
}
if (this.activeFilter === 'failed') {
return tool.success === false;
}
if (this.activeFilter.startsWith('tool:')) {
return tool.toolName === this.activeFilter.slice(5);
}
return true;
}
groupByTool(tools) {
return tools.reduce((groups, tool) => {
const name = tool.toolName || 'tool';
groups[name] = (groups[name] || 0) + 1;
return groups;
}, {});
}
agentClass(agent) {
if (agent.includes('planner')) {
return 'planner';
}
if (agent.includes('executor')) {
return 'executor';
}
if (agent.includes('verifier')) {
return 'verifier';
}
return '';
}
verdictClass(verdict) {
if (!verdict) {
return 'warn';
}
const value = String(verdict).toUpperCase();
if (value.includes('PASS')) {
return 'ok';
}
if (value.includes('REJECT')) {
return 'bad';
}
return 'warn';
}
factClass(verification) {
if (!verification) {
return 'warn';
}
const value = String(verification).toUpperCase();
if (value.includes('SUPPORTED') || value.includes('PASS')) {
return 'ok';
}
if (value.includes('UNSUPPORTED') || value.includes('CONTRADICT')) {
return 'bad';
}
return 'warn';
}
parseJson(raw) {
if (!raw || typeof raw !== 'string') {
return raw;
}
try {
return JSON.parse(raw);
} catch (error) {
return raw;
}
}
stringifyValue(value) {
if (typeof value === 'string') {
return value;
}
return this.prettyJson(value);
}
prettyJson(value) {
try {
return JSON.stringify(value, null, 2);
} catch (error) {
return String(value);
}
}
formatDuration(ms) {
if (ms === undefined || ms === null || ms === '') {
return '-';
}
if (ms < 1000) {
return `${ms}ms`;
}
const seconds = ms / 1000;
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remaining = Math.round(seconds % 60);
return `${minutes}m ${remaining}s`;
}
formatNumber(value) {
if (value === undefined || value === null || value === '') {
return '-';
}
return Number(value).toLocaleString('en-US');
}
formatScore(value) {
if (value === undefined || value === null || value === '') {
return '-';
}
const number = Number(value);
return Number.isFinite(number) ? number.toFixed(2) : String(value);
}
formatDate(value) {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value);
}
return date.toLocaleString('en-US', { hour12: false });
}
text(id, value) {
const node = document.getElementById(id);
if (node) {
node.textContent = value;
}
}
setState(message, statusClass) {
this.stateLine.textContent = message;
this.stateLine.className = `state-line ${statusClass || ''}`.trim();
}
escape(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
}
document.addEventListener('DOMContentLoaded', () => {
new TraceWorkbench();
});