Skip to content
Merged

Dev #3595

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion app/Services/Support/Agents/CursorCliTriageProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:
{
Expand All @@ -149,6 +151,8 @@ private function buildPrompt(SupportCase $case, string $rawText): string
"reasoning_summary": "<one sentence>",
"profile_firstname": "<requested first name or null>",
"profile_lastname": "<requested last name or null>",
"role_name": "<for role_add: the role to grant, e.g. leading teacher, else null>",
"role_operation": "<for role_add: add (remove is not supported), else null>",
"change_summary": "<for code_change: one sentence describing the fix, else null>",
"change_area": "<for code_change: e.g. frontend/blade/css/js, else null>",
"cursor_prompt": "<for code_change: a precise instruction for a coding agent to implement the fix and open a PR, else null>",
Expand Down Expand Up @@ -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),
Expand All @@ -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)) {
Expand Down
2 changes: 2 additions & 0 deletions app/Services/Support/Agents/TriageAgentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions app/Services/Support/SupportApprovalEmailService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}

Expand Down Expand Up @@ -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'],
],
Expand Down
129 changes: 129 additions & 0 deletions app/Services/Support/SupportRoleRequestResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

namespace App\Services\Support;

use App\Models\Support\SupportCase;

/**
* Resolve the role-change request for a case.
*
* 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
{
public function __construct(
private readonly SupportRoleRequestParser $parser,
) {
}

/**
* @return array{operation: string, role: ?string, emails: list<string>, 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<string>, 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<string>, 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<string>
*/
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<string, mixed>
*/
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;
}
}
10 changes: 5 additions & 5 deletions app/Services/Support/UserRoleAddService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
);
}

Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/Support/SupportApprovalCompletionEmailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/Support/SupportCompletionEmailCopyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions tests/Unit/Support/SupportDryRunEmailCopyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
80 changes: 80 additions & 0 deletions tests/Unit/Support/SupportRoleRequestResolverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace Tests\Unit\Support;

use App\Models\Support\SupportCase;
use App\Services\Support\SupportRoleRequestResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class SupportRoleRequestResolverTest extends TestCase
{
use RefreshDatabase;

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',
'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']);
}
}
Loading