import { callLlm } from "@/lib/llm"; import type { GitHubRepoData } from "@/types"; /** Exactly the fields the webhook consumer expects (snake_case, flat). */ export interface SocialCopy { instagram_caption: string; youtube_caption: string; hashtags: string[]; } function normalizeTags(tags: unknown, repo: GitHubRepoData): string[] { const list = Array.isArray(tags) ? tags : []; const cleaned = list .filter((t): t is string => typeof t === "string") .map((t) => `#${t.trim().replace(/^#+/, "").replace(/\s+/g, "")}`) .filter((t) => t.length > 1); // ponytail: dedupe case-insensitively — "#GitHub" and "#github" read as spam repeats const seen = new Set(); const unique = cleaned.filter((t) => { const key = t.toLowerCase(); if (seen.has(key)) return false; seen.add(key); return true; }); if (unique.length) return unique; // fallback from repo metadata so the webhook always carries usable tags return [ "#GitHub", "#OpenSource", "#Coding", ...(repo.language ? [`#${repo.language.replace(/\s+/g, "")}`] : []), ...repo.topics.slice(0, 3).map((t) => `#${t.replace(/[^a-z0-9]/gi, "")}`), ].filter((t) => t.length > 1); } function fallbackCopy(repo: GitHubRepoData, script: string): SocialCopy { const summary = repo.description || script; return { instagram_caption: `${summary} Check out ${repo.fullName} on GitHub and see what you can build with it.`, youtube_caption: `Repo: ${repo.fullName}. ${summary} GitHub: ${repo.htmlUrl}. Don't forget to subscribe for more developer tool updates!`, hashtags: normalizeTags(null, repo), }; } /** * Instagram + YouTube copy for a finished reel. Never throws: social text is a * nice-to-have, so a flaky LLM must not sink an otherwise successful render. */ export async function generateSocialCopy( repo: GitHubRepoData, script: string, ): Promise { const prompt = `You write social media copy for a short vertical video about a GitHub repo. Repo: ${repo.fullName} Description: ${repo.description || "No description"} Language: ${repo.language} Stars: ${repo.stars} Topics: ${repo.topics.join(", ") || "none"} Repo URL: ${repo.htmlUrl} README excerpt: ${repo.readme.slice(0, 1500)} The video narration says: ${script} Return ONLY minified JSON, no markdown fence, exactly this shape: {"instagram_caption":"...","youtube_caption":"...","hashtags":["#Tag"]} instagram_caption rules: - One flowing paragraph, 600-900 characters - Opens with a question or pain point the repo solves - Names the repo and explains what it actually does - Mentions a concrete detail (stars, a key feature, who it is for) - Ends with a question to the reader plus a call to check the repo - No hashtags and no line breaks inside the caption youtube_caption rules: - One paragraph, 400-700 characters - Starts exactly with "Repo: ${repo.fullName}." - Explains the problem it solves and who should use it - Includes "GitHub: ${repo.htmlUrl}" - Ends with a line inviting viewers to subscribe for more developer tool updates hashtags rules: - 5-8 tags, PascalCase, each starting with # - Broad reach tags (#AI, #OpenSource, #GitHub) plus 2-3 specific to this repo - No spaces inside a tag`; // ponytail: two tries — this model stalls now and then, and a retry is cheaper // than shipping the metadata fallback to a live social account for (let attempt = 1; attempt <= 2; attempt++) { try { const raw = await callLlm(prompt, { maxTokens: 900, temperature: 0.7 }); // models like wrapping JSON in prose or a fence — take the outermost object const json = raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1); const parsed = JSON.parse(json) as Partial; const instagram = parsed.instagram_caption?.trim().replace(/\s+/g, " "); const youtube = parsed.youtube_caption?.trim().replace(/\s+/g, " "); if (!instagram || !youtube) { throw new Error("LLM omitted instagram_caption or youtube_caption"); } return { instagram_caption: instagram.slice(0, 2200), youtube_caption: youtube.slice(0, 5000), hashtags: normalizeTags(parsed.hashtags, repo), }; } catch (error) { console.error(`Social copy attempt ${attempt} failed:`, error); } } console.error("Social copy generation failed twice, using metadata fallback"); return fallbackCopy(repo, script); }