← back to SDCC Stories
lib/gemini.js
208 lines
/**
* Gemini Integration — Story generation + social media graphics
* Uses gemini-2.0-flash for text, gemini-2.5-flash-image for graphics
*/
const https = require('https');
const fs = require('fs');
const GEMINI_TEXT_KEY = '${GEMINI_API_KEY}';
const GEMINI_IMAGE_KEY = '${GEMINI_API_KEY}';
const TEXT_MODEL = 'gemini-2.0-flash';
const IMAGE_MODEL = 'gemini-2.5-flash-image';
const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
// SDCC Brand Colors
const COLORS = {
action: '#ef4444', // Red — Lane A
policy: '#3b82f6', // Blue — Lane B
resources: '#22c55e', // Green — Lane C
bg: '#0a0a12',
surface: '#12121e',
text: '#fafafa',
secondary: '#eab308', // Gold accent
muted: '#71717a'
};
function geminiRequest(model, key, body) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const url = `${BASE_URL}/${model}:generateContent?key=${key}`;
const parsed = new URL(url);
const req = https.request({
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
}, (res) => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(new Error('Gemini parse error: ' + data.slice(0, 200))); }
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
/**
* Generate a social media story from borrower data
* Returns: { story_text, instagram_caption, x_post, hashtags }
*/
async function generateStory(candidate) {
const prompt = `You are writing a compelling 1-2 sentence social media story for a student debt advocacy organization.
The goal is to humanize the student debt crisis and encourage others to share their stories.
BORROWER DATA:
- Name: ${candidate.first_name || 'Anonymous'}
- Age: ${candidate.age || 'Unknown'}
- Amount owed: ${candidate.amount_owed_raw || candidate.amount_display || 'undisclosed'}
- Emotional impact (P): ${(candidate.emotional_impact || '').slice(0, 500)}
- What they can't afford (Q): ${(candidate.affordability_impacts_raw || '').slice(0, 300)}
- What they'd spend money on instead (R): ${(candidate.alt_spending || '').slice(0, 300)}
- Their story: ${(candidate.story || '').slice(0, 500)}
- Key quote: ${(candidate.key_quote || '').slice(0, 200)}
- Loan type: ${candidate.loan_types_raw || ''}
- Repayment plan: ${candidate.repayment_plan || ''}
Generate EXACTLY this JSON (no markdown, no backticks):
{
"story_one_liner": "A powerful 1-sentence story (max 140 chars) using their name and amount, suitable for a graphic overlay",
"instagram_caption": "2-3 sentence Instagram caption that tells their story emotionally. End with a CTA to share your own story. Include line breaks.",
"x_post": "Concise X/Twitter post under 280 characters. Impactful, shareable. Include the person's first name.",
"hashtags": ["5-7 relevant hashtags without # symbol"],
"story_category": "one of: hardship, resilience, injustice, hope, call_to_action"
}
RULES:
- Use their REAL first name (not "Anonymous" unless no name given)
- Include their debt amount naturally in the narrative
- Focus on the HUMAN impact, not policy
- Make it shareable — people should feel compelled to repost
- Instagram caption should have 2-3 paragraph breaks for readability
- X post must be under 280 characters total including hashtags`;
const result = await geminiRequest(TEXT_MODEL, GEMINI_TEXT_KEY, {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.8, maxOutputTokens: 500 }
});
const text = result?.candidates?.[0]?.content?.parts?.[0]?.text || '';
// Extract JSON from response (handle markdown wrapping)
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) return { story_one_liner: '', instagram_caption: '', x_post: '', hashtags: [], story_category: 'hardship' };
try {
return JSON.parse(jsonMatch[0]);
} catch (e) {
return { story_one_liner: text.slice(0, 140), instagram_caption: '', x_post: '', hashtags: [], story_category: 'hardship' };
}
}
/**
* Generate a social media graphic with SDCC branding
* Returns: { imageBuffer, text }
*/
async function generateGraphic(candidate, storyText, options = {}) {
const lane = options.lane || 'action';
const laneColor = COLORS[lane] || COLORS.action;
const laneLabel = lane === 'action' ? 'BORROWER STORY' : lane === 'policy' ? 'POLICY IMPACT' : 'RESOURCES';
const prompt = `Create a 1080x1080 social media graphic for a student debt advocacy organization.
DESIGN REQUIREMENTS:
- Background: Dark gradient from ${COLORS.bg} to ${COLORS.surface}
- Border: 4px solid ${laneColor} (${lane} lane)
- Top banner: ${laneColor} strip with white text "${laneLabel}"
- Main text area: Large white text with the story quote
- Bottom section: Name, amount owed, and organization watermark
- Typography: Clean, modern, sans-serif. Story text should be the hero element.
- Mood: Serious but hopeful. Professional advocacy graphics.
CONTENT:
- Quote/Story: "${storyText}"
- Name: ${candidate.first_name || 'Anonymous'}
- Amount: ${candidate.amount_owed_raw || candidate.amount_display || ''}
- Organization: Student Debt Crisis Center
STYLE:
- Think editorial/news graphic style
- No stock photos, just typography + color
- The quote should be the dominant visual element
- Include quotation marks around the story text
- Small "studentdebtcrisis.org" watermark bottom right
Do NOT include any faces or photographs. Text + color only.`;
try {
const result = await geminiRequest(IMAGE_MODEL, GEMINI_IMAGE_KEY, {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { responseModalities: ['TEXT', 'IMAGE'] }
});
const parts = result?.candidates?.[0]?.content?.parts || [];
let imageBuffer = null;
let responseText = '';
for (const part of parts) {
if (part.inlineData) {
imageBuffer = Buffer.from(part.inlineData.data, 'base64');
}
if (part.text) {
responseText = part.text;
}
}
return { imageBuffer, text: responseText };
} catch (e) {
console.error('Gemini image generation error:', e.message);
return { imageBuffer: null, text: e.message };
}
}
/**
* Generate a lawmaker-targeted graphic
*/
async function generateLawmakerGraphic(candidate, lawmaker, storyText) {
const prompt = `Create a 1080x1080 advocacy graphic targeting a specific lawmaker.
DESIGN:
- Background: Dark gradient ${COLORS.bg} to ${COLORS.surface}
- Top: ${COLORS.policy} (blue) banner with "YOUR CONSTITUENT'S STORY"
- Middle: Large quote text in white
- Bottom left: Borrower name + amount owed
- Bottom right: "Rep. ${lawmaker.name}" + "${lawmaker.district}" + "${lawmaker.party}"
- Border: 4px ${COLORS.policy} blue
- Watermark: studentdebtcrisis.org
CONTENT:
- Quote: "${storyText}"
- Borrower: ${candidate.first_name}, ${candidate.zip_code || ''}
- Amount: ${candidate.amount_owed_raw || ''}
- Lawmaker: Rep. ${lawmaker.name} (${lawmaker.party}-${lawmaker.state})
- District: ${lawmaker.district}
STYLE: Editorial, serious, professional. No photos. Typography + color only.`;
try {
const result = await geminiRequest(IMAGE_MODEL, GEMINI_IMAGE_KEY, {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { responseModalities: ['TEXT', 'IMAGE'] }
});
const parts = result?.candidates?.[0]?.content?.parts || [];
let imageBuffer = null;
for (const part of parts) {
if (part.inlineData) imageBuffer = Buffer.from(part.inlineData.data, 'base64');
}
return { imageBuffer };
} catch (e) {
return { imageBuffer: null };
}
}
module.exports = { generateStory, generateGraphic, generateLawmakerGraphic, COLORS };