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:
@@ -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:
|
||||
|
||||
27
apps/web/src/app/api/content/generate/route.ts
Normal file
27
apps/web/src/app/api/content/generate/route.ts
Normal 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 });
|
||||
}
|
||||
27
apps/web/src/app/api/content/topics/generate/route.ts
Normal file
27
apps/web/src/app/api/content/topics/generate/route.ts
Normal 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 });
|
||||
}
|
||||
151
apps/web/src/app/content/_actions.ts
Normal file
151
apps/web/src/app/content/_actions.ts
Normal 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");
|
||||
}
|
||||
134
apps/web/src/app/content/_controls.tsx
Normal file
134
apps/web/src/app/content/_controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
apps/web/src/app/content/costs/page.tsx
Normal file
174
apps/web/src/app/content/costs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
apps/web/src/app/content/page.tsx
Normal file
113
apps/web/src/app/content/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
apps/web/src/app/content/t/[id]/_draft-card.tsx
Normal file
191
apps/web/src/app/content/t/[id]/_draft-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/app/content/t/[id]/_topic-actions.tsx
Normal file
43
apps/web/src/app/content/t/[id]/_topic-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/app/content/t/[id]/page.tsx
Normal file
76
apps/web/src/app/content/t/[id]/page.tsx
Normal 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">Açı: {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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 /> },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
180
apps/worker/src/jobs/content-generate.ts
Normal file
180
apps/worker/src/jobs/content-generate.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
204
apps/worker/src/jobs/content-topics.ts
Normal file
204
apps/worker/src/jobs/content-topics.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
116
apps/worker/src/lib/content-budget.ts
Normal file
116
apps/worker/src/lib/content-budget.ts
Normal 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 };
|
||||
}
|
||||
216
apps/worker/src/lib/content-prompts.ts
Normal file
216
apps/worker/src/lib/content-prompts.ts
Normal 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}}
|
||||
Açı: {{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}}
|
||||
Açı: {{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}}
|
||||
Açı: {{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}}
|
||||
Açı: {{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,
|
||||
},
|
||||
];
|
||||
@@ -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 } },
|
||||
});
|
||||
|
||||
59
apps/worker/src/schedulers/content.ts
Normal file
59
apps/worker/src/schedulers/content.ts
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user