feat(research-lab): tier gate (beta+), 1–5 star ratings, run detail route

- Branding: research-lab registered in @mana/shared-branding with requiredTier: 'beta' + a custom flask-on-purple icon, so guest/public users are filtered out of the workbench picker.
- Backend: compare routes now return resultId alongside each CompareEntry so the frontend can wire ratings to the eval_results rows in research.*.
- Frontend: click-to-rate stars in CompareColumn (persists via POST /v1/runs/:runId/results/:resultId/rate), recent-run list rows are now buttons that navigate to /research-lab/runs/[id], and the detail route reconstructs CompareEntry shapes from eval_results + reuses CompareColumn for a full read-only view of any past run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-17 15:28:02 +02:00
parent bd1e273f60
commit 8f0a74b2e7
10 changed files with 358 additions and 16 deletions

View file

@ -6,6 +6,7 @@
is a thin orchestrator over the mana-research service. is a thin orchestrator over the mana-research service.
--> -->
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import ProviderPicker from './components/ProviderPicker.svelte'; import ProviderPicker from './components/ProviderPicker.svelte';
import CompareColumn from './components/CompareColumn.svelte'; import CompareColumn from './components/CompareColumn.svelte';
import { researchLabStore } from './stores/session.svelte'; import { researchLabStore } from './stores/session.svelte';
@ -164,7 +165,11 @@
</div> </div>
<div class="grid"> <div class="grid">
{#each session.lastRun.entries as entry (entry.provider)} {#each session.lastRun.entries as entry (entry.provider)}
<CompareColumn category={session.lastRun.category} {entry} /> <CompareColumn
category={session.lastRun.category}
{entry}
runId={session.lastRun.runId}
/>
{/each} {/each}
</div> </div>
</section> </section>
@ -177,16 +182,22 @@
{:else} {:else}
<ul class="runs-list"> <ul class="runs-list">
{#each recentRuns.slice(0, 10) as run (run.id)} {#each recentRuns.slice(0, 10) as run (run.id)}
<li class="run"> <li>
<span class="run-badge badge-{run.category}">{run.category}</span> <button
<span class="run-query">{run.query}</span> type="button"
<span class="run-providers"> class="run run-button"
{run.providersRequested.join(', ')} onclick={() => void goto(`/research-lab/runs/${run.id}`)}
</span> >
<span class="run-meta"> <span class="run-badge badge-{run.category}">{run.category}</span>
{run.mode} <span class="run-query">{run.query}</span>
{run.totalCostCredits > 0 ? ` · ${run.totalCostCredits}¢` : ''} <span class="run-providers">
</span> {run.providersRequested.join(', ')}
</span>
<span class="run-meta">
{run.mode}
{run.totalCostCredits > 0 ? ` · ${run.totalCostCredits}¢` : ''}
</span>
</button>
</li> </li>
{/each} {/each}
</ul> </ul>
@ -379,6 +390,19 @@
border-radius: 0.375rem; border-radius: 0.375rem;
background: hsl(var(--color-surface)); background: hsl(var(--color-surface));
font-size: 0.8125rem; font-size: 0.8125rem;
color: hsl(var(--color-foreground));
text-align: left;
width: 100%;
}
.run-button {
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.run-button:hover {
background: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-border-strong));
} }
.run-badge { .run-badge {
padding: 0.125rem 0.375rem; padding: 0.125rem 0.375rem;

View file

@ -3,6 +3,7 @@
Renders the appropriate card content based on category. Renders the appropriate card content based on category.
--> -->
<script lang="ts"> <script lang="ts">
import * as api from '../api';
import type { import type {
AgentAnswer, AgentAnswer,
CompareEntry, CompareEntry,
@ -14,9 +15,26 @@
interface Props { interface Props {
category: ResearchCategory; category: ResearchCategory;
entry: CompareEntry<unknown>; entry: CompareEntry<unknown>;
runId?: string;
} }
const { category, entry }: Props = $props(); const { category, entry, runId }: Props = $props();
let rating = $state(entry.userRating ?? 0);
let ratingError = $state<string | null>(null);
async function setRating(value: number) {
if (!runId || !entry.resultId) return;
ratingError = null;
const prev = rating;
rating = value;
try {
await api.rateResult(runId, entry.resultId, value);
} catch (err) {
rating = prev;
ratingError = err instanceof Error ? err.message : 'Bewertung fehlgeschlagen';
}
}
function fmtLatency(ms: number): string { function fmtLatency(ms: number): string {
if (ms < 1000) return `${ms}ms`; if (ms < 1000) return `${ms}ms`;
@ -135,6 +153,29 @@
{:else} {:else}
<p class="empty">Keine Daten.</p> <p class="empty">Keine Daten.</p>
{/if} {/if}
{#if runId && entry.resultId && entry.success}
<footer class="rate">
<span class="rate-label">Bewertung:</span>
<div class="stars" role="radiogroup" aria-label="Bewertung">
{#each [1, 2, 3, 4, 5] as n}
<button
type="button"
role="radio"
aria-checked={rating === n}
aria-label={`${n} Stern${n > 1 ? 'e' : ''}`}
class:filled={rating >= n}
onclick={() => void setRating(n)}
>
</button>
{/each}
</div>
{#if ratingError}
<span class="rate-error" title={ratingError}>⚠</span>
{/if}
</footer>
{/if}
</article> </article>
<style> <style>
@ -335,4 +376,41 @@
font-size: 0.6875rem; font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground)); color: hsl(var(--color-muted-foreground));
} }
.rate {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px dashed hsl(var(--color-border));
}
.rate-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.stars {
display: inline-flex;
gap: 0.125rem;
}
.stars button {
background: transparent;
border: none;
padding: 0.125rem 0.1875rem;
font-size: 0.9375rem;
line-height: 1;
color: hsl(var(--color-muted-foreground) / 0.5);
cursor: pointer;
transition: color 0.15s;
}
.stars button:hover {
color: hsl(var(--color-primary));
}
.stars button.filled {
color: hsl(var(--color-primary));
}
.rate-error {
color: hsl(var(--color-error, 0 84% 40%));
font-size: 0.75rem;
}
</style> </style>

View file

@ -57,6 +57,8 @@ export type CompareEntry<T> = {
success: boolean; success: boolean;
data?: T; data?: T;
meta: ProviderMeta; meta: ProviderMeta;
resultId?: string;
userRating?: number | null;
}; };
export interface SearchCompareResponse { export interface SearchCompareResponse {

View file

@ -0,0 +1,207 @@
<!--
/research-lab/runs/[id] — read-only detail view of a past comparison run.
Reconstructs CompareEntry shapes from eval_results and reuses CompareColumn.
-->
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as api from '$lib/modules/research-lab/api';
import CompareColumn from '$lib/modules/research-lab/components/CompareColumn.svelte';
import type { CompareEntry, RunSummary } from '$lib/modules/research-lab/types';
const runId = $derived($page.params.id);
let run = $state<RunSummary | null>(null);
let entries = $state<CompareEntry<unknown>[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
$effect(() => {
if (!runId) return;
loading = true;
error = null;
api
.getRun(runId)
.then((res) => {
run = res.run as RunSummary;
entries = (
res.results as Array<{
id: string;
providerId: string;
success: boolean;
latencyMs: number;
costCredits: number;
billingMode: string;
cacheHit: boolean;
normalizedResult: unknown;
errorCode: string | null;
userRating: number | null;
}>
).map((r) => ({
provider: r.providerId,
success: r.success,
data: r.normalizedResult,
resultId: r.id,
userRating: r.userRating,
meta: {
provider: r.providerId,
category: (run?.category ?? 'search') as 'search' | 'extract' | 'agent',
latencyMs: r.latencyMs,
costCredits: r.costCredits,
cacheHit: r.cacheHit,
billingMode: r.billingMode as 'server-key' | 'byo-key' | 'free' | 'mixed',
errorCode: r.errorCode ?? undefined,
},
}));
})
.catch((err: Error) => {
error = err.message;
})
.finally(() => {
loading = false;
});
});
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
}
</script>
<svelte:head>
<title>Research Run · Mana</title>
</svelte:head>
<div class="page">
<header class="header">
<button type="button" class="back" onclick={() => void goto('/research-lab')}>
← Zurück zum Lab
</button>
{#if run}
<div class="meta">
<span class="badge badge-{run.category}">{run.category}</span>
<span class="mode">{run.mode}</span>
{#if run.totalCostCredits > 0}
<span class="cost">{run.totalCostCredits}¢</span>
{/if}
<span class="time">{formatDate(run.createdAt)}</span>
</div>
{/if}
</header>
{#if loading}
<p class="loading">Lade Run …</p>
{:else if error}
<div class="error">{error}</div>
{:else if run}
<h1 class="query">{run.query}</h1>
<p class="providers-line">
{run.providersRequested.length} Anbieter · Run <code>{run.id.slice(0, 8)}</code>
</p>
<div class="grid">
{#each entries as entry (entry.resultId)}
<CompareColumn category={run.category} {entry} {runId} />
{/each}
</div>
{/if}
</div>
<style>
.page {
max-width: 100%;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
min-height: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.back {
padding: 0.375rem 0.75rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s;
}
.back:hover {
background: hsl(var(--color-surface-hover));
}
.meta {
display: flex;
align-items: center;
gap: 0.625rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.badge {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge-search {
background: hsl(200 80% 50% / 0.15);
color: hsl(200 80% 40%);
}
.badge-extract {
background: hsl(270 60% 55% / 0.15);
color: hsl(270 60% 45%);
}
.badge-agent {
background: hsl(30 90% 55% / 0.15);
color: hsl(30 90% 40%);
}
.cost {
color: hsl(var(--color-foreground));
font-weight: 500;
}
.query {
font-size: 1.25rem;
font-weight: 600;
margin: 0.5rem 0 0;
}
.providers-line {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin: 0.25rem 0 0;
}
.providers-line code {
font-family: ui-monospace, SFMono-Regular, monospace;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 0.875rem;
}
.loading {
color: hsl(var(--color-muted-foreground));
}
.error {
padding: 0.75rem 1rem;
background: hsl(var(--color-error, 0 84% 60%) / 0.1);
border: 1px solid hsl(var(--color-error, 0 84% 60%) / 0.4);
color: hsl(var(--color-error, 0 84% 40%));
border-radius: 0.375rem;
}
</style>

View file

@ -116,6 +116,9 @@ export const APP_ICONS = {
'news-research': svgToDataUrl( 'news-research': svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="nr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0891b2"/><stop offset="100%" style="stop-color:#22d3ee"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#nr)"/><path d="M30 30a6 6 0 0 0-6 6v6M30 30a6 6 0 0 1 6 6v0M30 30v0" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/><circle cx="30" cy="30" r="3" fill="white"/><circle cx="52" cy="52" r="18" stroke="white" stroke-width="4" fill="none"/><line x1="65" y1="65" x2="78" y2="78" stroke="white" stroke-width="5" stroke-linecap="round"/><line x1="44" y1="50" x2="58" y2="50" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="44" y1="56" x2="54" y2="56" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>` `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="nr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0891b2"/><stop offset="100%" style="stop-color:#22d3ee"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#nr)"/><path d="M30 30a6 6 0 0 0-6 6v6M30 30a6 6 0 0 1 6 6v0M30 30v0" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/><circle cx="30" cy="30" r="3" fill="white"/><circle cx="52" cy="52" r="18" stroke="white" stroke-width="4" fill="none"/><line x1="65" y1="65" x2="78" y2="78" stroke="white" stroke-width="5" stroke-linecap="round"/><line x1="44" y1="50" x2="58" y2="50" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="44" y1="56" x2="54" y2="56" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
), ),
'research-lab': svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="rl" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#8b5cf6"/><stop offset="100%" style="stop-color:#a78bfa"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#rl)"/><path d="M42 22h16v14l12 26a10 10 0 0 1-9 14H39a10 10 0 0 1-9-14l12-26V22z" fill="white" fill-opacity="0.2" stroke="white" stroke-width="3" stroke-linejoin="round"/><line x1="38" y1="22" x2="62" y2="22" stroke="white" stroke-width="4" stroke-linecap="round"/><circle cx="42" cy="58" r="2.5" fill="white"/><circle cx="55" cy="64" r="2" fill="white" fill-opacity="0.7"/><circle cx="48" cy="70" r="2.5" fill="white" fill-opacity="0.9"/></svg>`
),
guides: svgToDataUrl( guides: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="gg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0d9488"/><stop offset="100%" style="stop-color:#0f766e"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#gg)"/><rect x="18" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="54" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="46" y="25" width="8" height="50" fill="white" fill-opacity="0.25"/><circle cx="27" cy="40" r="4" fill="white" fill-opacity="0.9"/><rect x="34" y="37" width="11" height="3" rx="1.5" fill="white" fill-opacity="0.6"/><circle cx="27" cy="52" r="4" fill="white" fill-opacity="0.55"/><rect x="34" y="49" width="9" height="3" rx="1.5" fill="white" fill-opacity="0.4"/><circle cx="27" cy="64" r="4" fill="white" fill-opacity="0.3"/><rect x="34" y="61" width="10" height="3" rx="1.5" fill="white" fill-opacity="0.25"/><path d="M60 52l6 7 12-14" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>` `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="gg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0d9488"/><stop offset="100%" style="stop-color:#0f766e"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#gg)"/><rect x="18" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="54" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="46" y="25" width="8" height="50" fill="white" fill-opacity="0.25"/><circle cx="27" cy="40" r="4" fill="white" fill-opacity="0.9"/><rect x="34" y="37" width="11" height="3" rx="1.5" fill="white" fill-opacity="0.6"/><circle cx="27" cy="52" r="4" fill="white" fill-opacity="0.55"/><rect x="34" y="49" width="9" height="3" rx="1.5" fill="white" fill-opacity="0.4"/><circle cx="27" cy="64" r="4" fill="white" fill-opacity="0.3"/><rect x="34" y="61" width="10" height="3" rx="1.5" fill="white" fill-opacity="0.25"/><path d="M60 52l6 7 12-14" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
), ),

View file

@ -496,6 +496,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development', status: 'development',
requiredTier: 'guest', requiredTier: 'guest',
}, },
{
id: 'research-lab',
name: 'Research Lab',
description: {
de: 'Web-Research Anbieter Seite-an-Seite vergleichen',
en: 'Compare web-research providers side-by-side',
},
longDescription: {
de: 'Schick dieselbe Anfrage parallel an bis zu fünf Anbieter (Brave, Tavily, Exa, Perplexity, Claude, Gemini, OpenAI …) und vergleich Antworten, Latenzen und Kosten in einer Ansicht. Alle Runs werden serverseitig persistiert für spätere Auswertung.',
en: 'Send the same query to up to five providers in parallel (Brave, Tavily, Exa, Perplexity, Claude, Gemini, OpenAI …) and compare answers, latency, and cost side-by-side. All runs are persisted server-side for later review.',
},
icon: APP_ICONS['research-lab'],
color: '#8b5cf6',
comingSoon: false,
status: 'beta',
requiredTier: 'beta',
},
{ {
id: 'calc', id: 'calc',
name: 'Calc', name: 'Calc',

View file

@ -72,6 +72,7 @@ export interface CompareResponse<T> {
success: boolean; success: boolean;
data?: T; data?: T;
meta: ProviderMeta; meta: ProviderMeta;
resultId?: string;
}>; }>;
} }

View file

@ -126,10 +126,11 @@ export function createExtractRoutes(
); );
let totalCost = 0; let totalCost = 0;
const resultIds: string[] = [];
for (let i = 0; i < providers.length; i++) { for (let i = 0; i < providers.length; i++) {
const out = settled[i]; const out = settled[i];
totalCost += out.meta.costCredits; totalCost += out.meta.costCredits;
await storage.addResult({ const row = await storage.addResult({
runId: run.id, runId: run.id,
providerId: providers[i].id, providerId: providers[i].id,
success: out.success, success: out.success,
@ -140,6 +141,7 @@ export function createExtractRoutes(
normalizedResult: out.data ?? null, normalizedResult: out.data ?? null,
errorCode: out.meta.errorCode ?? null, errorCode: out.meta.errorCode ?? null,
}); });
resultIds.push(row.id);
} }
if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost); if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost);
@ -151,6 +153,7 @@ export function createExtractRoutes(
success: settled[i].success, success: settled[i].success,
data: settled[i].data as { content: ExtractedContent } | undefined, data: settled[i].data as { content: ExtractedContent } | undefined,
meta: settled[i].meta, meta: settled[i].meta,
resultId: resultIds[i],
})), })),
}); });
}); });

View file

@ -130,10 +130,11 @@ export function createResearchRoutes(
); );
let totalCost = 0; let totalCost = 0;
const resultIds: string[] = [];
for (let i = 0; i < providers.length; i++) { for (let i = 0; i < providers.length; i++) {
const out = settled[i]; const out = settled[i];
totalCost += out.meta.costCredits; totalCost += out.meta.costCredits;
await storage.addResult({ const row = await storage.addResult({
runId: run.id, runId: run.id,
providerId: providers[i].id, providerId: providers[i].id,
success: out.success, success: out.success,
@ -144,6 +145,7 @@ export function createResearchRoutes(
normalizedResult: out.data ?? null, normalizedResult: out.data ?? null,
errorCode: out.meta.errorCode ?? null, errorCode: out.meta.errorCode ?? null,
}); });
resultIds.push(row.id);
} }
if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost); if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost);
@ -155,6 +157,7 @@ export function createResearchRoutes(
success: settled[i].success, success: settled[i].success,
data: settled[i].data as { answer: AgentAnswer } | undefined, data: settled[i].data as { answer: AgentAnswer } | undefined,
meta: settled[i].meta, meta: settled[i].meta,
resultId: resultIds[i],
})), })),
}); });
}); });

View file

@ -86,7 +86,7 @@ export function createSearchRoutes(
deps deps
); );
await storage.addResult({ const resultRow = await storage.addResult({
runId: run.id, runId: run.id,
providerId, providerId,
success: out.success, success: out.success,
@ -110,6 +110,7 @@ export function createSearchRoutes(
success: out.success, success: out.success,
data: out.data, data: out.data,
meta: out.meta, meta: out.meta,
resultId: resultRow.id,
}); });
}) })
.post('/compare', async (c) => { .post('/compare', async (c) => {
@ -147,10 +148,11 @@ export function createSearchRoutes(
); );
let totalCost = 0; let totalCost = 0;
const resultIds: string[] = [];
for (let i = 0; i < providers.length; i++) { for (let i = 0; i < providers.length; i++) {
const out = settled[i]; const out = settled[i];
totalCost += out.meta.costCredits; totalCost += out.meta.costCredits;
await storage.addResult({ const row = await storage.addResult({
runId: run.id, runId: run.id,
providerId: providers[i].id, providerId: providers[i].id,
success: out.success, success: out.success,
@ -161,6 +163,7 @@ export function createSearchRoutes(
normalizedResult: out.data ?? null, normalizedResult: out.data ?? null,
errorCode: out.meta.errorCode ?? null, errorCode: out.meta.errorCode ?? null,
}); });
resultIds.push(row.id);
} }
if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost); if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost);
@ -172,6 +175,7 @@ export function createSearchRoutes(
success: settled[i].success, success: settled[i].success,
data: settled[i].data as { results: SearchHit[] } | undefined, data: settled[i].data as { results: SearchHit[] } | undefined,
meta: settled[i].meta, meta: settled[i].meta,
resultId: resultIds[i],
})), })),
}); });
}); });