feat: générateur d'exercices + planning de révision IA
- Générateur d'exercices : bouton dans menu note → IA crée 5 exercices - Niveaux variés (facile/moyen/difficile) avec emojis 🟢🟡🔴 - Corrigés détaillés dans des toggles (cliquer pour révéler) - Callout warning pour le niveau - Notes créées dans le même carnet - Planning de révision : bouton dans barre carnet → IA crée planning - Choix date d'examen - Répétition espacée (première lecture → revoir → révision globale) - Rappels automatiques ajoutés aux notes (9h le jour J) - Vue chronologique avec activités et notes par jour - Services : exercise-generator.service.ts + study-planner.service.ts - Endpoints : /api/ai/generate-exercises + /api/ai/study-plan - i18n FR/EN complet
This commit is contained in:
98
memento-note/app/api/ai/generate-exercises/route.ts
Normal file
98
memento-note/app/api/ai/generate-exercises/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { exerciseGeneratorService } from '@/lib/ai/services/exercise-generator.service'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { noteId, count, language } = await request.json()
|
||||
if (!noteId) {
|
||||
return NextResponse.json({ error: 'noteId 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 note = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
select: { id: true, title: true, content: true, notebookId: true },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const exercises = await exerciseGeneratorService.generate(
|
||||
note.title || 'Sans titre',
|
||||
note.content,
|
||||
{ count: count || 5, language: language || 'fr' }
|
||||
)
|
||||
|
||||
// Create exercise notes in the same notebook
|
||||
const lang = language || 'fr'
|
||||
const exerciseLabel = lang === 'fr' ? 'Exercice' : lang === 'fa' ? 'تمرین' : 'Exercise'
|
||||
const answerLabel = lang === 'fr' ? 'Corrigé' : lang === 'fa' ? 'پاسخ' : 'Answer'
|
||||
|
||||
const createdNotes = []
|
||||
for (let i = 0; i < exercises.length; i++) {
|
||||
const ex = exercises[i]
|
||||
const difficultyEmoji = ex.difficulty === 'facile' ? '🟢' : ex.difficulty === 'moyen' ? '🟡' : '🔴'
|
||||
|
||||
const content = `
|
||||
<div data-type="callout-block" data-callout-type="warning"><p><strong>${exerciseLabel} ${i + 1}</strong> — ${difficultyEmoji} ${ex.difficulty}</p></div>
|
||||
<h2>Énoncé</h2>
|
||||
<p>${ex.question}</p>
|
||||
<div data-type="toggle-block" data-opened="false"><p><strong>${answerLabel}</strong> — cliquer pour révéler</p><h3>Solution</h3><p>${ex.answer}</p></div>
|
||||
`.trim()
|
||||
|
||||
const created = await prisma.note.create({
|
||||
data: {
|
||||
title: `${exerciseLabel} ${i + 1} — ${note.title || ''}`,
|
||||
content,
|
||||
userId: session.user.id,
|
||||
notebookId: note.notebookId,
|
||||
type: 'richtext',
|
||||
order: 999 + i,
|
||||
},
|
||||
})
|
||||
createdNotes.push(created)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { embeddingService } = await import('@/lib/ai/services/embedding.service')
|
||||
const { upsertNoteEmbedding } = await import('@/lib/embeddings')
|
||||
const { chunkIndexingService } = await import('@/lib/ai/services/chunk-indexing.service')
|
||||
const { embedding } = await embeddingService.generateNoteEmbedding(created.title, content)
|
||||
await upsertNoteEmbedding(created.id, embedding)
|
||||
await chunkIndexingService.indexNote(created.id, created.title, content)
|
||||
} catch {}
|
||||
})()
|
||||
}
|
||||
|
||||
incrementUsageAsync(session.user.id, 'reformulate')
|
||||
|
||||
return NextResponse.json({
|
||||
exercises: createdNotes.map(n => ({ id: n.id, title: n.title })),
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('[Exercise Generator] Error:', error)
|
||||
return NextResponse.json({ error: error.message || 'Failed to generate exercises' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
70
memento-note/app/api/ai/study-plan/route.ts
Normal file
70
memento-note/app/api/ai/study-plan/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { studyPlannerService } from '@/lib/ai/services/study-planner.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, examDate } = await request.json()
|
||||
if (!notebookId || !examDate) {
|
||||
return NextResponse.json({ error: 'notebookId and examDate are 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 },
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
|
||||
if (notes.length === 0) {
|
||||
return NextResponse.json({ error: 'No notes found in notebook' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userLang = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { theme: true },
|
||||
})
|
||||
|
||||
const plan = await studyPlannerService.generate(notes, examDate)
|
||||
|
||||
// Set reminders on notes based on the plan
|
||||
for (const day of plan.days) {
|
||||
for (const noteId of day.noteIds) {
|
||||
try {
|
||||
const reminderDate = new Date(day.date)
|
||||
reminderDate.setHours(9, 0, 0, 0)
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { reminder: reminderDate },
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
incrementUsageAsync(session.user.id, 'reformulate')
|
||||
|
||||
return NextResponse.json(plan)
|
||||
} catch (error: any) {
|
||||
console.error('[Study Planner] Error:', error)
|
||||
return NextResponse.json({ error: error.message || 'Failed to generate study plan' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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 } from 'lucide-react'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays } 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'
|
||||
@@ -30,6 +30,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
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 { toast } from 'sonner'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
|
||||
@@ -134,6 +135,7 @@ export function HomeClient({
|
||||
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
|
||||
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
|
||||
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
||||
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
||||
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const schemaHook = useNotebookSchema(notebookFilter)
|
||||
@@ -1036,6 +1038,19 @@ export function HomeClient({
|
||||
<span>{t('notebook.summary')}</span>
|
||||
</button>
|
||||
)}
|
||||
{searchParams.get('notebook') && (
|
||||
<button
|
||||
onClick={() => setShowStudyPlanner(true)}
|
||||
disabled={!initialSettings.aiAssistantEnabled}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-[13px] font-medium transition-opacity",
|
||||
initialSettings.aiAssistantEnabled ? "text-brand-accent hover:opacity-70" : "text-muted-foreground opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<CalendarDays size={16} />
|
||||
<span>{t('wizard.studyPlanner') || 'Planning'}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : s === 'alpha' ? 'manual' : 'newest')}
|
||||
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||||
@@ -1269,6 +1284,14 @@ export function HomeClient({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStudyPlanner && currentNotebook && (
|
||||
<StudyPlannerDialog
|
||||
notebookId={currentNotebook.id}
|
||||
notebookName={currentNotebook.name}
|
||||
onClose={() => setShowStudyPlanner(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
|
||||
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon
|
||||
} from 'lucide-react'
|
||||
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
@@ -235,6 +235,29 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
input.click()
|
||||
}
|
||||
|
||||
const [generatingExercises, setGeneratingExercises] = useState(false)
|
||||
const handleGenerateExercises = async () => {
|
||||
if (generatingExercises) return
|
||||
setGeneratingExercises(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/generate-exercises', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ noteId: note.id, count: 5, language }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
||||
} else {
|
||||
toast.success(t('richTextEditor.exercisesGenerated') || `${data.exercises?.length || 0} exercices créés !`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Erreur')
|
||||
} finally {
|
||||
setGeneratingExercises(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConvertToRichtext = async () => {
|
||||
if (isConverting || !state.content.trim()) return
|
||||
setIsConverting(true)
|
||||
@@ -456,6 +479,17 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
<Printer className="h-4 w-4 me-2" />
|
||||
{t('richTextEditor.exportPdf') || 'Exporter en PDF'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleGenerateExercises} disabled={generatingExercises}>
|
||||
{generatingExercises
|
||||
? <Loader2Icon className="h-4 w-4 me-2 animate-spin" />
|
||||
: <PenTool className="h-4 w-4 me-2 text-brand-accent" />
|
||||
}
|
||||
{generatingExercises
|
||||
? (t('richTextEditor.exercisesLoading') || 'Génération...')
|
||||
: (t('richTextEditor.generateExercises') || 'Générer des exercices')
|
||||
}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={openMarkdownImport}>
|
||||
<FileUp className="h-4 w-4 me-2" />
|
||||
{t('richTextEditor.importMarkdown')}
|
||||
|
||||
146
memento-note/components/wizard/study-planner-dialog.tsx
Normal file
146
memento-note/components/wizard/study-planner-dialog.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Calendar, Loader2, X, Sparkles, CheckCircle2, BookOpen } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface StudyDay {
|
||||
date: string
|
||||
noteIds: string[]
|
||||
noteTitles: string[]
|
||||
activity: string
|
||||
}
|
||||
|
||||
export function StudyPlannerDialog({
|
||||
notebookId,
|
||||
notebookName,
|
||||
onClose,
|
||||
}: {
|
||||
notebookId: string
|
||||
notebookName: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const [examDate, setExamDate] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [plan, setPlan] = useState<{ days: StudyDay[]; totalDays: number } | null>(null)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!examDate) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/study-plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notebookId, examDate }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
||||
} else {
|
||||
setPlan(data)
|
||||
toast.success(t('wizard.studyPlanSuccess') || 'Planning créé ! Des rappels ont été ajoutés à vos notes.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Erreur')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" dir="auto" onClick={onClose}>
|
||||
<div className="w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-brand-accent/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-brand-accent" />
|
||||
<h2 className="text-base font-semibold">{t('wizard.studyPlanner') || 'Planning de révision'}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted text-muted-foreground">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 min-h-[300px]">
|
||||
{!plan && !loading && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('wizard.studyPlannerDesc') || `L'IA va créer un planning de révision pour le carnet "${notebookName}" basé sur la répétition espacée.`}
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground mb-2 block">
|
||||
{t('wizard.examDate') || 'Date de l\'examen'}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={examDate}
|
||||
min={today}
|
||||
onChange={(e) => setExamDate(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-brand-accent/30"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!examDate}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 disabled:opacity-40 transition-colors font-medium"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('wizard.generatePlan') || 'Générer le planning'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand-accent" />
|
||||
<p className="text-sm text-muted-foreground">{t('wizard.studyPlanLoading') || 'Création du planning...'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-green-600 dark:text-green-400 mb-2">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
{plan.totalDays} {t('wizard.daysPlanned') || 'jours planifiés'}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto space-y-2 pr-1">
|
||||
{plan.days.map((day, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 rounded-lg border border-border/50 hover:bg-muted/20 transition-colors">
|
||||
<div className="flex-shrink-0 w-12 text-center">
|
||||
<div className="text-[10px] uppercase font-bold text-muted-foreground">
|
||||
{new Date(day.date).toLocaleDateString(undefined, { weekday: 'short' })}
|
||||
</div>
|
||||
<div className="text-lg font-bold">
|
||||
{new Date(day.date).getDate()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-foreground mb-1">{day.activity}</p>
|
||||
{day.noteTitles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{day.noteTitles.map((title, j) => (
|
||||
<span key={j} className="inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full bg-brand-accent/10 text-brand-accent">
|
||||
<BookOpen size={9} />
|
||||
{title.length > 30 ? title.slice(0, 30) + '...' : title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground italic text-center">
|
||||
{t('wizard.studyPlanReminders') || 'Des rappels ont été ajoutés automatiquement à vos notes.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
memento-note/lib/ai/services/exercise-generator.service.ts
Normal file
97
memento-note/lib/ai/services/exercise-generator.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { getChatProvider } from '../factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export interface GeneratedExercise {
|
||||
question: string
|
||||
difficulty: 'facile' | 'moyen' | 'difficile'
|
||||
answer: string
|
||||
}
|
||||
|
||||
export class ExerciseGeneratorService {
|
||||
async generate(
|
||||
noteTitle: string,
|
||||
noteContent: string,
|
||||
options: { count?: number; language?: string }
|
||||
): Promise<GeneratedExercise[]> {
|
||||
const count = options.count || 5
|
||||
const lang = options.language || 'fr'
|
||||
const langName = lang === 'fr' ? 'français' : lang === 'fa' ? 'فارسی' : 'English'
|
||||
|
||||
// Strip HTML to plain text for the AI
|
||||
const plainText = noteContent
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 4000)
|
||||
|
||||
const prompt = `Tu es un professeur expert. Génère ${count} exercices basés sur ce cours.
|
||||
|
||||
COURS : "${noteTitle}"
|
||||
CONTENU :
|
||||
${plainText}
|
||||
|
||||
Langue : ${langName}
|
||||
|
||||
Génère ${count} exercices variés avec des niveaux de difficulté différents.
|
||||
Pour chaque exercice : une question claire et détaillée, et un corrigé complet et expliqué.
|
||||
|
||||
FORMAT JSON UNIQUEMENT :
|
||||
\`\`\`json
|
||||
[
|
||||
${Array.from({ length: count }, (_, i) => `{
|
||||
"question": "Question détaillée numéro ${i + 1}...",
|
||||
"difficulty": "${['facile', 'moyen', 'difficile'][i % 3]}",
|
||||
"answer": "Corrigé complet et expliqué..."
|
||||
}`).join(',\n ')}
|
||||
]
|
||||
\`\`\`
|
||||
|
||||
RÈGLES :
|
||||
- Les questions doivent couvrir différents aspects du cours
|
||||
- Mélange les types : application directe, analyse, synthèse
|
||||
- Les corrigés doivent être détaillés avec les étapes
|
||||
- Utilise des formules LaTeX avec la notation standard (\\frac, \\sum, etc.)`
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const raw = await provider.generateText(prompt)
|
||||
|
||||
return this.parseResponse(raw)
|
||||
}
|
||||
|
||||
private parseResponse(raw: string): GeneratedExercise[] {
|
||||
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 parsed.map((e: any) => ({
|
||||
question: String(e.question || ''),
|
||||
difficulty: e.difficulty || 'moyen',
|
||||
answer: String(e.answer || ''),
|
||||
}))
|
||||
} catch {
|
||||
// Fix backslash escaping
|
||||
try {
|
||||
const fixed = jsonStr.replace(/\\(?!["\\\/bfnrtu])/g, '\\\\')
|
||||
const parsed = JSON.parse(fixed)
|
||||
return parsed.map((e: any) => ({
|
||||
question: String(e.question || ''),
|
||||
difficulty: e.difficulty || 'moyen',
|
||||
answer: String(e.answer || ''),
|
||||
}))
|
||||
} catch {
|
||||
throw new Error('Failed to parse exercises')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const exerciseGeneratorService = new ExerciseGeneratorService()
|
||||
125
memento-note/lib/ai/services/study-planner.service.ts
Normal file
125
memento-note/lib/ai/services/study-planner.service.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { getChatProvider } from '../factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export interface StudyDay {
|
||||
date: string
|
||||
noteIds: string[]
|
||||
noteTitles: string[]
|
||||
activity: string
|
||||
}
|
||||
|
||||
export interface StudyPlan {
|
||||
days: StudyDay[]
|
||||
totalDays: number
|
||||
notesPerDay: number
|
||||
}
|
||||
|
||||
export class StudyPlannerService {
|
||||
async generate(
|
||||
notes: Array<{ id: string; title: string }>,
|
||||
examDate: string,
|
||||
language?: string
|
||||
): Promise<StudyPlan> {
|
||||
const lang = language || 'fr'
|
||||
const langName = lang === 'fr' ? 'français' : lang === 'fa' ? 'فارسی' : 'English'
|
||||
|
||||
const exam = new Date(examDate)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const daysUntilExam = Math.max(1, Math.ceil((exam.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)))
|
||||
|
||||
const notesList = notes.map((n, i) => `${i + 1}. ${n.title} (id: ${n.id})`).join('\n')
|
||||
|
||||
const prompt = `Tu es un expert en pédagogie et apprentissage. Crée un planning de révision optimisé.
|
||||
|
||||
DATE DE L'EXAMEN : ${examDate}
|
||||
JOURS RESTANTS : ${daysUntilExam}
|
||||
LANGUE : ${langName}
|
||||
|
||||
NOTES À RÉVISER :
|
||||
${notesList}
|
||||
|
||||
Crée un planning qui répartit ces notes sur ${daysUntilExam} jours en utilisant la répétition espacée :
|
||||
- Les premiers jours : couvrir tout le programme une fois
|
||||
- Les jours suivants : revoir les notes difficiles plus fréquemment
|
||||
- Les derniers jours : révision globale et fiches synthèses
|
||||
- Inclus des jours de repos légers (relecture rapide)
|
||||
|
||||
FORMAT JSON UNIQUEMENT :
|
||||
\`\`\`json
|
||||
{
|
||||
"days": [
|
||||
{
|
||||
"date": "YYYY-MM-DD",
|
||||
"noteIds": ["id1", "id2"],
|
||||
"activity": "Description courte de l'activité du jour"
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Génère exactement ${Math.min(daysUntilExam, 30)} entrées de jours (max 30).
|
||||
Les dates vont de aujourd'hui (${today.toISOString().slice(0, 10)}) jusqu'à la veille de l'examen.
|
||||
Chaque jour doit avoir 1-4 notes à réviser avec une activité descriptive.`
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const raw = await provider.generateText(prompt)
|
||||
|
||||
return this.parseResponse(raw, notes, daysUntilExam)
|
||||
}
|
||||
|
||||
private parseResponse(
|
||||
raw: string,
|
||||
notes: Array<{ id: string; title: string }>,
|
||||
totalDays: number
|
||||
): StudyPlan {
|
||||
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)
|
||||
const titleMap = new Map(notes.map(n => [n.id, n.title]))
|
||||
|
||||
const days: StudyDay[] = (parsed.days || []).map((d: any) => ({
|
||||
date: String(d.date || ''),
|
||||
noteIds: Array.isArray(d.noteIds) ? d.noteIds.map(String) : [],
|
||||
noteTitles: (Array.isArray(d.noteIds) ? d.noteIds : []).map((id: string) => titleMap.get(id) || 'Note'),
|
||||
activity: String(d.activity || 'Révision'),
|
||||
}))
|
||||
|
||||
return {
|
||||
days,
|
||||
totalDays: days.length,
|
||||
notesPerDay: days.length > 0 ? Math.round(notes.length / days.length) : 0,
|
||||
}
|
||||
} catch {
|
||||
// Fallback: simple distribution
|
||||
const days: StudyDay[] = []
|
||||
const today = new Date()
|
||||
const notesPerDay = Math.max(1, Math.ceil(notes.length / Math.min(totalDays, 14)))
|
||||
for (let i = 0; i < Math.min(totalDays, 14); i++) {
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() + i)
|
||||
const dayNotes = notes.slice(i * notesPerDay, (i + 1) * notesPerDay)
|
||||
if (dayNotes.length === 0 && i > 0) break
|
||||
days.push({
|
||||
date: date.toISOString().slice(0, 10),
|
||||
noteIds: dayNotes.map(n => n.id),
|
||||
noteTitles: dayNotes.map(n => n.title),
|
||||
activity: i === 0 ? 'Première lecture' : `Revoir ${dayNotes.length} notes`,
|
||||
})
|
||||
}
|
||||
|
||||
return { days, totalDays: days.length, notesPerDay }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const studyPlannerService = new StudyPlannerService()
|
||||
@@ -2558,6 +2558,17 @@
|
||||
"pdfExportBlocked": "Popup blocked — allow popups to export as PDF",
|
||||
"pdfExportLoading": "Generating PDF...",
|
||||
"pdfExportSuccess": "PDF ready!",
|
||||
"generateExercises": "Generate exercises",
|
||||
"exercisesLoading": "Generating exercises...",
|
||||
"exercisesGenerated": "exercises created!",
|
||||
"wizardStudyPlanner": "Study Plan",
|
||||
"wizardStudyPlannerDesc": "AI creates a revision plan based on spaced repetition.",
|
||||
"wizardExamDate": "Exam date",
|
||||
"wizardGeneratePlan": "Generate plan",
|
||||
"wizardStudyPlanLoading": "Creating plan...",
|
||||
"wizardStudyPlanSuccess": "Plan created! Reminders have been added to your notes.",
|
||||
"wizardDaysPlanned": "days planned",
|
||||
"wizardStudyPlanReminders": "Reminders have been automatically added to your notes.",
|
||||
"importMarkdown": "Import Markdown",
|
||||
"markdownExportSuccess": "Note exported as Markdown",
|
||||
"markdownExportError": "Failed to export note",
|
||||
|
||||
@@ -2562,6 +2562,17 @@
|
||||
"pdfExportBlocked": "Popup bloqué — autorisez les popups pour exporter en PDF",
|
||||
"pdfExportLoading": "Génération du PDF...",
|
||||
"pdfExportSuccess": "PDF prêt !",
|
||||
"generateExercises": "Générer des exercices",
|
||||
"exercisesLoading": "Génération des exercices...",
|
||||
"exercisesGenerated": "exercices créés !",
|
||||
"wizardStudyPlanner": "Planning de révision",
|
||||
"wizardStudyPlannerDesc": "L'IA crée un planning de révision basé sur la répétition espacée.",
|
||||
"wizardExamDate": "Date de l'examen",
|
||||
"wizardGeneratePlan": "Générer le planning",
|
||||
"wizardStudyPlanLoading": "Création du planning...",
|
||||
"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.",
|
||||
"importMarkdown": "Importer un Markdown",
|
||||
"markdownExportSuccess": "Note exportée en Markdown",
|
||||
"markdownExportError": "Échec de l'export de la note",
|
||||
|
||||
@@ -1,99 +1,69 @@
|
||||
/**
|
||||
* Test du ChunkIndexingService — dedup, stale deletion, upsert.
|
||||
* Mocke l'embedding (pas d'appel API), utilise la vraie DB.
|
||||
*/
|
||||
import { test, expect, describe, beforeAll, afterAll, beforeEach } from 'vitest'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { ChunkIndexingService } from '../../lib/ai/services/chunk-indexing.service'
|
||||
import { embeddingService } from '../../lib/ai/services/embedding.service'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const testNoteId = 'test-chunk-000001'
|
||||
|
||||
function test(name: string, fn: () => Promise<void>) {
|
||||
return fn()
|
||||
.then(() => console.log(` ✓ ${name}`))
|
||||
.catch((err: any) => {
|
||||
console.error(` ✗ ${name}: ${err.message}`)
|
||||
process.exitCode = 1
|
||||
describe('US-CHUNK-2 : Indexation incrémentale avec dedup', () => {
|
||||
let originalEmbedText: any
|
||||
|
||||
beforeAll(async () => {
|
||||
originalEmbedText = embeddingService.embedText
|
||||
embeddingService.embedText = async (text: string) => {
|
||||
const hash = crypto.createHash('md5').update(text).digest()
|
||||
return Array.from({ length: 1536 }, (_, i) => hash[i % 16] / 255)
|
||||
}
|
||||
|
||||
await prisma.note.upsert({
|
||||
where: { id: testNoteId },
|
||||
create: {
|
||||
id: testNoteId,
|
||||
title: 'Test Note for Chunk Indexing',
|
||||
content: '<p>Test</p>',
|
||||
},
|
||||
update: {},
|
||||
})
|
||||
}
|
||||
|
||||
// Mock l'embedding service : vecteur déterministe basé sur le hash du contenu
|
||||
const originalEmbedText =
|
||||
require('../../lib/ai/services/embedding.service').embeddingService.embedText
|
||||
|
||||
function mockEmbedding() {
|
||||
const svc = require('../../lib/ai/services/embedding.service').embeddingService
|
||||
svc.embedText = async (text: string) => {
|
||||
const crypto = require('crypto')
|
||||
const hash = crypto.createHash('md5').update(text).digest()
|
||||
return Array.from({ length: 1536 }, (_, i) => hash[i % 16] / 255)
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEmbedding() {
|
||||
const svc = require('../../lib/ai/services/embedding.service').embeddingService
|
||||
svc.embedText = originalEmbedText
|
||||
}
|
||||
|
||||
async function ensureTestNote() {
|
||||
await prisma.note.upsert({
|
||||
where: { id: testNoteId },
|
||||
create: {
|
||||
id: testNoteId,
|
||||
title: 'Test Note for Chunk Indexing',
|
||||
content: '<p>Test</p>',
|
||||
},
|
||||
update: {},
|
||||
})
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.noteEmbeddingChunk.deleteMany({ where: { noteId: testNoteId } })
|
||||
}
|
||||
afterAll(async () => {
|
||||
embeddingService.embedText = originalEmbedText
|
||||
await prisma.noteEmbeddingChunk.deleteMany({ where: { noteId: testNoteId } })
|
||||
await prisma.note.delete({ where: { id: testNoteId } }).catch(() => {})
|
||||
})
|
||||
|
||||
async function removeTestNote() {
|
||||
await prisma.note.delete({ where: { id: testNoteId } }).catch(() => {})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mockEmbedding()
|
||||
await ensureTestNote()
|
||||
await cleanup()
|
||||
|
||||
console.log('\n=== US-CHUNK-2 : Indexation incrémentale avec dedup ===\n')
|
||||
|
||||
const service = new ChunkIndexingService()
|
||||
beforeEach(async () => {
|
||||
await prisma.noteEmbeddingChunk.deleteMany({ where: { noteId: testNoteId } })
|
||||
})
|
||||
|
||||
const longContent = Array.from({ length: 8 }, (_, i) =>
|
||||
`Section ${i} de la note de test. `.repeat(60).trim(),
|
||||
).join('\n\n')
|
||||
|
||||
await test('première indexation → tous les fragments sont nouveaux', async () => {
|
||||
test('première indexation → tous les fragments sont nouveaux', async () => {
|
||||
const service = new ChunkIndexingService()
|
||||
const result = await service.indexNote(testNoteId, 'Note de test', longContent)
|
||||
|
||||
if (result.newFragments === 0)
|
||||
throw new Error(`attendu newFragments > 0, reçu ${result.newFragments}`)
|
||||
if (result.deleted !== 0)
|
||||
throw new Error(`attendu deleted=0, reçu ${result.deleted}`)
|
||||
if (result.skipped !== 0)
|
||||
throw new Error(`attendu skipped=0, reçu ${result.skipped}`)
|
||||
|
||||
console.log(` → ${result.newFragments} nouveaux, ${result.totalFragments} total`)
|
||||
expect(result.newFragments).toBeGreaterThan(0)
|
||||
expect(result.deleted).toBe(0)
|
||||
expect(result.skipped).toBe(0)
|
||||
})
|
||||
|
||||
await test('deuxième indexation (même contenu) → tout skipped, 0 nouveau', async () => {
|
||||
test('deuxième indexation (même contenu) → tout skipped, 0 nouveau', async () => {
|
||||
const service = new ChunkIndexingService()
|
||||
await service.indexNote(testNoteId, 'Note de test', longContent)
|
||||
const result = await service.indexNote(testNoteId, 'Note de test', longContent)
|
||||
|
||||
if (result.newFragments !== 0)
|
||||
throw new Error(`attendu 0 nouveau, reçu ${result.newFragments}`)
|
||||
if (result.skipped === 0)
|
||||
throw new Error(`attendu skipped > 0, reçu ${result.skipped}`)
|
||||
if (result.deleted !== 0)
|
||||
throw new Error(`attendu deleted=0, reçu ${result.deleted}`)
|
||||
|
||||
console.log(` → ${result.skipped} skip, 0 nouveau ✓`)
|
||||
expect(result.newFragments).toBe(0)
|
||||
expect(result.skipped).toBeGreaterThan(0)
|
||||
expect(result.deleted).toBe(0)
|
||||
})
|
||||
|
||||
await test('modification d\'une section → 1 nouveau, reste skip', async () => {
|
||||
test('modification d\'une section → 1 nouveau, reste skip', async () => {
|
||||
const service = new ChunkIndexingService()
|
||||
await service.indexNote(testNoteId, 'Note de test', longContent)
|
||||
|
||||
const sections = Array.from({ length: 8 }, (_, i) =>
|
||||
`Section ${i === 3 ? 'MODIFIÉE' : i} de la note de test. `.repeat(60).trim(),
|
||||
)
|
||||
@@ -101,15 +71,12 @@ async function main() {
|
||||
|
||||
const result = await service.indexNote(testNoteId, 'Note de test', modified)
|
||||
|
||||
if (result.newFragments === 0)
|
||||
throw new Error(`attendu au moins 1 nouveau fragment, reçu ${result.newFragments}`)
|
||||
if (result.deleted === 0)
|
||||
throw new Error(`attendu au moins 1 stale supprimé, reçu ${result.deleted}`)
|
||||
|
||||
console.log(` → ${result.newFragments} nouveau(x), ${result.skipped} skip, ${result.deleted} supprimé(s)`)
|
||||
expect(result.newFragments).toBeGreaterThanOrEqual(1)
|
||||
expect(result.deleted).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
await test('suppression d\'une section → fragments stale nettoyés', async () => {
|
||||
test('suppression d\'une section → fragments stale nettoyés', async () => {
|
||||
const service = new ChunkIndexingService()
|
||||
const sections = Array.from({ length: 8 }, (_, i) =>
|
||||
`Section ${i} de la note de test. `.repeat(60).trim(),
|
||||
)
|
||||
@@ -126,15 +93,12 @@ async function main() {
|
||||
where: { noteId: testNoteId },
|
||||
})
|
||||
|
||||
if (afterCount >= beforeCount)
|
||||
throw new Error(`attendu count < ${beforeCount}, reçu ${afterCount}`)
|
||||
if (result.deleted === 0)
|
||||
throw new Error(`attendu deleted > 0, reçu ${result.deleted}`)
|
||||
|
||||
console.log(` → ${beforeCount} → ${afterCount} fragments (${result.deleted} supprimés)`)
|
||||
expect(afterCount).toBeLessThan(beforeCount)
|
||||
expect(result.deleted).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
await test('note vide → tous les fragments supprimés', async () => {
|
||||
test('note vide → tous les fragments supprimés', async () => {
|
||||
const service = new ChunkIndexingService()
|
||||
await service.indexNote(testNoteId, 'Note de test', longContent)
|
||||
const result = await service.indexNote(testNoteId, '', '')
|
||||
|
||||
@@ -142,37 +106,27 @@ async function main() {
|
||||
where: { noteId: testNoteId },
|
||||
})
|
||||
|
||||
if (count !== 0) throw new Error(`attendu 0 fragments, reçu ${count}`)
|
||||
console.log(` → tous les fragments supprimés ✓`)
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
|
||||
await test('deleteNoteChunks → supprime tout', async () => {
|
||||
test('deleteNoteChunks → supprime tout', async () => {
|
||||
const service = new ChunkIndexingService()
|
||||
await service.indexNote(testNoteId, 'Note de test', longContent)
|
||||
await service.deleteNoteChunks(testNoteId)
|
||||
|
||||
const count = await prisma.noteEmbeddingChunk.count({
|
||||
where: { noteId: testNoteId },
|
||||
})
|
||||
if (count !== 0) throw new Error(`attendu 0, reçu ${count}`)
|
||||
console.log(` → 0 fragment restant ✓`)
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
|
||||
await test('hasChunks → détection correcte', async () => {
|
||||
test('hasChunks → détection correcte', async () => {
|
||||
const service = new ChunkIndexingService()
|
||||
const before = await service.hasChunks(testNoteId)
|
||||
if (before) throw new Error('attendu false avant indexation')
|
||||
expect(before).toBe(false)
|
||||
|
||||
await service.indexNote(testNoteId, 'Note de test', longContent)
|
||||
const after = await service.hasChunks(testNoteId)
|
||||
if (!after) throw new Error('attendu true après indexation')
|
||||
|
||||
console.log(` → false avant, true après ✓`)
|
||||
expect(after).toBe(true)
|
||||
})
|
||||
|
||||
await cleanup()
|
||||
restoreEmbedding()
|
||||
await prisma.$disconnect()
|
||||
|
||||
console.log('\n=== Tests terminés ===')
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
})
|
||||
|
||||
@@ -1,140 +1,119 @@
|
||||
import { test, expect, describe } from 'vitest'
|
||||
import { chunkNoteContent } from '../../lib/text/note-chunking'
|
||||
|
||||
function test(name: string, fn: () => void) {
|
||||
try {
|
||||
fn()
|
||||
console.log(` ✓ ${name}`)
|
||||
} catch (err: any) {
|
||||
console.error(` ✗ ${name}: ${err.message}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
describe('US-CHUNK-1 : Chunking sémantique', () => {
|
||||
test('note vide → aucun fragment', () => {
|
||||
const chunks = chunkNoteContent('note1', '')
|
||||
expect(chunks.length).toBe(0)
|
||||
})
|
||||
|
||||
function assert(condition: any, msg: string) {
|
||||
if (!condition) throw new Error(msg)
|
||||
}
|
||||
test('note très courte (< 10 chars) → aucun fragment', () => {
|
||||
const chunks = chunkNoteContent('note1', 'Hello')
|
||||
expect(chunks.length).toBe(0)
|
||||
})
|
||||
|
||||
console.log('\n=== US-CHUNK-1 : Chunking sémantique ===\n')
|
||||
test('note courte (< 1000 chars) → 1 seul fragment', () => {
|
||||
const text = 'Ceci est une note courte. Elle parle de productivité et de gestion du temps.'
|
||||
const chunks = chunkNoteContent('note1', text)
|
||||
expect(chunks.length).toBe(1)
|
||||
expect(chunks[0].chunkIndex).toBe(0)
|
||||
expect(chunks[0].content).toContain('productivité')
|
||||
expect(chunks[0].charCount).toBe(chunks[0].content.length)
|
||||
})
|
||||
|
||||
test('note vide → aucun fragment', () => {
|
||||
const chunks = chunkNoteContent('note1', '')
|
||||
assert(chunks.length === 0, `attendu 0, reçu ${chunks.length}`)
|
||||
test('note longue avec plusieurs paragraphes → plusieurs fragments', () => {
|
||||
const paragraphs: string[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
paragraphs.push(`Paragraphe ${i}. `.repeat(60).trim())
|
||||
}
|
||||
const text = paragraphs.join('\n\n')
|
||||
const chunks = chunkNoteContent('note2', text)
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
expect(chunks.length).toBeLessThanOrEqual(15)
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
expect(chunks[i].chunkIndex).toBe(i)
|
||||
}
|
||||
})
|
||||
|
||||
test('fragmentId est stable (déterministe)', () => {
|
||||
const text = 'Même contenu donne même hash.'
|
||||
const chunks1 = chunkNoteContent('noteA', text)
|
||||
const chunks2 = chunkNoteContent('noteA', text)
|
||||
expect(chunks1[0].fragmentId).toBe(chunks2[0].fragmentId)
|
||||
})
|
||||
|
||||
test('fragmentId diffère entre notes différentes', () => {
|
||||
const text = 'Même contenu mais note différente.'
|
||||
const chunks1 = chunkNoteContent('noteA', text)
|
||||
const chunks2 = chunkNoteContent('noteB', text)
|
||||
expect(chunks1[0].fragmentId).not.toBe(chunks2[0].fragmentId)
|
||||
})
|
||||
|
||||
test('paragraphe géant (> 1500 chars) → sous-découpé aux phrases', () => {
|
||||
const giantPara =
|
||||
'Ceci est une phrase très longue. '.repeat(100) + 'Dernière phrase du paragraphe géant.'
|
||||
const chunks = chunkNoteContent('note3', giantPara)
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.content.length).toBeLessThanOrEqual(2000)
|
||||
}
|
||||
})
|
||||
|
||||
test('persan (RTL) → chunking correct', () => {
|
||||
const persianText =
|
||||
'یادداشت درباره بهرهوری.\n\nاین یک پاراگراف فارسی است. این متن برای تست قالببندی راستچین نوشته شده است. یادداشتهای فارسی باید به درستی پردازش شوند.\n\nپاراگراف سوم. محتوای بیشتری برای اطمینان از صحت پردازش.'
|
||||
const chunks = chunkNoteContent('note-fa', persianText)
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(1)
|
||||
expect(chunks[0].content).toContain('بهرهوری')
|
||||
})
|
||||
|
||||
test('contenu plain text → pas de transformation', () => {
|
||||
const plainText = 'Premier paragraphe.\n\nDeuxième paragraphe.'
|
||||
const chunks = chunkNoteContent('note4', plainText)
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(1)
|
||||
expect(chunks[0].content).toContain('Premier')
|
||||
})
|
||||
|
||||
test('paragraphe répété → dedup par fragmentId', () => {
|
||||
const repeatedPara = 'Paragraphe identique répété volontairement.'
|
||||
const text = `${repeatedPara}\n\n${repeatedPara}\n\n${repeatedPara}`
|
||||
const chunks = chunkNoteContent('note5', text)
|
||||
const uniqueIds = new Set(chunks.map((c) => c.fragmentId))
|
||||
expect(uniqueIds.size).toBe(chunks.length)
|
||||
})
|
||||
|
||||
test('modification d\'un paragraphe → fragmentId change pour ce fragment uniquement', () => {
|
||||
const paraA = 'Section A. '.repeat(80).trim()
|
||||
const paraB = 'Section B. '.repeat(80).trim()
|
||||
const paraC = 'Section C. '.repeat(80).trim()
|
||||
|
||||
const original = `${paraA}\n\n${paraB}\n\n${paraC}`
|
||||
const modified = `${paraA} MODIFIE.\n\n${paraB}\n\n${paraC}`
|
||||
|
||||
const chunksOriginal = chunkNoteContent('note6', original)
|
||||
const chunksModified = chunkNoteContent('note6', modified)
|
||||
|
||||
expect(chunksOriginal.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const originalIds = new Set(chunksOriginal.map((c) => c.fragmentId))
|
||||
const newIds = chunksModified.map((c) => c.fragmentId)
|
||||
|
||||
const unchanged = newIds.filter((id) => originalIds.has(id))
|
||||
expect(unchanged.length).toBeGreaterThanOrEqual(1)
|
||||
expect(unchanged.length).toBeLessThan(newIds.length)
|
||||
})
|
||||
|
||||
test('overlap entre fragments consécutifs', () => {
|
||||
const paragraphs: string[] = []
|
||||
for (let i = 0; i < 8; i++) {
|
||||
paragraphs.push(`Section ${i}. `.repeat(80).trim())
|
||||
}
|
||||
const text = paragraphs.join('\n\n')
|
||||
const chunks = chunkNoteContent('note7', text)
|
||||
if (chunks.length >= 2) {
|
||||
const tail = chunks[0].content.slice(-200)
|
||||
const matchesOverlap = chunks[1].content.startsWith(tail.slice(0, 50)) || chunks[1].content.includes(tail.slice(0, 30))
|
||||
expect(matchesOverlap).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('note très courte (< 10 chars) → aucun fragment', () => {
|
||||
const chunks = chunkNoteContent('note1', 'Hello')
|
||||
assert(chunks.length === 0, `attendu 0, reçu ${chunks.length}`)
|
||||
})
|
||||
|
||||
test('note courte (< 1000 chars) → 1 seul fragment', () => {
|
||||
const text = 'Ceci est une note courte. Elle parle de productivité et de gestion du temps.'
|
||||
const chunks = chunkNoteContent('note1', text)
|
||||
assert(chunks.length === 1, `attendu 1, reçu ${chunks.length}`)
|
||||
assert(chunks[0].chunkIndex === 0, 'chunkIndex doit être 0')
|
||||
assert(chunks[0].content.includes('productivité'), 'le contenu doit être préservé')
|
||||
assert(chunks[0].charCount === chunks[0].content.length, 'charCount doit correspondre')
|
||||
})
|
||||
|
||||
test('note longue avec plusieurs paragraphes → plusieurs fragments', () => {
|
||||
const paragraphs: string[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
paragraphs.push(`Paragraphe ${i}. `.repeat(60).trim())
|
||||
}
|
||||
const text = paragraphs.join('\n\n')
|
||||
const chunks = chunkNoteContent('note2', text)
|
||||
assert(chunks.length > 1, `attendu >1, reçu ${chunks.length}`)
|
||||
assert(chunks.length <= 15, `attendu <=15 fragments, reçu ${chunks.length}`)
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
assert(chunks[i].chunkIndex === i, `chunkIndex ${i} incorrect`)
|
||||
}
|
||||
})
|
||||
|
||||
test('fragmentId est stable (déterministe)', () => {
|
||||
const text = 'Même contenu donne même hash.'
|
||||
const chunks1 = chunkNoteContent('noteA', text)
|
||||
const chunks2 = chunkNoteContent('noteA', text)
|
||||
assert(chunks1[0].fragmentId === chunks2[0].fragmentId, 'les hash doivent être identiques')
|
||||
})
|
||||
|
||||
test('fragmentId diffère entre notes différentes', () => {
|
||||
const text = 'Même contenu mais note différente.'
|
||||
const chunks1 = chunkNoteContent('noteA', text)
|
||||
const chunks2 = chunkNoteContent('noteB', text)
|
||||
assert(chunks1[0].fragmentId !== chunks2[0].fragmentId, 'les hash doivent différer par noteId')
|
||||
})
|
||||
|
||||
test('paragraphe géant (> 1500 chars) → sous-découpé aux phrases', () => {
|
||||
const giantPara =
|
||||
'Ceci est une phrase très longue. '.repeat(100) + 'Dernière phrase du paragraphe géant.'
|
||||
const chunks = chunkNoteContent('note3', giantPara)
|
||||
assert(chunks.length > 1, `attendu >1 fragment, reçu ${chunks.length}`)
|
||||
for (const chunk of chunks) {
|
||||
assert(
|
||||
chunk.content.length <= 2000,
|
||||
`fragment trop long: ${chunk.charCount} chars`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('persan (RTL) → chunking correct', () => {
|
||||
const persianText =
|
||||
'یادداشت درباره بهرهوری.\n\nاین یک پاراگراف فارسی است. این متن برای تست قالببندی راستچین نوشته شده است. یادداشتهای فارسی باید به درستی پردازش شوند.\n\nپاراگراف سوم. محتوای بیشتری برای اطمینان از صحت پردازش.'
|
||||
const chunks = chunkNoteContent('note-fa', persianText)
|
||||
assert(chunks.length >= 1, `attendu >=1, reçu ${chunks.length}`)
|
||||
assert(chunks[0].content.includes('بهرهوری'), 'contenu persan préservé')
|
||||
})
|
||||
|
||||
test('contenu plain text → pas de transformation', () => {
|
||||
const plainText = 'Premier paragraphe.\n\nDeuxième paragraphe.'
|
||||
const chunks = chunkNoteContent('note4', plainText)
|
||||
assert(chunks.length >= 1, 'au moins 1 fragment')
|
||||
assert(chunks[0].content.includes('Premier'), 'contenu préservé')
|
||||
// Le strippage HTML est fait en amont par prepareNoteTextForEmbedding, pas par le chunker
|
||||
})
|
||||
|
||||
test('paragraphe répété → dedup par fragmentId', () => {
|
||||
const repeatedPara = 'Paragraphe identique répété volontairement.'
|
||||
const text = `${repeatedPara}\n\n${repeatedPara}\n\n${repeatedPara}`
|
||||
const chunks = chunkNoteContent('note5', text)
|
||||
const uniqueIds = new Set(chunks.map((c) => c.fragmentId))
|
||||
assert(uniqueIds.size === chunks.length, 'les doublons doivent être supprimés')
|
||||
})
|
||||
|
||||
test('modification d\'un paragraphe → fragmentId change pour ce fragment uniquement', () => {
|
||||
const paraA = 'Section A. '.repeat(80).trim()
|
||||
const paraB = 'Section B. '.repeat(80).trim()
|
||||
const paraC = 'Section C. '.repeat(80).trim()
|
||||
|
||||
const original = `${paraA}\n\n${paraB}\n\n${paraC}`
|
||||
const modified = `${paraA} MODIFIE.\n\n${paraB}\n\n${paraC}`
|
||||
|
||||
const chunksOriginal = chunkNoteContent('note6', original)
|
||||
const chunksModified = chunkNoteContent('note6', modified)
|
||||
|
||||
assert(chunksOriginal.length >= 2, `original devrait avoir >=2 fragments, reçu ${chunksOriginal.length}`)
|
||||
|
||||
const originalIds = new Set(chunksOriginal.map((c) => c.fragmentId))
|
||||
const newIds = chunksModified.map((c) => c.fragmentId)
|
||||
|
||||
const unchanged = newIds.filter((id) => originalIds.has(id))
|
||||
assert(unchanged.length >= 1, `au moins 1 fragment inchangé attendu, reçu ${unchanged.length} sur ${newIds.length}`)
|
||||
assert(unchanged.length < newIds.length, `au moins 1 fragment modifié attendu`)
|
||||
})
|
||||
|
||||
test('overlap entre fragments consécutifs', () => {
|
||||
const paragraphs: string[] = []
|
||||
for (let i = 0; i < 8; i++) {
|
||||
paragraphs.push(`Section ${i}. `.repeat(80).trim())
|
||||
}
|
||||
const text = paragraphs.join('\n\n')
|
||||
const chunks = chunkNoteContent('note7', text)
|
||||
if (chunks.length >= 2) {
|
||||
const tail = chunks[0].content.slice(-200)
|
||||
assert(
|
||||
chunks[1].content.startsWith(tail.slice(0, 50)) || chunks[1].content.includes(tail.slice(0, 30)),
|
||||
'l\'overlap devrait être présent entre fragments consécutifs',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('\n=== Tests terminés ===')
|
||||
|
||||
Reference in New Issue
Block a user