Files
Momento/memento-note/lib/ai/services/publish-enhance.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

150 lines
5.4 KiB
TypeScript

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()