Merge: parts/catalog insight detection + Sase 360 evening brief

- tagger parts_render_blocked / catalog_empty_result + catalog-gap-detect job
- Sase 360 evening brief (briefs page + worker job + Telegram@20:00)
This commit is contained in:
Semih
2026-06-10 19:36:04 +03:00
19 changed files with 1152 additions and 3 deletions

View File

@@ -292,6 +292,30 @@ model Insight {
@@map("insights") @@map("insights")
} }
// Daily evening brief — one 360° report per project per day. Generated by the
// worker (sase-evening-brief job) from Sase DB + PostHog + Meta + pipeline, then
// rendered at /projects/sase/briefs and pushed to Telegram at 20:00.
model Brief {
id String @id @default(cuid())
projectKey String @default("sase")
briefDate DateTime @db.Date
status String @default("generated") // generated | failed
headline String @db.Text
summary String @db.Text
healthScore Int @default(0)
body Json // structured: { critical[], sections[], recommendations[] }
metrics Json // raw 360 aggregates that fed the LLM (audit + page render)
telegramSent Boolean @default(false)
sourceModel String @default("")
sourceCostUsd Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([projectKey, briefDate])
@@index([projectKey, briefDate(sort: Desc)])
@@map("briefs")
}
model CostLedger { model CostLedger {
id String @id @default(cuid()) id String @id @default(cuid())
insightId String? insightId String?

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
import { detectCatalogCoverageGaps } from "@/lib/sase/catalog-coverage";
export const dynamic = "force-dynamic";
const INTERNAL_TOKEN = process.env.INTERNAL_WORKER_TOKEN ?? "";
function validToken(req: Request): boolean {
if (!INTERNAL_TOKEN) return false;
const provided = req.headers.get("x-internal-worker-token") ?? "";
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(INTERNAL_TOKEN);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export async function GET(req: Request) {
if (!validToken(req)) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
}
try {
const gaps = await detectCatalogCoverageGaps({ windowDays: 7, minFailures: 3 });
return NextResponse.json({ ok: true, gaps });
} catch (e) {
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
import { getSaseBriefMetrics } from "@/lib/sase/brief-data";
export const dynamic = "force-dynamic";
const INTERNAL_TOKEN = process.env.INTERNAL_WORKER_TOKEN ?? "";
function validToken(req: Request): boolean {
if (!INTERNAL_TOKEN) return false;
const provided = req.headers.get("x-internal-worker-token") ?? "";
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(INTERNAL_TOKEN);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export async function GET(req: Request) {
if (!validToken(req)) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
}
try {
const data = await getSaseBriefMetrics();
return NextResponse.json({ ok: true, data });
} catch (e) {
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
}
}

View File

@@ -57,6 +57,12 @@ export async function SaseHealth() {
> >
VIN Decode VIN Decode
</Link> </Link>
<Link
href="/projects/sase/briefs"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Briefs
</Link>
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">

View File

@@ -0,0 +1,120 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { PanelShell } from "@/components/panel-shell";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export const dynamic = "force-dynamic";
type Section = { key?: string; title?: string; status?: string; bullets?: string[] };
type BriefBody = {
headline?: string;
summary?: string;
health_score?: number;
critical?: string[];
sections?: Section[];
recommendations?: string[];
};
function statusDot(status?: string): string {
if (status === "good") return "🟢";
if (status === "bad") return "🔴";
if (status === "warn") return "🟡";
return "⚪";
}
export default async function BriefDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const brief = await prisma.brief.findUnique({ where: { id } });
if (!brief) notFound();
const body = brief.body as unknown as BriefBody;
const healthVariant =
brief.healthScore >= 70 ? "default" : brief.healthScore >= 40 ? "secondary" : "destructive";
return (
<PanelShell title={`Sase Brief · ${brief.briefDate.toISOString().slice(0, 10)}`}>
<Link href="/projects/sase/briefs" className="text-xs underline text-muted-foreground">
Tüm briefler
</Link>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={healthVariant as "default" | "secondary" | "destructive"}>
Sağlık {brief.healthScore}/100
</Badge>
{brief.status === "failed" && <Badge variant="destructive">failed</Badge>}
{brief.telegramSent && <Badge variant="outline">📨 Telegram</Badge>}
<span className="text-xs text-muted-foreground">
{brief.sourceModel || "—"} · ${brief.sourceCostUsd.toFixed(4)}
</span>
</div>
{body.headline && <h2 className="text-xl font-semibold">{body.headline}</h2>}
{body.summary && <p className="text-sm text-muted-foreground">{body.summary}</p>}
{Array.isArray(body.critical) && body.critical.length > 0 && (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-base text-destructive">🔴 Kritik noktalar</CardTitle>
</CardHeader>
<CardContent>
<ul className="ml-4 list-disc space-y-1 text-sm">
{body.critical.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{(body.sections ?? []).map((s, i) => (
<Card key={i}>
<CardHeader>
<CardTitle className="text-base">
{statusDot(s.status)} {s.title ?? s.key ?? "Bölüm"}
</CardTitle>
</CardHeader>
<CardContent>
<ul className="ml-4 list-disc space-y-1 text-sm">
{(s.bullets ?? []).map((b, j) => (
<li key={j}>{b}</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
{Array.isArray(body.recommendations) && body.recommendations.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base"> Öneriler (yarın)</CardTitle>
</CardHeader>
<CardContent>
<ol className="ml-4 list-decimal space-y-1 text-sm">
{body.recommendations.map((r, i) => (
<li key={i}>{r}</li>
))}
</ol>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardDescription>Beslendiği ham veri (360°)</CardDescription>
</CardHeader>
<CardContent>
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">metrics JSON</summary>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap rounded-md bg-muted/30 p-3 font-mono">
{JSON.stringify(brief.metrics, null, 2)}
</pre>
</details>
</CardContent>
</Card>
</PanelShell>
);
}

View File

@@ -0,0 +1,94 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { PanelShell } from "@/components/panel-shell";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export const dynamic = "force-dynamic";
function HealthBadge({ score }: { score: number }) {
const variant = score >= 70 ? "default" : score >= 40 ? "secondary" : "destructive";
return <Badge variant={variant as "default" | "secondary" | "destructive"}>{score}</Badge>;
}
export default async function BriefsPage() {
const briefs = await prisma.brief.findMany({
where: { projectKey: "sase" },
orderBy: { briefDate: "desc" },
take: 90,
});
return (
<PanelShell title="Sase · Briefs">
<Link href="/projects/sase" className="text-xs underline text-muted-foreground">
Sase
</Link>
<p className="text-sm text-muted-foreground">
Her akşam 20:00&apos;de üretilen 360° günlük brief VIN decode gelir davranış reklam
insight pipeline. Detay için güne tıkla.
</p>
{briefs.length === 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Henüz brief yok</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
İlk brief bu akşam 20:00&apos;de (17:00 UTC) üretilecek ve Telegram&apos;a düşecek.
</CardContent>
</Card>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-28">Tarih</TableHead>
<TableHead className="w-16">Skor</TableHead>
<TableHead>Başlık</TableHead>
<TableHead className="w-28">Durum</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{briefs.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-mono">
<Link href={`/projects/sase/briefs/${b.id}`} className="underline">
{b.briefDate.toISOString().slice(0, 10)}
</Link>
</TableCell>
<TableCell>
<HealthBadge score={b.healthScore} />
</TableCell>
<TableCell className="max-w-[520px] truncate">
{b.status === "failed" ? (
<span className="text-destructive">{b.headline}</span>
) : (
b.headline
)}
</TableCell>
<TableCell>
{b.telegramSent ? (
<Badge variant="outline">📨 gönderildi</Badge>
) : b.status === "failed" ? (
<Badge variant="destructive">failed</Badge>
) : (
<Badge variant="secondary">üretildi</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</PanelShell>
);
}

View File

@@ -0,0 +1,77 @@
import { saseDb } from "@/lib/db-sase";
import { getOperationalHealth } from "./vin-decode";
import { detectCatalogCoverageGaps } from "./catalog-coverage";
import {
getSaseHealthSnapshot,
getSaseUserCounts,
getInsightSummary,
getRecentDeployStatuses,
} from "./daily-overview";
// The Sase-DB half of the evening brief — everything that needs `saseDb`
// (the panel is the only process with that connection). The worker fetches this
// over the internal API and adds PostHog/Meta/pipeline before the LLM pass.
export async function getSaseBriefMetrics() {
const since24 = new Date(Date.now() - 24 * 3600_000);
const since7 = new Date(Date.now() - 7 * 24 * 3600_000);
const [health, op, gaps, users, insights, deploys, rev24, rev7, pend7, byMethod7] =
await Promise.all([
getSaseHealthSnapshot(),
getOperationalHealth("24h"),
detectCatalogCoverageGaps({ windowDays: 7, minFailures: 3 }),
getSaseUserCounts(),
getInsightSummary(),
getRecentDeployStatuses(5),
saseDb.payment.aggregate({ where: { status: "completed", createdAt: { gte: since24 } }, _sum: { amount: true }, _count: true }),
saseDb.payment.aggregate({ where: { status: "completed", createdAt: { gte: since7 } }, _sum: { amount: true }, _count: true }),
saseDb.payment.aggregate({ where: { status: "pending", createdAt: { gte: since7 } }, _sum: { amount: true }, _count: true }),
saseDb.payment.groupBy({ by: ["method"], where: { status: "completed", createdAt: { gte: since7 } }, _sum: { amount: true }, _count: true }),
]);
// amount is stored in kuruş (TRY cents).
const kurus = (v: number | null | undefined) => Number(v ?? 0) / 100;
return {
decode: {
total24h: health.total24h,
successRate24h: round(health.successRate24h * 100, 1),
successDeltaPp: round(health.successDeltaPp, 1),
volumeDeltaPct: round(health.volumeDeltaPct * 100, 0),
errorRatePct: round(op.errorRate * 100, 1),
timeouts24h: op.timeoutCount,
unknownVins24h: op.unknownVinCount,
cacheHitRatePct: round(op.cacheHitRate * 100, 0),
p95ResponseMs: op.p95ResponseMs,
},
catalogGaps: gaps.slice(0, 10).map((g) => ({
brand: g.brand,
failures: g.failures,
users: g.uniqueUsers,
years: g.years,
})),
revenue: {
currency: "TRY",
completed24h: kurus(rev24._sum.amount),
completed24hCount: rev24._count,
completed7d: kurus(rev7._sum.amount),
completed7dCount: rev7._count,
pending7d: kurus(pend7._sum.amount),
pending7dCount: pend7._count,
byMethod7d: byMethod7.map((m) => ({ method: m.method, amount: kurus(m._sum.amount), count: m._count })),
},
users,
insights,
deploys: deploys.map((d) => ({
commit: d.commit?.slice(0, 8) ?? null,
regressed: d.regressed,
deltaPp: d.successRateDeltaPp,
startedAt: d.startedAt.toISOString(),
})),
};
}
function round(n: number, d: number): number {
const f = 10 ** d;
return Math.round(n * f) / f;
}

View File

@@ -0,0 +1,77 @@
import { saseDb } from "@/lib/db-sase";
// ─── Catalog coverage gaps ────────────────────────────────────────────────
// The single biggest VIN-decode failure mode is "No catalog — identified as X":
// the decoder *recognises* the vehicle (brand/year) but Sase has no parts
// catalog mapped for it, so the user sees their car but no parts. These rows
// land in query_logs with success=false and error_message starting
// "No catalog — identified as <Brand> [<Year>]". We aggregate them by brand so
// the worker can raise ONE insight per brand-gap instead of per failed query.
export type CatalogGap = {
brand: string;
failures: number;
uniqueUsers: number;
labels: string[]; // distinct "<Brand> <Year>" identities seen, e.g. ["Renault 2004","Renault 2006"]
years: number[]; // parsed model years (sorted)
firstSeen: string; // ISO
lastSeen: string; // ISO
windowDays: number;
};
export async function detectCatalogCoverageGaps(
opts: { windowDays?: number; minFailures?: number } = {},
): Promise<CatalogGap[]> {
const windowDays = opts.windowDays ?? 7;
const minFailures = opts.minFailures ?? 3;
// Brand is the first token of the identified label; initcap() folds HONDA/Honda
// into one group. array_agg collects the distinct "<Brand> <Year>" identities so
// the worker can show the affected model years.
const rows = await saseDb.$queryRaw<
Array<{
brand: string;
failures: number;
unique_users: number;
first_seen: Date;
last_seen: Date;
labels: string[];
}>
>`
WITH nc AS (
SELECT user_id, created_at,
trim(substring(error_message FROM 'identified as (.*)$')) AS label
FROM query_logs
WHERE success = false
AND created_at > now() - make_interval(days => ${windowDays})
AND error_message LIKE 'No catalog%'
)
SELECT
initcap(split_part(label, ' ', 1)) AS brand,
count(*)::int AS failures,
count(distinct user_id)::int AS unique_users,
min(created_at) AS first_seen,
max(created_at) AS last_seen,
array_agg(DISTINCT label ORDER BY label) AS labels
FROM nc
WHERE label IS NOT NULL AND label <> ''
GROUP BY 1
HAVING count(*) >= ${minFailures}
ORDER BY failures DESC
`;
return rows.map((r) => {
const years = Array.from(
new Set(r.labels.flatMap((l) => (l.match(/\b(?:19|20)\d{2}\b/g) ?? []).map(Number))),
).sort((a, b) => a - b);
return {
brand: r.brand,
failures: r.failures,
uniqueUsers: r.unique_users,
labels: r.labels,
years,
firstSeen: r.first_seen.toISOString(),
lastSeen: r.last_seen.toISOString(),
windowDays,
};
});
}

View File

@@ -260,6 +260,7 @@ export type ErrorBucketRow = {
export const ERROR_BUCKET_KEYS = [ export const ERROR_BUCKET_KEYS = [
"BUDGET_EXCEEDED", "BUDGET_EXCEEDED",
"NO_CATALOG",
"UNKNOWN_VIN", "UNKNOWN_VIN",
"TIMEOUT", "TIMEOUT",
"INVALID_VIN", "INVALID_VIN",
@@ -273,6 +274,9 @@ export type ErrorBucketKey = (typeof ERROR_BUCKET_KEYS)[number];
const ERROR_PATTERNS: Array<{ key: ErrorBucketKey; matchers: RegExp[] }> = [ const ERROR_PATTERNS: Array<{ key: ErrorBucketKey; matchers: RegExp[] }> = [
{ key: "BUDGET_EXCEEDED", matchers: [/budget/i, /aborted/i] }, { key: "BUDGET_EXCEEDED", matchers: [/budget/i, /aborted/i] },
// Vehicle identified but no parts catalog mapped — the #1 "OTHER" failure and
// the server-side twin of the catalog_empty_result insight signal.
{ key: "NO_CATALOG", matchers: [/no catalog/i] },
{ key: "UNKNOWN_VIN", matchers: [/unknown vin/i, /tanınamadı/i, /destekl/i] }, { key: "UNKNOWN_VIN", matchers: [/unknown vin/i, /tanınamadı/i, /destekl/i] },
{ key: "TIMEOUT", matchers: [/timeout/i, /timed out/i] }, { key: "TIMEOUT", matchers: [/timeout/i, /timed out/i] },
{ key: "INVALID_VIN", matchers: [/geçersiz/i, /invalid vin/i] }, { key: "INVALID_VIN", matchers: [/geçersiz/i, /invalid vin/i] },

View File

@@ -0,0 +1,196 @@
import { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { alertCatalogGap, isTelegramConfigured } from "../lib/telegram";
// Server-side companion to the session-driven insight pipeline. The behavioural
// pipeline (PostHog → tagger → analyze) catches "user resolved a vehicle but saw
// no parts" from client events (catalog_empty_result / parts_render_blocked).
// This job catches the SAME failure from the server side — query_logs rows where
// decode succeeded at identifying the car but no parts catalog exists
// ("No catalog — identified as X") — and raises one Insight per brand-gap into
// the founder's existing inbox. Panel owns the Sase DB; we fetch the aggregate
// over the internal API (same trust model as vin-anomaly-detect) and write rows.
const PANEL_BASE =
process.env.PANEL_INTERNAL_URL ?? process.env.PANEL_PUBLIC_URL ?? "http://panel-web:3000";
const PANEL_PUBLIC = process.env.PANEL_PUBLIC_URL ?? "https://sp.semih.ai";
const WORKER_TOKEN = process.env.INTERNAL_WORKER_TOKEN ?? "";
const PROJECT_KEY = process.env.INSIGHT_PROJECT_KEY ?? "sase";
type CatalogGap = {
brand: string;
failures: number;
uniqueUsers: number;
labels: string[];
years: number[];
firstSeen: string;
lastSeen: string;
windowDays: number;
};
type CheckResponse = { ok: boolean; error?: string; gaps?: CatalogGap[] };
type Summary = {
ok: boolean;
gaps: number;
created: number;
updated: number;
alertsFired: number;
reason?: string;
};
function brandSlug(b: string): string {
return b.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
}
// P1 once a gap bites real breadth (≥4 distinct users or ≥15 failed lookups in
// the window) — a core-value failure for a whole brand; otherwise P2.
function severityFor(g: CatalogGap): "P1" | "P2" {
return g.uniqueUsers >= 4 || g.failures >= 15 ? "P1" : "P2";
}
function priorityFor(sev: string, users: number): number {
return (sev === "P1" ? 80 : 55) + Math.min(15, users);
}
// Shape the body around keys the insight detail page already renders
// (hypothesis / affected_route / user_impact_estimate / suggested_investigation /
// reproduce_steps); extra keys still show in the Raw JSON panel.
function buildBody(g: CatalogGap): Prisma.InputJsonValue {
const yrs = g.years.length ? ` (${g.years[0]}${g.years[g.years.length - 1]})` : "";
const labelList = g.labels.slice(0, 12).join(", ");
return {
summary: `${g.brand}${yrs}: araç decode'da tanınıyor ama parça kataloğu bulunamıyor ("No catalog"). Son ${g.windowDays} günde ${g.failures} başarısız sorgu / ${g.uniqueUsers} kullanıcı.`,
hypothesis: `Decode aracı doğru tanıyor ama "tanınan araç → parça kataloğu" eşlemesi ${g.brand} için boş dönüyor. Hatalar belirli model yıllarında kümeleniyor (${labelList}) → muhtemelen katalog MAPPING eksikliği (EMEX/PCAT kaynağında veri var ama brand/yıl eşleşmiyor) ya da o segment için kaynak verisi hiç yok.`,
affected_route: `/dashboard/catalog/${g.brand}, /dashboard/vehicles/:id (decode → "No catalog")`,
user_impact_estimate: `${g.uniqueUsers} kullanıcı son ${g.windowDays} günde ${g.brand} için parça göremedi → doğrudan churn sinyali. ${g.brand} TR pazarında yaygın bir marka.`,
suggested_investigation: [
`vehicles.service'teki katalog lookup zincirinde "No catalog" branch'ini incele (apps/api .../vehicles/vehicles.service.ts)`,
`sase-catalog-src-emex / sase-catalog-src-pcat DB'lerinde ${g.brand} (${labelList}) var mı — veri mi eksik, mapping mi bozuk?`,
`Tanınan brand/model_year → catalog brand/subcatalog eşlemesini kontrol et`,
],
reproduce_steps: [
`Katalogdan ${g.brand} seç (ya da bu markaya ait bir VIN decode et)`,
`Bir model/yıl seç (${g.labels[0] ?? g.brand})`,
`Parça/kategori yerine boş sonuç / "No catalog" gözlenir`,
],
suggested_fix_effort: "M",
affected_labels: g.labels,
total_failures: g.failures,
unique_users: g.uniqueUsers,
window_days: g.windowDays,
};
}
export async function runCatalogGapDetect(): Promise<Summary> {
if (!WORKER_TOKEN) {
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: "INTERNAL_WORKER_TOKEN not set" };
}
let res: Response;
try {
res = await fetch(`${PANEL_BASE}/api/internal/catalog-gap-check`, {
headers: { "x-internal-worker-token": WORKER_TOKEN, "cache-control": "no-store" },
});
} catch (e) {
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: `fetch failed: ${(e as Error).message}` };
}
if (!res.ok) {
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: `panel returned ${res.status}` };
}
const data = (await res.json()) as CheckResponse;
if (!data.ok) {
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: data.error };
}
const gaps = data.gaps ?? [];
const day = new Date().toISOString().slice(0, 10);
let created = 0;
let updated = 0;
let alertsFired = 0;
for (const g of gaps) {
const fingerprint = `catalog_gap:${brandSlug(g.brand)}`;
const sev = severityFor(g);
const body = buildBody(g);
const title = `Katalog yok: ${g.brand}${g.uniqueUsers} kullanıcı parça göremiyor (son ${g.windowDays}g)`;
const existing = await prisma.insight.findUnique({
where: { projectKey_fingerprint: { projectKey: PROJECT_KEY, fingerprint } },
});
if (existing) {
// Refresh counts/severity but RESPECT founder triage: a dismissed/duplicate
// gap stays dismissed (no resurrection). A gap the founder had already
// validated/shipped that is failing again flips to "regressed" + alerts.
const regressed = ["validated", "shipped"].includes(existing.status);
await prisma.insight.update({
where: { id: existing.id },
data: {
title,
body,
severity: sev,
occurrenceCount: g.failures,
uniqueUserCount: g.uniqueUsers,
lastSeenAt: new Date(g.lastSeen),
priorityScore: priorityFor(sev, g.uniqueUsers),
...(regressed ? { status: "regressed", regressionDetected: true } : {}),
},
});
updated++;
if (regressed && isTelegramConfigured()) {
const r = await alertCatalogGap({
brand: g.brand,
failures: g.failures,
uniqueUsers: g.uniqueUsers,
windowDays: g.windowDays,
severity: sev,
insightId: existing.id,
panelUrl: PANEL_PUBLIC,
day,
regressed: true,
});
if (r.ok && !r.deduped) alertsFired++;
}
continue;
}
const row = await prisma.insight.create({
data: {
projectKey: PROJECT_KEY,
type: "catalog_coverage_gap",
severity: sev,
status: "new",
fingerprint,
title,
body,
relatedSessionIds: [],
occurrenceCount: g.failures,
uniqueUserCount: g.uniqueUsers,
firstSeenAt: new Date(g.firstSeen),
lastSeenAt: new Date(g.lastSeen),
confidence: 1.0,
priorityScore: priorityFor(sev, g.uniqueUsers),
sourcePromptTag: "catalog_gap_detector",
sourcePromptVersion: 0,
sourceModel: "rule:catalog-gap",
sourceCostUsd: 0,
},
});
created++;
if (isTelegramConfigured()) {
const r = await alertCatalogGap({
brand: g.brand,
failures: g.failures,
uniqueUsers: g.uniqueUsers,
windowDays: g.windowDays,
severity: sev,
insightId: row.id,
panelUrl: PANEL_PUBLIC,
day,
regressed: false,
});
if (r.ok && !r.deduped) alertsFired++;
}
}
return { ok: true, gaps: gaps.length, created, updated, alertsFired };
}

View File

@@ -0,0 +1,188 @@
import { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { hogqlQuery, isConfigured as posthogConfigured } from "../lib/posthog";
import { getMetaAdsSummary } from "../lib/meta";
import { callDeepSeek, extractJson } from "../lib/deepseek";
import { alertEveningBrief, isTelegramConfigured } from "../lib/telegram";
const PANEL_BASE =
process.env.PANEL_INTERNAL_URL ?? process.env.PANEL_PUBLIC_URL ?? "http://panel-web:3000";
const PANEL_PUBLIC = process.env.PANEL_PUBLIC_URL ?? "https://sp.semih.ai";
const WORKER_TOKEN = process.env.INTERNAL_WORKER_TOKEN ?? "";
const PROJECT_KEY = "sase";
type Summary = { ok: boolean; briefId?: string; sent: boolean; reason?: string };
// ─── 360° collection ──────────────────────────────────────────────────────
async function fetchSaseMetrics(): Promise<unknown> {
if (!WORKER_TOKEN) return { error: "INTERNAL_WORKER_TOKEN yok" };
try {
const res = await fetch(`${PANEL_BASE}/api/internal/sase-brief-data`, {
headers: { "x-internal-worker-token": WORKER_TOKEN, "cache-control": "no-store" },
});
if (!res.ok) return { error: `panel ${res.status}` };
const j = (await res.json()) as { ok: boolean; data?: unknown; error?: string };
return j.ok ? j.data : { error: j.error };
} catch (e) {
return { error: (e as Error).message };
}
}
async function fetchBehavior(): Promise<unknown> {
if (!posthogConfigured()) return { error: "posthog yapılandırılmamış" };
const out: Record<string, unknown> = {};
try {
const r = await hogqlQuery(`
SELECT count(DISTINCT person_id) AS visitors,
countIf(event='$pageview') AS pageviews,
countIf(event='user_signed_up') AS signups,
countIf(event='vin_decode_success') AS vin_success,
countIf(event='parts_panel_viewed') AS parts_views,
countIf(event='empty_catalog_cta_clicked') AS empty_catalog,
countIf(event='checkout_started') AS checkouts,
countIf(event='payment_success') AS payments,
countIf(event='trial_started') AS trials
FROM events WHERE timestamp > now() - INTERVAL 1 DAY`);
const row = (r.results?.[0] ?? []) as number[];
const keys = ["visitors","pageviews","signups","vin_success","parts_views","empty_catalog","checkouts","payments","trials"];
keys.forEach((k, i) => (out[k] = Number(row[i] ?? 0)));
} catch (e) {
out.error = (e as Error).message;
}
try {
const r = await hogqlQuery(`
SELECT person.properties.$initial_utm_source AS src,
count(DISTINCT person_id) AS visitors,
countIf(event='user_signed_up') AS signups
FROM events
WHERE timestamp > now() - INTERVAL 1 DAY AND person.properties.$initial_utm_source != ''
GROUP BY src ORDER BY visitors DESC LIMIT 8`);
out.bySource = (r.results ?? []).map((row: unknown[]) => ({
source: String(row[0] ?? ""),
visitors: Number(row[1] ?? 0),
signups: Number(row[2] ?? 0),
}));
} catch {
/* attribution is best-effort */
}
return out;
}
async function fetchPipelineStats(): Promise<unknown> {
const since24 = new Date(Date.now() - 24 * 3600_000);
const [created24, bySev, openP, catalogGapOpen] = await Promise.all([
prisma.insight.count({ where: { projectKey: PROJECT_KEY, createdAt: { gte: since24 } } }),
prisma.insight.groupBy({ by: ["severity"], where: { projectKey: PROJECT_KEY, createdAt: { gte: since24 } }, _count: { severity: true } }),
prisma.insight.count({ where: { projectKey: PROJECT_KEY, status: { in: ["new", "regressed"] }, severity: { in: ["P0", "P1"] } } }),
prisma.insight.count({ where: { projectKey: PROJECT_KEY, type: "catalog_coverage_gap", status: { in: ["new", "regressed"] } } }),
]);
const sev: Record<string, number> = {};
for (const r of bySev) sev[r.severity] = r._count.severity;
return { created24h: created24, bySeverity24h: sev, openP0P1: openP, openCatalogGaps: catalogGapOpen };
}
// ─── LLM ──────────────────────────────────────────────────────────────────
const SYSTEM_PROMPT = `Sen Sase.tr (B2B oto yedek-parça VIN/katalog SaaS'ı, TR pazarı) için AKŞAM yönetici brief'i yazıyorsun. Sana 360° metrikler verilecek: VIN decode sağlığı, katalog kapsama açıkları, gelir (Stripe/iyzico ₺), kullanıcı + abonelik durumu, PostHog davranışı + kaynak/UTM, Meta reklam, ve insight pipeline.
ÇOK DETAYLI ama KRİTİK NOKTALARI ÖN PLANDA tutan bir rapor üret. Founder bunu akşam okuyup günü kavrasın ve yarın ne yapacağını bilsin.
SADECE şu şemada geçerli JSON döndür (markdown/code-fence yok):
{
"headline": "tek cümle, günün özü",
"summary": "2-3 cümle, Telegram'a gidecek kısa özet",
"health_score": 0-100 arası tamsayı (genel sağlık),
"critical": ["en kritik 2-5 madde, ön planda, en acil önce"],
"sections": [
{"key": "vin_decode|revenue|users|behavior|ads|product|pipeline", "title": "...", "status": "good|warn|bad", "bullets": ["somut, rakamlı maddeler"]}
],
"recommendations": ["yarın için önerilen 2-4 aksiyon"]
}
Kurallar: Tüm metin TÜRKÇE. Rakamları SADECE verilen metrikten al, uydurma. Veri eksikse (örn. Meta 'configured:false') bunu dürüstçe belirt, kritik yapma. Para birimi ₺. Kritik noktalar: gelir durgunluğu, decode başarı düşüşü, katalog açıkları (Renault gibi), empty-handed payer, dormant payer, P0/P1 insight, deploy regresyonu. status alanını dürüst seç.`;
async function generateBrief(metrics: unknown): Promise<{ parsed: any; model: string; cost: number } | null> {
const today = new Date().toISOString().slice(0, 10);
const userPrompt = `Bugün: ${today}\n\nMETRİKLER:\n\`\`\`json\n${JSON.stringify(metrics, null, 2)}\n\`\`\`\n\nYukarıdaki şemada JSON brief üret.`;
try {
const res = await callDeepSeek({
tier: "pro",
systemPrompt: SYSTEM_PROMPT,
userPrompt,
maxOutputTokens: 2500,
temperature: 0.4,
});
const parsed = JSON.parse(extractJson(res.text));
return { parsed, model: res.model, cost: res.cost.totalUsd };
} catch (e) {
console.warn(`[evening-brief] LLM/parse failed: ${(e as Error).message}`);
return null;
}
}
// ─── Job ────────────────────────────────────────────────────────────────────
export async function runSaseEveningBrief(): Promise<Summary> {
const [sase, behavior, ads, pipeline] = await Promise.all([
fetchSaseMetrics(),
fetchBehavior(),
getMetaAdsSummary(1),
fetchPipelineStats(),
]);
const metrics = { generatedAt: new Date().toISOString(), sase, behavior, ads, pipeline };
const gen = await generateBrief(metrics);
const briefDate = new Date(new Date().toISOString().slice(0, 10)); // UTC midnight today
const fallback = {
headline: "Brief üretilemedi (LLM hatası) — ham metrikler kayıtlı",
summary: "LLM brief üretemedi; veriler panelde mevcut.",
health_score: 0,
critical: [] as string[],
sections: [] as unknown[],
recommendations: [] as string[],
};
const body = gen?.parsed ?? fallback;
const status = gen ? "generated" : "failed";
const brief = await prisma.brief.upsert({
where: { projectKey_briefDate: { projectKey: PROJECT_KEY, briefDate } },
create: {
projectKey: PROJECT_KEY,
briefDate,
status,
headline: String(body.headline ?? "").slice(0, 1000),
summary: String(body.summary ?? "").slice(0, 2000),
healthScore: Math.max(0, Math.min(100, Number(body.health_score ?? 0) | 0)),
body: body as Prisma.InputJsonValue,
metrics: metrics as Prisma.InputJsonValue,
sourceModel: gen?.model ?? "",
sourceCostUsd: gen?.cost ?? 0,
},
update: {
status,
headline: String(body.headline ?? "").slice(0, 1000),
summary: String(body.summary ?? "").slice(0, 2000),
healthScore: Math.max(0, Math.min(100, Number(body.health_score ?? 0) | 0)),
body: body as Prisma.InputJsonValue,
metrics: metrics as Prisma.InputJsonValue,
sourceModel: gen?.model ?? "",
sourceCostUsd: gen?.cost ?? 0,
},
});
let sent = false;
if (isTelegramConfigured()) {
const r = await alertEveningBrief({
briefId: brief.id,
headline: String(body.headline ?? ""),
summary: String(body.summary ?? ""),
critical: Array.isArray(body.critical) ? (body.critical as string[]).slice(0, 4) : [],
healthScore: brief.healthScore,
date: briefDate.toISOString().slice(0, 10),
panelUrl: PANEL_PUBLIC,
});
sent = r.ok && !r.deduped;
if (sent) await prisma.brief.update({ where: { id: brief.id }, data: { telegramSent: true } });
}
return { ok: true, briefId: brief.id, sent };
}

View File

@@ -47,6 +47,23 @@ export const TRACKED_EVENTS: string[] = [
"parts_export_completed", "parts_export_completed",
"oem_code_copied", "oem_code_copied",
// Catalog / category browsing — Sase.tr's core value path (vehicle → categories
// → parts). These were previously NOT fetched, so a vehicle that resolved but
// rendered no parts/categories (the #1 churn complaint) was invisible to the
// tagger. `empty_catalog_cta_clicked` / `parts_panel_viewed.parts_count=0` are
// the concrete empty-state signals; the rest let us tell "drilled in but got
// nothing" apart from "decoded and bounced".
"catalog_search_opened",
"catalog_brands_viewed",
"catalog_brand_clicked",
"catalog_subcatalog_selected",
"catalog_models_viewed",
"catalog_model_clicked",
"catalog_locked_brand_upgrade_clicked",
"category_view_changed",
"empty_catalog_cta_clicked",
"part_reference_clicked",
// Payment (v1 + v2) // Payment (v1 + v2)
"payment_initiated", "payment_initiated",
"payment_success", "payment_success",

View File

@@ -98,6 +98,7 @@ export function customEventPromoteReasons(eventNames: string[]): string[] {
"subscription_cancelled", "subscription_cancelled",
"trial_urgency_banner_cta_clicked", "trial_urgency_banner_cta_clicked",
"downgrade_offer_shown", "downgrade_offer_shown",
"empty_catalog_cta_clicked", // resolved a vehicle but the catalog was empty
]; ];
for (const e of single) if (set.has(e)) reasons.push(`event:${e}`); for (const e of single) if (set.has(e)) reasons.push(`event:${e}`);

View File

@@ -0,0 +1,65 @@
// Meta (Facebook/Instagram) Ads — last-N-day spend + results for the brief.
// Env-gated: the headless worker has no Meta token by default (it lives only in
// the interactive MCP config). Add META_ACCESS_TOKEN + META_AD_ACCOUNT_ID to the
// worker env to activate; until then the brief shows a "not wired" note instead
// of failing.
const TOKEN = process.env.META_ACCESS_TOKEN ?? "";
const ACCOUNT = process.env.META_AD_ACCOUNT_ID ?? "";
const API_VERSION = process.env.META_API_VERSION ?? "v21.0";
export type MetaAdsSummary = {
configured: boolean;
reason?: string;
spend?: number;
impressions?: number;
clicks?: number;
ctr?: number;
cpc?: number;
results?: number; // leads / registrations / purchases summed
currency?: string;
};
export function isMetaConfigured(): boolean {
return Boolean(TOKEN && ACCOUNT);
}
export async function getMetaAdsSummary(days = 1): Promise<MetaAdsSummary> {
if (!isMetaConfigured()) {
return { configured: false, reason: "META_ACCESS_TOKEN / META_AD_ACCOUNT_ID env yok" };
}
const acct = ACCOUNT.startsWith("act_") ? ACCOUNT : `act_${ACCOUNT}`;
const since = new Date(Date.now() - days * 86400_000).toISOString().slice(0, 10);
const until = new Date().toISOString().slice(0, 10);
const fields = "spend,impressions,clicks,ctr,cpc,actions,account_currency";
const url =
`https://graph.facebook.com/${API_VERSION}/${acct}/insights` +
`?fields=${fields}` +
`&time_range=${encodeURIComponent(JSON.stringify({ since, until }))}` +
`&access_token=${encodeURIComponent(TOKEN)}`;
try {
const res = await fetch(url);
if (!res.ok) {
const t = await res.text();
return { configured: true, reason: `meta ${res.status}: ${t.slice(0, 120)}` };
}
const j = (await res.json()) as { data?: Array<Record<string, unknown>> };
const d = j.data?.[0];
if (!d) return { configured: true, spend: 0, impressions: 0, clicks: 0, results: 0 };
const actions = Array.isArray(d.actions) ? (d.actions as Array<{ action_type: string; value: string }>) : [];
const results = actions
.filter((a) => /lead|purchase|complete_registration|submit_application|subscribe/i.test(a.action_type))
.reduce((s, a) => s + Number(a.value || 0), 0);
return {
configured: true,
spend: Number(d.spend ?? 0),
impressions: Number(d.impressions ?? 0),
clicks: Number(d.clicks ?? 0),
ctr: Number(d.ctr ?? 0),
cpc: Number(d.cpc ?? 0),
results,
currency: typeof d.account_currency === "string" ? d.account_currency : undefined,
};
} catch (e) {
return { configured: true, reason: (e as Error).message };
}
}

View File

@@ -322,8 +322,16 @@ export function pickPromptTag(tags: string[]): string {
) )
return "provider_quality"; return "provider_quality";
// Bugs // Bugs (incl. core-value parts/category render failures — bug_triage carries
if (set.has("bug_suspected") || set.has("server_error_impact")) return "bug_triage"; // is_likely_provider_issue / implicated_provider so the model can attribute an
// empty catalog to a provider data gap vs. a render/query defect).
if (
set.has("bug_suspected") ||
set.has("server_error_impact") ||
set.has("parts_render_blocked") ||
set.has("catalog_empty_result")
)
return "bug_triage";
// Onboarding // Onboarding
if (set.has("onboarding_stuck")) return "onboarding_stuck"; if (set.has("onboarding_stuck")) return "onboarding_stuck";

View File

@@ -121,5 +121,78 @@ const tags = (s: SessionMeta, events: CanonicalEvent[], up: Record<string, unkno
assert("validation-friction: frustrated_session NOT suppressed", t.includes("frustrated_session")); assert("validation-friction: frustrated_session NOT suppressed", t.includes("frustrated_session"));
} }
// 8) THE serkan case: signed-up trial user, VIN decodes (sees model), drills the
// category tree + clicks a catalog model, but parts NEVER render — zero rage,
// zero error, zero 5xx. A demo category that *does* return parts must not mask
// the real-page failure, and touching the search box must not silence it.
{
const t = tags(
mkSession({ rageClickCount: 0, errorCount: 0, network5xxCount: 0 }),
[
ev("search_input_focused"),
ev("vin_decode_succeeded"),
ev("category_view_changed"),
ev("category_view_changed"),
ev("category_view_changed"),
ev("catalog_model_clicked", { brand_name: "Ford" }),
// demo categories that DO return parts — must be excluded from the signal:
ev("parts_panel_viewed", { parts_count: 13, $current_url: "https://sase.tr/demo/categories/x" }),
],
);
assert("serkan: parts_render_blocked fired", t.includes("parts_render_blocked"));
assert("serkan: was caught (not tag-less / not search-misuse suppressed)", t.length > 0);
}
// 9) Parts panel renders EMPTY on a real dashboard page (parts_count=0, never non-empty).
{
const t = tags(mkSession({}), [
ev("vin_decode_succeeded"),
ev("parts_panel_viewed", {
parts_count: 0,
$current_url: "https://sase.tr/dashboard/vehicles/abc/categories/def",
}),
]);
assert("empty-panel: catalog_empty_result fired", t.includes("catalog_empty_result"));
assert("empty-panel: not falsely 'blocked' (panel did render)", !t.includes("parts_render_blocked"));
}
// 10) Explicit empty-state CTA → concrete empty result.
{
const t = tags(mkSession({}), [
ev("vin_decode_succeeded"),
ev("empty_catalog_cta_clicked", { vehicle_label: "Ford Focus", category_name: "mekanik" }),
]);
assert("empty-cta: catalog_empty_result fired", t.includes("catalog_empty_result"));
}
// 11) Happy path: parts actually render (count>0) → no parts-failure tags.
{
const url = "https://sase.tr/dashboard/vehicles/abc/categories/def";
const t = tags(mkSession({}), [
ev("vin_decode_succeeded"),
ev("catalog_model_clicked"),
ev("parts_panel_viewed", { parts_count: 20, $current_url: url }),
ev("oem_code_copied", { $current_url: url }),
]);
assert("happy: no parts_render_blocked", !t.includes("parts_render_blocked"));
assert("happy: no catalog_empty_result", !t.includes("catalog_empty_result"));
}
// 12) Decode-and-bounce (no drilling toward parts) → no false positive.
{
const t = tags(mkSession({}), [ev("vin_decode_succeeded")]);
assert("bounce: no parts_render_blocked", !t.includes("parts_render_blocked"));
}
// 13) Locked brand → upgrade prompt: paywall, not a defect.
{
const t = tags(mkSession({}), [
ev("vin_decode_succeeded"),
ev("catalog_model_clicked", { brand_name: "Mercedes" }),
ev("catalog_locked_brand_upgrade_clicked", { brand_name: "Mercedes" }),
]);
assert("locked: no parts_render_blocked (paywall, not bug)", !t.includes("parts_render_blocked"));
}
console.log(`\ntagger smoke: ${pass} passed, ${fail} failed`); console.log(`\ntagger smoke: ${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1); if (fail > 0) process.exit(1);

View File

@@ -47,6 +47,58 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
const userProps = ctx?.userProperties ?? {}; const userProps = ctx?.userProperties ?? {};
const groupProps = ctx?.groupProperties ?? null; const groupProps = ctx?.groupProperties ?? null;
// ─── Parts / category render signals (Sase.tr core-value path) ───
// The product's whole job: resolve a vehicle (VIN or catalog) → list its
// categories + parts. When that final step yields *nothing* the product has
// failed for the user even though VIN decode "succeeded" and no JS error/5xx
// fired — the catalog returned an empty panel, 0 models, or the user hit the
// explicit empty-state CTA. This used to be invisible (the catalog_* family
// wasn't even fetched), so a real prospect could browse Ford/Opel, see no
// parts, and churn with no insight raised. Scoped to the real /dashboard
// product: /demo is a separate curated marketing surface whose (sometimes
// working) categories must NOT mask a genuine in-product failure.
const inDemo = (e: CanonicalEvent): boolean =>
/\/demo(\/|\?|$)/.test(
String(e.properties["$current_url"] ?? e.properties["$pathname"] ?? ""),
);
const realPartsViews = events.filter((e) => e.name === "parts_panel_viewed" && !inDemo(e));
const successfulPartsView = realPartsViews.some((e) => Number(e.properties.parts_count) > 0);
const emptyPartsView = realPartsViews.some((e) => Number(e.properties.parts_count) === 0);
const emptyModelsList = events.some(
(e) => e.name === "catalog_models_viewed" && !inDemo(e) && Number(e.properties.count) === 0,
);
const explicitEmptyCatalog = events.some(
(e) => e.name === "empty_catalog_cta_clicked" && !inDemo(e),
);
const realOemCopied = events.some((e) => e.name === "oem_code_copied" && !inDemo(e));
// Shape 1 — "the system said: nothing here": panel rendered empty (and never
// non-empty in this session), 0 models listed, or the empty-state CTA shown.
const catalogEmptyResult =
explicitEmptyCatalog || emptyModelsList || (emptyPartsView && !successfulPartsView);
// Shape 2 — "drilled in and got nothing at all": resolved a vehicle and
// actively browsed categories/models but never saw a single part. Gated on
// real engagement (not a decode-and-bounce) and excludes the paywall case
// (locked brand → upgrade prompt, which is a conversion signal, not a defect).
const resolvedVehicle =
has(events, "vin_decode_succeeded") ||
has(events, "vin_decode_candidate_selected") ||
has(events, "catalog_model_clicked");
const browsedForParts =
count(events, "category_view_changed") >= 2 ||
has(events, "catalog_model_clicked") ||
has(events, "catalog_subcatalog_selected");
const partsRenderBlocked =
resolvedVehicle &&
browsedForParts &&
!successfulPartsView &&
!realOemCopied &&
!has(events, "catalog_locked_brand_upgrade_clicked");
// Any genuine parts/category failure disqualifies the search-misuse guard
// below — such a session is a real product defect, never "fiddling with the
// search box, nothing broken".
const partsFailure = catalogEmptyResult || partsRenderBlocked;
// ─── Expected search-box misuse → not insight-worthy ─── // ─── Expected search-box misuse → not insight-worthy ───
// The /dashboard/search box is VIN-only, but users routinely use it to look // The /dashboard/search box is VIN-only, but users routinely use it to look
// for a part by *name* (e.g. "Cam düğme") or fiddle with search/history and // for a part by *name* (e.g. "Cam düğme") or fiddle with search/history and
@@ -61,6 +113,7 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
const searchAffordanceMisuse = const searchAffordanceMisuse =
s.errorCount === 0 && s.errorCount === 0 &&
s.network5xxCount === 0 && s.network5xxCount === 0 &&
!partsFailure &&
has(events, "search_input_focused") && has(events, "search_input_focused") &&
!has(events, "vin_decode_failed") && !has(events, "vin_decode_failed") &&
count(events, "search_input_validation_failed") < 3 && count(events, "search_input_validation_failed") < 3 &&
@@ -200,6 +253,19 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
severity = bump(severity, "P3"); severity = bump(severity, "P3");
} }
// ─── Parts / category render failure (signals computed at top) ───
// Core-value failure: the user got a vehicle but no parts/categories. This is
// the highest-intent churn signal Sase.tr has — surface it as a concrete bug,
// not generic friction.
if (catalogEmptyResult) {
tags.push("catalog_empty_result");
severity = bump(severity, "P2");
}
if (partsRenderBlocked) {
tags.push("parts_render_blocked");
severity = bump(severity, "P1");
}
// ─── Search friction ─── // ─── Search friction ───
if (count(events, "search_input_validation_failed") >= 3) { if (count(events, "search_input_validation_failed") >= 3) {
tags.push("search_validation_friction"); tags.push("search_validation_friction");

View File

@@ -72,6 +72,55 @@ export function alertP0Insight(opts: {
return sendTelegram({ text, dedupeKey: `p0:${opts.insightId}` }); return sendTelegram({ text, dedupeKey: `p0:${opts.insightId}` });
} }
export function alertCatalogGap(opts: {
brand: string;
failures: number;
uniqueUsers: number;
windowDays: number;
severity: string;
insightId: string;
panelUrl: string;
day: string;
regressed?: boolean;
}): Promise<TelegramSendResult> {
const head = opts.regressed
? `↩️ <b>Katalog açığı GERİ DÖNDÜ</b> [${opts.severity}]`
: `🗂️ <b>Katalog kapsama açığı</b> [${opts.severity}]`;
const text = [
head,
`<b>${escapeHtml(opts.brand)}</b>: ${opts.failures} başarısız sorgu / ${opts.uniqueUsers} kullanıcı (son ${opts.windowDays}g)`,
`Araç tanınıyor ama parça kataloğu yok → kullanıcı parça göremiyor.`,
``,
`<a href="${opts.panelUrl}/insights/i/${opts.insightId}">Insight</a> · <a href="${opts.panelUrl}/projects/sase/vin-decode">VIN dashboard</a>`,
].join("\n");
return sendTelegram({
text,
dedupeKey: `catalog_gap:${opts.regressed ? "regress" : "new"}:${opts.brand}:${opts.day}`,
});
}
export function alertEveningBrief(opts: {
briefId: string;
headline: string;
summary: string;
critical: string[];
healthScore: number;
date: string;
panelUrl: string;
}): Promise<TelegramSendResult> {
const dot = opts.healthScore >= 70 ? "🟢" : opts.healthScore >= 40 ? "🟡" : "🔴";
const lines = [`${dot} <b>Sase · Akşam Brief</b> · <i>${opts.date}</i> · skor ${opts.healthScore}/100`];
if (opts.headline) lines.push(`<b>${escapeHtml(opts.headline)}</b>`);
if (opts.summary) lines.push(escapeHtml(opts.summary));
if (opts.critical.length) {
lines.push("", "<b>Kritik noktalar</b>");
for (const c of opts.critical) lines.push(`${escapeHtml(c)}`);
}
lines.push("", `<a href="${opts.panelUrl}/projects/sase/briefs/${opts.briefId}">Tam brief →</a>`);
// No dedupeKey — fire once per evening run regardless of content similarity.
return sendTelegram({ text: lines.join("\n") });
}
export function alertRegression(opts: { export function alertRegression(opts: {
insightId: string; insightId: string;
title: string; title: string;

View File

@@ -10,6 +10,8 @@ import { runRetention } from "../jobs/retention";
import { runEvalSet } from "../jobs/eval-run"; import { runEvalSet } from "../jobs/eval-run";
import { runDailyBrief } from "../jobs/daily-brief"; import { runDailyBrief } from "../jobs/daily-brief";
import { runVinAnomalyDetect } from "../jobs/vin-anomaly"; import { runVinAnomalyDetect } from "../jobs/vin-anomaly";
import { runCatalogGapDetect } from "../jobs/catalog-gap-detect";
import { runSaseEveningBrief } from "../jobs/sase-evening-brief";
import { runArchivePosthogEvents } from "../jobs/posthog-event-archive"; import { runArchivePosthogEvents } from "../jobs/posthog-event-archive";
import { runArchiveRecordings } from "../jobs/archive-recordings"; import { runArchiveRecordings } from "../jobs/archive-recordings";
import { runArchiveIdentity } from "../jobs/posthog-identity-archive"; import { runArchiveIdentity } from "../jobs/posthog-identity-archive";
@@ -91,6 +93,20 @@ async function runJob(job: Job) {
} }
return res; return res;
} }
case "catalog-gap-detect": {
const res = await runCatalogGapDetect();
if (res.created > 0 || res.updated > 0 || !res.ok) {
console.log(
`[pipeline] catalog-gap gaps=${res.gaps} created=${res.created} updated=${res.updated} alerts=${res.alertsFired}${res.reason ? ` reason=${res.reason}` : ""}`,
);
}
return res;
}
case "sase-evening-brief": {
const res = await runSaseEveningBrief();
console.log(`[pipeline] evening-brief ok=${res.ok} brief=${res.briefId ?? "-"} sent=${res.sent}${res.reason ? ` reason=${res.reason}` : ""}`);
return res;
}
case "posthog-event-archive": { case "posthog-event-archive": {
const res = await runArchivePosthogEvents(); const res = await runArchivePosthogEvents();
if (res.fetched > 0 || res.dumped > 0 || res.error) { if (res.fetched > 0 || res.dumped > 0 || res.error) {
@@ -181,6 +197,16 @@ export async function startInsightPipeline() {
{ pattern: "*/5 * * * *" }, { pattern: "*/5 * * * *" },
{ name: "vin-anomaly-detect", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } }, { name: "vin-anomaly-detect", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
); );
await queue.upsertJobScheduler(
"catalog-gap-detect",
{ pattern: "30 */6 * * *" },
{ name: "catalog-gap-detect", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
);
await queue.upsertJobScheduler(
"sase-evening-brief",
{ pattern: "0 17 * * *" }, // 20:00 Europe/Istanbul = 17:00 UTC
{ name: "sase-evening-brief", data: {}, opts: { removeOnComplete: 14, removeOnFail: 14 } },
);
await queue.upsertJobScheduler( await queue.upsertJobScheduler(
"posthog-event-archive", "posthog-event-archive",
{ pattern: "*/15 * * * *" }, { pattern: "*/15 * * * *" },
@@ -209,7 +235,7 @@ export async function startInsightPipeline() {
stalledInterval: 60_000, stalledInterval: 60_000,
}); });
console.log( console.log(
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15, daily-brief@05:00, vin-anomaly-detect@*/5min, posthog-event-archive@*/15min, archive-recordings@*/6h, posthog-identity-archive@03:00, sentry-archive@hourly", "[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15, daily-brief@05:00, vin-anomaly-detect@*/5min, catalog-gap-detect@*/6h, sase-evening-brief@17:00UTC, posthog-event-archive@*/15min, archive-recordings@*/6h, posthog-identity-archive@03:00, sentry-archive@hourly",
); );
return worker; return worker;
} }