Skip to content
Merged
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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A powerful CLI tool and Node.js module for sending mass lead emails with AI-powe

- 📧 **Mass Email Campaigns** - Send personalized emails to hundreds of leads
- 📞 **Voice AI Cold-calling** - Automated cold calling with Bland.ai integration
- 🤖 **AI Personalization** - OpenAI-powered template customization
- 🤖 **AI Personalization** - template customization on c0mpute, with OpenAI as the fallback
- 📊 **CSV Processing** - Import leads from CSV files with validation
- 🎯 **Smart Templates** - 10 pre-built sales email templates
- 📈 **Batch Processing** - Configurable batch sizes with rate limiting
Expand Down Expand Up @@ -43,7 +43,13 @@ Create a `.env` file with your API keys:
MAILGUN_API_KEY=your_mailgun_api_key_here
MAILGUN_DOMAIN=your_mailgun_domain_here

# OpenAI Configuration (Required for AI personalization)
# AI personalization runs on c0mpute first (no key needed: 10 free calls a day;
# an Infernet account key ik_live_... lifts that). C0MPUTE_DISABLED=1 turns it off.
C0MPUTE_API_KEY=your_infernet_account_key
# c0mpute is slow: 60 to 240 s per lead is normal. Whole-call cap:
C0MPUTE_TIMEOUT_MS=300000

# OpenAI (fallback when c0mpute fails)
OPENAI_API_KEY=your_openai_api_key_here

# Bland.ai Configuration (Required for voice calling)
Expand Down Expand Up @@ -445,7 +451,8 @@ MAILGUN_API_KEY=your_mailgun_api_key
MAILGUN_DOMAIN=your_mailgun_domain
MAILGUN_BASE_URL=https://api.mailgun.net

# OpenAI Configuration (AI Personalization)
# AI Personalization: c0mpute first, OpenAI fallback
C0MPUTE_API_KEY=your_infernet_account_key
OPENAI_API_KEY=your_openai_api_key
OPENAI_MODEL=gpt-4o-mini
OPENAI_MAX_TOKENS=500
Expand Down Expand Up @@ -576,7 +583,7 @@ lead-generator/
├── src/
│ ├── templates/ # Email templates
│ ├── csv-parser.js # CSV processing
│ ├── ai-service.js # OpenAI integration
│ ├── ai-service.js # AI personalization (c0mpute, OpenAI fallback)
│ ├── email-service.js # Mailgun integration
│ └── voice-service.js # Voice AI stub
├── test/ # Test files
Expand Down
20 changes: 16 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,16 @@ export {
getAllTones
} from './src/email-templates/index.js';

// Import for internal use
// Import for internal use (LeadGenerator calls these; `export ... from`
// does not bind them in this module)
import { processLeadsFromCSV } from './src/csv-parser.js';
import {
personalizeTemplate,
batchPersonalize,
getPersonalizationStats
} from './src/ai-service.js';
import { sendBatchEmails } from './src/email-service.js';
import { isC0mputeDisabled } from './src/c0mpute.js';
import {
templates as emailTemplates,
getTemplateById as getEmailTemplateById,
Expand Down Expand Up @@ -98,7 +107,9 @@ export class LeadGenerator {
apiKey: config.openaiApiKey || process.env.OPENAI_API_KEY,
model: config.openaiModel || process.env.OPENAI_MODEL || 'gpt-4o-mini',
maxTokens: config.openaiMaxTokens || parseInt(process.env.OPENAI_MAX_TOKENS) || 500,
temperature: config.openaiTemperature || parseFloat(process.env.OPENAI_TEMPERATURE) || 0.7
temperature: config.openaiTemperature || parseFloat(process.env.OPENAI_TEMPERATURE) || 0.7,
// c0mpute is tried first; key defaults to C0MPUTE_API_KEY
c0mputeApiKey: config.c0mputeApiKey || process.env.C0MPUTE_API_KEY
},

// Sender information
Expand Down Expand Up @@ -237,8 +248,9 @@ export class LeadGenerator {
errors.push('Sender name is required');
}

// OpenAI is optional but warn if personalization is enabled
if (this.config.campaign.enablePersonalization && !this.config.openai.apiKey) {
// Personalization runs on c0mpute with no key; OpenAI is only required
// when C0MPUTE_DISABLED turns c0mpute off
if (this.config.campaign.enablePersonalization && !this.config.openai.apiKey && isC0mputeDisabled()) {
errors.push('OpenAI API key is required for AI personalization (or disable personalization)');
}

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/lead-generator",
"version": "1.2.4",
"version": "1.3.0",
"description": "A CLI tool for sending mass lead emails to prospective clients from CSV files with AI-powered personalization",
"type": "module",
"main": "index.js",
Expand Down Expand Up @@ -61,6 +61,7 @@
"packageManager": "pnpm@10.6.5",
"dependencies": {
"@hono/node-server": "^1.14.4",
"@profullstack/ai-providers": "0.1.2",
"ansi-colors": "^4.1.3",
"cli-progress": "^3.12.0",
"csv-parser": "^3.0.0",
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 40 additions & 6 deletions src/ai-service.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/**
* AI Service Module
* Handles OpenAI integration for template personalization
* Template personalization: c0mpute first (see ./c0mpute.js), OpenAI as the
* fallback when a key is set.
*/

import OpenAI from 'openai';
import dotenv from 'dotenv';
import { createC0mpute } from './c0mpute.js';

dotenv.config();

Expand All @@ -21,14 +23,24 @@ export class AIService {
...config
};

if (!this.config.apiKey) {
// c0mpute first. `c0mpute: false` skips it; a router can be passed in.
this.c0mpute = config.c0mpute === false
? null
: config.c0mpute || createC0mpute({
apiKey: config.c0mputeApiKey,
timeoutMs: config.c0mputeTimeoutMs
});

if (!this.config.apiKey && !config.client && !this.c0mpute) {
throw new Error('OpenAI API key is required');
}

// Use provided client for testing, otherwise create new OpenAI client
this.client = config.client || new OpenAI({
apiKey: this.config.apiKey
});
this.client = config.client || (this.config.apiKey
? new OpenAI({
apiKey: this.config.apiKey
})
: null);
}

/**
Expand All @@ -38,13 +50,34 @@ export class AIService {
* @returns {Promise<string>} AI response
*/
async generateCompletion(prompt, options = {}) {
const system = 'You are a professional sales email personalization assistant. Generate personalized, professional, and engaging content based on the provided information.';

if (this.c0mpute) {
try {
const { object } = await this.c0mpute.generateObject({
system,
user: prompt,
maxTokens: options.maxTokens || this.config.maxTokens,
temperature: options.temperature || this.config.temperature,
feature: 'lead-generator'
});
this.lastProvider = 'c0mpute';
return JSON.stringify(object);
} catch (error) {
if (!this.client) {
throw new Error(`AI completion failed: ${error.message}`);
}
console.warn(`c0mpute failed, falling back to OpenAI: ${error.message}`);
}
}

try {
const response = await this.client.chat.completions.create({
model: this.config.model,
messages: [
{
role: 'system',
content: 'You are a professional sales email personalization assistant. Generate personalized, professional, and engaging content based on the provided information.'
content: system
},
{
role: 'user',
Expand All @@ -56,6 +89,7 @@ export class AIService {
response_format: { type: 'json_object' }
});

this.lastProvider = 'openai';
return response.choices[0]?.message?.content;
} catch (error) {
if (error.status === 429) {
Expand Down
49 changes: 49 additions & 0 deletions src/c0mpute.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* c0mpute first, the library's own vendor code second.
*
* Text generation goes to the c0mpute peer network through
* @profullstack/ai-providers. When c0mpute fails (no node, timeout, free tier
* used up), the caller runs its existing OpenAI code exactly as before, so a
* failure on both looks the same as a failure did before c0mpute.
*
* C0MPUTE_API_KEY Infernet account key (ik_live_...). Without one the
* network still answers, on a free tier of 10 calls a day.
* C0MPUTE_TIMEOUT_MS whole call, default 300000 (c0mpute runs at a few
* tokens a second; 60 to 240 s per call is normal)
* C0MPUTE_DISABLED 1 skips c0mpute entirely
*/
import { createAiProviders } from '@profullstack/ai-providers';

const ON = /^(1|true|yes|on)$/i;
const routers = new Map();

/** True when C0MPUTE_DISABLED turns c0mpute off. */
export function isC0mputeDisabled(env = process.env) {
return ON.test(env.C0MPUTE_DISABLED ?? '');
}

/**
* A c0mpute-only router (no paid fallback inside it: the library keeps its
* own), or null when c0mpute is disabled. One router per key and timeout, so
* the concurrency cap and parking cover every call in the process.
* @param {object} [opts]
* @param {string} [opts.apiKey] overrides C0MPUTE_API_KEY
* @param {number} [opts.timeoutMs] overrides C0MPUTE_TIMEOUT_MS
* @param {Record<string, string|undefined>} [opts.env]
*/
export function createC0mpute({ apiKey, timeoutMs, env = process.env } = {}) {
if (isC0mputeDisabled(env)) return null;
const key = apiKey || env.C0MPUTE_API_KEY || env.INFERNET_API_KEY || '';
const id = `${key}\0${timeoutMs ?? ''}`;
if (!routers.has(id)) {
routers.set(
id,
createAiProviders({
env: { ...env, C0MPUTE_API_KEY: key },
paidFallback: false,
c0mpute: timeoutMs ? { timeoutMs } : {},
}),
);
}
return routers.get(id);
}
5 changes: 2 additions & 3 deletions src/cli-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,14 @@ export async function promptForConfig() {
}
]);

console.log('\n🤖 AI Configuration (OpenAI)');
console.log('\n🤖 AI Configuration (c0mpute first, OpenAI fallback)');
console.log('----------------------------');

const aiAnswers = await inquirer.prompt([
{
type: 'input',
name: 'openaiApiKey',
message: 'OpenAI API Key:',
validate: (input) => input.trim() ? true : 'OpenAI API key is required'
message: 'OpenAI API Key (fallback; leave blank to use c0mpute only):'
},
{
type: 'list',
Expand Down
4 changes: 3 additions & 1 deletion src/config-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { isC0mputeDisabled } from './c0mpute.js';

/**
* Get the path to the configuration file
Expand Down Expand Up @@ -78,7 +79,8 @@ export function validateConfig(config) {
errors.push('Mailgun domain is required');
}

if (!config?.ai?.openai?.apiKey || config.ai.openai.apiKey.trim() === '') {
// c0mpute needs no key, so OpenAI is only required when c0mpute is off
if (isC0mputeDisabled() && (!config?.ai?.openai?.apiKey || config.ai.openai.apiKey.trim() === '')) {
errors.push('OpenAI API key is required');
}

Expand Down
59 changes: 55 additions & 4 deletions test/ai-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { expect } from 'chai';
import sinon from 'sinon';
import { personalizeTemplate, generatePlaceholderValues, AIService } from '../src/ai-service.js';

// The OpenAI tests below exercise the fallback path; c0mpute is covered in
// its own block with a stub router.
process.env.C0MPUTE_DISABLED = '1';

describe('AI Service', () => {
let aiService;
let mockOpenAI;
Expand Down Expand Up @@ -195,9 +199,15 @@ Best,
});

it('should validate required configuration', () => {
expect(() => {
new AIService({});
}).to.throw('OpenAI API key is required');
const saved = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
expect(() => {
new AIService({});
}).to.throw('OpenAI API key is required');
} finally {
if (saved !== undefined) process.env.OPENAI_API_KEY = saved;
}
});

it('should create prompt for template personalization', () => {
Expand Down Expand Up @@ -266,4 +276,45 @@ Best,
expect(result.body).to.equal('Hi John.');
});
});
});

describe('c0mpute first', () => {
it('does not need an OpenAI key when c0mpute is available', () => {
const service = new AIService({ apiKey: '', c0mpute: { generateObject: sinon.stub() } });
expect(service.client).to.equal(null);
});

it('returns the c0mpute JSON and never calls OpenAI', async () => {
const c0mpute = { generateObject: sinon.stub().resolves({ object: { FIRST_NAME: 'Ada' }, provider: 'c0mpute' }) };
const service = new AIService({ apiKey: 'sk-test', client: mockOpenAI, c0mpute });

const text = await service.generateCompletion('prompt');

expect(JSON.parse(text)).to.deep.equal({ FIRST_NAME: 'Ada' });
expect(service.lastProvider).to.equal('c0mpute');
expect(mockOpenAI.chat.completions.create.called).to.equal(false);
});

it('falls back to OpenAI when c0mpute fails', async () => {
const c0mpute = { generateObject: sinon.stub().rejects(new Error('no node')) };
mockOpenAI.chat.completions.create.resolves({ choices: [{ message: { content: '{"FIRST_NAME":"Bo"}' } }] });
const service = new AIService({ apiKey: 'sk-test', client: mockOpenAI, c0mpute });

const text = await service.generateCompletion('prompt');

expect(JSON.parse(text)).to.deep.equal({ FIRST_NAME: 'Bo' });
expect(service.lastProvider).to.equal('openai');
});

it('throws the c0mpute error when there is no OpenAI fallback', async () => {
const c0mpute = { generateObject: sinon.stub().rejects(new Error('no node')) };
const service = new AIService({ apiKey: '', c0mpute });

try {
await service.generateCompletion('prompt');
expect.fail('should have thrown');
} catch (error) {
expect(error.message).to.equal('AI completion failed: no node');
}
});
});
});
Loading