fix: images, couverture, slash, agents, wizard, Stripe et admin

- Collage/accès images: ownership path userId + meta legacy, 403 non caché
- Couverture note: explicite (choix/aucune), plus d’auto depuis le corps
- Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée
- Slash: listes par titre (plus d’index), keywords nettoyés
- Agents: nextRun à la réactivation, cron sans stampede null
- Wizard: titres courts + mode Expert plus robuste
- Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings
- Indexation notes courtes + test unit aligné
This commit is contained in:
Antigravity
2026-07-16 16:58:07 +00:00
parent 4fe31ebc99
commit 45297da333
27 changed files with 1302 additions and 412 deletions

View File

@@ -19,6 +19,7 @@
* - Pas de race condition (verrou par noteId)
*/
import { createHash } from 'crypto'
import PQueue from 'p-queue'
import { prisma } from '@/lib/prisma'
import { embeddingService } from './embedding.service'
@@ -78,7 +79,16 @@ export class ChunkIndexingService {
): Promise<IndexResult> {
const start = Date.now()
const plain = prepareNoteTextForEmbedding(title, content)
const newChunks = chunkNoteContent(noteId, plain)
// Index even very short notes (title-only or one-liners)
let newChunks = chunkNoteContent(noteId, plain)
if (newChunks.length === 0 && plain.trim().length > 0) {
// Force a single fragment when paragraph split yields nothing
const fragmentId = createHash('sha256')
.update(`${noteId}::${plain}`)
.digest('hex')
.slice(0, 32)
newChunks = [{ fragmentId, content: plain, chunkIndex: 0, charCount: plain.length }]
}
const newFragmentIds = new Set(newChunks.map((c) => c.fragmentId))
const existing = await prisma.noteEmbeddingChunk.findMany({

View File

@@ -9,12 +9,29 @@ export interface GeneratedNote {
}
export interface GeneratedCarnet {
/** Short notebook title (not the raw user prompt). */
notebookName?: string
notes: GeneratedNote[]
schemaProperties: Array<{ name: string; type: string; options?: string[] }>
}
export type WizardProfile = 'student' | 'teacher' | 'engineer'
/** Word-count targets by level — Expert was failing (timeouts / truncated JSON). */
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)' }
}
if (/intermédiaire|intermediate|moyen/.test(l)) {
return { min: 250, max: 500, label: 'intermédiaire (250500 mots/note)' }
}
if (/débutant|beginner|facile|easy/.test(l)) {
return { min: 150, max: 350, label: 'débutant (150350 mots/note)' }
}
return { min: 200, max: 450, label: 'standard (200450 mots/note)' }
}
export class NotebookWizardService {
async generateCarnet(
profile: WizardProfile,
@@ -22,14 +39,52 @@ export class NotebookWizardService {
options?: { level?: string; count?: number; language?: string }
): Promise<GeneratedCarnet> {
const lang = options?.language || 'fr'
const count = options?.count || 6
// Cap count in expert mode to avoid oversized responses
const level = options?.level
const isExpert = /expert/i.test(level || '')
const count = Math.min(options?.count || 6, isExpert ? 5 : 10)
const prompts = this.buildPrompt(profile, topic, lang, count, options?.level)
const prompts = this.buildPrompt(profile, topic, lang, count, level)
const config = await getSystemConfig()
const provider = getChatProvider(config)
const raw = await provider.generateText(prompts)
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
}
}
return this.parseResponse(raw, profile, lang)
const result = this.parseResponse(raw, profile, lang)
if (!result.notebookName?.trim()) {
result.notebookName = this.deriveNotebookName(topic, result.notes, lang)
}
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, '')
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(
@@ -41,6 +96,7 @@ export class NotebookWizardService {
): 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.`,
@@ -50,31 +106,36 @@ export class NotebookWizardService {
return `Tu es un expert pédagogue et créateur de contenu de haut niveau. ${profileContext}
Sujet : "${topic}"
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 ET DÉTAILLÉES sur ce sujet. Chaque note doit faire ENTRE 800 ET 1500 MOTS minimum. C'est non négociable.
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 :
- 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' : ''}
- 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 :
FORMAT DE SORTIE — JSON UNIQUEMENT (pas de markdown hors du bloc) :
\`\`\`json
{
"notebookName": "Titre court du carnet",
"notes": [
{
"title": "Titre détaillé et descriptif",
"title": "Titre de chapitre descriptif",
"difficulty": "facile",
"content": "<h2>Introduction</h2><p>Plusieurs paragraphes détaillés...</p>..."
"content": "<h2>Introduction</h2><p>...</p>..."
}
],
"schemaProperties": [
@@ -84,31 +145,17 @@ FORMAT DE SORTIE — JSON UNIQUEMENT :
}
\`\`\`
BLOCS HTML DISPONIBLES — UTILISE-LES ABONDAMMENT :
BLOCS HTML DISPONIBLES :
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
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>
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.`
Les "difficulty" doivent varier : facile/moyen/difficile.
Réponds UNIQUEMENT avec le JSON valide (échappement correct des guillemets dans le HTML).`
}
private getSchemaLabels(lang: string) {
@@ -193,7 +240,11 @@ Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
{ name: defaultLabels.difficulty, type: 'select', options: [defaultLabels.easy, defaultLabels.medium, defaultLabels.hard] },
]
return { notes, schemaProperties }
const notebookName = typeof parsed.notebookName === 'string'
? parsed.notebookName.trim().slice(0, 80)
: undefined
return { notebookName, notes, schemaProperties }
}
}