Files
Momento/memento-note/lib/ai/services/notebook-wizard.service.ts
Antigravity 96e7902f01
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped
feat: publication IA (magazine/brief/essay) + fixes critique
Publication IA:
- 4 templates (magazine, brief, essay, simple) avec CSS riche
- Rewrite IA (article/exercises/tutorial/reference/mixed)
- Modération avec timeout 12s + fallback safe
- Quotas publish_enhance par tier (basic=2, pro=15, business=100)
- Détection contenu stale (hash)
- Migration DB publishedContent/publishedTemplate/publishedSourceHash

Fixes:
- cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast
- _isShared ajouté au type Note (champ virtuel serveur)
- callout colors PDF export: extraction fonction pure testable
- admin/published: guard note.userId null
- Cmd+S fonctionne en mode dialog (pas seulement fullPage)

i18n:
- 23 clés publish* traduites dans les 15 locales
- Extension Web Clipper: 13 locales mise à jour

Tests:
- callout-colors.test.ts (6 tests)
- note-visible-in-view.test.ts (5 tests)
- entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs
- 199/199 tests passent

Tracker: user-stories.md sync avec sprint-status.yaml
2026-06-28 07:32:57 +00:00

201 lines
11 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 {
notes: GeneratedNote[]
schemaProperties: Array<{ name: string; type: string; options?: string[] }>
}
export type WizardProfile = 'student' | 'teacher' | 'engineer'
export class NotebookWizardService {
async generateCarnet(
profile: WizardProfile,
topic: string,
options?: { level?: string; count?: number; language?: string }
): Promise<GeneratedCarnet> {
const lang = options?.language || 'fr'
const count = options?.count || 6
const prompts = this.buildPrompt(profile, topic, lang, count, options?.level)
const config = await getSystemConfig()
const provider = getChatProvider(config)
const raw = await provider.generateText(prompts)
return this.parseResponse(raw, profile, lang)
}
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 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 : "${topic}"
${level ? `Niveau : ${level}` : ''}
Langue : ${langName}
Nombre de notes à créer : ${count}
CRÉE ${count} NOTES COMPLÈTES ET DÉTAILLÉES sur ce sujet. Chaque note doit faire ENTRE 800 ET 1500 MOTS minimum. C'est non négociable.
Le contenu doit être :
- COMPLET et APPROFONDI — pas de résumé superficiel
- Structuré avec des titres <h2> et <h3> pour les sections
- Des paragraphes développés avec des explications détaillées
- Des exemples concrets et des cas d'usage
- Des définitions précises dans des encadrés callout
${profile === 'student' ? '- Des "Points clés à retenir" en callout tip\n- Des "Pièges à éviter" en callout danger\n- Des exemples d\'application' : ''}
${profile === 'teacher' ? '- Des objectifs pédagogiques en début de note\n- Des sections "Exercices" avec 5 questions détaillées\n- Des corrigés indicatifs' : ''}
${profile === 'engineer' ? '- Des spécifications techniques précises\n- Des tableaux comparatifs\n- Des références aux normes et standards' : ''}
FORMAT DE SORTIE — JSON UNIQUEMENT :
\`\`\`json
{
"notes": [
{
"title": "Titre détaillé et descriptif",
"difficulty": "facile",
"content": "<h2>Introduction</h2><p>Plusieurs paragraphes détaillés...</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 — UTILISE-LES ABONDAMMENT :
1. **Callout** (encadré coloré) :
<div data-type="callout-block" data-callout-type="info"><p>Définition importante...</p></div>
Types : info, warning, tip, success, danger
2. **Toggle** (section repliable pour détails) :
<div data-type="toggle-block" data-opened="true"><p>Cliquer pour voir les détails</p><p>Contenu détaillé...</p></div>
3. **Math** (formules LaTeX) — UTILISE LES BALISES DIRECTEMENT, pas de $$ :
Block : <div data-type="math-equation" data-latex="E = mc^2"></div>
Inline : <span data-type="inline-math" data-latex="x^2">x^2</span>
N'UTILISE JAMAIS $$ ou $ comme délimiteur — utilise TOUJOURS les balises HTML ci-dessus.
4. **Colonnes** (comparaison côte à côte) :
<div data-type="columns" cols="2"><div data-type="column" index="0"><p>Concept A...</p></div><div data-type="column" index="1"><p>Concept B...</p></div></div>
5. **Sommaire** (début de note) :
<div data-type="outline-block"></div>
6. HTML standard : <h1>/<h2>/<h3>, <p>, <ul>/<ol>/<li>, <table>, <blockquote>, <pre><code>
IMPORTANT : Chaque note DOIT faire entre 800 et 1500 mots. Sois exhaustif. Développe chaque concept avec des exemples, des explications, et du contexte. N'écris pas de résumés courts.
Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
}
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] },
]
return { notes, schemaProperties }
}
}
export const notebookWizardService = new NotebookWizardService()