diff --git a/app/Services/Support/Agents/CursorCliTriageProvider.php b/app/Services/Support/Agents/CursorCliTriageProvider.php index 5664cc014..c9ac3ff18 100644 --- a/app/Services/Support/Agents/CursorCliTriageProvider.php +++ b/app/Services/Support/Agents/CursorCliTriageProvider.php @@ -135,7 +135,9 @@ private function buildPrompt(SupportCase $case, string $rawText): string Use "code_change" only when the request is about a bug or change in the website/application code (frontend or template/markup/styling/behaviour) that a developer would fix in the repository. Use "role_add" when the request is to add/grant a role (e.g. "leading teacher") to one or more -users identified by email. Put the affected emails in target_email/secondary_emails. +users identified by email. Put the affected emails in target_email/secondary_emails, put the +role being granted in "role_name" (singular, e.g. "leading teacher"), and set "role_operation" +to "add". Include EVERY email listed in the request (one per person), even for long pasted tables. {$artisanBlock}{$contentBlock} JSON schema to return: { @@ -149,6 +151,8 @@ private function buildPrompt(SupportCase $case, string $rawText): string "reasoning_summary": "", "profile_firstname": "", "profile_lastname": "", + "role_name": "", + "role_operation": "", "change_summary": "", "change_area": "", "cursor_prompt": "", @@ -321,6 +325,8 @@ private function normalize(array $data): array 'requested_action' => $requestedAction, 'profile_firstname' => $this->stringOrNull($data['profile_firstname'] ?? null), 'profile_lastname' => $this->stringOrNull($data['profile_lastname'] ?? null), + 'role_name' => $this->stringOrNull($data['role_name'] ?? null), + 'role_operation' => $this->normalizeRoleOperation($data['role_operation'] ?? null), 'risk_level' => $risk, 'recommended_runbook' => $this->stringOrNull($data['recommended_runbook'] ?? null) ?? $caseType, 'needs_human_review' => (bool) ($data['needs_human_review'] ?? false), @@ -339,6 +345,16 @@ private function normalize(array $data): array ]; } + private function normalizeRoleOperation(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + $value = strtolower(trim($value)); + + return in_array($value, ['add', 'grant', 'assign'], true) ? 'add' : null; + } + private function stringOrNull(mixed $value): ?string { if (!is_string($value)) { diff --git a/app/Services/Support/Agents/TriageAgentService.php b/app/Services/Support/Agents/TriageAgentService.php index 76b9d4f67..5cf243d71 100644 --- a/app/Services/Support/Agents/TriageAgentService.php +++ b/app/Services/Support/Agents/TriageAgentService.php @@ -132,6 +132,8 @@ private function heuristicTriage(SupportCase $case): array 'requested_action' => $requestedAction, 'profile_firstname' => $profile['firstname'], 'profile_lastname' => $profile['lastname'], + 'role_name' => $hasRoleRequest ? $roleRequest['role'] : null, + 'role_operation' => $hasRoleRequest ? $roleRequest['operation'] : null, 'risk_level' => $risk, 'recommended_runbook' => $runbook, 'needs_human_review' => $needsHuman, diff --git a/app/Services/Support/SupportApprovalEmailService.php b/app/Services/Support/SupportApprovalEmailService.php index f6aff8739..fe2e0aa66 100644 --- a/app/Services/Support/SupportApprovalEmailService.php +++ b/app/Services/Support/SupportApprovalEmailService.php @@ -15,7 +15,7 @@ public function __construct( private readonly GmailOutboundService $gmail, private readonly SupportSenderAllowlist $allowlist, private readonly SupportProfileRequestParser $profileParser, - private readonly SupportRoleRequestParser $roleParser, + private readonly SupportRoleRequestResolver $roleResolver, ) { } @@ -283,12 +283,12 @@ private function proposedActionForCase(SupportCase $case): array } if ($case->case_type === 'role_add') { - $role = $this->roleParser->parse((string) ($case->normalized_message ?? $case->raw_message ?? '')); + $role = $this->roleResolver->resolve($case); if ($role['role'] !== null && $role['emails'] !== []) { return [ 'action' => 'user_role_add', 'payload' => [ - 'operation' => 'add', + 'operation' => $role['operation'], 'role' => $role['role'], 'emails' => $role['emails'], ], diff --git a/app/Services/Support/SupportRoleRequestResolver.php b/app/Services/Support/SupportRoleRequestResolver.php new file mode 100644 index 000000000..4e00aea05 --- /dev/null +++ b/app/Services/Support/SupportRoleRequestResolver.php @@ -0,0 +1,129 @@ +, source: array{mode: string, role: string, emails: string}} + */ + public function resolve(SupportCase $case): array + { + return $this->aiEnabled() + ? $this->resolveFromTriage($case) + : $this->resolveFromParser($case); + } + + /** + * @return array{operation: string, role: ?string, emails: list, source: array{mode: string, role: string, emails: string}} + */ + private function resolveFromTriage(SupportCase $case): array + { + $triage = $this->triageOutput($case); + + $role = $this->stringOrNull($triage['role_name'] ?? null); + $operation = $this->stringOrNull($triage['role_operation'] ?? null); + $emails = $this->emailsFromCase($case); + + return [ + 'operation' => in_array($operation, ['add', 'remove'], true) ? $operation : 'add', + 'role' => $role, + 'emails' => $emails, + 'source' => [ + 'mode' => 'ai', + 'role' => $role !== null ? 'ai' : 'none', + 'emails' => $emails !== [] ? 'ai' : 'none', + ], + ]; + } + + /** + * @return array{operation: string, role: ?string, emails: list, source: array{mode: string, role: string, emails: string}} + */ + private function resolveFromParser(SupportCase $case): array + { + $parsed = $this->parser->parse((string) ($case->normalized_message ?? $case->raw_message ?? '')); + + return [ + 'operation' => $parsed['operation'] ?: 'add', + 'role' => $parsed['role'], + 'emails' => $parsed['emails'], + 'source' => [ + 'mode' => 'deterministic', + 'role' => $parsed['role'] !== null ? 'parser' : 'none', + 'emails' => $parsed['emails'] !== [] ? 'parser' : 'none', + ], + ]; + } + + private function aiEnabled(): bool + { + return (bool) config('support_ai.enabled', false) + && (bool) config('support_ai.triage.enabled', true); + } + + /** + * @return list + */ + private function emailsFromCase(SupportCase $case): array + { + $candidates = []; + if ($case->target_email) { + $candidates[] = (string) $case->target_email; + } + foreach ((array) ($case->secondary_emails ?? []) as $email) { + $candidates[] = (string) $email; + } + + $out = []; + foreach ($candidates as $email) { + $normalized = SupportEmailAddress::normalize($email); + if ($normalized !== null) { + $out[$normalized] = $normalized; + } + } + + return array_values($out); + } + + /** + * @return array + */ + private function triageOutput(SupportCase $case): array + { + $output = $case->actions() + ->where('action_name', 'triage') + ->latest() + ->first()?->output_json; + + return is_array($output) ? $output : []; + } + + private function stringOrNull(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + $value = trim($value); + + return $value === '' ? null : $value; + } +} diff --git a/app/Services/Support/UserRoleAddService.php b/app/Services/Support/UserRoleAddService.php index 784d8e44e..eb286bba4 100644 --- a/app/Services/Support/UserRoleAddService.php +++ b/app/Services/Support/UserRoleAddService.php @@ -17,21 +17,21 @@ class UserRoleAddService { public function __construct( - private readonly SupportRoleRequestParser $parser, + private readonly SupportRoleRequestResolver $resolver, ) { } public function addFromCase(SupportCase $case, bool $dryRun, bool $viaEmailApproval = false): array { - $parsed = $this->parser->parse((string) ($case->normalized_message ?? $case->raw_message ?? '')); + $resolved = $this->resolver->resolve($case); return $this->addRole( case: $case, - roleName: $parsed['role'], - emails: $parsed['emails'], + roleName: $resolved['role'], + emails: $resolved['emails'], dryRun: $dryRun, viaEmailApproval: $viaEmailApproval, - operation: $parsed['operation'], + operation: $resolved['operation'], ); } diff --git a/tests/Unit/Support/SupportApprovalCompletionEmailTest.php b/tests/Unit/Support/SupportApprovalCompletionEmailTest.php index 4c4ac1503..ebc12c751 100644 --- a/tests/Unit/Support/SupportApprovalCompletionEmailTest.php +++ b/tests/Unit/Support/SupportApprovalCompletionEmailTest.php @@ -66,7 +66,7 @@ public function test_send_action_completion_calls_gmail(): void $gmail, app(SupportSenderAllowlist::class), app(SupportProfileRequestParser::class), - app(\App\Services\Support\SupportRoleRequestParser::class), + app(\App\Services\Support\SupportRoleRequestResolver::class), ); $payload = $svc->sendActionCompletion( diff --git a/tests/Unit/Support/SupportCompletionEmailCopyTest.php b/tests/Unit/Support/SupportCompletionEmailCopyTest.php index 220e466ae..dd2701235 100644 --- a/tests/Unit/Support/SupportCompletionEmailCopyTest.php +++ b/tests/Unit/Support/SupportCompletionEmailCopyTest.php @@ -56,7 +56,7 @@ public function test_completion_body_uses_plain_language_for_profile_update(): v $gmail, app(SupportSenderAllowlist::class), app(SupportProfileRequestParser::class), - app(\App\Services\Support\SupportRoleRequestParser::class), + app(\App\Services\Support\SupportRoleRequestResolver::class), ); $svc->sendActionCompletion( diff --git a/tests/Unit/Support/SupportDryRunEmailCopyTest.php b/tests/Unit/Support/SupportDryRunEmailCopyTest.php index 0e9b19be5..ceffb5fca 100644 --- a/tests/Unit/Support/SupportDryRunEmailCopyTest.php +++ b/tests/Unit/Support/SupportDryRunEmailCopyTest.php @@ -70,7 +70,7 @@ public function test_dry_run_email_uses_plain_language(): void $gmail, app(SupportSenderAllowlist::class), app(SupportProfileRequestParser::class), - app(\App\Services\Support\SupportRoleRequestParser::class), + app(\App\Services\Support\SupportRoleRequestResolver::class), ); $svc->sendDryRunReview($case, 'admin@matrixinternet.ie'); @@ -144,7 +144,7 @@ public function test_dry_run_email_lists_role_add_targets(): void $gmail, app(SupportSenderAllowlist::class), app(SupportProfileRequestParser::class), - app(\App\Services\Support\SupportRoleRequestParser::class), + app(\App\Services\Support\SupportRoleRequestResolver::class), ); $svc->sendDryRunReview($case, 'admin@matrixinternet.ie'); diff --git a/tests/Unit/Support/SupportRoleRequestResolverTest.php b/tests/Unit/Support/SupportRoleRequestResolverTest.php new file mode 100644 index 000000000..08e261a2e --- /dev/null +++ b/tests/Unit/Support/SupportRoleRequestResolverTest.php @@ -0,0 +1,80 @@ + true, 'support_ai.triage.enabled' => true]); + + $case = SupportCase::create([ + 'source_channel' => 'gmail', + 'processing_mode' => 'automated', + 'subject' => 'add leading teachers', + 'raw_message' => 'Please grant the role to the people below.', + 'normalized_message' => 'Please grant the role to the people below.', + 'target_email' => 'a@example.com', + 'secondary_emails' => ['b@example.com', 'c@example.com'], + 'case_type' => 'role_add', + 'status' => 'diagnosed', + 'risk_level' => 'low', + 'correlation_id' => 'cid-ai', + ]); + + $case->actions()->create([ + 'action_name' => 'triage', + 'action_type' => 'classification', + 'input_json' => [], + 'output_json' => [ + 'case_type' => 'role_add', + 'role_name' => 'leading teacher', + 'role_operation' => 'add', + ], + 'succeeded' => true, + 'executed_by' => 'agent', + ]); + + $resolved = app(SupportRoleRequestResolver::class)->resolve($case); + + $this->assertSame('leading teacher', $resolved['role']); + $this->assertSame('add', $resolved['operation']); + $this->assertSame(['a@example.com', 'b@example.com', 'c@example.com'], $resolved['emails']); + $this->assertSame('ai', $resolved['source']['mode']); + $this->assertSame('ai', $resolved['source']['role']); + $this->assertSame('ai', $resolved['source']['emails']); + } + + public function test_uses_deterministic_parser_when_ai_disabled(): void + { + config(['support_ai.enabled' => false]); + + $case = SupportCase::create([ + 'source_channel' => 'gmail', + 'processing_mode' => 'automated', + 'subject' => 'add leading teachers', + 'raw_message' => "add role: leading teacher\nx@example.com\ny@example.com", + 'normalized_message' => "add role: leading teacher\nx@example.com\ny@example.com", + 'case_type' => 'role_add', + 'status' => 'diagnosed', + 'risk_level' => 'low', + 'correlation_id' => 'cid-fallback', + ]); + + $resolved = app(SupportRoleRequestResolver::class)->resolve($case); + + $this->assertSame('leading teacher', $resolved['role']); + $this->assertSame('add', $resolved['operation']); + $this->assertSame(['x@example.com', 'y@example.com'], $resolved['emails']); + $this->assertSame('deterministic', $resolved['source']['mode']); + $this->assertSame('parser', $resolved['source']['role']); + $this->assertSame('parser', $resolved['source']['emails']); + } +}