Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 42 additions & 23 deletions src/agents/content-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') ?? '';

Expand All @@ -19,7 +19,7 @@ export interface ContentOutput {
blog_post: string; // ~300 words
}

async function callLLM(prompt: string): Promise<string> {
export async function callLLM(prompt: string): Promise<string> {
// Try Groq first (faster, higher free limit)
if (GROQ_API_KEY) {
const r = await fetch('https://api.groq.com/openai/v1/chat/completions', {
Expand All @@ -31,6 +31,9 @@ async function callLLM(prompt: string): Promise<string> {
max_tokens: 1024
})
});
if (!r.ok) {
throw new Error('LLM call failed');
}
const data = await r.json();
return data.choices?.[0]?.message?.content ?? '';
}
Expand All @@ -42,63 +45,79 @@ async function callLLM(prompt: string): Promise<string> {
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<ContentOutput> {
// Ensure the db client can be mocked in tests
export { db };

export async function generate_content(bounty_id: string): Promise<ContentOutput> {
// 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}`
);

// Generate thread
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 });
}
});
}
97 changes: 97 additions & 0 deletions tests/content-agent.test.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});