From b9a80f9e640dae8fd51b1e4beff43d8355575a9a Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sun, 14 Jun 2026 20:16:01 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Organisateur=20IA=20=E2=80=94=20analyse?= =?UTF-8?q?=20carnet=20+=20tags=20+=20doublons=20+=20regroupements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Service notebook-organizer.service.ts : analyse IA des notes - Endpoint /api/ai/organize-notebook - Dialog avec 4 sections : 1. Résumé de l'état du carnet 2. Tags suggérés (cliquable pour appliquer) 3. Regroupements logiques par catégorie 4. Détection de doublons avec explication - Bouton 'Organiser' (Wand2) dans la barre du carnet - i18n FR/EN complet - Complète les 3 scénarios : Prof (wizard+exercices), Étudiant (wizard+planning), Ingénieur (organisateur) --- .../app/api/ai/organize-notebook/route.ts | 57 +++++ memento-note/components/home-client.tsx | 25 ++- .../wizard/notebook-organizer-dialog.tsx | 196 ++++++++++++++++++ .../ai/services/notebook-organizer.service.ts | 104 ++++++++++ memento-note/locales/en.json | 10 + memento-note/locales/fr.json | 10 + 6 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 memento-note/app/api/ai/organize-notebook/route.ts create mode 100644 memento-note/components/wizard/notebook-organizer-dialog.tsx create mode 100644 memento-note/lib/ai/services/notebook-organizer.service.ts diff --git a/memento-note/app/api/ai/organize-notebook/route.ts b/memento-note/app/api/ai/organize-notebook/route.ts new file mode 100644 index 0000000..3edf308 --- /dev/null +++ b/memento-note/app/api/ai/organize-notebook/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import prisma from '@/lib/prisma' +import { notebookOrganizerService } from '@/lib/ai/services/notebook-organizer.service' +import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' + +export async function POST(request: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { notebookId } = await request.json() + if (!notebookId) { + return NextResponse.json({ error: 'notebookId is required' }, { status: 400 }) + } + + try { + await checkEntitlementOrThrow(session.user.id, 'reformulate') + } catch (err) { + if (err instanceof QuotaExceededError) { + const isTierLocked = err.currentQuota === 0 + return NextResponse.json( + { error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded' }, + { status: 402 }, + ) + } + throw err + } + + const notes = await prisma.note.findMany({ + where: { notebookId, trashedAt: null, userId: session.user.id }, + select: { id: true, title: true, content: true }, + orderBy: { order: 'asc' }, + }) + + if (notes.length < 2) { + return NextResponse.json({ error: 'Need at least 2 notes to organize' }, { status: 400 }) + } + + const notesForAnalysis = notes.map(n => ({ + id: n.id, + title: n.title || 'Sans titre', + contentPreview: n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300), + })) + + const result = await notebookOrganizerService.analyze(notesForAnalysis) + + incrementUsageAsync(session.user.id, 'reformulate') + + return NextResponse.json(result) + } catch (error: any) { + console.error('[Notebook Organizer] Error:', error) + return NextResponse.json({ error: error.message || 'Failed to organize notebook' }, { status: 500 }) + } +} diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx index 9661c55..d1a68cc 100644 --- a/memento-note/components/home-client.tsx +++ b/memento-note/components/home-client.tsx @@ -20,7 +20,7 @@ import { import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast' import { Button } from '@/components/ui/button' -import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays } from 'lucide-react' +import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2 } from 'lucide-react' import { emitNoteChange } from '@/lib/note-change-sync' import { useReminderCheck } from '@/hooks/use-reminder-check' import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion' @@ -31,6 +31,7 @@ import { useEditorUI } from '@/context/editor-ui-context' import { NoteHistoryModal } from '@/components/note-history-modal' import { CreateNotebookDialog } from '@/components/create-notebook-dialog' import { StudyPlannerDialog } from '@/components/wizard/study-planner-dialog' +import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-dialog' import { toast } from 'sonner' import { AnimatePresence, motion } from 'motion/react' @@ -136,6 +137,7 @@ export function HomeClient({ const [addPropertyOpen, setAddPropertyOpen] = useState(false) const [isEnablingStructured, setIsEnablingStructured] = useState(false) const [showStudyPlanner, setShowStudyPlanner] = useState(false) + const [showOrganizer, setShowOrganizer] = useState(false) const notebookFilter = searchParams.get('notebook') const schemaHook = useNotebookSchema(notebookFilter) @@ -1051,6 +1053,19 @@ export function HomeClient({ {t('wizard.studyPlanner') || 'Planning'} )} + {searchParams.get('notebook') && ( + + )} + + +
+ {!result && !loading && ( +
+

+ {t('wizard.organizerDesc') || `L'IA va analyser toutes les notes du carnet "${notebookName}" et proposer : tags, regroupements, et détection de doublons.`} +

+ +
+ )} + + {loading && ( +
+ +

{t('wizard.organizing') || 'Analyse des notes...'}

+
+ )} + + {result && ( +
+ {/* Summary */} +
+

{result.summary}

+
+ + {/* Tags */} + {result.suggestedTags.length > 0 && ( +
+

+ {t('wizard.suggestedTags') || 'Tags suggérés'} +

+ {result.suggestedTags.map((tag, i) => ( +
+
+ + {tag.name} + + {tag.noteIds.length} notes +
+ +
+ ))} +
+ )} + + {/* Categories */} + {result.categories.length > 0 && ( +
+

+ {t('wizard.categories') || 'Regroupements suggérés'} +

+ {result.categories.map((cat, i) => ( +
+
{cat.name}
+
+ {cat.noteTitles.map((title, j) => ( + + {title.length > 35 ? title.slice(0, 35) + '...' : title} + + ))} +
+
+ ))} +
+ )} + + {/* Duplicates */} + {result.duplicates.length > 0 && ( +
+

+ {t('wizard.duplicates') || 'Doublons détectés'} +

+ {result.duplicates.map((dup, i) => ( +
+
+ + {dup.note1Title} + + {dup.note2Title} +
+

{dup.reason}

+
+ ))} +
+ )} + + {result.suggestedTags.length === 0 && result.duplicates.length === 0 && result.categories.length === 0 && ( +

+ {t('wizard.noSuggestions') || 'Aucune suggestion — le carnet semble bien organisé.'} +

+ )} +
+ )} +
+ + + ) +} diff --git a/memento-note/lib/ai/services/notebook-organizer.service.ts b/memento-note/lib/ai/services/notebook-organizer.service.ts new file mode 100644 index 0000000..18bbbde --- /dev/null +++ b/memento-note/lib/ai/services/notebook-organizer.service.ts @@ -0,0 +1,104 @@ +import { getChatProvider } from '../factory' +import { getSystemConfig } from '@/lib/config' + +export interface OrganizationSuggestion { + suggestedTags: Array<{ name: string; noteIds: string[] }> + duplicates: Array<{ note1Id: string; note1Title: string; note2Id: string; note2Title: string; reason: string }> + categories: Array<{ name: string; noteIds: string[]; noteTitles: string[] }> + summary: string +} + +export class NotebookOrganizerService { + async analyze( + notes: Array<{ id: string; title: string; contentPreview: string }>, + language?: string + ): Promise { + const lang = language || 'fr' + const langName = lang === 'fr' ? 'français' : lang === 'fa' ? 'فارسی' : 'English' + + const notesList = notes.map(n => + `ID: ${n.id}\nTitre: ${n.title}\nAperçu: ${n.contentPreview.slice(0, 200)}` + ).join('\n---\n') + + const prompt = `Tu es un expert en organisation de connaissances. Analyse ces notes et propose une organisation. + +LANGUE : ${langName} + +NOTES DANS LE CARNET : +${notesList} + +Analyse les notes et retourne : + +1. **suggestedTags** — Tags pertinents à appliquer (3-8 tags). Pour chaque tag, liste les IDs des notes qui correspondent. +2. **duplicates** — Notes qui se chevauchent ou répètent la même information. Pour chaque paire, explique pourquoi. +3. **categories** — Regroupements logiques (2-5 catégories). Pour chaque catégorie, liste les IDs et titres des notes. +4. **summary** — Un résumé court de l'état du carnet et des recommandations. + +FORMAT JSON UNIQUEMENT : +\`\`\`json +{ + "suggestedTags": [ + { "name": "nom-du-tag", "noteIds": ["id1", "id2"] } + ], + "duplicates": [ + { "note1Id": "id1", "note1Title": "...", "note2Id": "id2", "note2Title": "...", "reason": "..." } + ], + "categories": [ + { "name": "Nom catégorie", "noteIds": ["id1"], "noteTitles": ["Titre"] } + ], + "summary": "Résumé court..." +} +\`\`\` + +Si aucune note en double n'est trouvée, retourne un array vide pour "duplicates".` + + const config = await getSystemConfig() + const provider = getChatProvider(config) + const raw = await provider.generateText(prompt) + + return this.parseResponse(raw) + } + + private parseResponse(raw: string): OrganizationSuggestion { + const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/) + let jsonStr = jsonMatch ? jsonMatch[1] : raw + + const start = jsonStr.indexOf('{') + const end = jsonStr.lastIndexOf('}') + if (start >= 0 && end > start) { + jsonStr = jsonStr.slice(start, end + 1) + } + + try { + const parsed = JSON.parse(jsonStr) + return { + suggestedTags: (parsed.suggestedTags || []).map((t: any) => ({ + name: String(t.name || ''), + noteIds: Array.isArray(t.noteIds) ? t.noteIds.map(String) : [], + })), + duplicates: (parsed.duplicates || []).map((d: any) => ({ + note1Id: String(d.note1Id || ''), + note1Title: String(d.note1Title || ''), + note2Id: String(d.note2Id || ''), + note2Title: String(d.note2Title || ''), + reason: String(d.reason || ''), + })), + categories: (parsed.categories || []).map((c: any) => ({ + name: String(c.name || ''), + noteIds: Array.isArray(c.noteIds) ? c.noteIds.map(String) : [], + noteTitles: Array.isArray(c.noteTitles) ? c.noteTitles.map(String) : [], + })), + summary: String(parsed.summary || ''), + } + } catch { + return { + suggestedTags: [], + duplicates: [], + categories: [], + summary: 'Analyse impossible — réessayer plus tard.', + } + } + } +} + +export const notebookOrganizerService = new NotebookOrganizerService() diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 8cc66c8..5b32cac 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -2575,6 +2575,16 @@ "wizardStudyPlanSuccess": "Plan created! Reminders have been added to your notes.", "wizardDaysPlanned": "days planned", "wizardStudyPlanReminders": "Reminders have been automatically added to your notes.", + "wizardOrganizer": "Organize with AI", + "wizardOrganizerDesc": "AI analyzes your notes and suggests tags, groupings and duplicate detection.", + "wizardAnalyze": "Analyze notebook", + "wizardOrganizing": "Analyzing notes...", + "wizardSuggestedTags": "Suggested tags", + "wizardCategories": "Suggested groupings", + "wizardDuplicates": "Duplicates detected", + "wizardApply": "Apply", + "wizardTagApplied": "applied", + "wizardNoSuggestions": "No suggestions — notebook looks well organized.", "importMarkdown": "Import Markdown", "markdownExportSuccess": "Note exported as Markdown", "markdownExportError": "Failed to export note", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 2afb6ac..43ce987 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -2579,6 +2579,16 @@ "wizardStudyPlanSuccess": "Planning créé ! Des rappels ont été ajoutés à vos notes.", "wizardDaysPlanned": "jours planifiés", "wizardStudyPlanReminders": "Des rappels ont été ajoutés automatiquement à vos notes.", + "wizardOrganizer": "Organiser avec l'IA", + "wizardOrganizerDesc": "L'IA analyse vos notes et propose tags, regroupements et détection de doublons.", + "wizardAnalyze": "Analyser le carnet", + "wizardOrganizing": "Analyse des notes en cours...", + "wizardSuggestedTags": "Tags suggérés", + "wizardCategories": "Regroupements suggérés", + "wizardDuplicates": "Doublons détectés", + "wizardApply": "Appliquer", + "wizardTagApplied": "appliqué", + "wizardNoSuggestions": "Aucune suggestion — le carnet semble bien organisé.", "importMarkdown": "Importer un Markdown", "markdownExportSuccess": "Note exportée en Markdown", "markdownExportError": "Échec de l'export de la note",