Files
Momento/memento-note/lib/ai/services/notebook-wizard.service.ts
Antigravity 704fed1191 fix: Memory Echo sans vol de scroll, badge IA sidebar, wizard langue
- Ne plus auto-scroller vers Memory Echo : pastille bas de page + reset scroll au titre
- Badges notes/carnets générés par l’IA dans la sidebar
- Wizard : langue du contenu = langue de la requête ; refresh carnet après création
- Voix : erreurs not-allowed/no-speech moins bruyantes
2026-07-16 17:25:57 +00:00

391 lines
16 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'
import { LanguageDetectionService } from './language-detection.service'
export interface GeneratedNote {
title: string
content: string
difficulty?: 'facile' | 'moyen' | 'difficile' | 'easy' | 'medium' | 'hard'
}
export interface GeneratedCarnet {
/** Short notebook title (not the raw user prompt). */
notebookName?: string
notes: GeneratedNote[]
schemaProperties: Array<{ name: string; type: string; options?: string[] }>
/** Language actually used for generation (ISO 639-1). */
contentLanguage?: string
}
export type WizardProfile = 'student' | 'teacher' | 'engineer'
const LANG_NAMES: Record<string, string> = {
fr: 'français',
en: 'English',
fa: 'فارسی',
ar: 'العربية',
de: 'Deutsch',
es: 'Español',
it: 'Italiano',
pt: 'Português',
ru: 'Русский',
zh: '中文',
ja: '日本語',
ko: '한국어',
nl: 'Nederlands',
pl: 'Polski',
hi: 'हिन्दी',
}
/** Word-count targets by level */
function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } {
const l = (level || '').toLowerCase()
if (/expert|avancé|advanced|fort/.test(l)) {
return { min: 400, max: 800, label: 'expert (400800 words per note)' }
}
if (/intermédiaire|intermediate|moyen/.test(l)) {
return { min: 300, max: 550, label: 'intermediate (300550 words per note)' }
}
if (/débutant|beginner|facile|easy/.test(l)) {
return { min: 250, max: 450, label: 'beginner (250450 words per note)' }
}
return { min: 300, max: 550, label: 'standard (300550 words per note)' }
}
function countWords(html: string): number {
const plain = html
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z]+;/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!plain) return 0
return plain.split(/\s+/).filter(Boolean).length
}
export class NotebookWizardService {
private languageDetection = new LanguageDetectionService()
/**
* Langue de sortie = langue du **sujet / requête** (pas l'UI), sauf si
* la requête est trop courte / ambiguë (tinyld confond souvent fr↔it).
*
* Ex. UI=fr + "I want a list of…" → en
* UI=fr + "calcul différentiel" → fr (accents / UI, pas "it" de tinyld)
*/
async resolveOutputLanguage(topic: string, uiLanguage?: string): Promise<string> {
const ui = (uiLanguage || 'fr').slice(0, 2).toLowerCase()
const text = (topic || '').trim()
if (!text) return ui
const wordCount = text.split(/\s+/).filter(Boolean).length
// Scripts non latins : fiables même sur peu de mots
if (/[\u0600-\u06FF]/.test(text)) {
if (/\b(است|های|می‌|برای|که|این)\b/.test(text)) return 'fa'
return 'ar'
}
if (/[\u4e00-\u9fff]/.test(text)) return 'zh'
if (/[\u3040-\u30ff]/.test(text)) return 'ja'
if (/[\uac00-\ud7af]/.test(text)) return 'ko'
if (/[\u0400-\u04ff]/.test(text)) return 'ru'
if (/[\u0900-\u097f]/.test(text)) return 'hi'
// Anglais explicite (phrases courantes)
if (/\b(i want|i need|i'd like|please|make me|list of|how to|for a student|for students|write in english)\b/i.test(text)) {
return 'en'
}
// Français explicite
if (/\b(je veux|j'aimerais|peux-tu|créer|génère|cours de|pour un étudiant|pour les étudiants|en français)\b/i.test(text)) {
return 'fr'
}
// Accents / digrammes typiquement français → fr (évite "calcul différentiel" → it)
if (/[àâäéèêëïîôùûüçœæ]/i.test(text) || /\b(différentiel|différentielle|mathématiques|géométrie|algèbre|fonction|limite|dérivée)\b/i.test(text)) {
return 'fr'
}
// Allemand (umlauts / ß)
if (/[äöüß]/i.test(text)) return 'de'
// Espagnol (ñ, ¿, ¡)
if (/[ñ¿¡]/i.test(text)) return 'es'
// Sujet court (≤ 4 mots) : tinyld est peu fiable (ex. "calcul différentiel" → it @ 5%)
// → on suit l'UI sauf signal anglais fort déjà géré ci-dessus
if (wordCount <= 4) {
return ui
}
// Texte plus long : détection, mais on ignore un résultat peu crédible
try {
const { language, confidence } = await this.languageDetection.detectLanguage(text)
const code = (language || '').slice(0, 2).toLowerCase()
if (code && code !== 'unknown' && confidence >= 0.75) {
// Romance court : si UI=fr et détecté it/es/pt/ro, rester en fr (faux positifs fréquents)
if (ui === 'fr' && ['it', 'es', 'pt', 'ro'].includes(code) && wordCount < 12) {
return 'fr'
}
return code
}
} catch {
/* fall through */
}
return ui
}
private langName(code: string): string {
return LANG_NAMES[code] || code
}
private languageRuleBlock(lang: string): string {
const name = this.langName(lang)
return `CRITICAL LANGUAGE RULE (non-negotiable):
- The USER REQUEST language is: ${name} (code: ${lang}).
- The app UI language is IRRELEVANT — do NOT follow UI language.
- Write ALL human-readable text in ${name}: notebookName, note titles, HTML body, callouts, examples, exercises, table headers, difficulty free-text if any.
- JSON keys stay in English (notebookName, notes, title, content, difficulty, schemaProperties).
- Do NOT mix languages. Do NOT answer in French if the request is English (and vice versa).
- Math LaTeX may stay standard (symbols), but surrounding explanations must be in ${name}.`
}
async generateCarnet(
profile: WizardProfile,
topic: string,
options?: { level?: string; count?: number; language?: string }
): Promise<GeneratedCarnet> {
const uiLang = options?.language || 'fr'
const contentLang = await this.resolveOutputLanguage(topic, uiLang)
const level = options?.level
const isExpert = /expert/i.test(level || '')
// Fewer notes + richer content (quality over stuffing one JSON)
const count = Math.min(options?.count || 6, isExpert ? 5 : 6)
const words = wordTargetsForLevel(level)
const config = await getSystemConfig()
const provider = getChatProvider(config)
// Phase 1 — outline only (name + titles)
const plan = await this.generatePlan(provider, profile, topic, contentLang, count, level)
const schemaLabels = this.getSchemaLabels(contentLang)
// Phase 2 — one note at a time (avoids shallow multi-note dumps)
const notes: GeneratedNote[] = []
for (let i = 0; i < plan.titles.length; i++) {
const title = plan.titles[i]
const difficulty = i % 3 === 0 ? 'facile' : i % 3 === 1 ? 'moyen' : 'difficile'
let note = await this.generateOneNote(provider, {
profile,
topic,
title,
lang: contentLang,
level,
words,
difficulty,
noteIndex: i + 1,
totalNotes: plan.titles.length,
})
// Retry once if too short
if (countWords(note.content) < Math.max(120, Math.floor(words.min * 0.55))) {
note = await this.generateOneNote(provider, {
profile,
topic,
title,
lang: contentLang,
level,
words,
difficulty,
noteIndex: i + 1,
totalNotes: plan.titles.length,
forceLonger: true,
})
}
notes.push(note)
}
return {
notebookName: plan.notebookName || this.deriveNotebookName(topic, notes, contentLang),
notes,
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] },
],
contentLanguage: contentLang,
}
}
private async generatePlan(
provider: { generateText: (p: string) => Promise<string> },
profile: WizardProfile,
topic: string,
lang: string,
count: number,
level?: string,
): Promise<{ notebookName: string; titles: string[] }> {
const prompt = `${this.languageRuleBlock(lang)}
You are planning a ${profile} notebook on the user request below.
User request / topic: "${topic}"
${level ? `Level: ${level}` : ''}
Produce a short JSON plan only.
Return ONLY:
\`\`\`json
{
"notebookName": "38 words, short, in ${this.langName(lang)}",
"titles": ["chapter title 1", "chapter title 2", "... exactly ${count} titles"]
}
\`\`\`
Rules:
- Exactly ${count} titles, progressive learning order.
- Titles in ${this.langName(lang)} only.
- notebookName ≠ full user sentence.`
const raw = await provider.generateText(prompt)
const parsed = this.parseJsonObject(raw)
const titles = Array.isArray(parsed?.titles)
? parsed.titles.map((t: unknown) => String(t || '').trim()).filter(Boolean).slice(0, count)
: []
while (titles.length < count) {
titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`)
}
return {
notebookName: String(parsed?.notebookName || '').trim().slice(0, 80),
titles,
}
}
private async generateOneNote(
provider: { generateText: (p: string) => Promise<string> },
opts: {
profile: WizardProfile
topic: string
title: string
lang: string
level?: string
words: { min: number; max: number; label: string }
difficulty: string
noteIndex: number
totalNotes: number
forceLonger?: boolean
},
): Promise<GeneratedNote> {
const { profile, topic, title, lang, level, words, difficulty, noteIndex, totalNotes, forceLonger } = opts
const profileExtra = {
student: `Pedagogical note for a student: worked examples, tip + danger callouts, at least 2 exercises with brief solution hints.`,
teacher: `Lesson plan style: learning objectives, main content, 45 exercises, indicative answers.`,
engineer: `Technical documentation: precise definitions, comparison table or specs, practical examples.`,
}[profile]
const prompt = `${this.languageRuleBlock(lang)}
You write ONE complete notebook chapter as HTML for a ${profile}.
${profileExtra}
Overall topic: "${topic}"
Chapter title: "${title}"
Chapter ${noteIndex}/${totalNotes}
${level ? `Level: ${level}` : ''}
Target length: ${words.label}
${forceLonger ? `WARNING: previous draft was TOO SHORT. Write at least ${words.min} words of real explanation — not a summary.` : ''}
Mandatory structure (all section headings and text in ${this.langName(lang)}):
1. Short intro (why this chapter matters)
2. Core definitions / concepts with <h2>/<h3>
3. At least one worked example (step by step)
4. Callouts: info definition + tip + danger (using HTML blocks below)
5. At least one math equation if the topic is mathematical
6. Closing: key takeaways list
7. For student/teacher: short exercises section
HTML blocks:
- Callout: <div data-type="callout-block" data-callout-type="info|tip|danger|warning|success"><p>...</p></div>
- Math: <div data-type="math-equation" data-latex="..."></div> (never $$)
- Toggle optional: <div data-type="toggle-block" data-opened="false"><p>title</p><p>body</p></div>
- table, ul/ol allowed
Return ONLY:
\`\`\`json
{
"title": "${title.replace(/"/g, '\\"')}",
"difficulty": "${difficulty}",
"content": "<h2>...</h2><p>...</p>..."
}
\`\`\`
content must be rich HTML, NOT empty, NOT a 2-paragraph summary.
Escape quotes inside JSON properly.`
const raw = await provider.generateText(prompt)
const parsed = this.parseJsonObject(raw)
const content = preprocessMathInHtml(String(parsed?.content || raw || '<p></p>'))
return {
title: String(parsed?.title || title).trim() || title,
content,
difficulty: (parsed?.difficulty as GeneratedNote['difficulty']) || (difficulty as GeneratedNote['difficulty']),
}
}
private parseJsonObject(raw: string): any {
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 {
return JSON.parse(jsonStr)
} catch {
try {
const fixed = jsonStr
.replace(/\\(?!["\\\/bfnrtu])/g, '\\\\')
.replace(/\n/g, '\\n')
return JSON.parse(fixed)
} catch {
return null
}
}
}
private deriveNotebookName(topic: string, notes: GeneratedNote[], lang: string): string {
const fromNote = notes[0]?.title?.trim()
if (fromNote && fromNote.length <= 80 && fromNote.length >= 3) {
return fromNote.replace(/^\d+[\.\):\-]\s*/, '').slice(0, 80)
}
const cleaned = topic
.replace(/\s+/g, ' ')
.trim()
.replace(/^(je veux|i want|i need|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' : lang === 'en' ? 'New notebook' : 'Notebook'
}
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
}
}
export const notebookWizardService = new NotebookWizardService()