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

View File

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

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

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

View File

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

View File

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