feat: publication IA (magazine/brief/essay) + fixes critique
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped

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
This commit is contained in:
Antigravity
2026-06-28 07:32:57 +00:00
parent 902fe95a69
commit 96e7902f01
169 changed files with 5382 additions and 1527 deletions

View File

@@ -0,0 +1,102 @@
import { getChatProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
export interface NoteAnalysisResult {
noteId: string
include: boolean
reason: string
}
export interface NotebookAnalysis {
notes: NoteAnalysisResult[]
description: string
}
export class NotebookPublishAnalyzerService {
async analyze(
notebookName: string,
notes: Array<{ id: string; title: string; wordCount: number; preview: string }>,
language: string,
): Promise<NotebookAnalysis> {
const lang = language || 'fr'
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang
const notesList = notes.map((n, i) =>
`${i + 1}. [${n.id}] "${n.title || 'Sans titre'}" — ${n.wordCount} mots — "${n.preview}"`
).join('\n')
const prompt = `Tu es un éditeur web expert. Analyse ce carnet de notes "${notebookName}" et décide quelles notes méritent d'être publiées sur un site web professionnel.
NOTES DU CARNET :
${notesList}
CRITÈRES D'INCLUSION :
- Note complète (>150 mots), structurée, sans fautes évidentes
- Contenu utile pour un lecteur externe (pas juste des notes personnelles)
- Titre clair et représentatif du contenu
CRITÈRES D'EXCLUSION :
- Brouillon ou note vide (<50 mots)
- Contenu incomplet ("à compléter", "TODO", "...")
- Note purement personnelle ou de travail interne
Réponds en JSON strict :
{
"description": "Résumé du carnet en 1-2 phrases percutantes en ${langName}",
"notes": [
{ "noteId": "id_ici", "include": true, "reason": "Courte justification en ${langName}" }
]
}
Inclus toutes les notes dans le tableau "notes", même celles exclues.`
const config = await getSystemConfig()
const provider = getChatProvider(config)
const raw = await provider.generateText(prompt)
return this.parseResponse(raw, notes)
}
private parseResponse(
raw: string,
notes: Array<{ id: string }>,
): NotebookAnalysis {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
const s = jsonStr.indexOf('{')
const e = jsonStr.lastIndexOf('}')
if (s >= 0 && e > s) jsonStr = jsonStr.slice(s, e + 1)
try {
const parsed = JSON.parse(jsonStr)
const noteIds = new Set(notes.map(n => n.id))
const analysisNotes: NoteAnalysisResult[] = (parsed.notes || [])
.filter((n: any) => noteIds.has(n.noteId))
.map((n: any) => ({
noteId: String(n.noteId),
include: Boolean(n.include),
reason: String(n.reason || ''),
}))
// Include any notes missing from AI response with include=true as default
const analyzedIds = new Set(analysisNotes.map(n => n.noteId))
for (const note of notes) {
if (!analyzedIds.has(note.id)) {
analysisNotes.push({ noteId: note.id, include: true, reason: '' })
}
}
return {
notes: analysisNotes,
description: String(parsed.description || ''),
}
} catch {
return {
notes: notes.map(n => ({ noteId: n.id, include: true, reason: '' })),
description: '',
}
}
}
}
export const notebookPublishAnalyzerService = new NotebookPublishAnalyzerService()

View File

@@ -29,7 +29,7 @@ export class NotebookWizardService {
const provider = getChatProvider(config)
const raw = await provider.generateText(prompts)
return this.parseResponse(raw, profile)
return this.parseResponse(raw, profile, lang)
}
private buildPrompt(
@@ -39,8 +39,9 @@ export class NotebookWizardService {
count: number,
level?: string
): string {
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang
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.`,
@@ -77,8 +78,8 @@ FORMAT DE SORTIE — JSON UNIQUEMENT :
}
],
"schemaProperties": [
{ "name": "Statut", "type": "select", "options": ["À réviser", "En cours", "Maîtrisé"] },
{ "name": "Difficulté", "type": "select", "options": ["Facile", "Moyen", "Difficile"] }
{ "name": "${schemaLabels.status}", "type": "select", "options": ["${schemaLabels.statusTodo}", "${schemaLabels.statusInProgress}", "${schemaLabels.statusDone}"] },
{ "name": "${schemaLabels.difficulty}", "type": "select", "options": ["${schemaLabels.easy}", "${schemaLabels.medium}", "${schemaLabels.hard}"] }
]
}
\`\`\`
@@ -110,7 +111,28 @@ IMPORTANT : Chaque note DOIT faire entre 800 et 1500 mots. Sois exhaustif. Déve
Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
}
private parseResponse(raw: string, profile: WizardProfile): GeneratedCarnet {
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
@@ -145,11 +167,12 @@ Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
})
}
if (notes.length === 0) throw new Error('No notes found in response')
const fallbackLabels = this.getSchemaLabels(lang || 'fr')
return {
notes,
schemaProperties: [
{ name: 'Statut', type: 'select', options: ['À réviser', 'En cours', 'Maîtrisé'] },
{ name: 'Difficulté', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] },
{ 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 {
@@ -164,9 +187,10 @@ Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
difficulty: n.difficulty || undefined,
}))
const defaultLabels = this.getSchemaLabels(lang || 'fr')
const schemaProperties = parsed.schemaProperties || [
{ name: 'Statut', type: 'select', options: ['À réviser', 'En cours', 'Maîtrisé'] },
{ name: 'Difficulté', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] },
{ 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 }

View File

@@ -0,0 +1,149 @@
import { getChatProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
import { extractPublishImageUrls } from '@/lib/publish/process-note-html'
import { transformEditorBlocksForPublish } from '@/lib/publish/transform-editor-blocks'
import type { PublishEnhanceSpec, PublishRewriteSpec, PublishTemplateId } from '@/lib/publish/types'
const TEMPLATE_HINTS: Record<PublishTemplateId, string> = {
magazine: `Chapô accrocheur + une citation mise en avant (pullQuote). Style journalistique.`,
brief: `Résumé exécutif dense + 3 à 5 points clés (keyPoints). Ton professionnel actionnable.`,
essay: `Épigraphe inspirante + chapô réfléchi. Ton littéraire mais clair.`,
}
/* ─── MODE ÉDITORIAL (pas de réécriture) ─────────────────────────────────── */
export class PublishEnhanceService {
async enhance(
title: string,
noteContent: string,
template: PublishTemplateId,
language: string,
): Promise<PublishEnhanceSpec> {
const imageUrls = extractPublishImageUrls(noteContent)
const plainText = noteContent
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 6000)
const langName = language === 'fr' ? 'français' : language === 'fa' ? 'فارسی' : 'English'
const imageHint = imageUrls.length > 0
? `La note contient ${imageUrls.length} image(s) — elles sont affichées automatiquement.`
: ''
const prompt = `Tu es un éditeur web. Rédige UNIQUEMENT l'habillage éditorial d'une page publique.
TITRE : "${title || 'Sans titre'}"
LANGUE : ${langName}
STYLE : ${TEMPLATE_HINTS[template]}
${imageHint}
CONTENU (pour contexte) :
${plainText}
Réponds en JSON strict, sans markdown :
{
"summary": "chapô 2-3 phrases",
"pullQuote": "phrase marquante (magazine)",
"epigraph": "ouverture courte (essay)",
"keyPoints": ["point 1", "point 2", "point 3"]
}`
const config = await getSystemConfig()
const provider = getChatProvider(config)
const raw = await provider.generateText(prompt)
return this.parseEnhanceResponse(raw, plainText)
}
private parseEnhanceResponse(raw: string, fallback: string): PublishEnhanceSpec {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
const s = jsonStr.indexOf('{')
const e = jsonStr.lastIndexOf('}')
if (s >= 0 && e > s) jsonStr = jsonStr.slice(s, e + 1)
try {
const p = JSON.parse(jsonStr)
return {
summary: String(p.summary || '').trim() || fallback.slice(0, 280),
pullQuote: p.pullQuote ? String(p.pullQuote).trim() : undefined,
epigraph: p.epigraph ? String(p.epigraph).trim() : undefined,
keyPoints: Array.isArray(p.keyPoints)
? p.keyPoints.map((k: unknown) => String(k).trim()).filter(Boolean).slice(0, 6)
: undefined,
}
} catch {
return { summary: fallback.slice(0, 280) }
}
}
/* ─── MODE RÉÉCRITURE WEB ─────────────────────────────────────────────── */
async rewrite(
title: string,
noteContent: string,
template: PublishTemplateId,
language: string,
): Promise<PublishRewriteSpec> {
const langName = language === 'fr' ? 'français' : language === 'fa' ? 'فارسی' : 'English'
const plainText = noteContent
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 8000)
// Corps = transformation déterministe du HTML éditeur (exercices, toggles, callouts…)
const structuredBody = transformEditorBlocksForPublish(noteContent)
const prompt = `Tu es un éditeur web. Analyse ce contenu de note et rédige UNIQUEMENT l'habillage éditorial.
Langue : ${langName}
Titre : "${title || 'Sans titre'}"
Style : ${TEMPLATE_HINTS[template]}
Le corps HTML est déjà structuré (exercices, définitions, toggles) — tu ne réécris PAS le corps.
CONTENU (texte) :
${plainText}
Réponds en JSON strict, sans markdown :
{
"contentType": "article|exercises|tutorial|reference|mixed",
"summary": "chapô 1-2 phrases percutantes"
}`
const config = await getSystemConfig()
const provider = getChatProvider(config)
const raw = await provider.generateText(prompt)
return this.parseRewriteResponse(raw, title, plainText, structuredBody)
}
private parseRewriteResponse(
raw: string,
title: string,
fallback: string,
structuredBody: string,
): PublishRewriteSpec {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
const s = jsonStr.indexOf('{')
const e = jsonStr.lastIndexOf('}')
if (s >= 0 && e > s) jsonStr = jsonStr.slice(s, e + 1)
try {
const p = JSON.parse(jsonStr)
return {
contentType: ['article', 'exercises', 'tutorial', 'reference', 'mixed'].includes(p.contentType)
? p.contentType
: 'article',
summary: String(p.summary || '').trim() || '',
body: structuredBody || `<p>${fallback.slice(0, 500)}</p>`,
}
} catch {
return {
contentType: 'article',
summary: '',
body: structuredBody || `<h2>${title}</h2><p>${fallback.slice(0, 600)}</p>`,
}
}
}
}
export const publishEnhanceService = new PublishEnhanceService()

View File

@@ -66,13 +66,14 @@ Chaque jour doit avoir 1-4 notes à réviser avec une activité descriptive.`
const provider = getChatProvider(config)
const raw = await provider.generateText(prompt)
return this.parseResponse(raw, notes, daysUntilExam)
return this.parseResponse(raw, notes, daysUntilExam, language)
}
private parseResponse(
raw: string,
notes: Array<{ id: string; title: string }>,
totalDays: number
totalDays: number,
language?: string
): StudyPlan {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
@@ -101,6 +102,9 @@ Chaque jour doit avoir 1-4 notes à réviser avec une activité descriptive.`
}
} catch {
// Fallback: simple distribution
const lang = language || 'fr'
const firstReadLabel = lang === 'fa' ? 'اولین مطالعه' : lang === 'ar' ? 'القراءة الأولى' : lang === 'de' ? 'Erste Lektüre' : lang === 'es' ? 'Primera lectura' : lang === 'it' ? 'Prima lettura' : lang === 'pt' ? 'Primeira leitura' : lang === 'ru' ? 'Первое чтение' : lang === 'zh' ? '第一次阅读' : lang === 'ja' ? '最初の読み' : lang === 'ko' ? '첫 번째 읽기' : lang === 'nl' ? 'Eerste lezing' : lang === 'pl' ? 'Pierwsze czytanie' : lang === 'hi' ? 'पहली पढ़ाई' : lang === 'fr' ? 'Première lecture' : 'First reading'
const reviewLabel = (n: number) => lang === 'fa' ? `مرور ${n} یادداشت` : lang === 'ar' ? `مراجعة ${n} ملاحظات` : lang === 'de' ? `${n} Notizen wiederholen` : lang === 'es' ? `Repasar ${n} notas` : lang === 'it' ? `Ripassa ${n} note` : lang === 'pt' ? `Rever ${n} notas` : lang === 'ru' ? `Повторить ${n} заметок` : lang === 'zh' ? `复习 ${n} 条笔记` : lang === 'ja' ? `${n}件のノートを復習` : lang === 'ko' ? `${n}개 노트 복습` : lang === 'nl' ? `${n} notities herzien` : lang === 'pl' ? `Powtórz ${n} notatek` : lang === 'hi' ? `${n} नोट्स दोहराएं` : lang === 'fr' ? `Revoir ${n} notes` : `Review ${n} notes`
const days: StudyDay[] = []
const today = new Date()
const notesPerDay = Math.max(1, Math.ceil(notes.length / Math.min(totalDays, 14)))
@@ -113,7 +117,7 @@ Chaque jour doit avoir 1-4 notes à réviser avec une activité descriptive.`
date: date.toISOString().slice(0, 10),
noteIds: dayNotes.map(n => n.id),
noteTitles: dayNotes.map(n => n.title),
activity: i === 0 ? 'Première lecture' : `Revoir ${dayNotes.length} notes`,
activity: i === 0 ? firstReadLabel : reviewLabel(dayNotes.length),
})
}