Skip to content

Commit fe977e9

Browse files
OgeonX-AiAitomates
andauthored
feat: add tested prompt tournament dashboard (#19)
Co-authored-by: Kim Harjamäki <kim.harjamaki@prosimo.fi>
1 parent 8c2e462 commit fe977e9

9 files changed

Lines changed: 377 additions & 3 deletions

File tree

universal-refiner/src/core/dashboard.html

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ <h1 style="margin:0; font-size: 1rem; letter-spacing: -0.025em;">PROMPT<span sty
8383
<div class="nav-item" onclick="switchView('intelligence')">🧠 COMMIT INTELLIGENCE</div>
8484
<div class="nav-item" onclick="switchView('learning')">📖 LEARNING LAYER</div>
8585
<div class="nav-item" onclick="switchView('library')">📚 PROMPT LIBRARY</div>
86+
<div class="nav-item" onclick="switchView('tournaments')">🏆 TOURNAMENTS</div>
8687
<div class="nav-item" onclick="switchView('health')">PROVIDER HEALTH</div>
8788

8889
<div style="margin-top: 2rem; font-size: 0.6rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em; padding-left: 0.75rem;">Selected Project</div>
@@ -168,6 +169,35 @@ <h2>Provider Metrics</h2>
168169
</div>
169170
</div>
170171
</div>
172+
173+
<!-- VIEW: TOURNAMENTS -->
174+
<div id="view-tournaments" class="main-view hidden">
175+
<div class="grid">
176+
<div class="card col-12">
177+
<h2>Run A/B Prompt Tournament</h2>
178+
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
179+
<div style="flex: 1;">
180+
<label style="display:block; margin-bottom: 4px; color: var(--dim);">Baseline Prompt</label>
181+
<textarea id="t-baseline" style="width: 100%; height: 100px; background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.1); color: var(--text); padding: 8px; font-family: monospace;"></textarea>
182+
</div>
183+
<div style="flex: 1;">
184+
<label style="display:block; margin-bottom: 4px; color: var(--dim);">Variant A</label>
185+
<textarea id="t-variant-a" style="width: 100%; height: 100px; background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.1); color: var(--text); padding: 8px; font-family: monospace;"></textarea>
186+
</div>
187+
<div style="flex: 1;">
188+
<label style="display:block; margin-bottom: 4px; color: var(--dim);">Variant B</label>
189+
<textarea id="t-variant-b" style="width: 100%; height: 100px; background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.1); color: var(--text); padding: 8px; font-family: monospace;"></textarea>
190+
</div>
191+
</div>
192+
<button onclick="runTournament()" style="background: var(--accent); color: #fff; border: none; padding: 8px 16px; cursor: pointer; border-radius: 4px; font-weight: bold;">Run Heuristic Tournament</button>
193+
<div id="tournament-run-status" style="margin-top: 8px; font-size: 0.8rem; color: var(--dim);"></div>
194+
</div>
195+
<div class="card col-12">
196+
<h2>Tournament History</h2>
197+
<div id="tournament-history"></div>
198+
</div>
199+
</div>
200+
</div>
171201
</div>
172202

173203
<script id="dashboard-data" type="application/json">{{STATE_JSON}}</script>
@@ -190,12 +220,38 @@ <h2>Provider Metrics</h2>
190220
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
191221
event.target.classList.add('active');
192222

193-
const titles = { 'stream': 'Global Intelligence Stream', 'intelligence': 'Commit Intelligence', 'learning': 'Learning Layer', 'library': 'Prompt Library', 'health': 'Provider Health' };
223+
const titles = { 'stream': 'Global Intelligence Stream', 'intelligence': 'Commit Intelligence', 'learning': 'Learning Layer', 'library': 'Prompt Library', 'tournaments': 'A/B Tournaments', 'health': 'Provider Health' };
194224
document.getElementById('view-title').textContent = titles[viewId];
195225
currentView = viewId;
196226
refreshData();
197227
}
198228

229+
async function runTournament() {
230+
const baseline = document.getElementById('t-baseline').value;
231+
const variantA = document.getElementById('t-variant-a').value;
232+
const variantB = document.getElementById('t-variant-b').value;
233+
if (!baseline || !variantA || !variantB) {
234+
document.getElementById('tournament-run-status').textContent = "All three prompts are required.";
235+
return;
236+
}
237+
document.getElementById('tournament-run-status').textContent = "Running heuristic evaluation...";
238+
try {
239+
const res = await fetch(`/api/tournaments/run?project=${encodeURIComponent(currentProject)}`, {
240+
method: 'POST',
241+
headers: { 'Content-Type': 'application/json' },
242+
body: JSON.stringify({ baseline, variantA, variantB })
243+
});
244+
if (res.ok) {
245+
document.getElementById('tournament-run-status').innerHTML = `<span style="color:#22c55e">Tournament complete!</span>`;
246+
refreshData();
247+
} else {
248+
document.getElementById('tournament-run-status').textContent = "Failed to run tournament.";
249+
}
250+
} catch(e) {
251+
document.getElementById('tournament-run-status').textContent = "Error: " + e.message;
252+
}
253+
}
254+
199255
async function refreshData() {
200256
// 1. Refresh Basic State
201257
const stateRes = await fetch(`/api/state?project=${encodeURIComponent(currentProject)}`);
@@ -313,6 +369,46 @@ <h2>Provider Metrics</h2>
313369
`).join('') || '<p style="color:var(--dim)">Prompt library is currently empty.</p>';
314370
}
315371

372+
if (currentView === 'tournaments') {
373+
const res = await fetch(`/api/tournaments?project=${encodeURIComponent(currentProject)}`);
374+
const data = await res.json();
375+
document.getElementById('tournament-history').innerHTML = data.map(t => {
376+
let details = {};
377+
try { details = JSON.parse(t.details_json); } catch(e) {}
378+
379+
let winnerHtml = `<span class="badge" style="background: rgba(34, 197, 94, 0.15); color: #86efac; border-color: rgba(34, 197, 94, 0.3);">WINNER: ${t.winner_observed}</span>`;
380+
if (t.winner_observed === 'tie') {
381+
winnerHtml = `<span class="badge" style="background: rgba(255, 255, 255, 0.15); color: #fff; border-color: rgba(255, 255, 255, 0.3);">TIE</span>`;
382+
}
383+
384+
let scoreA = details.variantA?.evaluation?.heuristicScore || 0;
385+
let scoreB = details.variantB?.evaluation?.heuristicScore || 0;
386+
387+
return `
388+
<div class="card" style="border-top: 2px solid ${t.winner_observed === 'A' ? 'var(--accent)' : t.winner_observed === 'B' ? '#f472b6' : 'var(--dim)'}; margin-bottom: 1rem;">
389+
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
390+
<strong>Tournament ${t.id.substring(0,8)}...</strong>
391+
${winnerHtml}
392+
</div>
393+
<div style="font-size: 0.8rem; color: var(--dim); margin-bottom: 8px;">Created: ${new Date(t.created_at).toLocaleString()}</div>
394+
395+
<div style="font-size: 0.85rem; color: var(--dim); margin-bottom: 4px;"><strong>BASELINE:</strong> ${escapeHtml(t.baseline_prompt)}</div>
396+
397+
<div style="display: flex; gap: 1rem; margin-top: 10px;">
398+
<div style="flex:1; padding: 10px; background: rgba(0,0,0,0.2); border-left: 2px solid var(--accent); border-radius: 4px;">
399+
<div style="margin-bottom: 6px;"><strong>VARIANT A</strong> (Score: ${scoreA})</div>
400+
<pre style="font-size: 0.75rem; white-space: pre-wrap;">${escapeHtml(t.variant_a)}</pre>
401+
</div>
402+
<div style="flex:1; padding: 10px; background: rgba(0,0,0,0.2); border-left: 2px solid #f472b6; border-radius: 4px;">
403+
<div style="margin-bottom: 6px;"><strong>VARIANT B</strong> (Score: ${scoreB})</div>
404+
<pre style="font-size: 0.75rem; white-space: pre-wrap;">${escapeHtml(t.variant_b)}</pre>
405+
</div>
406+
</div>
407+
</div>
408+
`;
409+
}).join('') || '<p style="color:var(--dim)">No tournament history found.</p>';
410+
}
411+
316412
if (currentView === 'health') {
317413
const res = await fetch(`/api/health?project=${encodeURIComponent(currentProject)}`);
318414
const health = await res.json();

universal-refiner/src/core/dashboard.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { ConfigManager } from "./config.js";
1313
import { TimelineProvider } from "../history/timeline.js";
1414
import { EventStore } from "../history/event-store.js";
1515
import { AutoPilotStatus } from "./autopilot-status.js";
16+
import { createABEvaluationRecord } from "../evaluation/prompt-evaluator.js";
17+
import { randomUUID } from "crypto";
1618

1719
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1820

@@ -393,6 +395,69 @@ export class CommandCenterDashboard {
393395
}
394396
});
395397

398+
app.get("/api/tournaments", async (c) => {
399+
const selectedPath = this.resolveSelectedPath(c.req.query("project"));
400+
try {
401+
const repoId = EventStore.getInstance().ensureRepository(selectedPath).id;
402+
const tournaments = EventStore.getInstance().getTournaments(repoId);
403+
return c.json(tournaments);
404+
} catch (error) {
405+
this.logRouteError("api/tournaments", error, selectedPath);
406+
return c.json({ error: "Failed to fetch tournaments" }, 500);
407+
}
408+
});
409+
410+
app.post("/api/tournaments/run", async (c) => {
411+
const selectedPath = this.resolveSelectedPath(c.req.query("project"));
412+
try {
413+
if (!isSameOriginRequest(c.req.header("origin"), c.req.url)) {
414+
return c.json({ error: "Cross-origin tournament requests are not allowed" }, 403);
415+
}
416+
if (!isJsonContentType(c.req.header("content-type"))) {
417+
return c.json({ error: "Tournament requests must use application/json" }, 415);
418+
}
419+
420+
let body: { baseline?: unknown; variantA?: unknown; variantB?: unknown };
421+
try {
422+
body = await c.req.json() as { baseline?: unknown; variantA?: unknown; variantB?: unknown };
423+
} catch {
424+
return c.json({ error: "Tournament request body must be valid JSON" }, 400);
425+
}
426+
427+
const { baseline, variantA, variantB } = body;
428+
if (
429+
typeof baseline !== "string" || baseline.trim().length === 0 ||
430+
typeof variantA !== "string" || variantA.trim().length === 0 ||
431+
typeof variantB !== "string" || variantB.trim().length === 0
432+
) {
433+
return c.json({ error: "Tournament baseline, variantA, and variantB must be non-empty strings" }, 400);
434+
}
435+
436+
const experiment = createABEvaluationRecord({
437+
experimentId: `exp_${randomUUID()}`,
438+
baselinePrompt: baseline,
439+
variantA: { id: "A", prompt: variantA },
440+
variantB: { id: "B", prompt: variantB }
441+
});
442+
443+
const repoId = EventStore.getInstance().ensureRepository(selectedPath).id;
444+
EventStore.getInstance().recordTournament({
445+
id: experiment.experimentId,
446+
repo_id: repoId,
447+
baseline_prompt: baseline,
448+
variant_a: variantA,
449+
variant_b: variantB,
450+
winner_observed: experiment.heuristicPreference,
451+
details_json: JSON.stringify(experiment)
452+
});
453+
454+
return c.json(experiment);
455+
} catch (error) {
456+
this.logRouteError("api/tournaments/run", error, selectedPath);
457+
return c.json({ error: "Failed to run tournament" }, 500);
458+
}
459+
});
460+
396461
app.get("/api/events", async (c) => {
397462
try {
398463
return streamSSE(c, async (stream) => {

universal-refiner/src/history/event-store.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,43 @@ export class EventStore {
403403
);
404404
}
405405

406+
recordTournament(tournament: {
407+
id: string;
408+
repo_id?: string | null;
409+
baseline_prompt: string;
410+
variant_a: string;
411+
variant_b: string;
412+
winner_observed: string;
413+
details_json: string;
414+
}) {
415+
const now = new Date().toISOString();
416+
const stmt = this.db.prepare(`
417+
INSERT INTO tournaments (
418+
id, repo_id, baseline_prompt, variant_a, variant_b, winner_observed, details_json, created_at
419+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
420+
`);
421+
stmt.run(
422+
tournament.id,
423+
tournament.repo_id || null,
424+
tournament.baseline_prompt,
425+
tournament.variant_a,
426+
tournament.variant_b,
427+
tournament.winner_observed,
428+
tournament.details_json,
429+
now
430+
);
431+
}
432+
433+
getTournaments(repoId: string, limit = 50) {
434+
const stmt = this.db.prepare(`
435+
SELECT * FROM tournaments
436+
WHERE repo_id = ? OR repo_id IS NULL
437+
ORDER BY created_at DESC
438+
LIMIT ?
439+
`);
440+
return stmt.all(repoId, limit);
441+
}
442+
406443
recordTemplate(template: {
407444
id: string;
408445
repo_id: string;

universal-refiner/src/history/schema.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,4 +154,15 @@ CREATE TABLE IF NOT EXISTS prompt_template_links (
154154
commit_id TEXT,
155155
lesson_id TEXT
156156
);
157+
158+
CREATE TABLE IF NOT EXISTS tournaments (
159+
id TEXT PRIMARY KEY,
160+
repo_id TEXT,
161+
baseline_prompt TEXT NOT NULL,
162+
variant_a TEXT NOT NULL,
163+
variant_b TEXT NOT NULL,
164+
winner_observed TEXT NOT NULL,
165+
details_json TEXT NOT NULL DEFAULT '{}',
166+
created_at TEXT NOT NULL
167+
);
157168
`;

universal-refiner/tests/dashboard-api.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,49 @@ describe("dashboard review and health APIs", () => {
123123
expect(invalidJson.status).toBe(400);
124124
});
125125

126+
it("runs and persists prompt tournaments through same-origin JSON requests", async () => {
127+
const app = CommandCenterDashboard.createApp(repoDir);
128+
const response = await app.request("/api/tournaments/run", {
129+
method: "POST",
130+
headers: { "content-type": "application/json", origin: "http://localhost" },
131+
body: JSON.stringify({
132+
baseline: "Fix failing tests",
133+
variantA: "Fix failing tests with regression coverage and verification",
134+
variantB: "Fix tests",
135+
}),
136+
});
137+
138+
expect(response.status).toBe(200);
139+
const experiment = await response.json() as any;
140+
expect(experiment.experimentId).toMatch(/^exp_/);
141+
expect(experiment.heuristicPreference).toBe("A");
142+
143+
const listResponse = await app.request("/api/tournaments");
144+
const tournaments = await listResponse.json() as any[];
145+
expect(listResponse.status).toBe(200);
146+
expect(tournaments).toEqual([
147+
expect.objectContaining({
148+
id: experiment.experimentId,
149+
baseline_prompt: "Fix failing tests",
150+
winner_observed: "A",
151+
}),
152+
]);
153+
});
154+
155+
it("rejects unsafe or malformed tournament mutations", async () => {
156+
const app = CommandCenterDashboard.createApp(repoDir);
157+
const request = (body: string, headers: Record<string, string> = { "content-type": "application/json", origin: "http://localhost" }) =>
158+
app.request("/api/tournaments/run", { method: "POST", headers, body });
159+
160+
expect((await request(JSON.stringify({ baseline: "x", variantA: "y", variantB: "z" }), {
161+
"content-type": "application/json",
162+
origin: "https://attacker.example",
163+
})).status).toBe(403);
164+
expect((await request("{}", { origin: "http://localhost" })).status).toBe(415);
165+
expect((await request("{")).status).toBe(400);
166+
expect((await request(JSON.stringify({ baseline: "x", variantA: " ", variantB: "z" }))).status).toBe(400);
167+
});
168+
126169
it("returns sanitized semantic provider and runtime health", async () => {
127170
fs.writeFileSync(path.join(repoDir, ".universal-refiner.json"), JSON.stringify({
128171
semantic: {
@@ -177,6 +220,8 @@ describe("dashboard review and health APIs", () => {
177220
expect(html).toContain("reviewCandidate");
178221
expect(html).toContain("Approve");
179222
expect(html).toContain("Reject");
223+
expect(html).toContain("Run A/B Prompt Tournament");
224+
expect(html).toContain("/api/tournaments");
180225
expect(html).toContain("PROVIDER HEALTH");
181226
expect(html).toContain("/api/health");
182227
});

universal-refiner/tests/dashboard-coverage.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ describe("dashboard deterministic fallbacks", () => {
139139
["/api/commits", () => vi.spyOn(EventStore, "getInstance").mockImplementationOnce(() => { throw new Error("commit secret"); })],
140140
["/api/lessons", () => vi.spyOn(EventStore, "getInstance").mockImplementationOnce(() => { throw new Error("lesson secret"); })],
141141
["/api/templates", () => vi.spyOn(EventStore, "getInstance").mockImplementationOnce(() => { throw new Error("template secret"); })],
142+
["/api/tournaments", () => vi.spyOn(EventStore, "getInstance").mockImplementationOnce(() => { throw new Error("tournament secret"); })],
142143
["/api/health", () => vi.spyOn(CommandCenterDashboard as any, "buildHealth").mockImplementationOnce(() => { throw new Error("health secret"); })],
143144
["/", () => vi.spyOn(CommandCenterDashboard as any, "buildState").mockRejectedValueOnce("root failure")],
144145
];
@@ -151,6 +152,24 @@ describe("dashboard deterministic fallbacks", () => {
151152
expect(RuntimeLogger.error).toBeDefined();
152153
});
153154

155+
it("returns sanitized tournament mutation failures", async () => {
156+
const app = CommandCenterDashboard.createApp(directory);
157+
vi.spyOn(EventStore, "getInstance").mockImplementationOnce(() => { throw new Error("tournament write secret"); });
158+
159+
const response = await app.request("/api/tournaments/run", {
160+
method: "POST",
161+
headers: { "content-type": "application/json", origin: "http://localhost" },
162+
body: JSON.stringify({
163+
baseline: "baseline",
164+
variantA: "variant a",
165+
variantB: "variant b",
166+
}),
167+
});
168+
169+
expect(response.status).toBe(500);
170+
expect(await response.json()).toEqual({ error: "Failed to run tournament" });
171+
});
172+
154173
it("renders an Error without a stack in the root failure page", async () => {
155174
const app = CommandCenterDashboard.createApp(directory);
156175
const error = new Error("root message");

universal-refiner/tests/dashboard-routes.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe("dashboard route coverage", () => {
2626
fs.rmSync(testDir, { recursive: true, force: true });
2727
});
2828

29-
it("serves state, timeline, commits, lessons, templates, health, and HTML", async () => {
29+
it("serves state, timeline, commits, lessons, templates, tournaments, health, and HTML", async () => {
3030
const repoId = store.ensureRepository(repoDir).id;
3131
store.recordPrompt({ id: "prompt", repo_id: repoId, client: "test", raw_prompt: "Implement feature" });
3232
store.recordCommit({
@@ -55,9 +55,18 @@ describe("dashboard route coverage", () => {
5555
source_type: "test",
5656
success_score: 80,
5757
});
58+
store.recordTournament({
59+
id: "tournament",
60+
repo_id: repoId,
61+
baseline_prompt: "baseline",
62+
variant_a: "variant a",
63+
variant_b: "variant b",
64+
winner_observed: "A",
65+
details_json: "{}",
66+
});
5867
const app = CommandCenterDashboard.createApp(repoDir);
5968

60-
for (const route of ["/api/state", "/api/timeline", "/api/commits", "/api/lessons", "/api/templates", "/api/health", "/"]) {
69+
for (const route of ["/api/state", "/api/timeline", "/api/commits", "/api/lessons", "/api/templates", "/api/tournaments", "/api/health", "/"]) {
6170
const response = await app.request(route);
6271
expect(response.status, route).toBe(200);
6372
}

0 commit comments

Comments
 (0)