feat(content): Phase 8 content generation (Faz A+B) for Sase.tr

Hybrid content automation pilot: generation + review + drafts live in the
panel (reusing the insight pipeline's DeepSeek client, prompt_templates
versioning, cost_ledger and budget_settings); publishing/distribution will
go through n8n (Faz C, not built). Channels: blog, LinkedIn, X, Instagram.
Topic sourcing is automatic (LLM-generated ideas). Approval model: drafts
sit in the panel for manual review/edit/publish.

Faz A (worker):
- ContentTopic / ContentDraft Prisma models (content_topics, content_drafts)
- content-prompts.ts: 5 seed prompts (topic ideas[pro] + blog[pro] +
  linkedin/x/instagram[flash]), Turkish B2B automotive tone, per-channel
  JSON schemas
- content-budget.ts: separate budget envelope (sums only content_* spend)
- content-topics job (auto idea gen, backlog-capped, title dedupe) +
  content-generate job (queued topic -> one draft per channel)
- content-pipeline scheduler (separate BullMQ queue, topics@*/8h,
  generate@*/10min), wired into index.ts; seeded via seed-runtime
- content budget settings (caps + content_paused kill switch); seed default
  content_paused=true for a safe first deploy

Faz B (web):
- /content (queue + auto/manual triggers + manual topic form),
  /content/t/[id] (per-channel draft cards: preview, JSON edits,
  approve/reject), /content/costs (content-only spend)
- server actions (audit-logged), manual trigger API routes, contentQueue(),
  nav + Cmd+K entries
- content caps surfaced on /insights/settings/budgets + whitelisted

Both packages typecheck. Schema applies on deploy (web start runs
prisma db push).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-24 00:11:05 +03:00
parent 565ad5af59
commit fdce3f6bd0
22 changed files with 1856 additions and 1 deletions

View File

@@ -364,6 +364,71 @@ model EvalRun {
@@map("eval_runs")
}
// ---------- Phase 8a: Content generation ----------
// A topic/brief for content generation. Either auto-generated by the
// content-topics job (LLM idea generation) or entered manually. One topic
// fans out into one ContentDraft per selected channel.
model ContentTopic {
id String @id @default(cuid())
projectKey String
title String
brief String @db.Text
angle String? @db.Text
channels String[] // ["blog","linkedin","x","instagram"]
keywords String[]
/// queued | generating | drafted | archived
status String @default("queued")
/// auto | manual
source String @default("auto")
fingerprint String? // dedupe near-identical auto topics
sourcePromptTag String?
sourcePromptVersion Int?
sourceModel String?
sourceCostUsd Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
drafts ContentDraft[]
@@index([projectKey, status])
@@index([fingerprint])
@@map("content_topics")
}
// One generated piece of content for a single channel. Lives as a draft in
// the panel; founder reviews/edits, then publishes via the n8n webhook
// (Phase 8c). bodyJson holds the channel-specific generated structure;
// founderEdits holds any human overrides applied before publish.
model ContentDraft {
id String @id @default(cuid())
topicId String
projectKey String
/// blog | linkedin | x | instagram
channel String
/// draft | approved | publishing | published | failed | rejected
status String @default("draft")
bodyJson Json
founderEdits Json?
publishedUrl String?
n8nExecutionId String?
publishError String?
sourcePromptTag String
sourcePromptVersion Int
sourceModel String
sourceCostUsd Float @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
topic ContentTopic @relation(fields: [topicId], references: [id], onDelete: Cascade)
@@index([projectKey, status])
@@index([topicId])
@@index([channel, status])
@@map("content_drafts")
}
// ---------- Phase 7a: Sase user-management ----------
// Founder-only notes pinned to a Sase user. Stored panel-side (KVKK minimize:

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { contentQueue } from "@/lib/queue";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
// Manually trigger one content-generate run (queued topics → channel drafts).
// Also scheduled every 10min; this is for on-demand kicks after queueing topics.
export async function POST() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
const job = await contentQueue().add(
"content-generate",
{},
{ removeOnComplete: 50, removeOnFail: 25 },
);
await writeAudit({
projectKey: "sase",
endpoint: "/api/content/generate",
method: "POST",
responseStatus: 200,
});
return NextResponse.json({ ok: true, jobId: job.id });
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { contentQueue } from "@/lib/queue";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
// Manually trigger one content-topics run (auto topic-idea generation).
// The job itself is also scheduled (every 8h) — this is for on-demand kicks.
export async function POST() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
const job = await contentQueue().add(
"content-topics",
{},
{ removeOnComplete: 30, removeOnFail: 15 },
);
await writeAudit({
projectKey: "sase",
endpoint: "/api/content/topics/generate",
method: "POST",
responseStatus: 200,
});
return NextResponse.json({ ok: true, jobId: job.id });
}

View File

@@ -0,0 +1,151 @@
"use server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
import { writeAudit } from "@/lib/audit";
import { contentQueue } from "@/lib/queue";
const PROJECT_KEY = "sase";
const VALID_CHANNELS = ["blog", "linkedin", "x", "instagram"];
// Draft lifecycle reachable from the UI. publishing/published/failed are set
// by the publish action + n8n callback (Phase 8c), not directly here.
const DRAFT_STATUS = new Set(["draft", "approved", "rejected"]);
async function requireSession() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("unauthenticated");
return session;
}
export async function createTopic(input: {
title: string;
brief: string;
angle?: string;
channels: string[];
keywords: string[];
}) {
await requireSession();
const title = input.title.trim().slice(0, 250);
if (!title) throw new Error("title required");
const channels = input.channels.filter((c) => VALID_CHANNELS.includes(c));
if (channels.length === 0) throw new Error("pick at least one channel");
const topic = await prisma.contentTopic.create({
data: {
projectKey: PROJECT_KEY,
title,
brief: input.brief.trim().slice(0, 2000),
angle: input.angle?.trim().slice(0, 1000) || null,
channels,
keywords: input.keywords.map((k) => k.trim()).filter(Boolean).slice(0, 12),
status: "queued",
source: "manual",
},
});
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: "/content/topics",
method: "POST",
requestPayload: { title, channels },
responseStatus: 200,
});
revalidatePath("/content");
return topic.id;
}
export async function archiveTopic(topicId: string) {
await requireSession();
await prisma.contentTopic.update({ where: { id: topicId }, data: { status: "archived" } });
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: `/content/topics/${topicId}/archive`,
method: "POST",
responseStatus: 200,
});
revalidatePath("/content");
revalidatePath(`/content/t/${topicId}`);
}
export async function requeueTopic(topicId: string) {
await requireSession();
await prisma.contentTopic.update({ where: { id: topicId }, data: { status: "queued" } });
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: `/content/topics/${topicId}/requeue`,
method: "POST",
responseStatus: 200,
});
revalidatePath("/content");
revalidatePath(`/content/t/${topicId}`);
}
export async function enqueueTopicGeneration() {
await requireSession();
const job = await contentQueue().add("content-topics", {}, { removeOnComplete: 30, removeOnFail: 15 });
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: "/content/topics/generate",
method: "POST",
responseStatus: 200,
});
revalidatePath("/content");
return job.id;
}
export async function enqueueContentGeneration() {
await requireSession();
const job = await contentQueue().add("content-generate", {}, { removeOnComplete: 50, removeOnFail: 25 });
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: "/content/generate",
method: "POST",
responseStatus: 200,
});
revalidatePath("/content");
return job.id;
}
export async function saveDraftEdits(draftId: string, editsJson: string) {
await requireSession();
let parsed: unknown = null;
const trimmed = editsJson.trim();
if (trimmed) {
try {
parsed = JSON.parse(trimmed);
} catch (e) {
throw new Error(`invalid JSON: ${(e as Error).message}`);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("edits must be a JSON object");
}
}
const draft = await prisma.contentDraft.update({
where: { id: draftId },
data: { founderEdits: parsed === null ? undefined : (parsed as object) },
});
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: `/content/drafts/${draftId}/edits`,
method: "POST",
requestPayload: { length: trimmed.length },
responseStatus: 200,
});
revalidatePath(`/content/t/${draft.topicId}`);
}
export async function setDraftStatus(draftId: string, status: string) {
await requireSession();
if (!DRAFT_STATUS.has(status)) throw new Error(`bad status: ${status}`);
const draft = await prisma.contentDraft.update({ where: { id: draftId }, data: { status } });
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: `/content/drafts/${draftId}/status`,
method: "POST",
requestPayload: { status },
responseStatus: 200,
});
revalidatePath(`/content/t/${draft.topicId}`);
revalidatePath("/content");
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
createTopic,
enqueueTopicGeneration,
enqueueContentGeneration,
} from "./_actions";
const CHANNELS = [
{ value: "blog", label: "Blog" },
{ value: "linkedin", label: "LinkedIn" },
{ value: "x", label: "X" },
{ value: "instagram", label: "Instagram" },
];
export function ContentControls({ queuedCount }: { queuedCount: number }) {
const router = useRouter();
const [pending, start] = useTransition();
const [flash, setFlash] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
// manual topic form state
const [title, setTitle] = useState("");
const [brief, setBrief] = useState("");
const [angle, setAngle] = useState("");
const [keywords, setKeywords] = useState("");
const [channels, setChannels] = useState<string[]>(["blog", "linkedin", "x", "instagram"]);
const fire = (fn: () => Promise<unknown>, label: string) =>
start(async () => {
try {
await fn();
setFlash(label);
setTimeout(() => setFlash(null), 2500);
router.refresh();
} catch (e) {
setFlash(`hata: ${(e as Error).message}`);
}
});
const toggleChannel = (c: string) =>
setChannels((prev) => (prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]));
const submitTopic = () =>
fire(async () => {
await createTopic({
title,
brief,
angle,
channels,
keywords: keywords.split(",").map((k) => k.trim()).filter(Boolean),
});
setTitle("");
setBrief("");
setAngle("");
setKeywords("");
setShowForm(false);
}, "konu eklendi");
return (
<div className="space-y-3 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() => fire(() => enqueueTopicGeneration(), "konu üretimi kuyruğa alındı")}
>
Otomatik konu üret
</Button>
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() => fire(() => enqueueContentGeneration(), "üretim kuyruğa alındı")}
>
Taslakları üret ({queuedCount} kuyrukta)
</Button>
<Button size="sm" variant={showForm ? "default" : "outline"} onClick={() => setShowForm((v) => !v)}>
{showForm ? "Formu kapat" : "Elle konu ekle"}
</Button>
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
</div>
{showForm && (
<div className="space-y-2 border-t pt-3">
<input
className="w-full rounded border bg-background px-2 py-1 text-sm"
placeholder="Başlık"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className="w-full rounded border bg-background p-2 text-sm"
rows={2}
placeholder="Brief — içerik ne anlatacak?"
value={brief}
onChange={(e) => setBrief(e.target.value)}
/>
<input
className="w-full rounded border bg-background px-2 py-1 text-sm"
placeholder="Açı (opsiyonel)"
value={angle}
onChange={(e) => setAngle(e.target.value)}
/>
<input
className="w-full rounded border bg-background px-2 py-1 text-sm"
placeholder="Anahtar kelimeler (virgülle)"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
/>
<div className="flex flex-wrap items-center gap-3">
{CHANNELS.map((c) => (
<label key={c.value} className="flex items-center gap-1 text-xs">
<input
type="checkbox"
checked={channels.includes(c.value)}
onChange={() => toggleChannel(c.value)}
/>
{c.label}
</label>
))}
<Button size="sm" disabled={pending || !title.trim()} onClick={submitTopic}>
Ekle
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { PanelShell } from "@/components/panel-shell";
import { prisma } from "@/lib/db";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export const dynamic = "force-dynamic";
const CONTENT_FILTER = { promptTag: { startsWith: "content_" } };
function startOfDayUtc(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
function startOfMonthUtc(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
}
export default async function ContentCostDashboard() {
const now = new Date();
const [todayAgg, monthAgg, byPrompt, recentErrors, budgetSettings, draftCount, topicCount] =
await Promise.all([
prisma.costLedger.aggregate({
where: { ...CONTENT_FILTER, createdAt: { gte: startOfDayUtc(now) } },
_sum: { costTotalUsd: true },
_count: true,
}),
prisma.costLedger.aggregate({
where: { ...CONTENT_FILTER, createdAt: { gte: startOfMonthUtc(now) } },
_sum: { costTotalUsd: true },
_count: true,
}),
prisma.costLedger.groupBy({
by: ["promptTag"],
where: { ...CONTENT_FILTER, createdAt: { gte: startOfMonthUtc(now) } },
_sum: { costTotalUsd: true },
_count: true,
}),
prisma.costLedger.findMany({
where: { ...CONTENT_FILTER, errorCode: { not: null } },
orderBy: { createdAt: "desc" },
take: 10,
}),
prisma.budgetSetting.findMany({ where: { projectKey: null } }),
prisma.contentDraft.count({ where: { projectKey: "sase" } }),
prisma.contentTopic.count({ where: { projectKey: "sase" } }),
]);
const limits: Record<string, number> = {};
for (const b of budgetSettings) {
if (typeof b.settingValue === "number") limits[b.settingKey] = b.settingValue;
}
const monthlyCap = limits.content_monthly_hard_cap_usd ?? 15;
const dailyHardCap = limits.content_daily_hard_cap_usd ?? 2;
const today = Number(todayAgg._sum.costTotalUsd ?? 0);
const month = Number(monthAgg._sum.costTotalUsd ?? 0);
const monthCalls = Number(monthAgg._count ?? 0);
const avgPerCall = monthCalls > 0 ? month / monthCalls : 0;
const todayPct = Math.min(100, (today / dailyHardCap) * 100);
const monthPct = Math.min(100, (month / monthlyCap) * 100);
return (
<PanelShell title="Content · maliyet">
<p className="text-sm text-muted-foreground">
İçerik üretimi DeepSeek harcaması (insight bütçesinden ayrı).{" "}
<a href="/content" className="underline">Kuyruk</a> ·{" "}
<a href="/insights/settings/budgets" className="underline">Bütçe ayarları</a>
</p>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Bugün</CardDescription>
<CardTitle className="text-2xl">${today.toFixed(4)}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
{todayPct.toFixed(0)}% / günlük sert sınır ${dailyHardCap}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Bu ay</CardDescription>
<CardTitle className="text-2xl">${month.toFixed(2)}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
{monthPct.toFixed(0)}% / aylık sınır ${monthlyCap}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Çağrı başı ort. (ay)</CardDescription>
<CardTitle className="text-2xl">${avgPerCall.toFixed(4)}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">{monthCalls} çağrı</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Üretilen</CardDescription>
<CardTitle className="text-2xl">{draftCount}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">{topicCount} konu · taslak</CardContent>
</Card>
</div>
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Prompt başına (bu ay)</h2>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Prompt</TableHead>
<TableHead className="w-[120px]">Çağrı</TableHead>
<TableHead className="w-[120px]">Maliyet</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{byPrompt.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-xs text-muted-foreground">
Henüz içerik harcaması yok.
</TableCell>
</TableRow>
) : (
byPrompt.map((p) => (
<TableRow key={p.promptTag ?? "—"}>
<TableCell className="text-xs">{p.promptTag ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{p._count}</TableCell>
<TableCell className="font-mono text-xs">${Number(p._sum.costTotalUsd ?? 0).toFixed(4)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{recentErrors.length > 0 && (
<>
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Son hatalar</h2>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Zaman</TableHead>
<TableHead>Model</TableHead>
<TableHead>Prompt</TableHead>
<TableHead>Hata</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentErrors.map((r) => (
<TableRow key={r.id}>
<TableCell className="text-xs text-muted-foreground">
{r.createdAt.toISOString().slice(0, 19).replace("T", " ")}
</TableCell>
<TableCell className="text-xs">{r.model}</TableCell>
<TableCell className="text-xs">{r.promptTag ?? "—"}</TableCell>
<TableCell><Badge variant="destructive">{r.errorCode}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</PanelShell>
);
}

View File

@@ -0,0 +1,113 @@
import { PanelShell } from "@/components/panel-shell";
import { prisma } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ContentControls } from "./_controls";
export const dynamic = "force-dynamic";
const PROJECT_KEY = "sase";
function statusVariant(status: string): "default" | "secondary" | "outline" | "destructive" {
switch (status) {
case "drafted":
return "default";
case "generating":
return "secondary";
case "archived":
return "destructive";
default:
return "outline";
}
}
export default async function ContentInbox() {
const [topics, paused, queuedCount] = await Promise.all([
prisma.contentTopic.findMany({
where: { projectKey: PROJECT_KEY, status: { not: "archived" } },
orderBy: { createdAt: "desc" },
take: 100,
include: { drafts: { select: { status: true, channel: true } } },
}),
prisma.budgetSetting.findFirst({ where: { projectKey: null, settingKey: "content_paused" } }),
prisma.contentTopic.count({
where: { projectKey: PROJECT_KEY, status: { in: ["queued", "generating"] } },
}),
]);
const isPaused = paused?.settingValue === true;
return (
<PanelShell title="Content · konu kuyruğu">
<p className="text-sm text-muted-foreground">
Sase.tr için otomatik içerik üretimi. Konular LLM ile üretilir, kanal taslakları panelde birikir, sen
incele/düzenle/onayla.{" "}
<a href="/content/costs" className="underline">Maliyet</a>
{isPaused && (
<>
{" · "}
<Badge variant="destructive">content_paused</Badge>
</>
)}
</p>
<ContentControls queuedCount={queuedCount} />
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Konu</TableHead>
<TableHead className="w-[110px]">Durum</TableHead>
<TableHead className="w-[90px]">Kaynak</TableHead>
<TableHead className="w-[160px]">Kanallar</TableHead>
<TableHead className="w-[90px]">Taslak</TableHead>
<TableHead className="w-[110px]">Oluşturma</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topics.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-xs text-muted-foreground">
Henüz konu yok. Yukarıdan otomatik üret ya da elle ekle.
</TableCell>
</TableRow>
) : (
topics.map((t) => {
const approved = t.drafts.filter((d) => d.status === "approved").length;
return (
<TableRow key={t.id}>
<TableCell>
<a href={`/content/t/${t.id}`} className="font-medium underline-offset-2 hover:underline">
{t.title}
</a>
</TableCell>
<TableCell>
<Badge variant={statusVariant(t.status)}>{t.status}</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{t.source}</TableCell>
<TableCell className="text-xs">{(t.channels as string[]).join(", ")}</TableCell>
<TableCell className="font-mono text-xs">
{t.drafts.length}
{approved > 0 && <span className="text-muted-foreground"> ({approved})</span>}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{t.createdAt.toISOString().slice(0, 10)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</PanelShell>
);
}

View File

@@ -0,0 +1,191 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { saveDraftEdits, setDraftStatus } from "../../_actions";
type Json = unknown;
type Props = {
draftId: string;
channel: string;
status: string;
bodyJson: Json;
founderEdits: Json;
model: string | null;
costUsd: number;
publishedUrl: string | null;
publishError: string | null;
};
function asRecord(v: Json): Record<string, unknown> {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
}
function str(v: unknown): string {
return typeof v === "string" ? v : v == null ? "" : String(v);
}
function arr(v: unknown): string[] {
return Array.isArray(v) ? v.map(str) : [];
}
function ChannelPreview({ channel, content }: { channel: string; content: Record<string, unknown> }) {
if (channel === "blog") {
return (
<div className="space-y-2">
<div className="text-base font-semibold">{str(content.title)}</div>
<div className="text-xs text-muted-foreground">/{str(content.slug)}</div>
<div className="text-xs italic text-muted-foreground">{str(content.meta_description)}</div>
<pre className="whitespace-pre-wrap rounded bg-muted/40 p-2 text-sm">{str(content.body_markdown)}</pre>
{content.cta != null && <div className="text-sm">CTA: {str(content.cta)}</div>}
<div className="flex flex-wrap gap-1">
{arr(content.tags).map((t) => (
<Badge key={t} variant="outline" className="text-xs">{t}</Badge>
))}
</div>
</div>
);
}
if (channel === "x") {
const tweets = arr(content.tweets);
return (
<div className="space-y-2">
{tweets.map((t, i) => (
<div key={i} className="rounded border p-2 text-sm">
<span className="mr-2 text-xs text-muted-foreground">{i + 1}/{tweets.length}</span>
{t}
<span className="ml-2 text-xs text-muted-foreground">({t.length})</span>
</div>
))}
<div className="flex flex-wrap gap-1">
{arr(content.hashtags).map((h) => (
<Badge key={h} variant="outline" className="text-xs">{h}</Badge>
))}
</div>
</div>
);
}
// linkedin / instagram — body or caption + hashtags
const text = channel === "instagram" ? str(content.caption) : str(content.body);
return (
<div className="space-y-2">
<pre className="whitespace-pre-wrap rounded bg-muted/40 p-2 text-sm">{text}</pre>
{content.cta != null && <div className="text-sm">CTA: {str(content.cta)}</div>}
{content.image_prompt != null && (
<div className="text-xs text-muted-foreground">image_prompt: {str(content.image_prompt)}</div>
)}
<div className="flex flex-wrap gap-1">
{arr(content.hashtags).map((h) => (
<Badge key={h} variant="outline" className="text-xs">{h}</Badge>
))}
</div>
</div>
);
}
const STATUS_VARIANT: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
approved: "default",
draft: "outline",
rejected: "destructive",
published: "default",
publishing: "secondary",
failed: "destructive",
};
export function DraftCard(props: Props) {
const router = useRouter();
const [pending, start] = useTransition();
const [flash, setFlash] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [editsText, setEditsText] = useState(
props.founderEdits ? JSON.stringify(props.founderEdits, null, 2) : "",
);
const base = asRecord(props.bodyJson);
const edits = asRecord(props.founderEdits);
const effective = { ...base, ...edits };
const locked = ["published", "publishing"].includes(props.status);
const fire = (fn: () => Promise<unknown>, label: string) =>
start(async () => {
try {
await fn();
setFlash(label);
setTimeout(() => setFlash(null), 2500);
router.refresh();
} catch (e) {
setFlash(`hata: ${(e as Error).message}`);
}
});
return (
<div className="space-y-3 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-medium uppercase">{props.channel}</span>
<Badge variant={STATUS_VARIANT[props.status] ?? "outline"}>{props.status}</Badge>
{Object.keys(edits).length > 0 && <Badge variant="secondary" className="text-xs">düzenlendi</Badge>}
<span className="ml-auto text-xs text-muted-foreground">
{props.model ?? "—"} · ${props.costUsd.toFixed(4)}
</span>
</div>
{props.publishedUrl && (
<div className="text-xs">
Yayınlandı: <a href={props.publishedUrl} className="underline" target="_blank" rel="noreferrer">{props.publishedUrl}</a>
</div>
)}
{props.publishError && <div className="text-xs text-destructive">Yayın hatası: {props.publishError}</div>}
<ChannelPreview channel={props.channel} content={effective} />
{editing && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
founder edits (JSON object sadece değiştirmek istediğin alanları yaz, üzerine biner)
</div>
<textarea
className="w-full rounded border bg-background p-2 font-mono text-xs"
rows={8}
value={editsText}
onChange={(e) => setEditsText(e.target.value)}
placeholder={'{\n "title": "..."\n}'}
/>
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => saveDraftEdits(props.draftId, editsText), "düzenleme kaydedildi")}>
Düzenlemeyi kaydet
</Button>
</div>
)}
{!locked && (
<div className="flex flex-wrap items-center gap-2 border-t pt-2">
<Button size="sm" variant="outline" onClick={() => setEditing((v) => !v)}>
{editing ? "Düzenlemeyi gizle" : "Düzenle"}
</Button>
<Button
size="sm"
disabled={pending || props.status === "approved"}
onClick={() => fire(() => setDraftStatus(props.draftId, "approved"), "onaylandı")}
>
Onayla
</Button>
{props.status !== "draft" && (
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => setDraftStatus(props.draftId, "draft"), "taslağa alındı")}>
Taslağa al
</Button>
)}
<Button
size="sm"
variant="destructive"
disabled={pending || props.status === "rejected"}
onClick={() => fire(() => setDraftStatus(props.draftId, "rejected"), "reddedildi")}
>
Reddet
</Button>
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { archiveTopic, requeueTopic, enqueueContentGeneration } from "../../_actions";
export function TopicActions({ topicId, status }: { topicId: string; status: string }) {
const router = useRouter();
const [pending, start] = useTransition();
const [flash, setFlash] = useState<string | null>(null);
const fire = (fn: () => Promise<unknown>, label: string) =>
start(async () => {
try {
await fn();
setFlash(label);
setTimeout(() => setFlash(null), 2500);
router.refresh();
} catch (e) {
setFlash(`hata: ${(e as Error).message}`);
}
});
return (
<div className="flex flex-wrap items-center gap-2 pt-1">
{status !== "queued" && (
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => requeueTopic(topicId), "kuyruğa alındı")}>
Yeniden kuyruğa al
</Button>
)}
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => enqueueContentGeneration(), "üretim kuyruğa alındı")}>
Taslakları üret
</Button>
{status !== "archived" && (
<Button size="sm" variant="destructive" disabled={pending} onClick={() => fire(() => archiveTopic(topicId), "arşivlendi")}>
Arşivle
</Button>
)}
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { notFound } from "next/navigation";
import { PanelShell } from "@/components/panel-shell";
import { prisma } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { DraftCard } from "./_draft-card";
import { TopicActions } from "./_topic-actions";
export const dynamic = "force-dynamic";
const CHANNEL_ORDER = ["blog", "linkedin", "x", "instagram"];
export default async function TopicDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const topic = await prisma.contentTopic.findUnique({
where: { id },
include: { drafts: true },
});
if (!topic) notFound();
const drafts = [...topic.drafts].sort(
(a, b) => CHANNEL_ORDER.indexOf(a.channel) - CHANNEL_ORDER.indexOf(b.channel),
);
const totalCost = topic.drafts.reduce((s, d) => s + d.sourceCostUsd, topic.sourceCostUsd);
return (
<PanelShell title="Content · konu">
<p className="text-sm text-muted-foreground">
<a href="/content" className="underline"> Kuyruk</a>
</p>
<div className="space-y-2 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold">{topic.title}</h1>
<Badge variant="outline">{topic.status}</Badge>
<Badge variant="secondary">{topic.source}</Badge>
</div>
{topic.brief && <p className="text-sm">{topic.brief}</p>}
{topic.angle && <p className="text-xs text-muted-foreground">ı: {topic.angle}</p>}
<div className="flex flex-wrap gap-1">
{(topic.keywords as string[]).map((k) => (
<Badge key={k} variant="outline" className="text-xs">{k}</Badge>
))}
</div>
<div className="text-xs text-muted-foreground">
Kanallar: {(topic.channels as string[]).join(", ")} · Maliyet: ${totalCost.toFixed(4)} ·{" "}
{topic.sourceModel ?? "—"}
</div>
<TopicActions topicId={topic.id} status={topic.status} />
</div>
{drafts.length === 0 ? (
<p className="text-sm text-muted-foreground">
Henüz taslak üretilmemiş. Konu kuyruktaysa <span className="font-mono">content-generate</span> job'u
(10dk) ya da kuyruk sayfasındaki Taslakları üret bunu işler.
</p>
) : (
<div className="space-y-4">
{drafts.map((d) => (
<DraftCard
key={d.id}
draftId={d.id}
channel={d.channel}
status={d.status}
bodyJson={d.bodyJson}
founderEdits={d.founderEdits}
model={d.sourceModel}
costUsd={d.sourceCostUsd}
publishedUrl={d.publishedUrl}
publishError={d.publishError}
/>
))}
</div>
)}
</PanelShell>
);
}

View File

@@ -318,6 +318,12 @@ export async function updateBudgetSetting(key: string, value: number | boolean)
"min_score_for_analysis",
"cache_ttl_hours",
"analysis_paused",
// Content generation (separate envelope) — see lib/content-budget.ts
"content_monthly_hard_cap_usd",
"content_daily_soft_cap_usd",
"content_daily_hard_cap_usd",
"content_per_call_max_usd",
"content_paused",
]);
if (!allowed.has(key)) throw new Error("bad setting key");
const existing = await prisma.budgetSetting.findFirst({
@@ -342,4 +348,8 @@ export async function updateBudgetSetting(key: string, value: number | boolean)
revalidatePath("/insights/settings/budgets");
revalidatePath("/insights/costs");
revalidatePath("/insights");
if (key.startsWith("content_")) {
revalidatePath("/content/costs");
revalidatePath("/content");
}
}

View File

@@ -53,6 +53,41 @@ const SCHEMA: Array<{ key: string; label: string; unit?: string; help?: string;
help: "Kill switch — no LLM calls until cleared. Pipeline still ingests/tags/compresses.",
default: false,
},
// ---- Content generation (separate envelope from insight analysis) ----
{
key: "content_monthly_hard_cap_usd",
label: "Content · monthly hard cap",
unit: "USD/month",
help: "Content generation halts when its month-to-date spend reaches this.",
default: 15,
},
{
key: "content_daily_soft_cap_usd",
label: "Content · daily soft cap",
unit: "USD/day",
help: "Above this, content pro tier downgrades to flash for the rest of today.",
default: 1,
},
{
key: "content_daily_hard_cap_usd",
label: "Content · daily hard cap",
unit: "USD/day",
help: "Content generation paused above this until UTC midnight.",
default: 2,
},
{
key: "content_per_call_max_usd",
label: "Content · per-call max",
unit: "USD",
help: "Single content LLM call ceiling. Currently warn-only.",
default: 0.3,
},
{
key: "content_paused",
label: "Pause content generation",
help: "Kill switch for content topics + drafts. Independent of analysis_paused.",
default: false,
},
];
export default async function BudgetsSettingsPage() {

View File

@@ -7,6 +7,7 @@ import {
FolderIcon,
LayoutDashboardIcon,
LightbulbIcon,
PenLineIcon,
ScrollTextIcon,
Settings2Icon,
TerminalIcon,
@@ -30,6 +31,7 @@ const navMain = [
{ title: "Projects", url: "/projects", icon: <FolderIcon /> },
{ title: "Operations", url: "/operations", icon: <TerminalIcon /> },
{ title: "Insights", url: "/insights", icon: <LightbulbIcon /> },
{ title: "Content", url: "/content", icon: <PenLineIcon /> },
{ title: "Events", url: "/events", icon: <ActivityIcon /> },
{ title: "Audit", url: "/audit", icon: <ScrollTextIcon /> },
];

View File

@@ -18,6 +18,7 @@ import {
LayoutDashboardIcon,
LightbulbIcon,
LogOutIcon,
PenLineIcon,
ScrollTextIcon,
Settings2Icon,
SlidersHorizontalIcon,
@@ -94,6 +95,17 @@ export function CommandPalette({ projects }: { projects: ProjectLite[] }) {
<CommandSeparator />
<CommandGroup heading="Content">
<CommandItem keywords={["content","icerik","konu","kuyruk","queue"]} onSelect={() => go("/content")}>
<PenLineIcon /> İçerik kuyruğu
</CommandItem>
<CommandItem keywords={["content","cost","maliyet","spend"]} onSelect={() => go("/content/costs")}>
<CoinsIcon /> İçerik maliyeti
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Projects">
{projects.map((p) => (
<CommandItem

View File

@@ -3,6 +3,7 @@ import IORedis from "ioredis";
const url = process.env.REDIS_URL;
let _queue: Queue | null = null;
let _contentQueue: Queue | null = null;
export function pipelineQueue(): Queue {
if (_queue) return _queue;
@@ -11,3 +12,11 @@ export function pipelineQueue(): Queue {
_queue = new Queue("insight-pipeline", { connection });
return _queue;
}
export function contentQueue(): Queue {
if (_contentQueue) return _contentQueue;
if (!url) throw new Error("REDIS_URL not set");
const connection = new IORedis(url, { maxRetriesPerRequest: null });
_contentQueue = new Queue("content-pipeline", { connection });
return _contentQueue;
}

View File

@@ -1,6 +1,7 @@
import { startEventBus } from "./consumers/event-bus";
import { startScheduledJobs } from "./schedulers/nightly";
import { startInsightPipeline } from "./schedulers/pipeline";
import { startContentPipeline } from "./schedulers/content";
import { upsertSeedData } from "./lib/seed-runtime";
import { redis } from "./redis";
import { prisma } from "./db";
@@ -16,6 +17,7 @@ async function main() {
await startScheduledJobs();
await startInsightPipeline();
await startContentPipeline();
await startEventBus();
console.log("[worker] up.");

View File

@@ -0,0 +1,180 @@
// content-generate job (Phase 8a): turns queued ContentTopic rows into one
// ContentDraft per selected channel. Mirrors the insight analyze job:
// budget guard → per-call DeepSeek → JSON validate → persist + cost ledger.
// Drafts land in `draft` status; the founder reviews/edits and (Phase 8c)
// publishes via the n8n webhook. Nothing is published from here.
import { prisma } from "../db";
import { callDeepSeek, extractJson, type Tier, DeepSeekError } from "../lib/deepseek";
import { checkContentBudget } from "../lib/content-budget";
import { channelPromptTag, type ContentChannel } from "../lib/content-prompts";
import { validate } from "../lib/json-validate";
const PROJECT_KEY = process.env.CONTENT_PROJECT_KEY ?? "sase";
const GENERATE_BATCH = Number(process.env.CONTENT_GENERATE_BATCH ?? "3");
export type ContentGenerateResult = {
topics: number;
draftsCreated: number;
failed: number;
costUsd: number;
budgetState: string;
reason?: string;
};
export async function runContentGenerate(): Promise<ContentGenerateResult> {
const budget = await checkContentBudget();
if (!budget.allow) {
return { topics: 0, draftsCreated: 0, failed: 0, costUsd: 0, budgetState: budget.state, reason: budget.reason };
}
const topics = await prisma.contentTopic.findMany({
where: { projectKey: PROJECT_KEY, status: "queued" },
orderBy: { createdAt: "asc" },
take: GENERATE_BATCH,
});
if (topics.length === 0) {
return { topics: 0, draftsCreated: 0, failed: 0, costUsd: 0, budgetState: budget.state };
}
let draftsCreated = 0;
let failed = 0;
let costUsd = 0;
for (const topic of topics) {
await prisma.contentTopic.update({ where: { id: topic.id }, data: { status: "generating" } });
const channels = (topic.channels as string[]).filter((c) =>
["blog", "linkedin", "x", "instagram"].includes(c),
) as ContentChannel[];
for (const channel of channels) {
// Skip if a draft for this topic+channel already exists (idempotent reruns).
const existing = await prisma.contentDraft.findFirst({
where: { topicId: topic.id, channel },
select: { id: true },
});
if (existing) continue;
const tag = channelPromptTag(channel);
const template = await prisma.promptTemplate.findFirst({
where: { tag, active: true },
orderBy: { version: "desc" },
});
if (!template) {
console.warn(`[content-generate] no prompt for ${tag}`);
continue;
}
const tier: Tier = budget.forceTier ?? (template.modelTier as Tier);
const userPrompt = template.userPromptTemplate
.replace("{{title}}", topic.title)
.replace("{{brief}}", topic.brief ?? "")
.replace("{{angle}}", topic.angle ?? "(belirtilmedi)")
.replace("{{keywords}}", (topic.keywords as string[]).join(", ") || "(yok)");
let result;
try {
result = await callDeepSeek({
tier,
systemPrompt: template.systemPrompt,
userPrompt,
maxOutputTokens: template.maxOutputTokens,
temperature: template.temperature,
});
} catch (e) {
const status = e instanceof DeepSeekError ? e.status : 0;
console.warn(`[content-generate] deepseek error topic=${topic.id} ${channel}: ${status}`);
await logCost({ tag, tier, promptVersion: template.version, errorCode: `${status}` });
failed++;
continue;
}
costUsd += result.cost.totalUsd;
let parsed: any;
let validationErrors = "";
try {
parsed = JSON.parse(extractJson(result.text));
const errs = validate(parsed, template.outputSchemaJson as any);
if (errs.length) validationErrors = errs.map((e) => `${e.path}: ${e.message}`).join("; ");
} catch (e) {
validationErrors = `json parse: ${(e as Error).message}`;
}
await logCost({
tag,
tier,
promptVersion: template.version,
usage: result.usage,
cost: result.cost,
model: result.model,
durationMs: result.durationMs,
errorCode: validationErrors ? "validation_failed" : undefined,
});
if (validationErrors) {
console.warn(`[content-generate] validation failed topic=${topic.id} ${channel}: ${validationErrors.slice(0, 160)}`);
failed++;
continue;
}
await prisma.contentDraft.create({
data: {
topicId: topic.id,
projectKey: PROJECT_KEY,
channel,
status: "draft",
bodyJson: parsed,
sourcePromptTag: tag,
sourcePromptVersion: template.version,
sourceModel: result.model,
sourceCostUsd: result.cost.totalUsd,
},
});
draftsCreated++;
}
await prisma.contentTopic.update({ where: { id: topic.id }, data: { status: "drafted" } });
const recheck = await checkContentBudget();
if (!recheck.allow) {
console.log(`[content-generate] budget exhausted mid-batch (${recheck.state})`);
break;
}
}
return { topics: topics.length, draftsCreated, failed, costUsd, budgetState: budget.state };
}
async function logCost(opts: {
tag: string;
tier: Tier;
promptVersion: number;
usage?: { inputTokensMiss: number; inputTokensHit: number; outputTokens: number };
cost?: { inputMissUsd: number; inputHitUsd: number; outputUsd: number; totalUsd: number; cacheHitRatio: number };
model?: string;
durationMs?: number;
errorCode?: string;
}): Promise<void> {
await prisma.costLedger.create({
data: {
projectKey: PROJECT_KEY,
promptTag: opts.tag,
promptVersion: opts.promptVersion,
provider: "deepseek",
model: opts.model ?? (opts.tier === "pro" ? "deepseek-v4-pro" : "deepseek-v4-flash"),
tier: opts.tier,
tokensInputCacheMiss: opts.usage?.inputTokensMiss ?? 0,
tokensInputCacheHit: opts.usage?.inputTokensHit ?? 0,
tokensOutput: opts.usage?.outputTokens ?? 0,
costInputCacheMissUsd: opts.cost?.inputMissUsd ?? 0,
costInputCacheHitUsd: opts.cost?.inputHitUsd ?? 0,
costOutputUsd: opts.cost?.outputUsd ?? 0,
costTotalUsd: opts.cost?.totalUsd ?? 0,
cacheHitRatio: opts.cost?.cacheHitRatio ?? 0,
callDurationMs: opts.durationMs ?? null,
errorCode: opts.errorCode ?? null,
},
});
}

View File

@@ -0,0 +1,204 @@
// content-topics job (Phase 8a): auto-generates content topic ideas via the
// LLM and queues them as ContentTopic rows. Conservative by design — it only
// tops the backlog up to a target, dedupes near-identical titles, and is
// gated by the separate content budget.
import { prisma } from "../db";
import { callDeepSeek, extractJson, type Tier, DeepSeekError } from "../lib/deepseek";
import { checkContentBudget } from "../lib/content-budget";
import { validate } from "../lib/json-validate";
import { fingerprintHash } from "../lib/hash";
const PROJECT_KEY = process.env.CONTENT_PROJECT_KEY ?? "sase";
// Stop generating new ideas once this many topics are already waiting.
const BACKLOG_TARGET = Number(process.env.CONTENT_BACKLOG_TARGET ?? "12");
const DEFAULT_CHANNELS = (process.env.CONTENT_DEFAULT_CHANNELS ?? "blog,linkedin,x,instagram")
.split(",")
.map((c) => c.trim())
.filter(Boolean);
type TopicIdea = {
title: string;
brief: string;
angle?: string;
channels?: string[];
keywords?: string[];
};
export type ContentTopicsResult = {
generated: number;
inserted: number;
skipped: number;
costUsd: number;
budgetState: string;
reason?: string;
};
export async function runContentTopics(): Promise<ContentTopicsResult> {
const budget = await checkContentBudget();
if (!budget.allow) {
return { generated: 0, inserted: 0, skipped: 0, costUsd: 0, budgetState: budget.state, reason: budget.reason };
}
// Only top up the backlog — don't generate endlessly.
const queuedCount = await prisma.contentTopic.count({
where: { projectKey: PROJECT_KEY, status: { in: ["queued", "generating"] } },
});
if (queuedCount >= BACKLOG_TARGET) {
return { generated: 0, inserted: 0, skipped: 0, budgetState: budget.state, costUsd: 0, reason: "backlog_full" };
}
const want = Math.min(6, BACKLOG_TARGET - queuedCount);
const template = await prisma.promptTemplate.findFirst({
where: { tag: "content_topic_ideas", active: true },
orderBy: { version: "desc" },
});
if (!template) {
return { generated: 0, inserted: 0, skipped: 0, budgetState: budget.state, costUsd: 0, reason: "no_prompt" };
}
// Recent topics so the model avoids repeating itself.
const recent = await prisma.contentTopic.findMany({
where: { projectKey: PROJECT_KEY },
select: { title: true },
orderBy: { createdAt: "desc" },
take: 40,
});
const context =
recent.length > 0
? `Son üretilen konular (BUNLARA BENZER üretme):\n${recent.map((r) => `- ${r.title}`).join("\n")}`
: "Henüz üretilmiş konu yok.";
const tier: Tier = budget.forceTier ?? (template.modelTier as Tier);
const userPrompt = template.userPromptTemplate
.replace("{{context}}", context)
.replace("{{count}}", String(want));
let result;
try {
result = await callDeepSeek({
tier,
systemPrompt: template.systemPrompt,
userPrompt,
maxOutputTokens: template.maxOutputTokens,
temperature: template.temperature,
});
} catch (e) {
const status = e instanceof DeepSeekError ? e.status : 0;
await logCost({ tier, promptVersion: template.version, errorCode: `${status}` });
return {
generated: 0,
inserted: 0,
skipped: 0,
budgetState: budget.state,
costUsd: 0,
reason: `deepseek ${status}: ${(e as Error).message}`,
};
}
await logCost({
tier,
promptVersion: template.version,
usage: result.usage,
cost: result.cost,
model: result.model,
durationMs: result.durationMs,
});
let parsed: any;
try {
parsed = JSON.parse(extractJson(result.text));
} catch (e) {
return {
generated: 0,
inserted: 0,
skipped: 0,
budgetState: budget.state,
costUsd: result.cost.totalUsd,
reason: `json parse: ${(e as Error).message}`,
};
}
const errs = validate(parsed, template.outputSchemaJson as any);
if (errs.length) {
return {
generated: 0,
inserted: 0,
skipped: 0,
budgetState: budget.state,
costUsd: result.cost.totalUsd,
reason: `validation: ${errs.map((e) => e.path).join(",").slice(0, 120)}`,
};
}
const ideas: TopicIdea[] = Array.isArray(parsed.topics) ? parsed.topics : [];
let inserted = 0;
let skipped = 0;
for (const idea of ideas) {
const fp = fingerprintHash([PROJECT_KEY, idea.title]);
const dupe = await prisma.contentTopic.findFirst({ where: { projectKey: PROJECT_KEY, fingerprint: fp } });
if (dupe) {
skipped++;
continue;
}
const channels = (idea.channels && idea.channels.length ? idea.channels : DEFAULT_CHANNELS).filter((c) =>
["blog", "linkedin", "x", "instagram"].includes(c),
);
await prisma.contentTopic.create({
data: {
projectKey: PROJECT_KEY,
title: idea.title.slice(0, 250),
brief: idea.brief ?? "",
angle: idea.angle ?? null,
channels: channels.length ? channels : DEFAULT_CHANNELS,
keywords: Array.isArray(idea.keywords) ? idea.keywords.slice(0, 12) : [],
status: "queued",
source: "auto",
fingerprint: fp,
sourcePromptTag: "content_topic_ideas",
sourcePromptVersion: template.version,
sourceModel: result.model,
sourceCostUsd: result.cost.totalUsd / Math.max(1, ideas.length),
},
});
inserted++;
}
return {
generated: ideas.length,
inserted,
skipped,
budgetState: budget.state,
costUsd: result.cost.totalUsd,
};
}
async function logCost(opts: {
tier: Tier;
promptVersion: number;
usage?: { inputTokensMiss: number; inputTokensHit: number; outputTokens: number };
cost?: { inputMissUsd: number; inputHitUsd: number; outputUsd: number; totalUsd: number; cacheHitRatio: number };
model?: string;
durationMs?: number;
errorCode?: string;
}): Promise<void> {
await prisma.costLedger.create({
data: {
projectKey: PROJECT_KEY,
promptTag: "content_topic_ideas",
promptVersion: opts.promptVersion,
provider: "deepseek",
model: opts.model ?? (opts.tier === "pro" ? "deepseek-v4-pro" : "deepseek-v4-flash"),
tier: opts.tier,
tokensInputCacheMiss: opts.usage?.inputTokensMiss ?? 0,
tokensInputCacheHit: opts.usage?.inputTokensHit ?? 0,
tokensOutput: opts.usage?.outputTokens ?? 0,
costInputCacheMissUsd: opts.cost?.inputMissUsd ?? 0,
costInputCacheHitUsd: opts.cost?.inputHitUsd ?? 0,
costOutputUsd: opts.cost?.outputUsd ?? 0,
costTotalUsd: opts.cost?.totalUsd ?? 0,
cacheHitRatio: opts.cost?.cacheHitRatio ?? 0,
callDurationMs: opts.durationMs ?? null,
errorCode: opts.errorCode ?? null,
},
});
}

View File

@@ -0,0 +1,116 @@
// Content-generation budget guard. Mirrors lib/budget.ts but keeps a separate
// envelope from the insight pipeline: spend is summed only over cost_ledger
// rows whose promptTag starts with "content_", and the caps come from the
// `content_*` budget settings. This way content generation can never exhaust
// the insight analysis budget (or vice-versa).
import { prisma } from "../db";
import type { BudgetState } from "./budget";
export type ContentBudgetDecision = {
allow: boolean;
state: BudgetState;
reason: string;
forceTier?: "flash";
todayUsd: number;
monthUsd: number;
limits: {
monthlyHardCap: number;
dailySoftCap: number;
dailyHardCap: number;
perCallMax: number;
};
};
async function getNumber(key: string, fallback: number): Promise<number> {
const row = await prisma.budgetSetting.findFirst({
where: { projectKey: null, settingKey: key },
});
const v = row?.settingValue;
return typeof v === "number" ? v : fallback;
}
async function getBool(key: string, fallback: boolean): Promise<boolean> {
const row = await prisma.budgetSetting.findFirst({
where: { projectKey: null, settingKey: key },
});
const v = row?.settingValue;
return typeof v === "boolean" ? v : fallback;
}
function startOfDayUtc(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
function startOfMonthUtc(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
}
// Sums only content_* spend.
async function contentSpend(since: Date): Promise<number> {
const agg = await prisma.costLedger.aggregate({
where: { createdAt: { gte: since }, promptTag: { startsWith: "content_" } },
_sum: { costTotalUsd: true },
});
return Number(agg._sum.costTotalUsd ?? 0);
}
export async function checkContentBudget(): Promise<ContentBudgetDecision> {
const monthlyHardCap = await getNumber("content_monthly_hard_cap_usd", 15);
const dailySoftCap = await getNumber("content_daily_soft_cap_usd", 1);
const dailyHardCap = await getNumber("content_daily_hard_cap_usd", 2);
const perCallMax = await getNumber("content_per_call_max_usd", 0.3);
const paused = await getBool("content_paused", false);
const limits = { monthlyHardCap, dailySoftCap, dailyHardCap, perCallMax };
if (paused) {
return {
allow: false,
state: "hard_paused",
reason: "content_paused setting is true",
todayUsd: 0,
monthUsd: 0,
limits,
};
}
const now = new Date();
const [todayUsd, monthUsd] = await Promise.all([
contentSpend(startOfDayUtc(now)),
contentSpend(startOfMonthUtc(now)),
]);
if (monthUsd >= monthlyHardCap) {
return {
allow: false,
state: "monthly_paused",
reason: `content month spend $${monthUsd.toFixed(4)} >= monthly cap $${monthlyHardCap}`,
todayUsd,
monthUsd,
limits,
};
}
if (todayUsd >= dailyHardCap) {
return {
allow: false,
state: "hard_paused",
reason: `content today spend $${todayUsd.toFixed(4)} >= daily hard cap $${dailyHardCap}`,
todayUsd,
monthUsd,
limits,
};
}
if (todayUsd >= dailySoftCap) {
return {
allow: true,
state: "soft_throttled",
reason: `content today spend $${todayUsd.toFixed(4)} >= daily soft cap $${dailySoftCap}, force flash`,
forceTier: "flash",
todayUsd,
monthUsd,
limits,
};
}
return { allow: true, state: "active", reason: "ok", todayUsd, monthUsd, limits };
}

View File

@@ -0,0 +1,216 @@
// Content-generation prompt templates (Phase 8a). Same shape & DB table as
// the insight prompts (PromptTemplate) — distinguished by the `content_*` tag
// prefix, which is also how content spend is separated in the cost ledger.
//
// One topic-idea prompt (auto topic generation) + one prompt per channel.
// All natural-language output is Turkish (B2B automotive marketing tone).
import type { PromptTemplate } from "./prompts";
export const CONTENT_TAGS = [
"content_topic_ideas",
"content_blog",
"content_linkedin",
"content_x",
"content_instagram",
] as const;
export type ContentChannel = "blog" | "linkedin" | "x" | "instagram";
// Maps a channel to its generation prompt tag.
export function channelPromptTag(channel: ContentChannel): string {
return `content_${channel}`;
}
const SASE_BRAND = `Sase.tr hakkında:
- B2B SaaS: VIN/şasi sorgulama + OEM yedek parça uyumluluğu. Hedef kitle: Türkiye'deki yedek parçacılar, oto servisleri, tamirhaneler, parça ithalatçıları.
- Değer önerisi: doğru parçayı VIN'den hızlı bul, yanlış parça iadesini azalt, 4 upstream katalog (PL24/Partslink24, PCAT, RMEX, TecDoc) tek arayüzde.
- Abonelik: starter / brand_specific / full. Deneme akışı var.
Marka tonu:
- Profesyonel, net, sektörün dilini bilen. Esnaf/teknisyen okuyucuya saygılı, abartısız.
- Otomotiv terimlerini doğru kullan: VIN, şasi no, OEM, OE/eşdeğer parça, OBD, motor kodu, donanım kodu, katalog, çapraz referans.
- Satış baskısı değil; gerçek bir sorunu çözerek güven kur. CTA yumuşak ama net (örn. "Sase.tr'de VIN ile parça aramayı ücretsiz deneyin").
- Yanlış/uydurma teknik iddia YOK. Emin değilsen genel konuş, spesifik sayı/iddia uydurma.
Çıktı kuralları:
- SADECE şemaya uyan geçerli JSON döndür. Markdown yok, kod bloğu yok, açıklama yok.
- Tüm doğal dil alanları (başlık, gövde, caption, brief, CTA, vb.) TÜRKÇE. Hashtag'ler Türkçe veya sektör-standart İngilizce olabilir (örn. #yedekparça #OEM).
- Alan uzunluk sınırlarına (maxLength) uy; aşma, gerekirse kısalt.`;
// ---- Topic idea generation ----
const TOPIC_IDEAS_SCHEMA = {
type: "object",
required: ["topics"],
properties: {
topics: {
type: "array",
minItems: 1,
maxItems: 8,
items: {
type: "object",
required: ["title", "brief", "channels", "keywords"],
properties: {
title: { type: "string", maxLength: 160 },
brief: { type: "string", maxLength: 600 },
angle: { type: "string", maxLength: 300 },
channels: {
type: "array",
minItems: 1,
maxItems: 4,
items: { enum: ["blog", "linkedin", "x", "instagram"] },
},
keywords: { type: "array", minItems: 1, maxItems: 12, items: { type: "string" } },
},
},
},
},
};
// ---- Per-channel content schemas ----
const BLOG_SCHEMA = {
type: "object",
required: ["title", "slug", "meta_description", "body_markdown", "tags"],
properties: {
title: { type: "string", maxLength: 160 },
slug: { type: "string", maxLength: 120 },
meta_description: { type: "string", maxLength: 300 },
body_markdown: { type: "string", maxLength: 14000 },
tags: { type: "array", minItems: 1, maxItems: 12, items: { type: "string" } },
cta: { type: "string", maxLength: 300 },
},
};
const LINKEDIN_SCHEMA = {
type: "object",
required: ["body", "hashtags"],
properties: {
body: { type: "string", maxLength: 2600 },
hashtags: { type: "array", minItems: 0, maxItems: 10, items: { type: "string" } },
cta: { type: "string", maxLength: 200 },
},
};
const X_SCHEMA = {
type: "object",
required: ["tweets"],
properties: {
tweets: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", maxLength: 280 } },
hashtags: { type: "array", minItems: 0, maxItems: 6, items: { type: "string" } },
},
};
const INSTAGRAM_SCHEMA = {
type: "object",
required: ["caption", "hashtags"],
properties: {
caption: { type: "string", maxLength: 2200 },
hashtags: { type: "array", minItems: 0, maxItems: 30, items: { type: "string" } },
image_prompt: { type: "string", maxLength: 400 },
},
};
export const CONTENT_SEED_PROMPTS: PromptTemplate[] = [
{
tag: "content_topic_ideas",
version: 1,
name: "Content Topic Ideas v1 (TR)",
systemPrompt: `Sase.tr için içerik konusu fikirleri üreten bir B2B içerik stratejistisin. VIN/OEM/yedek parça/oto servis temalarında, hedef kitlenin (yedek parçacılar, servisler) gerçekten arayacağı veya faydalanacağı, SEO ve sosyal için uygun konular öner. Tekrara düşme, jenerik olma; sektöre özgü ve eyleme dönüştürülebilir açılar bul.
${SASE_BRAND}
Şema (tam olarak buna uy):
${JSON.stringify(TOPIC_IDEAS_SCHEMA)}`,
userPromptTemplate: `{{context}}
{{count}} adet yeni içerik konusu fikri üret. Her fikir için: başlık, kısa brief (içeriğin ne anlatacağı), açı (angle), uygun kanallar ve anahtar kelimeler. Yukarıdaki "son konular" listesindekilere benzer/çakışan konu ÜRETME. Şemaya uygun JSON döndür.`,
outputSchemaJson: TOPIC_IDEAS_SCHEMA,
modelTier: "pro",
maxOutputTokens: 1800,
temperature: 0.7,
},
{
tag: "content_blog",
version: 1,
name: "Content Blog v1 (TR, SEO)",
systemPrompt: `Sase.tr için Türkçe, SEO-optimize blog yazıları yazan bir içerik editörüsün. Yazı yapısı net (giriş, alt başlıklar, sonuç), okunabilir, gerçek değer veren ve anahtar kelimeleri doğal kullanan olmalı. Markdown gövdesinde başlıklar (##), kısa paragraflar ve gerektiğinde liste kullan.
${SASE_BRAND}
Şema:
${JSON.stringify(BLOG_SCHEMA)}`,
userPromptTemplate: `Konu: {{title}}
Brief: {{brief}}
ı: {{angle}}
Anahtar kelimeler: {{keywords}}
Bu konuda Türkçe bir blog yazısı üret. slug kısa ve URL-uyumlu (küçük harf, tireli, Türkçe karakter yok). meta_description SEO için 150-160 karakter civarı. Şemaya uygun JSON döndür.`,
outputSchemaJson: BLOG_SCHEMA,
modelTier: "pro",
maxOutputTokens: 4000,
temperature: 0.4,
},
{
tag: "content_linkedin",
version: 1,
name: "Content LinkedIn v1 (TR, B2B)",
systemPrompt: `Sase.tr için LinkedIn şirket sayfası gönderileri yazan bir B2B sosyal medya editörüsün. Ton profesyonel ama insani; ilk satır dikkat çeken bir kanca olmalı. Kısa paragraflar, gerektiğinde satır araları. Aşırı hashtag kullanma.
${SASE_BRAND}
Şema:
${JSON.stringify(LINKEDIN_SCHEMA)}`,
userPromptTemplate: `Konu: {{title}}
Brief: {{brief}}
ı: {{angle}}
Anahtar kelimeler: {{keywords}}
Bu konuda bir LinkedIn gönderisi üret. Şemaya uygun JSON döndür.`,
outputSchemaJson: LINKEDIN_SCHEMA,
modelTier: "flash",
maxOutputTokens: 1200,
temperature: 0.5,
},
{
tag: "content_x",
version: 1,
name: "Content X/Twitter v1 (TR)",
systemPrompt: `Sase.tr için X (Twitter) gönderileri/thread'leri yazan bir sosyal medya editörüsün. Her tweet ≤280 karakter. Tek güçlü gönderi ya da kısa bir thread üret; ilk tweet kanca olmalı, son tweet yumuşak CTA içerebilir.
${SASE_BRAND}
Şema:
${JSON.stringify(X_SCHEMA)}`,
userPromptTemplate: `Konu: {{title}}
Brief: {{brief}}
ı: {{angle}}
Anahtar kelimeler: {{keywords}}
Bu konuda bir X gönderisi ya da kısa thread üret (en fazla 8 tweet). Her tweet ayrı bir dizi elemanı, her biri ≤280 karakter. Şemaya uygun JSON döndür.`,
outputSchemaJson: X_SCHEMA,
modelTier: "flash",
maxOutputTokens: 1000,
temperature: 0.6,
},
{
tag: "content_instagram",
version: 1,
name: "Content Instagram v1 (TR)",
systemPrompt: `Sase.tr için Instagram caption'ları yazan bir sosyal medya editörüsün. Caption ilgi çekici, kısa paragraflı, emoji'yi ölçülü kullanan olsun. Hashtag'leri caption sonunda topla. Ayrıca içeriğe uygun bir görsel üretim prompt'u (image_prompt) öner (İngilizce, kısa, görsel betimleme).
${SASE_BRAND}
Şema:
${JSON.stringify(INSTAGRAM_SCHEMA)}`,
userPromptTemplate: `Konu: {{title}}
Brief: {{brief}}
ı: {{angle}}
Anahtar kelimeler: {{keywords}}
Bu konuda bir Instagram caption'ı + hashtag seti + image_prompt üret. Şemaya uygun JSON döndür.`,
outputSchemaJson: INSTAGRAM_SCHEMA,
modelTier: "flash",
maxOutputTokens: 1000,
temperature: 0.6,
},
];

View File

@@ -1,5 +1,6 @@
import { prisma } from "../db";
import { SEED_PROMPTS } from "./prompts";
import { CONTENT_SEED_PROMPTS } from "./content-prompts";
const DEFAULT_BUDGETS: Array<{ key: string; value: unknown }> = [
{ key: "monthly_hard_cap_usd", value: 30 },
@@ -9,12 +10,20 @@ const DEFAULT_BUDGETS: Array<{ key: string; value: unknown }> = [
{ key: "min_score_for_analysis", value: 30 },
{ key: "cache_ttl_hours", value: 6 },
{ key: "analysis_paused", value: false },
// Content generation — separate envelope from insight analysis.
{ key: "content_monthly_hard_cap_usd", value: 15 },
{ key: "content_daily_soft_cap_usd", value: 1 },
{ key: "content_daily_hard_cap_usd", value: 2 },
{ key: "content_per_call_max_usd", value: 0.3 },
// Safe default: first deploy lands paused so output quality can be reviewed
// before the cron auto-spends. Flip off in budget settings to enable.
{ key: "content_paused", value: true },
];
export async function upsertSeedData(): Promise<void> {
// Prompt templates — insert new versions if (tag, version) doesn't exist.
// When inserting a new version, deactivate older active versions of the same tag.
for (const p of SEED_PROMPTS) {
for (const p of [...SEED_PROMPTS, ...CONTENT_SEED_PROMPTS]) {
const existing = await prisma.promptTemplate.findUnique({
where: { tag_version: { tag: p.tag, version: p.version } },
});

View File

@@ -0,0 +1,59 @@
// Content pipeline scheduler (Phase 8a). Separate BullMQ queue from the
// insight pipeline so the two domains don't share concurrency or job names.
// Cadence is conservative and every job is gated by the content budget; the
// `content_paused` budget setting is the global kill-switch.
import { Queue, Worker, type Job } from "bullmq";
import { redis } from "../redis";
import { runContentTopics } from "../jobs/content-topics";
import { runContentGenerate } from "../jobs/content-generate";
const QUEUE = "content-pipeline";
const queue = new Queue(QUEUE, { connection: redis });
async function runJob(job: Job) {
switch (job.name) {
case "content-topics": {
const res = await runContentTopics();
if (res.inserted > 0 || res.reason) {
console.log(
`[content] topics generated=${res.generated} inserted=${res.inserted} skipped=${res.skipped} cost=$${res.costUsd.toFixed(4)} budget=${res.budgetState}${res.reason ? ` reason=${res.reason}` : ""}`,
);
}
return res;
}
case "content-generate": {
const res = await runContentGenerate();
if (res.draftsCreated > 0 || res.failed > 0 || res.reason) {
console.log(
`[content] generate topics=${res.topics} drafts=${res.draftsCreated} failed=${res.failed} cost=$${res.costUsd.toFixed(4)} budget=${res.budgetState}${res.reason ? ` reason=${res.reason}` : ""}`,
);
}
return res;
}
default:
return { ok: false, error: `unknown job ${job.name}` };
}
}
export async function startContentPipeline() {
await queue.upsertJobScheduler(
"content-topics",
{ pattern: "0 */8 * * *" }, // every 8h: top up the topic backlog
{ name: "content-topics", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
);
await queue.upsertJobScheduler(
"content-generate",
{ pattern: "*/10 * * * *" }, // every 10min: drain queued topics into drafts
{ name: "content-generate", data: {}, opts: { removeOnComplete: 50, removeOnFail: 25 } },
);
new Worker(QUEUE, runJob, {
connection: redis,
concurrency: 1,
lockDuration: 5 * 60_000,
stalledInterval: 60_000,
});
console.log("[content] armed: content-topics@*/8h, content-generate@*/10min");
}