Files
calvana/temp_files/AIAppealReviewService.php
Omair Saleh 170a2e7c68 Complete dashboard UI overhaul: persona journeys + brand unification
Navigation: goal-oriented, not feature-oriented
- Overview → Home
- Campaigns → Collect ('I want people to pledge')
- Pledges → Money ('Where's the money?')
- Exports → Reports ('My treasurer needs numbers')
- Old routes still work via re-exports

Terminology: human language, not SaaS jargon
- new → Waiting
- initiated → Said they paid
- paid → Received ✓
- overdue → Needs a nudge
- Campaign → Appeal
- QR Source → Pledge link
- Reconcile → Match payments
- Rail → Payment method
- Pipeline by Status → How pledges are doing
- Conversion rate → % who pledged
- CRM Export Pack → Full data download

Visual identity: brand-consistent dashboard
- Sharp edges (no rounded-lg cards)
- Gap-px grids for stats (brand signature pattern)
- Left-border accents (brand signature pattern)
- Midnight/Paper/Promise Blue 60-30-10 color rule
- Typography as hero (big bold numbers, not card-heavy)
- No emoji in UI chrome
- Brand-consistent status badges (colored bg + text, not shadcn Badge)
- Consistent header typography (text-3xl font-black tracking-tight)

Pages rewritten: layout, home, events (collect), pledges (money),
exports (reports), reconcile, settings

Reconcile: auto-detects bank CSV format via presets + AI before upload

UX spec: docs/UX_OVERHAUL_SPEC.md
2026-03-04 20:50:42 +08:00

277 lines
12 KiB
PHP

<?php
namespace App\Services;
use App\Models\Appeal;
use App\Models\ApprovalQueue;
use App\Models\DonationCountry;
use App\Models\DonationType;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AIAppealReviewService
{
/**
* Review an appeal and return a structured verdict.
*/
public function review(Appeal $appeal): array
{
// First, run fast deterministic checks (no AI needed)
$preflightResult = $this->preflightChecks($appeal);
if ($preflightResult !== null) {
return $preflightResult;
}
// Run AI review for content that passes preflight
return $this->aiReview($appeal);
}
/**
* Bulk review all pending approvals.
*/
public function reviewAllPending(): array
{
$pending = ApprovalQueue::where('status', 'pending')
->with('appeal')
->get();
$stats = ['reviewed' => 0, 'approved' => 0, 'rejected' => 0, 'flagged' => 0];
foreach ($pending as $item) {
if (!$item->appeal) {
$item->update(['status' => 'confirmed', 'message' => 'Auto-closed: fundraiser was deleted']);
$stats['reviewed']++;
$stats['rejected']++;
continue;
}
$result = $this->review($item->appeal);
$stats['reviewed']++;
$item->update([
'extra_data' => json_encode([
'ai_review' => $result,
'previous_extra_data' => $item->extra_data,
]),
]);
if ($result['decision'] === 'approve') {
app(ApprovalQueueService::class)->approveAppeal($item);
$stats['approved']++;
} elseif ($result['decision'] === 'reject') {
$item->update([
'status' => 'change_requested',
'message' => 'Auto-flagged: ' . implode('; ', $result['reasons']),
]);
$appeal = $item->appeal;
$appeal->status = 'change_requested';
$appeal->save();
$stats['rejected']++;
} else {
$stats['flagged']++;
}
}
return $stats;
}
/**
* Fast deterministic checks that don't need AI.
*/
protected function preflightChecks(Appeal $appeal): ?array
{
$flags = [];
$reasons = [];
// 1. Check for duplicate names by same user
$duplicateCount = Appeal::where('id', '!=', $appeal->id)
->where('name', $appeal->name)
->where('user_id', $appeal->user_id)
->count();
if ($duplicateCount > 0) {
return [
'decision' => 'reject',
'confidence' => 0.99,
'reasons' => ['Duplicate fundraiser — same person already created an identical page'],
'summary' => 'This is a duplicate of an existing fundraiser by the same person.',
'flags' => ['duplicate'],
];
}
// 2. Check for obvious spam keywords
$spamKeywords = [
'buy adderall', 'buy xanax', 'buy viagra', 'buy oxycodone', 'buy tramadol',
'casino', 'gambling', 'poker online', 'sports betting',
'forex trading', 'crypto trading', 'bitcoin investment',
'weight loss pill', 'diet pill',
'seo service', 'web development service', 'digital marketing agency',
];
$nameLower = strtolower($appeal->name ?? '');
$storyLower = strtolower(strip_tags($appeal->story ?? ''));
foreach ($spamKeywords as $keyword) {
if (str_contains($nameLower, $keyword) || str_contains($storyLower, $keyword)) {
return [
'decision' => 'reject',
'confidence' => 0.99,
'reasons' => ["Contains prohibited content: {$keyword}"],
'summary' => 'This fundraiser contains spam/prohibited content.',
'flags' => ['spam', 'prohibited-content'],
];
}
}
// 3. Check for excessive external links (SEO spam pattern)
$externalLinkCount = preg_match_all(
'/href=["\'](?!https?:\/\/(www\.)?charityright)/i',
$appeal->story ?? '',
$matches
);
if ($externalLinkCount >= 3) {
$flags[] = 'excessive-external-links';
$reasons[] = "Contains {$externalLinkCount} external links (possible SEO spam)";
}
// 4. Check if story is too short
$plainStory = trim(strip_tags($appeal->story ?? ''));
if (strlen($plainStory) < 50) {
$flags[] = 'story-too-short';
$reasons[] = 'Story is very short (' . strlen($plainStory) . ' characters)';
}
// 5. Check if donation type exists
if (!$appeal->donation_type_id || !DonationType::find($appeal->donation_type_id)) {
$flags[] = 'invalid-donation-type';
$reasons[] = 'No valid cause selected';
}
if (count($flags) > 0) {
return [
'decision' => 'review',
'confidence' => 0.7,
'reasons' => $reasons,
'summary' => 'Automatic checks flagged potential issues that need human review.',
'flags' => $flags,
];
}
return null;
}
/**
* AI-powered content review using Claude.
*/
protected function aiReview(Appeal $appeal): array
{
$apiKey = config('services.anthropic.api_key');
if (!$apiKey) {
Log::warning('AIAppealReview: No Anthropic API key configured');
return $this->fallbackResult('No API key configured');
}
$donationTypes = DonationType::pluck('display_name')->join(', ');
$donationCountries = DonationCountry::pluck('name')->join(', ');
$plainStory = strip_tags($appeal->story ?? '');
$appealOwner = $appeal->user?->name ?? 'Unknown';
$appealOwnerEmail = $appeal->user?->email ?? 'Unknown';
$isInMemory = $appeal->is_in_memory ? 'Yes' : 'No';
$prompt = "You are a compliance reviewer for CharityRight, a UK-registered Muslim charity (Charity Commission regulated).\n\n";
$prompt .= "CharityRight's mission: Providing food (school meals, family meals), emergency humanitarian relief, and Islamic religious observance programs (Qurbani, Zakat, Fidya, Kaffarah) across Muslim-majority countries affected by poverty and conflict.\n\n";
$prompt .= "CHARITY'S ACTIVE PROGRAMS: {$donationTypes}\n";
$prompt .= "COUNTRIES OF OPERATION: {$donationCountries}\n\n";
$prompt .= "A member of the public has created a fundraising page on our platform. You must decide if this fundraising page should be approved to go live on our charity's website.\n\n";
$prompt .= "FUNDRAISER DETAILS:\n";
$prompt .= "- Title: {$appeal->name}\n";
$prompt .= "- Description: {$appeal->description}\n";
$prompt .= "- Created by: {$appealOwner} ({$appealOwnerEmail})\n";
$prompt .= "- Cause: " . ($appeal->donationType?->display_name ?? 'None') . "\n";
$prompt .= "- Target amount: £{$appeal->amount_to_raise}\n";
$prompt .= "- In memory of someone: {$isInMemory}\n";
$prompt .= "- Story/content:\n{$plainStory}\n\n";
$prompt .= "REVIEW CRITERIA — Check ALL of these:\n\n";
$prompt .= "1. RELEVANCE: Is this fundraiser related to CharityRight's charitable purposes? (food aid, emergency relief, Islamic programs, humanitarian causes)\n";
$prompt .= "2. LEGITIMACY: Does this look like a genuine fundraising effort, not spam, SEO content, or marketing?\n";
$prompt .= "3. COMPLIANCE: Does the content comply with UK Charity Commission regulations? No political campaigning, no commercial activity, no personal enrichment.\n";
$prompt .= "4. CONTENT QUALITY: Is the story coherent and appropriate for a charity website?\n";
$prompt .= "5. SAFETY: No hate speech, no extremism, no content that could damage the charity's reputation.\n";
$prompt .= "6. LINKS: Are there suspicious external links that suggest SEO spam or affiliate marketing?\n\n";
$prompt .= "RESPOND IN THIS EXACT JSON FORMAT (no markdown, no code blocks, just raw JSON):\n";
$prompt .= "{\"decision\": \"approve\" or \"reject\" or \"review\", \"confidence\": 0.0 to 1.0, \"reasons\": [\"reason 1\"], \"summary\": \"One sentence\", \"flags\": [\"flag1\"]}\n\n";
$prompt .= "Rules:\n";
$prompt .= "- \"approve\" = clearly legitimate fundraiser aligned with charity's mission\n";
$prompt .= "- \"reject\" = clearly spam, prohibited content, or violates charity regulations\n";
$prompt .= "- \"review\" = uncertain, needs human judgment (use this when confidence < 0.7)\n";
$prompt .= "- Be strict about spam but generous with genuine supporters who may have imperfect English\n";
$prompt .= "- A sincere person raising money for food/meals/emergency relief = approve\n";
$prompt .= "- Blog-style content with external links about travel/umrah packages/quran apps = reject (SEO spam)\n";
$prompt .= "- Drug sales, commercial products, gambling = reject immediately\n";
try {
$response = Http::withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])
->timeout(30)
->post('https://api.anthropic.com/v1/messages', [
'model' => 'claude-sonnet-4-20250514',
'max_tokens' => 500,
'messages' => [
['role' => 'user', 'content' => $prompt],
],
]);
if (!$response->successful()) {
Log::error('AIAppealReview: API error', ['status' => $response->status(), 'body' => $response->body()]);
return $this->fallbackResult('API error: ' . $response->status());
}
$content = $response->json('content.0.text', '');
$content = trim($content);
$content = preg_replace('/^```json?\s*/m', '', $content);
$content = preg_replace('/```\s*$/m', '', $content);
$content = trim($content);
$result = json_decode($content, true);
if (!$result || !isset($result['decision'])) {
Log::warning('AIAppealReview: Could not parse response', ['content' => $content]);
return $this->fallbackResult('Could not parse AI response');
}
if (!in_array($result['decision'], ['approve', 'reject', 'review'])) {
$result['decision'] = 'review';
}
return [
'decision' => $result['decision'],
'confidence' => (float) ($result['confidence'] ?? 0.5),
'reasons' => (array) ($result['reasons'] ?? []),
'summary' => (string) ($result['summary'] ?? ''),
'flags' => (array) ($result['flags'] ?? []),
];
} catch (\Throwable $e) {
Log::error('AIAppealReview: Exception', ['message' => $e->getMessage()]);
return $this->fallbackResult($e->getMessage());
}
}
protected function fallbackResult(string $reason): array
{
return [
'decision' => 'review',
'confidence' => 0.0,
'reasons' => ["AI review failed: {$reason}"],
'summary' => 'Automated review unavailable. Needs manual review.',
'flags' => ['ai-error'],
];
}
}