184 lines
5.9 KiB
TypeScript
184 lines
5.9 KiB
TypeScript
/**
|
|
* POST /api/comment
|
|
* Soumettre un commentaire sur une fiche.
|
|
*
|
|
* Corps attendu :
|
|
* { orga_id: number, contenu: string, auteur_pseudo?: string, auteur_email?: string }
|
|
*
|
|
* Flux :
|
|
* 1. Rate limit Redis : ≤ 5 commentaires / IP / jour (Sonnet 3)
|
|
* 2. Validation basique
|
|
* 3. Filtre éthique Mistral Nemo (timeout 2s — fallback pending si timeout/erreur)
|
|
* 4. INSERT NocoDB table avis
|
|
* 5. Retourner { ok: true, status: 'approved' | 'pending' }
|
|
*/
|
|
|
|
import { checkRateLimit } from '~/server/utils/rateLimit'
|
|
|
|
interface CommentBody {
|
|
orga_id: number
|
|
contenu: string
|
|
auteur_pseudo?: string
|
|
auteur_email?: string
|
|
}
|
|
|
|
interface MistralFilterResult {
|
|
safe: boolean
|
|
category: string | null
|
|
reason: string | null
|
|
}
|
|
|
|
async function filtreEthique(
|
|
contenu: string,
|
|
mistralKey: string
|
|
): Promise<MistralFilterResult> {
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), 2000)
|
|
|
|
try {
|
|
const response: any = await $fetch('https://api.mistral.ai/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${mistralKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'open-mistral-nemo',
|
|
temperature: 0.0,
|
|
max_tokens: 100,
|
|
response_format: { type: 'json_object' },
|
|
messages: [
|
|
{
|
|
role: 'system',
|
|
content: `Tu es un modèle de modération de contenu. Tu analyses des commentaires soumis sur une plateforme professionnelle française dédiée aux architectes. Tu dois détecter les contenus problématiques.
|
|
|
|
CONTENUS À SIGNALER (safe=false) :
|
|
- Propos racistes, antisémites, islamophobes, xénophobes
|
|
- Propos sexistes, misogynes, homophobes, transphobes, LGBTQIA-phobes
|
|
- Propos validistes (mépris des personnes handicapées)
|
|
- Diffamation personnelle nominative (accusations graves sans fondement contre une personne identifiable)
|
|
- Spam publicitaire explicite (lien commercial non sollicité + texte promotionnel)
|
|
- Harcèlement ciblé, menaces explicites
|
|
|
|
CONTENUS À AUTORISER (safe=true) :
|
|
- Critiques d'organisations ou de services (même sévères), sans attaque personnelle
|
|
- Partages d'expériences professionnelles négatives factuelles
|
|
- Désaccords et débats professionnels
|
|
- Signalement d'erreurs ou d'informations inexactes
|
|
|
|
RÈGLES :
|
|
1. En cas de doute, favorise safe=true (la liberté d'expression professionnelle prime).
|
|
2. Le champ "category" est obligatoire si safe=false.
|
|
3. Le champ "reason" est une phrase courte en français (max 20 mots).
|
|
4. Retourne UNIQUEMENT un objet JSON valide.
|
|
|
|
FORMAT DE SORTIE :
|
|
{
|
|
"safe": true | false,
|
|
"category": "racisme" | "sexisme" | "homophobie" | "antisémitisme" | "lgbtqia-phobie" | "validisme" | "diffamation" | "spam" | "harcelement" | null,
|
|
"reason": "string (max 20 mots)" | null
|
|
}`,
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: `COMMENTAIRE À ANALYSER :\n\n"${contenu}"\n\nAnalyse ce commentaire et retourne le JSON de modération.`,
|
|
},
|
|
],
|
|
}),
|
|
// @ts-ignore — signal pas dans les types $fetch mais fonctionne en pratique
|
|
signal: controller.signal,
|
|
})
|
|
|
|
clearTimeout(timeout)
|
|
const raw = response?.choices?.[0]?.message?.content ?? '{}'
|
|
const parsed = JSON.parse(raw)
|
|
return {
|
|
safe: parsed.safe !== false,
|
|
category: parsed.category ?? null,
|
|
reason: parsed.reason ?? null,
|
|
}
|
|
} catch {
|
|
clearTimeout(timeout)
|
|
// Timeout ou erreur réseau → fallback pending
|
|
return { safe: false, category: null, reason: 'timeout_ou_erreur_mistral' }
|
|
}
|
|
}
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const config = useRuntimeConfig()
|
|
|
|
// Rate limit : 5 commentaires / IP / jour
|
|
const ip =
|
|
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
|
event.node.req.socket?.remoteAddress ||
|
|
'0.0.0.0'
|
|
|
|
const allowed = await checkRateLimit(ip, 'comment', 5)
|
|
if (!allowed) {
|
|
throw createError({
|
|
statusCode: 429,
|
|
statusMessage: 'Trop de commentaires. Réessaie demain.',
|
|
})
|
|
}
|
|
|
|
const body = await readBody<CommentBody>(event)
|
|
|
|
// Validation basique
|
|
if (!body?.orga_id || !body?.contenu) {
|
|
throw createError({ statusCode: 400, message: 'Champs requis manquants : orga_id, contenu' })
|
|
}
|
|
|
|
const contenu = body.contenu.trim()
|
|
if (contenu.length < 10 || contenu.length > 500) {
|
|
throw createError({ statusCode: 400, message: 'Le commentaire doit faire entre 10 et 500 caractères' })
|
|
}
|
|
|
|
// Filtre éthique Mistral Nemo
|
|
let published = false
|
|
let safeCheck = 'pending'
|
|
let safeReason: string | null = null
|
|
|
|
if (config.mistralApiKey) {
|
|
const result = await filtreEthique(contenu, config.mistralApiKey)
|
|
published = result.safe
|
|
safeCheck = result.safe ? 'safe' : (result.reason === 'timeout_ou_erreur_mistral' ? 'pending' : 'flagged')
|
|
safeReason = result.reason
|
|
} else {
|
|
// Clé Mistral absente → fallback pending (TODO configurer MISTRAL_API_KEY)
|
|
safeCheck = 'pending'
|
|
safeReason = 'mistral_key_manquante'
|
|
}
|
|
|
|
// Insertion NocoDB
|
|
const tableId = config.commentTableId
|
|
const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records`
|
|
|
|
const record = await $fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'xc-token': config.nocodbToken,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
orga_id: body.orga_id,
|
|
contenu,
|
|
auteur_pseudo: body.auteur_pseudo || null,
|
|
auteur_email: body.auteur_email || null,
|
|
published,
|
|
safe_check: safeCheck,
|
|
safe_reason: safeReason,
|
|
submitted_at: new Date().toISOString(),
|
|
}),
|
|
}).catch((err: any) => {
|
|
throw createError({ statusCode: 502, message: 'Erreur NocoDB lors de l\'insertion du commentaire' })
|
|
})
|
|
|
|
return {
|
|
ok: true,
|
|
status: published ? 'approved' : 'pending',
|
|
message: published
|
|
? 'Commentaire publié. Merci pour ta contribution !'
|
|
: 'Commentaire reçu et en cours de modération.',
|
|
}
|
|
})
|