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:
@@ -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({
|
||||
|
||||
@@ -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 (350–700 mots/note — dense mais JSON-fiable)' }
|
||||
}
|
||||
if (/intermédiaire|intermediate|moyen/.test(l)) {
|
||||
return { min: 250, max: 500, label: 'intermédiaire (250–500 mots/note)' }
|
||||
}
|
||||
if (/débutant|beginner|facile|easy/.test(l)) {
|
||||
return { min: 150, max: 350, label: 'débutant (150–350 mots/note)' }
|
||||
}
|
||||
return { min: 200, max: 450, label: 'standard (200–450 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 (3–8 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 (3–5 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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user