-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathCursorCliTriageProvider.php
More file actions
354 lines (309 loc) · 13.3 KB
/
Copy pathCursorCliTriageProvider.php
File metadata and controls
354 lines (309 loc) · 13.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
<?php
namespace App\Services\Support\Agents;
use App\Models\Support\SupportCase;
use App\Services\Support\Artisan\ArtisanActionRegistry;
use App\Services\Support\Content\ContentActionRegistry;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
/**
* AI triage brain backed by the Cursor headless CLI:
*
* agent -p --output-format json --model <model> "<prompt>"
*
* Authenticated via the CURSOR_API_KEY env var. Returns a triage result in the
* same stable schema as the deterministic TriageAgentService, plus the
* `code_change` case type for frontend/code fixes.
*/
class CursorCliTriageProvider implements TriageProvider
{
/** Case types the model is allowed to choose. */
private const CASE_TYPES = [
'account_restore',
'profile_update',
'duplicate_account',
'missing_events',
'certificate_issue',
'role_issue',
'role_add',
'code_change',
'artisan_command',
'content_update',
'unknown',
];
public function __construct(
private readonly ArtisanActionRegistry $registry,
private readonly ContentActionRegistry $contentRegistry,
) {
}
public function available(): bool
{
return (bool) config('support_ai.enabled')
&& (bool) config('support_ai.triage.enabled')
&& trim((string) config('support_ai.cursor_api_key', '')) !== '';
}
private function artisanEnabled(): bool
{
return (bool) config('support_ai.artisan.enabled');
}
private function contentEnabled(): bool
{
return (bool) config('support_ai.content.enabled');
}
/** @return list<string> case types offered to the model this run. */
private function offeredCaseTypes(): array
{
return array_values(array_filter(self::CASE_TYPES, function (string $type): bool {
return match ($type) {
'artisan_command' => $this->artisanEnabled(),
'content_update' => $this->contentEnabled(),
default => true,
};
}));
}
public function triage(SupportCase $case): ?array
{
if (!$this->available()) {
return null;
}
$rawText = (string) ($case->normalized_message ?? $case->raw_message ?? '');
if (trim($rawText) === '') {
return null;
}
try {
$result = Process::timeout((int) config('support_ai.triage.timeout_seconds', 120))
->path(base_path())
->env(['CURSOR_API_KEY' => (string) config('support_ai.cursor_api_key')])
->run([
(string) config('support_ai.triage.cli_bin', 'agent'),
'-p',
// --force trusts the workspace non-interactively (no Workspace Trust prompt).
'--force',
'--output-format', 'json',
'--model', (string) config('support_ai.triage.model', 'gpt-5.5'),
$this->buildPrompt($case, $rawText),
]);
} catch (\Throwable $e) {
Log::warning('Cursor CLI triage failed to run', ['case_id' => $case->id, 'error' => $e->getMessage()]);
return null;
}
if (!$result->successful()) {
Log::warning('Cursor CLI triage non-zero exit', [
'case_id' => $case->id,
'exit' => $result->exitCode(),
'stderr' => mb_substr($result->errorOutput(), 0, 500),
]);
return null;
}
$parsed = $this->parseModelJson($result->output());
if ($parsed === null) {
Log::warning('Cursor CLI triage produced unparseable output', ['case_id' => $case->id]);
return null;
}
return $this->normalize($parsed);
}
private function buildPrompt(SupportCase $case, string $rawText): string
{
$types = implode(', ', $this->offeredCaseTypes());
// Keep the ticket text bounded so the CLI invocation stays small.
$ticket = mb_substr($rawText, 0, 6000);
$artisanBlock = $this->artisanEnabled() ? $this->artisanPromptBlock() : '';
$contentBlock = $this->contentEnabled() ? $this->contentPromptBlock() : '';
return <<<PROMPT
You are the triage brain for the CodeWeek support copilot. Classify ONE support ticket.
Do NOT make any code changes, run tools, or edit files. Respond with a single JSON object ONLY (no prose, no code fences).
Allowed case_type values: {$types}
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.
{$artisanBlock}{$contentBlock}
JSON schema to return:
{
"case_type": "<one allowed value>",
"confidence": <number 0..1>,
"target_email": "<the affected user's email, or null>",
"secondary_emails": ["<other emails mentioned>"],
"risk_level": "low|medium|high",
"recommended_runbook": "<short snake_case label>",
"needs_human_review": <true|false>,
"reasoning_summary": "<one sentence>",
"profile_firstname": "<requested first name or null>",
"profile_lastname": "<requested last name or 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>",
"artisan_command_name": "<for artisan_command: an allowlisted command name, else null>",
"artisan_args": {},
"artisan_raw_command": "<for artisan_command: a raw artisan command WITHOUT 'php artisan' prefix if no allowlisted command fits, else null>",
"content_model": "<for content_update: an allowlisted content model key, else null>",
"content_identifier": "<for content_update: the record id or unique reference; null for single-row pages>",
"content_changes": {},
"content_summary": "<for content_update: one sentence describing the copy change, else null>"
}
Ticket subject: {$case->subject}
Ticket body:
\"\"\"
{$ticket}
\"\"\"
PROMPT;
}
private function artisanPromptBlock(): string
{
$lines = [];
foreach ($this->registry->all() as $name => $spec) {
$args = array_keys((array) ($spec['arguments'] ?? []));
$argList = $args === [] ? '' : ' (args: '.implode(', ', $args).')';
$lines[] = "- {$name}{$argList} — {$spec['description']}";
}
$allow = implode("\n", $lines);
return <<<BLOCK
Use "artisan_command" only when the fix requires running a server maintenance command.
Prefer an allowlisted command and put its name in "artisan_command_name" with values in "artisan_args"
(keys = argument/option names, e.g. {"email":"user@example.com","--firstname":"Ada"}).
Allowlisted commands:
{$allow}
If none fits, set "artisan_command_name" to null and put the bare artisan command in "artisan_raw_command"
(no "php artisan" prefix, no shell operators). Destructive commands will be rejected.
BLOCK;
}
private function contentPromptBlock(): string
{
$keys = implode(', ', $this->contentRegistry->keys());
return <<<BLOCK
Use "content_update" only when the request is to change editorial text/copy on an existing
page or content record (e.g. fix a typo, reword a paragraph, update a heading). Put the
content model key in "content_model" (one of: {$keys}), the record reference in
"content_identifier" (an id or unique reference; null for single-row pages), and the
field→new-text pairs in "content_changes" (e.g. {"hero_title":"New heading"}).
Plain text only — no HTML, no links/URLs. Use "code_change" instead if it needs a developer.
BLOCK;
}
/**
* The CLI's --output-format json wraps the agent result; the model's JSON
* may be the whole payload, a "result"/"text" field, or embedded in prose.
*
* @return array<string, mixed>|null
*/
private function parseModelJson(string $stdout): ?array
{
$stdout = trim($stdout);
if ($stdout === '') {
return null;
}
$direct = json_decode($stdout, true);
if (is_array($direct)) {
if ($this->looksLikeTriage($direct)) {
return $direct;
}
foreach (['result', 'text', 'output', 'response', 'message'] as $key) {
if (isset($direct[$key]) && is_string($direct[$key])) {
$inner = $this->extractFirstJsonObject($direct[$key]);
if ($inner !== null) {
return $inner;
}
}
}
}
return $this->extractFirstJsonObject($stdout);
}
/**
* @return array<string, mixed>|null
*/
private function extractFirstJsonObject(string $text): ?array
{
if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $text, $m)) {
$decoded = json_decode($m[0], true);
if (is_array($decoded) && $this->looksLikeTriage($decoded)) {
return $decoded;
}
}
return null;
}
/**
* @param array<string, mixed> $data
*/
private function looksLikeTriage(array $data): bool
{
return array_key_exists('case_type', $data);
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function normalize(array $data): array
{
$caseType = is_string($data['case_type'] ?? null) ? strtolower(trim($data['case_type'])) : 'unknown';
if (!in_array($caseType, self::CASE_TYPES, true)) {
$caseType = 'unknown';
}
$risk = is_string($data['risk_level'] ?? null) ? strtolower(trim($data['risk_level'])) : 'low';
if (!in_array($risk, ['low', 'medium', 'high'], true)) {
$risk = 'low';
}
$confidence = is_numeric($data['confidence'] ?? null) ? (float) $data['confidence'] : 0.5;
$confidence = max(0.0, min(1.0, $confidence));
$secondary = [];
foreach ((array) ($data['secondary_emails'] ?? []) as $email) {
if (is_string($email) && $email !== '') {
$secondary[] = strtolower(trim($email));
}
}
$requestedAction = match ($caseType) {
'profile_update' => 'user_profile_update',
'account_restore' => 'user_restore',
'role_add' => 'user_role_add',
'code_change' => 'code_change',
'artisan_command' => 'artisan_command',
'content_update' => 'content_update',
default => null,
};
$artisanArgs = [];
foreach ((array) ($data['artisan_args'] ?? []) as $key => $value) {
if (is_string($key) && (is_string($value) || is_numeric($value) || is_bool($value))) {
$artisanArgs[$key] = is_bool($value) ? $value : (string) $value;
}
}
$contentChanges = [];
foreach ((array) ($data['content_changes'] ?? []) as $key => $value) {
if (is_string($key) && (is_string($value) || is_numeric($value))) {
$contentChanges[$key] = (string) $value;
}
}
return [
'case_type' => $caseType,
'confidence' => $confidence,
'target_email' => $this->stringOrNull($data['target_email'] ?? null),
'secondary_emails' => array_values(array_unique($secondary)),
'target_user_id' => null,
'requested_action' => $requestedAction,
'profile_firstname' => $this->stringOrNull($data['profile_firstname'] ?? null),
'profile_lastname' => $this->stringOrNull($data['profile_lastname'] ?? null),
'risk_level' => $risk,
'recommended_runbook' => $this->stringOrNull($data['recommended_runbook'] ?? null) ?? $caseType,
'needs_human_review' => (bool) ($data['needs_human_review'] ?? false),
'reasoning_summary' => $this->stringOrNull($data['reasoning_summary'] ?? null) ?? 'AI triage (Cursor CLI).',
'change_summary' => $this->stringOrNull($data['change_summary'] ?? null),
'change_area' => $this->stringOrNull($data['change_area'] ?? null),
'cursor_prompt' => $this->stringOrNull($data['cursor_prompt'] ?? null),
'artisan_command_name' => $this->stringOrNull($data['artisan_command_name'] ?? null),
'artisan_args' => $artisanArgs,
'artisan_raw_command' => $this->stringOrNull($data['artisan_raw_command'] ?? null),
'content_model' => $this->stringOrNull($data['content_model'] ?? null),
'content_identifier' => $this->stringOrNull($data['content_identifier'] ?? null),
'content_changes' => $contentChanges,
'content_summary' => $this->stringOrNull($data['content_summary'] ?? null),
'triage_source' => 'cursor_cli',
];
}
private function stringOrNull(mixed $value): ?string
{
if (!is_string($value)) {
return null;
}
$value = trim($value);
if ($value === '' || strtolower($value) === 'null') {
return null;
}
return $value;
}
}