fix: improve AI health score accuracy and consistency
Address 4 issues identified in AI feature audit: 1. Reduce temperature from 0.3 to 0.1 for health score calculations to reduce 16-40 point score volatility across runs 2. Add explicit cash runway classification rules to operating prompt preventing the model from rating sub-3-month runway as "positive" 3. Pre-compute total special assessment income in both operating and reserve prompts, eliminating per-unit vs total confusion ($300 vs $20,100) 4. Make YTD budget comparison actuals-aware: only compare months with posted journal entries, show current month budget separately, and add prompt guidance about month-end posting cadence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -252,7 +252,7 @@ export class HealthScoresService {
|
||||
private async gatherOperatingData(qr: any) {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const [accounts, budgets, assessments, cashFlow, recentTransactions] = await Promise.all([
|
||||
const [accounts, budgets, assessments, cashFlow, recentTransactions, actualsMonths] = await Promise.all([
|
||||
// Operating accounts with balances
|
||||
qr.query(`
|
||||
SELECT a.name, a.account_number, a.account_type, a.fund_type,
|
||||
@@ -311,21 +311,54 @@ export class HealthScoresService {
|
||||
FROM invoices
|
||||
WHERE status IN ('sent', 'overdue') AND due_date < CURRENT_DATE
|
||||
`),
|
||||
// Detect which months have posted actuals (expense or income JEs)
|
||||
qr.query(`
|
||||
SELECT DISTINCT EXTRACT(MONTH FROM je.entry_date)::int as month_num
|
||||
FROM journal_entries je
|
||||
JOIN journal_entry_lines jel ON jel.journal_entry_id = je.id
|
||||
JOIN accounts a ON a.id = jel.account_id
|
||||
WHERE je.entry_date >= $1
|
||||
AND je.entry_date < $2
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND a.fund_type = 'operating'
|
||||
AND a.account_type IN ('income', 'expense')
|
||||
ORDER BY month_num
|
||||
`, [`${year}-01-01`, `${year + 1}-01-01`]),
|
||||
]);
|
||||
|
||||
// Calculate month-by-month budget actuals progress
|
||||
const currentMonth = new Date().getMonth(); // 0-indexed
|
||||
const dayOfMonth = new Date().getDate();
|
||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||
const monthLabelsForBudget = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
|
||||
// Determine which months have posted actuals
|
||||
const monthsWithActuals: number[] = actualsMonths.map((r: any) => parseInt(r.month_num)); // 1-indexed
|
||||
const lastActualsMonth0 = monthsWithActuals.length > 0
|
||||
? Math.max(...monthsWithActuals) - 1 // convert to 0-indexed
|
||||
: -1; // no actuals posted at all
|
||||
|
||||
// YTD budget = sum through last month with actuals only (NOT current incomplete month)
|
||||
let budgetedIncomeYTD = 0;
|
||||
let budgetedExpenseYTD = 0;
|
||||
for (const b of budgets) {
|
||||
for (let m = 0; m <= currentMonth; m++) {
|
||||
for (let m = 0; m <= lastActualsMonth0; m++) {
|
||||
const amt = parseFloat(b[monthNames[m]]) || 0;
|
||||
if (b.account_type === 'income') budgetedIncomeYTD += amt;
|
||||
else if (b.account_type === 'expense') budgetedExpenseYTD += amt;
|
||||
}
|
||||
}
|
||||
|
||||
// Current month budget (shown separately, not included in YTD comparison)
|
||||
let currentMonthBudgetIncome = 0;
|
||||
let currentMonthBudgetExpense = 0;
|
||||
for (const b of budgets) {
|
||||
const amt = parseFloat(b[monthNames[currentMonth]]) || 0;
|
||||
if (b.account_type === 'income') currentMonthBudgetIncome += amt;
|
||||
else if (b.account_type === 'expense') currentMonthBudgetExpense += amt;
|
||||
}
|
||||
const currentMonthHasActuals = monthsWithActuals.includes(currentMonth + 1);
|
||||
|
||||
const operatingCash = accounts
|
||||
.filter((a: any) => a.account_type === 'asset')
|
||||
.reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
|
||||
@@ -459,11 +492,27 @@ export class HealthScoresService {
|
||||
ytdIncome,
|
||||
ytdExpense,
|
||||
monthlyAssessmentIncome,
|
||||
totalAnnualAssessmentIncome: assessments.reduce((sum: number, ag: any) => {
|
||||
const regular = parseFloat(ag.regular_assessment) || 0;
|
||||
const units = parseInt(ag.unit_count) || 0;
|
||||
const total = regular * units;
|
||||
const freq = ag.frequency || 'monthly';
|
||||
if (freq === 'monthly') return sum + total * 12;
|
||||
if (freq === 'quarterly') return sum + total * 4;
|
||||
return sum + total; // annual
|
||||
}, 0),
|
||||
delinquentCount: parseInt(recentTransactions[0]?.count || '0'),
|
||||
delinquentAmount: parseFloat(recentTransactions[0]?.total_overdue || '0'),
|
||||
monthsOfExpenses: budgetedExpenseAnnual > 0 ? (operatingCash / (budgetedExpenseAnnual / 12)) : 0,
|
||||
year,
|
||||
currentMonth: currentMonth + 1,
|
||||
dayOfMonth,
|
||||
monthsWithActuals,
|
||||
lastActualsMonthLabel: lastActualsMonth0 >= 0 ? monthLabelsForBudget[lastActualsMonth0] : null,
|
||||
currentMonthLabel: monthLabelsForBudget[currentMonth],
|
||||
currentMonthBudgetIncome,
|
||||
currentMonthBudgetExpense,
|
||||
currentMonthHasActuals,
|
||||
forecast,
|
||||
lowestCash: Math.round(lowestCash * 100) / 100,
|
||||
lowestCashMonth,
|
||||
@@ -741,6 +790,14 @@ KEY FACTORS TO EVALUATE:
|
||||
4. Income-to-expense ratio
|
||||
5. Emergency buffer adequacy
|
||||
6. CRITICAL — Projected cash flow: Use the 12-MONTH CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from assessments and budgeted sources), expenses (from budget), and project costs. Check whether cash will go negative or dangerously low in any future month. If projected income arrives before projected expenses, the position may be adequate even if current cash seems low. Conversely, if a large expense precedes income in a given month, flag the timing risk.
|
||||
7. BUDGET TIMING: YTD budget comparisons only include months where actual accounting entries have been posted. Do NOT penalize the HOA for a budget variance in the current month if actuals have not yet been submitted — this is normal operational procedure. Actuals are posted at month-end. The current month's budget is shown separately for context only, not for variance analysis.
|
||||
|
||||
CASH RUNWAY CLASSIFICATION (strict — use these rules for the Cash Reserves factor):
|
||||
- <2 months of expenses: impact = "negative"
|
||||
- 2-3 months of expenses: impact = "neutral"
|
||||
- 3-6 months of expenses: impact = "positive"
|
||||
- 6+ months of expenses: impact = "strongly positive" (contributes to Excellent score)
|
||||
Do NOT rate cash runway as positive based on projected future inflows — evaluate the CURRENT cash-on-hand position for this factor. Future inflows should be evaluated separately under the Projected Cash Flow factor.
|
||||
|
||||
RESPONSE FORMAT:
|
||||
Respond with ONLY valid JSON (no markdown, no code fences):
|
||||
@@ -768,14 +825,30 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
||||
.join('\n') || 'No budget line items.';
|
||||
|
||||
const assessmentLines = data.assessments
|
||||
.map((a: any) => `- ${a.name}: $${parseFloat(a.regular_assessment || '0').toFixed(2)}/unit × ${a.unit_count} units (${a.frequency})`)
|
||||
.map((a: any) => {
|
||||
const regular = parseFloat(a.regular_assessment || '0');
|
||||
const units = parseInt(a.unit_count || '0');
|
||||
const total = regular * units;
|
||||
return `- ${a.name}: $${regular.toFixed(2)}/unit × ${units} units (${a.frequency}) = $${total.toFixed(2)} total/period`;
|
||||
})
|
||||
.join('\n') || 'No assessment groups.';
|
||||
|
||||
const totalAnnualAssessmentIncome = data.assessments.reduce((sum: number, a: any) => {
|
||||
const regular = parseFloat(a.regular_assessment || '0');
|
||||
const units = parseInt(a.unit_count || '0');
|
||||
const total = regular * units;
|
||||
const freq = a.frequency || 'monthly';
|
||||
if (freq === 'monthly') return sum + total * 12;
|
||||
if (freq === 'quarterly') return sum + total * 4;
|
||||
return sum + total; // annual
|
||||
}, 0);
|
||||
|
||||
const userPrompt = `Evaluate this HOA's operating fund health.
|
||||
|
||||
TODAY: ${today}
|
||||
FISCAL YEAR: ${data.year}
|
||||
CURRENT MONTH: ${data.currentMonth} of 12
|
||||
CURRENT MONTH: ${data.currentMonthLabel} (day ${data.dayOfMonth}), month ${data.currentMonth} of 12
|
||||
Months with posted actuals: ${data.monthsWithActuals.length > 0 ? data.monthsWithActuals.map((m: number) => ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][m - 1]).join(', ') : 'None yet'}
|
||||
|
||||
=== OPERATING FUND ACCOUNTS ===
|
||||
${accountLines}
|
||||
@@ -789,20 +862,28 @@ Budgeted Annual Income: $${data.budgetedIncomeAnnual.toFixed(2)}
|
||||
Budgeted Annual Expenses: $${data.budgetedExpenseAnnual.toFixed(2)}
|
||||
Monthly Expense Run Rate: $${(data.budgetedExpenseAnnual / 12).toFixed(2)}
|
||||
|
||||
=== BUDGET VS ACTUAL (YTD through month ${data.currentMonth}) ===
|
||||
=== BUDGET VS ACTUAL (YTD through ${data.lastActualsMonthLabel || 'N/A — no actuals posted yet'}) ===
|
||||
Note: This comparison only covers months with posted accounting entries. ${data.lastActualsMonthLabel ? `Actuals have been posted through ${data.lastActualsMonthLabel}.` : 'No monthly actuals have been posted yet for this fiscal year.'} Budget figures are used for forecasting until actuals are submitted at month-end.
|
||||
|
||||
Budgeted Income YTD: $${data.budgetedIncomeYTD.toFixed(2)}
|
||||
Actual Income YTD: $${data.ytdIncome.toFixed(2)}
|
||||
Income Variance: $${(data.ytdIncome - data.budgetedIncomeYTD).toFixed(2)} (${data.budgetedIncomeYTD > 0 ? ((data.ytdIncome / data.budgetedIncomeYTD) * 100).toFixed(1) : 0}% of budget)
|
||||
Income Variance: $${(data.ytdIncome - data.budgetedIncomeYTD).toFixed(2)}${data.budgetedIncomeYTD > 0 ? ` (${((data.ytdIncome / data.budgetedIncomeYTD) * 100).toFixed(1)}% of budget)` : ''}
|
||||
|
||||
Budgeted Expenses YTD: $${data.budgetedExpenseYTD.toFixed(2)}
|
||||
Actual Expenses YTD: $${data.ytdExpense.toFixed(2)}
|
||||
Expense Variance: $${(data.ytdExpense - data.budgetedExpenseYTD).toFixed(2)} (${data.budgetedExpenseYTD > 0 ? ((data.ytdExpense / data.budgetedExpenseYTD) * 100).toFixed(1) : 0}% of budget)
|
||||
Expense Variance: $${(data.ytdExpense - data.budgetedExpenseYTD).toFixed(2)}${data.budgetedExpenseYTD > 0 ? ` (${((data.ytdExpense / data.budgetedExpenseYTD) * 100).toFixed(1)}% of budget)` : ''}
|
||||
|
||||
=== CURRENT MONTH (${data.currentMonthLabel}, ${data.dayOfMonth} days elapsed) ===
|
||||
Budgeted Income this month: $${data.currentMonthBudgetIncome.toFixed(2)}
|
||||
Budgeted Expenses this month: $${data.currentMonthBudgetExpense.toFixed(2)}
|
||||
Actuals posted this month: ${data.currentMonthHasActuals ? 'Yes' : 'No — actuals are typically posted at month-end'}
|
||||
|
||||
=== CASH RUNWAY ===
|
||||
Months of Operating Expenses Covered: ${data.monthsOfExpenses.toFixed(1)} months
|
||||
|
||||
=== ASSESSMENT INCOME ===
|
||||
${assessmentLines}
|
||||
Total Annual Assessment Income: $${data.totalAnnualAssessmentIncome.toFixed(2)}
|
||||
Monthly Assessment Income: $${data.monthlyAssessmentIncome.toFixed(2)}
|
||||
|
||||
=== DELINQUENCY ===
|
||||
@@ -944,11 +1025,26 @@ ${budgetLines}
|
||||
|
||||
=== SPECIAL ASSESSMENT INCOME (Reserve Fund) ===
|
||||
${data.assessments.length === 0 ? 'No special assessments configured.' :
|
||||
data.assessments.map((a: any) => {
|
||||
const special = parseFloat(a.special_assessment || '0');
|
||||
if (special === 0) return null;
|
||||
return `- ${a.name}: $${special.toFixed(2)}/unit × ${a.unit_count} units (${a.frequency}) = $${(special * parseInt(a.unit_count || '0')).toFixed(2)}/period → Reserve Fund`;
|
||||
}).filter(Boolean).join('\n') || 'No special assessments currently being collected.'}
|
||||
(() => {
|
||||
const lines = data.assessments.map((a: any) => {
|
||||
const special = parseFloat(a.special_assessment || '0');
|
||||
if (special === 0) return null;
|
||||
const units = parseInt(a.unit_count || '0');
|
||||
const totalPerPeriod = special * units;
|
||||
return `- ${a.name}: $${special.toFixed(2)}/unit × ${units} units (${a.frequency}) = $${totalPerPeriod.toFixed(2)}/period → Reserve Fund`;
|
||||
}).filter(Boolean);
|
||||
if (lines.length === 0) return 'No special assessments currently being collected.';
|
||||
const totalAnnual = data.assessments.reduce((sum: number, a: any) => {
|
||||
const special = parseFloat(a.special_assessment || '0');
|
||||
const units = parseInt(a.unit_count || '0');
|
||||
const total = special * units;
|
||||
const freq = a.frequency || 'monthly';
|
||||
if (freq === 'monthly') return sum + total * 12;
|
||||
if (freq === 'quarterly') return sum + total * 4;
|
||||
return sum + total;
|
||||
}, 0);
|
||||
return lines.join('\n') + '\nTotal Annual Special Assessment Income to Reserves: $' + totalAnnual.toFixed(2);
|
||||
})()}
|
||||
|
||||
=== 12-MONTH PROJECTED CASH FLOW (Reserve Fund) ===
|
||||
Starting Reserve Cash: $${data.reserveCash.toFixed(2)}
|
||||
@@ -993,7 +1089,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
|
||||
const requestBody = {
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.3,
|
||||
temperature: 0.1,
|
||||
max_tokens: 2048,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user