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

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