304 lines
9.4 KiB
JavaScript
304 lines
9.4 KiB
JavaScript
const { googleAI } = require('@genkit-ai/googleai')
|
|
const { genkit, z } = require('genkit')
|
|
const { GEMINI_API_KEY } = require('../config/secrets')
|
|
const admin = require('firebase-admin')
|
|
const { Buffer } = require('buffer')
|
|
const { setTimeout } = require('timers/promises')
|
|
|
|
// --- CONFIGURATION ---
|
|
// Utilise des versions stables explicites en production. L'alias `latest` peut
|
|
// changer sans déploiement et pointer temporairement vers un modèle saturé.
|
|
const TEXT_MODEL_NAMES = ['gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.1-flash-lite']
|
|
const MODERATION_MODEL_NAMES = ['gemini-3.1-flash-lite', 'gemini-3.6-flash', 'gemini-3.5-flash']
|
|
const RETRYABLE_STATUS_CODES = new Set([404, 429, 500, 502, 503, 504])
|
|
|
|
const getErrorStatus = (error) => {
|
|
const status = Number(error?.status || error?.statusCode || error?.response?.status)
|
|
return Number.isFinite(status) ? status : null
|
|
}
|
|
|
|
const canTryFallbackModel = (error) => {
|
|
const status = getErrorStatus(error)
|
|
if (status && RETRYABLE_STATUS_CODES.has(status)) return true
|
|
|
|
const message = String(error?.message || '').toLowerCase()
|
|
return (
|
|
message.includes('high demand') ||
|
|
message.includes('overloaded') ||
|
|
message.includes('temporarily unavailable') ||
|
|
message.includes('model not found')
|
|
)
|
|
}
|
|
|
|
const generateWithModelFallback = async ({ ai, modelNames, label, request }) => {
|
|
let lastError = null
|
|
|
|
for (let index = 0; index < modelNames.length; index++) {
|
|
const modelName = modelNames[index]
|
|
|
|
try {
|
|
console.log(`🤖 [${label}] Modèle ${modelName} (${index + 1}/${modelNames.length})`)
|
|
const response = await ai.generate({
|
|
...request,
|
|
model: googleAI.model(modelName),
|
|
})
|
|
|
|
if (!response?.output) {
|
|
throw new Error(`${label}: réponse structurée vide pour ${modelName}.`)
|
|
}
|
|
|
|
return response.output
|
|
} catch (error) {
|
|
lastError = error
|
|
const hasFallback = index < modelNames.length - 1
|
|
|
|
if (!hasFallback || !canTryFallbackModel(error)) throw error
|
|
|
|
console.warn(`⚠️ [${label}] ${modelName} indisponible, essai du modèle suivant`, {
|
|
status: getErrorStatus(error),
|
|
message: error?.message,
|
|
})
|
|
await setTimeout(500 * (index + 1))
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error(`${label}: aucun modèle disponible.`)
|
|
}
|
|
const IMAGE_MODEL_NAME = 'gemini-3-pro-image'
|
|
|
|
// --- SINGLETON PATTERN (WARM START) ---
|
|
// On stocke l'instance en dehors de la fonction pour la réutiliser
|
|
// entre les invocations si le conteneur est "chaud".
|
|
let aiInstance = null
|
|
|
|
const getAiInstance = () => {
|
|
if (!aiInstance) {
|
|
console.log('⚡ [Gemini] Initialisation froide (Cold Start)')
|
|
aiInstance = genkit({
|
|
plugins: [googleAI({ apiKey: GEMINI_API_KEY.value() })],
|
|
})
|
|
}
|
|
return aiInstance
|
|
}
|
|
|
|
/**
|
|
* Génération de texte générique
|
|
*/
|
|
exports.generateAI = async ({ system = '', prompt = '', schema }) => {
|
|
const ai = getAiInstance() // Récupère l'instance singleton
|
|
|
|
if (prompt?.length < 1) {
|
|
throw new Error('Vous devez spécifier un prompt pour effectuer cette action.')
|
|
}
|
|
|
|
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAMES.join(' -> ')})`)
|
|
const startedAt = Date.now()
|
|
|
|
try {
|
|
return await generateWithModelFallback({
|
|
ai,
|
|
modelNames: TEXT_MODEL_NAMES,
|
|
label: 'generateAI',
|
|
request: {
|
|
system,
|
|
prompt,
|
|
output: { schema },
|
|
config: {
|
|
temperature: 0.7, // Créativité équilibrée
|
|
},
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('❌ [generateAI] Error:', error.message)
|
|
throw error
|
|
} finally {
|
|
console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Analyse la toxicité des paroles.
|
|
* Utilise gemini-1.5-flash-002 avec des réglages permissifs pour l'analyse.
|
|
*/
|
|
exports.analyseLyrics = async ({ title = '', lyrics }) => {
|
|
const ai = getAiInstance()
|
|
|
|
// --- Normalisation ---
|
|
const normalizeLyrics = (raw) => {
|
|
if (!raw) return ''
|
|
if (typeof raw === 'string') return raw
|
|
if (Array.isArray(raw)) {
|
|
return raw
|
|
.map((s) => {
|
|
if (!s) return ''
|
|
const label = s.type ? String(s.type).toUpperCase() : 'SECTION'
|
|
return `[${label}]\n${s.lyrics || ''}`
|
|
})
|
|
.filter(Boolean)
|
|
.join('\n\n')
|
|
}
|
|
if (typeof raw === 'object') {
|
|
const parts = []
|
|
if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`)
|
|
if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`)
|
|
return parts.join('\n\n')
|
|
}
|
|
return String(raw || '')
|
|
}
|
|
|
|
const lyricsText = normalizeLyrics(lyrics).trim()
|
|
if (!lyricsText) throw new Error('analyseLyrics: paroles requises.')
|
|
|
|
// --- Schéma ---
|
|
const moderationSchema = z.object({
|
|
title: z.string().describe('Titre analysé'),
|
|
flagged: z.boolean().describe('Vrai si le contenu nécessite un avertissement.'),
|
|
blocked: z.boolean().describe('Vrai UNIQUEMENT si violation grave (Haine, Violence réelle).'),
|
|
score: z.number().min(0).max(1).describe('Score de risque (0=Sûr, 1=Dangereux).'),
|
|
reasons: z.array(z.string()).describe('Liste concise des raisons.'),
|
|
excerpts: z
|
|
.array(
|
|
z.object({
|
|
quote: z.string(),
|
|
category: z.string(),
|
|
severity: z.string(),
|
|
})
|
|
)
|
|
.max(10),
|
|
success: z.boolean(),
|
|
})
|
|
|
|
// --- Prompt ---
|
|
const system = `Tu es un Expert en Modération de Contenu Musical (Trust & Safety).
|
|
TA MISSION : Distinguer l'expression artistique (même crue/vulgaire) du contenu réellement dangereux.
|
|
|
|
1. "FLAGGED" (Avertissement) : Vulgarités, thèmes matures, drogue, sexe consensuel.
|
|
2. "BLOCKED" (Interdit) : Discours de haine, harcèlement ciblé, pédopornographie, incitation explicite violence/suicide.
|
|
|
|
Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`
|
|
|
|
const userPrompt = `
|
|
ANALYSE CETTE CHANSON :
|
|
Titre : ${title || 'Inconnu'}
|
|
|
|
PAROLES :
|
|
"""
|
|
${lyricsText}
|
|
"""
|
|
`
|
|
|
|
console.log(`🛡️ [analyseLyrics] Start (${MODERATION_MODEL_NAMES.join(' -> ')})`)
|
|
const startedAt = Date.now()
|
|
|
|
try {
|
|
const output = await generateWithModelFallback({
|
|
ai,
|
|
modelNames: MODERATION_MODEL_NAMES,
|
|
label: 'analyseLyrics',
|
|
request: {
|
|
system,
|
|
prompt: userPrompt,
|
|
output: { schema: moderationSchema },
|
|
config: {
|
|
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
|
safetySettings: [
|
|
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
|
|
{
|
|
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
|
threshold: 'BLOCK_NONE',
|
|
},
|
|
{
|
|
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
|
threshold: 'BLOCK_NONE',
|
|
},
|
|
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
|
|
],
|
|
},
|
|
},
|
|
})
|
|
|
|
// Correction de cohérence
|
|
if (output.blocked) {
|
|
output.flagged = true
|
|
if (output.score < 0.7) output.score = 0.85
|
|
}
|
|
|
|
return output
|
|
} finally {
|
|
console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Génération d'image via Imagen 3
|
|
*/
|
|
exports.generateImageV2 = async (prompt, size = 1024, path = '') => {
|
|
const ai = getAiInstance()
|
|
|
|
if (typeof prompt !== 'string' || prompt.trim().length < 1) {
|
|
throw new Error('Prompt requis.')
|
|
}
|
|
|
|
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`)
|
|
const startedAt = Date.now()
|
|
|
|
// Optimisation du prompt pour Imagen
|
|
let enhancedPrompt = prompt.trim()
|
|
if (!enhancedPrompt.toLowerCase().includes('high quality')) {
|
|
enhancedPrompt += ', high quality, detailed, 4k'
|
|
}
|
|
// Aspect ratio 1:1 pour les pochettes
|
|
enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`
|
|
|
|
const maxAttempts = 3
|
|
let lastError = null
|
|
|
|
try {
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
try {
|
|
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`)
|
|
|
|
const response = await ai.generate({
|
|
model: googleAI.model(IMAGE_MODEL_NAME),
|
|
prompt: enhancedPrompt,
|
|
})
|
|
|
|
const media = response.media
|
|
|
|
if (media && media.url) {
|
|
console.log('✅ Image générée.')
|
|
|
|
// --- Sauvegarde dans Firebase Storage ---
|
|
const dataUrl = String(media.url)
|
|
const commaIdx = dataUrl.indexOf(',')
|
|
const b64 = commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl
|
|
const buffer = Buffer.from(b64, 'base64')
|
|
|
|
const bucket = admin.storage().bucket()
|
|
const token = require('crypto').randomUUID()
|
|
const file = bucket.file(path)
|
|
|
|
await file.save(buffer, {
|
|
resumable: false,
|
|
metadata: {
|
|
contentType: media.contentType || 'image/png',
|
|
metadata: { firebaseStorageDownloadTokens: token },
|
|
},
|
|
})
|
|
|
|
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`
|
|
}
|
|
|
|
throw new Error('Pas de média dans la réponse IA.')
|
|
} catch (err) {
|
|
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message)
|
|
lastError = err
|
|
if (attempt < maxAttempts) await setTimeout(2000 * attempt)
|
|
}
|
|
}
|
|
throw new Error(`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`)
|
|
} finally {
|
|
console.log(`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`)
|
|
}
|
|
}
|