Files
calvana/temp_files/AIAppealReviewService.php
Omair Saleh 6fb97e1461 Telepathic onboarding: welcome flow + context-aware dashboard
New: /dashboard/welcome — guided first-time setup
- Step 1: 'What are you raising for?' (starts with what excites them)
- Step 2: 'Where should donors send money?' (natural follow-up)
- Step 3: 'Want auto-reminders?' (WhatsApp as bonus, skippable)
- Step 4: 'Here's your link!' (dark section with copy/WhatsApp/share)
- Auto-creates event + first pledge link during flow
- User holds a shareable link within 90 seconds of signing up

Updated: /dashboard (context-aware home)
- State 1 (no events): auto-redirects to /dashboard/welcome
- State 2 (0 pledges): shows pledge link + share buttons prominently
- State 3 (has pledges): shows stats + feed
- State 4 (has 'said paid'): amber prompt to upload bank statement
- State 5 (100% collected): celebration banner
- No more onboarding checklist — dashboard adapts instead
- Event name as page header (not generic 'Home')
- Event switcher for multi-event orgs

Updated: /signup → redirects to /dashboard/welcome (not /dashboard)

Persona spec: docs/PERSONA_JOURNEY_SPEC.md
2026-03-04 21:01:16 +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' => substr('Auto-flagged: ' . implode('; ', $result['reasons']), 0, 250),
]);
$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'],
];
}
}