Add AI debug logging and switch from fetch to https module
- 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>
This commit is contained in:
@@ -60,12 +60,27 @@ export interface AIResponse {
|
||||
@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 ──
|
||||
|
||||
@@ -152,13 +167,32 @@ export class InvestmentPlanningService {
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -413,39 +447,92 @@ Based on this complete financial picture, provide your investment recommendation
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log(`Calling AI API: ${apiUrl} with model ${model}`);
|
||||
const requestBody = {
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 4096,
|
||||
};
|
||||
|
||||
const response = await fetch(`${apiUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.3,
|
||||
// High token limit to accommodate thinking models (e.g. kimi-k2.5)
|
||||
// which use tokens for internal reasoning before generating output
|
||||
max_tokens: 16384,
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000), // 3 minute timeout for thinking models
|
||||
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();
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
this.logger.error(`AI API error ${response.status}: ${errorBody}`);
|
||||
throw new Error(`AI API returned ${response.status}: ${errorBody}`);
|
||||
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 = await response.json() as any;
|
||||
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.');
|
||||
@@ -457,17 +544,47 @@ Based on this complete financial picture, provide your investment recommendation
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user