Files
HOA_Financial_Platform/backend/src/modules/shadow-ai/shadow-ai.service.ts
JoeBot 4797669591 feat: add shadow AI benchmarking for admin model comparison
Add a new admin-only feature that allows the platform owner to benchmark
the production AI model against up to 2 alternate models (any OpenAI-compatible
API) using real tenant data, without impacting users.

Backend:
- Shared AI caller utility (ai-caller.ts) for OpenAI-compatible endpoints
- Shadow AI module with service, controller, and 3 entities
- 6 admin API endpoints for model config CRUD, run trigger, and history
- Auto-creates shadow_ai_models, shadow_runs, shadow_run_results tables
- Exposes health-scores and investment-planning prompt builders for reuse

Frontend:
- New admin page at /admin/shadow-ai with 3 tabs:
  - Model Configuration (production + 2 alternate slots)
  - Run Comparison (tenant select, feature select, side-by-side results)
  - History (filterable run log with detail drill-down)
- Full side-by-side output display with diff highlighting
- Sidebar navigation link for AI Benchmarking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 07:50:59 -04:00

724 lines
36 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { HealthScoresService } from '../health-scores/health-scores.service';
import { callOpenAICompatible } from '../../common/utils/ai-caller';
type Feature = 'operating_health' | 'reserve_health' | 'investment_recommendations';
interface ModelConfig {
role: string;
name: string;
apiUrl: string;
apiKey: string;
modelName: string;
}
@Injectable()
export class ShadowAiService {
private readonly logger = new Logger(ShadowAiService.name);
constructor(
private dataSource: DataSource,
private configService: ConfigService,
private healthScoresService: HealthScoresService,
) {}
// ── Model Configuration CRUD ──
async getModels() {
const rows = await this.dataSource.query(
`SELECT id, slot, name, api_url, api_key, model_name, is_active, created_at, updated_at
FROM shared.shadow_ai_models ORDER BY slot`,
);
return rows.map((r: any) => ({
...r,
api_key: r.api_key ? `****${r.api_key.slice(-4)}` : null,
}));
}
async upsertModel(slot: string, dto: { name: string; apiUrl: string; apiKey: string; modelName: string; isActive?: boolean }) {
const isActive = dto.isActive !== undefined ? dto.isActive : true;
// Check if model exists for this slot
const existing = await this.dataSource.query(
`SELECT id, api_key FROM shared.shadow_ai_models WHERE slot = $1`,
[slot],
);
if (existing.length > 0) {
// If apiKey is masked (starts with ****), keep the existing key
const apiKey = dto.apiKey.startsWith('****') ? existing[0].api_key : dto.apiKey;
await this.dataSource.query(
`UPDATE shared.shadow_ai_models
SET name = $1, api_url = $2, api_key = $3, model_name = $4, is_active = $5, updated_at = NOW()
WHERE slot = $6`,
[dto.name, dto.apiUrl, apiKey, dto.modelName, isActive, slot],
);
} else {
await this.dataSource.query(
`INSERT INTO shared.shadow_ai_models (slot, name, api_url, api_key, model_name, is_active)
VALUES ($1, $2, $3, $4, $5, $6)`,
[slot, dto.name, dto.apiUrl, dto.apiKey, dto.modelName, isActive],
);
}
return { slot, status: 'saved' };
}
async deleteModel(slot: string) {
await this.dataSource.query(
`DELETE FROM shared.shadow_ai_models WHERE slot = $1`,
[slot],
);
return { slot, status: 'deleted' };
}
// ── Shadow Run Execution ──
async triggerRun(tenantId: string, feature: Feature, userId: string) {
// Look up tenant schema
const orgs = await this.dataSource.query(
`SELECT schema_name, name FROM shared.organizations WHERE id = $1`,
[tenantId],
);
if (!orgs.length) throw new Error('Tenant not found');
const schemaName = orgs[0].schema_name;
// Build prompt messages for the feature
const messages = await this.buildPromptMessages(schemaName, feature);
// Create shadow run record
const runRows = await this.dataSource.query(
`INSERT INTO shared.shadow_runs (tenant_id, feature, status, triggered_by, prompt_messages, started_at)
VALUES ($1, $2, 'running', $3, $4, NOW())
RETURNING id`,
[tenantId, feature, userId, JSON.stringify(messages)],
);
const runId = runRows[0].id;
// Get model configs
const modelConfigs = await this.getModelConfigs();
// Create pending result rows
for (const config of modelConfigs) {
await this.dataSource.query(
`INSERT INTO shared.shadow_run_results (run_id, model_role, model_name, api_url, status)
VALUES ($1, $2, $3, $4, 'pending')`,
[runId, config.role, config.modelName, config.apiUrl],
);
}
// Fire-and-forget: run all models in parallel
this.executeModels(runId, messages, modelConfigs, feature).catch((err) => {
this.logger.error(`Shadow run ${runId} failed: ${err.message}`);
});
return { runId, status: 'running' };
}
// ── Run History ──
async getRunHistory(query: { page?: number; limit?: number; tenantId?: string; feature?: string }) {
const page = query.page || 1;
const limit = Math.min(query.limit || 20, 100);
const offset = (page - 1) * limit;
let where = '';
const params: any[] = [];
let paramIdx = 1;
if (query.tenantId) {
where += ` AND sr.tenant_id = $${paramIdx++}`;
params.push(query.tenantId);
}
if (query.feature) {
where += ` AND sr.feature = $${paramIdx++}`;
params.push(query.feature);
}
const [rows, countRows] = await Promise.all([
this.dataSource.query(
`SELECT sr.id, sr.tenant_id, sr.feature, sr.status, sr.started_at, sr.completed_at, sr.created_at,
o.name as tenant_name,
(SELECT COUNT(*) FROM shared.shadow_run_results rr WHERE rr.run_id = sr.id) as result_count,
(SELECT COUNT(*) FROM shared.shadow_run_results rr WHERE rr.run_id = sr.id AND rr.status = 'success') as success_count
FROM shared.shadow_runs sr
LEFT JOIN shared.organizations o ON o.id = sr.tenant_id
WHERE 1=1 ${where}
ORDER BY sr.created_at DESC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
[...params, limit, offset],
),
this.dataSource.query(
`SELECT COUNT(*) as total FROM shared.shadow_runs sr WHERE 1=1 ${where}`,
params,
),
]);
return {
runs: rows,
total: parseInt(countRows[0]?.total || '0'),
page,
limit,
};
}
async getRunDetail(runId: string) {
const [runs, results] = await Promise.all([
this.dataSource.query(
`SELECT sr.*, o.name as tenant_name
FROM shared.shadow_runs sr
LEFT JOIN shared.organizations o ON o.id = sr.tenant_id
WHERE sr.id = $1`,
[runId],
),
this.dataSource.query(
`SELECT * FROM shared.shadow_run_results
WHERE run_id = $1
ORDER BY CASE model_role
WHEN 'production' THEN 1
WHEN 'alternate_a' THEN 2
WHEN 'alternate_b' THEN 3
END`,
[runId],
),
]);
if (!runs.length) return null;
return {
...runs[0],
results,
};
}
// ── Private Helpers ──
private async buildPromptMessages(
schemaName: string,
feature: Feature,
): Promise<Array<{ role: string; content: string }>> {
if (feature === 'operating_health' || feature === 'reserve_health') {
const qr = this.dataSource.createQueryRunner();
try {
await qr.connect();
await qr.query(`SET search_path TO "${schemaName}"`);
const scoreType = feature === 'operating_health' ? 'operating' : 'reserve';
const data = scoreType === 'operating'
? await this.healthScoresService.gatherOperatingData(qr)
: await this.healthScoresService.gatherReserveData(qr);
return scoreType === 'operating'
? this.healthScoresService.buildOperatingPrompt(data)
: this.healthScoresService.buildReservePrompt(data);
} finally {
await qr.release();
}
}
// investment_recommendations — build prompt directly via DataSource
return this.buildInvestmentPromptForSchema(schemaName);
}
/**
* Build investment recommendation prompts for a given tenant schema.
* Self-contained: uses DataSource directly, no request-scoped dependencies.
*/
private async buildInvestmentPromptForSchema(schemaName: string): Promise<Array<{ role: string; content: string }>> {
const qr = this.dataSource.createQueryRunner();
try {
await qr.connect();
await qr.query(`SET search_path TO "${schemaName}"`);
const year = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
// ── Financial snapshot ──
const [accountBalances, investmentAccounts, budgets, projects] = await Promise.all([
qr.query(`
SELECT a.id, a.account_number, a.name, a.account_type, a.fund_type, a.interest_rate,
CASE WHEN a.account_type IN ('asset', 'expense')
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
END as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity')
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type, a.interest_rate ORDER BY a.account_number
`),
qr.query(`SELECT id, name, institution, investment_type, fund_type, principal, interest_rate, maturity_date, purchase_date, current_value
FROM investment_accounts WHERE is_active = true ORDER BY maturity_date NULLS LAST`),
qr.query(`SELECT b.fund_type, a.account_type, a.name, a.account_number,
(b.jan+b.feb+b.mar+b.apr+b.may+b.jun+b.jul+b.aug+b.sep+b.oct+b.nov+b.dec_amt) as annual_total
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1 ORDER BY a.account_type, a.account_number`, [year]),
qr.query(`SELECT name, estimated_cost, target_year, target_month, fund_source, status, priority, current_fund_balance, funded_percentage
FROM projects WHERE is_active = true AND status IN ('planned','approved','in_progress') ORDER BY target_year, target_month NULLS LAST, priority`),
]);
const [opCashResult, resCashResult, budgetSummary, assessmentIncome] = await Promise.all([
qr.query(`SELECT COALESCE(SUM(sub.bal),0) as total FROM (SELECT COALESCE(SUM(jel.debit),0)-COALESCE(SUM(jel.credit),0) as bal FROM accounts a JOIN journal_entry_lines jel ON jel.account_id=a.id JOIN journal_entries je ON je.id=jel.journal_entry_id AND je.is_posted=true AND je.is_void=false WHERE a.account_type='asset' AND a.fund_type='operating' AND a.is_active=true GROUP BY a.id) sub`),
qr.query(`SELECT COALESCE(SUM(sub.bal),0) as total FROM (SELECT COALESCE(SUM(jel.debit),0)-COALESCE(SUM(jel.credit),0) as bal FROM accounts a JOIN journal_entry_lines jel ON jel.account_id=a.id JOIN journal_entries je ON je.id=jel.journal_entry_id AND je.is_posted=true AND je.is_void=false WHERE a.account_type='asset' AND a.fund_type='reserve' AND a.is_active=true GROUP BY a.id) sub`),
qr.query(`SELECT b.fund_type, a.account_type, SUM(b.jan+b.feb+b.mar+b.apr+b.may+b.jun+b.jul+b.aug+b.sep+b.oct+b.nov+b.dec_amt) as annual_total FROM budgets b JOIN accounts a ON a.id=b.account_id WHERE b.fiscal_year=$1 GROUP BY b.fund_type, a.account_type`, [year]),
qr.query(`SELECT COALESCE(SUM(ag.regular_assessment*(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id=ag.id AND u.status='active')),0) as monthly_assessment_income FROM assessment_groups ag WHERE ag.is_active=true`),
]);
const operatingCash = accountBalances.filter((a: any) => a.fund_type === 'operating' && a.account_type === 'asset').reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
const reserveCash = accountBalances.filter((a: any) => a.fund_type === 'reserve' && a.account_type === 'asset').reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
const operatingInvestments = investmentAccounts.filter((i: any) => i.fund_type === 'operating').reduce((s: number, i: any) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const reserveInvestments = investmentAccounts.filter((i: any) => i.fund_type === 'reserve').reduce((s: number, i: any) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const snapshot = {
summary: { operating_cash: operatingCash, reserve_cash: reserveCash, operating_investments: operatingInvestments, reserve_investments: reserveInvestments,
total_operating: operatingCash + operatingInvestments, total_reserve: reserveCash + reserveInvestments, total_all: operatingCash + reserveCash + operatingInvestments + reserveInvestments },
account_balances: accountBalances, investment_accounts: investmentAccounts, budgets, projects,
cash_flow_context: {
current_operating_cash: parseFloat(opCashResult[0]?.total || '0'), current_reserve_cash: parseFloat(resCashResult[0]?.total || '0'),
budget_summary: budgetSummary, monthly_assessment_income: parseFloat(assessmentIncome[0]?.monthly_assessment_income || '0'),
},
};
// ── 12-month forecast ──
const [opInvRows, resInvRows] = await Promise.all([
qr.query(`SELECT COALESCE(SUM(current_value),0) as total FROM investment_accounts WHERE fund_type='operating' AND is_active=true`),
qr.query(`SELECT COALESCE(SUM(current_value),0) as total FROM investment_accounts WHERE fund_type='reserve' AND is_active=true`),
]);
let runOpCash = parseFloat(opCashResult[0]?.total || '0'), runResCash = parseFloat(resCashResult[0]?.total || '0');
let runOpInv = parseFloat(opInvRows[0]?.total || '0'), runResInv = parseFloat(resInvRows[0]?.total || '0');
const assessmentGroups = await qr.query(`SELECT ag.frequency, ag.regular_assessment, ag.special_assessment,
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id=ag.id AND u.status='active') as unit_count FROM assessment_groups ag WHERE ag.is_active=true`);
const getAssessmentInc = (month: number) => {
let op = 0, res = 0;
for (const g of assessmentGroups) {
const units = parseInt(g.unit_count) || 0, reg = parseFloat(g.regular_assessment) || 0, spec = parseFloat(g.special_assessment) || 0;
const freq = g.frequency || 'monthly';
let applies = freq === 'monthly' || (freq === 'quarterly' && [1,4,7,10].includes(month)) || (freq === 'annual' && month === 1);
if (applies) { op += reg * units; res += spec * units; }
}
return { operating: op, reserve: res };
};
const budgetsByYM: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
for (const yr of [year, year + 1]) {
const bRows = await qr.query(`SELECT b.fund_type, a.account_type, b.jan,b.feb,b.mar,b.apr,b.may,b.jun,b.jul,b.aug,b.sep,b.oct,b.nov,b.dec_amt FROM budgets b JOIN accounts a ON a.id=b.account_id WHERE b.fiscal_year=$1`, [yr]);
for (let m = 0; m < 12; m++) {
const k = `${yr}-${m+1}`;
if (!budgetsByYM[k]) budgetsByYM[k] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
for (const r of bRows) {
const amt = parseFloat(r[monthNames[m]]) || 0;
if (!amt) continue;
const isOp = r.fund_type === 'operating';
if (r.account_type === 'income') { if (isOp) budgetsByYM[k].opIncome += amt; else budgetsByYM[k].resIncome += amt; }
else if (r.account_type === 'expense') { if (isOp) budgetsByYM[k].opExpense += amt; else budgetsByYM[k].resExpense += amt; }
}
}
}
const maturities = await qr.query(`SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date FROM investment_accounts WHERE is_active=true AND maturity_date IS NOT NULL AND maturity_date>CURRENT_DATE`);
const matIdx: Record<string, { operating: number; reserve: number }> = {};
for (const inv of maturities) {
const d = new Date(inv.maturity_date), k = `${d.getFullYear()}-${d.getMonth()+1}`;
if (!matIdx[k]) matIdx[k] = { operating: 0, reserve: 0 };
const val = parseFloat(inv.current_value) || 0, rate = parseFloat(inv.interest_rate) || 0;
const pDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
const days = Math.max((d.getTime() - pDate.getTime()) / 86400000, 1);
const total = val + val * (rate/100) * (days/365);
if (inv.fund_type === 'operating') matIdx[k].operating += total; else matIdx[k].reserve += total;
}
const projExp = await qr.query(`SELECT estimated_cost, target_year, target_month, fund_source FROM projects WHERE is_active=true AND status IN ('planned','in_progress') AND target_year IS NOT NULL AND estimated_cost>0`);
const projIdx: Record<string, { operating: number; reserve: number }> = {};
for (const p of projExp) {
const k = `${parseInt(p.target_year)}-${parseInt(p.target_month)||6}`;
if (!projIdx[k]) projIdx[k] = { operating: 0, reserve: 0 };
const c = parseFloat(p.estimated_cost) || 0;
if (p.fund_source === 'operating') projIdx[k].operating += c; else projIdx[k].reserve += c;
}
const datapoints: any[] = [];
for (let i = 0; i < 12; i++) {
const fY = year + Math.floor((currentMonth-1+i)/12), fM = ((currentMonth-1+i)%12)+1;
const k = `${fY}-${fM}`, label = `${monthLabels[fM-1]} ${fY}`;
const asmt = getAssessmentInc(fM), bud = budgetsByYM[k] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
const mat = matIdx[k] || { operating: 0, reserve: 0 }, proj = projIdx[k] || { operating: 0, reserve: 0 };
const opInc = bud.opIncome > 0 ? bud.opIncome : asmt.operating, resInc = bud.resIncome > 0 ? bud.resIncome : asmt.reserve;
runOpCash += opInc - bud.opExpense - proj.operating + mat.operating;
runResCash += resInc - bud.resExpense - proj.reserve + mat.reserve;
if (mat.operating > 0) runOpInv = Math.max(0, runOpInv - mat.operating * 0.96);
if (mat.reserve > 0) runResInv = Math.max(0, runResInv - mat.reserve * 0.96);
datapoints.push({ month: label, operating_cash: Math.round(runOpCash*100)/100, operating_investments: Math.round(runOpInv*100)/100,
reserve_cash: Math.round(runResCash*100)/100, reserve_investments: Math.round(runResInv*100)/100,
op_income: Math.round(opInc*100)/100, op_expense: Math.round(bud.opExpense*100)/100,
res_income: Math.round(resInc*100)/100, res_expense: Math.round(bud.resExpense*100)/100,
project_cost_op: Math.round(proj.operating*100)/100, project_cost_res: Math.round(proj.reserve*100)/100,
maturity_op: Math.round(mat.operating*100)/100, maturity_res: Math.round(mat.reserve*100)/100 });
}
const asmtSchedule = assessmentGroups.map((g: any) => ({
frequency: g.frequency || 'monthly', regular_per_unit: parseFloat(g.regular_assessment) || 0,
special_per_unit: parseFloat(g.special_assessment) || 0, units: parseInt(g.unit_count) || 0,
total_regular: (parseFloat(g.regular_assessment) || 0) * (parseInt(g.unit_count) || 0),
total_special: (parseFloat(g.special_assessment) || 0) * (parseInt(g.unit_count) || 0),
}));
// ── Market rates from shared schema ──
const fetchLatest = async (rateType: string) =>
qr.query(`SELECT bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at
FROM shared.cd_rates WHERE rate_type=$1 AND fetched_at=(SELECT MAX(fetched_at) FROM shared.cd_rates WHERE rate_type=$1)
ORDER BY apy DESC LIMIT 25`, [rateType]);
const [cdRates, mmRates, hysRates] = await Promise.all([fetchLatest('cd'), fetchLatest('money_market'), fetchLatest('high_yield_savings')]);
const allRates = { cd: cdRates, money_market: mmRates, high_yield_savings: hysRates };
// ── Build prompt (replicates InvestmentPlanningService.buildPromptMessages) ──
const { summary, investment_accounts: invAccts, cash_flow_context: cfc } = snapshot;
const today = new Date().toISOString().split('T')[0];
const systemPrompt = `You are a financial advisor specializing in HOA (Homeowners Association) reserve fund management and conservative investment strategy. You provide fiduciary-grade investment recommendations.
CRITICAL RULES:
1. HOAs are legally required to maintain adequate reserves. NEVER recommend depleting reserve funds below safe levels.
2. HOA investments must be conservative ONLY: CDs, money market accounts, treasury bills, and high-yield savings. NO stocks, bonds, mutual funds, or speculative instruments.
3. Liquidity is paramount: always ensure enough cash to cover at least 3 months of operating expenses AND any capital project expenses due within the next 12 months.
4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access.
5. Operating funds should remain highly liquid (money market or high-yield savings only).
6. Respect the separation between operating funds and reserve funds. Never suggest commingling.
7. Base your recommendations ONLY on the available market rates (CDs, Money Market, High Yield Savings) provided. Do not reference rates or banks not in the provided data.
8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due.
9. When recommending money market or high yield savings accounts, focus on their liquidity advantages for operating funds. When recommending CDs, focus on their higher yields for longer-term reserve fund placement.
10. Compare current account rates against available market rates. If better rates are available, suggest specific moves with the potential additional interest income that could be earned.
RESPONSE FORMAT:
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
{
"recommendations": [
{
"type": "cd_ladder" | "new_investment" | "reallocation" | "maturity_action" | "liquidity_warning" | "general",
"priority": "high" | "medium" | "low",
"title": "Short action title (under 60 chars)",
"summary": "One sentence summary of the recommendation",
"details": "Detailed explanation with specific dollar amounts and timeframes",
"fund_type": "operating" | "reserve" | "both",
"suggested_amount": 50000.00,
"suggested_term": "12 months",
"suggested_rate": 4.50,
"bank_name": "Bank name from market rates (if applicable)",
"rationale": "Financial reasoning for why this makes sense",
"components": [
{
"label": "Component label (e.g. '6-Month CD at Marcus')",
"amount": 6600.00,
"term_months": 6,
"rate": 4.05,
"bank_name": "Marcus",
"investment_type": "cd"
}
]
}
],
"overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities",
"risk_notes": ["Array of risk items or concerns to flag for the board"]
}
IMPORTANT ABOUT COMPONENTS:
- For cd_ladder recommendations, you MUST include a "components" array with each individual CD as a separate component. Each component should have its own label, amount, term_months, rate, and bank_name. The suggested_amount should be the total of all component amounts.
- For other multi-part strategies (e.g. splitting funds across multiple accounts), also include a "components" array.
- For simple single-investment recommendations, omit the "components" field entirely.
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
const investmentsList = invAccts.length === 0 ? 'No current investments.'
: invAccts.map((i: any) => `- ${i.name} | Type: ${i.investment_type} | Fund: ${i.fund_type} | Principal: $${parseFloat(i.principal).toFixed(2)} | Rate: ${parseFloat(i.interest_rate||'0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`).join('\n');
const budgetLines = budgets.length === 0 ? 'No budget data available.'
: budgets.map((b: any) => `- ${b.name} (${b.account_number}) | ${b.account_type}/${b.fund_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr`).join('\n');
const projectLines = projects.length === 0 ? 'No upcoming capital projects.'
: projects.map((p: any) => `- ${p.name} | Cost: $${parseFloat(p.estimated_cost).toFixed(2)} | Target: ${p.target_year||'?'}/${p.target_month||'?'} | Fund: ${p.fund_source} | Status: ${p.status} | Funded: ${parseFloat(p.funded_percentage||'0').toFixed(1)}%`).join('\n');
const budgetSummaryLines = (cfc.budget_summary || []).length === 0 ? 'No budget summary available.'
: cfc.budget_summary.map((b: any) => `- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total)/12).toFixed(2)}/mo)`).join('\n');
const formatRates = (rates: any[], label: string) => rates.length === 0
? `No ${label} rate data available. Rate fetcher may not have been run yet.`
: rates.map((r: any) => `- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}%${r.term !== 'N/A' ? ` | Term: ${r.term}` : ''} | Min Deposit: ${r.min_deposit ? '$'+parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`).join('\n');
const asmtLines = asmtSchedule.length === 0 ? 'No assessment schedule available.'
: asmtSchedule.map((a: any) => `- ${a.frequency} collection | ${a.units} units | Regular: $${a.regular_per_unit.toFixed(2)}/unit ($${a.total_regular.toFixed(2)} total) → Operating | Special: $${a.special_per_unit.toFixed(2)}/unit ($${a.total_special.toFixed(2)} total) → Reserve`).join('\n');
const forecastLines = datapoints.map((dp: any) => {
const d: string[] = [];
if (dp.op_income > 0) d.push(`OpInc:$${dp.op_income.toFixed(0)}`);
if (dp.op_expense > 0) d.push(`OpExp:$${dp.op_expense.toFixed(0)}`);
if (dp.res_income > 0) d.push(`ResInc:$${dp.res_income.toFixed(0)}`);
if (dp.res_expense > 0) d.push(`ResExp:$${dp.res_expense.toFixed(0)}`);
if (dp.project_cost_res > 0) d.push(`ResProjCost:$${dp.project_cost_res.toFixed(0)}`);
if (dp.project_cost_op > 0) d.push(`OpProjCost:$${dp.project_cost_op.toFixed(0)}`);
if (dp.maturity_op > 0) d.push(`OpMaturity:$${dp.maturity_op.toFixed(0)}`);
if (dp.maturity_res > 0) d.push(`ResMaturity:$${dp.maturity_res.toFixed(0)}`);
return `- ${dp.month} | OpCash: $${dp.operating_cash.toFixed(0)} | ResCash: $${dp.reserve_cash.toFixed(0)} | OpInv: $${dp.operating_investments.toFixed(0)} | ResInv: $${dp.reserve_investments.toFixed(0)} | Drivers: ${d.join(', ') || 'none'}`;
}).join('\n');
const userPrompt = `Analyze this HOA's financial position and provide investment recommendations.
TODAY'S DATE: ${today}
=== CURRENT CASH POSITIONS ===
Operating Cash (bank accounts): $${summary.operating_cash.toFixed(2)}
Reserve Cash (bank accounts): $${summary.reserve_cash.toFixed(2)}
Operating Investments: $${summary.operating_investments.toFixed(2)}
Reserve Investments: $${summary.reserve_investments.toFixed(2)}
Total Operating Fund: $${summary.total_operating.toFixed(2)}
Total Reserve Fund: $${summary.total_reserve.toFixed(2)}
Grand Total: $${summary.total_all.toFixed(2)}
=== CURRENT INVESTMENTS ===
${investmentsList}
=== ASSESSMENT INCOME SCHEDULE ===
${asmtLines}
Note: "Regular" assessments fund Operating. "Special" assessments fund Reserve. Both are collected from homeowners per the frequency above.
=== ANNUAL BUDGET (${new Date().getFullYear()}) ===
${budgetLines}
=== BUDGET SUMMARY (Annual Totals by Category) ===
${budgetSummaryLines}
=== MONTHLY ASSESSMENT INCOME ===
Recurring monthly regular assessment income: $${cfc.monthly_assessment_income.toFixed(2)}/month (operating fund)
=== UPCOMING CAPITAL PROJECTS ===
${projectLines}
=== 12-MONTH CASH FLOW FORECAST (Projected) ===
This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities.
${forecastLines}
=== AVAILABLE MARKET RATES ===
--- CD Rates ---
${formatRates(allRates.cd, 'CD')}
--- Money Market Rates ---
${formatRates(allRates.money_market, 'Money Market')}
--- High Yield Savings Rates ---
${formatRates(allRates.high_yield_savings, 'High Yield Savings')}
Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider:
1. Is there excess cash that could earn better returns in CDs, money market accounts, or high-yield savings?
2. Are any current investments maturing soon that need reinvestment planning?
3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low.
4. Would a CD ladder strategy improve the yield while maintaining access to funds?
5. Are operating and reserve funds properly separated in the investment strategy?
6. Could any current money market or savings accounts earn better rates at a different bank? Quantify the potential additional annual interest.
7. For operating funds that need to stay liquid, are money market or high-yield savings accounts being used optimally?`;
return [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
];
} finally {
await qr.release();
}
}
private async getModelConfigs(): Promise<ModelConfig[]> {
const configs: ModelConfig[] = [];
// Production model from env vars
const prodApiUrl = this.configService.get<string>('AI_API_URL') || 'https://integrate.api.nvidia.com/v1';
const prodApiKey = this.configService.get<string>('AI_API_KEY');
const prodModel = this.configService.get<string>('AI_MODEL') || 'qwen/qwen3.5-397b-a17b';
if (prodApiKey) {
configs.push({
role: 'production',
name: 'Production',
apiUrl: prodApiUrl,
apiKey: prodApiKey,
modelName: prodModel,
});
}
// Alternate models from DB
const alternates = await this.dataSource.query(
`SELECT slot, name, api_url, api_key, model_name
FROM shared.shadow_ai_models
WHERE is_active = true
ORDER BY slot`,
);
for (const alt of alternates) {
configs.push({
role: alt.slot === 'A' ? 'alternate_a' : 'alternate_b',
name: alt.name,
apiUrl: alt.api_url,
apiKey: alt.api_key,
modelName: alt.model_name,
});
}
return configs;
}
private getFeatureParams(feature: Feature): { temperature: number; maxTokens: number } {
if (feature === 'investment_recommendations') {
return { temperature: 0.3, maxTokens: 4096 };
}
return { temperature: 0.1, maxTokens: 2048 };
}
private async executeModels(
runId: string,
messages: Array<{ role: string; content: string }>,
configs: ModelConfig[],
feature: Feature,
) {
const { temperature, maxTokens } = this.getFeatureParams(feature);
const promises = configs.map(async (config) => {
// Mark as running
await this.dataSource.query(
`UPDATE shared.shadow_run_results SET status = 'running' WHERE run_id = $1 AND model_role = $2`,
[runId, config.role],
);
try {
const result = await callOpenAICompatible({
apiUrl: config.apiUrl,
apiKey: config.apiKey,
model: config.modelName,
messages,
temperature,
maxTokens,
});
// Try to parse the response as JSON
let parsedResponse: any = null;
try {
parsedResponse = JSON.parse(result.content);
} catch {
// Store raw content if not valid JSON
parsedResponse = { raw_text: result.content };
}
await this.dataSource.query(
`UPDATE shared.shadow_run_results
SET status = 'success', raw_response = $1, parsed_response = $2,
response_time_ms = $3, token_usage = $4
WHERE run_id = $5 AND model_role = $6`,
[
result.rawResponse,
JSON.stringify(parsedResponse),
result.responseTimeMs,
result.usage ? JSON.stringify(result.usage) : null,
runId,
config.role,
],
);
this.logger.log(`Shadow run ${runId} - ${config.role} (${config.modelName}) completed in ${result.responseTimeMs}ms`);
} catch (error: any) {
this.logger.error(`Shadow run ${runId} - ${config.role} (${config.modelName}) failed: ${error.message}`);
await this.dataSource.query(
`UPDATE shared.shadow_run_results
SET status = 'error', error_message = $1
WHERE run_id = $2 AND model_role = $3`,
[error.message, runId, config.role],
);
}
});
await Promise.allSettled(promises);
// Determine overall run status
const results = await this.dataSource.query(
`SELECT status FROM shared.shadow_run_results WHERE run_id = $1`,
[runId],
);
const allSuccess = results.every((r: any) => r.status === 'success');
const allError = results.every((r: any) => r.status === 'error');
const status = allSuccess ? 'completed' : allError ? 'failed' : 'partial';
await this.dataSource.query(
`UPDATE shared.shadow_runs SET status = $1, completed_at = NOW() WHERE id = $2`,
[status, runId],
);
this.logger.log(`Shadow run ${runId} finished with status: ${status}`);
}
// ── Table Creation (for initial setup) ──
async ensureTables() {
const qr = this.dataSource.createQueryRunner();
try {
await qr.connect();
await qr.query(`
CREATE TABLE IF NOT EXISTS shared.shadow_ai_models (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slot VARCHAR(10) NOT NULL UNIQUE CHECK (slot IN ('A', 'B')),
name VARCHAR(100) NOT NULL,
api_url VARCHAR(500) NOT NULL,
api_key VARCHAR(500) NOT NULL,
model_name VARCHAR(200) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await qr.query(`
CREATE TABLE IF NOT EXISTS shared.shadow_runs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL,
feature VARCHAR(30) NOT NULL CHECK (feature IN ('operating_health', 'reserve_health', 'investment_recommendations')),
status VARCHAR(20) NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'partial', 'failed')),
triggered_by UUID,
prompt_messages JSONB NOT NULL,
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await qr.query(`
CREATE INDEX IF NOT EXISTS idx_shadow_runs_tenant ON shared.shadow_runs(tenant_id)
`);
await qr.query(`
CREATE INDEX IF NOT EXISTS idx_shadow_runs_created ON shared.shadow_runs(created_at DESC)
`);
await qr.query(`
CREATE TABLE IF NOT EXISTS shared.shadow_run_results (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
run_id UUID NOT NULL REFERENCES shared.shadow_runs(id) ON DELETE CASCADE,
model_role VARCHAR(20) NOT NULL CHECK (model_role IN ('production', 'alternate_a', 'alternate_b')),
model_name VARCHAR(200) NOT NULL,
api_url VARCHAR(500) NOT NULL,
raw_response TEXT,
parsed_response JSONB,
response_time_ms INTEGER,
token_usage JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'success', 'error')),
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(run_id, model_role)
)
`);
await qr.query(`
CREATE INDEX IF NOT EXISTS idx_shadow_results_run ON shared.shadow_run_results(run_id)
`);
this.logger.log('Shadow AI tables ensured');
} finally {
await qr.release();
}
}
}