import { generateText } from 'ai' import { getChatProvider } from '@/lib/ai/factory' import { willUseByokForLane } from '@/lib/ai/provider-for-user' import { getSystemConfig } from '@/lib/config' import { prisma } from '@/lib/prisma' import { auth } from '@/auth' import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements' import { hasUserAiConsent } from '@/lib/consent/server-consent' export const maxDuration = 30 interface SuggestChartsRequest { content: string selection?: string | null noteId?: string } interface ChartSuggestion { type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'radar' | 'funnel' | 'gauge' title: string data: { label: string; value: number }[] description: string rationale?: string } interface SuggestChartsResponse { suggestions: ChartSuggestion[] analyzedText: string detectedData: string hasData: boolean } export async function POST(req: Request) { console.log('[suggest-charts] ===== REQUEST START =====') // 1. Auth check const session = await auth() if (!session?.user?.id) { console.error('[suggest-charts] NO SESSION') return new Response('Unauthorized', { status: 401 }) } if (!(await hasUserAiConsent())) { return new Response(JSON.stringify({ error: 'ai_consent_required' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }) } const userId = session.user.id console.log('[suggest-charts] userId:', userId) // 1.5 Quota check try { const sysConfigEarly = await getSystemConfig() const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId) console.log('[suggest-charts] BYOK:', willUseByok) if (!willUseByok) { await reserveUsageOrThrow(userId, 'suggest_charts') console.log('[suggest-charts] Quota OK') } } catch (err) { console.error('[suggest-charts] QUOTA ERROR:', err) if (err instanceof QuotaExceededError) { return Response.json(err.toJSON(), { status: 402 }) } if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') { return Response.json({ error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 }) } console.error('[suggest-charts] Quota check error (fail-open):', err) } // 2. Parse request body let body: SuggestChartsRequest try { body = await req.json() } catch (e) { console.error('[suggest-charts] INVALID JSON:', e) return Response.json({ error: 'Invalid request', suggestions: [], analyzedText: '', detectedData: '', hasData: false, } satisfies SuggestChartsResponse, { status: 400 }) } const { content, selection, noteId } = body console.log('[suggest-charts] contentLen:', content?.length, 'selectionLen:', selection?.length) if (!content || content.trim().length === 0) { console.error('[suggest-charts] EMPTY CONTENT') return Response.json({ error: 'Content is required', suggestions: [], analyzedText: '', detectedData: '', hasData: false, } satisfies SuggestChartsResponse, { status: 400 }) } const textToAnalyze = selection && selection.trim() ? selection.trim() : content.trim() console.log('[suggest-charts] analyzeLen:', textToAnalyze.length, 'preview:', textToAnalyze.substring(0, 100)) // 3. Build AI context let provider, model try { const sysConfig = await getSystemConfig() provider = getChatProvider(sysConfig) model = provider.getModel() console.log('[suggest-charts] AI model:', model) } catch (e) { console.error('[suggest-charts] AI ROUTE ERROR:', e) return Response.json({ error: 'AI service unavailable: ' + (e instanceof Error ? e.message : String(e)), suggestions: [], analyzedText: textToAnalyze.substring(0, 100), detectedData: '', hasData: false, } satisfies SuggestChartsResponse, { status: 500 }) } try { // 4. Call AI to analyze and suggest - direct JSON response (no tool) const { text } = await generateText({ model, system: `You are a data visualization assistant. Analyze the provided text and suggest appropriate chart types. IMPORTANT: You MUST respond with valid JSON only. No markdown, no code blocks, no explanations. DATA EXTRACTION RULES: - Find ANY numbers in the text - even simple lists count - Look for: "X: 123", "X = 123", "123", "123%", "$123", etc. - If you find ANY 2+ numbers, create a chart - Be creative with labels if none exist (use "Item 1", "Item 2", etc.) - NEVER return hasData=false unless text is completely empty or has no numbers at all CHART TYPES TO SUGGEST (always 3 different types): 1. bar - for comparing categories 2. line - for trends/sequences 3. pie - for parts of whole Response format (COPY this structure): {"suggestions":[ {"type":"bar","title":"Chart Title","data":[{"label":"A","value":100},{"label":"B","value":200}],"description":"...","rationale":"..."}, {"type":"line","title":"...","data":[...],"description":"...","rationale":"..."}, {"type":"pie","title":"...","data":[...],"description":"...","rationale":"..."} ],"analyzedText":"...","detectedData":"...","hasData":true}`, messages: [ { role: 'user', content: `Extract numbers from this text and suggest 3 charts:\n\n${textToAnalyze}`, }, ], temperature: 0.3, }) // 5. Parse AI response - be very lenient console.log('[suggest-charts] AI response:', text.substring(0, 500)) let parsed: SuggestChartsResponse try { // Clean the response - remove markdown code blocks const cleanText = text .replace(/```json\n?/gi, '') .replace(/```\n?/gi, '') .trim() // Find JSON object const jsonMatch = cleanText.match(/\{[\s\S]*\}/) if (!jsonMatch) { throw new Error('No JSON found') } parsed = JSON.parse(jsonMatch[0]) } catch (e) { console.error('[suggest-charts] Parse error:', e, 'Raw text:', text) // Check if text has ANY numbers - if so, create a simple chart const numbers = textToAnalyze.match(/\d+/g) if (numbers && numbers.length >= 2) { const values = numbers.slice(0, 6).map(n => parseInt(n) || 0) parsed = { suggestions: [ { type: 'bar', title: 'Data from Note', data: values.map((v, i) => ({ label: `Item ${i + 1}`, value: v })), description: 'Bar chart of extracted values', rationale: 'Simple comparison of numerical values found' }, { type: 'line', title: 'Data Trend', data: values.map((v, i) => ({ label: `Item ${i + 1}`, value: v })), description: 'Line chart showing progression', rationale: 'Visualizes the sequence of values' }, { type: 'pie', title: 'Data Distribution', data: values.map((v, i) => ({ label: `Item ${i + 1}`, value: v })), description: 'Pie chart of value proportions', rationale: 'Shows relative sizes of each value' } ], analyzedText: textToAnalyze.substring(0, 100), detectedData: `Found ${numbers.length} numerical values`, hasData: true } } else { return Response.json({ suggestions: [], analyzedText: textToAnalyze.substring(0, 100), detectedData: 'No numerical data found', hasData: false, error: 'Add numbers to your note (e.g., "Jan: 100, Feb: 200")', } satisfies SuggestChartsResponse, { status: 200 }) } } // Validate and fix response if (!parsed.suggestions || !Array.isArray(parsed.suggestions)) { parsed.suggestions = [] } // Ensure we have 3 suggestions when hasData=true if (parsed.hasData && parsed.suggestions.length < 3) { const baseSuggestion = parsed.suggestions[0] if (baseSuggestion) { const types = ['bar', 'line', 'pie'].filter(t => t !== baseSuggestion.type) while (parsed.suggestions.length < 3 && types.length > 0) { parsed.suggestions.push({ ...baseSuggestion, type: types.shift()! }) } } } // Ensure each suggestion has valid data parsed.suggestions = parsed.suggestions.filter(s => { if (!s.type || !s.data || !Array.isArray(s.data) || s.data.length < 2) return false // Ensure all data points have label and value s.data = s.data.filter(d => d && typeof d.value === 'number') return s.data.length >= 2 }) // If after filtering we have no valid suggestions, set hasData=false if (parsed.suggestions.length === 0) { parsed.hasData = false } return Response.json(parsed satisfies SuggestChartsResponse) } catch (error) { console.error('[suggest-charts] ===== MAIN ERROR =====') console.error('[suggest-charts] Error name:', error instanceof Error ? error.name : typeof error) console.error('[suggest-charts] Error message:', error instanceof Error ? error.message : String(error)) console.error('[suggest-charts] Error stack:', error instanceof Error ? error.stack : 'no stack') return Response.json({ error: 'Failed: ' + (error instanceof Error ? error.message : String(error)), suggestions: [], analyzedText: textToAnalyze?.substring(0, 100) || '', detectedData: 'Error occurred', hasData: false, } satisfies SuggestChartsResponse, { status: 500 }) } }