'use server' import { auth } from '@/auth' import prisma from '@/lib/prisma' import { getChatProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' import { revalidatePath } from 'next/cache' export interface OrganizationNote { id: string title: string } export interface OrganizationGroup { name: string isNew: boolean existingId?: string notes: OrganizationNote[] } export interface OrganizationPlan { notebookId: string groups: OrganizationGroup[] } export interface AnalyzeResult { success: boolean plan?: OrganizationPlan error?: string } export interface ExecuteResult { success: boolean created: number moved: number error?: string } /** * Analyze a notebook's notes with AI and suggest a sub-notebook organization plan. */ export async function analyzeNotebookForOrganization(notebookId: string): Promise { const session = await auth() if (!session?.user?.id) return { success: false, error: 'Non autorisé' } try { // 1. Fetch the target notebook (ensure ownership) const notebook = await prisma.notebook.findFirst({ where: { id: notebookId, userId: session.user.id, trashedAt: null }, select: { id: true, name: true }, }) if (!notebook) return { success: false, error: 'Carnet introuvable' } // 2. Fetch all notes in this notebook const notes = await prisma.note.findMany({ where: { notebookId, userId: session.user.id, trashedAt: null, isArchived: false, }, select: { id: true, title: true, content: true, labels: true }, orderBy: { createdAt: 'asc' }, }) if (notes.length < 2) { return { success: false, error: 'Ce carnet contient moins de 2 notes — il n\'y a rien à organiser.', } } // 3. Fetch existing sub-notebooks const existingSubs = await prisma.notebook.findMany({ where: { parentId: notebookId, userId: session.user.id, trashedAt: null }, select: { id: true, name: true }, }) // 4. Build prompt context — truncate content to save tokens const notesContext = notes.map((n, i) => { const title = n.title || `Note sans titre ${i + 1}` const content = (n.content || '').slice(0, 300).replace(/\n+/g, ' ') let labelsStr = '' if (n.labels) { try { const parsed = JSON.parse(n.labels as string) if (Array.isArray(parsed)) labelsStr = parsed.join(', ') } catch {} } return `[${n.id}] "${title}"${labelsStr ? ` (tags: ${labelsStr})` : ''} — ${content}` }).join('\n') const existingSubsContext = existingSubs.length > 0 ? `\nSous-carnets existants:\n${existingSubs.map(s => `- "${s.name}" (id: ${s.id})`).join('\n')}` : '\nIl n\'y a pas encore de sous-carnets.' const prompt = `Tu es un assistant d'organisation de notes. Analyse les notes suivantes du carnet "${notebook.name}" et regroupe-les par thème en proposant des sous-carnets. ${existingSubsContext} Notes à organiser: ${notesContext} RÈGLES IMPORTANTES: - Regroupe les notes par thème ou sujet similaire. - Propose entre 2 et 6 groupes maximum. - Si un sous-carnet existant correspond déjà à un thème, utilise-le (indique son id). - Les noms de groupe doivent être courts (2-4 mots), clairs et en français. - N'inclus PAS toutes les notes si certaines sont trop générales ou ne correspondent à aucun groupe clair — laisse-les de côté. - Réponds UNIQUEMENT avec du JSON valide, sans markdown, sans explication. Format de réponse JSON attendu: { "groups": [ { "name": "Nom du sous-carnet", "existingId": "id-si-sous-carnet-existant-ou-null", "noteIds": ["id1", "id2", "id3"] } ] }` // 5. Call AI using the chat provider (not embeddings provider) const config = await getSystemConfig() let provider try { provider = getChatProvider(config) } catch (providerErr) { console.error('[organize-notebook] Failed to get AI provider:', providerErr) return { success: false, error: `Fournisseur IA non configuré : ${(providerErr as Error).message}` } } let rawResponse: string try { rawResponse = await provider.generateText(prompt) } catch (aiErr) { console.error('[organize-notebook] AI generateText failed:', aiErr) return { success: false, error: `L'IA n'a pas pu répondre : ${(aiErr as Error).message}` } } // 6. Parse AI response let parsed: { groups: Array<{ name: string; existingId?: string | null; noteIds: string[] }> } try { // Clean possible markdown code blocks const cleaned = rawResponse.trim().replace(/^```json\n?/, '').replace(/\n?```$/, '') parsed = JSON.parse(cleaned) } catch { return { success: false, error: 'L\'IA n\'a pas pu générer un plan valide. Réessayez.' } } if (!parsed?.groups || !Array.isArray(parsed.groups)) { return { success: false, error: 'Réponse IA invalide.' } } // 7. Build the OrganizationPlan const noteMap = new Map(notes.map(n => [n.id, n])) const existingSubMap = new Map(existingSubs.map(s => [s.id, s])) const groups: OrganizationGroup[] = parsed.groups .filter(g => g.name && Array.isArray(g.noteIds) && g.noteIds.length > 0) .map(g => { const existingId = g.existingId && existingSubMap.has(g.existingId) ? g.existingId : undefined const groupNotes = g.noteIds .filter(id => noteMap.has(id)) .map(id => { const n = noteMap.get(id)! return { id: n.id, title: n.title || 'Note sans titre' } }) return { name: g.name, isNew: !existingId, existingId, notes: groupNotes, } }) .filter(g => g.notes.length > 0) if (groups.length === 0) { return { success: false, error: 'L\'IA n\'a pas trouvé de groupes thématiques distincts.' } } return { success: true, plan: { notebookId, groups } } } catch (err) { console.error('[organize-notebook] Analysis error:', err) return { success: false, error: 'Une erreur s\'est produite lors de l\'analyse.' } } } /** * Execute an approved organization plan: * - Creates missing sub-notebooks (always as children of plan.notebookId) * - Moves notes to the correct sub-notebook */ export async function executeNotebookOrganization(plan: OrganizationPlan): Promise { const session = await auth() if (!session?.user?.id) return { success: false, created: 0, moved: 0, error: 'Non autorisé' } try { // Verify notebook ownership const notebook = await prisma.notebook.findFirst({ where: { id: plan.notebookId, userId: session.user.id, trashedAt: null }, select: { id: true }, }) if (!notebook) return { success: false, created: 0, moved: 0, error: 'Carnet introuvable' } console.log('[organize-notebook] Executing plan:', JSON.stringify({ notebookId: plan.notebookId, groups: plan.groups.map(g => ({ name: g.name, isNew: g.isNew, existingId: g.existingId, noteCount: g.notes.length })) })) let created = 0 let moved = 0 for (const group of plan.groups) { let targetNotebookId: string // Validate existingId: must be a real sub-notebook of plan.notebookId (not the parent itself) const validExistingId = group.existingId && group.existingId !== plan.notebookId ? group.existingId : undefined if (!group.isNew && validExistingId) { // Verify it's actually a child of plan.notebookId const verifiedSub = await prisma.notebook.findFirst({ where: { id: validExistingId, parentId: plan.notebookId, userId: session.user.id, trashedAt: null }, select: { id: true }, }) if (verifiedSub) { targetNotebookId = verifiedSub.id } else { // Fall through to create a new sub-notebook group.isNew = true } } if (group.isNew || !targetNotebookId!) { // Create new sub-notebook — always as a child of plan.notebookId const highestOrder = await prisma.notebook.findFirst({ where: { parentId: plan.notebookId, userId: session.user.id }, orderBy: { order: 'desc' }, select: { order: true }, }) const nextOrder = (highestOrder?.order ?? -1) + 1 const newSub = await prisma.notebook.create({ data: { name: group.name.trim(), icon: '📁', color: '#A47148', order: nextOrder, parentId: plan.notebookId, // always a sub-notebook userId: session.user.id, }, }) console.log(`[organize-notebook] Created sub-notebook "${newSub.name}" (id: ${newSub.id}) under ${plan.notebookId}`) targetNotebookId = newSub.id created++ } // Move notes — update notebookId to the target sub-notebook const noteIds = group.notes.map(n => n.id) if (noteIds.length > 0) { const result = await prisma.note.updateMany({ where: { id: { in: noteIds }, userId: session.user.id, }, data: { notebookId: targetNotebookId }, }) console.log(`[organize-notebook] Moved ${result.count} notes to "${group.name}"`) moved += result.count } } revalidatePath('/home') return { success: true, created, moved } } catch (err) { console.error('[organize-notebook] Execute error:', err) return { success: false, created: 0, moved: 0, error: 'Erreur lors de l\'exécution du plan.' } } }