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

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