feat(brief): Sase 360° evening brief — page + worker job + Telegram@20:00

Daily Sase brief aggregating VIN decode health, catalog coverage gaps,
revenue (Stripe/iyzico TRY), users/subscriptions, PostHog behavior + UTM
source, Meta ads (env-gated), and the insight pipeline into one
critical-points-first report.

- Brief model (panel DB): one row/project/day (date, headline, summary,
  health score, structured body, raw 360 metrics).
- Panel: getSaseBriefMetrics() (saseDb half) + /api/internal/sase-brief-data.
- Worker: sase-evening-brief job collects panel metrics + PostHog
  (hogqlQuery) + Meta (lib/meta, env-gated) + pipeline stats -> DeepSeek
  structured brief -> upsert Brief -> Telegram. Scheduled 17:00 UTC
  (20:00 Europe/Istanbul).
- UI: /projects/sase/briefs (table of days) + /briefs/[id] (detail:
  critical-first, sections by status, recommendations, raw metrics).

Meta dormant until META_ACCESS_TOKEN/META_AD_ACCOUNT_ID set on the worker.
Needs a `briefs` table migration on the panel DB before first run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-06-10 19:17:01 +03:00
parent 1d5daf2e5c
commit 01c93daf6a
10 changed files with 637 additions and 1 deletions

View File

@@ -292,6 +292,30 @@ model Insight {
@@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 {
id String @id @default(cuid())
insightId String?

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
</Link>
<Link
href="/projects/sase/briefs"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Briefs
</Link>
</div>
<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;
}