- 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
391 lines
16 KiB
TypeScript
391 lines
16 KiB
TypeScript
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 (400–800 words per note)' }
|
||
}
|
||
if (/intermédiaire|intermediate|moyen/.test(l)) {
|
||
return { min: 300, max: 550, label: 'intermediate (300–550 words per note)' }
|
||
}
|
||
if (/débutant|beginner|facile|easy/.test(l)) {
|
||
return { min: 250, max: 450, label: 'beginner (250–450 words per note)' }
|
||
}
|
||
return { min: 300, max: 550, label: 'standard (300–550 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": "3–8 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, 4–5 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()
|