Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul. Co-authored-by: Cursor <cursoragent@cursor.com>
113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
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' })
|
|
}
|
|
}
|