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
150 lines
5.4 KiB
TypeScript
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(/ /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(/ /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()
|