filter on lyrics for bloking unwanted content, tickets fix
This commit is contained in:
+139
-1
@@ -1,5 +1,5 @@
|
||||
const { googleAI } = require("@genkit-ai/googleai");
|
||||
const { genkit } = require("genkit");
|
||||
const { genkit, z } = require("genkit");
|
||||
const { GEMINI_API_KEY } = require("../config/keys");
|
||||
const admin = require("firebase-admin");
|
||||
const { Buffer } = require("buffer");
|
||||
@@ -26,6 +26,144 @@ exports.generateAI = async ({ system = "", prompt = "", schema }) => {
|
||||
return output;
|
||||
};
|
||||
|
||||
// Analyse la toxicité des paroles d'une chanson pour modération.
|
||||
// Entrée: { title: string, lyrics: string | {couplet?, refrain?} | Array<{type, lyrics}> }
|
||||
// Sortie (schéma strict): indicateurs de toxicité, raisons, extraits et décision de blocage.
|
||||
// Cloud Function plus nuancée : bloque le réellement problématique, laisse passer le reste
|
||||
exports.analyseLyrics = async ({ title = "", lyrics }) => {
|
||||
// --- Normalisation inchangée (robuste aux différents formats) ---
|
||||
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: vous devez fournir des paroles (lyrics) non vides.",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Schéma de sortie (inchangé pour compatibilité) ---
|
||||
const moderationSchema = z.object({
|
||||
title: z.string().describe("Titre analysé (copie du titre fourni)"),
|
||||
flagged: z
|
||||
.boolean()
|
||||
.describe("Vrai si au moins un signalement de toxicité est détecté."),
|
||||
blocked: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Vrai si la publication doit être BLOQUÉE (cas graves et non ambigus).",
|
||||
),
|
||||
score: z.number().min(0).max(1).describe("Score global de toxicité (0-1)"),
|
||||
reasons: z.array(z.string()).describe("Raisons concises de la décision."),
|
||||
excerpts: z
|
||||
.array(
|
||||
z.object({
|
||||
quote: z.string().describe("Extrait exact problématique"),
|
||||
category: z
|
||||
.string()
|
||||
.describe(
|
||||
"Catégorie principale (ex: racism, nazism, sexism, homophobia, harassment, self-harm incitement, terrorism, minors-sexual-content, etc.)",
|
||||
),
|
||||
severity: z
|
||||
.string()
|
||||
.describe("none|low|medium|high|critical pour l'extrait"),
|
||||
}),
|
||||
)
|
||||
.max(10)
|
||||
.describe("Jusqu'à 10 extraits problématiques"),
|
||||
success: z.boolean().describe("Toujours true si l'analyse a abouti"),
|
||||
});
|
||||
|
||||
// --- Politique : davantage de place à la création tout en restant sûre ---
|
||||
const system = `
|
||||
Tu es un modérateur de contenu spécialisé dans l'analyse de paroles de chanson.
|
||||
Objectif: protéger les utilisateurs en BLOQUANT uniquement les cas réellement problématiques et non ambigus, tout en LAISSANT PASSER les œuvres qui utilisent la narration, la satire, la critique ou la représentation de personnages.
|
||||
|
||||
Sois STRICT sur les risques graves, mais NE SOIS PAS PUNITIF envers l'expression artistique.
|
||||
Évalue TOUJOURS: 1) l'intention, 2) le contexte (récit, citation, dénonciation, rôle/persona), 3) la cible, 4) la probabilité de tort réel.
|
||||
|
||||
Retourne UNIQUEMENT un JSON conforme au schéma. Pas de texte hors JSON.
|
||||
`.trim();
|
||||
|
||||
// Règles détaillées injectées dans le prompt utilisateur pour guider la décision
|
||||
const policy = `
|
||||
RUBRIQUE DE DÉCISION (résumée):
|
||||
|
||||
A. QUAND BLOQUER (blocked = true) — cas graves et clairs (pas ambigus):
|
||||
1) Sexualité impliquant des mineurs (référence explicite, érotisation, incitation).
|
||||
2) Apologie explicite et sérieuse de terrorisme/extrémisme réel, ou appel à rejoindre/soutenir.
|
||||
3) Appels explicites à la violence ou à un préjudice grave contre une personne/groupe réel.
|
||||
4) Déshumanisation évidente et sérieuse de groupes protégés couplée à menace/incitation.
|
||||
5) Instruction ou encouragement clair à commettre des crimes violents.
|
||||
6) Nazisme/fascisme réel promu (praise/advocacy) ou symboles glorifiés sans critique.
|
||||
7) Partage de contenu sexuel non consenti réel (ex: doxxing sexuel, vengeance) ou exploitation.
|
||||
|
||||
B. QUAND NE PAS BLOQUER (blocked = false, mais flagged peut être true):
|
||||
- Œuvres qui mettent en scène un personnage toxique pour le critiquer, le montrer ou le contextualiser.
|
||||
- Citations, descriptions historiques, satire, ironie, mises en garde, ou re-appropriation par les communautés visées.
|
||||
- Métaphores violentes ou langage dur SANS cible réelle ni intention d'incitation.
|
||||
- Références à des thèmes sombres dans un but narratif/éducatif/critique, sans promotion réelle.
|
||||
|
||||
C. SCORE ET SEVERITY:
|
||||
- Calibre "score" selon l'impact potentiel et la clarté du tort.
|
||||
- Indice: low≈0.1–0.3, medium≈0.3–0.6, high≈0.6–0.85, critical>0.85.
|
||||
- Utilise severity par extrait pour refléter le pire passage.
|
||||
|
||||
D. RELECTURE DE CONTEXTE:
|
||||
- Demande-toi si le passage est: (i) narration/roleplay/critique —> plutôt allow; (ii) injonction réelle —> plutôt block.
|
||||
- Si ambigu OU probablement artistique, NE PAS bloquer: flagged=true si nécessaire, blocked=false.
|
||||
`.trim();
|
||||
|
||||
const userPrompt = `
|
||||
Analyse de paroles de chanson
|
||||
Titre: ${title || "Sans titre"}
|
||||
|
||||
Paroles:
|
||||
"""
|
||||
${lyricsText}
|
||||
"""
|
||||
|
||||
Consigne:
|
||||
- Applique la RUBRIQUE DE DÉCISION ci-dessous pour autoriser la création artistique tout en bloquant les cas réellement dangereux.
|
||||
- "blocked" = true UNIQUEMENT pour les cas A.1 à A.7 (non ambigus).
|
||||
- Si le contenu semble artistique (récit, personnage, satire, citation, dénonciation) et ne fait pas d'apologie/incitation réelle: "blocked" = false (tu peux mettre "flagged" = true + "score" adapté).
|
||||
- Donne des "excerpts" courts (citations EXACTES) si possible.
|
||||
|
||||
${policy}
|
||||
`.trim();
|
||||
|
||||
const { output } = await genkit({
|
||||
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
|
||||
model: googleAI.model("gemini-2.5-flash"),
|
||||
}).generate({
|
||||
system,
|
||||
prompt: userPrompt,
|
||||
output: { schema: moderationSchema },
|
||||
});
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
exports.generateImageV2 = async (prompt, size = 1024) => {
|
||||
if (typeof prompt !== "string" || prompt.trim().length < 1) {
|
||||
throw new Error("Vous devez spécifier un prompt (string) non vide.");
|
||||
|
||||
+56
-1
@@ -1,6 +1,6 @@
|
||||
const { onCall } = require("firebase-functions/v2/https");
|
||||
const { z } = require("genkit");
|
||||
const { generateAI } = require("../helpers/gemini");
|
||||
const { generateAI, analyseLyrics } = require("../helpers/gemini");
|
||||
|
||||
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
|
||||
try {
|
||||
@@ -90,3 +90,58 @@ Pour chaque ` +
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Vérifie la toxicité des paroles via Gemini et retourne un résultat structuré
|
||||
// Nom Cloud Function: lyrics-analyseLyricsToxicity
|
||||
exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
|
||||
try {
|
||||
// Validation d'entrée minimale
|
||||
const requestSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
lyrics: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.object({ couplet: z.string().optional(), refrain: z.string().optional() }),
|
||||
z.array(z.object({ type: z.string().optional(), lyrics: z.string().optional() })),
|
||||
])
|
||||
.describe("Paroles à analyser (formats supportés)"),
|
||||
});
|
||||
|
||||
const parsed = requestSchema.parse(data || {});
|
||||
const result = await analyseLyrics({ title: parsed.title || "", lyrics: parsed.lyrics });
|
||||
|
||||
// Prépare un message lisible pour le front
|
||||
const highCats = Object.entries(result.categories || {})
|
||||
.filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5)
|
||||
.map(([k]) => k)
|
||||
.slice(0, 5);
|
||||
|
||||
let errorCode = "OK";
|
||||
let message = "Analyse effectuée: aucun blocage.";
|
||||
|
||||
if (result.blocked) {
|
||||
errorCode = "TOXIC_CONTENT_BLOCKED";
|
||||
const list = highCats.length ? ` (${highCats.join(", ")})` : "";
|
||||
message = `Publication bloquée: contenu interdit détecté${list}.`;
|
||||
} else if (result.flagged) {
|
||||
errorCode = "TOXIC_CONTENT_FLAGGED";
|
||||
const list = highCats.length ? ` (${highCats.join(", ")})` : "";
|
||||
message = `Attention: contenu potentiellement sensible détecté${list}.`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: !result.blocked,
|
||||
errorCode,
|
||||
message,
|
||||
result,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("analyseLyricsToxicity failed", err?.message || err);
|
||||
return {
|
||||
success: false,
|
||||
errorCode: "ANALYSE_FAILED",
|
||||
message:
|
||||
"Erreur lors de l'analyse de la toxicité. Réessayez plus tard.",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
+118
-14
@@ -32,6 +32,97 @@ function clampLen(str = "", max) {
|
||||
return str.length <= max ? str : str.slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Déduit des balises vocales explicites reconnues par les modèles (EN)
|
||||
* à partir de la description utilisateur (FR)
|
||||
*/
|
||||
function extractVoiceStrings(voice) {
|
||||
if (!voice) return [];
|
||||
if (typeof voice === "string") return [voice];
|
||||
if (Array.isArray(voice)) {
|
||||
return voice
|
||||
.map((v) => (typeof v === "string" ? v : v?.value || v?.text || ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof voice === "object") {
|
||||
return Object.values(voice).filter((v) => typeof v === "string" && v.trim());
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function voiceTagsFromUserChoice(voiceInput = "") {
|
||||
const all = extractVoiceStrings(voiceInput);
|
||||
const tags = [];
|
||||
const addFromText = (txt) => {
|
||||
const v = String(txt || "").toLowerCase();
|
||||
|
||||
// Gender / ensemble
|
||||
if (/féminin|feminin|féminine|feminine/.test(v)) tags.push("Female lead vocal");
|
||||
if (/masculin|masculine/.test(v)) tags.push("Male lead vocal");
|
||||
if (/deux voix|duo|deux chanteurs/.test(v)) tags.push("Duet (male and female voices)");
|
||||
if (/choeu?r|gospel/.test(v)) tags.push("Gospel choir vocals");
|
||||
|
||||
// Delivery/techniques
|
||||
if (/rap\b/.test(v)) tags.push("Rap vocal");
|
||||
if (/slam|parl[ée]e|chant parl[ée]/.test(v)) tags.push("Spoken word / slam");
|
||||
if (/raconte|narrat/.test(v)) tags.push("Narration / spoken narrator");
|
||||
if (/cri|scream|rugueu|growl/.test(v)) tags.push("Screamed vocals");
|
||||
if (/sprech/.test(v)) tags.push("Sprechgesang (sung-spoken) style");
|
||||
|
||||
// Tone/texture
|
||||
if (/séduis|sensuel/.test(v)) tags.push("Seductive, sensual tone");
|
||||
if (/profonde|r[ée]sonnan/.test(v)) tags.push("Deep, resonant voice");
|
||||
if (/l[ée]g[èe]re|a[ée]rienne/.test(v)) tags.push("Light, airy voice");
|
||||
if (/relaxante|sophistiqu[ée]/.test(v)) tags.push("Smooth, lounge vocal");
|
||||
if (/synth[ée]tique|robot/.test(v)) tags.push("Synthetic/processed vocal");
|
||||
if (/d[ée]coup|chopp/.test(v)) tags.push("Chopped vocal samples");
|
||||
|
||||
// Choir color
|
||||
if (/c[ée]leste|divin|angel/i.test(v)) tags.push("Ethereal, angelic choir");
|
||||
};
|
||||
|
||||
if (all.length === 0) addFromText(voiceInput);
|
||||
else all.forEach(addFromText);
|
||||
|
||||
return Array.from(new Set(tags));
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte un genre vocal simple (m/f) pour Suno à partir du texte utilisateur.
|
||||
*/
|
||||
function detectVocalGender(voiceInput = "") {
|
||||
// If array of objects with category, prefer 'base'
|
||||
if (Array.isArray(voiceInput)) {
|
||||
const baseItem = voiceInput.find((v) => v && (v.category === "base" || v?.category?.toLowerCase() === "base"));
|
||||
const text = baseItem ? baseItem.value || baseItem.text || baseItem : null;
|
||||
if (text) return detectVocalGender(text);
|
||||
// Fallback: scan all
|
||||
for (const it of voiceInput) {
|
||||
const g = detectVocalGender(it?.value || it?.text || it);
|
||||
if (g) return g;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof voiceInput === "object" && voiceInput) {
|
||||
// object keyed by category
|
||||
const base = voiceInput.base || voiceInput["base"];
|
||||
if (base) return detectVocalGender(base);
|
||||
const vals = Object.values(voiceInput).filter(Boolean);
|
||||
for (const v of vals) {
|
||||
const g = detectVocalGender(v);
|
||||
if (g) return g;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const v = String(voiceInput || "").toLowerCase();
|
||||
if (!v) return undefined;
|
||||
if (/(féminin|feminin|féminine|feminine|voix\s*f[ée]min)/.test(v)) return "f";
|
||||
if (/(masculin|masculine|voix\s*mascul)/.test(v)) return "m";
|
||||
if (/(femme)/.test(v)) return "f";
|
||||
if (/(homme)/.test(v)) return "m";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le style musical à partir des paramètres
|
||||
* @param {Object} params - Les paramètres de style
|
||||
@@ -42,12 +133,17 @@ function buildStyle({ genres = [], voice = "", instruments = [], tempo = "" }) {
|
||||
const flatInstruments = (instruments || [])
|
||||
.map((i) => ("" + i).trim())
|
||||
.filter(Boolean);
|
||||
const voiceTags = voiceTagsFromUserChoice(voice);
|
||||
const voiceStrings = extractVoiceStrings(voice);
|
||||
const voiceUserText = voiceStrings.join(" ; ");
|
||||
|
||||
const styleParts = [
|
||||
flatGenres.length ? `Genres: ${flatGenres.join(", ")}` : "",
|
||||
tempo ? `Tempo: ${tempo}` : "",
|
||||
flatInstruments.length ? `Instruments: ${flatInstruments.join(", ")}` : "",
|
||||
voice ? `Vocal: ${voice}` : "",
|
||||
// Inject tags AND the raw user string to fully convey preference
|
||||
voiceTags.length ? `Vocal: ${voiceTags.join(", ")}` : "",
|
||||
voiceUserText ? `Vocal (user): ${voiceUserText}` : "",
|
||||
"Mix: propre, punchy, large stéréo, radio-ready",
|
||||
].filter(Boolean);
|
||||
|
||||
@@ -132,8 +228,11 @@ function buildGuides({
|
||||
styleElements.push(`Instruments principaux: ${instruments.join(", ")}`);
|
||||
}
|
||||
|
||||
if (voice) {
|
||||
styleElements.push(`Style vocal: ${voice}`);
|
||||
const voiceStrings = extractVoiceStrings(voice);
|
||||
if (voiceStrings.length) {
|
||||
const tags = voiceTagsFromUserChoice(voice);
|
||||
if (tags.length) styleElements.push(`Vocal direction: ${tags.join(", ")}`);
|
||||
styleElements.push(`User vocal request: ${voiceStrings.join(" ; ")}`);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -191,6 +290,10 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
|
||||
callBackUrl: SUNO_CALLBACK_URL || "",
|
||||
};
|
||||
|
||||
// Appliquer la préférence de genre vocal si détectée (doc: vocalGender: "m" | "f")
|
||||
const vGender = detectVocalGender(voice);
|
||||
if (vGender) payload.vocalGender = vGender;
|
||||
|
||||
console.log("PAYLOAD", payload);
|
||||
|
||||
const response = await axios.post(
|
||||
@@ -201,7 +304,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${SUNO_API_KEY}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = response.data;
|
||||
@@ -214,13 +317,14 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
|
||||
title: safeTitle,
|
||||
style: enhancedStyle,
|
||||
prompt: taggedLyrics,
|
||||
vocalGender: vGender || null,
|
||||
},
|
||||
response: parsed || {},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur lors de la génération de musique:", error);
|
||||
throw new Error(
|
||||
`Erreur lors de la génération de musique: ${error.message}`
|
||||
`Erreur lors de la génération de musique: ${error.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -249,7 +353,7 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
|
||||
Authorization: `Bearer ${SUNO_API_KEY}`,
|
||||
},
|
||||
timeout: 30000, // 30 secondes de timeout
|
||||
}
|
||||
},
|
||||
);
|
||||
parsed = response.data;
|
||||
|
||||
@@ -365,7 +469,7 @@ exports.getSunoTimestamps = onCall(async ({ data = {} }) => {
|
||||
Authorization: `Bearer ${SUNO_API_KEY}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
},
|
||||
);
|
||||
parsed = response.data;
|
||||
console.log("📊 Réponse timestamps Suno API:", parsed?.code, parsed?.msg);
|
||||
@@ -417,7 +521,7 @@ exports.getSunoTimestamps = onCall(async ({ data = {} }) => {
|
||||
},
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
{ merge: true },
|
||||
);
|
||||
saved = true;
|
||||
console.log("💾 Timestamps sauvegardés dans Firestore", {
|
||||
@@ -427,7 +531,7 @@ exports.getSunoTimestamps = onCall(async ({ data = {} }) => {
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"❌ Échec sauvegarde timestamps Firestore:",
|
||||
e?.message || e
|
||||
e?.message || e,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -535,7 +639,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
||||
const audioUrls = tracks
|
||||
.map(
|
||||
(t) =>
|
||||
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl
|
||||
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl,
|
||||
)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2);
|
||||
@@ -550,7 +654,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
||||
has_audio_url: !!t.audio_url,
|
||||
has_stream_audio_url: !!t.stream_audio_url,
|
||||
})),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -575,14 +679,14 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
||||
},
|
||||
});
|
||||
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||
path
|
||||
path,
|
||||
)}?alt=media&token=${token}`;
|
||||
console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
|
||||
return { path, url: downloadUrl };
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
|
||||
e.message
|
||||
e.message,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -602,7 +706,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
||||
musicUrls,
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
{ merge: true },
|
||||
);
|
||||
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
|
||||
projectId,
|
||||
|
||||
Reference in New Issue
Block a user