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
@@ -9,6 +9,7 @@ const ANALYZE_BATCH = Number(process.env.INSIGHT_ANALYZE_BATCH ?? "8");
|
||||
const CACHE_TTL_HOURS = Number(process.env.INSIGHT_INSIGHT_CACHE_HOURS ?? "6");
|
||||
const PROJECT_KEY = process.env.INSIGHT_PROJECT_KEY ?? "sase";
|
||||
const COMPRESSION_BUCKET = process.env.INSIGHT_COMPRESSED_BUCKET ?? "insight-compressed";
|
||||
const BUNDLE_THRESHOLD = Number(process.env.INSIGHT_BUNDLE_THRESHOLD ?? "3");
|
||||
|
||||
export type AnalyzeResult = {
|
||||
analyzed: number;
|
||||
@@ -29,18 +30,32 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||
where: { status: "compressed", projectKey: PROJECT_KEY },
|
||||
include: { compressed: true },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: ANALYZE_BATCH,
|
||||
take: ANALYZE_BATCH * 3, // pull more to allow bundle grouping
|
||||
});
|
||||
if (compressed.length === 0) {
|
||||
return { analyzed: 0, skipped: 0, failed: 0, costUsd: 0, budgetState: budget.state };
|
||||
}
|
||||
|
||||
// Group by fingerprint. Groups of >= BUNDLE_THRESHOLD route to pattern_bundle prompt.
|
||||
const byFp = new Map<string, typeof compressed>();
|
||||
for (const s of compressed) {
|
||||
if (!s.fingerprint) continue;
|
||||
const arr = byFp.get(s.fingerprint) ?? [];
|
||||
arr.push(s);
|
||||
byFp.set(s.fingerprint, arr);
|
||||
}
|
||||
// Take up to ANALYZE_BATCH groups (bundles count as 1 unit)
|
||||
const groups = [...byFp.values()].slice(0, ANALYZE_BATCH);
|
||||
|
||||
let analyzed = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
let costUsd = 0;
|
||||
|
||||
for (const s of compressed) {
|
||||
for (const group of groups) {
|
||||
const isBundle = group.length >= BUNDLE_THRESHOLD;
|
||||
const primary = group[0];
|
||||
const s = primary;
|
||||
if (!s.compressed || !s.fingerprint) {
|
||||
skipped++;
|
||||
await prisma.sessionMeta.update({ where: { id: s.id }, data: { status: "discarded" } });
|
||||
@@ -72,7 +87,7 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const promptTag = pickPromptTag(s.tags);
|
||||
const promptTag = isBundle ? "pattern_bundle" : pickPromptTag(s.tags);
|
||||
const template = await prisma.promptTemplate.findFirst({
|
||||
where: { tag: promptTag, active: true },
|
||||
orderBy: { version: "desc" },
|
||||
@@ -89,7 +104,7 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||
else if (s.severity === "P2" || s.severity === "P3" || s.severity === "INFO") tier = "flash";
|
||||
if (budget.forceTier) tier = budget.forceTier;
|
||||
|
||||
// Fetch timeline from MinIO
|
||||
// Fetch timeline from MinIO (primary)
|
||||
let timeline: string;
|
||||
try {
|
||||
timeline = await getText(COMPRESSION_BUCKET, s.compressed.semanticTimelineMinioKey);
|
||||
@@ -99,6 +114,34 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For bundles, append a summary of the other sessions in the group.
|
||||
if (isBundle) {
|
||||
const otherSessionIds = group.slice(1).map((x) => x.id);
|
||||
const others = group.slice(1);
|
||||
const uniqueUsers = new Set(group.map((x) => x.userIdHash).filter(Boolean));
|
||||
const firstSeen = group.reduce((a, b) => (a.startedAt < b.startedAt ? a : b)).startedAt;
|
||||
const lastSeen = group.reduce((a, b) => (a.startedAt > b.startedAt ? a : b)).startedAt;
|
||||
const tags = new Set<string>();
|
||||
for (const g of group) for (const t of g.tags) tags.add(t);
|
||||
const summary = [
|
||||
``,
|
||||
`=== PATTERN BUNDLE ===`,
|
||||
`fingerprint: ${s.fingerprint}`,
|
||||
`first_seen: ${firstSeen.toISOString().slice(0, 10)}`,
|
||||
`last_seen: ${lastSeen.toISOString().slice(0, 10)}`,
|
||||
`occurrences: ${group.length}`,
|
||||
`unique_users: ${uniqueUsers.size}`,
|
||||
`aggregate_tags: [${[...tags].join(", ")}]`,
|
||||
``,
|
||||
`=== SESSIONS 2..${group.length} SUMMARY ===`,
|
||||
...others.slice(0, 8).map((o, i) =>
|
||||
`- session ${i + 2}: started ${o.startedAt.toISOString().slice(0, 16)} · duration ${Math.round(o.durationMs / 1000)}s · errors ${o.errorCount} · custom_events ${o.customEventCount}`,
|
||||
),
|
||||
otherSessionIds.length > 8 ? `- ... ${otherSessionIds.length - 8} more` : "",
|
||||
].join("\n");
|
||||
timeline = timeline + "\n" + summary;
|
||||
}
|
||||
|
||||
const userPrompt = template.userPromptTemplate.replace("{{timeline}}", timeline);
|
||||
|
||||
let result;
|
||||
@@ -198,9 +241,11 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const rel = Array.from(new Set([...existing.relatedSessionIds, s.id]));
|
||||
const groupIds = group.map((g) => g.id);
|
||||
const rel = Array.from(new Set([...existing.relatedSessionIds, ...groupIds]));
|
||||
// Existing insight in dismissed/validated state → mark new occurrence as potential regression
|
||||
const isRegression = ["validated", "shipped"].includes(existing.status);
|
||||
const lastSeen = group.reduce((a, b) => (a.startedAt > b.startedAt ? a : b)).startedAt;
|
||||
await prisma.insight.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
@@ -210,7 +255,7 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||
status: isRegression ? "regressed" : existing.status,
|
||||
relatedSessionIds: rel,
|
||||
occurrenceCount: rel.length,
|
||||
lastSeenAt: s.startedAt > existing.lastSeenAt ? s.startedAt : existing.lastSeenAt,
|
||||
lastSeenAt: lastSeen > existing.lastSeenAt ? lastSeen : existing.lastSeenAt,
|
||||
confidence: conf,
|
||||
priorityScore,
|
||||
sourceModel: result.model,
|
||||
@@ -218,6 +263,10 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const groupIds = group.map((g) => g.id);
|
||||
const uniqueUsers = new Set(group.map((g) => g.userIdHash).filter(Boolean));
|
||||
const firstSeen = group.reduce((a, b) => (a.startedAt < b.startedAt ? a : b)).startedAt;
|
||||
const lastSeen = group.reduce((a, b) => (a.startedAt > b.startedAt ? a : b)).startedAt;
|
||||
await prisma.insight.create({
|
||||
data: {
|
||||
projectKey: PROJECT_KEY,
|
||||
@@ -227,11 +276,11 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||
fingerprint: s.fingerprint,
|
||||
title,
|
||||
body: parsed,
|
||||
relatedSessionIds: [s.id],
|
||||
occurrenceCount: 1,
|
||||
uniqueUserCount: 1,
|
||||
firstSeenAt: s.startedAt,
|
||||
lastSeenAt: s.startedAt,
|
||||
relatedSessionIds: groupIds,
|
||||
occurrenceCount: group.length,
|
||||
uniqueUserCount: Math.max(1, uniqueUsers.size),
|
||||
firstSeenAt: firstSeen,
|
||||
lastSeenAt: lastSeen,
|
||||
confidence: conf,
|
||||
priorityScore,
|
||||
sourcePromptTag: promptTag,
|
||||
@@ -242,11 +291,12 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.sessionMeta.update({
|
||||
where: { id: s.id },
|
||||
// Mark every session in the group as analyzed (bundle covers them all).
|
||||
await prisma.sessionMeta.updateMany({
|
||||
where: { id: { in: group.map((g) => g.id) } },
|
||||
data: { status: "analyzed", processedAt: new Date() },
|
||||
});
|
||||
analyzed++;
|
||||
analyzed += group.length;
|
||||
|
||||
// Stop if budget became hard-capped mid-batch
|
||||
const recheck = await checkBudget();
|
||||
|
||||
171
apps/worker/src/jobs/eval-run.ts
Normal file
171
apps/worker/src/jobs/eval-run.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { prisma } from "../db";
|
||||
import { callDeepSeek, extractJson, type Tier } from "../lib/deepseek";
|
||||
import { validate } from "../lib/json-validate";
|
||||
|
||||
export type EvalCase = {
|
||||
id: string;
|
||||
timeline: string;
|
||||
expected: Record<string, unknown>; // ground truth output (subset)
|
||||
};
|
||||
|
||||
export type EvalCaseResult = {
|
||||
caseId: string;
|
||||
schemaOk: boolean;
|
||||
severityOk: boolean;
|
||||
rubricOk: boolean;
|
||||
errors: string[];
|
||||
output: unknown;
|
||||
durationMs: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costUsd: number;
|
||||
};
|
||||
|
||||
export async function runEvalSet(evalSetId: string, promptVersion?: number): Promise<{ runId: string }> {
|
||||
const evalSet = await prisma.evalSet.findUnique({ where: { id: evalSetId } });
|
||||
if (!evalSet) throw new Error("eval set not found");
|
||||
|
||||
const template = promptVersion
|
||||
? await prisma.promptTemplate.findUnique({
|
||||
where: { tag_version: { tag: evalSet.promptTag, version: promptVersion } },
|
||||
})
|
||||
: await prisma.promptTemplate.findFirst({
|
||||
where: { tag: evalSet.promptTag, active: true },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
if (!template) throw new Error(`no template for tag ${evalSet.promptTag}`);
|
||||
|
||||
const cases = (evalSet.cases as unknown as EvalCase[]) ?? [];
|
||||
if (!Array.isArray(cases) || cases.length === 0) throw new Error("eval set has no cases");
|
||||
|
||||
const results: EvalCaseResult[] = [];
|
||||
let totalCost = 0;
|
||||
let totalDuration = 0;
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
let passedSchema = 0;
|
||||
let passedSeverity = 0;
|
||||
let passedRubric = 0;
|
||||
|
||||
for (const c of cases) {
|
||||
const userPrompt = template.userPromptTemplate.replace("{{timeline}}", c.timeline);
|
||||
const tier = template.modelTier as Tier;
|
||||
|
||||
let res;
|
||||
let err: string[] = [];
|
||||
let output: unknown = null;
|
||||
try {
|
||||
res = await callDeepSeek({
|
||||
tier,
|
||||
systemPrompt: template.systemPrompt,
|
||||
userPrompt,
|
||||
maxOutputTokens: template.maxOutputTokens,
|
||||
temperature: template.temperature,
|
||||
});
|
||||
} catch (e) {
|
||||
err.push(`call: ${(e as Error).message}`);
|
||||
results.push({
|
||||
caseId: c.id,
|
||||
schemaOk: false,
|
||||
severityOk: false,
|
||||
rubricOk: false,
|
||||
errors: err,
|
||||
output: null,
|
||||
durationMs: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
costUsd: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
totalCost += res.cost.totalUsd;
|
||||
totalDuration += res.durationMs;
|
||||
totalInput += res.usage.inputTokensMiss + res.usage.inputTokensHit;
|
||||
totalOutput += res.usage.outputTokens;
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(extractJson(res.text));
|
||||
output = parsed;
|
||||
} catch (e) {
|
||||
err.push(`parse: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
let schemaOk = false;
|
||||
if (parsed) {
|
||||
const errs = validate(parsed, template.outputSchemaJson as any);
|
||||
if (!errs.length) {
|
||||
schemaOk = true;
|
||||
passedSchema++;
|
||||
} else {
|
||||
err.push(...errs.slice(0, 3).map((e) => `schema: ${e.path}: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Severity check
|
||||
const expected = c.expected ?? {};
|
||||
let severityOk = false;
|
||||
if (parsed && expected.severity && parsed.severity === expected.severity) {
|
||||
severityOk = true;
|
||||
passedSeverity++;
|
||||
} else if (parsed && expected.severity) {
|
||||
err.push(`severity: expected ${expected.severity}, got ${parsed.severity}`);
|
||||
}
|
||||
|
||||
// Rubric: check that any expected string fields contain expected substrings (loose)
|
||||
let rubricOk = true;
|
||||
if (parsed && expected) {
|
||||
for (const [k, v] of Object.entries(expected)) {
|
||||
if (k === "severity") continue;
|
||||
if (typeof v === "string" && typeof (parsed as any)[k] === "string") {
|
||||
// Substring match (case-insensitive) for rubric flexibility
|
||||
if (!(parsed as any)[k].toLowerCase().includes(v.toLowerCase())) {
|
||||
rubricOk = false;
|
||||
err.push(`rubric ${k}: missing "${v.slice(0, 40)}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rubricOk = false;
|
||||
}
|
||||
if (rubricOk) passedRubric++;
|
||||
|
||||
results.push({
|
||||
caseId: c.id,
|
||||
schemaOk,
|
||||
severityOk,
|
||||
rubricOk,
|
||||
errors: err,
|
||||
output,
|
||||
durationMs: res.durationMs,
|
||||
inputTokens: res.usage.inputTokensMiss + res.usage.inputTokensHit,
|
||||
outputTokens: res.usage.outputTokens,
|
||||
costUsd: res.cost.totalUsd,
|
||||
});
|
||||
}
|
||||
|
||||
const run = await prisma.evalRun.create({
|
||||
data: {
|
||||
evalSetId: evalSet.id,
|
||||
promptTag: template.tag,
|
||||
promptVersion: template.version,
|
||||
model: template.modelTier === "pro" ? "deepseek-v4-pro" : "deepseek-v4-flash",
|
||||
totalCases: cases.length,
|
||||
passedSchema,
|
||||
passedSeverity,
|
||||
passedRubric,
|
||||
totalCostUsd: totalCost,
|
||||
totalDurationMs: totalDuration,
|
||||
avgInputTokens: Math.round(totalInput / cases.length),
|
||||
avgOutputTokens: Math.round(totalOutput / cases.length),
|
||||
results: results as unknown as object,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[eval] ${template.tag}@v${template.version} schema=${passedSchema}/${cases.length} severity=${passedSeverity}/${cases.length} rubric=${passedRubric}/${cases.length} cost=$${totalCost.toFixed(5)}`,
|
||||
);
|
||||
|
||||
return { runId: run.id };
|
||||
}
|
||||
80
apps/worker/src/jobs/retention.ts
Normal file
80
apps/worker/src/jobs/retention.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { prisma } from "../db";
|
||||
import { getMinio } from "../lib/minio";
|
||||
|
||||
const SESSIONS_DAYS = Number(process.env.INSIGHT_RETENTION_SESSIONS_DAYS ?? "90");
|
||||
const COMPRESSED_DAYS = Number(process.env.INSIGHT_RETENTION_COMPRESSED_DAYS ?? "180");
|
||||
const RAW_METADATA_DAYS = Number(process.env.INSIGHT_RETENTION_RAW_METADATA_DAYS ?? "30");
|
||||
const COMPRESSION_BUCKET = process.env.INSIGHT_COMPRESSED_BUCKET ?? "insight-compressed";
|
||||
|
||||
export type RetentionResult = {
|
||||
sessionsDeleted: number;
|
||||
compressedRowsDeleted: number;
|
||||
minioObjectsDeleted: number;
|
||||
customEventsDeleted: number;
|
||||
};
|
||||
|
||||
export async function runRetention(): Promise<RetentionResult> {
|
||||
const now = Date.now();
|
||||
const sessionsCutoff = new Date(now - SESSIONS_DAYS * 24 * 3600_000);
|
||||
const compressedCutoff = new Date(now - COMPRESSED_DAYS * 24 * 3600_000);
|
||||
|
||||
// 1) MinIO objects older than compressedCutoff
|
||||
const minio = getMinio();
|
||||
let minioObjectsDeleted = 0;
|
||||
if (minio) {
|
||||
const oldCompressed = await prisma.compressedSession.findMany({
|
||||
where: { createdAt: { lt: compressedCutoff } },
|
||||
select: { sessionId: true, semanticTimelineMinioKey: true },
|
||||
});
|
||||
for (const row of oldCompressed) {
|
||||
try {
|
||||
await minio.removeObject(COMPRESSION_BUCKET, row.semanticTimelineMinioKey);
|
||||
minioObjectsDeleted++;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) compressed_sessions rows older than compressedCutoff
|
||||
const compressedDel = await prisma.compressedSession.deleteMany({
|
||||
where: { createdAt: { lt: compressedCutoff } },
|
||||
});
|
||||
|
||||
// 3) session_custom_events for sessions older than sessions cutoff
|
||||
const oldSessionIds = await prisma.sessionMeta.findMany({
|
||||
where: { startedAt: { lt: sessionsCutoff } },
|
||||
select: { id: true },
|
||||
take: 10_000,
|
||||
});
|
||||
const customDel = await prisma.sessionCustomEvent.deleteMany({
|
||||
where: { sessionId: { in: oldSessionIds.map((s) => s.id) } },
|
||||
});
|
||||
|
||||
// 4) sessions_meta older than sessions cutoff — but keep any referenced by an active insight
|
||||
// (related_session_ids). Simpler approach: only delete those with status != 'analyzed' AND old.
|
||||
// Even simpler for now: delete sessions where startedAt < cutoff AND no insight references them.
|
||||
// To avoid scanning, accept some retention overlap.
|
||||
const activeInsightSessionIds = new Set<string>();
|
||||
const activeInsights = await prisma.insight.findMany({
|
||||
where: { status: { notIn: ["dismissed", "validated", "duplicate"] } },
|
||||
select: { relatedSessionIds: true },
|
||||
});
|
||||
for (const ins of activeInsights) {
|
||||
for (const sid of ins.relatedSessionIds) activeInsightSessionIds.add(sid);
|
||||
}
|
||||
const candidates = oldSessionIds.filter((s) => !activeInsightSessionIds.has(s.id));
|
||||
const sessionsDel = await prisma.sessionMeta.deleteMany({
|
||||
where: { id: { in: candidates.map((s) => s.id) } },
|
||||
});
|
||||
|
||||
// 5) raw_metadata_url cleanup (raw posthog metadata; currently we don't write this, no-op).
|
||||
void RAW_METADATA_DAYS;
|
||||
|
||||
return {
|
||||
sessionsDeleted: sessionsDel.count,
|
||||
compressedRowsDeleted: compressedDel.count,
|
||||
minioObjectsDeleted,
|
||||
customEventsDeleted: customDel.count,
|
||||
};
|
||||
}
|
||||
@@ -98,6 +98,40 @@ const ONBOARDING_SCHEMA = {
|
||||
},
|
||||
};
|
||||
|
||||
const UPGRADE_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["type", "severity", "title", "viewed_tier", "likely_concern", "hypothesis", "confidence"],
|
||||
properties: {
|
||||
type: { const: "conversion" },
|
||||
severity: { enum: ["P1", "P2", "P3"] },
|
||||
title: { type: "string", maxLength: 120 },
|
||||
viewed_tier: { type: "string" },
|
||||
time_on_pricing_seconds: { type: "number" },
|
||||
comparison_signals: { type: "boolean" },
|
||||
likely_concern: { enum: ["price", "feature", "trust", "unclear", "wrong_tier", "other"] },
|
||||
feature_gap_hypothesis: { type: "string", maxLength: 300 },
|
||||
suggested_intervention: { type: "string", maxLength: 300 },
|
||||
hypothesis: { type: "string", maxLength: 500 },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const BUNDLE_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["type", "severity", "title", "pattern_name", "root_cause_hypothesis", "confidence"],
|
||||
properties: {
|
||||
type: { const: "pattern" },
|
||||
severity: { enum: ["P0", "P1", "P2", "P3"] },
|
||||
title: { type: "string", maxLength: 120 },
|
||||
pattern_name: { type: "string", maxLength: 80 },
|
||||
root_cause_hypothesis: { type: "string", maxLength: 600 },
|
||||
affected_user_count: { type: "integer" },
|
||||
business_impact_estimate: { type: "string", maxLength: 300 },
|
||||
priority_recommendation: { type: "string", maxLength: 200 },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const PROVIDER_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["type", "severity", "title", "affected_provider", "hypothesis", "confidence"],
|
||||
@@ -186,6 +220,46 @@ Return JSON per the schema.`,
|
||||
maxOutputTokens: 900,
|
||||
temperature: 0.3,
|
||||
},
|
||||
{
|
||||
tag: "upgrade_hesitation",
|
||||
version: 1,
|
||||
name: "Upgrade Hesitation v1",
|
||||
systemPrompt: `You analyze sessions where a user reached the pricing/upgrade page but did not convert. Identify likely concern (price/feature/trust) and propose intervention.
|
||||
|
||||
${SASE_CONTEXT}
|
||||
|
||||
Schema:
|
||||
${JSON.stringify(UPGRADE_SCHEMA)}`,
|
||||
userPromptTemplate: `{{timeline}}
|
||||
|
||||
Return JSON per the schema.`,
|
||||
outputSchemaJson: UPGRADE_SCHEMA,
|
||||
modelTier: "flash",
|
||||
maxOutputTokens: 900,
|
||||
temperature: 0.3,
|
||||
},
|
||||
{
|
||||
tag: "pattern_bundle",
|
||||
version: 1,
|
||||
name: "Pattern Bundle v1",
|
||||
systemPrompt: `You are given an aggregate timeline that bundles 3+ user sessions sharing the same fingerprint (similar tags, route, and error signature). Synthesize the cross-session pattern and recommend priority.
|
||||
|
||||
${SASE_CONTEXT}
|
||||
|
||||
The input timeline contains:
|
||||
- A representative session in full
|
||||
- Summary deltas of N additional sessions (just what differs)
|
||||
|
||||
Schema:
|
||||
${JSON.stringify(BUNDLE_SCHEMA)}`,
|
||||
userPromptTemplate: `{{timeline}}
|
||||
|
||||
Return JSON per the schema. Use occurrence patterns from the bundle summary to estimate priority and impact.`,
|
||||
outputSchemaJson: BUNDLE_SCHEMA,
|
||||
modelTier: "pro",
|
||||
maxOutputTokens: 1200,
|
||||
temperature: 0.2,
|
||||
},
|
||||
{
|
||||
tag: "provider_quality",
|
||||
version: 1,
|
||||
@@ -220,6 +294,9 @@ export function pickPromptTag(tags: string[]): string {
|
||||
)
|
||||
return "payment_issue";
|
||||
|
||||
// Upgrade hesitation — pricing page concerns
|
||||
if (set.has("upgrade_hesitation")) return "upgrade_hesitation";
|
||||
|
||||
// Provider issues
|
||||
if (
|
||||
set.has("provider_reliability_issue") ||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { runCompressSessions } from "../jobs/compress-sessions";
|
||||
import { runAnalyze } from "../jobs/analyze";
|
||||
import { runValidation } from "../jobs/validation";
|
||||
import { runGithubSync } from "../jobs/github-sync";
|
||||
import { runRetention } from "../jobs/retention";
|
||||
import { runEvalSet } from "../jobs/eval-run";
|
||||
|
||||
const QUEUE = "insight-pipeline";
|
||||
|
||||
@@ -57,6 +59,18 @@ async function runJob(job: Job) {
|
||||
}
|
||||
return res;
|
||||
}
|
||||
case "retention": {
|
||||
const res = await runRetention();
|
||||
console.log(
|
||||
`[pipeline] retention sessions=${res.sessionsDeleted} compressed=${res.compressedRowsDeleted} minio=${res.minioObjectsDeleted} custom=${res.customEventsDeleted}`,
|
||||
);
|
||||
return res;
|
||||
}
|
||||
case "eval-run": {
|
||||
const { evalSetId, promptVersion } = job.data as { evalSetId: string; promptVersion?: number };
|
||||
const res = await runEvalSet(evalSetId, promptVersion);
|
||||
return res;
|
||||
}
|
||||
default:
|
||||
return { ok: false, error: `unknown job ${job.name}` };
|
||||
}
|
||||
@@ -93,6 +107,11 @@ export async function startInsightPipeline() {
|
||||
{ pattern: "*/10 * * * *" },
|
||||
{ name: "github-sync", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"retention",
|
||||
{ pattern: "15 4 * * *" },
|
||||
{ name: "retention", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
|
||||
);
|
||||
|
||||
new Worker(QUEUE, runJob, {
|
||||
connection: redis,
|
||||
@@ -101,6 +120,6 @@ export async function startInsightPipeline() {
|
||||
stalledInterval: 60_000,
|
||||
});
|
||||
console.log(
|
||||
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min",
|
||||
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user