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:
188
apps/worker/src/jobs/sase-evening-brief.ts
Normal file
188
apps/worker/src/jobs/sase-evening-brief.ts
Normal 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 };
|
||||
}
|
||||
65
apps/worker/src/lib/meta.ts
Normal file
65
apps/worker/src/lib/meta.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user