feat(phase6b): LLM analysis layer — DeepSeek + insights + cost ledger + budget guard
Schema: - insights (fingerprint dedup, occurrence_count, related_session_ids[], priority_score) - cost_ledger (per-call, cache_hit/miss/output token split + USD) - prompt_templates (versioned, per-tag, with JSON schema + tier + temperature) - budget_settings (global key/value: monthly_hard_cap_usd, daily_soft/hard, per_call_max, analysis_paused) Worker: - lib/deepseek.ts: Anthropic-compat endpoint client (deepseek-v4-flash | deepseek-v4-pro), usage→USD with V4 promo pricing, cache_read_input_tokens awareness, extractJson() helper - lib/budget.ts: checkBudget() returns active|soft_throttled|hard_paused|monthly_paused, forces flash tier on soft cap, halts on hard/monthly cap or analysis_paused - lib/prompts.ts: 5 seed templates (bug_triage P, ux_friction F, payment_issue P, onboarding_stuck F, provider_quality F) with embedded JSON schemas + Sase.tr context; pickPromptTag() maps session tags → prompt - lib/json-validate.ts: lightweight schema validator (no ajv dep) - lib/seed-runtime.ts: idempotent upsert of prompts + default budget settings on worker boot - lib/minio.ts: +getText() for compressed timeline fetch - jobs/analyze.ts: budget guard → 6h fingerprint cache (attach session to existing insight) → template lookup → severity-based tier override → DeepSeek call → JSON parse + validate → insert insight (or aggregate occurrence) → write cost_ledger - scheduler: analyze@*/4min on insight-pipeline queue UI: - /insights (was pipeline view) → now Insight Inbox: priority-sorted list w/ KPI strip (new/in_backlog/shipped/today $/month $), severity badges, link to detail - /insights/i/[id]: insight detail with structured body render, related sessions, per-session cost breakdown, raw JSON collapsible - /insights/costs: KPI cards (today, month, avg, cache hit), daily 30d bar table, by-model + by-prompt breakdowns, top 10 expensive, recent errors - /insights/pipeline: moved old session-pipeline view here - /insights/sessions/[id]: unchanged session timeline viewer Defaults: - monthly cap $30, daily soft $1.50 / hard $3, per-call $0.20, analysis_paused=false - Severity→Tier: P0/P1=pro, P2/P3/INFO=flash; budget soft-cap forces flash Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -217,3 +217,108 @@ model SessionCustomEvent {
|
|||||||
@@index([sessionId, timestamp])
|
@@index([sessionId, timestamp])
|
||||||
@@map("session_custom_events")
|
@@map("session_custom_events")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Phase 6b: LLM Analysis ----------
|
||||||
|
|
||||||
|
model PromptTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tag String
|
||||||
|
version Int
|
||||||
|
name String
|
||||||
|
systemPrompt String @db.Text
|
||||||
|
userPromptTemplate String @db.Text
|
||||||
|
outputSchemaJson Json
|
||||||
|
modelTier String // "flash" | "pro"
|
||||||
|
maxOutputTokens Int @default(1500)
|
||||||
|
temperature Float @default(0.3)
|
||||||
|
active Boolean @default(true)
|
||||||
|
performanceStats Json @default("{}")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([tag, version])
|
||||||
|
@@map("prompt_templates")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Insight {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectKey String
|
||||||
|
type String
|
||||||
|
severity String
|
||||||
|
status String @default("new")
|
||||||
|
fingerprint String
|
||||||
|
title String
|
||||||
|
body Json
|
||||||
|
relatedSessionIds String[]
|
||||||
|
occurrenceCount Int @default(1)
|
||||||
|
uniqueUserCount Int @default(1)
|
||||||
|
firstSeenAt DateTime
|
||||||
|
lastSeenAt DateTime
|
||||||
|
confidence Float?
|
||||||
|
priorityScore Int @default(0)
|
||||||
|
sourcePromptTag String
|
||||||
|
sourcePromptVersion Int
|
||||||
|
sourceModel String
|
||||||
|
sourceCostUsd Float
|
||||||
|
githubIssueUrl String?
|
||||||
|
githubIssueId BigInt?
|
||||||
|
githubIssueState String?
|
||||||
|
shippedAt DateTime?
|
||||||
|
validationStartedAt DateTime?
|
||||||
|
validationPeriodDays Int @default(14)
|
||||||
|
regressionDetected Boolean @default(false)
|
||||||
|
validatedAt DateTime?
|
||||||
|
founderNotes String? @db.Text
|
||||||
|
founderSeverityOverride String?
|
||||||
|
founderPriority Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([projectKey, fingerprint])
|
||||||
|
@@index([status, projectKey])
|
||||||
|
@@index([severity, status])
|
||||||
|
@@index([lastSeenAt(sort: Desc)])
|
||||||
|
@@index([priorityScore(sort: Desc)])
|
||||||
|
@@map("insights")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CostLedger {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
insightId String?
|
||||||
|
sessionId String?
|
||||||
|
projectKey String
|
||||||
|
promptTag String?
|
||||||
|
promptVersion Int?
|
||||||
|
provider String // "deepseek" | "anthropic" | "openrouter"
|
||||||
|
model String
|
||||||
|
tier String // "flash" | "pro"
|
||||||
|
tokensInputCacheMiss Int
|
||||||
|
tokensInputCacheHit Int
|
||||||
|
tokensOutput Int
|
||||||
|
costInputCacheMissUsd Float
|
||||||
|
costInputCacheHitUsd Float
|
||||||
|
costOutputUsd Float
|
||||||
|
costTotalUsd Float
|
||||||
|
cacheHitRatio Float
|
||||||
|
callDurationMs Int?
|
||||||
|
wasFallback Boolean @default(false)
|
||||||
|
wasRetry Boolean @default(false)
|
||||||
|
errorCode String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([projectKey, createdAt])
|
||||||
|
@@index([insightId])
|
||||||
|
@@map("cost_ledger")
|
||||||
|
}
|
||||||
|
|
||||||
|
model BudgetSetting {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectKey String? // null = global
|
||||||
|
settingKey String
|
||||||
|
settingValue Json
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([projectKey, settingKey])
|
||||||
|
@@map("budget_settings")
|
||||||
|
}
|
||||||
|
|||||||
293
apps/web/src/app/insights/costs/page.tsx
Normal file
293
apps/web/src/app/insights/costs/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function startOfDayUtc(d: Date): Date {
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||||
|
}
|
||||||
|
function startOfMonthUtc(d: Date): Date {
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CostDashboard() {
|
||||||
|
const now = new Date();
|
||||||
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 3600_000);
|
||||||
|
|
||||||
|
const [todayAgg, monthAgg, byModel, byPrompt, byDay, recentExpensive, recentErrors, budgetSettings] = await Promise.all([
|
||||||
|
prisma.costLedger.aggregate({
|
||||||
|
where: { createdAt: { gte: startOfDayUtc(now) } },
|
||||||
|
_sum: { costTotalUsd: true, tokensInputCacheHit: true, tokensInputCacheMiss: true, tokensOutput: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.costLedger.aggregate({
|
||||||
|
where: { createdAt: { gte: startOfMonthUtc(now) } },
|
||||||
|
_sum: { costTotalUsd: true, tokensInputCacheHit: true, tokensInputCacheMiss: true, tokensOutput: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.costLedger.groupBy({
|
||||||
|
by: ["model"],
|
||||||
|
where: { createdAt: { gte: startOfMonthUtc(now) } },
|
||||||
|
_sum: { costTotalUsd: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.costLedger.groupBy({
|
||||||
|
by: ["promptTag"],
|
||||||
|
where: { createdAt: { gte: startOfMonthUtc(now) } },
|
||||||
|
_sum: { costTotalUsd: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.$queryRaw<Array<{ day: Date; cost: number; calls: bigint }>>`
|
||||||
|
SELECT date_trunc('day', "createdAt") AS day,
|
||||||
|
SUM("costTotalUsd") AS cost,
|
||||||
|
COUNT(*) AS calls
|
||||||
|
FROM "cost_ledger"
|
||||||
|
WHERE "createdAt" >= ${monthAgo}
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day DESC
|
||||||
|
LIMIT 30
|
||||||
|
`,
|
||||||
|
prisma.costLedger.findMany({
|
||||||
|
where: { createdAt: { gte: startOfMonthUtc(now) } },
|
||||||
|
orderBy: { costTotalUsd: "desc" },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
prisma.costLedger.findMany({
|
||||||
|
where: { errorCode: { not: null } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
prisma.budgetSetting.findMany({ where: { projectKey: null } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const limits: Record<string, number> = {};
|
||||||
|
for (const b of budgetSettings) {
|
||||||
|
if (typeof b.settingValue === "number") limits[b.settingKey] = b.settingValue;
|
||||||
|
}
|
||||||
|
const monthlyCap = limits.monthly_hard_cap_usd ?? 30;
|
||||||
|
const dailyHardCap = limits.daily_hard_cap_usd ?? 3;
|
||||||
|
|
||||||
|
const today = Number(todayAgg._sum.costTotalUsd ?? 0);
|
||||||
|
const month = Number(monthAgg._sum.costTotalUsd ?? 0);
|
||||||
|
const todayHit = Number(todayAgg._sum.tokensInputCacheHit ?? 0);
|
||||||
|
const todayMiss = Number(todayAgg._sum.tokensInputCacheMiss ?? 0);
|
||||||
|
const hitRate = todayHit + todayMiss > 0 ? todayHit / (todayHit + todayMiss) : 0;
|
||||||
|
|
||||||
|
const monthCalls = Number(monthAgg._count ?? 0);
|
||||||
|
const avgPerInsight = monthCalls > 0 ? month / monthCalls : 0;
|
||||||
|
|
||||||
|
const todayPct = Math.min(100, (today / dailyHardCap) * 100);
|
||||||
|
const monthPct = Math.min(100, (month / monthlyCap) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelShell title="Insights · cost dashboard">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
DeepSeek LLM spend. Caps are configurable in budget_settings.{" "}
|
||||||
|
<a href="/insights" className="underline">Inbox</a> ·{" "}
|
||||||
|
<a href="/insights/pipeline" className="underline">Pipeline</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Today</CardDescription>
|
||||||
|
<CardTitle className="text-2xl">${today.toFixed(4)}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-xs text-muted-foreground">
|
||||||
|
{todayPct.toFixed(0)}% of daily hard cap ${dailyHardCap}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>This month</CardDescription>
|
||||||
|
<CardTitle className="text-2xl">${month.toFixed(2)}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-xs text-muted-foreground">
|
||||||
|
{monthPct.toFixed(0)}% of monthly cap ${monthlyCap}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Avg per call (month)</CardDescription>
|
||||||
|
<CardTitle className="text-2xl">${avgPerInsight.toFixed(4)}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-xs text-muted-foreground">
|
||||||
|
{monthCalls} calls month-to-date
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Cache hit (today)</CardDescription>
|
||||||
|
<CardTitle className="text-2xl">{(hitRate * 100).toFixed(0)}%</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-xs text-muted-foreground">
|
||||||
|
{todayHit} hit / {todayMiss} miss
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Daily (last 30 days)</h2>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Day</TableHead>
|
||||||
|
<TableHead className="w-[120px]">Calls</TableHead>
|
||||||
|
<TableHead className="w-[120px]">Cost</TableHead>
|
||||||
|
<TableHead>Bar</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{byDay.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center text-xs text-muted-foreground">
|
||||||
|
No cost ledger entries yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
byDay.map((row) => {
|
||||||
|
const c = Number(row.cost ?? 0);
|
||||||
|
const w = dailyHardCap > 0 ? Math.min(100, (c / dailyHardCap) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<TableRow key={row.day.toISOString()}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{row.day.toISOString().slice(0, 10)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{String(row.calls)}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">${c.toFixed(4)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="h-2 w-full rounded bg-muted">
|
||||||
|
<div className="h-full rounded bg-foreground/60" style={{ width: `${w}%` }} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-sm font-medium text-muted-foreground">By model (this month)</h2>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Model</TableHead>
|
||||||
|
<TableHead>Calls</TableHead>
|
||||||
|
<TableHead>Cost</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{byModel.map((m) => (
|
||||||
|
<TableRow key={m.model}>
|
||||||
|
<TableCell className="text-xs">{m.model}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{m._count}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">${Number(m._sum.costTotalUsd ?? 0).toFixed(4)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-sm font-medium text-muted-foreground">By prompt (this month)</h2>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Prompt</TableHead>
|
||||||
|
<TableHead>Calls</TableHead>
|
||||||
|
<TableHead>Cost</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{byPrompt.map((p) => (
|
||||||
|
<TableRow key={p.promptTag ?? "—"}>
|
||||||
|
<TableCell className="text-xs">{p.promptTag ?? "—"}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{p._count}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">${Number(p._sum.costTotalUsd ?? 0).toFixed(4)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Top expensive calls (this month)</h2>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Model</TableHead>
|
||||||
|
<TableHead>Prompt</TableHead>
|
||||||
|
<TableHead>In miss/hit</TableHead>
|
||||||
|
<TableHead>Out</TableHead>
|
||||||
|
<TableHead>Cost</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{recentExpensive.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{r.createdAt.toISOString().slice(0, 16).replace("T", " ")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">{r.model}</TableCell>
|
||||||
|
<TableCell className="text-xs">{r.promptTag ?? "—"}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{r.tokensInputCacheMiss}/{r.tokensInputCacheHit}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{r.tokensOutput}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">${r.costTotalUsd.toFixed(5)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentErrors.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Recent errors</h2>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Model</TableHead>
|
||||||
|
<TableHead>Prompt</TableHead>
|
||||||
|
<TableHead>Error</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{recentErrors.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{r.createdAt.toISOString().slice(0, 19).replace("T", " ")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">{r.model}</TableCell>
|
||||||
|
<TableCell className="text-xs">{r.promptTag ?? "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="destructive">{r.errorCode}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PanelShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
apps/web/src/app/insights/i/[id]/page.tsx
Normal file
169
apps/web/src/app/insights/i/[id]/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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 { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function InsightDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const insight = await prisma.insight.findUnique({ where: { id } });
|
||||||
|
if (!insight) notFound();
|
||||||
|
|
||||||
|
const ledger = await prisma.costLedger.findMany({
|
||||||
|
where: { sessionId: { in: insight.relatedSessionIds } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCost = ledger.reduce((s, r) => s + r.costTotalUsd, 0);
|
||||||
|
const body = insight.body as Record<string, unknown>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelShell title={insight.title}>
|
||||||
|
<a href="/insights" className="text-xs underline text-muted-foreground">
|
||||||
|
← Back to inbox
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant={insight.severity === "P0" || insight.severity === "P1" ? "destructive" : "default"}>
|
||||||
|
{insight.severity}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">{insight.type}</Badge>
|
||||||
|
<Badge variant="outline">{insight.status}</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">score {insight.priorityScore}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">conf {insight.confidence?.toFixed(2) ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs md:grid-cols-4">
|
||||||
|
<div>first_seen: {insight.firstSeenAt.toISOString().slice(0, 16).replace("T", " ")}</div>
|
||||||
|
<div>last_seen: {insight.lastSeenAt.toISOString().slice(0, 16).replace("T", " ")}</div>
|
||||||
|
<div>occurrences: {insight.occurrenceCount}</div>
|
||||||
|
<div>cost: ${insight.sourceCostUsd.toFixed(4)}</div>
|
||||||
|
<div className="col-span-2">model: {insight.sourceModel} · template: {insight.sourcePromptTag}@v{insight.sourcePromptVersion}</div>
|
||||||
|
<div className="col-span-2">fingerprint: <span className="font-mono">{insight.fingerprint}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-4 text-sm font-medium">AI Analysis</h2>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{renderBody(body)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="rounded-md border bg-muted/20 p-3 text-xs">
|
||||||
|
<summary className="cursor-pointer">Raw JSON</summary>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap font-mono text-xs">
|
||||||
|
{JSON.stringify(body, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<h2 className="mt-4 text-sm font-medium">Related sessions ({insight.relatedSessionIds.length})</h2>
|
||||||
|
<ul className="space-y-1 text-xs font-mono">
|
||||||
|
{insight.relatedSessionIds.slice(0, 20).map((sid) => (
|
||||||
|
<li key={sid}>
|
||||||
|
<a href={`/insights/sessions/${sid}`} className="underline">
|
||||||
|
{sid.slice(0, 16)}…
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 className="mt-4 text-sm font-medium">Cost detail (total ${totalCost.toFixed(4)})</h2>
|
||||||
|
<div className="rounded-md border text-xs">
|
||||||
|
<table className="w-full font-mono">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="p-2">when</th>
|
||||||
|
<th className="p-2">model</th>
|
||||||
|
<th className="p-2">in_miss</th>
|
||||||
|
<th className="p-2">in_hit</th>
|
||||||
|
<th className="p-2">out</th>
|
||||||
|
<th className="p-2">$</th>
|
||||||
|
<th className="p-2">err</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ledger.map((r) => (
|
||||||
|
<tr key={r.id} className="border-t">
|
||||||
|
<td className="p-2 text-muted-foreground">{r.createdAt.toISOString().slice(11, 19)}</td>
|
||||||
|
<td className="p-2">{r.model}</td>
|
||||||
|
<td className="p-2">{r.tokensInputCacheMiss}</td>
|
||||||
|
<td className="p-2">{r.tokensInputCacheHit}</td>
|
||||||
|
<td className="p-2">{r.tokensOutput}</td>
|
||||||
|
<td className="p-2">${r.costTotalUsd.toFixed(5)}</td>
|
||||||
|
<td className="p-2 text-amber-600">{r.errorCode ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</PanelShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody(body: Record<string, unknown>) {
|
||||||
|
// Render known fields in a readable way; fall back to JSON for unknown
|
||||||
|
const fields: Array<{ label: string; value: unknown; mono?: boolean }> = [];
|
||||||
|
const keys: Array<[string, string]> = [
|
||||||
|
["hypothesis", "Hypothesis"],
|
||||||
|
["affected_route", "Affected route"],
|
||||||
|
["affected_component_hypothesis", "Affected component"],
|
||||||
|
["error_signature", "Error signature"],
|
||||||
|
["implicated_provider", "Provider"],
|
||||||
|
["affected_provider", "Provider"],
|
||||||
|
["failure_mode", "Failure mode"],
|
||||||
|
["friction_type", "Friction type"],
|
||||||
|
["intent_hypothesis", "User intent"],
|
||||||
|
["friction_point", "Friction point"],
|
||||||
|
["quick_fix", "Quick fix"],
|
||||||
|
["long_term_fix", "Long-term fix"],
|
||||||
|
["block_point", "Block point"],
|
||||||
|
["unclear_concept", "Unclear concept"],
|
||||||
|
["documentation_gap", "Documentation gap"],
|
||||||
|
["suggested_in_app_help", "Suggested in-app help"],
|
||||||
|
["suggested_action", "Suggested action"],
|
||||||
|
["payment_method_hint", "Payment method hint"],
|
||||||
|
["business_impact_estimate", "Business impact"],
|
||||||
|
["user_impact_estimate", "User impact"],
|
||||||
|
["suggested_fix_effort", "Fix effort"],
|
||||||
|
];
|
||||||
|
for (const [k, label] of keys) {
|
||||||
|
if (body[k] !== undefined && body[k] !== null && body[k] !== "") {
|
||||||
|
fields.push({ label, value: body[k] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrays: Array<[string, string]> = [
|
||||||
|
["reproduce_steps", "Reproduce steps"],
|
||||||
|
["suggested_investigation", "Suggested investigation"],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<div key={f.label} className="grid grid-cols-[180px_1fr] gap-2">
|
||||||
|
<div className="text-muted-foreground">{f.label}</div>
|
||||||
|
<div className={f.mono ? "font-mono" : ""}>{String(f.value)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{arrays.map(([k, label]) => {
|
||||||
|
const arr = body[k];
|
||||||
|
if (!Array.isArray(arr) || !arr.length) return null;
|
||||||
|
return (
|
||||||
|
<div key={k}>
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
<ol className="ml-4 list-decimal text-sm">
|
||||||
|
{arr.map((s, i) => (
|
||||||
|
<li key={i}>{String(s)}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,97 +13,104 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function InsightsPage() {
|
function severityVariant(sev: string): "default" | "destructive" | "secondary" | "outline" {
|
||||||
const [byStatus, recent, watermark, compressed] = await Promise.all([
|
if (sev === "P0") return "destructive";
|
||||||
prisma.sessionMeta.groupBy({
|
if (sev === "P1") return "destructive";
|
||||||
by: ["status"],
|
if (sev === "P2") return "default";
|
||||||
_count: { status: true },
|
return "outline";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function InsightInboxPage() {
|
||||||
|
const [byStatus, byPromptTag, todayCost, monthCost, recent] = await Promise.all([
|
||||||
|
prisma.insight.groupBy({ by: ["status"], _count: { status: true } }),
|
||||||
|
prisma.insight.groupBy({ by: ["sourcePromptTag"], _count: { sourcePromptTag: true } }),
|
||||||
|
prisma.costLedger.aggregate({
|
||||||
|
where: { createdAt: { gte: startOfDayUtc(new Date()) } },
|
||||||
|
_sum: { costTotalUsd: true },
|
||||||
}),
|
}),
|
||||||
prisma.sessionMeta.findMany({
|
prisma.costLedger.aggregate({
|
||||||
where: { status: { in: ["tagged", "compressed"] } },
|
where: { createdAt: { gte: startOfMonthUtc(new Date()) } },
|
||||||
orderBy: { startedAt: "desc" },
|
_sum: { costTotalUsd: true },
|
||||||
take: 50,
|
}),
|
||||||
|
prisma.insight.findMany({
|
||||||
|
where: { status: { in: ["new", "triaged", "in_backlog", "regressed"] } },
|
||||||
|
orderBy: [{ founderPriority: "desc" }, { priorityScore: "desc" }, { lastSeenAt: "desc" }],
|
||||||
|
take: 80,
|
||||||
}),
|
}),
|
||||||
prisma.ingestionWatermark.findUnique({ where: { projectKey: "sase" } }),
|
|
||||||
prisma.compressedSession.count(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const row of byStatus) counts[row.status] = row._count.status;
|
for (const row of byStatus) counts[row.status] = row._count.status;
|
||||||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
||||||
|
const today = Number(todayCost._sum.costTotalUsd ?? 0);
|
||||||
|
const month = Number(monthCost._sum.costTotalUsd ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell title="Insights · Sase.tr (pilot)">
|
<PanelShell title="Insights · inbox">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Phase 6a — ingestion · heuristic filter · tagging · semantic compression. LLM stage gelmedi.
|
AI-generated insights from Sase.tr session pipeline. Sorted by priority.{" "}
|
||||||
|
<a href="/insights/pipeline" className="underline">View pipeline</a> ·{" "}
|
||||||
|
<a href="/insights/costs" className="underline">Cost dashboard</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||||||
<KPI label="Total sessions" value={total.toString()} />
|
<KPI label="New" value={String(counts.new ?? 0)} />
|
||||||
<KPI label="Pending" value={(counts.pending_signal ?? 0).toString()} />
|
<KPI label="In backlog" value={String(counts.in_backlog ?? 0)} />
|
||||||
<KPI label="Tagged" value={(counts.tagged ?? 0).toString()} />
|
<KPI label="Shipped" value={String(counts.shipped ?? 0)} />
|
||||||
<KPI label="Compressed" value={compressed.toString()} />
|
<KPI label="Today $" value={`$${today.toFixed(3)}`} />
|
||||||
<KPI label="Discarded" value={(counts.discarded ?? 0).toString()} />
|
<KPI label="Month $" value={`$${month.toFixed(2)}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border p-3 text-xs text-muted-foreground">
|
<h2 className="mt-2 text-sm font-medium text-muted-foreground">
|
||||||
Watermark:{" "}
|
Active insights ({recent.length})
|
||||||
<span className="font-mono">
|
</h2>
|
||||||
{watermark ? watermark.lastPolledAt.toISOString() : "never polled"}
|
|
||||||
</span>{" "}
|
|
||||||
· ingest cadence: 5 min
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Recent tagged sessions</h2>
|
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Session</TableHead>
|
<TableHead className="w-[60px]">Sev</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Title</TableHead>
|
||||||
<TableHead>Severity</TableHead>
|
<TableHead className="w-[100px]">Type</TableHead>
|
||||||
<TableHead>Score</TableHead>
|
<TableHead className="w-[80px]">Score</TableHead>
|
||||||
<TableHead>Tags</TableHead>
|
<TableHead className="w-[60px]">Occ</TableHead>
|
||||||
<TableHead>Started</TableHead>
|
<TableHead className="w-[100px]">Conf</TableHead>
|
||||||
<TableHead>Duration</TableHead>
|
<TableHead className="w-[120px]">Last seen</TableHead>
|
||||||
<TableHead>Events</TableHead>
|
<TableHead className="w-[100px]">Status</TableHead>
|
||||||
<TableHead>URL</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{recent.length === 0 ? (
|
{recent.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} className="text-center text-xs text-muted-foreground">
|
<TableCell colSpan={8} className="text-center text-xs text-muted-foreground">
|
||||||
No tagged sessions yet — pipeline may still be warming up.
|
No insights yet — LLM analysis runs every 4 minutes.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
recent.map((s) => (
|
recent.map((i) => (
|
||||||
<TableRow key={s.id}>
|
<TableRow key={i.id}>
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell>
|
||||||
<a className="underline" href={`/insights/sessions/${s.id}`}>
|
<Badge variant={severityVariant(i.severity)}>{i.severity}</Badge>
|
||||||
{s.id.slice(0, 12)}…
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<a href={`/insights/i/${i.id}`} className="font-medium underline">
|
||||||
|
{i.title}
|
||||||
</a>
|
</a>
|
||||||
|
<div className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
{i.fingerprint.slice(0, 16)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-xs">{i.type}</TableCell>
|
||||||
<Badge variant={s.status === "compressed" ? "default" : "outline"}>
|
<TableCell className="font-mono text-xs">{i.priorityScore}</TableCell>
|
||||||
{s.status}
|
<TableCell className="font-mono text-xs">{i.occurrenceCount}</TableCell>
|
||||||
</Badge>
|
<TableCell className="font-mono text-xs">
|
||||||
</TableCell>
|
{i.confidence?.toFixed(2) ?? "—"}
|
||||||
<TableCell>
|
|
||||||
{s.severity ? <Badge variant="outline">{s.severity}</Badge> : "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">{s.score ?? "—"}</TableCell>
|
|
||||||
<TableCell className="text-xs">
|
|
||||||
{s.tags.length ? s.tags.join(", ") : "—"}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{s.startedAt.toISOString().slice(0, 19).replace("T", " ")}
|
{i.lastSeenAt.toISOString().slice(0, 16).replace("T", " ")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">{Math.round(s.durationMs / 1000)}s</TableCell>
|
<TableCell>
|
||||||
<TableCell className="text-xs font-mono">{s.customEventCount || "—"}</TableCell>
|
<Badge variant="outline">{i.status}</Badge>
|
||||||
<TableCell className="max-w-[200px] truncate font-mono text-xs">
|
|
||||||
{s.startUrl ?? "—"}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@@ -111,6 +118,15 @@ export default async function InsightsPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{byPromptTag.length > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
by template:{" "}
|
||||||
|
{byPromptTag
|
||||||
|
.map((b) => `${b.sourcePromptTag}=${b._count.sourcePromptTag}`)
|
||||||
|
.join(" · ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,3 +142,10 @@ function KPI({ label, value }: { label: string; value: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startOfDayUtc(d: Date): Date {
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||||
|
}
|
||||||
|
function startOfMonthUtc(d: Date): Date {
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
||||||
|
}
|
||||||
|
|||||||
128
apps/web/src/app/insights/pipeline/page.tsx
Normal file
128
apps/web/src/app/insights/pipeline/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function InsightsPage() {
|
||||||
|
const [byStatus, recent, watermark, compressed] = await Promise.all([
|
||||||
|
prisma.sessionMeta.groupBy({
|
||||||
|
by: ["status"],
|
||||||
|
_count: { status: true },
|
||||||
|
}),
|
||||||
|
prisma.sessionMeta.findMany({
|
||||||
|
where: { status: { in: ["tagged", "compressed"] } },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
}),
|
||||||
|
prisma.ingestionWatermark.findUnique({ where: { projectKey: "sase" } }),
|
||||||
|
prisma.compressedSession.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const row of byStatus) counts[row.status] = row._count.status;
|
||||||
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelShell title="Insights · Sase.tr (pilot)">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Phase 6a — ingestion · heuristic filter · tagging · semantic compression. LLM stage gelmedi.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||||||
|
<KPI label="Total sessions" value={total.toString()} />
|
||||||
|
<KPI label="Pending" value={(counts.pending_signal ?? 0).toString()} />
|
||||||
|
<KPI label="Tagged" value={(counts.tagged ?? 0).toString()} />
|
||||||
|
<KPI label="Compressed" value={compressed.toString()} />
|
||||||
|
<KPI label="Discarded" value={(counts.discarded ?? 0).toString()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border p-3 text-xs text-muted-foreground">
|
||||||
|
Watermark:{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{watermark ? watermark.lastPolledAt.toISOString() : "never polled"}
|
||||||
|
</span>{" "}
|
||||||
|
· ingest cadence: 5 min
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Recent tagged sessions</h2>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Session</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Severity</TableHead>
|
||||||
|
<TableHead>Score</TableHead>
|
||||||
|
<TableHead>Tags</TableHead>
|
||||||
|
<TableHead>Started</TableHead>
|
||||||
|
<TableHead>Duration</TableHead>
|
||||||
|
<TableHead>Events</TableHead>
|
||||||
|
<TableHead>URL</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="text-center text-xs text-muted-foreground">
|
||||||
|
No tagged sessions yet — pipeline may still be warming up.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
recent.map((s) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
<a className="underline" href={`/insights/sessions/${s.id}`}>
|
||||||
|
{s.id.slice(0, 12)}…
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={s.status === "compressed" ? "default" : "outline"}>
|
||||||
|
{s.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{s.severity ? <Badge variant="outline">{s.severity}</Badge> : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{s.score ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{s.tags.length ? s.tags.join(", ") : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{s.startedAt.toISOString().slice(0, 19).replace("T", " ")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">{Math.round(s.durationMs / 1000)}s</TableCell>
|
||||||
|
<TableCell className="text-xs font-mono">{s.customEventCount || "—"}</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate font-mono text-xs">
|
||||||
|
{s.startUrl ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</PanelShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KPI({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>{label}</CardDescription>
|
||||||
|
<CardTitle className="text-2xl">{value}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-1" />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
|||||||
import { startEventBus } from "./consumers/event-bus";
|
import { startEventBus } from "./consumers/event-bus";
|
||||||
import { startScheduledJobs } from "./schedulers/nightly";
|
import { startScheduledJobs } from "./schedulers/nightly";
|
||||||
import { startInsightPipeline } from "./schedulers/pipeline";
|
import { startInsightPipeline } from "./schedulers/pipeline";
|
||||||
|
import { upsertSeedData } from "./lib/seed-runtime";
|
||||||
import { redis } from "./redis";
|
import { redis } from "./redis";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ async function main() {
|
|||||||
await prisma.$queryRaw`SELECT 1`;
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
console.log("[worker] panel-db ok");
|
console.log("[worker] panel-db ok");
|
||||||
|
|
||||||
|
await upsertSeedData().catch((e) => console.warn("[seed] failed:", e.message));
|
||||||
|
|
||||||
await startScheduledJobs();
|
await startScheduledJobs();
|
||||||
await startInsightPipeline();
|
await startInsightPipeline();
|
||||||
await startEventBus();
|
await startEventBus();
|
||||||
|
|||||||
273
apps/worker/src/jobs/analyze.ts
Normal file
273
apps/worker/src/jobs/analyze.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { prisma } from "../db";
|
||||||
|
import { callDeepSeek, extractJson, type Tier, DeepSeekError } from "../lib/deepseek";
|
||||||
|
import { checkBudget } from "../lib/budget";
|
||||||
|
import { pickPromptTag } from "../lib/prompts";
|
||||||
|
import { validate } from "../lib/json-validate";
|
||||||
|
import { getText } from "../lib/minio";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
export type AnalyzeResult = {
|
||||||
|
analyzed: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
costUsd: number;
|
||||||
|
budgetState: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||||
|
const budget = await checkBudget();
|
||||||
|
if (!budget.allow) {
|
||||||
|
console.log(`[analyze] budget=${budget.state} ${budget.reason}`);
|
||||||
|
return { analyzed: 0, skipped: 0, failed: 0, costUsd: 0, budgetState: budget.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressed = await prisma.sessionMeta.findMany({
|
||||||
|
where: { status: "compressed", projectKey: PROJECT_KEY },
|
||||||
|
include: { compressed: true },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
take: ANALYZE_BATCH,
|
||||||
|
});
|
||||||
|
if (compressed.length === 0) {
|
||||||
|
return { analyzed: 0, skipped: 0, failed: 0, costUsd: 0, budgetState: budget.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
let analyzed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let costUsd = 0;
|
||||||
|
|
||||||
|
for (const s of compressed) {
|
||||||
|
if (!s.compressed || !s.fingerprint) {
|
||||||
|
skipped++;
|
||||||
|
await prisma.sessionMeta.update({ where: { id: s.id }, data: { status: "discarded" } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application-level fingerprint cache: if an active insight with same fingerprint
|
||||||
|
// exists and is younger than CACHE_TTL_HOURS, just attach this session to it.
|
||||||
|
const cutoff = new Date(Date.now() - CACHE_TTL_HOURS * 3600_000);
|
||||||
|
const existing = await prisma.insight.findUnique({
|
||||||
|
where: { projectKey_fingerprint: { projectKey: PROJECT_KEY, fingerprint: s.fingerprint } },
|
||||||
|
});
|
||||||
|
if (existing && existing.updatedAt > cutoff && !["dismissed", "validated"].includes(existing.status)) {
|
||||||
|
// Aggregate this session into the existing insight
|
||||||
|
const rel = Array.from(new Set([...existing.relatedSessionIds, s.id]));
|
||||||
|
await prisma.insight.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
relatedSessionIds: rel,
|
||||||
|
occurrenceCount: rel.length,
|
||||||
|
lastSeenAt: s.startedAt > existing.lastSeenAt ? s.startedAt : existing.lastSeenAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.sessionMeta.update({
|
||||||
|
where: { id: s.id },
|
||||||
|
data: { status: "analyzed", processedAt: new Date() },
|
||||||
|
});
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptTag = pickPromptTag(s.tags);
|
||||||
|
const template = await prisma.promptTemplate.findFirst({
|
||||||
|
where: { tag: promptTag, active: true },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
});
|
||||||
|
if (!template) {
|
||||||
|
console.warn(`[analyze] no prompt for tag=${promptTag}`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier selection: severity-based override of template default
|
||||||
|
let tier: Tier = template.modelTier as Tier;
|
||||||
|
if (s.severity === "P0" || s.severity === "P1") tier = "pro";
|
||||||
|
else if (s.severity === "P2" || s.severity === "P3" || s.severity === "INFO") tier = "flash";
|
||||||
|
if (budget.forceTier) tier = budget.forceTier;
|
||||||
|
|
||||||
|
// Fetch timeline from MinIO
|
||||||
|
let timeline: string;
|
||||||
|
try {
|
||||||
|
timeline = await getText(COMPRESSION_BUCKET, s.compressed.semanticTimelineMinioKey);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[analyze] timeline fetch failed ${s.id}: ${(e as Error).message}`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPrompt = template.userPromptTemplate.replace("{{timeline}}", timeline);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await callDeepSeek({
|
||||||
|
tier,
|
||||||
|
systemPrompt: template.systemPrompt,
|
||||||
|
userPrompt,
|
||||||
|
maxOutputTokens: template.maxOutputTokens,
|
||||||
|
temperature: template.temperature,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const status = e instanceof DeepSeekError ? e.status : 0;
|
||||||
|
const msg = (e as Error).message;
|
||||||
|
console.warn(`[analyze] deepseek error ${s.id}: ${status} ${msg}`);
|
||||||
|
await prisma.costLedger.create({
|
||||||
|
data: {
|
||||||
|
sessionId: s.id,
|
||||||
|
projectKey: PROJECT_KEY,
|
||||||
|
promptTag,
|
||||||
|
promptVersion: template.version,
|
||||||
|
provider: "deepseek",
|
||||||
|
model: tier === "pro" ? "deepseek-v4-pro" : "deepseek-v4-flash",
|
||||||
|
tier,
|
||||||
|
tokensInputCacheMiss: 0,
|
||||||
|
tokensInputCacheHit: 0,
|
||||||
|
tokensOutput: 0,
|
||||||
|
costInputCacheMissUsd: 0,
|
||||||
|
costInputCacheHitUsd: 0,
|
||||||
|
costOutputUsd: 0,
|
||||||
|
costTotalUsd: 0,
|
||||||
|
cacheHitRatio: 0,
|
||||||
|
errorCode: `${status}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-call cost cap
|
||||||
|
if (result.cost.totalUsd > budget.limits.perCallMax) {
|
||||||
|
console.warn(`[analyze] over per-call cap ${result.cost.totalUsd} for ${s.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse + validate JSON
|
||||||
|
let parsed: any;
|
||||||
|
let validationErrors = "";
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(extractJson(result.text));
|
||||||
|
const errs = validate(parsed, template.outputSchemaJson as any);
|
||||||
|
if (errs.length) validationErrors = errs.map((e) => `${e.path}: ${e.message}`).join("; ");
|
||||||
|
} catch (e) {
|
||||||
|
validationErrors = `json parse: ${(e as Error).message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
costUsd += result.cost.totalUsd;
|
||||||
|
await prisma.costLedger.create({
|
||||||
|
data: {
|
||||||
|
sessionId: s.id,
|
||||||
|
projectKey: PROJECT_KEY,
|
||||||
|
promptTag,
|
||||||
|
promptVersion: template.version,
|
||||||
|
provider: "deepseek",
|
||||||
|
model: result.model,
|
||||||
|
tier,
|
||||||
|
tokensInputCacheMiss: result.usage.inputTokensMiss,
|
||||||
|
tokensInputCacheHit: result.usage.inputTokensHit,
|
||||||
|
tokensOutput: result.usage.outputTokens,
|
||||||
|
costInputCacheMissUsd: result.cost.inputMissUsd,
|
||||||
|
costInputCacheHitUsd: result.cost.inputHitUsd,
|
||||||
|
costOutputUsd: result.cost.outputUsd,
|
||||||
|
costTotalUsd: result.cost.totalUsd,
|
||||||
|
cacheHitRatio: result.cost.cacheHitRatio,
|
||||||
|
callDurationMs: result.durationMs,
|
||||||
|
errorCode: validationErrors ? "validation_failed" : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationErrors) {
|
||||||
|
console.warn(`[analyze] validation failed ${s.id}: ${validationErrors.slice(0, 200)}`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute severity from parsed (LLM may override) but fall back to session severity
|
||||||
|
const sev = String(parsed.severity ?? s.severity ?? "P3");
|
||||||
|
const conf = typeof parsed.confidence === "number" ? parsed.confidence : 0.5;
|
||||||
|
const title = String(parsed.title ?? s.tags.join(", ") ?? "Insight");
|
||||||
|
const type = String(parsed.type ?? promptTag);
|
||||||
|
|
||||||
|
// Priority score (10.3 in PRD)
|
||||||
|
const priorityScore = computePriorityScore({
|
||||||
|
severity: sev,
|
||||||
|
occurrenceCount: existing ? existing.occurrenceCount + 1 : 1,
|
||||||
|
ageHours: 0,
|
||||||
|
confidence: conf,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const rel = Array.from(new Set([...existing.relatedSessionIds, s.id]));
|
||||||
|
// Existing insight in dismissed/validated state → mark new occurrence as potential regression
|
||||||
|
const isRegression = ["validated", "shipped"].includes(existing.status);
|
||||||
|
await prisma.insight.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
body: parsed,
|
||||||
|
title,
|
||||||
|
severity: sev,
|
||||||
|
status: isRegression ? "regressed" : existing.status,
|
||||||
|
relatedSessionIds: rel,
|
||||||
|
occurrenceCount: rel.length,
|
||||||
|
lastSeenAt: s.startedAt > existing.lastSeenAt ? s.startedAt : existing.lastSeenAt,
|
||||||
|
confidence: conf,
|
||||||
|
priorityScore,
|
||||||
|
sourceModel: result.model,
|
||||||
|
sourceCostUsd: existing.sourceCostUsd + result.cost.totalUsd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.insight.create({
|
||||||
|
data: {
|
||||||
|
projectKey: PROJECT_KEY,
|
||||||
|
type,
|
||||||
|
severity: sev,
|
||||||
|
status: "new",
|
||||||
|
fingerprint: s.fingerprint,
|
||||||
|
title,
|
||||||
|
body: parsed,
|
||||||
|
relatedSessionIds: [s.id],
|
||||||
|
occurrenceCount: 1,
|
||||||
|
uniqueUserCount: 1,
|
||||||
|
firstSeenAt: s.startedAt,
|
||||||
|
lastSeenAt: s.startedAt,
|
||||||
|
confidence: conf,
|
||||||
|
priorityScore,
|
||||||
|
sourcePromptTag: promptTag,
|
||||||
|
sourcePromptVersion: template.version,
|
||||||
|
sourceModel: result.model,
|
||||||
|
sourceCostUsd: result.cost.totalUsd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.sessionMeta.update({
|
||||||
|
where: { id: s.id },
|
||||||
|
data: { status: "analyzed", processedAt: new Date() },
|
||||||
|
});
|
||||||
|
analyzed++;
|
||||||
|
|
||||||
|
// Stop if budget became hard-capped mid-batch
|
||||||
|
const recheck = await checkBudget();
|
||||||
|
if (!recheck.allow) {
|
||||||
|
console.log(`[analyze] budget exhausted mid-batch (${recheck.state})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { analyzed, skipped, failed, costUsd, budgetState: budget.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePriorityScore(input: {
|
||||||
|
severity: string;
|
||||||
|
occurrenceCount: number;
|
||||||
|
ageHours: number;
|
||||||
|
confidence: number;
|
||||||
|
}): number {
|
||||||
|
const severityWeight: Record<string, number> = { P0: 1, P1: 0.8, P2: 0.5, P3: 0.3, INFO: 0.1 };
|
||||||
|
const sw = severityWeight[input.severity] ?? 0.3;
|
||||||
|
const occ = Math.min(1.0, Math.log10(input.occurrenceCount + 1) / 2);
|
||||||
|
const recency = input.ageHours < 1 ? 1.0 : input.ageHours < 24 ? 0.7 : 0.4;
|
||||||
|
return Math.round(sw * 30 + occ * 25 + recency * 15 + input.confidence * 10);
|
||||||
|
}
|
||||||
120
apps/worker/src/lib/budget.ts
Normal file
120
apps/worker/src/lib/budget.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { prisma } from "../db";
|
||||||
|
|
||||||
|
export type BudgetState = "active" | "soft_throttled" | "hard_paused" | "monthly_paused";
|
||||||
|
|
||||||
|
export type BudgetDecision = {
|
||||||
|
allow: boolean;
|
||||||
|
state: BudgetState;
|
||||||
|
reason: string;
|
||||||
|
forceTier?: "flash"; // when soft cap hit, force pro→flash downgrade
|
||||||
|
todayUsd: number;
|
||||||
|
monthUsd: number;
|
||||||
|
limits: {
|
||||||
|
monthlyHardCap: number;
|
||||||
|
dailySoftCap: number;
|
||||||
|
dailyHardCap: number;
|
||||||
|
perCallMax: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getNumber(key: string, fallback: number): Promise<number> {
|
||||||
|
const row = await prisma.budgetSetting.findFirst({
|
||||||
|
where: { projectKey: null, settingKey: key },
|
||||||
|
});
|
||||||
|
const v = row?.settingValue;
|
||||||
|
return typeof v === "number" ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBool(key: string, fallback: boolean): Promise<boolean> {
|
||||||
|
const row = await prisma.budgetSetting.findFirst({
|
||||||
|
where: { projectKey: null, settingKey: key },
|
||||||
|
});
|
||||||
|
const v = row?.settingValue;
|
||||||
|
return typeof v === "boolean" ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDayUtc(d: Date): Date {
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||||
|
}
|
||||||
|
function startOfMonthUtc(d: Date): Date {
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkBudget(): Promise<BudgetDecision> {
|
||||||
|
const monthlyHardCap = await getNumber("monthly_hard_cap_usd", 30);
|
||||||
|
const dailySoftCap = await getNumber("daily_soft_cap_usd", 1.5);
|
||||||
|
const dailyHardCap = await getNumber("daily_hard_cap_usd", 3);
|
||||||
|
const perCallMax = await getNumber("per_call_max_usd", 0.2);
|
||||||
|
const paused = await getBool("analysis_paused", false);
|
||||||
|
|
||||||
|
const limits = { monthlyHardCap, dailySoftCap, dailyHardCap, perCallMax };
|
||||||
|
|
||||||
|
if (paused) {
|
||||||
|
return {
|
||||||
|
allow: false,
|
||||||
|
state: "hard_paused",
|
||||||
|
reason: "analysis_paused setting is true",
|
||||||
|
todayUsd: 0,
|
||||||
|
monthUsd: 0,
|
||||||
|
limits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = startOfDayUtc(now);
|
||||||
|
const monthStart = startOfMonthUtc(now);
|
||||||
|
|
||||||
|
const [todayAgg, monthAgg] = await Promise.all([
|
||||||
|
prisma.costLedger.aggregate({
|
||||||
|
where: { createdAt: { gte: todayStart } },
|
||||||
|
_sum: { costTotalUsd: true },
|
||||||
|
}),
|
||||||
|
prisma.costLedger.aggregate({
|
||||||
|
where: { createdAt: { gte: monthStart } },
|
||||||
|
_sum: { costTotalUsd: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const todayUsd = Number(todayAgg._sum.costTotalUsd ?? 0);
|
||||||
|
const monthUsd = Number(monthAgg._sum.costTotalUsd ?? 0);
|
||||||
|
|
||||||
|
if (monthUsd >= monthlyHardCap) {
|
||||||
|
return {
|
||||||
|
allow: false,
|
||||||
|
state: "monthly_paused",
|
||||||
|
reason: `month spend $${monthUsd.toFixed(4)} >= monthly cap $${monthlyHardCap}`,
|
||||||
|
todayUsd,
|
||||||
|
monthUsd,
|
||||||
|
limits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (todayUsd >= dailyHardCap) {
|
||||||
|
return {
|
||||||
|
allow: false,
|
||||||
|
state: "hard_paused",
|
||||||
|
reason: `today spend $${todayUsd.toFixed(4)} >= daily hard cap $${dailyHardCap}`,
|
||||||
|
todayUsd,
|
||||||
|
monthUsd,
|
||||||
|
limits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (todayUsd >= dailySoftCap) {
|
||||||
|
return {
|
||||||
|
allow: true,
|
||||||
|
state: "soft_throttled",
|
||||||
|
reason: `today spend $${todayUsd.toFixed(4)} >= daily soft cap $${dailySoftCap}, force flash tier`,
|
||||||
|
forceTier: "flash",
|
||||||
|
todayUsd,
|
||||||
|
monthUsd,
|
||||||
|
limits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allow: true,
|
||||||
|
state: "active",
|
||||||
|
reason: "ok",
|
||||||
|
todayUsd,
|
||||||
|
monthUsd,
|
||||||
|
limits,
|
||||||
|
};
|
||||||
|
}
|
||||||
138
apps/worker/src/lib/deepseek.ts
Normal file
138
apps/worker/src/lib/deepseek.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// DeepSeek client using Anthropic-compatible endpoint.
|
||||||
|
// https://api.deepseek.com/anthropic/v1/messages
|
||||||
|
|
||||||
|
const BASE = process.env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com/anthropic";
|
||||||
|
const KEY = process.env.DEEPSEEK_API_KEY ?? "";
|
||||||
|
|
||||||
|
export type Tier = "flash" | "pro";
|
||||||
|
|
||||||
|
// Pricing per million tokens (USD). V4 Pro is 75% off until 2026-05-31.
|
||||||
|
// Override via DEEPSEEK_PRICING env (JSON) if rates change.
|
||||||
|
const DEFAULT_PRICING: Record<Tier, { in_miss: number; in_hit: number; out: number }> = {
|
||||||
|
flash: { in_miss: 0.14, in_hit: 0.0028, out: 0.28 },
|
||||||
|
pro: { in_miss: 0.435, in_hit: 0.003625, out: 0.87 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function pricing(): typeof DEFAULT_PRICING {
|
||||||
|
const raw = process.env.DEEPSEEK_PRICING;
|
||||||
|
if (!raw) return DEFAULT_PRICING;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_PRICING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelForTier(tier: Tier): string {
|
||||||
|
if (tier === "pro") return process.env.DEEPSEEK_MODEL_PRO ?? "deepseek-v4-pro";
|
||||||
|
return process.env.DEEPSEEK_MODEL_FLASH ?? "deepseek-v4-flash";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CallResult = {
|
||||||
|
text: string;
|
||||||
|
usage: {
|
||||||
|
inputTokensMiss: number;
|
||||||
|
inputTokensHit: number;
|
||||||
|
outputTokens: number;
|
||||||
|
};
|
||||||
|
cost: {
|
||||||
|
inputMissUsd: number;
|
||||||
|
inputHitUsd: number;
|
||||||
|
outputUsd: number;
|
||||||
|
totalUsd: number;
|
||||||
|
cacheHitRatio: number;
|
||||||
|
};
|
||||||
|
model: string;
|
||||||
|
durationMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DeepSeekError extends Error {
|
||||||
|
constructor(public status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callDeepSeek(opts: {
|
||||||
|
tier: Tier;
|
||||||
|
systemPrompt: string;
|
||||||
|
userPrompt: string;
|
||||||
|
maxOutputTokens: number;
|
||||||
|
temperature?: number;
|
||||||
|
// When true, mark the system prompt for caching (DeepSeek auto-caches stable prefixes).
|
||||||
|
}): Promise<CallResult> {
|
||||||
|
if (!KEY) throw new DeepSeekError(0, "DEEPSEEK_API_KEY not set");
|
||||||
|
|
||||||
|
const model = modelForTier(opts.tier);
|
||||||
|
const url = `${BASE}/v1/messages`;
|
||||||
|
const body = {
|
||||||
|
model,
|
||||||
|
max_tokens: opts.maxOutputTokens,
|
||||||
|
temperature: opts.temperature ?? 0.3,
|
||||||
|
system: opts.systemPrompt,
|
||||||
|
messages: [{ role: "user" as const, content: opts.userPrompt }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"x-api-key": KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new DeepSeekError(0, `network: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) throw new DeepSeekError(res.status, `${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new DeepSeekError(res.status, `non-json: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const content = parsed?.content?.[0]?.text;
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
throw new DeepSeekError(res.status, `no content text: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = parsed.usage ?? {};
|
||||||
|
const inputTokensMiss = Number(usage.input_tokens ?? 0) - Number(usage.cache_read_input_tokens ?? 0);
|
||||||
|
const inputTokensHit = Number(usage.cache_read_input_tokens ?? 0);
|
||||||
|
const outputTokens = Number(usage.output_tokens ?? 0);
|
||||||
|
|
||||||
|
const p = pricing()[opts.tier];
|
||||||
|
const inputMissUsd = (inputTokensMiss * p.in_miss) / 1_000_000;
|
||||||
|
const inputHitUsd = (inputTokensHit * p.in_hit) / 1_000_000;
|
||||||
|
const outputUsd = (outputTokens * p.out) / 1_000_000;
|
||||||
|
const totalUsd = inputMissUsd + inputHitUsd + outputUsd;
|
||||||
|
const totalIn = Math.max(1, inputTokensMiss + inputTokensHit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: content,
|
||||||
|
usage: { inputTokensMiss, inputTokensHit, outputTokens },
|
||||||
|
cost: {
|
||||||
|
inputMissUsd,
|
||||||
|
inputHitUsd,
|
||||||
|
outputUsd,
|
||||||
|
totalUsd,
|
||||||
|
cacheHitRatio: inputTokensHit / totalIn,
|
||||||
|
},
|
||||||
|
model,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip code fences if the model wraps JSON in ```json ... ```
|
||||||
|
export function extractJson(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const fence = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/);
|
||||||
|
if (fence) return fence[1].trim();
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
73
apps/worker/src/lib/json-validate.ts
Normal file
73
apps/worker/src/lib/json-validate.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Lightweight JSON schema validator (subset used in prompt schemas).
|
||||||
|
// Avoids pulling ajv into the worker bundle.
|
||||||
|
|
||||||
|
type Schema = {
|
||||||
|
type?: string;
|
||||||
|
const?: unknown;
|
||||||
|
enum?: unknown[];
|
||||||
|
required?: string[];
|
||||||
|
properties?: Record<string, Schema>;
|
||||||
|
items?: Schema;
|
||||||
|
minItems?: number;
|
||||||
|
maxItems?: number;
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationError = { path: string; message: string };
|
||||||
|
|
||||||
|
export function validate(value: unknown, schema: Schema, path = "$"): ValidationError[] {
|
||||||
|
const errs: ValidationError[] = [];
|
||||||
|
|
||||||
|
if (schema.const !== undefined) {
|
||||||
|
if (value !== schema.const) errs.push({ path, message: `expected const ${JSON.stringify(schema.const)}` });
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
if (schema.enum) {
|
||||||
|
if (!schema.enum.includes(value as any)) {
|
||||||
|
errs.push({ path, message: `not in enum ${JSON.stringify(schema.enum)}` });
|
||||||
|
}
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
if (schema.type) {
|
||||||
|
const t = schema.type;
|
||||||
|
const actual =
|
||||||
|
value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
||||||
|
if (t === "integer") {
|
||||||
|
if (actual !== "number" || !Number.isInteger(value)) errs.push({ path, message: "not integer" });
|
||||||
|
} else if (actual !== t) {
|
||||||
|
errs.push({ path, message: `expected ${t}, got ${actual}` });
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && schema.maxLength && value.length > schema.maxLength) {
|
||||||
|
errs.push({ path, message: `string > maxLength ${schema.maxLength}` });
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (schema.minimum !== undefined && value < schema.minimum) errs.push({ path, message: `< minimum` });
|
||||||
|
if (schema.maximum !== undefined && value > schema.maximum) errs.push({ path, message: `> maximum` });
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (schema.minItems !== undefined && value.length < schema.minItems)
|
||||||
|
errs.push({ path, message: `array < minItems ${schema.minItems}` });
|
||||||
|
if (schema.maxItems !== undefined && value.length > schema.maxItems)
|
||||||
|
errs.push({ path, message: `array > maxItems ${schema.maxItems}` });
|
||||||
|
if (schema.items) {
|
||||||
|
value.forEach((v, i) => errs.push(...validate(v, schema.items!, `${path}[${i}]`)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (schema.properties && value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
for (const [k, sub] of Object.entries(schema.properties)) {
|
||||||
|
if (k in obj) errs.push(...validate(obj[k], sub, `${path}.${k}`));
|
||||||
|
}
|
||||||
|
if (schema.required) {
|
||||||
|
for (const r of schema.required) {
|
||||||
|
if (!(r in obj)) errs.push({ path: `${path}.${r}`, message: "required" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
@@ -40,3 +40,15 @@ export async function putText(
|
|||||||
const buf = Buffer.from(body, "utf-8");
|
const buf = Buffer.from(body, "utf-8");
|
||||||
await c.putObject(bucket, key, buf, buf.length, { "Content-Type": contentType });
|
await c.putObject(bucket, key, buf, buf.length, { "Content-Type": contentType });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getText(bucket: string, key: string): Promise<string> {
|
||||||
|
const c = getMinio();
|
||||||
|
if (!c) throw new Error("minio_not_configured");
|
||||||
|
const stream = await c.getObject(bucket, key);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
return await new Promise<string>((resolve, reject) => {
|
||||||
|
stream.on("data", (d) => chunks.push(d as Buffer));
|
||||||
|
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||||
|
stream.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
253
apps/worker/src/lib/prompts.ts
Normal file
253
apps/worker/src/lib/prompts.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
// Prompt templates with embedded JSON schemas (zod-like, validated by ajv-style logic).
|
||||||
|
// Loaded from DB at runtime; this module also provides the seed catalog.
|
||||||
|
|
||||||
|
export type PromptTier = "flash" | "pro";
|
||||||
|
|
||||||
|
export type PromptTemplate = {
|
||||||
|
tag: string;
|
||||||
|
version: number;
|
||||||
|
name: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
userPromptTemplate: string;
|
||||||
|
outputSchemaJson: object;
|
||||||
|
modelTier: PromptTier;
|
||||||
|
maxOutputTokens: number;
|
||||||
|
temperature: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SASE_CONTEXT = `Sase.tr context:
|
||||||
|
- B2B SaaS — VIN lookup + parts compatibility for Turkish auto-parts dealers and service shops.
|
||||||
|
- Stack: NestJS backend, Next.js frontend, PostgreSQL/Prisma.
|
||||||
|
- 4 upstream providers: PL24 (Partslink24), PCAT, RMEX, TecDoc. Each can timeout/fail independently.
|
||||||
|
- Auth: JWT, sticky session routing.
|
||||||
|
- Subscription tiers: starter, brand_specific, full. Trial flow exists.
|
||||||
|
- Common URL paths: /vin-lookup, /dashboard/search, /dashboard/catalog/<brand>/<vehicleId>, /api-keys, /subscription, /upgrade, /pricing.
|
||||||
|
|
||||||
|
Output rules:
|
||||||
|
- Return ONLY valid JSON matching the schema. No markdown, no code fences, no preamble.
|
||||||
|
- If you are uncertain, lower the confidence; do not invent specifics.
|
||||||
|
- All string fields are concise (titles ≤120 chars, hypothesis ≤500 chars).`;
|
||||||
|
|
||||||
|
const BUG_TRIAGE_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
required: ["type", "severity", "title", "reproduce_steps", "hypothesis", "confidence"],
|
||||||
|
properties: {
|
||||||
|
type: { const: "bug" },
|
||||||
|
severity: { enum: ["P0", "P1", "P2", "P3"] },
|
||||||
|
title: { type: "string", maxLength: 120 },
|
||||||
|
reproduce_steps: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 10 },
|
||||||
|
affected_route: { type: "string" },
|
||||||
|
affected_component_hypothesis: { type: "string" },
|
||||||
|
error_signature: { type: "string" },
|
||||||
|
hypothesis: { type: "string", maxLength: 500 },
|
||||||
|
suggested_investigation: { type: "array", items: { type: "string" } },
|
||||||
|
suggested_fix_effort: { enum: ["S", "M", "L"] },
|
||||||
|
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||||
|
user_impact_estimate: { type: "string" },
|
||||||
|
is_likely_provider_issue: { type: "boolean" },
|
||||||
|
implicated_provider: { enum: ["PL24", "PCAT", "RMEX", "TecDoc", null] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const UX_FRICTION_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
required: ["type", "severity", "title", "intent_hypothesis", "friction_point", "quick_fix", "confidence"],
|
||||||
|
properties: {
|
||||||
|
type: { const: "ux_friction" },
|
||||||
|
severity: { enum: ["P1", "P2", "P3"] },
|
||||||
|
title: { type: "string", maxLength: 120 },
|
||||||
|
intent_hypothesis: { type: "string", maxLength: 300 },
|
||||||
|
friction_point: { type: "string", maxLength: 300 },
|
||||||
|
friction_type: { enum: ["copy", "layout", "affordance", "performance", "terminology", "discoverability"] },
|
||||||
|
quick_fix: { type: "string", maxLength: 300 },
|
||||||
|
long_term_fix: { type: "string", maxLength: 500 },
|
||||||
|
affected_user_segment: { type: "string" },
|
||||||
|
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYMENT_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
required: ["type", "severity", "title", "failure_mode", "hypothesis", "confidence"],
|
||||||
|
properties: {
|
||||||
|
type: { const: "payment" },
|
||||||
|
severity: { enum: ["P0", "P1", "P2"] },
|
||||||
|
title: { type: "string", maxLength: 120 },
|
||||||
|
failure_mode: { enum: ["ui_silent", "backend_decline", "user_abandoned", "checkout_friction", "validation_failed", "trial_to_paid_lost"] },
|
||||||
|
hypothesis: { type: "string", maxLength: 500 },
|
||||||
|
payment_method_hint: { type: "string" },
|
||||||
|
suggested_investigation: { type: "array", items: { type: "string" } },
|
||||||
|
business_impact_estimate: { type: "string" },
|
||||||
|
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ONBOARDING_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
required: ["type", "severity", "title", "block_point", "hypothesis", "confidence"],
|
||||||
|
properties: {
|
||||||
|
type: { const: "onboarding" },
|
||||||
|
severity: { enum: ["P1", "P2", "P3"] },
|
||||||
|
title: { type: "string", maxLength: 120 },
|
||||||
|
block_point: { type: "string", maxLength: 200 },
|
||||||
|
unclear_concept: { type: "string" },
|
||||||
|
documentation_gap: { type: "string" },
|
||||||
|
suggested_in_app_help: { type: "string" },
|
||||||
|
hypothesis: { type: "string", maxLength: 500 },
|
||||||
|
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
required: ["type", "severity", "title", "affected_provider", "hypothesis", "confidence"],
|
||||||
|
properties: {
|
||||||
|
type: { const: "provider_quality" },
|
||||||
|
severity: { enum: ["P1", "P2", "P3"] },
|
||||||
|
title: { type: "string", maxLength: 120 },
|
||||||
|
affected_provider: { enum: ["PL24", "PCAT", "RMEX", "TecDoc", "multi"] },
|
||||||
|
failure_mode: { enum: ["timeout", "incomplete_data", "wrong_data", "auth_error", "ratelimit", "unknown"] },
|
||||||
|
hypothesis: { type: "string", maxLength: 500 },
|
||||||
|
suggested_action: { type: "string", maxLength: 300 },
|
||||||
|
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEED_PROMPTS: PromptTemplate[] = [
|
||||||
|
{
|
||||||
|
tag: "bug_triage",
|
||||||
|
version: 1,
|
||||||
|
name: "Bug Triage v1",
|
||||||
|
systemPrompt: `You analyze bugs found in user sessions of a B2B SaaS. Extract reproducible steps, locate the failing component, and propose a hypothesis.
|
||||||
|
|
||||||
|
${SASE_CONTEXT}
|
||||||
|
|
||||||
|
Schema (return JSON conforming exactly):
|
||||||
|
${JSON.stringify(BUG_TRIAGE_SCHEMA)}`,
|
||||||
|
userPromptTemplate: `{{timeline}}
|
||||||
|
|
||||||
|
This session contains a bug. Return JSON per the schema.`,
|
||||||
|
outputSchemaJson: BUG_TRIAGE_SCHEMA,
|
||||||
|
modelTier: "pro",
|
||||||
|
maxOutputTokens: 1200,
|
||||||
|
temperature: 0.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "ux_friction",
|
||||||
|
version: 1,
|
||||||
|
name: "UX Friction v1",
|
||||||
|
systemPrompt: `You are a senior UX engineer. Given a session timeline, identify where the user got stuck and propose a quick fix and a long-term fix.
|
||||||
|
|
||||||
|
${SASE_CONTEXT}
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
${JSON.stringify(UX_FRICTION_SCHEMA)}`,
|
||||||
|
userPromptTemplate: `{{timeline}}
|
||||||
|
|
||||||
|
Return JSON per the schema.`,
|
||||||
|
outputSchemaJson: UX_FRICTION_SCHEMA,
|
||||||
|
modelTier: "flash",
|
||||||
|
maxOutputTokens: 1000,
|
||||||
|
temperature: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "payment_issue",
|
||||||
|
version: 1,
|
||||||
|
name: "Payment Issue v1",
|
||||||
|
systemPrompt: `You analyze payment/conversion failures in a B2B SaaS. Identify failure mode and likely cause.
|
||||||
|
|
||||||
|
${SASE_CONTEXT}
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
${JSON.stringify(PAYMENT_SCHEMA)}`,
|
||||||
|
userPromptTemplate: `{{timeline}}
|
||||||
|
|
||||||
|
Return JSON per the schema.`,
|
||||||
|
outputSchemaJson: PAYMENT_SCHEMA,
|
||||||
|
modelTier: "pro",
|
||||||
|
maxOutputTokens: 1000,
|
||||||
|
temperature: 0.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "onboarding_stuck",
|
||||||
|
version: 1,
|
||||||
|
name: "Onboarding Stuck v1",
|
||||||
|
systemPrompt: `You analyze new-user onboarding sessions where the user did not reach first value. Propose where they got stuck and what help is missing.
|
||||||
|
|
||||||
|
${SASE_CONTEXT}
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
${JSON.stringify(ONBOARDING_SCHEMA)}`,
|
||||||
|
userPromptTemplate: `{{timeline}}
|
||||||
|
|
||||||
|
Return JSON per the schema.`,
|
||||||
|
outputSchemaJson: ONBOARDING_SCHEMA,
|
||||||
|
modelTier: "flash",
|
||||||
|
maxOutputTokens: 900,
|
||||||
|
temperature: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "provider_quality",
|
||||||
|
version: 1,
|
||||||
|
name: "Provider Quality v1",
|
||||||
|
systemPrompt: `You analyze upstream provider failures (PL24/PCAT/RMEX/TecDoc) impacting Sase.tr users. Identify which provider failed and propose action.
|
||||||
|
|
||||||
|
${SASE_CONTEXT}
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
${JSON.stringify(PROVIDER_SCHEMA)}`,
|
||||||
|
userPromptTemplate: `{{timeline}}
|
||||||
|
|
||||||
|
Return JSON per the schema.`,
|
||||||
|
outputSchemaJson: PROVIDER_SCHEMA,
|
||||||
|
modelTier: "flash",
|
||||||
|
maxOutputTokens: 800,
|
||||||
|
temperature: 0.2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Maps session tags → which prompt template to use. First match wins.
|
||||||
|
// Severity routing (P0/P1 → pro, P2/P3/INFO → flash) is layered on top of the template's modelTier.
|
||||||
|
export function pickPromptTag(tags: string[]): string {
|
||||||
|
const set = new Set(tags);
|
||||||
|
// Payment first (highest business impact)
|
||||||
|
if (
|
||||||
|
set.has("payment_ui_silent_failure") ||
|
||||||
|
set.has("payment_friction") ||
|
||||||
|
set.has("payment_failed_session") ||
|
||||||
|
set.has("checkout_abandonment") ||
|
||||||
|
set.has("downgrade_pending")
|
||||||
|
)
|
||||||
|
return "payment_issue";
|
||||||
|
|
||||||
|
// Provider issues
|
||||||
|
if (
|
||||||
|
set.has("provider_reliability_issue") ||
|
||||||
|
set.has("provider_mismatch") ||
|
||||||
|
set.has("vin_decode_fail_pattern") ||
|
||||||
|
set.has("vin_decode_repeated_failure")
|
||||||
|
)
|
||||||
|
return "provider_quality";
|
||||||
|
|
||||||
|
// Bugs
|
||||||
|
if (set.has("bug_suspected") || set.has("server_error_impact")) return "bug_triage";
|
||||||
|
|
||||||
|
// Onboarding
|
||||||
|
if (set.has("onboarding_stuck")) return "onboarding_stuck";
|
||||||
|
|
||||||
|
// UX friction
|
||||||
|
if (
|
||||||
|
set.has("ux_friction") ||
|
||||||
|
set.has("upgrade_hesitation") ||
|
||||||
|
set.has("search_validation_friction") ||
|
||||||
|
set.has("api_key_friction") ||
|
||||||
|
set.has("webhook_setup_struggle") ||
|
||||||
|
set.has("compatibility_quality_gap") ||
|
||||||
|
set.has("parts_export_abandoned") ||
|
||||||
|
set.has("vin_decode_no_outcome") ||
|
||||||
|
set.has("vin_decode_failed_single")
|
||||||
|
)
|
||||||
|
return "ux_friction";
|
||||||
|
|
||||||
|
return "ux_friction"; // safe default
|
||||||
|
}
|
||||||
55
apps/worker/src/lib/seed-runtime.ts
Normal file
55
apps/worker/src/lib/seed-runtime.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { prisma } from "../db";
|
||||||
|
import { SEED_PROMPTS } from "./prompts";
|
||||||
|
|
||||||
|
const DEFAULT_BUDGETS: Array<{ key: string; value: unknown }> = [
|
||||||
|
{ key: "monthly_hard_cap_usd", value: 30 },
|
||||||
|
{ key: "daily_soft_cap_usd", value: 1.5 },
|
||||||
|
{ key: "daily_hard_cap_usd", value: 3 },
|
||||||
|
{ key: "per_call_max_usd", value: 0.2 },
|
||||||
|
{ key: "min_score_for_analysis", value: 30 },
|
||||||
|
{ key: "cache_ttl_hours", value: 6 },
|
||||||
|
{ key: "analysis_paused", value: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function upsertSeedData(): Promise<void> {
|
||||||
|
// Prompt templates — only insert if (tag, version) doesn't exist.
|
||||||
|
for (const p of SEED_PROMPTS) {
|
||||||
|
const existing = await prisma.promptTemplate.findUnique({
|
||||||
|
where: { tag_version: { tag: p.tag, version: p.version } },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.promptTemplate.create({
|
||||||
|
data: {
|
||||||
|
tag: p.tag,
|
||||||
|
version: p.version,
|
||||||
|
name: p.name,
|
||||||
|
systemPrompt: p.systemPrompt,
|
||||||
|
userPromptTemplate: p.userPromptTemplate,
|
||||||
|
outputSchemaJson: p.outputSchemaJson as object,
|
||||||
|
modelTier: p.modelTier,
|
||||||
|
maxOutputTokens: p.maxOutputTokens,
|
||||||
|
temperature: p.temperature,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`[seed] inserted prompt ${p.tag}@v${p.version}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget settings — only insert if missing (don't overwrite user changes).
|
||||||
|
for (const b of DEFAULT_BUDGETS) {
|
||||||
|
const existing = await prisma.budgetSetting.findFirst({
|
||||||
|
where: { projectKey: null, settingKey: b.key },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.budgetSetting.create({
|
||||||
|
data: {
|
||||||
|
projectKey: null,
|
||||||
|
settingKey: b.key,
|
||||||
|
settingValue: b.value as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`[seed] inserted budget ${b.key}=${JSON.stringify(b.value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { redis } from "../redis";
|
|||||||
import { runPostHogIngest } from "../jobs/posthog-ingest";
|
import { runPostHogIngest } from "../jobs/posthog-ingest";
|
||||||
import { runTagSessions } from "../jobs/tag-sessions";
|
import { runTagSessions } from "../jobs/tag-sessions";
|
||||||
import { runCompressSessions } from "../jobs/compress-sessions";
|
import { runCompressSessions } from "../jobs/compress-sessions";
|
||||||
|
import { runAnalyze } from "../jobs/analyze";
|
||||||
|
|
||||||
const QUEUE = "insight-pipeline";
|
const QUEUE = "insight-pipeline";
|
||||||
|
|
||||||
@@ -27,6 +28,15 @@ async function runJob(job: Job) {
|
|||||||
if (res.compressed + res.failed > 0) console.log(`[pipeline] compress ok=${res.compressed} fail=${res.failed}`);
|
if (res.compressed + res.failed > 0) console.log(`[pipeline] compress ok=${res.compressed} fail=${res.failed}`);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
case "analyze": {
|
||||||
|
const res = await runAnalyze();
|
||||||
|
if (res.analyzed + res.skipped + res.failed > 0 || res.budgetState !== "active") {
|
||||||
|
console.log(
|
||||||
|
`[pipeline] analyze ok=${res.analyzed} skip=${res.skipped} fail=${res.failed} cost=$${res.costUsd.toFixed(4)} budget=${res.budgetState}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return { ok: false, error: `unknown job ${job.name}` };
|
return { ok: false, error: `unknown job ${job.name}` };
|
||||||
}
|
}
|
||||||
@@ -48,6 +58,11 @@ export async function startInsightPipeline() {
|
|||||||
{ pattern: "*/3 * * * *" },
|
{ pattern: "*/3 * * * *" },
|
||||||
{ name: "compress-sessions", data: {}, opts: { removeOnComplete: 50, removeOnFail: 25 } },
|
{ name: "compress-sessions", data: {}, opts: { removeOnComplete: 50, removeOnFail: 25 } },
|
||||||
);
|
);
|
||||||
|
await queue.upsertJobScheduler(
|
||||||
|
"analyze",
|
||||||
|
{ pattern: "*/4 * * * *" },
|
||||||
|
{ name: "analyze", data: {}, opts: { removeOnComplete: 50, removeOnFail: 25 } },
|
||||||
|
);
|
||||||
|
|
||||||
new Worker(QUEUE, runJob, {
|
new Worker(QUEUE, runJob, {
|
||||||
connection: redis,
|
connection: redis,
|
||||||
@@ -55,5 +70,7 @@ export async function startInsightPipeline() {
|
|||||||
lockDuration: 5 * 60_000,
|
lockDuration: 5 * 60_000,
|
||||||
stalledInterval: 60_000,
|
stalledInterval: 60_000,
|
||||||
});
|
});
|
||||||
console.log("[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min");
|
console.log(
|
||||||
|
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user