← 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 };