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.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import ProviderPicker from './components/ProviderPicker.svelte';
import CompareColumn from './components/CompareColumn.svelte';
import { researchLabStore } from './stores/session.svelte';
@ -164,7 +165,11 @@
</div>
<div class="grid">
{#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}
</div>
</section>
@ -177,16 +182,22 @@
{:else}
<ul class="runs-list">
{#each recentRuns.slice(0, 10) as run (run.id)}
<li class="run">
<span class="run-badge badge-{run.category}">{run.category}</span>
<span class="run-query">{run.query}</span>
<span class="run-providers">
{run.providersRequested.join(', ')}
</span>
<span class="run-meta">
{run.mode}
{run.totalCostCredits > 0 ? ` · ${run.totalCostCredits}¢` : ''}
</span>
<li>
<button
type="button"
class="run run-button"
onclick={() => void goto(`/research-lab/runs/${run.id}`)}
>
<span class="run-badge badge-{run.category}">{run.category}</span>
<span class="run-query">{run.query}</span>
<span class="run-providers">
{run.providersRequested.join(', ')}
</span>
<span class="run-meta">
{run.mode}
{run.totalCostCredits > 0 ? ` · ${run.totalCostCredits}¢` : ''}
</span>
</button>
</li>
{/each}
</ul>
@ -379,6 +390,19 @@
border-radius: 0.375rem;
background: hsl(var(--color-surface));
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 {
padding: 0.125rem 0.375rem;

View file

@ -3,6 +3,7 @@
Renders the appropriate card content based on category.
-->
<script lang="ts">
import * as api from '../api';
import type {
AgentAnswer,
CompareEntry,
@ -14,9 +15,26 @@
interface Props {
category: ResearchCategory;
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 {
if (ms < 1000) return `${ms}ms`;
@ -135,6 +153,29 @@
{:else}
<p class="empty">Keine Daten.</p>
{/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>
<style>
@ -335,4 +376,41 @@
font-size: 0.6875rem;
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>

View file

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