feat(phase6e): eval framework + prompt editor + daily brief + retention + bundle mode
Schema:
- eval_sets (promptTag, cases JSON: [{id, timeline, expected: {...}}])
- eval_runs (per-execution scoring: passedSchema/Severity/Rubric, cost, tokens, results)
Prompts:
- 2 new seed templates: upgrade_hesitation (flash), pattern_bundle (pro)
- pickPromptTag() routes 'upgrade_hesitation' tag to upgrade_hesitation prompt
- Editor UI at /insights/settings/prompts/[id]: edit system/user/schema/tier/temp,
publishPromptVersion() creates new version + deactivates old (CRUD with auto bump)
- setPromptActive() to toggle versions
Daily Brief (/insights/brief):
- Last 24h: sessions processed, insights produced (with severity breakdown),
cost, cache hit rate; today's top 5 priorities; week stats (shipped/validated/regressed)
Retention cron (04:15 UTC daily):
- Delete sessions_meta + session_custom_events older than 90d (unless referenced by
active insight)
- Delete compressed_sessions rows + MinIO timeline blobs older than 180d
- raw_metadata 30d (currently no-op; we don't persist raw metadata to MinIO)
Eval framework:
- apps/worker/src/jobs/eval-run.ts: runs cases against current/specified prompt version,
scores schema_pass + severity_match + rubric_substring; stores EvalRun
- apps/worker BullMQ queue handler for 'eval-run' job name
- apps/web installed bullmq; /lib/queue.ts thin Queue accessor
- Web actions: createEvalSet, triggerEvalRun (enqueues job to insight-pipeline queue)
- UI: /insights/settings/eval-sets list, /new create form (paste JSON cases),
/[id] detail with Run button + recent runs + per-case JSON
Bundle mode (analyze job):
- Pull 3x batch, group by fingerprint
- Groups ≥ INSIGHT_BUNDLE_THRESHOLD (default 3) → use 'pattern_bundle' prompt
- Timeline = primary rep + PATTERN BUNDLE summary block (occurrences, unique users, deltas)
- Insight stores ALL group session IDs as relatedSessionIds; all marked analyzed in one tx
- Cost amortized: 1 LLM call per group
Nav/Cmd+K:
- Inbox header links: Brief, Patterns, Eval sets
- Palette: Daily brief, Eval sets entries
Deferred to backlog: embedding-similarity cross-fingerprint clustering, Telegram brief delivery,
Sase.tr-side data-private audit (separate repo).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"bullmq": "^5.34.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"minio": "^8.0.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
|
||||
@@ -323,3 +323,43 @@ model BudgetSetting {
|
||||
@@unique([projectKey, settingKey])
|
||||
@@map("budget_settings")
|
||||
}
|
||||
|
||||
// ---------- Phase 6e: Eval framework ----------
|
||||
|
||||
model EvalSet {
|
||||
id String @id @default(cuid())
|
||||
promptTag String
|
||||
name String
|
||||
description String?
|
||||
cases Json // [{ id, timeline, expected: {...}, rubric: {severity, hypothesis, ...} }]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
runs EvalRun[]
|
||||
|
||||
@@index([promptTag])
|
||||
@@map("eval_sets")
|
||||
}
|
||||
|
||||
model EvalRun {
|
||||
id String @id @default(cuid())
|
||||
evalSetId String
|
||||
promptTag String
|
||||
promptVersion Int
|
||||
model String
|
||||
totalCases Int
|
||||
passedSchema Int // JSON parse + schema validate succeeded
|
||||
passedSeverity Int // severity matched expected
|
||||
passedRubric Int // overall rubric pass (subjective fields)
|
||||
totalCostUsd Float
|
||||
totalDurationMs Int
|
||||
avgInputTokens Int
|
||||
avgOutputTokens Int
|
||||
results Json // per-case: { caseId, ok, errors[], output }
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
evalSet EvalSet @relation(fields: [evalSetId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([evalSetId, createdAt])
|
||||
@@index([promptTag, promptVersion])
|
||||
@@map("eval_runs")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { createIssue, buildIssueBody } from "@/lib/github";
|
||||
import { pipelineQueue } from "@/lib/queue";
|
||||
|
||||
const ALLOWED_STATUS = new Set([
|
||||
"new",
|
||||
@@ -168,6 +169,132 @@ export async function createGithubIssueForInsight(
|
||||
return { url: issue.html_url, number: issue.number };
|
||||
}
|
||||
|
||||
export type PromptEditInput = {
|
||||
tag: string;
|
||||
systemPrompt: string;
|
||||
userPromptTemplate: string;
|
||||
outputSchemaJson: object;
|
||||
modelTier: "flash" | "pro";
|
||||
maxOutputTokens: number;
|
||||
temperature: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Create a new version of a prompt and deactivate the old one.
|
||||
export async function publishPromptVersion(input: PromptEditInput): Promise<{ id: string; version: number }> {
|
||||
await requireSession();
|
||||
const latest = await prisma.promptTemplate.findFirst({
|
||||
where: { tag: input.tag },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
const nextVersion = (latest?.version ?? 0) + 1;
|
||||
|
||||
// Deactivate all previous versions of this tag
|
||||
await prisma.promptTemplate.updateMany({
|
||||
where: { tag: input.tag, active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
|
||||
const created = await prisma.promptTemplate.create({
|
||||
data: {
|
||||
tag: input.tag,
|
||||
version: nextVersion,
|
||||
name: input.name || `${input.tag} v${nextVersion}`,
|
||||
systemPrompt: input.systemPrompt,
|
||||
userPromptTemplate: input.userPromptTemplate,
|
||||
outputSchemaJson: input.outputSchemaJson as object,
|
||||
modelTier: input.modelTier,
|
||||
maxOutputTokens: input.maxOutputTokens,
|
||||
temperature: input.temperature,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
await writeAudit({
|
||||
endpoint: `/insights/prompts/${input.tag}/publish`,
|
||||
method: "POST",
|
||||
requestPayload: { version: nextVersion },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/insights/settings/prompts");
|
||||
revalidatePath(`/insights/settings/prompts/${created.id}`);
|
||||
return { id: created.id, version: nextVersion };
|
||||
}
|
||||
|
||||
export async function setPromptActive(id: string, active: boolean) {
|
||||
await requireSession();
|
||||
const p = await prisma.promptTemplate.findUnique({ where: { id } });
|
||||
if (!p) throw new Error("prompt not found");
|
||||
if (active) {
|
||||
// Deactivate other versions of this tag
|
||||
await prisma.promptTemplate.updateMany({
|
||||
where: { tag: p.tag, active: true, id: { not: id } },
|
||||
data: { active: false },
|
||||
});
|
||||
}
|
||||
await prisma.promptTemplate.update({ where: { id }, data: { active } });
|
||||
await writeAudit({
|
||||
endpoint: `/insights/prompts/${p.tag}/active`,
|
||||
method: "POST",
|
||||
requestPayload: { active, version: p.version },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/insights/settings/prompts");
|
||||
revalidatePath(`/insights/settings/prompts/${id}`);
|
||||
}
|
||||
|
||||
export async function createEvalSet(input: {
|
||||
promptTag: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
casesJson: string;
|
||||
}): Promise<{ id: string }> {
|
||||
await requireSession();
|
||||
let cases: unknown;
|
||||
try {
|
||||
cases = JSON.parse(input.casesJson);
|
||||
} catch (e) {
|
||||
throw new Error(`cases not valid JSON: ${(e as Error).message}`);
|
||||
}
|
||||
if (!Array.isArray(cases)) throw new Error("cases must be an array");
|
||||
const created = await prisma.evalSet.create({
|
||||
data: {
|
||||
promptTag: input.promptTag,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
cases: cases as object,
|
||||
},
|
||||
});
|
||||
await writeAudit({
|
||||
endpoint: `/insights/eval-sets`,
|
||||
method: "POST",
|
||||
requestPayload: { tag: input.promptTag, cases: (cases as unknown[]).length },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/insights/settings/eval-sets");
|
||||
return { id: created.id };
|
||||
}
|
||||
|
||||
export async function triggerEvalRun(
|
||||
evalSetId: string,
|
||||
promptVersion?: number,
|
||||
): Promise<{ jobId: string }> {
|
||||
await requireSession();
|
||||
const queue = pipelineQueue();
|
||||
const job = await queue.add(
|
||||
"eval-run",
|
||||
{ evalSetId, promptVersion: promptVersion ?? null },
|
||||
{ removeOnComplete: 50, removeOnFail: 25 },
|
||||
);
|
||||
await writeAudit({
|
||||
endpoint: `/insights/eval-sets/${evalSetId}/run`,
|
||||
method: "POST",
|
||||
requestPayload: { promptVersion },
|
||||
responseStatus: 202,
|
||||
});
|
||||
return { jobId: String(job.id ?? "unknown") };
|
||||
}
|
||||
|
||||
export async function updateBudgetSetting(key: string, value: number | boolean) {
|
||||
await requireSession();
|
||||
const allowed = new Set([
|
||||
|
||||
156
apps/web/src/app/insights/brief/page.tsx
Normal file
156
apps/web/src/app/insights/brief/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function severityVariant(sev: string): "default" | "destructive" | "outline" {
|
||||
if (sev === "P0" || sev === "P1") return "destructive";
|
||||
if (sev === "P2") return "default";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
export default async function DailyBriefPage() {
|
||||
const now = new Date();
|
||||
const yesterdayStart = new Date(now.getTime() - 24 * 3600_000);
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 3600_000);
|
||||
|
||||
const [
|
||||
sessionsProcessed,
|
||||
insightsCreated,
|
||||
bySev,
|
||||
costAgg,
|
||||
cacheAgg,
|
||||
topPriorities,
|
||||
validatedThisWeek,
|
||||
regressedThisWeek,
|
||||
shippedThisWeek,
|
||||
] = await Promise.all([
|
||||
prisma.sessionMeta.count({
|
||||
where: { processedAt: { gte: yesterdayStart }, status: { in: ["analyzed", "compressed", "discarded", "tagged"] } },
|
||||
}),
|
||||
prisma.insight.count({ where: { createdAt: { gte: yesterdayStart } } }),
|
||||
prisma.insight.groupBy({
|
||||
by: ["severity"],
|
||||
where: { createdAt: { gte: yesterdayStart } },
|
||||
_count: { severity: true },
|
||||
}),
|
||||
prisma.costLedger.aggregate({
|
||||
where: { createdAt: { gte: yesterdayStart } },
|
||||
_sum: { costTotalUsd: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.costLedger.aggregate({
|
||||
where: { createdAt: { gte: yesterdayStart } },
|
||||
_sum: { tokensInputCacheHit: true, tokensInputCacheMiss: true },
|
||||
}),
|
||||
prisma.insight.findMany({
|
||||
where: { status: { in: ["new", "triaged", "in_backlog", "regressed"] } },
|
||||
orderBy: [{ founderPriority: "desc" }, { priorityScore: "desc" }],
|
||||
take: 5,
|
||||
}),
|
||||
prisma.insight.count({ where: { validatedAt: { gte: weekAgo } } }),
|
||||
prisma.insight.count({ where: { regressionDetected: true, updatedAt: { gte: weekAgo } } }),
|
||||
prisma.insight.count({ where: { shippedAt: { gte: weekAgo } } }),
|
||||
]);
|
||||
|
||||
const sevCounts: Record<string, number> = {};
|
||||
for (const row of bySev) sevCounts[row.severity] = row._count.severity;
|
||||
|
||||
const cost = Number(costAgg._sum.costTotalUsd ?? 0);
|
||||
const calls = Number(costAgg._count ?? 0);
|
||||
const hit = Number(cacheAgg._sum.tokensInputCacheHit ?? 0);
|
||||
const miss = Number(cacheAgg._sum.tokensInputCacheMiss ?? 0);
|
||||
const hitRate = hit + miss > 0 ? hit / (hit + miss) : 0;
|
||||
|
||||
return (
|
||||
<PanelShell title={`🌅 Daily Brief · ${now.toISOString().slice(0, 10)}`}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last 24 hours summary.{" "}
|
||||
<a href="/insights" className="underline">Inbox</a> ·{" "}
|
||||
<a href="/insights/costs" className="underline">Costs</a>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<KPI label="Sessions processed" value={String(sessionsProcessed)} />
|
||||
<KPI label="Insights produced" value={String(insightsCreated)} sub={sevHint(sevCounts)} />
|
||||
<KPI label="Cost (24h)" value={`$${cost.toFixed(4)}`} sub={`${calls} LLM calls`} />
|
||||
<KPI label="Cache hit" value={`${(hitRate * 100).toFixed(0)}%`} sub={`${hit + miss} input tokens`} />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-2 text-sm font-medium text-muted-foreground">
|
||||
Today's priorities ({topPriorities.length})
|
||||
</h2>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px]">Sev</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="w-[80px]">Score</TableHead>
|
||||
<TableHead className="w-[80px]">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topPriorities.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-xs text-muted-foreground">
|
||||
Nothing in the inbox.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
topPriorities.map((i) => (
|
||||
<TableRow key={i.id}>
|
||||
<TableCell>
|
||||
<Badge variant={severityVariant(i.severity)}>{i.severity}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<a href={`/insights/i/${i.id}`} className="underline">
|
||||
{i.title}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{i.priorityScore}</TableCell>
|
||||
<TableCell className="text-xs">{i.status}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-2 text-sm font-medium text-muted-foreground">This week</h2>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
<KPI label="Shipped (7d)" value={String(shippedThisWeek)} />
|
||||
<KPI label="Validated (7d)" value={String(validatedThisWeek)} />
|
||||
<KPI label="Regressed (7d)" value={String(regressedThisWeek)} sub={regressedThisWeek > 0 ? "⚠ investigate" : ""} />
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
function sevHint(counts: Record<string, number>): string {
|
||||
const parts: string[] = [];
|
||||
for (const s of ["P0", "P1", "P2", "P3"]) if (counts[s]) parts.push(`${counts[s]} ${s}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function KPI({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-2xl">{value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">{sub ?? " "}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -84,11 +84,13 @@ export default async function InsightInboxPage({
|
||||
<PanelShell title="Insights · inbox">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI-generated insights from Sase.tr session pipeline. Press <kbd>?</kbd> for shortcuts.{" "}
|
||||
<a href="/insights/brief" className="underline">Brief</a> ·{" "}
|
||||
<a href="/insights/pipeline" className="underline">Pipeline</a> ·{" "}
|
||||
<a href="/insights/patterns" className="underline">Patterns</a> ·{" "}
|
||||
<a href="/insights/costs" className="underline">Costs</a> ·{" "}
|
||||
<a href="/insights/settings/budgets" className="underline">Budgets</a> ·{" "}
|
||||
<a href="/insights/settings/prompts" className="underline">Prompts</a>
|
||||
<a href="/insights/settings/prompts" className="underline">Prompts</a> ·{" "}
|
||||
<a href="/insights/settings/eval-sets" className="underline">Evals</a>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { triggerEvalRun } from "../../../_actions";
|
||||
|
||||
export function RunButton({ evalSetId }: { evalSetId: string }) {
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
|
||||
const fire = () =>
|
||||
start(async () => {
|
||||
try {
|
||||
const res = await triggerEvalRun(evalSetId);
|
||||
setFlash(`queued (job ${res.jobId})`);
|
||||
setTimeout(() => setFlash(null), 4000);
|
||||
} catch (e) {
|
||||
setFlash(`err: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="default" disabled={pending} onClick={fire}>
|
||||
Run eval (active version)
|
||||
</Button>
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
apps/web/src/app/insights/settings/eval-sets/[id]/page.tsx
Normal file
70
apps/web/src/app/insights/settings/eval-sets/[id]/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { notFound } from "next/navigation";
|
||||
import { RunButton } from "./_run-button";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EvalSetDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const set = await prisma.evalSet.findUnique({
|
||||
where: { id },
|
||||
include: { runs: { orderBy: { createdAt: "desc" }, take: 10 } },
|
||||
});
|
||||
if (!set) notFound();
|
||||
|
||||
const cases = Array.isArray(set.cases) ? (set.cases as unknown[]) : [];
|
||||
|
||||
return (
|
||||
<PanelShell title={set.name}>
|
||||
<a href="/insights/settings/eval-sets" className="text-xs underline text-muted-foreground">
|
||||
← Back to eval sets
|
||||
</a>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge variant="outline">tag {set.promptTag}</Badge>
|
||||
<Badge variant="outline">{cases.length} cases</Badge>
|
||||
{set.description && <span className="text-muted-foreground">{set.description}</span>}
|
||||
</div>
|
||||
|
||||
<RunButton evalSetId={set.id} />
|
||||
|
||||
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Recent runs</h2>
|
||||
<div className="space-y-2">
|
||||
{set.runs.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No runs yet — click "Run eval".</p>
|
||||
) : (
|
||||
set.runs.map((r) => (
|
||||
<div key={r.id} className="rounded-md border p-3 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono">{r.createdAt.toISOString().slice(0, 19).replace("T", " ")}</span>
|
||||
<Badge variant="outline">v{r.promptVersion}</Badge>
|
||||
<Badge variant="outline">{r.model}</Badge>
|
||||
<span>schema: {r.passedSchema}/{r.totalCases}</span>
|
||||
<span>severity: {r.passedSeverity}/{r.totalCases}</span>
|
||||
<span>rubric: {r.passedRubric}/{r.totalCases}</span>
|
||||
<span className="ml-auto font-mono">${r.totalCostUsd.toFixed(5)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
avg in: {r.avgInputTokens} · avg out: {r.avgOutputTokens} · {r.totalDurationMs}ms total
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Cases</h2>
|
||||
<details className="rounded-md border bg-muted/20 p-3 text-xs">
|
||||
<summary className="cursor-pointer">View {cases.length} case JSON</summary>
|
||||
<pre className="mt-2 whitespace-pre-wrap font-mono">
|
||||
{JSON.stringify(cases, null, 2).slice(0, 8000)}
|
||||
</pre>
|
||||
</details>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createEvalSet } from "../../../_actions";
|
||||
|
||||
const EXAMPLE = JSON.stringify(
|
||||
[
|
||||
{
|
||||
id: "case-1",
|
||||
timeline: "=== SESSION ===\nid: ex_1\ntags: [bug_suspected]\n=== TIMELINE ===\n00:01 → click [button:\"Sorgula\"]\n00:03 → net POST /api/v1/vin-lookup ❌ 500",
|
||||
expected: {
|
||||
severity: "P1",
|
||||
hypothesis: "PL24 timeout",
|
||||
},
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
export function CreateEvalForm({ promptTags }: { promptTags: string[] }) {
|
||||
const router = useRouter();
|
||||
const [tag, setTag] = useState(promptTags[0] ?? "bug_triage");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [casesJson, setCasesJson] = useState(EXAMPLE);
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
|
||||
const submit = () =>
|
||||
start(async () => {
|
||||
if (!name) {
|
||||
setFlash("err: name required");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await createEvalSet({ promptTag: tag, name, description, casesJson });
|
||||
router.push(`/insights/settings/eval-sets/${res.id}`);
|
||||
} catch (e) {
|
||||
setFlash(`err: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<label className="block text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Prompt tag</div>
|
||||
<select
|
||||
className="rounded border bg-background px-2 py-1"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
>
|
||||
{promptTags.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Name</div>
|
||||
<input
|
||||
className="w-full rounded border bg-background px-2 py-1 text-sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. bug_triage golden set"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Description</div>
|
||||
<input
|
||||
className="w-full rounded border bg-background px-2 py-1 text-sm"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
<div className="mb-1 text-muted-foreground">
|
||||
Cases (JSON array of {`{ id, timeline, expected: {...} }`})
|
||||
</div>
|
||||
<textarea
|
||||
className="h-64 w-full rounded border bg-background p-2 font-mono text-xs"
|
||||
value={casesJson}
|
||||
onChange={(e) => setCasesJson(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="default" disabled={pending} onClick={submit}>
|
||||
Create
|
||||
</Button>
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
apps/web/src/app/insights/settings/eval-sets/new/page.tsx
Normal file
22
apps/web/src/app/insights/settings/eval-sets/new/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { CreateEvalForm } from "./_create-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function NewEvalSetPage() {
|
||||
const prompts = await prisma.promptTemplate.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { tag: "asc" },
|
||||
distinct: ["tag"],
|
||||
});
|
||||
|
||||
return (
|
||||
<PanelShell title="Insights · new eval set">
|
||||
<a href="/insights/settings/eval-sets" className="text-xs underline text-muted-foreground">
|
||||
← Back to eval sets
|
||||
</a>
|
||||
<CreateEvalForm promptTags={prompts.map((p) => p.tag)} />
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
86
apps/web/src/app/insights/settings/eval-sets/page.tsx
Normal file
86
apps/web/src/app/insights/settings/eval-sets/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EvalSetsPage() {
|
||||
const sets = await prisma.evalSet.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { runs: { orderBy: { createdAt: "desc" }, take: 1 } },
|
||||
});
|
||||
|
||||
return (
|
||||
<PanelShell title="Insights · eval sets">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Prompt quality evaluations. Each set is a list of (timeline, expected output) cases used to
|
||||
regression-test a prompt template.{" "}
|
||||
<a href="/insights/settings/eval-sets/new" className="underline">+ New eval set</a> ·{" "}
|
||||
<a href="/insights/settings/prompts" className="underline">Prompts</a>
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Tag</TableHead>
|
||||
<TableHead>Cases</TableHead>
|
||||
<TableHead>Latest run</TableHead>
|
||||
<TableHead>Pass</TableHead>
|
||||
<TableHead>Cost</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-xs text-muted-foreground">
|
||||
No eval sets yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sets.map((s) => {
|
||||
const r = s.runs[0];
|
||||
const cases = Array.isArray(s.cases) ? (s.cases as unknown[]).length : 0;
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>
|
||||
<a href={`/insights/settings/eval-sets/${s.id}`} className="underline">
|
||||
{s.name}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{s.promptTag}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{cases}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{r ? r.createdAt.toISOString().slice(0, 16).replace("T", " ") : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r ? (
|
||||
<Badge variant={r.passedSchema === r.totalCases ? "default" : "outline"}>
|
||||
{r.passedSchema}/{r.totalCases} schema
|
||||
</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{r ? `$${r.totalCostUsd.toFixed(5)}` : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
173
apps/web/src/app/insights/settings/prompts/[id]/_editor.tsx
Normal file
173
apps/web/src/app/insights/settings/prompts/[id]/_editor.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { publishPromptVersion, setPromptActive } from "../../../_actions";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
tag: string;
|
||||
initial: {
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
userPromptTemplate: string;
|
||||
outputSchemaJson: string;
|
||||
modelTier: "flash" | "pro";
|
||||
maxOutputTokens: number;
|
||||
temperature: number;
|
||||
active: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function PromptEditor({ id, tag, initial }: Props) {
|
||||
const [name, setName] = useState(initial.name);
|
||||
const [system, setSystem] = useState(initial.systemPrompt);
|
||||
const [user, setUser] = useState(initial.userPromptTemplate);
|
||||
const [schema, setSchema] = useState(initial.outputSchemaJson);
|
||||
const [tier, setTier] = useState(initial.modelTier);
|
||||
const [maxOut, setMaxOut] = useState(String(initial.maxOutputTokens));
|
||||
const [temp, setTemp] = useState(String(initial.temperature));
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
|
||||
const publish = () =>
|
||||
start(async () => {
|
||||
let parsedSchema: object;
|
||||
try {
|
||||
parsedSchema = JSON.parse(schema);
|
||||
} catch {
|
||||
setFlash("err: schema is not valid JSON");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await publishPromptVersion({
|
||||
tag,
|
||||
name,
|
||||
systemPrompt: system,
|
||||
userPromptTemplate: user,
|
||||
outputSchemaJson: parsedSchema,
|
||||
modelTier: tier,
|
||||
maxOutputTokens: Number(maxOut),
|
||||
temperature: Number(temp),
|
||||
});
|
||||
setFlash(`published v${res.version}`);
|
||||
setTimeout(() => setFlash(null), 3000);
|
||||
} catch (e) {
|
||||
setFlash(`err: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const toggleActive = () =>
|
||||
start(async () => {
|
||||
try {
|
||||
await setPromptActive(id, !initial.active);
|
||||
setFlash(initial.active ? "deactivated" : "activated");
|
||||
setTimeout(() => setFlash(null), 2500);
|
||||
} catch (e) {
|
||||
setFlash(`err: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Editing → publishes as a NEW version of tag <span className="font-mono">{tag}</span>.
|
||||
Old versions get deactivated.
|
||||
</div>
|
||||
|
||||
<Field label="Name">
|
||||
<input
|
||||
className="w-full rounded border bg-background px-2 py-1 text-sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="System prompt">
|
||||
<textarea
|
||||
className="h-48 w-full rounded border bg-background p-2 font-mono text-xs"
|
||||
value={system}
|
||||
onChange={(e) => setSystem(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="User template ({{timeline}} required)">
|
||||
<textarea
|
||||
className="h-24 w-full rounded border bg-background p-2 font-mono text-xs"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Output schema (JSON)">
|
||||
<textarea
|
||||
className="h-40 w-full rounded border bg-background p-2 font-mono text-xs"
|
||||
value={schema}
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Field label="Tier" inline>
|
||||
<select
|
||||
className="rounded border bg-background px-2 py-1 text-xs"
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as "flash" | "pro")}
|
||||
>
|
||||
<option value="flash">flash</option>
|
||||
<option value="pro">pro</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Max out" inline>
|
||||
<input
|
||||
className="w-20 rounded border bg-background px-2 py-1 text-xs font-mono"
|
||||
value={maxOut}
|
||||
onChange={(e) => setMaxOut(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Temp" inline>
|
||||
<input
|
||||
className="w-16 rounded border bg-background px-2 py-1 text-xs font-mono"
|
||||
value={temp}
|
||||
onChange={(e) => setTemp(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="default" disabled={pending} onClick={publish}>
|
||||
Publish new version
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={toggleActive}>
|
||||
{initial.active ? "Deactivate this version" : "Reactivate this version"}
|
||||
</Button>
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
inline,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
inline?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (inline) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-muted-foreground">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PromptEditor } from "./_editor";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -48,6 +49,22 @@ export default async function PromptDetailPage({
|
||||
{JSON.stringify(p.outputSchemaJson, null, 2)}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-sm font-medium">Edit (publishes as new version)</h2>
|
||||
<PromptEditor
|
||||
id={p.id}
|
||||
tag={p.tag}
|
||||
initial={{
|
||||
name: p.name,
|
||||
systemPrompt: p.systemPrompt,
|
||||
userPromptTemplate: p.userPromptTemplate,
|
||||
outputSchemaJson: JSON.stringify(p.outputSchemaJson, null, 2),
|
||||
modelTier: p.modelTier as "flash" | "pro",
|
||||
maxOutputTokens: p.maxOutputTokens,
|
||||
temperature: p.temperature,
|
||||
active: p.active,
|
||||
}}
|
||||
/>
|
||||
|
||||
{recentCost.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-sm font-medium">Recent calls</h2>
|
||||
|
||||
@@ -69,6 +69,12 @@ export function CommandPalette({ projects }: { projects: ProjectLite[] }) {
|
||||
<CommandItem keywords={["inbox","triage"]} onSelect={() => go("/insights")}>
|
||||
<LightbulbIcon /> Insight inbox
|
||||
</CommandItem>
|
||||
<CommandItem keywords={["brief","daily","summary"]} onSelect={() => go("/insights/brief")}>
|
||||
<LightbulbIcon /> Daily brief
|
||||
</CommandItem>
|
||||
<CommandItem keywords={["eval","test","quality"]} onSelect={() => go("/insights/settings/eval-sets")}>
|
||||
<SparklesIcon /> Eval sets
|
||||
</CommandItem>
|
||||
<CommandItem keywords={["cost","spend","budget","llm"]} onSelect={() => go("/insights/costs")}>
|
||||
<CoinsIcon /> Cost dashboard
|
||||
</CommandItem>
|
||||
|
||||
13
apps/web/src/lib/queue.ts
Normal file
13
apps/web/src/lib/queue.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Queue } from "bullmq";
|
||||
import IORedis from "ioredis";
|
||||
|
||||
const url = process.env.REDIS_URL;
|
||||
let _queue: Queue | null = null;
|
||||
|
||||
export function pipelineQueue(): Queue {
|
||||
if (_queue) return _queue;
|
||||
if (!url) throw new Error("REDIS_URL not set");
|
||||
const connection = new IORedis(url, { maxRetriesPerRequest: null });
|
||||
_queue = new Queue("insight-pipeline", { connection });
|
||||
return _queue;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user