import { NextResponse } from 'next/server' import { auth } from '@/auth' import prisma from '@/lib/prisma' import { getChatProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' import { redis } from '@/lib/redis' import { detectUserLanguage } from '@/lib/i18n/detect-user-language' import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota' import { QuotaExceededError } from '@/lib/entitlements' const CACHE_TTL_SEC = 3600 /** * GET /api/briefing/sentiment * Analyzes the emotional tone of recent notes (last 7 days) using LLM. */ export async function GET() { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = session.user.id const locale = await detectUserLanguage() const cacheKey = `briefing:sentiment:${userId}:${locale}:v2` try { const cached = await redis.get(cacheKey) if (cached) return NextResponse.json(JSON.parse(cached)) } catch {} const weekAgo = new Date() weekAgo.setDate(weekAgo.getDate() - 7) const recentNotes = await prisma.note.findMany({ where: { userId, trashedAt: null, isArchived: false, updatedAt: { gte: weekAgo }, }, select: { id: true, title: true, content: true, notebookId: true }, take: 20, orderBy: { updatedAt: 'desc' }, }) if (recentNotes.length < 3) { return NextResponse.json({ available: false, reason: 'Not enough notes this week', }) } const snippets = recentNotes.map(n => { const text = n.content.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim() return `${n.title || ''}: ${text.slice(0, 200)}` }).join('\n---\n') try { const config = await getSystemConfig() const provider = getChatProvider(config) if (!provider) { return NextResponse.json({ available: false, reason: 'No AI provider' }) } const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale const voice = locale === 'fr' ? 'Write summary in French, second person singular (tu). Example: « Cette semaine, tu as surtout… ». Never write « the person », « the user », or third person.' : `Write summary in ${localeLabel}, second person ("you"). Never write "the person", "the user", or third person.` const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences). ${voice} Keep dominantEmotion keys in English as listed. One short sentence for summary. Do not quote secrets, passwords, or URLs. {"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence","topTopic":"most discussed topic"} Notes: ${snippets.slice(0, 3000)}` const result = await withAiQuota( userId, 'reformulate', () => provider.generateText(prompt), { lane: 'chat' }, ) const jsonMatch = result.match(/\{[\s\S]*\}/) if (!jsonMatch) { return NextResponse.json({ available: false, reason: 'Parse error' }) } const parsed = JSON.parse(jsonMatch[0]) const relatedNotes = recentNotes.slice(0, 2).map(n => { const title = n.title?.trim() const plain = n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() return { id: n.id, title: title || (plain ? (plain.length > 80 ? `${plain.slice(0, 80).trim()}…` : plain) : null), notebookId: n.notebookId, } }) const payload = { available: true, ...parsed, relatedNotes } try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {} return NextResponse.json(payload) } catch (error) { const quotaResp = handleQuotaHttpError(error) if (quotaResp) return quotaResp if (error instanceof QuotaExceededError) { return NextResponse.json(error.toJSON(), { status: 402 }) } console.error('[briefing/sentiment]', error) return NextResponse.json({ available: false, reason: 'Analysis failed' }) } }