- Add toggle-able debug logging (AI_DEBUG env var) that logs prompts, request metadata, raw responses, parsed output, and full error chains - Replace Node.js native fetch() with https module for Docker Alpine compatibility (fixes "fetch failed" error with large payloads) - Reduce max_tokens from 16384 to 4096 (qwen3.5 doesn't need thinking token budget) - Strip <think> blocks from model responses - Add AI_DEBUG to docker-compose.yml and .env.example Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
607 lines
23 KiB
TypeScript
607 lines
23 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { TenantService } from '../../database/tenant.service';
|
|
import { DataSource } from 'typeorm';
|
|
|
|
// ── Interfaces ──
|
|
|
|
export interface AccountBalance {
|
|
id: string;
|
|
account_number: string;
|
|
name: string;
|
|
account_type: string;
|
|
fund_type: string;
|
|
interest_rate: string | null;
|
|
balance: string;
|
|
}
|
|
|
|
export interface InvestmentAccount {
|
|
id: string;
|
|
name: string;
|
|
institution: string;
|
|
investment_type: string;
|
|
fund_type: string;
|
|
principal: string;
|
|
interest_rate: string;
|
|
maturity_date: string | null;
|
|
purchase_date: string | null;
|
|
current_value: string;
|
|
}
|
|
|
|
export interface CdRate {
|
|
bank_name: string;
|
|
apy: string;
|
|
min_deposit: string | null;
|
|
term: string;
|
|
term_months: number | null;
|
|
fetched_at: string;
|
|
}
|
|
|
|
export interface Recommendation {
|
|
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
|
|
priority: 'high' | 'medium' | 'low';
|
|
title: string;
|
|
summary: string;
|
|
details: string;
|
|
fund_type: 'operating' | 'reserve' | 'both';
|
|
suggested_amount?: number;
|
|
suggested_term?: string;
|
|
suggested_rate?: number;
|
|
bank_name?: string;
|
|
rationale: string;
|
|
}
|
|
|
|
export interface AIResponse {
|
|
recommendations: Recommendation[];
|
|
overall_assessment: string;
|
|
risk_notes: string[];
|
|
}
|
|
|
|
@Injectable()
|
|
export class InvestmentPlanningService {
|
|
private readonly logger = new Logger(InvestmentPlanningService.name);
|
|
private debugEnabled: boolean;
|
|
|
|
constructor(
|
|
private tenant: TenantService,
|
|
private configService: ConfigService,
|
|
private dataSource: DataSource,
|
|
) {
|
|
// Toggle with AI_DEBUG=true in .env for detailed prompt/response logging
|
|
this.debugEnabled = this.configService.get<string>('AI_DEBUG') === 'true';
|
|
if (this.debugEnabled) {
|
|
this.logger.warn('AI DEBUG MODE ENABLED — prompts and responses will be logged');
|
|
}
|
|
}
|
|
|
|
private debug(label: string, data: any) {
|
|
if (!this.debugEnabled) return;
|
|
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
// Truncate very long output to keep logs manageable
|
|
const truncated = text.length > 5000 ? text.slice(0, 5000) + `\n... [truncated, ${text.length} total chars]` : text;
|
|
this.logger.log(`[AI_DEBUG] ${label}:\n${truncated}`);
|
|
}
|
|
|
|
// ── Public API Methods ──
|
|
|
|
/**
|
|
* Build a comprehensive financial snapshot for the investment planning page.
|
|
* All financial data is tenant-scoped via TenantService.
|
|
*/
|
|
async getFinancialSnapshot() {
|
|
const [
|
|
accountBalances,
|
|
investmentAccounts,
|
|
budgets,
|
|
projects,
|
|
cashFlowContext,
|
|
] = await Promise.all([
|
|
this.getAccountBalances(),
|
|
this.getInvestmentAccounts(),
|
|
this.getBudgets(),
|
|
this.getProjects(),
|
|
this.getCashFlowContext(),
|
|
]);
|
|
|
|
// Compute summary totals
|
|
const operatingCash = accountBalances
|
|
.filter((a) => a.fund_type === 'operating' && a.account_type === 'asset')
|
|
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
|
|
|
const reserveCash = accountBalances
|
|
.filter((a) => a.fund_type === 'reserve' && a.account_type === 'asset')
|
|
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
|
|
|
const operatingInvestments = investmentAccounts
|
|
.filter((i) => i.fund_type === 'operating')
|
|
.reduce((sum, i) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
|
|
|
const reserveInvestments = investmentAccounts
|
|
.filter((i) => i.fund_type === 'reserve')
|
|
.reduce((sum, i) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
|
|
|
return {
|
|
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: cashFlowContext,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch latest CD rates from the shared schema (cross-tenant market data).
|
|
* Uses DataSource directly since this queries the shared schema, not tenant.
|
|
*/
|
|
async getCdRates(): Promise<CdRate[]> {
|
|
const queryRunner = this.dataSource.createQueryRunner();
|
|
try {
|
|
await queryRunner.connect();
|
|
const rates = await queryRunner.query(
|
|
`SELECT bank_name, apy, min_deposit, term, term_months, fetched_at
|
|
FROM shared.cd_rates
|
|
ORDER BY apy DESC
|
|
LIMIT 25`,
|
|
);
|
|
return rates;
|
|
} finally {
|
|
await queryRunner.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Orchestrate the AI recommendation flow:
|
|
* 1. Gather all financial data (tenant-scoped)
|
|
* 2. Fetch CD rates (shared schema)
|
|
* 3. Build the prompt with all context
|
|
* 4. Call the AI API
|
|
* 5. Parse and return structured recommendations
|
|
*/
|
|
async getAIRecommendations(): Promise<AIResponse> {
|
|
this.debug('getAIRecommendations', 'Starting AI recommendation flow');
|
|
|
|
const [snapshot, cdRates] = await Promise.all([
|
|
this.getFinancialSnapshot(),
|
|
this.getCdRates(),
|
|
]);
|
|
|
|
this.debug('snapshot_summary', {
|
|
operating_cash: snapshot.summary.operating_cash,
|
|
reserve_cash: snapshot.summary.reserve_cash,
|
|
total_all: snapshot.summary.total_all,
|
|
investment_accounts: snapshot.investment_accounts.length,
|
|
budgets: snapshot.budgets.length,
|
|
projects: snapshot.projects.length,
|
|
cd_rates: cdRates.length,
|
|
});
|
|
|
|
const messages = this.buildPromptMessages(snapshot, cdRates);
|
|
const aiResponse = await this.callAI(messages);
|
|
|
|
this.debug('final_response', {
|
|
recommendation_count: aiResponse.recommendations.length,
|
|
has_assessment: !!aiResponse.overall_assessment,
|
|
risk_notes_count: aiResponse.risk_notes?.length || 0,
|
|
});
|
|
|
|
return aiResponse;
|
|
}
|
|
|
|
// ── Private: Tenant-Scoped Data Queries ──
|
|
|
|
private async getAccountBalances(): Promise<AccountBalance[]> {
|
|
return this.tenant.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
|
|
`);
|
|
}
|
|
|
|
private async getInvestmentAccounts(): Promise<InvestmentAccount[]> {
|
|
return this.tenant.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
|
|
`);
|
|
}
|
|
|
|
private async getBudgets() {
|
|
const year = new Date().getFullYear();
|
|
return this.tenant.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],
|
|
);
|
|
}
|
|
|
|
private async getProjects() {
|
|
return this.tenant.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
|
|
`);
|
|
}
|
|
|
|
private async getCashFlowContext() {
|
|
const year = new Date().getFullYear();
|
|
|
|
// Current operating cash position
|
|
const opCashResult = await this.tenant.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
|
|
`);
|
|
|
|
// Current reserve cash position
|
|
const resCashResult = await this.tenant.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
|
|
`);
|
|
|
|
// Annual budget summary by fund_type and account_type
|
|
const budgetSummary = await this.tenant.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],
|
|
);
|
|
|
|
// Assessment income (monthly recurring revenue)
|
|
const assessmentIncome = await this.tenant.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
|
|
`);
|
|
|
|
return {
|
|
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'),
|
|
};
|
|
}
|
|
|
|
// ── Private: AI Prompt Construction ──
|
|
|
|
private buildPromptMessages(snapshot: any, cdRates: CdRate[]) {
|
|
const { summary, investment_accounts, budgets, projects, cash_flow_context } = 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 CD rates and instruments provided. Do not reference rates or banks not in the provided data.
|
|
|
|
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 CD rates (if applicable)",
|
|
"rationale": "Financial reasoning for why this makes sense"
|
|
}
|
|
],
|
|
"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: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible.`;
|
|
|
|
// Build the data context for the user prompt
|
|
const investmentsList = investment_accounts.length === 0
|
|
? 'No current investments.'
|
|
: investment_accounts.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 = (cash_flow_context.budget_summary || []).length === 0
|
|
? 'No budget summary available.'
|
|
: cash_flow_context.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 cdRateLines = cdRates.length === 0
|
|
? 'No CD rate data available. Rate fetcher may not have been run yet.'
|
|
: cdRates.map((r: CdRate) =>
|
|
`- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}% | Term: ${r.term} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`,
|
|
).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}
|
|
|
|
=== ANNUAL BUDGET (${new Date().getFullYear()}) ===
|
|
${budgetLines}
|
|
|
|
=== BUDGET SUMMARY (Annual Totals by Category) ===
|
|
${budgetSummaryLines}
|
|
|
|
=== MONTHLY ASSESSMENT INCOME ===
|
|
Recurring monthly assessment income: $${cash_flow_context.monthly_assessment_income.toFixed(2)}/month
|
|
|
|
=== UPCOMING CAPITAL PROJECTS ===
|
|
${projectLines}
|
|
|
|
=== AVAILABLE CD RATES (Market Data) ===
|
|
${cdRateLines}
|
|
|
|
Based on this complete financial picture, provide your investment recommendations. Consider:
|
|
1. Is there excess cash that could earn better returns in CDs?
|
|
2. Are any current investments maturing soon that need reinvestment planning?
|
|
3. Is the liquidity position adequate for upcoming expenses and projects?
|
|
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?`;
|
|
|
|
return [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
];
|
|
}
|
|
|
|
// ── Private: AI API Call ──
|
|
|
|
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {
|
|
const apiUrl = this.configService.get<string>('AI_API_URL') || 'https://integrate.api.nvidia.com/v1';
|
|
const apiKey = this.configService.get<string>('AI_API_KEY');
|
|
const model = this.configService.get<string>('AI_MODEL') || 'qwen/qwen3.5-397b-a17b';
|
|
|
|
if (!apiKey) {
|
|
this.logger.error('AI_API_KEY not configured');
|
|
return {
|
|
recommendations: [],
|
|
overall_assessment: 'AI recommendations are not available. The AI_API_KEY has not been configured in the environment.',
|
|
risk_notes: ['Configure AI_API_KEY in .env to enable investment recommendations.'],
|
|
};
|
|
}
|
|
|
|
const requestBody = {
|
|
model,
|
|
messages,
|
|
temperature: 0.3,
|
|
max_tokens: 4096,
|
|
};
|
|
|
|
const bodyString = JSON.stringify(requestBody);
|
|
|
|
this.debug('prompt_system', messages[0]?.content);
|
|
this.debug('prompt_user', messages[1]?.content);
|
|
this.debug('request_meta', {
|
|
url: `${apiUrl}/chat/completions`,
|
|
model,
|
|
temperature: 0.3,
|
|
max_tokens: 4096,
|
|
body_length_bytes: Buffer.byteLength(bodyString, 'utf-8'),
|
|
message_count: messages.length,
|
|
});
|
|
|
|
try {
|
|
this.logger.log(`Calling AI API: ${apiUrl} with model ${model} (body: ${Buffer.byteLength(bodyString, 'utf-8')} bytes)`);
|
|
|
|
const startTime = Date.now();
|
|
|
|
// Use Node.js https module instead of native fetch for better
|
|
// compatibility in Docker Alpine environments
|
|
const { URL } = await import('url');
|
|
const https = await import('https');
|
|
|
|
const aiResult = await new Promise<any>((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: 180000, // 3 minute timeout
|
|
};
|
|
|
|
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 180s`));
|
|
});
|
|
|
|
req.write(bodyString);
|
|
req.end();
|
|
});
|
|
|
|
const elapsed = Date.now() - startTime;
|
|
this.logger.log(`AI API responded in ${elapsed}ms with status ${aiResult.status}`);
|
|
this.debug('response_timing', { elapsed_ms: elapsed, status: aiResult.status });
|
|
|
|
if (aiResult.status >= 400) {
|
|
this.logger.error(`AI API error ${aiResult.status}: ${aiResult.body}`);
|
|
this.debug('response_error_body', aiResult.body);
|
|
throw new Error(`AI API returned ${aiResult.status}: ${aiResult.body}`);
|
|
}
|
|
|
|
const data = JSON.parse(aiResult.body);
|
|
const msg = data.choices?.[0]?.message;
|
|
// Thinking models (kimi-k2.5) may return content in 'content' or
|
|
// spend all tokens on 'reasoning_content' with content=null
|
|
const content = msg?.content || null;
|
|
|
|
this.logger.log(`AI response: content=${content ? content.length + ' chars' : 'null'}, reasoning=${msg?.reasoning_content ? 'yes' : 'no'}, finish=${data.choices?.[0]?.finish_reason}`);
|
|
this.debug('response_raw_content', content);
|
|
this.debug('response_usage', data.usage);
|
|
if (msg?.reasoning_content) {
|
|
this.debug('response_reasoning', msg.reasoning_content);
|
|
}
|
|
|
|
if (!content) {
|
|
throw new Error('AI model returned empty content — it may have exhausted tokens on reasoning. Try a non-thinking model or increase max_tokens.');
|
|
}
|
|
|
|
// Parse the JSON response — handle potential markdown code fences
|
|
let cleaned = content.trim();
|
|
if (cleaned.startsWith('```')) {
|
|
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
|
}
|
|
|
|
// Handle thinking model wrapper: strip <think>...</think> blocks
|
|
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
|
|
|
|
const parsed = JSON.parse(cleaned) as AIResponse;
|
|
|
|
// Validate the response structure
|
|
if (!parsed.recommendations || !Array.isArray(parsed.recommendations)) {
|
|
this.debug('invalid_response_structure', parsed);
|
|
throw new Error('Invalid AI response: missing recommendations array');
|
|
}
|
|
|
|
this.logger.log(`AI returned ${parsed.recommendations.length} recommendations`);
|
|
this.debug('parsed_recommendations', parsed.recommendations.map((r) => ({
|
|
type: r.type,
|
|
priority: r.priority,
|
|
title: r.title,
|
|
fund_type: r.fund_type,
|
|
suggested_amount: r.suggested_amount,
|
|
})));
|
|
|
|
return parsed;
|
|
} catch (error: any) {
|
|
// Log the full error chain for debugging
|
|
this.logger.error(`AI recommendation failed: ${error.message}`);
|
|
if (error.cause) {
|
|
this.logger.error(` → cause: ${error.cause?.message || error.cause}`);
|
|
this.debug('error_cause', {
|
|
message: error.cause?.message,
|
|
code: error.cause?.code,
|
|
errno: error.cause?.errno,
|
|
syscall: error.cause?.syscall,
|
|
hostname: error.cause?.hostname,
|
|
stack: error.cause?.stack,
|
|
});
|
|
}
|
|
this.debug('error_full', {
|
|
message: error.message,
|
|
name: error.name,
|
|
code: error.code,
|
|
stack: error.stack,
|
|
});
|
|
|
|
// For JSON parse errors, return what we can
|
|
if (error instanceof SyntaxError) {
|
|
return {
|
|
recommendations: [],
|
|
overall_assessment: 'The AI service returned an invalid response format. Please try again.',
|
|
risk_notes: [`Response parsing error: ${error.message}`],
|
|
};
|
|
}
|
|
|
|
// For network/timeout errors, return a graceful fallback
|
|
return {
|
|
recommendations: [],
|
|
overall_assessment: 'Unable to generate AI recommendations at this time. Please try again later.',
|
|
risk_notes: [`AI service error: ${error.message}`],
|
|
};
|
|
}
|
|
}
|
|
}
|