a scoring model
This commit is contained in:
parent
bdfc8d4671
commit
8a46341755
17 changed files with 832 additions and 3 deletions
264
src/Yavsc.Org/Services/ModerationPipelineService.cs
Normal file
264
src/Yavsc.Org/Services/ModerationPipelineService.cs
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
// 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 0–100
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue