← back to Norma
agents/shared/ai.js
220 lines
/**
* Norma AI Helper — Gemini 2.0 Flash
*
* Content generation for petition creation, social replies,
* talking points, and platform-specific content adaptation.
*/
const https = require('https');
const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
function getGeminiKey() {
const geminiKey = process.env.GEMINI_API_KEY;
if (!geminiKey) {
throw new Error('[agents/shared/ai] GEMINI_API_KEY env var is required');
}
return geminiKey;
}
/**
* Make a POST request to Gemini API.
*
* @param {string} prompt - The text prompt
* @param {Object} options
* @param {number} [options.maxTokens=2048] - Max output tokens
* @param {number} [options.temperature=0.7] - Temperature (0.0 - 1.0)
* @returns {Promise<string>} The generated text response
*/
function generateContent(prompt, { maxTokens = 2048, temperature = 0.7 } = {}) {
return new Promise((resolve, reject) => {
const url = new URL(`${GEMINI_URL}?key=${getGeminiKey()}`);
const payload = JSON.stringify({
contents: [
{
parts: [{ text: prompt }],
},
],
generationConfig: {
maxOutputTokens: maxTokens,
temperature: temperature,
},
});
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
timeout: 30000,
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
try {
const parsed = JSON.parse(body);
if (res.statusCode !== 200) {
const errMsg = parsed.error?.message || `Gemini API returned ${res.statusCode}`;
console.error(`[${new Date().toISOString()}] [ai] Gemini error: ${errMsg}`);
reject(new Error(errMsg));
return;
}
// Extract text from Gemini response
const text = parsed.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) {
reject(new Error('No text in Gemini response'));
return;
}
resolve(text);
} catch (e) {
reject(new Error(`Failed to parse Gemini response: ${e.message}`));
}
});
});
req.on('error', (err) => {
console.error(`[${new Date().toISOString()}] [ai] Gemini request error:`, err.message);
reject(err);
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Gemini request timed out after 30s'));
});
req.write(payload);
req.end();
});
}
/**
* Generate petition content using AI.
* Returns structured petition data ready for platform posting.
*
* @param {Object} params
* @param {string} params.topic - Petition topic (e.g. 'civic advocacy')
* @param {string} params.target - Who the petition targets (e.g. 'Department of Education')
* @param {string} params.category - Category (e.g. 'education', 'environment', 'healthcare')
* @param {string} [params.tone='urgent'] - Tone: 'urgent', 'professional', 'grassroots', 'data-driven'
* @returns {Promise<{ title: string, body: string, talking_points: string[], target: string, tone: string, confidence: number }>}
*/
async function generatePetitionContent({ topic, target, category, tone = 'urgent' }) {
const prompt = `You are a civic engagement strategist. Generate a compelling petition for the following:
TOPIC: ${topic}
TARGET: ${target}
CATEGORY: ${category}
TONE: ${tone}
Respond ONLY with valid JSON (no markdown, no code fences, no explanation) in this exact format:
{
"title": "A compelling petition title (max 120 chars)",
"body": "The full petition body text (2-4 paragraphs, persuasive and factual)",
"talking_points": ["Point 1", "Point 2", "Point 3", "Point 4", "Point 5"],
"target": "${target}",
"tone": "${tone}",
"confidence": 0.85
}
Requirements:
- Title should be action-oriented and specific
- Body should include facts, emotional appeal, and a clear call to action
- Talking points should be concise and shareable
- Confidence is your self-assessed quality score (0.0 to 1.0)`;
try {
const text = await generateContent(prompt, { maxTokens: 2048, temperature: 0.7 });
// Parse JSON from response (strip any accidental markdown fences)
const cleaned = text.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(cleaned);
// Validate required fields
if (!result.title || !result.body || !result.talking_points) {
throw new Error('Missing required fields in petition response');
}
return result;
} catch (err) {
console.error(`[${new Date().toISOString()}] [ai] generatePetitionContent error:`, err.message);
throw err;
}
}
/**
* Generate a contextual reply for social platform engagement.
* Replies are designed to be authentic, relevant, and drive petition awareness.
*
* @param {Object} params
* @param {string} params.context - The post/comment being replied to
* @param {string} [params.petitionUrl] - URL of the petition to promote (optional)
* @param {string} params.platform - Platform name ('reddit', 'twitter', 'discord', 'twitch')
* @returns {Promise<{ reply: string, hashtags: string[] }>}
*/
async function generateReply({ context, petitionUrl, platform }) {
const platformGuidelines = {
reddit: 'Keep it conversational. No emojis. Reference specific points from the post. Avoid sounding like a bot or marketer. Max 300 words.',
twitter: 'Max 280 chars. Use 1-2 relevant hashtags. Be punchy and direct. Can use emojis sparingly.',
discord: 'Casual and community-oriented. Can be slightly longer. Reference the channel topic. Use Discord-style formatting if helpful.',
twitch: 'Keep it very short (1-2 sentences). Casual chat style. Reference the streamer or game if relevant.',
};
const guidelines = platformGuidelines[platform] || 'Be concise, authentic, and relevant.';
const petitionNote = petitionUrl
? `If naturally relevant, mention this petition: ${petitionUrl}. Do NOT force it — only include if it genuinely fits the conversation.`
: 'Do not mention any petition — just engage authentically with the topic.';
const prompt = `You are an authentic community member on ${platform}. Generate a reply to the following post/comment:
---
${context}
---
PLATFORM GUIDELINES: ${guidelines}
${petitionNote}
Respond ONLY with valid JSON (no markdown, no code fences):
{
"reply": "Your reply text here",
"hashtags": ["relevant", "hashtags"]
}
Important:
- Sound like a real person, not a bot
- Be empathetic and add value to the conversation
- Don't be preachy or aggressive
- Hashtags should be relevant (empty array if not applicable)`;
try {
const text = await generateContent(prompt, { maxTokens: 1024, temperature: 0.8 });
const cleaned = text.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(cleaned);
if (!result.reply) {
throw new Error('Missing reply field in response');
}
return {
reply: result.reply,
hashtags: result.hashtags || [],
};
} catch (err) {
console.error(`[${new Date().toISOString()}] [ai] generateReply error:`, err.message);
throw err;
}
}
module.exports = { generateContent, generatePetitionContent, generateReply };