- Collage/accès images: ownership path userId + meta legacy, 403 non caché - Couverture note: explicite (choix/aucune), plus d’auto depuis le corps - Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée - Slash: listes par titre (plus d’index), keywords nettoyés - Agents: nextRun à la réactivation, cron sans stampede null - Wizard: titres courts + mode Expert plus robuste - Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings - Indexation notes courtes + test unit aligné
252 lines
13 KiB
TypeScript
252 lines
13 KiB
TypeScript
import { getChatProvider } from '../factory'
|
||
import { getSystemConfig } from '@/lib/config'
|
||
import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
|
||
|
||
export interface GeneratedNote {
|
||
title: string
|
||
content: string
|
||
difficulty?: 'facile' | 'moyen' | 'difficile'
|
||
}
|
||
|
||
export interface GeneratedCarnet {
|
||
/** Short notebook title (not the raw user prompt). */
|
||
notebookName?: string
|
||
notes: GeneratedNote[]
|
||
schemaProperties: Array<{ name: string; type: string; options?: string[] }>
|
||
}
|
||
|
||
export type WizardProfile = 'student' | 'teacher' | 'engineer'
|
||
|
||
/** Word-count targets by level — Expert was failing (timeouts / truncated JSON). */
|
||
function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } {
|
||
const l = (level || '').toLowerCase()
|
||
if (/expert|avancé|advanced|fort/.test(l)) {
|
||
return { min: 350, max: 700, label: 'expert (350–700 mots/note — dense mais JSON-fiable)' }
|
||
}
|
||
if (/intermédiaire|intermediate|moyen/.test(l)) {
|
||
return { min: 250, max: 500, label: 'intermédiaire (250–500 mots/note)' }
|
||
}
|
||
if (/débutant|beginner|facile|easy/.test(l)) {
|
||
return { min: 150, max: 350, label: 'débutant (150–350 mots/note)' }
|
||
}
|
||
return { min: 200, max: 450, label: 'standard (200–450 mots/note)' }
|
||
}
|
||
|
||
export class NotebookWizardService {
|
||
async generateCarnet(
|
||
profile: WizardProfile,
|
||
topic: string,
|
||
options?: { level?: string; count?: number; language?: string }
|
||
): Promise<GeneratedCarnet> {
|
||
const lang = options?.language || 'fr'
|
||
// Cap count in expert mode to avoid oversized responses
|
||
const level = options?.level
|
||
const isExpert = /expert/i.test(level || '')
|
||
const count = Math.min(options?.count || 6, isExpert ? 5 : 10)
|
||
|
||
const prompts = this.buildPrompt(profile, topic, lang, count, level)
|
||
const config = await getSystemConfig()
|
||
const provider = getChatProvider(config)
|
||
let raw: string
|
||
try {
|
||
raw = await provider.generateText(prompts)
|
||
} catch (err) {
|
||
// Retry once with lighter prompt if expert/full generation fails
|
||
if (isExpert) {
|
||
const light = this.buildPrompt(profile, topic, lang, Math.min(count, 4), 'Avancé')
|
||
raw = await provider.generateText(light)
|
||
} else {
|
||
throw err
|
||
}
|
||
}
|
||
|
||
const result = this.parseResponse(raw, profile, lang)
|
||
if (!result.notebookName?.trim()) {
|
||
result.notebookName = this.deriveNotebookName(topic, result.notes, lang)
|
||
}
|
||
return result
|
||
}
|
||
|
||
/** Fallback short name when the model omits notebookName. */
|
||
private deriveNotebookName(topic: string, notes: GeneratedNote[], lang: string): string {
|
||
const fromNote = notes[0]?.title?.trim()
|
||
if (fromNote && fromNote.length <= 80 && fromNote.length >= 3) {
|
||
// Prefer first chapter title cleaned of numbering
|
||
return fromNote.replace(/^\d+[\.\):\-–]\s*/, '').slice(0, 80)
|
||
}
|
||
const cleaned = topic
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.replace(/^(je veux|i want|please|peux-tu|can you|crée|create|génère|generate)\b[\s,:-]*/i, '')
|
||
if (cleaned.length > 0 && cleaned.length <= 60) return cleaned
|
||
if (cleaned.length > 60) {
|
||
const cut = cleaned.slice(0, 57)
|
||
const lastSpace = cut.lastIndexOf(' ')
|
||
return (lastSpace > 20 ? cut.slice(0, lastSpace) : cut) + '…'
|
||
}
|
||
return lang === 'fr' ? 'Nouveau carnet' : 'New notebook'
|
||
}
|
||
|
||
private buildPrompt(
|
||
profile: WizardProfile,
|
||
topic: string,
|
||
lang: string,
|
||
count: number,
|
||
level?: string
|
||
): string {
|
||
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang === 'it' ? 'Italiano' : lang === 'pt' ? 'Português' : lang === 'ru' ? 'Русский' : lang === 'zh' ? '中文' : lang === 'ja' ? '日本語' : lang === 'ko' ? '한국어' : lang === 'nl' ? 'Nederlands' : lang === 'pl' ? 'Polski' : lang === 'hi' ? 'हिन्दी' : lang
|
||
|
||
const words = wordTargetsForLevel(level)
|
||
const schemaLabels = this.getSchemaLabels(lang)
|
||
const profileContext = {
|
||
student: `Tu crées des notes de cours pour un étudiant. Le contenu doit être pédagogique, clair, avec des exemples.`,
|
||
teacher: `Tu crées la structure d'un cours pour un professeur. Chaque note est un chapitre avec des sections à remplir, des objectifs, et des exercices.`,
|
||
engineer: `Tu crées une documentation technique. Le contenu doit être précis, structuré, avec des spécifications et des références.`,
|
||
}[profile]
|
||
|
||
return `Tu es un expert pédagogue et créateur de contenu de haut niveau. ${profileContext}
|
||
|
||
Sujet (intention de l'utilisateur, NE PAS recopier tel quel comme titre de carnet) : "${topic}"
|
||
${level ? `Niveau : ${level}` : ''}
|
||
Langue : ${langName}
|
||
Nombre de notes à créer : ${count}
|
||
Profondeur cible : ${words.label}
|
||
|
||
CRÉE ${count} NOTES COMPLÈTES sur ce sujet. Chaque note : environ ${words.min}–${words.max} mots (qualité > longueur extrême).
|
||
|
||
IMPORTANT — NOM DU CARNET :
|
||
- Fournis "notebookName" : un titre COURT et PERTINENT (3–8 mots, max 60 caractères).
|
||
- Exemple : pour un long message sur la thermo HVAC → "Modélisation thermodynamique HVAC"
|
||
- N'utilise JAMAIS la phrase complète de l'utilisateur comme nom.
|
||
|
||
Le contenu doit être :
|
||
- Clair et structuré avec <h2> et <h3>
|
||
- Des exemples concrets
|
||
- Des définitions dans des callouts
|
||
${profile === 'student' ? '- Points clés en callout tip\n- Pièges à éviter en callout danger' : ''}
|
||
${profile === 'teacher' ? '- Objectifs pédagogiques\n- Section Exercices (3–5 questions)' : ''}
|
||
${profile === 'engineer' ? '- Spécifications techniques\n- Tableaux comparatifs si utile' : ''}
|
||
|
||
FORMAT DE SORTIE — JSON UNIQUEMENT (pas de markdown hors du bloc) :
|
||
\`\`\`json
|
||
{
|
||
"notebookName": "Titre court du carnet",
|
||
"notes": [
|
||
{
|
||
"title": "Titre de chapitre descriptif",
|
||
"difficulty": "facile",
|
||
"content": "<h2>Introduction</h2><p>...</p>..."
|
||
}
|
||
],
|
||
"schemaProperties": [
|
||
{ "name": "${schemaLabels.status}", "type": "select", "options": ["${schemaLabels.statusTodo}", "${schemaLabels.statusInProgress}", "${schemaLabels.statusDone}"] },
|
||
{ "name": "${schemaLabels.difficulty}", "type": "select", "options": ["${schemaLabels.easy}", "${schemaLabels.medium}", "${schemaLabels.hard}"] }
|
||
]
|
||
}
|
||
\`\`\`
|
||
|
||
BLOCS HTML DISPONIBLES :
|
||
|
||
1. Callout : <div data-type="callout-block" data-callout-type="info"><p>...</p></div> (info|warning|tip|success|danger)
|
||
2. Toggle : <div data-type="toggle-block" data-opened="true"><p>Titre</p><p>Contenu</p></div>
|
||
3. Math : <div data-type="math-equation" data-latex="E = mc^2"></div> — JAMAIS $$
|
||
4. Colonnes : <div data-type="columns" cols="2">...</div>
|
||
5. Sommaire : <div data-type="outline-block"></div>
|
||
6. HTML : <h2>/<h3>, <p>, <ul>/<ol>/<li>, <table>, <blockquote>, <pre><code>
|
||
|
||
Les "difficulty" doivent varier : facile/moyen/difficile.
|
||
Réponds UNIQUEMENT avec le JSON valide (échappement correct des guillemets dans le HTML).`
|
||
}
|
||
|
||
private getSchemaLabels(lang: string) {
|
||
const map: Record<string, { status: string; statusTodo: string; statusInProgress: string; statusDone: string; difficulty: string; easy: string; medium: string; hard: string }> = {
|
||
fr: { status: 'Statut', statusTodo: 'À réviser', statusInProgress: 'En cours', statusDone: 'Maîtrisé', difficulty: 'Difficulté', easy: 'Facile', medium: 'Moyen', hard: 'Difficile' },
|
||
en: { status: 'Status', statusTodo: 'To review', statusInProgress: 'In progress', statusDone: 'Mastered', difficulty: 'Difficulty', easy: 'Easy', medium: 'Medium', hard: 'Hard' },
|
||
fa: { status: 'وضعیت', statusTodo: 'باید مرور شود', statusInProgress: 'در حال یادگیری', statusDone: 'تسلط یافته', difficulty: 'سختی', easy: 'آسان', medium: 'متوسط', hard: 'دشوار' },
|
||
ar: { status: 'الحالة', statusTodo: 'للمراجعة', statusInProgress: 'قيد الدراسة', statusDone: 'متقن', difficulty: 'الصعوبة', easy: 'سهل', medium: 'متوسط', hard: 'صعب' },
|
||
de: { status: 'Status', statusTodo: 'Zu wiederholen', statusInProgress: 'In Bearbeitung', statusDone: 'Beherrscht', difficulty: 'Schwierigkeit', easy: 'Einfach', medium: 'Mittel', hard: 'Schwer' },
|
||
es: { status: 'Estado', statusTodo: 'Por repasar', statusInProgress: 'En progreso', statusDone: 'Dominado', difficulty: 'Dificultad', easy: 'Fácil', medium: 'Medio', hard: 'Difícil' },
|
||
it: { status: 'Stato', statusTodo: 'Da ripassare', statusInProgress: 'In corso', statusDone: 'Padroneggiato', difficulty: 'Difficoltà', easy: 'Facile', medium: 'Medio', hard: 'Difficile' },
|
||
pt: { status: 'Estado', statusTodo: 'A rever', statusInProgress: 'Em progresso', statusDone: 'Dominado', difficulty: 'Dificuldade', easy: 'Fácil', medium: 'Médio', hard: 'Difícil' },
|
||
ru: { status: 'Статус', statusTodo: 'На повторение', statusInProgress: 'В процессе', statusDone: 'Усвоено', difficulty: 'Сложность', easy: 'Лёгкий', medium: 'Средний', hard: 'Сложный' },
|
||
zh: { status: '状态', statusTodo: '待复习', statusInProgress: '学习中', statusDone: '已掌握', difficulty: '难度', easy: '简单', medium: '中等', hard: '困难' },
|
||
ja: { status: 'ステータス', statusTodo: '要復習', statusInProgress: '学習中', statusDone: '習得済み', difficulty: '難易度', easy: '易しい', medium: '普通', hard: '難しい' },
|
||
ko: { status: '상태', statusTodo: '복습 필요', statusInProgress: '학습 중', statusDone: '마스터', difficulty: '난이도', easy: '쉬움', medium: '보통', hard: '어려움' },
|
||
nl: { status: 'Status', statusTodo: 'Te herhalen', statusInProgress: 'Bezig', statusDone: 'Beheerst', difficulty: 'Moeilijkheid', easy: 'Makkelijk', medium: 'Gemiddeld', hard: 'Moeilijk' },
|
||
pl: { status: 'Status', statusTodo: 'Do powtórki', statusInProgress: 'W trakcie', statusDone: 'Opanowane', difficulty: 'Trudność', easy: 'Łatwy', medium: 'Średni', hard: 'Trudny' },
|
||
hi: { status: 'स्थिति', statusTodo: 'दोहराने के लिए', statusInProgress: 'प्रगति में', statusDone: 'महारत हासिल', difficulty: 'कठिनाई', easy: 'आसान', medium: 'मध्यम', hard: 'कठिन' },
|
||
}
|
||
return map[lang] || map.en
|
||
}
|
||
|
||
private parseResponse(raw: string, profile: WizardProfile, lang?: string): GeneratedCarnet {
|
||
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
|
||
let jsonStr = jsonMatch ? jsonMatch[1] : raw
|
||
|
||
// Extract the JSON object boundaries
|
||
const start = jsonStr.indexOf('{')
|
||
const end = jsonStr.lastIndexOf('}')
|
||
if (start >= 0 && end > start) {
|
||
jsonStr = jsonStr.slice(start, end + 1)
|
||
}
|
||
|
||
let parsed: any
|
||
try {
|
||
parsed = JSON.parse(jsonStr)
|
||
} catch {
|
||
// Fix common AI JSON issues: unescaped backslashes in LaTeX
|
||
try {
|
||
const fixed = jsonStr
|
||
.replace(/\\(?!["\\\/bfnrtu])/g, '\\\\') // Escape lone backslashes (LaTeX \frac etc.)
|
||
.replace(/\n/g, '\\n') // Fix raw newlines in strings
|
||
parsed = JSON.parse(fixed)
|
||
} catch {
|
||
try {
|
||
// Last resort: extract notes manually with regex
|
||
const notes: GeneratedNote[] = []
|
||
const titleMatches = [...jsonStr.matchAll(/"title"\s*:\s*"([^"]+)"/g)]
|
||
const contentMatches = [...jsonStr.matchAll(/"content"\s*:\s*"([\s\S]*?)"(?:,|\s*})/g)]
|
||
for (let i = 0; i < titleMatches.length; i++) {
|
||
notes.push({
|
||
title: titleMatches[i][1],
|
||
content: contentMatches[i]?.[1]?.replace(/\\n/g, '\n').replace(/\\"/g, '"') || '<p></p>',
|
||
difficulty: i % 3 === 0 ? 'facile' : i % 3 === 1 ? 'moyen' : 'difficile',
|
||
})
|
||
}
|
||
if (notes.length === 0) throw new Error('No notes found in response')
|
||
const fallbackLabels = this.getSchemaLabels(lang || 'fr')
|
||
return {
|
||
notes,
|
||
schemaProperties: [
|
||
{ name: fallbackLabels.status, type: 'select', options: [fallbackLabels.statusTodo, fallbackLabels.statusInProgress, fallbackLabels.statusDone] },
|
||
{ name: fallbackLabels.difficulty, type: 'select', options: [fallbackLabels.easy, fallbackLabels.medium, fallbackLabels.hard] },
|
||
],
|
||
}
|
||
} catch {
|
||
throw new Error('Failed to parse AI response')
|
||
}
|
||
}
|
||
}
|
||
|
||
const notes: GeneratedNote[] = (parsed.notes || []).map((n: any) => ({
|
||
title: String(n.title || 'Sans titre'),
|
||
content: preprocessMathInHtml(String(n.content || '<p></p>')),
|
||
difficulty: n.difficulty || undefined,
|
||
}))
|
||
|
||
const defaultLabels = this.getSchemaLabels(lang || 'fr')
|
||
const schemaProperties = parsed.schemaProperties || [
|
||
{ name: defaultLabels.status, type: 'select', options: [defaultLabels.statusTodo, defaultLabels.statusInProgress, defaultLabels.statusDone] },
|
||
{ name: defaultLabels.difficulty, type: 'select', options: [defaultLabels.easy, defaultLabels.medium, defaultLabels.hard] },
|
||
]
|
||
|
||
const notebookName = typeof parsed.notebookName === 'string'
|
||
? parsed.notebookName.trim().slice(0, 80)
|
||
: undefined
|
||
|
||
return { notebookName, notes, schemaProperties }
|
||
}
|
||
}
|
||
|
||
export const notebookWizardService = new NotebookWizardService()
|