From 897c33eeac52608c046201482a52d5f5390debf2 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 22 Sep 2026 22:16:02 +0700 Subject: [PATCH] =?UTF-8?q?feat(shorts):=20AI=20agent=20tips=20category=20?= =?UTF-8?q?=E2=80=94=20ideas,=20script=20structure,=20tips=20voice,=20cate?= =?UTF-8?q?gory=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + prisma/schema.prisma | 3 + scripts/render-worker.mjs | 30 ++++++--- src/app/api/youtube/generate/route.ts | 24 +++++--- src/app/api/youtube/ideas/generate/route.ts | 67 +++++++++++++++++---- src/app/youtube/page.tsx | 28 +++++++-- 6 files changed, 116 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index c34b3c1..ff4fffa 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ yarn-error.log* next-env.d.ts .env*.local /services/client-pulse-bot/dist/ +/tmp/checkcat.mjs diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 674e733..c5ff6f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -133,6 +133,7 @@ model YoutubeIdea { funnelStage String? status String @default("pending") rejectedReason String? + category String? @default("brands") sourceUrl String? sourceSummary String? competitionScore String? // "gap" | "moderate" | "crowded" @@ -172,6 +173,8 @@ model YoutubeScript { // Source content (tweet/thread/news) rendered as visual cards sourceUrl String? sourceData Json? + // Content category (brands | ai-agent-tips | ...) + category String? @default("brands") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/scripts/render-worker.mjs b/scripts/render-worker.mjs index 9b81bbc..5a261c1 100644 --- a/scripts/render-worker.mjs +++ b/scripts/render-worker.mjs @@ -29,6 +29,7 @@ const EDGE_TTS_BIN = ? "/home/code/.hermes/hermes-agent/venv/bin/edge-tts" : "edge-tts"); const VOICE = process.env.TTS_VOICE || "en-US-ChristopherNeural"; +const VOICE_TIPS = process.env.TTS_VOICE_TIPS || "en-US-GuyNeural"; // tech/explainer const CHROME = process.env.CHROME_BIN || "/usr/bin/google-chrome-stable"; const MAX_TOTAL_SEC = 60; const FPS = 30; @@ -111,12 +112,12 @@ function sh(cmd, args, opts = {}) { return r; } -function tts(text, outBase) { +function tts(text, outBase, voice = VOICE) { const out = `${outBase}.mp3`; const srt = `${outBase}.sentences.json`; sh( TTS_PYTHON, - [TTS_WORDS_PY, text, VOICE, out, srt], + [TTS_WORDS_PY, text, voice, out, srt], { timeout: 60_000, env: { ...process.env } } ); let sentences = []; @@ -166,18 +167,29 @@ function extractStatData(text) { /** Build segment text list from a YoutubeScript's structured fields. */ function buildSegments(script) { + const isTips = script.category === "ai-agent-tips"; const segs = []; const push = (text, label, highlight) => { const clean = (text || "").trim(); const chunk = clean.length > 180 ? clean.slice(0, 180) + "…" : clean; if (chunk) segs.push({ text: chunk, label, highlight }); }; - push(script.hook, "HOOK", script.hook); - push(script.onScreenText, "ON SCREEN", script.onScreenText); - push(script.storySetup, "THE STORY", null); - push(script.conflict, "THE CONFLICT", null); - push(script.insight, "THE INSIGHT", null); - push(script.cta, "CTA", null); + // For AI-agent tips: labels = MISTAKE / THE FIX instead of THE STORY / ... + if (isTips) { + push(script.hook, "HOOK", script.hook); + push(script.onScreenText, "ON SCREEN", script.onScreenText); + push(script.storySetup, "THE MISTAKE", null); + push(script.conflict, "WHY IT FAILS", null); + push(script.insight, "THE FIX", null); + push(script.cta, "CTA", null); + } else { + push(script.hook, "HOOK", script.hook); + push(script.onScreenText, "ON SCREEN", script.onScreenText); + push(script.storySetup, "THE STORY", null); + push(script.conflict, "THE CONFLICT", null); + push(script.insight, "THE INSIGHT", null); + push(script.cta, "CTA", null); + } if (segs.length === 0) push(script.fullScript || script.title, script.title || "SHORTS", null); return segs; } @@ -194,7 +206,7 @@ async function renderScript(script) { const voiced = []; for (let i = 0; i < baseSegs.length; i++) { const s = baseSegs[i]; - const t = tts(s.text, path.join(work, `seg${i}`)); + const t = tts(s.text, path.join(work, `seg${i}`), script.category === "ai-agent-tips" ? VOICE_TIPS : VOICE); voiced.push({ ...s, ...t }); log(`[${id}] seg${i}: ${t.durationSec.toFixed(2)}s "${s.text.slice(0, 40)}"`); } diff --git a/src/app/api/youtube/generate/route.ts b/src/app/api/youtube/generate/route.ts index b0ea822..e613628 100644 --- a/src/app/api/youtube/generate/route.ts +++ b/src/app/api/youtube/generate/route.ts @@ -45,8 +45,9 @@ function fetchArticle(url: string): string { } export async function POST(req: NextRequest) { - const { topic, sourceUrl, sourceSummary } = await req.json(); + const { topic, sourceUrl, sourceSummary, category } = await req.json(); if (!topic) return NextResponse.json({ error: "topic required" }, { status: 400 }); + const isTips = category === "ai-agent-tips"; const apiKey = process.env.OPENAI_API_KEY || ""; const braveKey = process.env.BRAVE_API_KEY || ""; @@ -171,20 +172,25 @@ ${sourceContext || "No sources available — be EXTRA cautious with claims, hedg - Include a "factClaims" array listing every specific factual claim in the script (dates, numbers, events, quotes) - Each claim MUST be traceable to the source material above -## THE USER'S ANGLE +## THE USER'S ANGLE${isTips ? ` +- Builds and ships AI agents (coding agents, MCP, agent loops) — the tips are for OTHER builders +- Voice: direct, first-principles, tells you the mistake you are probably making +- Every tip must be concrete and actionable — no fluff +- The viewer is technical: they write code and wire tools together +- Ground each tip in a real failure/lesson: "I ran this in production and learned X"` : ` - Runs a marketing-focused business - Content: founder branding, marketing, distribution > product, culture > features -- Voice: conversational, no fancy words, lowercase energy, real numbers, honest/vulnerable -${pastScriptsRef} -${feedbackContext} +- Voice: conversational, no fancy words, lowercase energy, real numbers, honest/vulnerable`} + ${pastScriptsRef} + ${feedbackContext} OUTPUT FORMAT — valid JSON only: { "title": "Brand/Person — Core Lesson (short)", - "hook": "Opening 2-4 lines. Scroll-stopping.", - "storySetup": "The backstory context. 3-5 sentences.", - "conflict": "The tension pivot or controversial statement. 3-5 sentences.", - "insight": "The aha moment payoff lesson for founders. 3-5 sentences.", + "hook": "Opening 2-4 lines. Scroll-stopping.${isTips ? " Lead with the COMMON MISTAKE or counter-intuitive truth about AI agents." : ""}", + "storySetup": "The backstory context. 3-5 sentences.${isTips ? " The situation: why builders get this wrong." : ""}", + "conflict": "The tension pivot or controversial statement. 3-5 sentences.${isTips ? " The failure mode / what everyone does that backfires." : ""}", + "insight": "The aha moment payoff lesson for founders. 3-5 sentences.${isTips ? " THE concrete fix/pattern — specific, actionable, names the tool/technique." : ""}", "cta": "One clear action. One line.", "caption": "One punchy line without spoilers.", "onScreenText": "What appears on screen during the hook", diff --git a/src/app/api/youtube/ideas/generate/route.ts b/src/app/api/youtube/ideas/generate/route.ts index 984c9e5..2dfbbef 100644 --- a/src/app/api/youtube/ideas/generate/route.ts +++ b/src/app/api/youtube/ideas/generate/route.ts @@ -33,7 +33,11 @@ function braveSearch(query: string, braveKey: string, count = 5): { title: strin } } -export async function POST() { +export async function POST(req?: Request) { + const body = req ? await req.json().catch(() => ({})) : {}; + const category: string = body.category || "brands"; + const isTips = category === "ai-agent-tips"; + const apiKey = process.env.OPENAI_API_KEY || ""; const braveKey = process.env.BRAVE_API_KEY || ""; if (!apiKey) return NextResponse.json({ error: "No API key" }, { status: 500 }); @@ -65,10 +69,19 @@ export async function POST() { try { coveredTopics = fs.readFileSync(SCRIPTS_FILE, "utf-8").match(/### .+/g)?.join(", ") || ""; } catch { /* ok - file may not exist on Vercel */ } // -- STEP 1: Ask GPT to generate search queries -- - const queryGenResult = callOpenAI(apiKey, { - model: "gpt-4o-mini", - messages: [ - { role: "system", content: `You generate search queries to find fascinating REAL stories about brands, businesses, and cultural moments for a viral short-form video creator. + const querySystem = isTips + ? `You generate search queries to find practical, actionable AI AGENT tips and best practices for a viral short-form video creator. + +The creator makes videos for builders/developers who ship AI agents (coding agents, MCP servers, tool-using LLMs, agent loops). +Topics to cover: agent architecture, tool design, context window management, evals/testing, prompt engineering for agents, MCP, sub-agents, cost control, reliability/failure modes, memory, permissions/safety, orchestration patterns. + +Generate 10 diverse search queries for REAL, concrete tips (engineering blogs, docs, HN discussions, practitioner posts). Prefer "best practices", "lessons learned", "common mistakes", "how to", "patterns". + +DO NOT search for ideas already generated: ${existingTitles} +${rejectionContext} + +Output JSON: {"queries":["search query 1","search query 2",...]}` + : `You generate search queries to find fascinating REAL stories about brands, businesses, and cultural moments for a viral short-form video creator. The creator covers a WIDE mix: mainstream brands (Red Bull, Nike, Barbie, Tesla, Apple, McDonald's, etc.), tech, pop culture, and business strategy. Most videos are about mainstream brands with a marketing/founder lesson. @@ -81,8 +94,15 @@ DO NOT search for stories already covered: ${coveredTopics} DO NOT search for ideas already generated: ${existingTitles} ${rejectionContext} -Output JSON: {"queries":["search query 1","search query 2",...]}` }, - { role: "user", content: "10 diverse search queries for fascinating real stories. Mix mainstream brands, crypto, pop culture." } +Output JSON: {"queries":["search query 1","search query 2",...]}`; + + const queryGenResult = callOpenAI(apiKey, { + model: "gpt-4o-mini", + messages: [ + { role: "system", content: querySystem }, + { role: "user", content: isTips + ? "10 diverse search queries for real, actionable AI agent tips and lessons." + : "10 diverse search queries for fascinating real stories. Mix mainstream brands, crypto, pop culture." } ], temperature: 0.95, response_format: { type: "json_object" } @@ -104,10 +124,23 @@ Output JSON: {"queries":["search query 1","search query 2",...]}` }, ).join("\n\n"); // -- STEP 3: Feed real articles to GPT to create ideas -- - const ideaGenResult = callOpenAI(apiKey, { - model: "gpt-4o-mini", - messages: [ - { role: "system", content: `You create viral short-form video ideas for the user based on REAL articles and stories found via web search. + const ideaSystem = isTips + ? `You create viral short-form video IDEAS about practical AI AGENT tips for builders, based on REAL articles found via web search. + +CRITICAL RULES: +- Each idea MUST be one concrete, actionable tip/lesson from the search results below +- Each idea MUST include a sourceUrl from the search results +- Each idea MUST include a sourceSummary (1-2 sentence summary of the real tip/lesson) +- Hook must stop the scroll in 3 seconds — lead with a common MISTAKE or counter-intuitive truth +- Examples of strong hooks: "Your AI agent fails because of one missing tool", "Stop giving your agent 40 tools", "Nobody tells you this about MCP servers" +- The angle must name a SPECIFIC technique/tool/pattern (MCP, tool schemas, evals, context pruning, sub-agents...) — never vague "use AI better" +- Mix funnel stages: 5 TOF (wide builder appeal), 3 MOF (specific workflows), 2 BOF (advanced architecture) +- DO NOT repeat these existing ideas: ${existingTitles} +- Each hook should have normal punctuation (commas, periods) +${rejectionContext} + +Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scroll-stopper","angle":"specific technique/pattern","hookType":"observation|experience|expert|controversial","funnelStage":"TOF|MOF|BOF","sourceUrl":"url from search results","sourceSummary":"1-2 sentence summary of the real tip"}]}` + : `You create viral short-form video ideas for the user based on REAL articles and stories found via web search. CRITICAL RULES: - Each idea MUST be based on a REAL story from the search results below @@ -124,8 +157,15 @@ CRITICAL RULES: - Always name the SPECIFIC brand/person — never be vague ${rejectionContext} -Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scroll-stopper","angle":"founder/crypto lesson","hookType":"observation|experience|expert|controversial","funnelStage":"TOF|MOF|BOF","sourceUrl":"url from search results","sourceSummary":"1-2 sentence summary of the real story"}]}` }, - { role: "user", content: `Based on these REAL articles found via web search, create 10 video ideas. Each idea MUST reference a real story and include the source URL.\n\nSEARCH RESULTS:\n${searchContext}\n\nMake the hooks FIRE.` } +Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scroll-stopper","angle":"founder/crypto lesson","hookType":"observation|experience|expert|controversial","funnelStage":"TOF|MOF|BOF","sourceUrl":"url from search results","sourceSummary":"1-2 sentence summary of the real story"}]}`; + + const ideaGenResult = callOpenAI(apiKey, { + model: "gpt-4o-mini", + messages: [ + { role: "system", content: ideaSystem }, + { role: "user", content: isTips + ? `Based on these REAL articles found via web search, create 10 AI AGENT TIP video ideas. Each idea MUST be one concrete tip and include the source URL.\n\nSEARCH RESULTS:\n${searchContext}\n\nMake the hooks FIRE.` + : `Based on these REAL articles found via web search, create 10 video ideas. Each idea MUST reference a real story and include the source URL.\n\nSEARCH RESULTS:\n${searchContext}\n\nMake the hooks FIRE.` } ], temperature: 0.85, response_format: { type: "json_object" } @@ -149,6 +189,7 @@ Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scrol angle: idea.angle || null, hookType: idea.hookType || null, funnelStage: idea.funnelStage || null, + category, status: "pending", }, }); diff --git a/src/app/youtube/page.tsx b/src/app/youtube/page.tsx index cd42f09..a0e77eb 100644 --- a/src/app/youtube/page.tsx +++ b/src/app/youtube/page.tsx @@ -22,15 +22,17 @@ const LongformTab = dynamic(() => import("@/app/longform/page"), { ssr: false, l const OutlierFeed = dynamic(() => import("@/components/OutlierFeed"), { ssr: false, loading: () => }); interface Idea { + id: string; title: string; - hook: string; - angle: string; + hook?: string; + angle?: string; hookType?: string; funnelStage?: string; - rejectedReason?: string; - status?: "pending" | "rejected" | "approved"; + category?: string; sourceUrl?: string; sourceSummary?: string; + rejectedReason?: string; + status?: "pending" | "rejected" | "approved"; } interface Script { @@ -82,6 +84,7 @@ export default function YouTubePage() { const [batchGenerating, setBatchGenerating] = useState(false); const [ideaTab, setIdeaTab] = useState<"pending" | "rejected">("pending"); const [scriptTab, setScriptTab] = useState<"draft" | "approved" | "rejected">("draft"); + const [ideaCategory, setIdeaCategory] = useState<"brands" | "ai-agent-tips">("brands"); const [rejectModal, setRejectModal] = useState<{ type: "idea" | "script"; id: string; title: string } | null>(null); const rejectRef = React.useRef(null); @@ -139,7 +142,7 @@ export default function YouTubePage() { const res = await fetch("/api/youtube/generate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ topic: `${idea.title}. Hook angle: ${idea.hook}. Lesson: ${idea.angle}`, sourceUrl: idea.sourceUrl, sourceSummary: idea.sourceSummary }), + body: JSON.stringify({ topic: `${idea.title}. Hook angle: ${idea.hook}. Lesson: ${idea.angle}`, sourceUrl: idea.sourceUrl, sourceSummary: idea.sourceSummary, category: idea.category || ideaCategory }), }); if (res.ok) { const data = await res.json(); @@ -629,11 +632,24 @@ export default function YouTubePage() { }} >{batchGenerating ? `✨ Generating ${selectedIdeas.size}...` : `Generate ${selectedIdeas.size} Script${selectedIdeas.size > 1 ? "s" : ""}`} )} +