@hybridlabor-api/bdb-synapse 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/synapse +0 -0
- package/cmd/rubriceval/main.go +308 -0
- package/cmd/synapse/main.go +280 -0
- package/cmd/synapse/main_test.go +16 -0
- package/go.mod +7 -0
- package/go.sum +6 -0
- package/internal/adapter/adapter.go +1117 -0
- package/internal/adapter/adapter_test.go +518 -0
- package/internal/adapter/agy/adapter.go +193 -0
- package/internal/adapter/claudecode/adapter.go +415 -0
- package/internal/adapter/claudecode/adapter_test.go +260 -0
- package/internal/adapter/claudecode/agents.go +387 -0
- package/internal/adapter/claudecode/agents_test.go +480 -0
- package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
- package/internal/adapter/codex/adapter.go +919 -0
- package/internal/adapter/codex/adapter_test.go +920 -0
- package/internal/adapter/codex/agents.go +401 -0
- package/internal/adapter/codex/agents_test.go +610 -0
- package/internal/adapter/codex/summary_inputs_test.go +20 -0
- package/internal/adapter/pi/adapter.go +483 -0
- package/internal/adapter/pi/adapter_test.go +517 -0
- package/internal/citymap/builder.go +1124 -0
- package/internal/citymap/builder_test.go +818 -0
- package/internal/judge/cache.go +180 -0
- package/internal/judge/cli.go +240 -0
- package/internal/judge/cli_test.go +68 -0
- package/internal/judge/fresh_summary_test.go +37 -0
- package/internal/judge/input.go +233 -0
- package/internal/judge/judge.go +416 -0
- package/internal/judge/judge_test.go +288 -0
- package/internal/judge/prompt.go +129 -0
- package/internal/judge/rubric.go +275 -0
- package/internal/judge/rubric_test.go +642 -0
- package/internal/model/agent.go +63 -0
- package/internal/model/agent_schema_test.go +86 -0
- package/internal/model/agent_test.go +64 -0
- package/internal/model/model.go +175 -0
- package/internal/model/report.go +166 -0
- package/internal/model/stats.go +151 -0
- package/internal/model/stats_test.go +89 -0
- package/internal/model/trace_schema_test.go +67 -0
- package/internal/server/analyze.go +286 -0
- package/internal/server/analyze_test.go +297 -0
- package/internal/server/codex_index_test.go +40 -0
- package/internal/server/hardening_test.go +147 -0
- package/internal/server/reportindex.go +91 -0
- package/internal/server/reportindex_test.go +116 -0
- package/internal/server/server.go +1099 -0
- package/internal/server/server_test.go +1389 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
- package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
- package/internal/server/static/assets/index-C_adLrJr.js +3 -0
- package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
- package/internal/server/static/index.html +26 -0
- package/internal/server/tracestore.go +173 -0
- package/internal/server/tracestore_test.go +51 -0
- package/internal/textutil/truncate.go +30 -0
- package/internal/textutil/truncate_test.go +39 -0
- package/package.json +35 -0
- package/web/e2e/agent-lens.spec.ts +688 -0
- package/web/index.html +23 -0
- package/web/package-lock.json +1933 -0
- package/web/package.json +33 -0
- package/web/playwright.config.ts +24 -0
- package/web/src/App.tsx +876 -0
- package/web/src/api/client.ts +74 -0
- package/web/src/main.tsx +12 -0
- package/web/src/playback/recorder.ts +160 -0
- package/web/src/playback/reducer.ts +91 -0
- package/web/src/scene/CityScene.tsx +638 -0
- package/web/src/scene/TreeScene.tsx +656 -0
- package/web/src/scene/dirLabels.ts +145 -0
- package/web/src/scene/sceneUtils.ts +144 -0
- package/web/src/scene/textures.ts +60 -0
- package/web/src/scene/trail.ts +79 -0
- package/web/src/scene/treeLayout.ts +169 -0
- package/web/src/state/filters.ts +40 -0
- package/web/src/state/store.ts +83 -0
- package/web/src/styles.css +2565 -0
- package/web/src/types.ts +315 -0
- package/web/src/ui/AgentsPanel.tsx +376 -0
- package/web/src/ui/Dock.tsx +104 -0
- package/web/src/ui/Hud.tsx +335 -0
- package/web/src/ui/Inspector.tsx +107 -0
- package/web/src/ui/LogoMark.tsx +38 -0
- package/web/src/ui/ReportPanel.tsx +491 -0
- package/web/src/ui/SessionRail.tsx +316 -0
- package/web/src/ui/Timeline.tsx +458 -0
- package/web/src/ui/ViewPanel.tsx +45 -0
- package/web/src/ui/shortcuts.ts +4 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +28 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { useCallback, useState, type ReactNode } from "react";
|
|
2
|
+
import { AlertTriangle, RefreshCw, Sparkles, X } from "lucide-react";
|
|
3
|
+
import type {
|
|
4
|
+
JudgeChoice,
|
|
5
|
+
ReportDimension,
|
|
6
|
+
ReportFinding,
|
|
7
|
+
ReportStatus,
|
|
8
|
+
Rubric,
|
|
9
|
+
RubricCriterion,
|
|
10
|
+
RubricTask,
|
|
11
|
+
Severity,
|
|
12
|
+
Verdict
|
|
13
|
+
} from "../types";
|
|
14
|
+
|
|
15
|
+
interface ReportPanelProps {
|
|
16
|
+
status?: ReportStatus;
|
|
17
|
+
analyzing: boolean;
|
|
18
|
+
locked: boolean;
|
|
19
|
+
onAnalyze: (choice: JudgeChoice) => void;
|
|
20
|
+
onClose: () => void;
|
|
21
|
+
/** jump the playhead to an evidence seq and focus its file in the scene */
|
|
22
|
+
onJumpTo: (seq: number) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DIMENSION_WORDS: Record<string, { title: string; hint: string }> = {
|
|
26
|
+
exploration: { title: "Exploration", hint: "Did the agent build enough understanding before editing?" },
|
|
27
|
+
scope: { title: "Scope", hint: "Does the footprint match what the task needed?" },
|
|
28
|
+
wandering: { title: "Wandering", hint: "Purposeful path, or circles and dead ends?" },
|
|
29
|
+
verification: { title: "Verification", hint: "Were edits verified, and errors followed up?" }
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** the mainstream models each judge CLI can be pinned to; "" keeps its default */
|
|
33
|
+
const JUDGE_MODELS: Record<string, { value: string; label: string }[]> = {
|
|
34
|
+
claude: [
|
|
35
|
+
{ value: "", label: "default model" },
|
|
36
|
+
{ value: "sonnet", label: "sonnet" },
|
|
37
|
+
{ value: "opus", label: "opus" },
|
|
38
|
+
{ value: "fable", label: "fable" }
|
|
39
|
+
],
|
|
40
|
+
codex: [
|
|
41
|
+
{ value: "", label: "default model" },
|
|
42
|
+
{ value: "gpt-5.6-sol", label: "gpt-5.6 sol" },
|
|
43
|
+
{ value: "gpt-5.6-terra", label: "gpt-5.6 terra" }
|
|
44
|
+
]
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const JUDGE_CHOICE_KEY = "mindwalk:judge-choice";
|
|
48
|
+
|
|
49
|
+
function loadStoredChoice(): JudgeChoice {
|
|
50
|
+
try {
|
|
51
|
+
const raw = localStorage.getItem(JUDGE_CHOICE_KEY);
|
|
52
|
+
if (raw) {
|
|
53
|
+
const parsed = JSON.parse(raw) as Partial<JudgeChoice>;
|
|
54
|
+
return {
|
|
55
|
+
cli: typeof parsed.cli === "string" ? parsed.cli : "",
|
|
56
|
+
model: typeof parsed.model === "string" ? parsed.model : ""
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// corrupt storage reads as "no preference"
|
|
61
|
+
}
|
|
62
|
+
return { cli: "", model: "" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** clamp a stored choice to what is actually installed and offered */
|
|
66
|
+
function resolveChoice(choice: JudgeChoice, clis: string[]): JudgeChoice {
|
|
67
|
+
const cli = clis.includes(choice.cli) ? choice.cli : (clis[0] ?? "");
|
|
68
|
+
const models = JUDGE_MODELS[cli] ?? [];
|
|
69
|
+
const model = models.some((m) => m.value === choice.model) ? choice.model : "";
|
|
70
|
+
return { cli, model };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// dock panel content: the session evaluation. The Dock owns positioning;
|
|
74
|
+
// this owns only its own markup.
|
|
75
|
+
export function ReportPanel({ status, analyzing, locked, onAnalyze, onClose, onJumpTo }: ReportPanelProps) {
|
|
76
|
+
// the judge choice persists across sessions and reloads; the picker shows
|
|
77
|
+
// wherever a run can start (empty, failed, stale)
|
|
78
|
+
const [storedChoice, setStoredChoice] = useState<JudgeChoice>(loadStoredChoice);
|
|
79
|
+
const clis = status?.judgeClis ?? (status?.judgeCli ? [status.judgeCli] : []);
|
|
80
|
+
const choice = resolveChoice(storedChoice, clis);
|
|
81
|
+
const changeChoice = useCallback((next: JudgeChoice) => {
|
|
82
|
+
setStoredChoice(next);
|
|
83
|
+
try {
|
|
84
|
+
localStorage.setItem(JUDGE_CHOICE_KEY, JSON.stringify(next));
|
|
85
|
+
} catch {
|
|
86
|
+
// storage full or unavailable — the selection still applies this session
|
|
87
|
+
}
|
|
88
|
+
}, []);
|
|
89
|
+
const analyze = useCallback(() => onAnalyze(choice), [onAnalyze, choice]);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="dock-body" aria-label="Session evaluation">
|
|
93
|
+
<div className="inspector-head">
|
|
94
|
+
<div>
|
|
95
|
+
<div className="inspector-path">Evaluation</div>
|
|
96
|
+
{status?.report ? (
|
|
97
|
+
<div className="report-meta">
|
|
98
|
+
judged by {status.report.judge.cli}
|
|
99
|
+
{status.report.judge.model ? ` · ${status.report.judge.model}` : ""} ·{" "}
|
|
100
|
+
{day(status.report.judge.generatedAt)}
|
|
101
|
+
</div>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
<button className="icon-btn" onClick={onClose} title="Close" aria-label="Close evaluation">
|
|
105
|
+
<X size={15} />
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
<PanelBody
|
|
109
|
+
status={status}
|
|
110
|
+
analyzing={analyzing}
|
|
111
|
+
locked={locked}
|
|
112
|
+
analyze={analyze}
|
|
113
|
+
onJumpTo={onJumpTo}
|
|
114
|
+
picker={clis.length > 0 ? <JudgePicker clis={clis} choice={choice} onChange={changeChoice} /> : null}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function JudgePicker({
|
|
121
|
+
clis,
|
|
122
|
+
choice,
|
|
123
|
+
onChange
|
|
124
|
+
}: {
|
|
125
|
+
clis: string[];
|
|
126
|
+
choice: JudgeChoice;
|
|
127
|
+
onChange: (choice: JudgeChoice) => void;
|
|
128
|
+
}) {
|
|
129
|
+
const models = JUDGE_MODELS[choice.cli] ?? [{ value: "", label: "default model" }];
|
|
130
|
+
return (
|
|
131
|
+
<div className="report-picker">
|
|
132
|
+
<select
|
|
133
|
+
value={choice.cli}
|
|
134
|
+
onChange={(e) => onChange({ cli: e.target.value, model: "" })}
|
|
135
|
+
aria-label="Judge agent"
|
|
136
|
+
title="Which agent CLI judges this session"
|
|
137
|
+
>
|
|
138
|
+
{clis.map((cli) => (
|
|
139
|
+
<option key={cli} value={cli}>
|
|
140
|
+
{cli}
|
|
141
|
+
</option>
|
|
142
|
+
))}
|
|
143
|
+
</select>
|
|
144
|
+
<select
|
|
145
|
+
value={choice.model}
|
|
146
|
+
onChange={(e) => onChange({ ...choice, model: e.target.value })}
|
|
147
|
+
aria-label="Judge model"
|
|
148
|
+
title="Which model the judge runs on"
|
|
149
|
+
>
|
|
150
|
+
{models.map((model) => (
|
|
151
|
+
<option key={model.value} value={model.value}>
|
|
152
|
+
{model.label}
|
|
153
|
+
</option>
|
|
154
|
+
))}
|
|
155
|
+
</select>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function PanelBody({
|
|
161
|
+
status,
|
|
162
|
+
analyzing,
|
|
163
|
+
locked,
|
|
164
|
+
analyze,
|
|
165
|
+
onJumpTo,
|
|
166
|
+
picker
|
|
167
|
+
}: {
|
|
168
|
+
status?: ReportStatus;
|
|
169
|
+
analyzing: boolean;
|
|
170
|
+
locked: boolean;
|
|
171
|
+
analyze: () => void;
|
|
172
|
+
onJumpTo: (seq: number) => void;
|
|
173
|
+
picker: ReactNode;
|
|
174
|
+
}) {
|
|
175
|
+
if (!status) {
|
|
176
|
+
return <p className="report-note">Checking for an existing report…</p>;
|
|
177
|
+
}
|
|
178
|
+
if (status.state === "running" || analyzing) {
|
|
179
|
+
return (
|
|
180
|
+
<div className="report-note">
|
|
181
|
+
<p className="report-running">Judging the trajectory…</p>
|
|
182
|
+
<p>
|
|
183
|
+
The judge first drafts task-specific criteria from your request, then scores the session against
|
|
184
|
+
them plus four process dimensions. Usually a minute or two; you can keep exploring meanwhile.
|
|
185
|
+
</p>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (status.state === "failed") {
|
|
190
|
+
return (
|
|
191
|
+
<div className="report-note">
|
|
192
|
+
<p className="report-error">
|
|
193
|
+
<AlertTriangle size={13} /> Evaluation failed
|
|
194
|
+
</p>
|
|
195
|
+
<p className="report-error-detail">{status.error}</p>
|
|
196
|
+
{picker}
|
|
197
|
+
<button className="report-run" onClick={analyze}>
|
|
198
|
+
<RefreshCw size={13} />
|
|
199
|
+
Retry
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (status.state === "none" || !status.report) {
|
|
205
|
+
if (!status.judgeAvailable) {
|
|
206
|
+
return (
|
|
207
|
+
<p className="report-note">
|
|
208
|
+
Evaluation needs a local agent CLI as judge. Install <code>claude</code> or <code>codex</code> and
|
|
209
|
+
make it available on PATH.
|
|
210
|
+
</p>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return (
|
|
214
|
+
<div className="report-note">
|
|
215
|
+
<p>
|
|
216
|
+
Ask an agent to evaluate this session: how it explored, whether the footprint matched the task,
|
|
217
|
+
where it wandered, and how it verified its work. Every finding links back to the timeline.
|
|
218
|
+
</p>
|
|
219
|
+
{picker}
|
|
220
|
+
<button className="report-run" onClick={analyze}>
|
|
221
|
+
<Sparkles size={13} />
|
|
222
|
+
Evaluate session
|
|
223
|
+
</button>
|
|
224
|
+
<p className="report-cost">
|
|
225
|
+
Runs the selected CLI under your own account and sends it a summary of this session — task wording,
|
|
226
|
+
file paths, event digests — for the model to read. About a minute.
|
|
227
|
+
</p>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const report = status.report;
|
|
233
|
+
return (
|
|
234
|
+
<div className="report-body">
|
|
235
|
+
<div className="report-controls">
|
|
236
|
+
{status.stale ? (
|
|
237
|
+
<p className="report-stale-note">
|
|
238
|
+
Based on {report.session.eventCount} events — the session has grown since.
|
|
239
|
+
</p>
|
|
240
|
+
) : null}
|
|
241
|
+
<div className="report-stale-actions">
|
|
242
|
+
{picker}
|
|
243
|
+
<button
|
|
244
|
+
className="report-rerun"
|
|
245
|
+
onClick={analyze}
|
|
246
|
+
title={status.stale ? "Re-evaluate with the current trace" : "Run a fresh evaluation of this session"}
|
|
247
|
+
>
|
|
248
|
+
<RefreshCw size={12} />
|
|
249
|
+
Re-evaluate
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
{/* the lede: one line of what was asked, then the judge's overview —
|
|
254
|
+
the report reads summary-first, details after */}
|
|
255
|
+
<p className="report-task">{report.taskSummary}</p>
|
|
256
|
+
<p className="report-narrative">{report.narrative}</p>
|
|
257
|
+
<RubricSection rubric={report.rubric} locked={locked} onJumpTo={onJumpTo} />
|
|
258
|
+
<p className="report-chapter">Process</p>
|
|
259
|
+
{report.dimensions.map((dimension) => (
|
|
260
|
+
<Dimension key={dimension.name} dimension={dimension} locked={locked} onJumpTo={onJumpTo} />
|
|
261
|
+
))}
|
|
262
|
+
{report.notableMoments?.length ? (
|
|
263
|
+
<section className="report-section">
|
|
264
|
+
<p className="eyebrow">Moments</p>
|
|
265
|
+
{report.notableMoments.map((moment) => (
|
|
266
|
+
<button
|
|
267
|
+
key={moment.seq}
|
|
268
|
+
className="report-moment"
|
|
269
|
+
onClick={() => onJumpTo(moment.seq)}
|
|
270
|
+
disabled={locked}
|
|
271
|
+
title={`Jump to step ${moment.seq + 1}`}
|
|
272
|
+
>
|
|
273
|
+
<strong>#{moment.seq + 1}</strong>
|
|
274
|
+
<span>{moment.note}</span>
|
|
275
|
+
</button>
|
|
276
|
+
))}
|
|
277
|
+
</section>
|
|
278
|
+
) : null}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// the task-accounting layer: rubric criteria grouped by task, between the
|
|
284
|
+
// task summary and the four process dimensions. Absent or unavailable
|
|
285
|
+
// rubrics collapse to a single quiet line — the fixed layer never waits.
|
|
286
|
+
function RubricSection({
|
|
287
|
+
rubric,
|
|
288
|
+
locked,
|
|
289
|
+
onJumpTo
|
|
290
|
+
}: {
|
|
291
|
+
rubric?: Rubric;
|
|
292
|
+
locked: boolean;
|
|
293
|
+
onJumpTo: (seq: number) => void;
|
|
294
|
+
}) {
|
|
295
|
+
if (!rubric) {
|
|
296
|
+
return <p className="report-rubric-note">This report has no task rubric — re-evaluate to add one.</p>;
|
|
297
|
+
}
|
|
298
|
+
if (rubric.status !== "scored" || !rubric.tasks?.length) {
|
|
299
|
+
const text =
|
|
300
|
+
rubric.reason === "generation-failed"
|
|
301
|
+
? "Task rubric unavailable this run — showing process dimensions only."
|
|
302
|
+
: rubric.reason === "no-events"
|
|
303
|
+
? "No tool events to evidence a rubric."
|
|
304
|
+
: "Not enough task text to build a rubric from.";
|
|
305
|
+
return <p className="report-rubric-note">{text}</p>;
|
|
306
|
+
}
|
|
307
|
+
const tasks = rubric.tasks;
|
|
308
|
+
const criteria = tasks.flatMap((task) => task.criteria);
|
|
309
|
+
const thin = criteria.filter((criterion) => criterion.coverage && criterion.coverage !== "sufficient");
|
|
310
|
+
const showHint = criteria.length > 0 && thin.length / criteria.length > 0.4;
|
|
311
|
+
const multi = tasks.length > 1;
|
|
312
|
+
return (
|
|
313
|
+
<div className="report-rubric">
|
|
314
|
+
<p className="report-chapter">Tasks</p>
|
|
315
|
+
{showHint ? (
|
|
316
|
+
<p className="report-rubric-hint">
|
|
317
|
+
{thin.length} of {criteria.length} criteria had thin evidence — the log may not show enough to
|
|
318
|
+
judge them.
|
|
319
|
+
</p>
|
|
320
|
+
) : null}
|
|
321
|
+
{tasks.map((task, i) => (
|
|
322
|
+
// anchor seqs are validated disjoint across tasks, so the first one is
|
|
323
|
+
// a unique stable key; titles are LLM-authored and may collide
|
|
324
|
+
<RubricTaskBlock
|
|
325
|
+
key={task.anchorSeqs?.[0] ?? `task-${i}`}
|
|
326
|
+
task={task}
|
|
327
|
+
multi={multi}
|
|
328
|
+
locked={locked}
|
|
329
|
+
onJumpTo={onJumpTo}
|
|
330
|
+
/>
|
|
331
|
+
))}
|
|
332
|
+
{rubric.note ? (
|
|
333
|
+
<div className="report-rubric-footnote">
|
|
334
|
+
<p className="eyebrow">Rubric note</p>
|
|
335
|
+
<p>{rubric.note}</p>
|
|
336
|
+
</div>
|
|
337
|
+
) : null}
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function RubricTaskBlock({
|
|
343
|
+
task,
|
|
344
|
+
multi,
|
|
345
|
+
locked,
|
|
346
|
+
onJumpTo
|
|
347
|
+
}: {
|
|
348
|
+
task: RubricTask;
|
|
349
|
+
multi: boolean;
|
|
350
|
+
locked: boolean;
|
|
351
|
+
onJumpTo: (seq: number) => void;
|
|
352
|
+
}) {
|
|
353
|
+
const startSeq = task.anchorSeqs?.[0];
|
|
354
|
+
return (
|
|
355
|
+
<section className="report-rubric-task">
|
|
356
|
+
{multi ? (
|
|
357
|
+
// single-task sessions skip the header: the criteria read like a
|
|
358
|
+
// fifth..nth dimension and the layout stays close to what it was
|
|
359
|
+
<button
|
|
360
|
+
className="rubric-task-head"
|
|
361
|
+
onClick={() => {
|
|
362
|
+
if (startSeq !== undefined) onJumpTo(startSeq);
|
|
363
|
+
}}
|
|
364
|
+
disabled={locked || startSeq === undefined}
|
|
365
|
+
title={startSeq !== undefined ? `Jump to this task's start (step ${startSeq + 1})` : undefined}
|
|
366
|
+
>
|
|
367
|
+
{/* no state dot here: each criterion below carries its own verdict
|
|
368
|
+
chip, and severity dots stay the panel's only dot vocabulary */}
|
|
369
|
+
<span className="rubric-task-title">
|
|
370
|
+
{task.title}
|
|
371
|
+
{task.type ? <span className="rubric-task-type">{task.type}</span> : null}
|
|
372
|
+
</span>
|
|
373
|
+
</button>
|
|
374
|
+
) : null}
|
|
375
|
+
{task.criteria.map((criterion) => (
|
|
376
|
+
<Criterion key={criterion.id} criterion={criterion} locked={locked} onJumpTo={onJumpTo} />
|
|
377
|
+
))}
|
|
378
|
+
</section>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// a criterion renders exactly like a dimension — same head, chip, and
|
|
383
|
+
// finding buttons — plus a muted coverage badge when evidence ran thin
|
|
384
|
+
function Criterion({
|
|
385
|
+
criterion,
|
|
386
|
+
locked,
|
|
387
|
+
onJumpTo
|
|
388
|
+
}: {
|
|
389
|
+
criterion: RubricCriterion;
|
|
390
|
+
locked: boolean;
|
|
391
|
+
onJumpTo: (seq: number) => void;
|
|
392
|
+
}) {
|
|
393
|
+
const hint = [
|
|
394
|
+
criterion.why,
|
|
395
|
+
criterion.good ? `good: ${criterion.good}` : "",
|
|
396
|
+
criterion.bad ? `bad: ${criterion.bad}` : ""
|
|
397
|
+
]
|
|
398
|
+
.filter(Boolean)
|
|
399
|
+
.join("\n");
|
|
400
|
+
return (
|
|
401
|
+
<section className="report-dimension report-criterion">
|
|
402
|
+
<div className="report-dimension-head">
|
|
403
|
+
<span className="report-criterion-name" title={hint || undefined}>
|
|
404
|
+
{criterion.title}
|
|
405
|
+
</span>
|
|
406
|
+
<span className="report-criterion-badges">
|
|
407
|
+
{criterion.coverage === "partial" ? <span className="coverage-badge">partial evidence</span> : null}
|
|
408
|
+
<span
|
|
409
|
+
className={`verdict verdict-${criterion.verdict}`}
|
|
410
|
+
title={
|
|
411
|
+
criterion.coverage === "none"
|
|
412
|
+
? "The log cannot evidence this criterion either way"
|
|
413
|
+
: undefined
|
|
414
|
+
}
|
|
415
|
+
>
|
|
416
|
+
{verdictWord(criterion.verdict)}
|
|
417
|
+
</span>
|
|
418
|
+
</span>
|
|
419
|
+
</div>
|
|
420
|
+
{criterion.findings.map((finding) => (
|
|
421
|
+
<FindingButton key={`${finding.severity}|${finding.evidenceSeqs?.join(",")}|${finding.claim}`} finding={finding} locked={locked} onJumpTo={onJumpTo} />
|
|
422
|
+
))}
|
|
423
|
+
</section>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function FindingButton({
|
|
428
|
+
finding,
|
|
429
|
+
locked,
|
|
430
|
+
onJumpTo
|
|
431
|
+
}: {
|
|
432
|
+
finding: ReportFinding;
|
|
433
|
+
locked: boolean;
|
|
434
|
+
onJumpTo: (seq: number) => void;
|
|
435
|
+
}) {
|
|
436
|
+
return (
|
|
437
|
+
<button
|
|
438
|
+
className="report-finding"
|
|
439
|
+
onClick={() => {
|
|
440
|
+
const seq = finding.evidenceSeqs?.[0];
|
|
441
|
+
if (seq !== undefined) onJumpTo(seq);
|
|
442
|
+
}}
|
|
443
|
+
disabled={locked || !finding.evidenceSeqs?.length}
|
|
444
|
+
title={
|
|
445
|
+
finding.evidenceSeqs?.length
|
|
446
|
+
? `Jump to step ${finding.evidenceSeqs[0] + 1} — evidence: ${finding.evidenceSeqs.map((seq) => `#${seq + 1}`).join(" ")}`
|
|
447
|
+
: undefined
|
|
448
|
+
}
|
|
449
|
+
>
|
|
450
|
+
<span className={`severity-dot ${severityClass(finding.severity)}`} />
|
|
451
|
+
<span className="report-claim">{finding.claim}</span>
|
|
452
|
+
</button>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function Dimension({
|
|
457
|
+
dimension,
|
|
458
|
+
locked,
|
|
459
|
+
onJumpTo
|
|
460
|
+
}: {
|
|
461
|
+
dimension: ReportDimension;
|
|
462
|
+
locked: boolean;
|
|
463
|
+
onJumpTo: (seq: number) => void;
|
|
464
|
+
}) {
|
|
465
|
+
const words = DIMENSION_WORDS[dimension.name] ?? { title: dimension.name, hint: "" };
|
|
466
|
+
return (
|
|
467
|
+
<section className="report-dimension">
|
|
468
|
+
<div className="report-dimension-head" data-hint={words.hint}>
|
|
469
|
+
<span className="report-dimension-name">{words.title}</span>
|
|
470
|
+
<span className={`verdict verdict-${dimension.verdict}`}>{verdictWord(dimension.verdict)}</span>
|
|
471
|
+
</div>
|
|
472
|
+
{dimension.findings.map((finding) => (
|
|
473
|
+
<FindingButton key={`${finding.severity}|${finding.evidenceSeqs?.join(",")}|${finding.claim}`} finding={finding} locked={locked} onJumpTo={onJumpTo} />
|
|
474
|
+
))}
|
|
475
|
+
</section>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function verdictWord(verdict: Verdict): string {
|
|
480
|
+
return verdict === "insufficient-data" ? "no signal" : verdict;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function severityClass(severity: Severity): string {
|
|
484
|
+
return `sev-${severity}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function day(iso: string): string {
|
|
488
|
+
const d = new Date(iso);
|
|
489
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
490
|
+
return d.toISOString().slice(0, 10);
|
|
491
|
+
}
|