feat: add shadow AI benchmarking for admin model comparison #12

Merged
JoeBot merged 1 commits from feature-shadowAI into main 2026-04-05 07:54:12 -04:00
16 changed files with 2461 additions and 6 deletions

View File

@@ -34,6 +34,7 @@ import { BillingModule } from './modules/billing/billing.module';
import { EmailModule } from './modules/email/email.module';
import { OnboardingModule } from './modules/onboarding/onboarding.module';
import { IdeasModule } from './modules/ideas/ideas.module';
import { ShadowAiModule } from './modules/shadow-ai/shadow-ai.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
@@ -90,6 +91,7 @@ import { ScheduleModule } from '@nestjs/schedule';
EmailModule,
OnboardingModule,
IdeasModule,
ShadowAiModule,
ScheduleModule.forRoot(),
],
controllers: [AppController],

View File

@@ -0,0 +1,101 @@
/**
* Shared utility for calling OpenAI-compatible chat completion APIs.
* Used by both production AI features and shadow AI benchmarking.
*/
export interface AICallerParams {
apiUrl: string;
apiKey: string;
model: string;
messages: Array<{ role: string; content: string }>;
temperature: number;
maxTokens: number;
timeoutMs?: number;
}
export interface AICallerResult {
content: string;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
responseTimeMs: number;
rawResponse: string;
}
export async function callOpenAICompatible(params: AICallerParams): Promise<AICallerResult> {
const { apiUrl, apiKey, model, messages, temperature, maxTokens, timeoutMs = 600000 } = params;
const requestBody = {
model,
messages,
temperature,
max_tokens: maxTokens,
};
const bodyString = JSON.stringify(requestBody);
const startTime = Date.now();
const { URL } = await import('url');
const https = await import('https');
const aiResult = await new Promise<{ status: number; body: string }>((resolve, reject) => {
const url = new URL(`${apiUrl}/chat/completions`);
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
},
timeout: timeoutMs,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
resolve({ status: res.statusCode!, body: data });
});
});
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request timed out after ${timeoutMs / 1000}s`));
});
req.write(bodyString);
req.end();
});
const responseTimeMs = Date.now() - startTime;
if (aiResult.status >= 400) {
throw new Error(`AI API returned ${aiResult.status}: ${aiResult.body}`);
}
const data = JSON.parse(aiResult.body);
const content = data.choices?.[0]?.message?.content || null;
if (!content) {
throw new Error('AI model returned empty content');
}
// Clean response: strip markdown fences and thinking blocks
let cleaned = content.trim();
if (cleaned.startsWith('```')) {
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
}
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
const usage = data.usage || undefined;
return {
content: cleaned,
usage,
responseTimeMs,
rawResponse: content,
};
}

View File

@@ -6,5 +6,6 @@ import { HealthScoresScheduler } from './health-scores.scheduler';
@Module({
controllers: [HealthScoresController],
providers: [HealthScoresService, HealthScoresScheduler],
exports: [HealthScoresService],
})
export class HealthScoresModule {}

View File

@@ -180,7 +180,7 @@ export class HealthScoresService {
// ── Data Readiness Checks ──
private async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
const missing: string[] = [];
if (scoreType === 'operating') {
@@ -249,7 +249,7 @@ export class HealthScoresService {
// ── Data Gathering ──
private async gatherOperatingData(qr: any) {
async gatherOperatingData(qr: any) {
const year = new Date().getFullYear();
const [accounts, budgets, assessments, cashFlow, recentTransactions, actualsMonths] = await Promise.all([
@@ -520,7 +520,7 @@ export class HealthScoresService {
};
}
private async gatherReserveData(qr: any) {
async gatherReserveData(qr: any) {
const year = new Date().getFullYear();
const currentMonth = new Date().getMonth(); // 0-indexed
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
@@ -787,7 +787,7 @@ export class HealthScoresService {
// ── AI Prompt Construction ──
private buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
const today = new Date().toISOString().split('T')[0];
const systemPrompt = `You are an HOA financial health analyst. You evaluate the operating fund health of homeowners associations on a scale of 0-100.
@@ -927,7 +927,7 @@ Projected Year-End Cash: $${data.projectedYearEndCash.toFixed(0)}`;
];
}
private buildReservePrompt(data: any): Array<{ role: string; content: string }> {
buildReservePrompt(data: any): Array<{ role: string; content: string }> {
const today = new Date().toISOString().split('T')[0];
const systemPrompt = `You are an HOA reserve fund analyst. You evaluate reserve fund health on a scale of 0-100, assessing whether the HOA is adequately prepared for future capital expenditures.

View File

@@ -5,5 +5,6 @@ import { InvestmentPlanningService } from './investment-planning.service';
@Module({
controllers: [InvestmentPlanningController],
providers: [InvestmentPlanningService],
exports: [InvestmentPlanningService],
})
export class InvestmentPlanningModule {}

View File

@@ -877,7 +877,7 @@ export class InvestmentPlanningService {
// ── Private: AI Prompt Construction ──
private buildPromptMessages(
buildPromptMessages(
snapshot: any,
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
monthlyForecast: any,
@@ -1059,6 +1059,285 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
];
}
// ── Schema-Based Prompt Building (for shadow AI benchmarking) ──
/**
* Build investment recommendation prompt messages for a specific tenant schema.
* Bypasses request-scoped TenantService by using DataSource directly.
*/
async buildPromptForSchema(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'];
// ── Gather financial snapshot data ──
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
`),
]);
// Cash flow context
const [opCashResult, resCashResult, 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 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((sum: number, a: any) => sum + parseFloat(a.balance || '0'), 0);
const reserveCash = accountBalances
.filter((a: any) => a.fund_type === 'reserve' && a.account_type === 'asset')
.reduce((sum: number, a: any) => sum + parseFloat(a.balance || '0'), 0);
const operatingInvestments = investmentAccounts
.filter((i: any) => i.fund_type === 'operating')
.reduce((sum: number, i: any) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
const reserveInvestments = investmentAccounts
.filter((i: any) => i.fund_type === 'reserve')
.reduce((sum: number, i: any) => sum + 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: await 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],
),
monthly_assessment_income: parseFloat(assessmentIncome[0]?.monthly_assessment_income || '0'),
},
};
// ── Build monthly forecast ──
const [opCashRows2, resCashRows2, opInvRows, resInvRows] = 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 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(opCashRows2[0]?.total || '0');
let runResCash = parseFloat(resCashRows2[0]?.total || '0');
let runOpInv = parseFloat(opInvRows[0]?.total || '0');
let 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 getAssessmentIncome = (month: number): { operating: number; reserve: number } => {
let operating = 0, reserve = 0;
for (const g of assessmentGroups) {
const units = parseInt(g.unit_count) || 0;
const regular = parseFloat(g.regular_assessment) || 0;
const special = parseFloat(g.special_assessment) || 0;
const freq = g.frequency || 'monthly';
let applies = false;
if (freq === 'monthly') applies = true;
else if (freq === 'quarterly') applies = [1,4,7,10].includes(month);
else if (freq === 'annual') applies = month === 1;
if (applies) { operating += regular * units; reserve += special * units; }
}
return { operating, reserve };
};
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
for (const yr of [year, year + 1]) {
const budgetRows = 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 key = `${yr}-${m + 1}`;
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
for (const row of budgetRows) {
const amt = parseFloat(row[monthNames[m]]) || 0;
if (amt === 0) continue;
const isOp = row.fund_type === 'operating';
if (row.account_type === 'income') { if (isOp) budgetsByYearMonth[key].opIncome += amt; else budgetsByYearMonth[key].resIncome += amt; }
else if (row.account_type === 'expense') { if (isOp) budgetsByYearMonth[key].opExpense += amt; else budgetsByYearMonth[key].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 maturityIndex: Record<string, { operating: number; reserve: number }> = {};
for (const inv of maturities) {
const d = new Date(inv.maturity_date);
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
if (!maturityIndex[key]) maturityIndex[key] = { operating: 0, reserve: 0 };
const val = parseFloat(inv.current_value) || 0;
const rate = parseFloat(inv.interest_rate) || 0;
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
const matDate = new Date(inv.maturity_date);
const daysHeld = Math.max((matDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
const interestEarned = val * (rate / 100) * (daysHeld / 365);
const maturityTotal = val + interestEarned;
if (inv.fund_type === 'operating') maturityIndex[key].operating += maturityTotal;
else maturityIndex[key].reserve += maturityTotal;
}
const projectExpenses = 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 projectIndex: Record<string, { operating: number; reserve: number }> = {};
for (const p of projectExpenses) {
const yr2 = parseInt(p.target_year);
const mo = parseInt(p.target_month) || 6;
const key = `${yr2}-${mo}`;
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
const cost = parseFloat(p.estimated_cost) || 0;
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
else projectIndex[key].reserve += cost;
}
const datapoints: any[] = [];
for (let i = 0; i < 12; i++) {
const fYear = year + Math.floor((currentMonth - 1 + i) / 12);
const fMonth = ((currentMonth - 1 + i) % 12) + 1;
const key = `${fYear}-${fMonth}`;
const label = `${monthLabels[fMonth - 1]} ${fYear}`;
const assessments = getAssessmentIncome(fMonth);
const budget = budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
const maturity = maturityIndex[key] || { operating: 0, reserve: 0 };
const project = projectIndex[key] || { operating: 0, reserve: 0 };
const opIncomeMonth = budget.opIncome > 0 ? budget.opIncome : assessments.operating;
const resIncomeMonth = budget.resIncome > 0 ? budget.resIncome : assessments.reserve;
runOpCash += opIncomeMonth - budget.opExpense - project.operating + maturity.operating;
runResCash += resIncomeMonth - budget.resExpense - project.reserve + maturity.reserve;
if (maturity.operating > 0) runOpInv = Math.max(0, runOpInv - (maturity.operating * 0.96));
if (maturity.reserve > 0) runResInv = Math.max(0, runResInv - (maturity.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(opIncomeMonth * 100) / 100,
op_expense: Math.round(budget.opExpense * 100) / 100,
res_income: Math.round(resIncomeMonth * 100) / 100,
res_expense: Math.round(budget.resExpense * 100) / 100,
project_cost_op: Math.round(project.operating * 100) / 100,
project_cost_res: Math.round(project.reserve * 100) / 100,
maturity_op: Math.round(maturity.operating * 100) / 100,
maturity_res: Math.round(maturity.reserve * 100) / 100,
});
}
const assessmentSchedule = 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),
}));
const monthlyForecast = { datapoints, assessment_schedule: assessmentSchedule };
const allRates = await this.getMarketRates();
return this.buildPromptMessages(snapshot, allRates, monthlyForecast);
} finally {
await qr.release();
}
}
// ── Private: AI API Call ──
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {

View File

@@ -0,0 +1,37 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ schema: 'shared', name: 'shadow_ai_models' })
export class ShadowAiModel {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 10, unique: true })
slot: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ name: 'api_url', type: 'varchar', length: 500 })
apiUrl: string;
@Column({ name: 'api_key', type: 'varchar', length: 500 })
apiKey: string;
@Column({ name: 'model_name', type: 'varchar', length: 200 })
modelName: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ShadowRun } from './shadow-run.entity';
@Entity({ schema: 'shared', name: 'shadow_run_results' })
export class ShadowRunResult {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'run_id', type: 'uuid' })
runId: string;
@Column({ name: 'model_role', type: 'varchar', length: 20 })
modelRole: string;
@Column({ name: 'model_name', type: 'varchar', length: 200 })
modelName: string;
@Column({ name: 'api_url', type: 'varchar', length: 500 })
apiUrl: string;
@Column({ name: 'raw_response', type: 'text', nullable: true })
rawResponse: string;
@Column({ name: 'parsed_response', type: 'jsonb', nullable: true })
parsedResponse: any;
@Column({ name: 'response_time_ms', type: 'integer', nullable: true })
responseTimeMs: number;
@Column({ name: 'token_usage', type: 'jsonb', nullable: true })
tokenUsage: any;
@Column({ type: 'varchar', length: 20, default: 'pending' })
status: string;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@ManyToOne(() => ShadowRun, (run) => run.results, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'run_id' })
run: ShadowRun;
}

View File

@@ -0,0 +1,44 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToMany,
} from 'typeorm';
import { ShadowRunResult } from './shadow-run-result.entity';
@Entity({ schema: 'shared', name: 'shadow_runs' })
export class ShadowRun {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 30 })
feature: string;
@Column({ type: 'varchar', length: 20, default: 'running' })
status: string;
@Column({ name: 'triggered_by', type: 'uuid', nullable: true })
triggeredBy: string;
@Column({ name: 'prompt_messages', type: 'jsonb' })
promptMessages: any;
@Column({ name: 'started_at', type: 'timestamptz', default: () => 'NOW()' })
startedAt: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@OneToMany(() => ShadowRunResult, (result) => result.run, { eager: true })
results: ShadowRunResult[];
// Virtual field populated via JOIN
tenantName?: string;
}

View File

@@ -0,0 +1,118 @@
import {
Controller,
Get,
Put,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
Req,
ForbiddenException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UsersService } from '../users/users.service';
import { ShadowAiService } from './shadow-ai.service';
@ApiTags('admin/shadow-ai')
@Controller('admin/shadow-ai')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class ShadowAiController {
constructor(
private shadowAiService: ShadowAiService,
private usersService: UsersService,
) {}
private async requireSuperadmin(req: any) {
const user = await this.usersService.findById(req.user.userId || req.user.sub);
if (!user?.isSuperadmin) {
throw new ForbiddenException('Superadmin access required');
}
return user;
}
// ── Model Configuration ──
@Get('models')
async getModels(@Req() req: any) {
await this.requireSuperadmin(req);
return this.shadowAiService.getModels();
}
@Put('models/:slot')
async upsertModel(
@Req() req: any,
@Param('slot') slot: string,
@Body() body: { name: string; apiUrl: string; apiKey: string; modelName: string; isActive?: boolean },
) {
await this.requireSuperadmin(req);
if (!['A', 'B'].includes(slot)) {
throw new BadRequestException('Slot must be A or B');
}
if (!body.name || !body.apiUrl || !body.apiKey || !body.modelName) {
throw new BadRequestException('name, apiUrl, apiKey, and modelName are required');
}
return this.shadowAiService.upsertModel(slot, body);
}
@Delete('models/:slot')
async deleteModel(@Req() req: any, @Param('slot') slot: string) {
await this.requireSuperadmin(req);
if (!['A', 'B'].includes(slot)) {
throw new BadRequestException('Slot must be A or B');
}
return this.shadowAiService.deleteModel(slot);
}
// ── Shadow Runs ──
@Post('runs')
async triggerRun(
@Req() req: any,
@Body() body: { tenantId: string; feature: string },
) {
const user = await this.requireSuperadmin(req);
const validFeatures = ['operating_health', 'reserve_health', 'investment_recommendations'];
if (!validFeatures.includes(body.feature)) {
throw new BadRequestException(`Feature must be one of: ${validFeatures.join(', ')}`);
}
if (!body.tenantId) {
throw new BadRequestException('tenantId is required');
}
return this.shadowAiService.triggerRun(
body.tenantId,
body.feature as any,
user.id,
);
}
@Get('runs')
async getRunHistory(
@Req() req: any,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('tenantId') tenantId?: string,
@Query('feature') feature?: string,
) {
await this.requireSuperadmin(req);
return this.shadowAiService.getRunHistory({
page: page ? parseInt(page) : undefined,
limit: limit ? parseInt(limit) : undefined,
tenantId,
feature,
});
}
@Get('runs/:id')
async getRunDetail(@Req() req: any, @Param('id') id: string) {
await this.requireSuperadmin(req);
const detail = await this.shadowAiService.getRunDetail(id);
if (!detail) throw new NotFoundException('Shadow run not found');
return detail;
}
}

View File

@@ -0,0 +1,26 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ShadowAiController } from './shadow-ai.controller';
import { ShadowAiService } from './shadow-ai.service';
import { ShadowAiModel } from './entities/shadow-ai-model.entity';
import { ShadowRun } from './entities/shadow-run.entity';
import { ShadowRunResult } from './entities/shadow-run-result.entity';
import { HealthScoresModule } from '../health-scores/health-scores.module';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
TypeOrmModule.forFeature([ShadowAiModel, ShadowRun, ShadowRunResult]),
HealthScoresModule,
UsersModule,
],
controllers: [ShadowAiController],
providers: [ShadowAiService],
})
export class ShadowAiModule implements OnModuleInit {
constructor(private shadowAiService: ShadowAiService) {}
async onModuleInit() {
await this.shadowAiService.ensureTables();
}
}

View File

@@ -0,0 +1,723 @@
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();
}
}
}

View File

@@ -0,0 +1,275 @@
# Shadow AI Benchmarking Feature
## Context
The platform uses a single AI model (Qwen 3.5 via NVIDIA NIM) for three features: Operating Health Score, Reserve Health Score, and Investment Recommendations. The platform owner needs a way to evaluate alternate models (different providers, different versions) against the production model using real tenant data — without impacting users. This enables informed model migration decisions by comparing outputs side-by-side.
## Architecture Overview
- **New admin page** at `/admin/shadow-ai` with model configuration, run trigger, and history
- **New backend module** `shadow-ai` with controller, service, and 3 entities
- **3 new DB tables** in the `shared` schema for model configs, runs, and results
- **Shared AI caller utility** to avoid duplicating HTTP logic
- **Minimal changes** to existing services: make prompt-building methods public and export modules
---
## Phase 1: Shared AI Caller Utility
### New file: `backend/src/common/utils/ai-caller.ts`
Extract the HTTP POST logic (currently duplicated in both `callAI()` methods) into a reusable function:
```typescript
export async function callOpenAICompatible(params: {
apiUrl: string;
apiKey: string;
model: string;
messages: Array<{ role: string; content: string }>;
temperature: number;
maxTokens: number;
timeoutMs?: number; // default 600000
}): Promise<{
content: string; // cleaned JSON string (fences + <think> stripped)
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
responseTimeMs: number;
}>
```
Handles: HTTPS POST to `{apiUrl}/chat/completions`, timeout, markdown fence stripping, `<think>` block removal, timing.
## Phase 2: Expose Existing Prompt Builders
### `backend/src/modules/health-scores/health-scores.service.ts`
- Change `private``public` on:
- `gatherOperatingData(qr)` (line 252)
- `gatherReserveData(qr)` (line 523)
- `buildOperatingPrompt(data)` (line 790)
- `buildReservePrompt(data)` (line 930)
- `checkDataReadiness(qr, scoreType)` (used to validate data exists)
### `backend/src/modules/health-scores/health-scores.module.ts`
- Add `exports: [HealthScoresService]`
### `backend/src/modules/investment-planning/investment-planning.service.ts`
- Add new public method `buildPromptForSchema(schemaName: string)` that:
1. Creates a query runner, sets `search_path` to the tenant schema
2. Runs the same data-gathering queries (financial snapshot, market rates, monthly forecast) using the query runner directly (bypassing request-scoped `TenantService`)
3. Calls the existing `buildPromptMessages()` with gathered data
4. Returns `Array<{ role: string; content: string }>`
- Change `buildPromptMessages()` from `private``public` (line 880)
### `backend/src/modules/investment-planning/investment-planning.module.ts`
- Add `exports: [InvestmentPlanningService]`
## Phase 3: Database Tables & Entities
### 3 new tables in `shared` schema
**`shared.shadow_ai_models`** — Alternate model configurations (slots A and B)
| Column | Type | Notes |
|--------|------|-------|
| id | UUID PK | |
| slot | VARCHAR(10) | CHECK IN ('A', 'B'), UNIQUE |
| name | VARCHAR(100) | Display label |
| api_url | VARCHAR(500) | OpenAI-compatible endpoint |
| api_key | VARCHAR(500) | Bearer token |
| model_name | VARCHAR(200) | Model identifier |
| is_active | BOOLEAN | Default true |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |
**`shared.shadow_runs`** — One row per comparison execution
| Column | Type | Notes |
|--------|------|-------|
| id | UUID PK | |
| tenant_id | UUID FK | → shared.organizations |
| feature | VARCHAR(30) | CHECK IN ('operating_health', 'reserve_health', 'investment_recommendations') |
| status | VARCHAR(20) | CHECK IN ('running', 'completed', 'partial', 'failed') |
| triggered_by | UUID FK | → shared.users |
| prompt_messages | JSONB | Exact messages sent to all models (proof of identical input) |
| started_at | TIMESTAMPTZ | |
| completed_at | TIMESTAMPTZ | |
| created_at | TIMESTAMPTZ | |
**`shared.shadow_run_results`** — One row per model per run (up to 3 per run)
| Column | Type | Notes |
|--------|------|-------|
| id | UUID PK | |
| run_id | UUID FK | → shadow_runs ON DELETE CASCADE |
| model_role | VARCHAR(20) | CHECK IN ('production', 'alternate_a', 'alternate_b'), UNIQUE(run_id, model_role) |
| model_name | VARCHAR(200) | Snapshot of model used |
| api_url | VARCHAR(500) | Snapshot of endpoint used |
| raw_response | TEXT | Unprocessed AI response |
| parsed_response | JSONB | Validated structured output |
| response_time_ms | INTEGER | |
| token_usage | JSONB | { prompt_tokens, completion_tokens, total_tokens } |
| status | VARCHAR(20) | CHECK IN ('pending', 'running', 'success', 'error') |
| error_message | TEXT | |
| created_at | TIMESTAMPTZ | |
### Entity files
- `backend/src/modules/shadow-ai/entities/shadow-ai-model.entity.ts`
- `backend/src/modules/shadow-ai/entities/shadow-run.entity.ts`
- `backend/src/modules/shadow-ai/entities/shadow-run-result.entity.ts`
All use `@Entity({ schema: 'shared', name: '...' })` pattern.
## Phase 4: Shadow AI Backend Module
### New directory: `backend/src/modules/shadow-ai/`
### `shadow-ai.service.ts`
**Model CRUD:**
- `getModels()` — Return both slots, mask API keys (show last 4 chars)
- `upsertModel(slot, dto)` — INSERT/UPDATE config for slot A or B
- `deleteModel(slot)` — Remove model config
**Run Execution:**
- `triggerRun(tenantId, feature, userId)`:
1. Look up tenant `schema_name` from `shared.organizations`
2. Build prompt messages by calling the appropriate exposed method:
- `operating_health`: Create query runner → set search_path → `healthScoresService.gatherOperatingData(qr)``healthScoresService.buildOperatingPrompt(data)`
- `reserve_health`: Same pattern with reserve methods
- `investment_recommendations`: `investmentPlanningService.buildPromptForSchema(schemaName)`
3. Insert `shadow_runs` row with `prompt_messages` stored as JSONB
4. Get production config from env vars, alternate configs from DB
5. Insert 1-3 `shadow_run_results` rows as 'pending' (production + active alternates)
6. Return `{ runId }` immediately
7. Fire-and-forget: call all models in parallel using `callOpenAICompatible()`
- Per feature: operating/reserve use temp 0.1, max_tokens 2048; investment uses temp 0.3, max_tokens 4096
8. Update each result row as it completes (success/error, parsed response, timing)
9. Update run status when all complete
**History:**
- `getRunHistory(page, limit, tenantFilter?, featureFilter?)` — Paginated list with tenant name JOIN
- `getRunDetail(runId)` — Full run + all results
### `shadow-ai.controller.ts`
All endpoints use `@UseGuards(JwtAuthGuard)` + `requireSuperadmin(req)` pattern from `admin.controller.ts`.
| Method | Path | Body/Params |
|--------|------|-------------|
| GET | `/admin/shadow-ai/models` | — |
| PUT | `/admin/shadow-ai/models/:slot` | `{ name, apiUrl, apiKey, modelName, isActive }` |
| DELETE | `/admin/shadow-ai/models/:slot` | — |
| POST | `/admin/shadow-ai/runs` | `{ tenantId, feature }` |
| GET | `/admin/shadow-ai/runs` | `?page&limit&tenantId&feature` |
| GET | `/admin/shadow-ai/runs/:id` | — |
### `shadow-ai.module.ts`
```typescript
@Module({
imports: [
TypeOrmModule.forFeature([ShadowAiModel, ShadowRun, ShadowRunResult]),
HealthScoresModule,
InvestmentPlanningModule,
UsersModule,
],
controllers: [ShadowAiController],
providers: [ShadowAiService],
})
```
### Register in `backend/src/app.module.ts`
- Add `import { ShadowAiModule }` and include in the `imports` array
## Phase 5: Frontend — Admin Shadow AI Page
### New file: `frontend/src/pages/admin/AdminShadowAiPage.tsx`
**Layout**: Mantine `Tabs` with 3 tabs
#### Tab 1: "Model Configuration"
- Three `Card` components in a `SimpleGrid cols={3}`:
- **Production** (read-only): Shows model name, API URL from a dedicated endpoint or hardcoded label "From environment config"
- **Alternate A**: Form with `TextInput` (name, API URL, model name), `PasswordInput` (API key), `Switch` (active), Save/Delete buttons
- **Alternate B**: Same form
- Fetches via `GET /api/admin/shadow-ai/models`
- Saves via `PUT /api/admin/shadow-ai/models/A` or `/B`
#### Tab 2: "Run Comparison"
- `Select` dropdown for tenant (reuse `GET /api/admin/organizations` already used by AdminPage)
- `Select` for feature type (Operating Health / Reserve Health / Investment Recommendations)
- `Button` "Run Shadow Comparison"
- On trigger: `POST /api/admin/shadow-ai/runs` → get `runId`
- Poll `GET /api/admin/shadow-ai/runs/:id` every 3s via `refetchInterval` until status !== 'running'
- Show per-model progress indicators during run
- Once complete, render results using shared comparison component (below)
#### Tab 3: "History"
- `Table` with columns: Date, Tenant, Feature, Status (Badge), Duration
- Filter controls: tenant Select, feature Select
- Click row → expand detail or modal showing full comparison
- Pagination
#### Shared Component: Side-by-Side Results Display
- `SimpleGrid cols={3}` (or fewer columns if only some models were configured)
- Each column:
- Header: model name + response time `Badge`
- **For health scores**: Score with `RingProgress`, label `Badge`, summary text, factors list (color-coded by impact), recommendations list (color-coded by priority)
- **For investment**: Overall assessment text, recommendation cards with type/priority badges, risk notes
- Collapsible raw JSON via `Accordion`
- **Diff highlighting**: Where parsed values differ across models, apply subtle background highlight (e.g., `yellow.0` in Mantine theme). Simple recursive comparison of JSON keys/values.
### Route addition: `frontend/src/App.tsx`
Within the `/admin` route group (after `<Route index element={<AdminPage />} />`):
```tsx
<Route path="shadow-ai" element={<AdminShadowAiPage />} />
```
### Sidebar nav: `frontend/src/components/layout/Sidebar.tsx`
In the `isAdminOnly` section (after the "Admin Panel" NavLink, around line 134):
```tsx
<NavLink
label="AI Benchmarking"
leftSection={<IconScale size={18} />}
active={location.pathname === '/admin/shadow-ai'}
onClick={() => go('/admin/shadow-ai')}
color="violet"
/>
```
## Implementation Order
1. **`ai-caller.ts`** — Shared utility (no dependencies)
2. **Health scores + investment planning** — Make methods public, add exports, add `buildPromptForSchema`
3. **Entities** — 3 TypeORM entity files
4. **Service + Controller + Module** — Shadow AI backend
5. **Register module** in `app.module.ts`
6. **Frontend page**`AdminShadowAiPage.tsx`
7. **Route + Sidebar** — Wire up navigation
## Verification
1. **Backend**: Start server, confirm no TypeORM errors for new entities
2. **Model config**: Use admin UI to save/load/delete alternate model configs
3. **Run comparison**: Select a tenant, trigger a run, verify all 3 models are called with identical prompts
4. **Results display**: Confirm side-by-side output renders correctly for all 3 feature types
5. **History**: Verify past runs are persisted and browsable
6. **Auth**: Confirm non-superadmin users get 403 on all shadow-ai endpoints
7. **Production safety**: Verify no changes to production AI behavior — shadow runs are completely isolated
## Key Files to Modify
- `backend/src/modules/health-scores/health-scores.service.ts` — Make 5 methods public
- `backend/src/modules/health-scores/health-scores.module.ts` — Add exports
- `backend/src/modules/investment-planning/investment-planning.service.ts` — Add `buildPromptForSchema()`, make `buildPromptMessages()` public
- `backend/src/modules/investment-planning/investment-planning.module.ts` — Add exports
- `backend/src/app.module.ts` — Register ShadowAiModule
- `frontend/src/App.tsx` — Add route
- `frontend/src/components/layout/Sidebar.tsx` — Add nav item
## New Files
- `backend/src/common/utils/ai-caller.ts`
- `backend/src/modules/shadow-ai/shadow-ai.module.ts`
- `backend/src/modules/shadow-ai/shadow-ai.service.ts`
- `backend/src/modules/shadow-ai/shadow-ai.controller.ts`
- `backend/src/modules/shadow-ai/entities/shadow-ai-model.entity.ts`
- `backend/src/modules/shadow-ai/entities/shadow-run.entity.ts`
- `backend/src/modules/shadow-ai/entities/shadow-run-result.entity.ts`
- `frontend/src/pages/admin/AdminShadowAiPage.tsx`

View File

@@ -30,6 +30,7 @@ import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
import { AdminPage } from './pages/admin/AdminPage';
import { AdminIdeasPage } from './pages/admin/AdminIdeasPage';
import { AdminShadowAiPage } from './pages/admin/AdminShadowAiPage';
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
@@ -135,6 +136,7 @@ export function App() {
>
<Route index element={<AdminPage />} />
<Route path="ideas" element={<AdminIdeasPage />} />
<Route path="shadow-ai" element={<AdminShadowAiPage />} />
</Route>
{/* Main app routes (require auth + org) */}

View File

@@ -140,6 +140,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
onClick={() => go('/admin/ideas')}
color="yellow"
/>
<NavLink
label="AI Benchmarking"
leftSection={<IconScale size={18} />}
active={location.pathname === '/admin/shadow-ai'}
onClick={() => go('/admin/shadow-ai')}
color="violet"
/>
{organizations && organizations.length > 0 && (
<>
<Divider my="sm" />
@@ -245,6 +252,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
onClick={() => go('/admin/ideas')}
color="yellow"
/>
<NavLink
label="AI Benchmarking"
leftSection={<IconScale size={18} />}
active={location.pathname === '/admin/shadow-ai'}
onClick={() => go('/admin/shadow-ai')}
color="violet"
/>
</>
)}
</ScrollArea>

View File

@@ -0,0 +1,780 @@
import { useState, useEffect } from 'react';
import {
Title, Text, Card, SimpleGrid, Group, Stack, Badge, Loader, Center,
Tabs, TextInput, Button, PasswordInput, Select, Table, Accordion,
Switch, Paper, RingProgress, Divider, Alert, Code, ScrollArea, Box,
Tooltip, ActionIcon,
} from '@mantine/core';
import {
IconScale, IconSettings, IconPlayerPlay, IconHistory,
IconCheck, IconX, IconAlertTriangle, IconClock, IconTrash,
IconRefresh, IconArrowRight, IconChevronDown,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
// ── Interfaces ──
interface ShadowModel {
id: string;
slot: string;
name: string;
api_url: string;
api_key: string;
model_name: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
interface ShadowRunResult {
id: string;
run_id: string;
model_role: string;
model_name: string;
api_url: string;
raw_response: string;
parsed_response: any;
response_time_ms: number;
token_usage: any;
status: string;
error_message: string;
created_at: string;
}
interface ShadowRun {
id: string;
tenant_id: string;
tenant_name: string;
feature: string;
status: string;
prompt_messages: any;
started_at: string;
completed_at: string;
created_at: string;
results: ShadowRunResult[];
result_count?: string;
success_count?: string;
}
interface AdminOrg {
id: string;
name: string;
status: string;
}
// ── Helper Functions ──
const featureLabels: Record<string, string> = {
operating_health: 'Operating Health',
reserve_health: 'Reserve Health',
investment_recommendations: 'Investment Recommendations',
};
const roleLabels: Record<string, string> = {
production: 'Production',
alternate_a: 'Alternate A',
alternate_b: 'Alternate B',
};
const statusColor: Record<string, string> = {
running: 'blue',
completed: 'green',
partial: 'yellow',
failed: 'red',
pending: 'gray',
success: 'green',
error: 'red',
};
function formatDuration(ms: number | null): string {
if (!ms) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function formatDate(d: string): string {
if (!d) return '-';
return new Date(d).toLocaleString();
}
// ── Model Configuration Tab ──
function ModelConfigTab() {
const queryClient = useQueryClient();
const { data: models, isLoading } = useQuery<ShadowModel[]>({
queryKey: ['shadow-ai-models'],
queryFn: () => api.get('/admin/shadow-ai/models').then((r) => r.data),
});
const modelA = models?.find((m) => m.slot === 'A');
const modelB = models?.find((m) => m.slot === 'B');
return (
<Stack>
<Text size="sm" c="dimmed">
Configure alternate AI models to benchmark against the production model.
Each model can use any OpenAI-compatible API endpoint.
</Text>
<SimpleGrid cols={{ base: 1, md: 3 }}>
<ProductionModelCard />
<ModelSlotCard slot="A" model={modelA} isLoading={isLoading} />
<ModelSlotCard slot="B" model={modelB} isLoading={isLoading} />
</SimpleGrid>
</Stack>
);
}
function ProductionModelCard() {
return (
<Card withBorder shadow="sm">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>Production Model</Text>
<Badge color="green" variant="light">Active</Badge>
</Group>
<Divider />
<Text size="sm" c="dimmed">Configured via environment variables</Text>
<TextInput label="Model" value="(from AI_MODEL env var)" readOnly disabled size="sm" />
<TextInput label="API URL" value="(from AI_API_URL env var)" readOnly disabled size="sm" />
<Text size="xs" c="dimmed" mt="xs">
Production model settings are managed through server environment
variables and cannot be changed from the UI.
</Text>
</Stack>
</Card>
);
}
function ModelSlotCard({ slot, model, isLoading }: { slot: string; model?: ShadowModel; isLoading: boolean }) {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [apiUrl, setApiUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const [modelName, setModelName] = useState('');
const [isActive, setIsActive] = useState(true);
useEffect(() => {
if (model) {
setName(model.name);
setApiUrl(model.api_url);
setApiKey(model.api_key);
setModelName(model.model_name);
setIsActive(model.is_active);
}
}, [model]);
const saveMutation = useMutation({
mutationFn: () => api.put(`/admin/shadow-ai/models/${slot}`, { name, apiUrl, apiKey, modelName, isActive }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shadow-ai-models'] }),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/admin/shadow-ai/models/${slot}`),
onSuccess: () => {
setName(''); setApiUrl(''); setApiKey(''); setModelName(''); setIsActive(true);
queryClient.invalidateQueries({ queryKey: ['shadow-ai-models'] });
},
});
if (isLoading) return <Card withBorder shadow="sm"><Center h={200}><Loader size="sm" /></Center></Card>;
return (
<Card withBorder shadow="sm">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>Alternate {slot}</Text>
{model ? (
<Badge color={isActive ? 'blue' : 'gray'} variant="light">
{isActive ? 'Active' : 'Inactive'}
</Badge>
) : (
<Badge color="gray" variant="light">Not configured</Badge>
)}
</Group>
<Divider />
<TextInput label="Display Name" placeholder="e.g. GPT-4o" value={name} onChange={(e) => setName(e.target.value)} size="sm" />
<TextInput label="API URL" placeholder="https://api.openai.com/v1" value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} size="sm" />
<PasswordInput label="API Key" placeholder="sk-..." value={apiKey} onChange={(e) => setApiKey(e.target.value)} size="sm" />
<TextInput label="Model Name" placeholder="gpt-4o" value={modelName} onChange={(e) => setModelName(e.target.value)} size="sm" />
<Switch label="Active" checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} />
<Group>
<Button
size="sm"
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={!name || !apiUrl || !apiKey || !modelName}
>
Save
</Button>
{model && (
<Button size="sm" color="red" variant="light" onClick={() => deleteMutation.mutate()} loading={deleteMutation.isPending}>
<IconTrash size={16} />
</Button>
)}
</Group>
{saveMutation.isError && <Text size="xs" c="red">Failed to save</Text>}
{saveMutation.isSuccess && <Text size="xs" c="green">Saved</Text>}
</Stack>
</Card>
);
}
// ── Run Comparison Tab ──
function RunComparisonTab() {
const queryClient = useQueryClient();
const [tenantId, setTenantId] = useState<string | null>(null);
const [feature, setFeature] = useState<string | null>(null);
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const { data: orgs } = useQuery<AdminOrg[]>({
queryKey: ['admin-orgs'],
queryFn: () => api.get('/admin/organizations').then((r) => r.data),
});
const triggerMutation = useMutation({
mutationFn: () => api.post('/admin/shadow-ai/runs', { tenantId, feature }),
onSuccess: (res) => {
setActiveRunId(res.data.runId);
},
});
const { data: activeRun } = useQuery<ShadowRun>({
queryKey: ['shadow-ai-run', activeRunId],
queryFn: () => api.get(`/admin/shadow-ai/runs/${activeRunId}`).then((r) => r.data),
enabled: !!activeRunId,
refetchInterval: (query) => {
const run = query.state.data;
return run?.status === 'running' ? 3000 : false;
},
});
const orgOptions = (orgs || [])
.filter((o) => o.status === 'active')
.map((o) => ({ value: o.id, label: o.name }));
const featureOptions = [
{ value: 'operating_health', label: 'Operating Health Score' },
{ value: 'reserve_health', label: 'Reserve Health Score' },
{ value: 'investment_recommendations', label: 'Investment Recommendations' },
];
return (
<Stack>
<Card withBorder shadow="sm">
<Stack gap="md">
<Text fw={600}>Run Shadow Comparison</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Select
label="Tenant"
placeholder="Select a tenant"
data={orgOptions}
value={tenantId}
onChange={setTenantId}
searchable
/>
<Select
label="AI Feature"
placeholder="Select feature"
data={featureOptions}
value={feature}
onChange={setFeature}
/>
<Stack justify="flex-end">
<Button
leftSection={<IconPlayerPlay size={16} />}
onClick={() => triggerMutation.mutate()}
loading={triggerMutation.isPending}
disabled={!tenantId || !feature}
>
Run Comparison
</Button>
</Stack>
</SimpleGrid>
{triggerMutation.isError && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
Failed to start comparison. Ensure at least one alternate model is configured.
</Alert>
)}
</Stack>
</Card>
{activeRun && (
<Card withBorder shadow="sm">
<Stack gap="md">
<Group justify="space-between">
<Group>
<Text fw={600}>
{featureLabels[activeRun.feature] || activeRun.feature}
</Text>
<Badge color={statusColor[activeRun.status]}>{activeRun.status}</Badge>
</Group>
{activeRun.tenant_name && (
<Text size="sm" c="dimmed">Tenant: {activeRun.tenant_name}</Text>
)}
</Group>
{activeRun.status === 'running' && (
<Center py="md">
<Stack align="center" gap="xs">
<Loader size="md" />
<Text size="sm" c="dimmed">Running models... This may take a few minutes.</Text>
<Group gap="xs">
{(activeRun.results || []).map((r) => (
<Badge key={r.model_role} color={statusColor[r.status]} variant="light">
{roleLabels[r.model_role]}: {r.status}
</Badge>
))}
</Group>
</Stack>
</Center>
)}
{activeRun.status !== 'running' && activeRun.results && (
<ComparisonResults results={activeRun.results} feature={activeRun.feature} />
)}
</Stack>
</Card>
)}
</Stack>
);
}
// ── History Tab ──
function HistoryTab() {
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [tenantFilter, setTenantFilter] = useState<string | null>(null);
const [featureFilter, setFeatureFilter] = useState<string | null>(null);
const { data: orgs } = useQuery<AdminOrg[]>({
queryKey: ['admin-orgs'],
queryFn: () => api.get('/admin/organizations').then((r) => r.data),
});
const { data: historyData, isLoading } = useQuery({
queryKey: ['shadow-ai-runs', tenantFilter, featureFilter],
queryFn: () => {
const params = new URLSearchParams();
if (tenantFilter) params.set('tenantId', tenantFilter);
if (featureFilter) params.set('feature', featureFilter);
params.set('limit', '50');
return api.get(`/admin/shadow-ai/runs?${params}`).then((r) => r.data);
},
});
const { data: selectedRun } = useQuery<ShadowRun>({
queryKey: ['shadow-ai-run', selectedRunId],
queryFn: () => api.get(`/admin/shadow-ai/runs/${selectedRunId}`).then((r) => r.data),
enabled: !!selectedRunId,
});
const orgOptions = [
{ value: '', label: 'All Tenants' },
...(orgs || []).map((o) => ({ value: o.id, label: o.name })),
];
const featureOptions = [
{ value: '', label: 'All Features' },
{ value: 'operating_health', label: 'Operating Health' },
{ value: 'reserve_health', label: 'Reserve Health' },
{ value: 'investment_recommendations', label: 'Investment Recommendations' },
];
const runs: ShadowRun[] = historyData?.runs || [];
return (
<Stack>
<Group>
<Select
size="sm"
placeholder="Filter by tenant"
data={orgOptions}
value={tenantFilter || ''}
onChange={(v) => setTenantFilter(v || null)}
clearable
w={200}
/>
<Select
size="sm"
placeholder="Filter by feature"
data={featureOptions}
value={featureFilter || ''}
onChange={(v) => setFeatureFilter(v || null)}
clearable
w={200}
/>
</Group>
{isLoading ? (
<Center py="xl"><Loader /></Center>
) : runs.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No shadow runs found.</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Date</Table.Th>
<Table.Th>Tenant</Table.Th>
<Table.Th>Feature</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Models</Table.Th>
<Table.Th>Duration</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{runs.map((run) => {
const duration = run.completed_at && run.started_at
? new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()
: null;
return (
<Table.Tr
key={run.id}
style={{ cursor: 'pointer' }}
onClick={() => setSelectedRunId(run.id)}
bg={selectedRunId === run.id ? 'var(--mantine-color-blue-light)' : undefined}
>
<Table.Td>{formatDate(run.created_at)}</Table.Td>
<Table.Td>{run.tenant_name || '-'}</Table.Td>
<Table.Td>{featureLabels[run.feature] || run.feature}</Table.Td>
<Table.Td><Badge color={statusColor[run.status]} size="sm">{run.status}</Badge></Table.Td>
<Table.Td>{run.success_count || '0'}/{run.result_count || '0'}</Table.Td>
<Table.Td>{formatDuration(duration)}</Table.Td>
<Table.Td><IconArrowRight size={14} /></Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
)}
{selectedRun && selectedRun.results && (
<Card withBorder shadow="sm" mt="md">
<Stack gap="md">
<Group justify="space-between">
<Group>
<Text fw={600}>{featureLabels[selectedRun.feature] || selectedRun.feature}</Text>
<Badge color={statusColor[selectedRun.status]}>{selectedRun.status}</Badge>
</Group>
<Text size="sm" c="dimmed">
{selectedRun.tenant_name} | {formatDate(selectedRun.created_at)}
</Text>
</Group>
<ComparisonResults results={selectedRun.results} feature={selectedRun.feature} />
</Stack>
</Card>
)}
</Stack>
);
}
// ── Comparison Results Component ──
function ComparisonResults({ results, feature }: { results: ShadowRunResult[]; feature: string }) {
const isHealthScore = feature === 'operating_health' || feature === 'reserve_health';
// Collect all parsed values for diff highlighting
const parsedValues = results
.filter((r) => r.status === 'success' && r.parsed_response)
.map((r) => r.parsed_response);
return (
<SimpleGrid cols={{ base: 1, md: Math.min(results.length, 3) }}>
{results.map((result) => (
<ResultCard
key={result.model_role}
result={result}
isHealthScore={isHealthScore}
allParsed={parsedValues}
/>
))}
</SimpleGrid>
);
}
function ResultCard({
result,
isHealthScore,
allParsed,
}: {
result: ShadowRunResult;
isHealthScore: boolean;
allParsed: any[];
}) {
const roleColor: Record<string, string> = {
production: 'green',
alternate_a: 'blue',
alternate_b: 'violet',
};
return (
<Card withBorder shadow="xs" padding="md">
<Stack gap="sm">
<Group justify="space-between">
<Group gap="xs">
<Badge color={roleColor[result.model_role] || 'gray'} variant="filled">
{roleLabels[result.model_role]}
</Badge>
</Group>
<Badge
color={statusColor[result.status]}
variant="light"
leftSection={result.status === 'success' ? <IconCheck size={12} /> : result.status === 'error' ? <IconX size={12} /> : <IconClock size={12} />}
>
{result.status}
</Badge>
</Group>
<Text size="xs" c="dimmed" truncate>{result.model_name}</Text>
{result.response_time_ms && (
<Badge color="gray" variant="light" size="sm">
{formatDuration(result.response_time_ms)}
</Badge>
)}
{result.token_usage && (
<Text size="xs" c="dimmed">
Tokens: {result.token_usage.prompt_tokens || '?'} prompt / {result.token_usage.completion_tokens || '?'} completion
</Text>
)}
<Divider />
{result.status === 'error' && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{result.error_message || 'Unknown error'}</Text>
</Alert>
)}
{result.status === 'success' && result.parsed_response && (
isHealthScore
? <HealthScoreDisplay data={result.parsed_response} allParsed={allParsed} />
: <InvestmentDisplay data={result.parsed_response} allParsed={allParsed} />
)}
{result.status === 'success' && (
<Accordion variant="contained">
<Accordion.Item value="raw">
<Accordion.Control>
<Text size="xs">Raw JSON Response</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollArea h={300}>
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(result.parsed_response, null, 2)}
</Code>
</ScrollArea>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</Stack>
</Card>
);
}
// ── Health Score Display ──
function HealthScoreDisplay({ data, allParsed }: { data: any; allParsed: any[] }) {
const score = data.score ?? data.raw_text;
const label = data.label || '';
const summary = data.summary || '';
const factors = data.factors || [];
const recommendations = data.recommendations || [];
// Check if score differs from other models
const scores = allParsed.map((p) => p.score).filter((s) => typeof s === 'number');
const scoreDiffers = scores.length > 1 && !scores.every((s) => s === scores[0]);
const labelColor: Record<string, string> = {
Excellent: 'green', Good: 'teal', Fair: 'yellow',
'Needs Attention': 'orange', 'At Risk': 'red', Critical: 'red',
};
return (
<Stack gap="sm">
{typeof score === 'number' && (
<Group justify="center">
<Box bg={scoreDiffers ? 'yellow.0' : undefined} p="xs" style={{ borderRadius: 8 }}>
<RingProgress
size={100}
thickness={10}
roundCaps
sections={[{ value: score, color: labelColor[label] || 'blue' }]}
label={
<Text ta="center" fw={700} size="lg">{score}</Text>
}
/>
</Box>
</Group>
)}
{label && (
<Group justify="center">
<Badge color={labelColor[label] || 'gray'} size="lg">{label}</Badge>
</Group>
)}
{summary && <Text size="sm">{summary}</Text>}
{factors.length > 0 && (
<>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Factors</Text>
{factors.map((f: any, i: number) => (
<Group key={i} gap="xs" wrap="nowrap">
<Badge
size="xs"
variant="light"
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
>
{f.impact}
</Badge>
<Text size="xs" style={{ flex: 1 }}><b>{f.name}:</b> {f.detail}</Text>
</Group>
))}
</>
)}
{recommendations.length > 0 && (
<>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Recommendations</Text>
{recommendations.map((r: any, i: number) => (
<Group key={i} gap="xs" wrap="nowrap">
<Badge
size="xs"
variant="light"
color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'yellow' : 'blue'}
>
{r.priority}
</Badge>
<Text size="xs" style={{ flex: 1 }}>{r.text}</Text>
</Group>
))}
</>
)}
</Stack>
);
}
// ── Investment Display ──
function InvestmentDisplay({ data, allParsed }: { data: any; allParsed: any[] }) {
const recommendations = data.recommendations || [];
const overall = data.overall_assessment || '';
const riskNotes = data.risk_notes || [];
const recCounts = allParsed.map((p) => (p.recommendations || []).length);
const countDiffers = recCounts.length > 1 && !recCounts.every((c) => c === recCounts[0]);
const typeColors: Record<string, string> = {
cd_ladder: 'violet', new_investment: 'blue', reallocation: 'teal',
maturity_action: 'orange', liquidity_warning: 'red', general: 'gray',
};
return (
<Stack gap="sm">
{overall && (
<Paper p="xs" bg="gray.0" radius="sm">
<Text size="sm">{overall}</Text>
</Paper>
)}
{recommendations.length > 0 && (
<>
<Group gap="xs">
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
Recommendations
</Text>
<Badge
size="xs"
variant="light"
color={countDiffers ? 'yellow' : 'gray'}
>
{recommendations.length}
</Badge>
</Group>
{recommendations.map((rec: any, i: number) => (
<Card key={i} withBorder padding="xs" radius="sm">
<Stack gap={4}>
<Group gap="xs">
<Badge size="xs" color={typeColors[rec.type] || 'gray'}>{rec.type}</Badge>
<Badge size="xs" variant="light" color={rec.priority === 'high' ? 'red' : rec.priority === 'medium' ? 'yellow' : 'blue'}>
{rec.priority}
</Badge>
{rec.fund_type && <Badge size="xs" variant="outline">{rec.fund_type}</Badge>}
</Group>
<Text size="sm" fw={600}>{rec.title}</Text>
<Text size="xs">{rec.summary}</Text>
{rec.suggested_amount && (
<Text size="xs" c="dimmed">
Amount: ${rec.suggested_amount.toLocaleString()}
{rec.suggested_rate ? ` | Rate: ${rec.suggested_rate}%` : ''}
{rec.suggested_term ? ` | Term: ${rec.suggested_term}` : ''}
</Text>
)}
</Stack>
</Card>
))}
</>
)}
{riskNotes.length > 0 && (
<>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Risk Notes</Text>
{riskNotes.map((note: string, i: number) => (
<Group key={i} gap="xs" wrap="nowrap">
<IconAlertTriangle size={14} color="orange" />
<Text size="xs">{note}</Text>
</Group>
))}
</>
)}
</Stack>
);
}
// ── Main Page ──
export function AdminShadowAiPage() {
return (
<Stack gap="lg" p="md">
<Group>
<IconScale size={28} />
<Title order={2}>AI Benchmarking</Title>
</Group>
<Text c="dimmed" size="sm">
Compare AI model outputs side-by-side using real tenant data.
Configure alternate models, run shadow comparisons, and review historical results.
</Text>
<Tabs defaultValue="run">
<Tabs.List>
<Tabs.Tab value="config" leftSection={<IconSettings size={16} />}>
Model Configuration
</Tabs.Tab>
<Tabs.Tab value="run" leftSection={<IconPlayerPlay size={16} />}>
Run Comparison
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<IconHistory size={16} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="config" pt="md">
<ModelConfigTab />
</Tabs.Panel>
<Tabs.Panel value="run" pt="md">
<RunComparisonTab />
</Tabs.Panel>
<Tabs.Panel value="history" pt="md">
<HistoryTab />
</Tabs.Panel>
</Tabs>
</Stack>
);
}