yavsc/src/Yavsc.Org/Services/ModerationPipelineService.cs
2026-05-24 19:35:35 +01:00

264 lines
No EOL
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Yavsc.Services.Kyc/ModerationPipelineService.cs
namespace Yavsc.Services.Kyc
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Kyc;
using Yavsc.ViewModels.Kyc;
public interface IModerationPipelineService
{
/// <summary>
/// Soumet une déclaration : applique les regex, place en queue.
/// Retourne la déclaration créée (avec ses flags éventuels).
/// </summary>
Task<TrustDeclaration> SubmitDeclarationAsync(
Guid subjectTokenId,
Guid declarantTokenId,
string content,
DeclarationSentiment sentiment);
/// <summary>
/// Le modérateur approuve : applique le ScoreDelta sur le TrustToken.
/// </summary>
Task ApproveAsync(long declarationId, int scoreDelta, string moderatorId);
/// <summary>
/// Le modérateur rejette : aucun effet sur le score.
/// </summary>
Task RejectAsync(long declarationId, string moderatorId);
/// <summary>
/// Le modérateur expurge : contenu effacé, score partiellement appliqué.
/// </summary>
Task RedactAsync(long declarationId, int scoreDelta, string moderatorId);
/// <summary>
/// Retourne la queue de modération (pending + flagged en priorité).
/// </summary>
Task<List<ModerationQueueItem>> GetQueueAsync();
}
public class ModerationPipelineService : IModerationPipelineService
{
private readonly ApplicationDbContext _db;
// Cache des patterns actifs — rechargé à chaque soumission
// (en prod : IMemoryCache avec invalidation)
private List<RegexAlertPattern> _patterns;
public ModerationPipelineService(ApplicationDbContext db)
{
_db = db;
}
// ── Soumission ───────────────────────────────────────────────────────
public async Task<TrustDeclaration> SubmitDeclarationAsync(
Guid subjectTokenId,
Guid declarantTokenId,
string content,
DeclarationSentiment sentiment)
{
// Vérification basique
if (string.IsNullOrWhiteSpace(content))
throw new ArgumentException("Contenu vide", nameof(content));
if (subjectTokenId == declarantTokenId)
throw new InvalidOperationException("Auto-déclaration interdite");
// Chargement des patterns actifs
_patterns = await _db.RegexAlertPatterns
.Where(p => p.IsActive)
.ToListAsync();
// Analyse regex
var flags = RunRegexAnalysis(content);
var hasBloking = flags.Any(f =>
_patterns.First(p => p.Id == f.PatternId).Severity == PatternSeverity.Blocking);
var declaration = new TrustDeclaration
{
TrustTokenId = subjectTokenId,
DeclarantTokenId = declarantTokenId,
Content = content,
Sentiment = sentiment,
SubmittedAt = DateTime.UtcNow,
// Flagged = priorité haute en queue, sinon Pending
Status = flags.Any() ? ModerationStatus.Flagged
: ModerationStatus.Pending,
Flags = flags
};
_db.TrustDeclarations.Add(declaration);
await _db.SaveChangesAsync();
return declaration;
}
// ── Décisions de modération ──────────────────────────────────────────
public async Task ApproveAsync(long declarationId, int scoreDelta, string moderatorId)
{
var (declaration, token) = await LoadForModerationAsync(declarationId);
ValidateScoreDelta(scoreDelta);
declaration.Status = ModerationStatus.Approved;
declaration.ScoreDelta = scoreDelta;
ApplyDelta(token, scoreDelta);
await _db.ModerationLogs.AddAsync(new ModerationLog
{
DeclarationId = declarationId,
ModeratorId = moderatorId,
Action = ModerationAction.Approved,
ScoreDelta = scoreDelta,
Timestamp = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
public async Task RejectAsync(long declarationId, string moderatorId)
{
var (declaration, _) = await LoadForModerationAsync(declarationId);
declaration.Status = ModerationStatus.Rejected;
// Aucun effet sur le score
await _db.ModerationLogs.AddAsync(new ModerationLog
{
DeclarationId = declarationId,
ModeratorId = moderatorId,
Action = ModerationAction.Rejected,
ScoreDelta = 0,
Timestamp = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
public async Task RedactAsync(long declarationId, int scoreDelta, string moderatorId)
{
var (declaration, token) = await LoadForModerationAsync(declarationId);
ValidateScoreDelta(scoreDelta);
// Contenu expurgé — on garde la trace de la déclaration, pas du texte
declaration.Content = "[expurgé]";
declaration.Status = ModerationStatus.Redacted;
declaration.ScoreDelta = scoreDelta;
ApplyDelta(token, scoreDelta);
await _db.ModerationLogs.AddAsync(new ModerationLog
{
DeclarationId = declarationId,
ModeratorId = moderatorId,
Action = ModerationAction.Redacted,
ScoreDelta = scoreDelta,
Timestamp = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
// ── Queue de modération ──────────────────────────────────────────────
public async Task<List<ModerationQueueItem>> GetQueueAsync()
{
return await _db.TrustDeclarations
.Where(d => d.Status == ModerationStatus.Pending
|| d.Status == ModerationStatus.Flagged)
.OrderByDescending(d => d.Status == ModerationStatus.Flagged) // Flagged en tête
.ThenBy(d => d.SubmittedAt)
.Select(d => new ModerationQueueItem
{
DeclarationId = d.Id,
// Pseudonyme tronqué — jamais le hash complet exposé
SubjectTokenHash = d.Subject.TokenHash.Substring(0, 8) + "…",
Content = d.Content,
Sentiment = d.Sentiment,
Status = d.Status,
HasBlockingFlag = d.Flags.Any(f =>
f.Pattern.Severity == PatternSeverity.Blocking),
FlagDescriptions = d.Flags
.Select(f => f.Pattern.Description)
.ToList()
})
.ToListAsync();
}
// ── Privé ────────────────────────────────────────────────────────────
private List<DeclarationFlag> RunRegexAnalysis(string content)
{
var flags = new List<DeclarationFlag>();
foreach (var pattern in _patterns)
{
try
{
var match = Regex.Match(content, pattern.Pattern,
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(200)); // timeout anti-ReDoS
if (!match.Success) continue;
// Extrait tronqué — jamais le contenu complet
var excerpt = match.Value.Length > 80
? match.Value.Substring(0, 80) + "…"
: match.Value;
flags.Add(new DeclarationFlag
{
PatternId = pattern.Id,
MatchExcerpt = excerpt
});
}
catch (RegexMatchTimeoutException)
{
// Pattern trop lent — on logue et on continue
// TODO: alerter l'admin sur ce pattern
}
}
return flags;
}
private async Task<(TrustDeclaration, TrustToken)> LoadForModerationAsync(long declarationId)
{
var declaration = await _db.TrustDeclarations
.Include(d => d.Subject)
.FirstOrDefaultAsync(d => d.Id == declarationId)
?? throw new KeyNotFoundException($"Déclaration {declarationId} introuvable");
if (declaration.Status == ModerationStatus.Approved
|| declaration.Status == ModerationStatus.Rejected
|| declaration.Status == ModerationStatus.Redacted)
throw new InvalidOperationException("Déclaration déjà traitée");
return (declaration, declaration.Subject);
}
private static void ApplyDelta(TrustToken token, int delta)
{
var current = token.TrustScore ?? 50; // Score initial neutre : 50
// Bornes 0100
token.TrustScore = Math.Clamp(current + delta, 0, 100);
}
private static void ValidateScoreDelta(int delta)
{
if (delta < -10 || delta > 10)
throw new ArgumentOutOfRangeException(nameof(delta),
"ScoreDelta doit être entre -10 et +10");
}
}
}