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 = { 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 { 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 { 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 }, 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 }, 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 { 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

/

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:

...

- Math:
(never $$) - Toggle optional:

title

body

- table, ul/ol allowed Return ONLY: \`\`\`json { "title": "${title.replace(/"/g, '\\"')}", "difficulty": "${difficulty}", "content": "

...

...

..." } \`\`\` 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 || '

')) 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 = { 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()