feat(aep): carte AEP — push Gitea 2026-04-28
This commit is contained in:
183
server/api/comment/index.post.ts
Normal file
183
server/api/comment/index.post.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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.',
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user