From 938d757cc891f1f3c8dc4283b59943f7294536b7 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Tue, 30 Jun 2026 15:09:31 +0100 Subject: [PATCH 1/2] support copilot: make role-add extraction AI-first The role-add action now takes the role and target emails from the Cursor triage result (emails via the case target/secondary fields, role via new role_name/role_operation triage outputs), falling back to the deterministic SupportRoleRequestParser only when triage produced nothing usable. - Cursor triage schema + prompt now emit role_name/role_operation - heuristic triage exposes the same fields so the schema is stable - new SupportRoleRequestResolver centralises AI-first-with-parser-fallback - UserRoleAddService and proposedActionForCase use the resolver - dry-run + APPROVE gate is unchanged; reviewer still confirms the full list Tests: resolver AI-first + fallback, plus existing role/email copy suites. Co-authored-by: Cursor --- .../Agents/CursorCliTriageProvider.php | 18 +++- .../Support/Agents/TriageAgentService.php | 2 + .../Support/SupportApprovalEmailService.php | 6 +- .../Support/SupportRoleRequestResolver.php | 98 +++++++++++++++++++ app/Services/Support/UserRoleAddService.php | 10 +- .../SupportApprovalCompletionEmailTest.php | 2 +- .../SupportCompletionEmailCopyTest.php | 2 +- .../Support/SupportDryRunEmailCopyTest.php | 4 +- .../SupportRoleRequestResolverTest.php | 74 ++++++++++++++ 9 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 app/Services/Support/SupportRoleRequestResolver.php create mode 100644 tests/Unit/Support/SupportRoleRequestResolverTest.php 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..cec4b8cec --- /dev/null +++ b/app/Services/Support/SupportRoleRequestResolver.php @@ -0,0 +1,98 @@ +, source: array{role: string, emails: string}} + */ + public function resolve(SupportCase $case): array + { + $parsed = $this->parser->parse((string) ($case->normalized_message ?? $case->raw_message ?? '')); + $triage = $this->triageOutput($case); + + $aiRole = $this->stringOrNull($triage['role_name'] ?? null); + $aiOperation = $this->stringOrNull($triage['role_operation'] ?? null); + $aiEmails = $this->emailsFromCase($case); + + $role = $aiRole ?? $parsed['role']; + $roleSource = $aiRole !== null ? 'ai' : ($parsed['role'] !== null ? 'parser' : 'none'); + + $emails = $aiEmails !== [] ? $aiEmails : $parsed['emails']; + $emailSource = $aiEmails !== [] ? 'ai' : ($parsed['emails'] !== [] ? 'parser' : 'none'); + + $operation = in_array($aiOperation, ['add', 'remove'], true) + ? $aiOperation + : ($parsed['operation'] ?: 'add'); + + return [ + 'operation' => $operation, + 'role' => $role, + 'emails' => $emails, + 'source' => ['role' => $roleSource, 'emails' => $emailSource], + ]; + } + + /** + * @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..dbf1c5f48 --- /dev/null +++ b/tests/Unit/Support/SupportRoleRequestResolverTest.php @@ -0,0 +1,74 @@ + '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']['role']); + $this->assertSame('ai', $resolved['source']['emails']); + } + + public function test_falls_back_to_parser_when_triage_has_no_role(): void + { + $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('parser', $resolved['source']['role']); + $this->assertSame('parser', $resolved['source']['emails']); + } +} From 47c7452b08ab76303d8ba1b8c54ad81848245cb0 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Tue, 30 Jun 2026 15:17:02 +0100 Subject: [PATCH 2/2] support copilot: gate role-add fallback on AI-enabled flag Role-add resolution is now strictly AI-first: when Cursor triage is enabled (support_ai.enabled + triage.enabled) the resolver trusts the triage result exclusively (role + emails), and only uses the deterministic SupportRoleRequestParser when AI triage is disabled. The triage result is already "AI over heuristic", so the AI path still degrades gracefully if a given AI call fails. Co-authored-by: Cursor --- .../Support/SupportRoleRequestResolver.php | 75 +++++++++++++------ .../SupportRoleRequestResolverTest.php | 8 +- 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/app/Services/Support/SupportRoleRequestResolver.php b/app/Services/Support/SupportRoleRequestResolver.php index cec4b8cec..4e00aea05 100644 --- a/app/Services/Support/SupportRoleRequestResolver.php +++ b/app/Services/Support/SupportRoleRequestResolver.php @@ -5,12 +5,15 @@ use App\Models\Support\SupportCase; /** - * Resolve the role-change request for a case, AI-first. + * Resolve the role-change request for a case. * - * Emails and role come from the triage result (Cursor AI when enabled, the - * heuristic otherwise) which is stored on the case and the triage action. - * The deterministic SupportRoleRequestParser is used only as a fallback when - * the triage layer did not produce a usable value. + * AI-first by design: when Cursor triage is enabled we trust the triage result + * (role + emails) exclusively. The triage result is itself already "AI over + * heuristic" — if the AI call fails for a run, TriageAgentService keeps the + * heuristic values, so this path degrades gracefully without re-parsing here. + * + * The deterministic SupportRoleRequestParser is used ONLY when AI triage is not + * enabled. */ class SupportRoleRequestResolver { @@ -20,35 +23,63 @@ public function __construct( } /** - * @return array{operation: string, role: ?string, emails: list, source: array{role: string, emails: string}} + * @return array{operation: string, role: ?string, emails: list, source: array{mode: string, role: string, emails: string}} */ public function resolve(SupportCase $case): array { - $parsed = $this->parser->parse((string) ($case->normalized_message ?? $case->raw_message ?? '')); - $triage = $this->triageOutput($case); - - $aiRole = $this->stringOrNull($triage['role_name'] ?? null); - $aiOperation = $this->stringOrNull($triage['role_operation'] ?? null); - $aiEmails = $this->emailsFromCase($case); - - $role = $aiRole ?? $parsed['role']; - $roleSource = $aiRole !== null ? 'ai' : ($parsed['role'] !== null ? 'parser' : 'none'); + return $this->aiEnabled() + ? $this->resolveFromTriage($case) + : $this->resolveFromParser($case); + } - $emails = $aiEmails !== [] ? $aiEmails : $parsed['emails']; - $emailSource = $aiEmails !== [] ? 'ai' : ($parsed['emails'] !== [] ? 'parser' : 'none'); + /** + * @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); - $operation = in_array($aiOperation, ['add', 'remove'], true) - ? $aiOperation - : ($parsed['operation'] ?: 'add'); + $role = $this->stringOrNull($triage['role_name'] ?? null); + $operation = $this->stringOrNull($triage['role_operation'] ?? null); + $emails = $this->emailsFromCase($case); return [ - 'operation' => $operation, + 'operation' => in_array($operation, ['add', 'remove'], true) ? $operation : 'add', 'role' => $role, 'emails' => $emails, - 'source' => ['role' => $roleSource, 'emails' => $emailSource], + '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 */ diff --git a/tests/Unit/Support/SupportRoleRequestResolverTest.php b/tests/Unit/Support/SupportRoleRequestResolverTest.php index dbf1c5f48..08e261a2e 100644 --- a/tests/Unit/Support/SupportRoleRequestResolverTest.php +++ b/tests/Unit/Support/SupportRoleRequestResolverTest.php @@ -13,6 +13,8 @@ final class SupportRoleRequestResolverTest extends TestCase public function test_prefers_ai_triage_role_and_case_emails(): void { + config(['support_ai.enabled' => true, 'support_ai.triage.enabled' => true]); + $case = SupportCase::create([ 'source_channel' => 'gmail', 'processing_mode' => 'automated', @@ -45,12 +47,15 @@ public function test_prefers_ai_triage_role_and_case_emails(): void $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_falls_back_to_parser_when_triage_has_no_role(): void + public function test_uses_deterministic_parser_when_ai_disabled(): void { + config(['support_ai.enabled' => false]); + $case = SupportCase::create([ 'source_channel' => 'gmail', 'processing_mode' => 'automated', @@ -68,6 +73,7 @@ public function test_falls_back_to_parser_when_triage_has_no_role(): void $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']); }