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:
@@ -10,3 +10,5 @@ NODE_ENV=development
|
|||||||
AI_API_URL=https://integrate.api.nvidia.com/v1
|
AI_API_URL=https://integrate.api.nvidia.com/v1
|
||||||
AI_API_KEY=your_nvidia_api_key_here
|
AI_API_KEY=your_nvidia_api_key_here
|
||||||
AI_MODEL=qwen/qwen3.5-397b-a17b
|
AI_MODEL=qwen/qwen3.5-397b-a17b
|
||||||
|
# Set to 'true' to enable detailed AI prompt/response logging
|
||||||
|
AI_DEBUG=false
|
||||||
|
|||||||
@@ -60,12 +60,27 @@ export interface AIResponse {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class InvestmentPlanningService {
|
export class InvestmentPlanningService {
|
||||||
private readonly logger = new Logger(InvestmentPlanningService.name);
|
private readonly logger = new Logger(InvestmentPlanningService.name);
|
||||||
|
private debugEnabled: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private tenant: TenantService,
|
private tenant: TenantService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private dataSource: DataSource,
|
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 ──
|
// ── Public API Methods ──
|
||||||
|
|
||||||
@@ -152,13 +167,32 @@ export class InvestmentPlanningService {
|
|||||||
* 5. Parse and return structured recommendations
|
* 5. Parse and return structured recommendations
|
||||||
*/
|
*/
|
||||||
async getAIRecommendations(): Promise<AIResponse> {
|
async getAIRecommendations(): Promise<AIResponse> {
|
||||||
|
this.debug('getAIRecommendations', 'Starting AI recommendation flow');
|
||||||
|
|
||||||
const [snapshot, cdRates] = await Promise.all([
|
const [snapshot, cdRates] = await Promise.all([
|
||||||
this.getFinancialSnapshot(),
|
this.getFinancialSnapshot(),
|
||||||
this.getCdRates(),
|
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 messages = this.buildPromptMessages(snapshot, cdRates);
|
||||||
const aiResponse = await this.callAI(messages);
|
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;
|
return aiResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,39 +447,92 @@ Based on this complete financial picture, provide your investment recommendation
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const requestBody = {
|
||||||
this.logger.log(`Calling AI API: ${apiUrl} with model ${model}`);
|
model,
|
||||||
|
messages,
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 4096,
|
||||||
|
};
|
||||||
|
|
||||||
const response = await fetch(`${apiUrl}/chat/completions`, {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
timeout: 180000, // 3 minute timeout
|
||||||
model,
|
};
|
||||||
messages,
|
|
||||||
temperature: 0.3,
|
const req = https.request(options, (res) => {
|
||||||
// High token limit to accommodate thinking models (e.g. kimi-k2.5)
|
let data = '';
|
||||||
// which use tokens for internal reasoning before generating output
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
max_tokens: 16384,
|
res.on('end', () => {
|
||||||
}),
|
resolve({ status: res.statusCode, body: data });
|
||||||
signal: AbortSignal.timeout(180000), // 3 minute timeout for thinking models
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
req.on('error', (err) => reject(err));
|
||||||
const errorBody = await response.text();
|
req.on('timeout', () => {
|
||||||
this.logger.error(`AI API error ${response.status}: ${errorBody}`);
|
req.destroy();
|
||||||
throw new Error(`AI API returned ${response.status}: ${errorBody}`);
|
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 = await response.json() as any;
|
const data = JSON.parse(aiResult.body);
|
||||||
const msg = data.choices?.[0]?.message;
|
const msg = data.choices?.[0]?.message;
|
||||||
// Thinking models (kimi-k2.5) may return content in 'content' or
|
// Thinking models (kimi-k2.5) may return content in 'content' or
|
||||||
// spend all tokens on 'reasoning_content' with content=null
|
// spend all tokens on 'reasoning_content' with content=null
|
||||||
const content = msg?.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.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) {
|
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.');
|
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*$/, '');
|
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;
|
const parsed = JSON.parse(cleaned) as AIResponse;
|
||||||
|
|
||||||
// Validate the response structure
|
// Validate the response structure
|
||||||
if (!parsed.recommendations || !Array.isArray(parsed.recommendations)) {
|
if (!parsed.recommendations || !Array.isArray(parsed.recommendations)) {
|
||||||
|
this.debug('invalid_response_structure', parsed);
|
||||||
throw new Error('Invalid AI response: missing recommendations array');
|
throw new Error('Invalid AI response: missing recommendations array');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`AI returned ${parsed.recommendations.length} recommendations`);
|
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;
|
return parsed;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Log the full error chain for debugging
|
||||||
this.logger.error(`AI recommendation failed: ${error.message}`);
|
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
|
// For JSON parse errors, return what we can
|
||||||
if (error instanceof SyntaxError) {
|
if (error instanceof SyntaxError) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ services:
|
|||||||
- AI_API_URL=${AI_API_URL}
|
- AI_API_URL=${AI_API_URL}
|
||||||
- AI_API_KEY=${AI_API_KEY}
|
- AI_API_KEY=${AI_API_KEY}
|
||||||
- AI_MODEL=${AI_MODEL}
|
- AI_MODEL=${AI_MODEL}
|
||||||
|
- AI_DEBUG=${AI_DEBUG:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/src:/app/src
|
- ./backend/src:/app/src
|
||||||
- ./backend/nest-cli.json:/app/nest-cli.json
|
- ./backend/nest-cli.json:/app/nest-cli.json
|
||||||
|
|||||||
Reference in New Issue
Block a user