Add OpenAI-compatible AI investment advisor to benefit calculator

- server.js: add /api/calculate endpoint using openai SDK with configurable
  AI_API_URL, AI_API_KEY, AI_MODEL, AI_DEBUG env vars (works with any
  OpenAI-compatible provider: NVIDIA NIM, Together AI, Groq, Ollama, etc.)
- app.js: make calculator submit handler async; call /api/calculate with
  graceful fallback to client-side generated text if AI is unavailable
- package.json: add openai and dotenv dependencies
- AI_SETUP.md: rewrite to document new unified env var config with provider examples

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 10:05:36 -04:00
parent c95fd7d424
commit bf70efc0d7
5 changed files with 1423 additions and 156 deletions

View File

@@ -1,10 +1,8 @@
# HOA LedgerIQ — AI API Configuration Guide # HOA LedgerIQ — AI Investment Advisor Configuration
## Overview ## Overview
The **Benefit Calculator widget** currently runs entirely client-side using conservative The **Benefit Calculator** uses an AI model to generate a personalized investment recommendation. It supports any **OpenAI-compatible API** — including OpenAI, NVIDIA NIM, Together AI, Groq, Ollama, and others — configured via environment variables.
fixed-rate math. This guide explains how to upgrade it to call a real AI API
(Claude or OpenAI) so the recommendation text is generated dynamically by a language model.
--- ---
@@ -13,162 +11,72 @@ fixed-rate math. This guide explains how to upgrade it to call a real AI API
``` ```
Browser (app.js) Browser (app.js)
└─► POST /api/calculate (server.js) └─► POST /api/calculate (server.js)
└─► Anthropic / OpenAI API └─► OpenAI-compatible API (AI_API_URL)
└─► Returns AI-generated investment recommendation text └─► Returns AI-generated recommendation text
└─► JSON response back to browser └─► JSON response back to browser
(falls back to client-side math text if unavailable)
``` ```
--- ---
## Step 1 — Add your API key to `.env` ## Configuration (.env)
Open `.env` in the project root and add one of the following: Add these variables to your `.env` file (or systemd `EnvironmentFile`):
```env ```env
# For Claude (Anthropic) — recommended # AI Investment Advisor (OpenAI-compatible API)
ANTHROPIC_API_KEY=sk-ant-... AI_API_URL=https://integrate.api.nvidia.com/v1
AI_API_KEY=your_api_key_here
AI_MODEL=qwen/qwen3.5-397b-a17b
# OR for OpenAI # Set to 'true' to enable detailed AI prompt/response logging
OPENAI_API_KEY=sk-... AI_DEBUG=false
``` ```
### Provider Examples
| Provider | AI_API_URL | Example Model |
|---|---|---|
| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` |
| NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | `qwen/qwen3.5-397b-a17b` |
| Together AI | `https://api.together.xyz/v1` | `meta-llama/Llama-3-70b-chat-hf` |
| Groq | `https://api.groq.com/openai/v1` | `llama3-70b-8192` |
| Ollama (local) | `http://localhost:11434/v1` | `llama3` |
> If `AI_API_KEY` is not set, the `/api/calculate` endpoint returns 503 and the calculator falls back to client-side generated text automatically.
--- ---
## Step 2 — Install the SDK ## How It Works
```bash `server.js` initializes the OpenAI client with your configured base URL and key:
# Claude (Anthropic)
npm install @anthropic-ai/sdk
# OR OpenAI
npm install openai
```
---
## Step 3 — Add the `/api/calculate` endpoint to `server.js`
Add this block after the existing `/api/health` route:
### Using Claude (Anthropic)
```js ```js
const Anthropic = require('@anthropic-ai/sdk'); const aiClient = AI_API_KEY
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); ? new OpenAI({ apiKey: AI_API_KEY, baseURL: AI_API_URL })
: null;
```
app.post('/api/calculate', async (req, res) => { The `POST /api/calculate` endpoint builds a prompt from the form inputs and calls:
const { homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025 } = req.body ?? {};
if (!homesites || !annualIncome) { ```js
return res.status(400).json({ error: 'homesites and annualIncome are required.' }); const completion = await aiClient.chat.completions.create({
} model: AI_MODEL,
const fmt = n => '$' + Math.round(n).toLocaleString();
const freqLabel = { monthly: 'monthly', quarterly: 'quarterly', annually: 'annual' }[paymentFreq] || 'monthly';
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
const prompt = `You are a conservative HOA financial advisor. Given the following community data, provide a brief (3-4 sentence) plain-English investment income recommendation. Use only conservative, realistic estimates. Do not speculate beyond what the data supports.
Community: ${homesites}-unit ${typeLabel} association
Annual dues income: ${fmt(annualIncome)} (collected ${freqLabel})
Reserve fund balance: ${fmt(reserveFunds || 0)}
Interest income earned in 2025: ${fmt(interest2025 || 0)}
Provide a recommendation focused on:
1. How much of the reserve funds could conservatively be invested and in what vehicle (e.g. CD ladder, money market, T-bills)
2. How much operating cash could earn interest between collection and expense periods
3. A realistic estimated annual interest income potential
4. A single sentence comparing that to their 2025 actual if provided
Keep the tone professional and factual. No bullet points — flowing paragraph only.`;
try {
const message = await anthropic.messages.create({
model: 'claude-opus-4-6',
max_tokens: 300, max_tokens: 300,
messages: [{ role: 'user', content: prompt }], messages: [{ role: 'user', content: prompt }],
}); });
const text = message.content[0]?.text ?? '';
res.json({ recommendation: text });
} catch (err) {
console.error('AI API error:', err.message);
res.status(502).json({ error: 'AI service unavailable. Showing estimated result.' });
}
});
``` ```
### Using OpenAI (GPT-4o) `app.js` calls this endpoint on form submit and falls back to the client-side text if the server returns an error or is unreachable.
```js
const OpenAI = require('openai');
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
app.post('/api/calculate', async (req, res) => {
const { homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025 } = req.body ?? {};
// ... (same prompt construction as above) ...
try {
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
max_tokens: 300,
messages: [{ role: 'user', content: prompt }],
});
const text = completion.choices[0]?.message?.content ?? '';
res.json({ recommendation: text });
} catch (err) {
console.error('AI API error:', err.message);
res.status(502).json({ error: 'AI service unavailable.' });
}
});
```
--- ---
## Step 4 — Update `app.js` to call the API endpoint ## Restart & Verify
In the `initCalculator` function, replace this line in the submitBtn handler:
```js
document.getElementById('calcAiText').textContent = ai; // current: client-side text
```
With this:
```js
// Call the AI endpoint; fall back to client-side text if unavailable
try {
const aiRes = await fetch('/api/calculate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025 }),
});
if (aiRes.ok) {
const { recommendation } = await aiRes.json();
document.getElementById('calcAiText').textContent = recommendation;
} else {
document.getElementById('calcAiText').textContent = ai; // fallback
}
} catch (_) {
document.getElementById('calcAiText').textContent = ai; // fallback
}
```
> Note: The `submitBtn` handler must be declared `async` for the `await` above to work:
> ```js
> submitBtn?.addEventListener('click', async () => { ... });
> ```
---
## Step 5 — Restart the server
```bash ```bash
sudo systemctl restart hoaledgeriqweb sudo systemctl restart hoaledgeriqweb
# Verify the endpoint is live # Test the endpoint
curl -X POST http://localhost:3000/api/calculate \ curl -X POST http://localhost:3000/api/calculate \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"homesites":150,"propertyType":"sfh","annualIncome":300000,"paymentFreq":"monthly","reserveFunds":500000,"interest2025":4200}' -d '{"homesites":150,"propertyType":"sfh","annualIncome":300000,"paymentFreq":"monthly","reserveFunds":500000,"interest2025":4200}'
@@ -176,44 +84,35 @@ curl -X POST http://localhost:3000/api/calculate \
--- ---
## Prompt Tuning Tips ## Prompt Tuning
The prompt in Step 3 is the core of the AI's behavior. You can adjust it to: Edit the prompt in `server.js` (inside the `/api/calculate` route) to adjust tone or output:
| Goal | Change | | Goal | Change |
|---|---| |---|---|
| More optimistic estimates | Change "conservative" to "moderate" in the prompt | | More optimistic estimates | Change "conservative" to "moderate" |
| Shorter output | Reduce `max_tokens` to `150` | | Shorter output | Reduce `max_tokens` to `150` |
| Include specific investment products | Add "mention specific products like Vanguard Federal Money Market or 6-month T-bills" | | Specific products | Add "mention Vanguard Federal Money Market or 6-month T-bills" |
| Add a disclaimer | Append "End with one sentence reminding them this is not financial advice." | | Add disclaimer | Append "End with one sentence reminding them this is not financial advice." |
--- ---
## Cost Estimate ## Debug Logging
| Model | Approx. cost per calculator use | Set `AI_DEBUG=true` in `.env` to log the full prompt and response to the server console. Useful for testing new models or prompt changes.
|---|---|
| Claude Opus 4.6 | ~$0.002 |
| Claude Sonnet 4.6 | ~$0.0004 |
| GPT-4o | ~$0.002 |
| GPT-4o-mini | ~$0.00005 |
For a landing page with low traffic, even Claude Opus is negligible cost. For scale,
`claude-sonnet-4-6` is the best balance of quality and price.
--- ---
## Security Notes ## Security
- **Never expose your API key in `app.js` or any client-side code.** All AI calls must go through `server.js`. - **Never put `AI_API_KEY` in `app.js`** — all AI calls go through `server.js`.
- Rate-limit the `/api/calculate` endpoint to prevent abuse (e.g. with `express-rate-limit`): - Rate-limit the endpoint to prevent abuse:
```bash ```bash
npm install express-rate-limit npm install express-rate-limit --ignore-scripts
``` ```
```js ```js
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const calcLimiter = rateLimit({ windowMs: 60 * 1000, max: 10 }); // 10 req/min per IP app.use('/api/calculate', rateLimit({ windowMs: 60_000, max: 10 }));
app.use('/api/calculate', calcLimiter);
``` ```

17
app.js
View File

@@ -52,7 +52,7 @@
close(); close();
}); });
submitBtn?.addEventListener('click', () => { submitBtn?.addEventListener('click', async () => {
const homesites = parseFloat(document.getElementById('calcHomesites').value) || 0; const homesites = parseFloat(document.getElementById('calcHomesites').value) || 0;
const propertyType = document.getElementById('calcPropertyType').value; const propertyType = document.getElementById('calcPropertyType').value;
const annualIncome = parseFloat(document.getElementById('calcAnnualIncome').value) || 0; const annualIncome = parseFloat(document.getElementById('calcAnnualIncome').value) || 0;
@@ -116,7 +116,22 @@
ai += `This would represent entirely new interest income for your community at no additional risk.`; ai += `This would represent entirely new interest income for your community at no additional risk.`;
} }
// ── AI recommendation — call server; fall back to client-side text ──
try {
const aiRes = await fetch('/api/calculate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025 }),
});
if (aiRes.ok) {
const { recommendation } = await aiRes.json();
document.getElementById('calcAiText').textContent = recommendation;
} else {
document.getElementById('calcAiText').textContent = ai; document.getElementById('calcAiText').textContent = ai;
}
} catch (_) {
document.getElementById('calcAiText').textContent = ai;
}
// ── Animate the main number ── // ── Animate the main number ──
animateValue(document.getElementById('resultAmount'), 0, totalPotential); animateValue(document.getElementById('resultAmount'), 0, totalPotential);

1286
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
"express": "^4.18.3" "dotenv": "^17.3.1",
"express": "^4.18.3",
"openai": "^6.27.0"
} }
} }

View File

@@ -14,9 +14,20 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const express = require('express'); const express = require('express');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const OpenAI = require('openai');
// ── Config ────────────────────────────────────────────── // ── Config ──────────────────────────────────────────────
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// ── AI client (OpenAI-compatible) ────────────────────────
const AI_API_URL = process.env.AI_API_URL || 'https://api.openai.com/v1';
const AI_API_KEY = process.env.AI_API_KEY || '';
const AI_MODEL = process.env.AI_MODEL || 'gpt-4o-mini';
const AI_DEBUG = process.env.AI_DEBUG === 'true';
const aiClient = AI_API_KEY
? new OpenAI({ apiKey: AI_API_KEY, baseURL: AI_API_URL })
: null;
const DB_DIR = path.join(__dirname, 'data'); const DB_DIR = path.join(__dirname, 'data');
const DB_PATH = path.join(DB_DIR, 'leads.db'); const DB_PATH = path.join(DB_DIR, 'leads.db');
@@ -127,6 +138,60 @@ app.get('/api/leads', (req, res) => {
res.json({ count: leads.length, leads }); res.json({ count: leads.length, leads });
}); });
// POST /api/calculate — AI-powered investment recommendation
app.post('/api/calculate', async (req, res) => {
if (!aiClient) {
return res.status(503).json({ error: 'AI service not configured.' });
}
const { homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025 } = req.body ?? {};
if (!homesites || !annualIncome) {
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
}
const fmt = n => '$' + Math.round(n).toLocaleString();
const freqLabel = { monthly: 'monthly', quarterly: 'quarterly', annually: 'annual' }[paymentFreq] || 'monthly';
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
const prompt = `You are a conservative HOA financial advisor. Given the following community data, provide a brief (3-4 sentence) plain-English investment income recommendation. Use only conservative, realistic estimates. Do not speculate beyond what the data supports.
Community: ${homesites}-unit ${typeLabel} association
Annual dues income: ${fmt(annualIncome)} (collected ${freqLabel})
Reserve fund balance: ${fmt(reserveFunds || 0)}
Interest income earned in 2025: ${fmt(interest2025 || 0)}
Provide a recommendation focused on:
1. How much of the reserve funds could conservatively be invested and in what vehicle (e.g. CD ladder, money market, T-bills)
2. How much operating cash could earn interest between collection and expense periods
3. A realistic estimated annual interest income potential
4. A single sentence comparing that to their 2025 actual if provided
Keep the tone professional and factual. No bullet points — flowing paragraph only.`;
if (AI_DEBUG) {
console.log('[AI_DEBUG] model:', AI_MODEL);
console.log('[AI_DEBUG] prompt:', prompt);
}
try {
const completion = await aiClient.chat.completions.create({
model: AI_MODEL,
max_tokens: 300,
messages: [{ role: 'user', content: prompt }],
});
const text = completion.choices[0]?.message?.content ?? '';
if (AI_DEBUG) console.log('[AI_DEBUG] response:', text);
res.json({ recommendation: text });
} catch (err) {
console.error('AI API error:', err.message);
res.status(502).json({ error: 'AI service unavailable. Showing estimated result.' });
}
});
// Health check // Health check
app.get('/api/health', (_req, res) => res.json({ status: 'ok', ts: new Date().toISOString() })); app.get('/api/health', (_req, res) => res.json({ status: 'ok', ts: new Date().toISOString() }));