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 }
}
}

View File

@@ -20,14 +20,26 @@ export async function deleteImageFileSafely(imageUrl: string, excludeNoteId?: st
try {
const notes = await prisma.note.findMany({
where: { images: { contains: imageUrl } },
where: {
OR: [
{ images: { contains: imageUrl } },
{ content: { contains: imageUrl } },
],
},
select: { id: true },
})
const otherRefs = notes.filter(n => n.id !== excludeNoteId)
if (otherRefs.length > 0) return // File still referenced elsewhere
const filePath = path.join(process.cwd(), 'data', imageUrl)
// imageUrl = /uploads/notes/... → data/uploads/notes/...
const filePath = path.join(process.cwd(), 'data', imageUrl.replace(/^\//, ''))
await fs.unlink(filePath)
// Remove ownership sidecar if present
try {
await fs.unlink(filePath + '.meta.json')
} catch {
/* no meta */
}
} catch {
// File already gone or unreadable -- silently skip
}

View File

@@ -14,7 +14,8 @@ import { createHash } from 'crypto'
const CHUNK_TARGET_CHARS = 1000
const CHUNK_OVERLAP_CHARS = 200
const MIN_FRAGMENT_CHARS = 10
/** Allow short notes (flashcards, reminders, one-liners) to be indexed. */
const MIN_FRAGMENT_CHARS = 1
const MAX_PARAGRAPH_BEFORE_SPLIT = 1500
export interface NoteChunk {

View File

@@ -1,37 +1,118 @@
import { readFile } from 'fs/promises'
import path from 'path'
import { prisma } from './prisma'
/** Whether a note image upload may be served to the current viewer. */
const NOTES_UPLOAD_ROOT = path.join(process.cwd(), 'data', 'uploads', 'notes')
export type UploadMeta = {
userId: string
createdAt: number
}
/**
* Canonical URL form:
* /uploads/notes/{userId}/{filename} ← preferred (ownership in path)
* Legacy:
* /uploads/notes/{filename} ← meta sidecar or note content
*/
export function uploadMetaPathForRelative(relativeUnderNotes: string): string {
// relativeUnderNotes e.g. "userId/uuid.png" or "uuid.png"
return path.join(NOTES_UPLOAD_ROOT, `${relativeUnderNotes}.meta.json`)
}
export async function writeUploadMeta(relativeUnderNotes: string, userId: string): Promise<void> {
const { writeFile, mkdir } = await import('fs/promises')
const full = uploadMetaPathForRelative(relativeUnderNotes)
await mkdir(path.dirname(full), { recursive: true })
const meta: UploadMeta = { userId, createdAt: Date.now() }
await writeFile(full, JSON.stringify(meta), 'utf8')
}
async function readUploadMeta(relativeUnderNotes: string): Promise<UploadMeta | null> {
try {
const raw = await readFile(uploadMetaPathForRelative(relativeUnderNotes), 'utf8')
const parsed = JSON.parse(raw) as UploadMeta
if (parsed?.userId && typeof parsed.userId === 'string') return parsed
return null
} catch {
return null
}
}
/**
* @param pathSegments path after `/uploads/` e.g. `['notes', userId, 'file.png']` or `['notes', 'file.png']`
*/
export async function canAccessUploadedNoteImage(
filename: string,
pathSegments: string[],
userId: string | null | undefined,
): Promise<boolean> {
if (pathSegments.length < 2 || pathSegments[0] !== 'notes') return false
// notes/{userId}/{filename}
if (pathSegments.length >= 3) {
const ownerId = pathSegments[1]
const filename = pathSegments[pathSegments.length - 1]
const relative = pathSegments.slice(1).join('/') // userId/file.png
const imagePath = `/uploads/notes/${relative}`
// 1) Path ownership — definitive for new uploads
if (userId && ownerId === userId) return true
// 2) Meta (redundant but safe)
if (userId) {
const meta = await readUploadMeta(relative)
if (meta?.userId === userId) return true
}
// 3) Public note embeds this URL
const published = await noteContainsImage({ imagePath, filename, isPublic: true })
if (published) return true
if (!userId) return false
// 4) Private note owned by user embeds this URL
return noteContainsImage({ imagePath, filename, userId })
}
// Legacy: notes/{filename}
const filename = pathSegments[1]
const imagePath = `/uploads/notes/${filename}`
const published = await prisma.note.findFirst({
where: {
isPublic: true,
trashedAt: null,
OR: [
{ content: { contains: imagePath } },
{ images: { contains: filename } },
],
},
select: { id: true },
})
if (userId) {
const meta = await readUploadMeta(filename)
if (meta?.userId === userId) return true
}
const published = await noteContainsImage({ imagePath, filename, isPublic: true })
if (published) return true
if (!userId) return false
return noteContainsImage({ imagePath, filename, userId })
}
const owned = await prisma.note.findFirst({
where: {
userId,
trashedAt: null,
OR: [
{ content: { contains: imagePath } },
{ images: { contains: filename } },
],
},
async function noteContainsImage(opts: {
imagePath: string
filename: string
userId?: string
isPublic?: boolean
}): Promise<boolean> {
// Match full URL only (not bare filename) to avoid accidental cross-access
const where: Record<string, unknown> = {
trashedAt: null,
OR: [
{ content: { contains: opts.imagePath } },
{ images: { contains: opts.imagePath } },
// images JSON sometimes stored without leading path prefix
{ images: { contains: opts.filename } },
],
}
if (opts.isPublic) where.isPublic = true
if (opts.userId) where.userId = opts.userId
const row = await prisma.note.findFirst({
where,
select: { id: true },
})
return !!owned
return !!row
}