feat: async AI calls, 10-min timeout, and failure messaging

- Make all AI endpoints (health scores + investment recommendations)
  fire-and-forget: POST returns immediately, frontend polls for results
- Extend AI API timeout from 2-5 min to 10 min for both services
- Add "last analysis failed — showing cached data" message to the
  Investment Recommendations panel (matches health score widgets)
- Add status/error_message columns to ai_recommendations table
- Remove nginx AI timeout overrides (no longer needed)
- Users can now navigate away during AI processing without interruption

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 14:42:53 -05:00
parent 07d15001ae
commit 9146118df1
11 changed files with 461 additions and 180 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
Title,
Text,
@@ -33,7 +33,7 @@ import {
IconChevronDown,
IconChevronUp,
} from '@tabler/icons-react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
@@ -107,6 +107,9 @@ interface SavedRecommendation {
risk_notes: string[];
response_time_ms: number;
created_at: string;
status: 'processing' | 'complete' | 'error';
last_failed: boolean;
error_message?: string;
}
// ── Helpers ──
@@ -181,14 +184,29 @@ function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean
// ── Recommendations Display Component ──
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
function RecommendationsDisplay({
aiResult,
lastUpdated,
lastFailed,
}: {
aiResult: AIResponse;
lastUpdated?: string;
lastFailed?: boolean;
}) {
return (
<Stack>
{/* Last Updated timestamp */}
{/* Last Updated timestamp + failure message */}
{lastUpdated && (
<Text size="xs" c="dimmed" ta="right">
Last updated: {new Date(lastUpdated).toLocaleString()}
</Text>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed" ta="right">
Last updated: {new Date(lastUpdated).toLocaleString()}
</Text>
{lastFailed && (
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
last analysis failed showing cached data
</Text>
)}
</Stack>
)}
{/* Overall Assessment */}
@@ -327,9 +345,8 @@ function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIRespons
// ── Main Component ──
export function InvestmentPlanningPage() {
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [ratesExpanded, setRatesExpanded] = useState(true);
const [isTriggering, setIsTriggering] = useState(false);
// Load financial snapshot on mount
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
@@ -349,50 +366,86 @@ export function InvestmentPlanningPage() {
},
});
// Load saved recommendation on mount
// Load saved recommendation — polls every 3s when processing
const { data: savedRec } = useQuery<SavedRecommendation | null>({
queryKey: ['investment-planning-saved-recommendation'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/saved-recommendation');
return data;
},
refetchInterval: (query) => {
const rec = query.state.data;
// Poll every 3 seconds while processing
if (rec?.status === 'processing') return 3000;
// Also poll if we just triggered (status may not be 'processing' yet)
if (isTriggering) return 3000;
return false;
},
});
// Populate AI results from saved recommendation on load
useEffect(() => {
if (savedRec && !aiResult) {
setAiResult({
recommendations: savedRec.recommendations,
overall_assessment: savedRec.overall_assessment,
risk_notes: savedRec.risk_notes,
});
setLastUpdated(savedRec.created_at);
}
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
// Derive display state from saved recommendation
const isProcessing = savedRec?.status === 'processing' || isTriggering;
const lastFailed = savedRec?.last_failed || false;
const hasResults = savedRec && savedRec.status === 'complete' && savedRec.recommendations.length > 0;
const hasError = savedRec?.status === 'error' && !savedRec?.recommendations?.length;
// AI recommendation (on-demand)
const aiMutation = useMutation({
mutationFn: async () => {
const { data } = await api.post('/investment-planning/recommendations', {}, { timeout: 300000 });
return data as AIResponse;
},
onSuccess: (data) => {
setAiResult(data);
setLastUpdated(new Date().toISOString());
if (data.recommendations.length > 0) {
notifications.show({
message: `Generated ${data.recommendations.length} investment recommendations`,
color: 'green',
});
}
},
onError: (err: any) => {
// Clear triggering flag once backend confirms processing or completes
useEffect(() => {
if (isTriggering && savedRec?.status === 'processing') {
setIsTriggering(false);
}
if (isTriggering && savedRec?.status === 'complete') {
setIsTriggering(false);
}
}, [savedRec?.status, isTriggering]);
// Show notification when processing completes (transition from processing)
const prevStatusRef = useState<string | null>(null);
useEffect(() => {
const [prevStatus, setPrevStatus] = prevStatusRef;
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
notifications.show({
message: err.response?.data?.message || 'Failed to get AI recommendations',
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
color: 'green',
});
}
if (prevStatus === 'processing' && savedRec?.status === 'error') {
notifications.show({
message: savedRec.error_message || 'AI recommendation analysis failed',
color: 'red',
});
},
});
}
setPrevStatus(savedRec?.status || null);
}, [savedRec?.status]); // eslint-disable-line react-hooks/exhaustive-deps
// Trigger AI recommendations (async — returns immediately)
const handleTriggerAI = useCallback(async () => {
setIsTriggering(true);
try {
await api.post('/investment-planning/recommendations');
} catch (err: any) {
setIsTriggering(false);
notifications.show({
message: err.response?.data?.message || 'Failed to start AI analysis',
color: 'red',
});
}
}, []);
// Build AI result from saved recommendation for display
const aiResult: AIResponse | null = hasResults
? {
recommendations: savedRec!.recommendations,
overall_assessment: savedRec!.overall_assessment,
risk_notes: savedRec!.risk_notes,
}
: (lastFailed && savedRec?.recommendations?.length)
? {
recommendations: savedRec!.recommendations,
overall_assessment: savedRec!.overall_assessment,
risk_notes: savedRec!.risk_notes,
}
: null;
if (snapshotLoading) {
return (
@@ -645,8 +698,8 @@ export function InvestmentPlanningPage() {
</Group>
<Button
leftSection={<IconSparkles size={16} />}
onClick={() => aiMutation.mutate()}
loading={aiMutation.isPending}
onClick={handleTriggerAI}
loading={isProcessing}
variant="gradient"
gradient={{ from: 'grape', to: 'violet' }}
>
@@ -654,8 +707,8 @@ export function InvestmentPlanningPage() {
</Button>
</Group>
{/* Loading State */}
{aiMutation.isPending && (
{/* Processing State */}
{isProcessing && (
<Center py="xl">
<Stack align="center" gap="sm">
<Loader size="lg" type="dots" />
@@ -663,19 +716,32 @@ export function InvestmentPlanningPage() {
Analyzing your financial data and market rates...
</Text>
<Text c="dimmed" size="xs">
This may take a few minutes for complex tenant data
You can navigate away results will appear when ready
</Text>
</Stack>
</Center>
)}
{/* Results */}
{aiResult && !aiMutation.isPending && (
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
{/* Error State (no cached data) */}
{hasError && !isProcessing && (
<Alert color="red" variant="light" title="Analysis Failed" mb="md">
<Text size="sm">
{savedRec?.error_message || 'The last AI analysis failed. Please try again.'}
</Text>
</Alert>
)}
{/* Results (with optional failure watermark) */}
{aiResult && !isProcessing && (
<RecommendationsDisplay
aiResult={aiResult}
lastUpdated={savedRec?.created_at || undefined}
lastFailed={lastFailed}
/>
)}
{/* Empty State */}
{!aiResult && !aiMutation.isPending && (
{!aiResult && !isProcessing && !hasError && (
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
<IconSparkles size={28} />