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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user