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
This commit is contained in:
Antigravity
2026-07-16 17:25:57 +00:00
parent 45297da333
commit 704fed1191
11 changed files with 672 additions and 210 deletions

View File

@@ -1,11 +1,12 @@
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'
difficulty?: 'facile' | 'moyen' | 'difficile' | 'easy' | 'medium' | 'hard'
}
export interface GeneratedCarnet {
@@ -13,149 +14,355 @@ export interface GeneratedCarnet {
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'
/** Word-count targets by level — Expert was failing (timeouts / truncated JSON). */
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: 350, max: 700, label: 'expert (350700 mots/note — dense mais JSON-fiable)' }
return { min: 400, max: 800, label: 'expert (400800 words per note)' }
}
if (/intermédiaire|intermediate|moyen/.test(l)) {
return { min: 250, max: 500, label: 'intermédiaire (250500 mots/note)' }
return { min: 300, max: 550, label: 'intermediate (300550 words per note)' }
}
if (/débutant|beginner|facile|easy/.test(l)) {
return { min: 150, max: 350, label: 'débutant (150350 mots/note)' }
return { min: 250, max: 450, label: 'beginner (250450 words per note)' }
}
return { min: 200, max: 450, label: 'standard (200450 mots/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 lang = options?.language || 'fr'
// Cap count in expert mode to avoid oversized responses
const uiLang = options?.language || 'fr'
const contentLang = await this.resolveOutputLanguage(topic, uiLang)
const level = options?.level
const isExpert = /expert/i.test(level || '')
const count = Math.min(options?.count || 6, isExpert ? 5 : 10)
// Fewer notes + richer content (quality over stuffing one JSON)
const count = Math.min(options?.count || 6, isExpert ? 5 : 6)
const words = wordTargetsForLevel(level)
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
// 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)
}
const result = this.parseResponse(raw, profile, lang)
if (!result.notebookName?.trim()) {
result.notebookName = this.deriveNotebookName(topic, result.notes, lang)
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
}
}
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, '')
.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' : '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).`
return lang === 'fr' ? 'Nouveau carnet' : lang === 'en' ? 'New notebook' : 'Notebook'
}
private getSchemaLabels(lang: string) {
@@ -178,74 +385,6 @@ Réponds UNIQUEMENT avec le JSON valide (échappement correct des guillemets dan
}
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()