Add comment spam classification and captcha checks.

Score artwork comments with local signatures plus Together AI, and optionally require Turnstile before posting.
This commit is contained in:
2026-09-20 14:48:50 +02:00
parent 37bacc1334
commit 586c44ba74
16 changed files with 858 additions and 135 deletions
@@ -0,0 +1,10 @@
<?php
namespace App\Contracts\Moderation;
use App\Data\Moderation\CommentSpamClassification;
interface CommentSpamClassifier
{
public function classify(string $content): CommentSpamClassification;
}
@@ -0,0 +1,15 @@
<?php
namespace App\Data\Moderation;
final class CommentSpamClassification
{
public function __construct(
public readonly bool $spam,
public readonly float $confidence,
public readonly string $reason,
public readonly string $provider,
public readonly string $model,
public readonly int $latencyMs,
) {}
}
@@ -3,15 +3,17 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Activity\UserActivityService;
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use App\Models\UserMention;
use App\Notifications\ArtworkCommentedNotification;
use App\Notifications\ArtworkMentionedNotification;
use App\Services\ContentSanitizer;
use App\Services\Activity\UserActivityService;
use App\Services\CommentReactionService;
use App\Services\ContentSanitizer;
use App\Services\Moderation\CommentSpamService;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
@@ -31,7 +33,10 @@ class ArtworkCommentController extends Controller
{
private const MAX_LENGTH = 10_000;
public function __construct(private readonly CommentReactionService $commentReactions) {}
public function __construct(
private readonly CommentReactionService $commentReactions,
private readonly CommentSpamService $commentSpam,
) {}
// ─────────────────────────────────────────────────────────────────────────
// List
@@ -41,14 +46,14 @@ class ArtworkCommentController extends Controller
{
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
// Only fetch top-level comments (no parent). Replies are recursively eager-loaded.
$comments = ArtworkComment::with([
'user', 'user.profile',
'approvedReplies',
])
'user', 'user.profile',
'approvedReplies',
])
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->whereNull('parent_id')
@@ -58,15 +63,15 @@ class ArtworkCommentController extends Controller
$userId = $request->user()?->id;
$commentIds = $this->commentIds($comments->getCollection());
$reactionTotals = $this->commentReactions->forComments($commentIds, $userId);
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals));
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals));
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
],
]);
}
@@ -80,7 +85,7 @@ class ArtworkCommentController extends Controller
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
'content' => ['required', 'string', 'min:1', 'max:'.self::MAX_LENGTH],
'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'],
]);
@@ -109,43 +114,54 @@ class ArtworkCommentController extends Controller
$rendered = ContentSanitizer::render($raw);
$comment = ArtworkComment::create([
'artwork_id' => $artwork->id,
'user_id' => $request->user()->id,
'parent_id' => $parentId,
'content' => $raw, // legacy column (plain text fallback)
'raw_content' => $raw,
'artwork_id' => $artwork->id,
'user_id' => $request->user()->id,
'parent_id' => $parentId,
'content' => $raw, // legacy column (plain text fallback)
'raw_content' => $raw,
'rendered_content' => $rendered,
'is_approved' => true, // auto-approve; extend with moderation as needed
'is_approved' => true,
]);
$moderation = $this->commentSpam->moderate($comment, $request->user());
// Bust the comments cache for this user's 'all' feed
Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']);
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
if ($comment->is_approved) {
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
}
// Record activity event (fire-and-forget; never break the response)
try {
\App\Models\ActivityEvent::record(
actorId: $request->user()->id,
type: \App\Models\ActivityEvent::TYPE_COMMENT,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id,
ActivityEvent::record(
actorId: $request->user()->id,
type: ActivityEvent::TYPE_COMMENT,
targetType: ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id,
);
} catch (\Throwable) {}
} catch (\Throwable) {
}
try {
app(UserActivityService::class)->logComment(
(int) $request->user()->id,
(int) $comment->id,
$parentId !== null,
['artwork_id' => (int) $artwork->id],
);
} catch (\Throwable) {}
if ($comment->is_approved) {
app(UserActivityService::class)->logComment(
(int) $request->user()->id,
(int) $comment->id,
$parentId !== null,
['artwork_id' => (int) $artwork->id],
);
}
} catch (\Throwable) {
}
$reactionTotals = $this->commentReactions->forComments([$comment->id], $request->user()->id);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals)], 201);
return response()->json([
'data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals),
'moderation' => ['status' => $moderation['status']],
], 201);
}
// ─────────────────────────────────────────────────────────────────────────
@@ -160,10 +176,10 @@ class ArtworkCommentController extends Controller
Gate::authorize('update', $comment);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
'content' => ['required', 'string', 'min:1', 'max:'.self::MAX_LENGTH],
]);
$raw = $request->input('content');
$raw = $request->input('content');
$errors = ContentSanitizer::validate($raw);
if ($errors) {
return response()->json(['errors' => ['content' => $errors]], 422);
@@ -172,8 +188,8 @@ class ArtworkCommentController extends Controller
$rendered = ContentSanitizer::render($raw);
$comment->update([
'content' => $raw,
'raw_content' => $raw,
'content' => $raw,
'raw_content' => $raw,
'rendered_content' => $rendered,
]);
@@ -206,27 +222,27 @@ class ArtworkCommentController extends Controller
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false, array $reactionTotals = []): array
{
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
$data = [
'id' => $c->id,
'parent_id' => $c->parent_id,
'raw_content' => $c->raw_content ?? $c->content,
'id' => $c->id,
'parent_id' => $c->parent_id,
'raw_content' => $c->raw_content ?? $c->content,
'rendered_content' => $this->renderCommentContent($c),
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'can_edit' => $currentUserId === $userId,
'can_delete' => $currentUserId === $userId,
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'can_edit' => $currentUserId === $userId,
'can_delete' => $currentUserId === $userId,
'user' => [
'id' => $userId,
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
'id' => $userId,
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@'.$user->username : '/profile/'.$userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
'reactions' => $reactionTotals[(int) $c->id] ?? [],
];
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Middleware;
use Closure;
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
class CommentCaptchaMiddleware
{
public function __construct(private readonly BotProtectionService $botProtection) {}
public function handle(Request $request, Closure $next): Response
{
if (! (bool) config('comment_spam.captcha.enabled', false)) {
return $next($request);
}
$assessment = $this->botProtection->assess($request, 'comment_create');
if ((bool) ($assessment['blocked'] ?? false)) {
return response()->json(['message' => 'Suspicious activity detected.', 'errors' => ['bot' => ['Suspicious activity detected.']]], 429);
}
$threshold = (int) config('comment_spam.captcha.threshold', 40);
if ((int) ($assessment['risk_score'] ?? 0) < $threshold) {
return $next($request);
}
$token = (string) ($request->input('comment-turnstile-response') ?: $request->header('X-Turnstile-Token', ''));
$valid = false;
if ($token !== '') {
try {
$valid = (bool) Http::asForm()->timeout(5)->post(
(string) config('comment_spam.captcha.verify_url'),
['secret' => (string) config('comment_spam.captcha.secret_key'), 'response' => $token, 'remoteip' => $request->ip()],
)->json('success', false);
} catch (\Throwable) {
$valid = (bool) config('comment_spam.captcha.fail_open', false);
}
}
if ($valid) {
return $next($request);
}
$payload = [
'message' => 'Complete the captcha challenge to continue.',
'errors' => ['captcha' => ['Complete the captcha challenge to continue.']],
'requires_captcha' => true,
'captcha' => [
'provider' => 'turnstile',
'siteKey' => (string) config('comment_spam.captcha.site_key'),
'inputName' => 'comment-turnstile-response',
'scriptUrl' => (string) config('comment_spam.captcha.script_url'),
],
'captcha_provider' => 'turnstile',
'captcha_site_key' => (string) config('comment_spam.captcha.site_key'),
'captcha_input' => 'comment-turnstile-response',
'captcha_script_url' => (string) config('comment_spam.captcha.script_url'),
];
return response()->json($payload, 422);
}
}
+82 -67
View File
@@ -1,95 +1,110 @@
<?php
namespace App\Models;
use App\Observers\ArtworkCommentObserver;
use App\Services\ContentSanitizer;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* App\Models\ArtworkComment
*
* @property int $id
* @property int $artwork_id
* @property int $user_id
* @property string|null $content Legacy plain-text column
* @property string|null $raw_content User-submitted Markdown
* @property string|null $rendered_content Cached sanitized HTML
* @property bool $is_approved
* @property int $id
* @property int $artwork_id
* @property int $user_id
* @property string|null $content Legacy plain-text column
* @property string|null $raw_content User-submitted Markdown
* @property string|null $rendered_content Cached sanitized HTML
* @property bool $is_approved
* @property-read Artwork $artwork
* @property-read User $user
* @property-read \Illuminate\Database\Eloquent\Collection|CommentReaction[] $reactions
* @property-read Collection|CommentReaction[] $reactions
*/
#[ObservedBy([ArtworkCommentObserver::class])]
class ArtworkComment extends Model
{
use HasFactory, SoftDeletes;
use HasFactory, SoftDeletes;
protected $table = 'artwork_comments';
protected $table = 'artwork_comments';
protected $fillable = [
'legacy_id',
'artwork_id',
'user_id',
'parent_id',
'content',
'raw_content',
'rendered_content',
'is_approved',
];
protected $fillable = [
'legacy_id',
'artwork_id',
'user_id',
'parent_id',
'content',
'raw_content',
'rendered_content',
'is_approved',
'spam_score',
'spam_probability',
'spam_reason',
'moderation_source',
'moderated_at',
];
protected $casts = [
'is_approved' => 'boolean',
];
protected $casts = [
'is_approved' => 'boolean',
'spam_score' => 'integer',
'spam_probability' => 'integer',
'moderated_at' => 'datetime',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at');
}
public function replies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at');
}
/**
* Recursively eager-load approved replies (tree structure).
*/
public function approvedReplies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')
->where('is_approved', true)
->orderBy('created_at')
->with(['user.profile', 'approvedReplies']);
}
/**
* Recursively eager-load approved replies (tree structure).
*/
public function approvedReplies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')
->where('is_approved', true)
->orderBy('created_at')
->with(['user.profile', 'approvedReplies']);
}
public function reactions(): HasMany
{
return $this->hasMany(CommentReaction::class, 'comment_id');
}
public function reactions(): HasMany
{
return $this->hasMany(CommentReaction::class, 'comment_id');
}
/**
* Return the best available rendered content for display.
* Falls back to escaping raw legacy content if rendering isn't done yet.
*/
public function getDisplayHtml(): string
{
if ($this->rendered_content !== null) {
return $this->rendered_content;
}
/**
* Return the best available rendered content for display.
* Falls back to escaping raw legacy content if rendering isn't done yet.
*/
public function getDisplayHtml(): string
{
if ($this->rendered_content !== null) {
return $this->rendered_content;
}
// Lazy render: raw_content takes priority over legacy content
$raw = $this->raw_content ?? $this->content ?? '';
return \App\Services\ContentSanitizer::render($raw);
}
// Lazy render: raw_content takes priority over legacy content
$raw = $this->raw_content ?? $this->content ?? '';
return ContentSanitizer::render($raw);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CommentSpamSignature extends Model
{
public $timestamps = false;
protected $fillable = [
'content_hash', 'pattern_signature', 'source', 'reason',
'confidence', 'hit_count', 'reviewed_by', 'metadata', 'created_at',
];
protected $casts = [
'confidence' => 'integer',
'hit_count' => 'integer',
'metadata' => 'array',
'created_at' => 'datetime',
];
}
@@ -0,0 +1,179 @@
<?php
namespace App\Services\Moderation;
use App\Contracts\Moderation\CommentSpamClassifier;
use App\Models\ArtworkComment;
use App\Models\CommentSpamSignature;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Throwable;
class CommentSpamService
{
public function __construct(
private readonly ContentModerationProcessingService $processing,
private readonly CommentSpamClassifier $ai,
) {}
/**
* Classify after the comment exists, so the existing moderation queue can
* retain the full source context and admin review workflow.
*
* @return array{status:string, score:int, probability:int, source:string, reason:string}
*/
public function moderate(ArtworkComment $comment, User $user, ?string $mode = null): array
{
$mode ??= (string) config('comment_spam.mode', 'enforce');
$content = (string) ($comment->raw_content ?? $comment->content ?? '');
$hash = hash('sha256', $this->normalize($content));
if (! (bool) config('comment_spam.enabled', true) || $mode === 'off') {
return $this->saveMetadata($comment, 0, 0, 'disabled', 'Comment spam protection disabled.');
}
$signature = $this->signature($hash);
if ($signature !== null) {
$this->recordSignatureHit($signature);
$isSpam = ($signature->source === 'spam' || $signature->source === 'admin_spam');
return $this->finish($comment, $mode, $isSpam ? 100 : 0, $isSpam ? 100 : 0, 'signature', (string) $signature->reason, $isSpam);
}
$scan = $this->processing->process($content, [
'content_type' => 'artwork_comment',
'content_id' => (int) $comment->id,
'artwork_id' => (int) $comment->artwork_id,
'user_id' => (int) $user->id,
'content_snapshot' => $content,
'comment_spam_mode' => $mode,
], true);
$local = (int) $scan['result']->score;
$probability = $local;
$source = 'local';
$reason = implode(' ', array_slice($scan['result']->reasons, 0, 2));
$isSpam = $local >= (int) config('comment_spam.local_spam_min', 70);
$needsAi = $local > (int) config('comment_spam.local_safe_max', 29) && ! $isSpam;
$aiPending = false;
// Repeated external links and explicit copy/paste promotions are
// deterministic spam signals. Do not let an AI false negative make
// an obvious advertisement public.
if ($this->isObviousPromotionalSpam($content, (array) $scan['result']->matchedLinks)) {
$local = max($local, (int) config('comment_spam.local_spam_min', 70));
$probability = 100;
$isSpam = true;
$needsAi = false;
$source = 'local_hard_rule';
$reason = trim($reason.' Repeated promotional external link pattern.');
}
if ($needsAi && (bool) config('comment_spam.ai_enabled', true)) {
try {
$assessment = $this->ai->classify($content);
$probability = $assessment->spam
? max($local, (int) round($assessment->confidence * 100))
: $local;
$source = 'local+ai';
$reason = trim(implode(' ', array_filter([$reason, $assessment->reason])));
$isSpam = $assessment->spam && $assessment->confidence >= (float) config('comment_spam.ai.spam_confidence', 0.90);
$aiPending = $assessment->spam && ! $isSpam;
Log::info('comment_spam_ai_classification', ['comment_id' => $comment->id, 'provider' => $assessment->provider, 'model' => $assessment->model, 'spam' => $assessment->spam, 'confidence' => $assessment->confidence, 'latency_ms' => $assessment->latencyMs, 'decision' => $isSpam ? 'spam' : ($assessment->spam ? 'pending' : 'approved')]);
} catch (Throwable) {
$source = 'local+ai_error';
$aiPending = true;
}
}
$status = $mode === 'observe'
? 'observed'
: ($isSpam ? 'spam' : ($needsAi && $aiPending ? 'pending' : 'approved'));
$comment->forceFill([
'is_approved' => $mode === 'enforce' ? ! in_array($status, ['spam', 'pending'], true) : true,
'spam_score' => min(100, $local),
'spam_probability' => min(100, $probability),
'spam_reason' => $reason !== '' ? mb_substr($reason, 0, 500) : null,
'moderation_source' => $source,
'moderated_at' => now(),
])->save();
if ($mode === 'enforce' && $status === 'spam') {
$this->rememberSignature($hash, 'spam', $reason, $probability);
}
return compact('status', 'local', 'probability', 'source', 'reason') + ['score' => $local];
}
private function finish(ArtworkComment $comment, string $mode, int $score, int $probability, string $source, string $reason, ?bool $isSpam = null): array
{
$isSpam ??= $score >= (int) config('comment_spam.local_spam_min', 70);
$status = $mode === 'observe' ? 'observed' : ($isSpam ? 'spam' : 'approved');
$comment->forceFill([
'is_approved' => $mode !== 'enforce' || ! $isSpam,
'spam_score' => $score,
'spam_probability' => $probability,
'spam_reason' => mb_substr($reason, 0, 500),
'moderation_source' => $source,
'moderated_at' => now(),
])->save();
return compact('status', 'score', 'probability', 'source', 'reason');
}
private function saveMetadata(ArtworkComment $comment, int $score, int $probability, string $source, string $reason): array
{
$comment->forceFill(['spam_score' => $score, 'spam_probability' => $probability, 'spam_reason' => $reason, 'moderation_source' => $source, 'moderated_at' => now()])->save();
return ['status' => 'approved', 'score' => $score, 'probability' => $probability, 'source' => $source, 'reason' => $reason];
}
private function signature(string $hash): ?CommentSpamSignature
{
return Cache::remember('comment-spam:'.$hash, now()->addMinutes((int) config('comment_spam.signature_cache_minutes', 1440)), fn () => CommentSpamSignature::query()->where('content_hash', $hash)->first());
}
private function recordSignatureHit(CommentSpamSignature $signature): void
{
try {
if (! Schema::hasColumn('comment_spam_signatures', 'hit_count')) {
return;
}
CommentSpamSignature::query()->whereKey($signature->getKey())->increment('hit_count');
} catch (Throwable $e) {
Log::warning('comment_spam_signature_hit_failed', [
'signature_id' => $signature->getKey(),
'message' => $e->getMessage(),
]);
}
}
private function rememberSignature(string $hash, string $source, string $reason, int $confidence): void
{
CommentSpamSignature::query()->updateOrCreate(['content_hash' => $hash], ['source' => $source, 'reason' => mb_substr($reason, 0, 500), 'confidence' => min(100, $confidence), 'created_at' => now()]);
Cache::forget('comment-spam:'.$hash);
}
private function normalize(string $content): string
{
return mb_strtolower((string) preg_replace('/\s+/u', ' ', trim($content)));
}
private function isObviousPromotionalSpam(string $content, array $matchedLinks): bool
{
$links = array_values(array_unique(array_map(
fn (string $link): string => mb_strtolower(trim($link)),
array_filter($matchedLinks, 'is_string'),
)));
preg_match_all('#https?://[^\s<>\[\]"\'`\)]+#iu', $content, $matches);
$allLinks = array_map('mb_strtolower', $matches[0] ?? []);
$hasRepeatedLink = count($allLinks) >= 2 && count(array_unique($allLinks)) < count($allLinks);
$hasPromotion = preg_match('/\b(copy\s*(?:&|and)\s*paste|buy\s+now|cheap\s+seo|guaranteed\s+traffic|visit\s+my\s+profile)\b/iu', $content) === 1;
return $hasRepeatedLink || ($hasPromotion && $links !== []);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Services\Moderation;
use App\Contracts\Moderation\CommentSpamClassifier;
use App\Data\Moderation\CommentSpamClassification;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class TogetherCommentSpamClassifier implements CommentSpamClassifier
{
public function classify(string $content): CommentSpamClassification
{
$config = (array) config('comment_spam.ai', []);
$key = (string) ($config['api_key'] ?? '');
$model = (string) ($config['model'] ?? '');
if ($key === '' || $model === '') {
throw new RuntimeException('Together AI is not configured.');
}
if (! in_array($model, (array) ($config['allowed_models'] ?? []), true)) {
throw new RuntimeException('Unsupported Together AI comment spam model.');
}
$started = microtime(true);
$response = Http::timeout(min(5, max(1, (int) ($config['timeout'] ?? 5))))
->withToken($key)
->post(rtrim((string) ($config['base_url'] ?? 'https://api.together.xyz/v1'), '/').'/chat/completions', [
'model' => $model,
'temperature' => 0,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => 'Classify the comment as spam or not spam. Return only JSON: {"spam":true|false,"confidence":0.0,"reason":"short explanation"}. Confidence must be a number from 0 to 1. Spam includes advertising, SEO promotion, unsolicited links, scams, and bot-like promotional repetition.'],
['role' => 'user', 'content' => mb_substr($content, 0, (int) ($config['max_input_chars'] ?? 2500))],
],
]);
if ($response->failed()) {
throw new RuntimeException('Together AI request failed with status '.$response->status().'.');
}
$message = Arr::get($response->json(), 'choices.0.message.content');
$decoded = is_string($message) ? json_decode($message, true) : null;
if (! is_array($decoded) || ! is_bool($decoded['spam'] ?? null)
|| ! is_numeric($decoded['confidence'] ?? null)
|| $decoded['confidence'] < 0 || $decoded['confidence'] > 1
|| ! is_string($decoded['reason'] ?? null)) {
throw new RuntimeException('Together AI returned invalid moderation JSON.');
}
return new CommentSpamClassification(
$decoded['spam'],
(float) $decoded['confidence'],
mb_substr(trim($decoded['reason']), 0, 500),
'together',
$model,
(int) round((microtime(true) - $started) * 1000),
);
}
}