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:
@@ -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?
|
||||
|
||||
29
apps/web/src/app/api/internal/sase-brief-data/route.ts
Normal file
29
apps/web/src/app/api/internal/sase-brief-data/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
120
apps/web/src/app/projects/sase/briefs/[id]/page.tsx
Normal file
120
apps/web/src/app/projects/sase/briefs/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
apps/web/src/app/projects/sase/briefs/page.tsx
Normal file
94
apps/web/src/app/projects/sase/briefs/page.tsx
Normal 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'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'de (17:00 UTC) üretilecek ve Telegram'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>
|
||||
);
|
||||
}
|
||||
77
apps/web/src/lib/sase/brief-data.ts
Normal file
77
apps/web/src/lib/sase/brief-data.ts
Normal 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;
|
||||
}
|
||||
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