diff --git a/src/agents/content-agent.ts b/src/agents/content-agent.ts index 941b42e..1f1afb1 100644 --- a/src/agents/content-agent.ts +++ b/src/agents/content-agent.ts @@ -6,8 +6,8 @@ import { createClient } from 'jsr:@supabase/supabase-js@2'; -const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!; -const SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; +const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'http://localhost:54321'; +const SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || 'mock-key'; const GROQ_API_KEY = Deno.env.get('GROQ_API_KEY') ?? ''; const GEMINI_API_KEY = Deno.env.get('GEMINI_API_KEY') ?? ''; @@ -19,7 +19,7 @@ export interface ContentOutput { blog_post: string; // ~300 words } -async function callLLM(prompt: string): Promise { +export async function callLLM(prompt: string): Promise { // Try Groq first (faster, higher free limit) if (GROQ_API_KEY) { const r = await fetch('https://api.groq.com/openai/v1/chat/completions', { @@ -31,6 +31,9 @@ async function callLLM(prompt: string): Promise { max_tokens: 1024 }) }); + if (!r.ok) { + throw new Error('LLM call failed'); + } const data = await r.json(); return data.choices?.[0]?.message?.content ?? ''; } @@ -42,27 +45,39 @@ async function callLLM(prompt: string): Promise { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }) }); + if (!r.ok) { + throw new Error('LLM call failed'); + } const data = await r.json(); return data.candidates?.[0]?.content?.parts?.[0]?.text ?? ''; } + + if (Deno.env.get('MOCK_LLM_FOR_TESTING') === 'true') { + return `Mocked response for prompt: ${prompt.slice(0, 20)}...`; + } throw new Error('No LLM API key configured. Set GROQ_API_KEY or GEMINI_API_KEY.'); } -export async function generateContent(bountyId: string): Promise { +// Ensure the db client can be mocked in tests +export { db }; + +export async function generate_content(bounty_id: string): Promise { // Fetch bounty details - const { data: bounty } = await db + const { data: bounty, error } = await db .from('bounty_executions') .select('title, description, reward_amount, repo_owner, repo_name, pr_number') - .eq('id', bountyId) + .eq('id', bounty_id) .maybeSingle(); - if (!bounty) throw new Error(`Bounty not found: ${bountyId}`); + if (error || !bounty) { + throw new Error(`Bounty not found: ${bounty_id}`); + } const ctx = `Bounty: "${bounty.title}" | Reward: $${bounty.reward_amount} USDC | Repo: ${bounty.repo_owner}/${bounty.repo_name} | PR: #${bounty.pr_number}`; // Generate tweet - const tweet = await callLLM( + const tweetText = await callLLM( `Write a single tweet (max 280 chars) announcing this completed open-source bounty. Be enthusiastic, include the reward amount and a call to action. No hashtag spam. Context: ${ctx}` ); @@ -70,35 +85,39 @@ export async function generateContent(bountyId: string): Promise const threadRaw = await callLLM( `Write a 5-tweet Twitter thread announcing this completed bounty and explaining why open AI bounties matter. Each tweet separated by "---". Context: ${ctx}` ); - const thread = threadRaw.split('---').map(t => t.trim()).filter(Boolean).slice(0, 5); + const thread = threadRaw.split('---').map((t: string) => t.trim()).filter(Boolean).slice(0, 5); // Generate blog post const blog_post = await callLLM( `Write a 300-word blog post about this completed open-source AI bounty. Include: what was built, why it matters, how others can participate. Professional but accessible tone. Context: ${ctx}` ); + const tweet = tweetText.slice(0, 280); + // Store in outreach_sent await db.from('outreach_sent').insert({ - bounty_id: bountyId, + bounty_id: bounty_id, channel: 'content_agent', content: JSON.stringify({ tweet, thread, blog_post }), sent_at: new Date().toISOString() }); - return { tweet: tweet.slice(0, 280), thread, blog_post }; + return { tweet, thread, blog_post }; } // Edge Function entry point -Deno.serve(async (req: Request) => { - if (req.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); - try { - const { bounty_id } = await req.json(); - if (!bounty_id) return new Response(JSON.stringify({ error: 'bounty_id required' }), { status: 400 }); - const content = await generateContent(bounty_id); - return new Response(JSON.stringify({ ok: true, content }), { - headers: { 'Content-Type': 'application/json' } +if (import.meta.main) { + Deno.serve(async (req: Request) => { + if (req.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); + try { + const { bounty_id } = await req.json(); + if (!bounty_id) return new Response(JSON.stringify({ error: 'bounty_id required' }), { status: 400 }); + const content = await generate_content(bounty_id); + return new Response(JSON.stringify({ ok: true, content }), { + headers: { 'Content-Type': 'application/json' } + }); + } catch (e: any) { + return new Response(JSON.stringify({ error: e.message || String(e) }), { status: 500 }); + } }); - } catch (e) { - return new Response(JSON.stringify({ error: String(e) }), { status: 500 }); - } -}); +} diff --git a/tests/content-agent.test.ts b/tests/content-agent.test.ts new file mode 100644 index 0000000..0e37c4b --- /dev/null +++ b/tests/content-agent.test.ts @@ -0,0 +1,97 @@ +import { assertEquals, assertRejects } from 'https://deno.land/std@0.224.0/assert/mod.ts'; +import { generate_content, db } from '../src/agents/content-agent.ts'; + +// Mock the db client for testing +const originalFrom = db.from; + +Deno.test('generate_content generates content and saves it to outreach_sent', async () => { + Deno.env.set('MOCK_LLM_FOR_TESTING', 'true'); + + let insertedData: any = null; + db.from = (table: string) => { + if (table === 'bounty_executions') { + return { + select: () => ({ + eq: (field: string, val: string) => ({ + maybeSingle: async () => { + if (val === 'test-123') { + return { + data: { + title: 'Test Bounty', + description: 'A test bounty', + reward_amount: 100, + repo_owner: 'Nexussyn', + repo_name: 'ai-growth-engine', + pr_number: 42 + }, + error: null + }; + } + return { data: null, error: null }; + } + }) + }) + } as any; + } + if (table === 'outreach_sent') { + return { + insert: async (data: any) => { + insertedData = data; + return { data, error: null }; + } + } as any; + } + return originalFrom.call(db, table); + }; + + try { + const content = await generate_content('test-123'); + + // Assert content generation + assertEquals(typeof content.tweet, 'string'); + assertEquals(Array.isArray(content.thread), true); + assertEquals(typeof content.blog_post, 'string'); + + // Assert outreach_sent insertion + assertEquals(insertedData.bounty_id, 'test-123'); + assertEquals(insertedData.channel, 'content_agent'); + + const parsedContent = JSON.parse(insertedData.content); + assertEquals(parsedContent.tweet, content.tweet); + assertEquals(parsedContent.thread, content.thread); + assertEquals(parsedContent.blog_post, content.blog_post); + } finally { + db.from = originalFrom; + Deno.env.delete('MOCK_LLM_FOR_TESTING'); + } +}); + +Deno.test('generate_content throws error for invalid bounty_id', async () => { + Deno.env.set('MOCK_LLM_FOR_TESTING', 'true'); + + db.from = (table: string) => { + if (table === 'bounty_executions') { + return { + select: () => ({ + eq: (field: string, val: string) => ({ + maybeSingle: async () => { + return { data: null, error: null }; + } + }) + }) + } as any; + } + return originalFrom.call(db, table); + }; + + try { + await assertRejects( + () => generate_content('invalid-id'), + Error, + 'Bounty not found: invalid-id' + ); + } finally { + db.from = originalFrom; + Deno.env.delete('MOCK_LLM_FOR_TESTING'); + } +});