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])
|
||||
@@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 default async function InsightsPage() {
|
||||
const [byStatus, recent, watermark, compressed] = await Promise.all([
|
||||
prisma.sessionMeta.groupBy({
|
||||
by: ["status"],
|
||||
_count: { status: true },
|
||||
function severityVariant(sev: string): "default" | "destructive" | "secondary" | "outline" {
|
||||
if (sev === "P0") return "destructive";
|
||||
if (sev === "P1") return "destructive";
|
||||
if (sev === "P2") return "default";
|
||||
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({
|
||||
where: { status: { in: ["tagged", "compressed"] } },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 50,
|
||||
prisma.costLedger.aggregate({
|
||||
where: { createdAt: { gte: startOfMonthUtc(new Date()) } },
|
||||
_sum: { costTotalUsd: true },
|
||||
}),
|
||||
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> = {};
|
||||
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 (
|
||||
<PanelShell title="Insights · Sase.tr (pilot)">
|
||||
<PanelShell title="Insights · inbox">
|
||||
<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>
|
||||
|
||||
<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()} />
|
||||
<KPI label="New" value={String(counts.new ?? 0)} />
|
||||
<KPI label="In backlog" value={String(counts.in_backlog ?? 0)} />
|
||||
<KPI label="Shipped" value={String(counts.shipped ?? 0)} />
|
||||
<KPI label="Today $" value={`$${today.toFixed(3)}`} />
|
||||
<KPI label="Month $" value={`$${month.toFixed(2)}`} />
|
||||
</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">
|
||||
Active insights ({recent.length})
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<TableHead className="w-[60px]">Sev</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="w-[100px]">Type</TableHead>
|
||||
<TableHead className="w-[80px]">Score</TableHead>
|
||||
<TableHead className="w-[60px]">Occ</TableHead>
|
||||
<TableHead className="w-[100px]">Conf</TableHead>
|
||||
<TableHead className="w-[120px]">Last seen</TableHead>
|
||||
<TableHead className="w-[100px]">Status</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 colSpan={8} className="text-center text-xs text-muted-foreground">
|
||||
No insights yet — LLM analysis runs every 4 minutes.
|
||||
</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)}…
|
||||
recent.map((i) => (
|
||||
<TableRow key={i.id}>
|
||||
<TableCell>
|
||||
<Badge variant={severityVariant(i.severity)}>{i.severity}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<a href={`/insights/i/${i.id}`} className="font-medium underline">
|
||||
{i.title}
|
||||
</a>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{i.fingerprint.slice(0, 16)}
|
||||
</div>
|
||||
</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 className="text-xs">{i.type}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{i.priorityScore}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{i.occurrenceCount}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{i.confidence?.toFixed(2) ?? "—"}
|
||||
</TableCell>
|
||||
<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 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>
|
||||
<Badge variant="outline">{i.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -111,6 +118,15 @@ export default async function InsightsPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{byPromptTag.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
by template:{" "}
|
||||
{byPromptTag
|
||||
.map((b) => `${b.sourcePromptTag}=${b._count.sourcePromptTag}`)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
@@ -126,3 +142,10 @@ function KPI({ label, value }: { label: string; value: string }) {
|
||||
</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
Reference in New Issue
Block a user