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:
Semih
2026-05-14 06:11:48 +00:00
parent 9c7682ae63
commit b635b529e2
21 changed files with 1257 additions and 17 deletions

View File

@@ -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",

View File

@@ -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")
}

View File

@@ -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([

View 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&apos;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>
);
}

View File

@@ -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">

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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
View 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