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

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

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

@@ -99,6 +99,28 @@ export function alertCatalogGap(opts: {
});
}
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: {
insightId: string;
title: string;

View File

@@ -11,6 +11,7 @@ import { runEvalSet } from "../jobs/eval-run";
import { runDailyBrief } from "../jobs/daily-brief";
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 { runArchiveRecordings } from "../jobs/archive-recordings";
import { runArchiveIdentity } from "../jobs/posthog-identity-archive";
@@ -101,6 +102,11 @@ async function runJob(job: Job) {
}
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": {
const res = await runArchivePosthogEvents();
if (res.fetched > 0 || res.dumped > 0 || res.error) {
@@ -196,6 +202,11 @@ export async function startInsightPipeline() {
{ 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(
"posthog-event-archive",
{ pattern: "*/15 * * * *" },
@@ -224,7 +235,7 @@ export async function startInsightPipeline() {
stalledInterval: 60_000,
});
console.log(
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15, daily-brief@05:00, vin-anomaly-detect@*/5min, catalog-gap-detect@*/6h, 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;
}