Files
Momento/memento-note/lib/ai/services/notebook-wizard.service.ts
Antigravity 45297da333 fix: images, couverture, slash, agents, wizard, Stripe et admin
- 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é
2026-07-16 16:58:07 +00:00

252 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (350700 mots/note — dense mais JSON-fiable)' }
}
if (/intermédiaire|intermediate|moyen/.test(l)) {
return { min: 250, max: 500, label: 'intermédiaire (250500 mots/note)' }
}
if (/débutant|beginner|facile|easy/.test(l)) {
return { min: 150, max: 350, label: 'débutant (150350 mots/note)' }
}
return { min: 200, max: 450, label: 'standard (200450 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 (38 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 (35 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()