Skip to content
Merged

Dev #3584

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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,8 @@ SUPPORT_GITHUB_TOKEN=
SUPPORT_AI_ARTISAN_ENABLED=false
SUPPORT_AI_ARTISAN_ALLOW_RAW=true
SUPPORT_AI_ARTISAN_TIMEOUT=120
SUPPORT_AI_ARTISAN_OUTPUT_LIMIT=8000
SUPPORT_AI_ARTISAN_OUTPUT_LIMIT=8000

# Phase 3 — AI content edits on Nova-managed records (text fields only, dry-run + APPROVE)
SUPPORT_AI_CONTENT_ENABLED=false
SUPPORT_AI_CONTENT_MAX_FIELD_LENGTH=5000
5 changes: 5 additions & 0 deletions app/Console/Commands/Support/AiSetupCheckCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public function handle(CursorAgentService $cursorAgent, GitHubPullRequestService
$checks['code_change_enabled'] = (bool) config('support_ai.code_change.enabled');
$checks['artisan_enabled'] = (bool) config('support_ai.artisan.enabled');
$checks['artisan_allow_raw'] = (bool) config('support_ai.artisan.allow_raw_fallback', true);
$checks['content_enabled'] = (bool) config('support_ai.content.enabled');
$checks['gmail_dry_run'] = (bool) config('support_gmail.dry_run', true);

$apiKey = trim((string) config('support_ai.cursor_api_key', ''));
Expand Down Expand Up @@ -73,9 +74,13 @@ public function handle(CursorAgentService $cursorAgent, GitHubPullRequestService
$allowedActions = (array) config('support_gmail.allowed_write_actions', []);
$checks['code_change_in_allowed_actions'] = in_array('code_change', $allowedActions, true);
$checks['artisan_command_in_allowed_actions'] = in_array('artisan_command', $allowedActions, true);
$checks['content_update_in_allowed_actions'] = in_array('content_update', $allowedActions, true);
if ($checks['artisan_enabled'] && !$checks['artisan_command_in_allowed_actions']) {
$warnings[] = "artisan_command missing from support_gmail.allowed_write_actions — approvals can't execute it.";
}
if ($checks['content_enabled'] && !$checks['content_update_in_allowed_actions']) {
$warnings[] = "content_update missing from support_gmail.allowed_write_actions — approvals can't execute it.";
}
if ($checks['artisan_enabled'] && $checks['gmail_dry_run'] === false) {
$warnings[] = 'Artisan actions enabled with SUPPORT_GMAIL_DRY_RUN=false — commands run live after APPROVE.';
}
Expand Down
10 changes: 10 additions & 0 deletions app/Jobs/Support/ExecuteApprovedSupportActionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Models\Support\SupportApproval;
use App\Models\Support\SupportCase;
use App\Services\Support\Artisan\ArtisanCommandRunner;
use App\Services\Support\Content\ContentUpdateService;
use App\Services\Support\Cursor\CursorAgentService;
use App\Services\Support\SupportActionLogger;
use App\Services\Support\SupportApprovalEmailService;
Expand All @@ -31,6 +32,7 @@ public function handle(
SupportActionLogger $logger,
CursorAgentService $cursorAgent,
ArtisanCommandRunner $artisanRunner,
ContentUpdateService $contentUpdate,
): void
{
$approval = SupportApproval::findOrFail($this->supportApprovalId);
Expand Down Expand Up @@ -105,6 +107,14 @@ public function handle(
$result = ($plan['ok'] ?? false)
? $artisanRunner->execute((array) $plan['result'])
: $plan;
} elseif ($action === 'content_update') {
// Re-read the proposed change from triage and re-validate at execution time.
$triage = (array) ($case->actions()->where('action_name', 'triage')->latest()->first()?->output_json ?? []);
$result = $contentUpdate->execute(
modelKey: (string) ($triage['content_model'] ?? ''),
identifier: isset($triage['content_identifier']) ? (string) $triage['content_identifier'] : null,
changes: (array) ($triage['content_changes'] ?? []),
);
} else {
$result = [
'ok' => false,
Expand Down
17 changes: 17 additions & 0 deletions app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Models\Support\SupportCaseMessage;
use App\Services\Support\Agents\DiagnosticsAgentService;
use App\Services\Support\Artisan\ArtisanCommandRunner;
use App\Services\Support\Content\ContentUpdateService;
use App\Services\Support\SupportActionLogger;
use App\Services\Support\SupportJson;
use App\Services\Support\UserProfileUpdateService;
Expand All @@ -30,6 +31,7 @@ public function handle(
UserRestoreService $userRestore,
UserProfileUpdateService $userProfileUpdate,
ArtisanCommandRunner $artisanRunner,
ContentUpdateService $contentUpdate,
): void {
$case = SupportCase::findOrFail($this->supportCaseId);
$case->update(['status' => 'investigating']);
Expand Down Expand Up @@ -108,6 +110,21 @@ public function handle(
);
}

if ($case->case_type === 'content_update') {
$triage = (array) ($case->actions()->where('action_name', 'triage')->latest()->first()?->output_json ?? []);
$plan = $contentUpdate->planFromTriage($triage);
$logger->log(
case: $case,
actionName: 'content_update',
actionType: 'write',
input: ['dry_run' => true],
output: $plan,
succeeded: (bool) ($plan['ok'] ?? false),
executedBy: 'agent',
correlationId: $case->correlation_id,
);
}

// Persist diagnostics snapshot as a message for UI/debugging (stable storage for later external orchestrator).
SupportCaseMessage::create([
'support_case_id' => $case->id,
Expand Down
61 changes: 53 additions & 8 deletions app/Services/Support/Agents/CursorCliTriageProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

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;

Expand All @@ -28,11 +29,14 @@ class CursorCliTriageProvider implements TriageProvider
'role_issue',
'code_change',
'artisan_command',
'content_update',
'unknown',
];

public function __construct(private readonly ArtisanActionRegistry $registry)
{
public function __construct(
private readonly ArtisanActionRegistry $registry,
private readonly ContentActionRegistry $contentRegistry,
) {
}

public function available(): bool
Expand All @@ -47,13 +51,21 @@ 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,
fn (string $type) => $type !== 'artisan_command' || $this->artisanEnabled()
));
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
Expand Down Expand Up @@ -112,6 +124,7 @@ private function buildPrompt(SupportCase $case, string $rawText): string
// 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.
Expand All @@ -120,7 +133,7 @@ private function buildPrompt(SupportCase $case, string $rawText): string
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.
{$artisanBlock}
{$artisanBlock}{$contentBlock}
JSON schema to return:
{
"case_type": "<one allowed value>",
Expand All @@ -138,7 +151,11 @@ private function buildPrompt(SupportCase $case, string $rawText): string
"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>"
"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}
Expand Down Expand Up @@ -169,6 +186,22 @@ private function artisanPromptBlock(): string
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;
}

Expand Down Expand Up @@ -257,6 +290,7 @@ private function normalize(array $data): array
'account_restore' => 'user_restore',
'code_change' => 'code_change',
'artisan_command' => 'artisan_command',
'content_update' => 'content_update',
default => null,
};

Expand All @@ -267,6 +301,13 @@ private function normalize(array $data): array
}
}

$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,
Expand All @@ -286,6 +327,10 @@ private function normalize(array $data): array
'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',
];
}
Expand Down
65 changes: 65 additions & 0 deletions app/Services/Support/Content/ContentActionRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace App\Services\Support\Content;

/**
* Allowlist of content models the support AI may edit. Editable fields are NOT
* listed here — they are resolved dynamically (string/text columns minus the
* structural deny-list) by ContentFieldResolver, so this stays a thin registry
* of "which records" rather than "which columns".
*
* - singleton: page-style tables with a single config row (looked up via the
* model's config()/first() — no identifier needed).
* - lookup_fields: unique string columns the AI may reference instead of an id.
*/
class ContentActionRegistry
{
/**
* @return array<string, array{model: class-string, label: string, singleton: bool, lookup_fields: list<string>}>
*/
public function all(): array
{
return [
'static_page' => ['model' => \App\StaticPage::class, 'label' => 'Static page', 'singleton' => false, 'lookup_fields' => ['unique_identifier', 'path']],
'home_slide' => ['model' => \App\HomeSlide::class, 'label' => 'Homepage slide', 'singleton' => false, 'lookup_fields' => []],
'get_involved_page' => ['model' => \App\GetInvolvedPage::class, 'label' => 'Get Involved page', 'singleton' => true, 'lookup_fields' => []],
'hackathons_page' => ['model' => \App\HackathonsPage::class, 'label' => 'Hackathons page', 'singleton' => true, 'lookup_fields' => []],
'dance_page' => ['model' => \App\DancePage::class, 'label' => 'Dance page', 'singleton' => true, 'lookup_fields' => []],
'treasure_hunt_page' => ['model' => \App\TreasureHuntPage::class, 'label' => 'Treasure Hunt page', 'singleton' => true, 'lookup_fields' => []],
'online_courses_page' => ['model' => \App\OnlineCoursesPage::class, 'label' => 'Online Courses page', 'singleton' => true, 'lookup_fields' => []],
'girls_in_digital_page' => ['model' => \App\GirlsInDigitalPage::class, 'label' => 'Girls in Digital page', 'singleton' => true, 'lookup_fields' => []],
'girls_in_digital_faq_item' => ['model' => \App\GirlsInDigitalFaqItem::class, 'label' => 'Girls in Digital FAQ item', 'singleton' => false, 'lookup_fields' => []],
'dream_jobs_page' => ['model' => \App\DreamJobsPage::class, 'label' => 'Dream Jobs page', 'singleton' => true, 'lookup_fields' => []],
'csr_campaign_page' => ['model' => \App\CsrCampaignPage::class, 'label' => 'CSR Campaign page', 'singleton' => true, 'lookup_fields' => []],
'grassroots_grants_page' => ['model' => \App\GrassrootsGrantsPage::class, 'label' => 'Grassroots Grants page', 'singleton' => true, 'lookup_fields' => []],
'grassroots_grants_hub' => ['model' => \App\GrassrootsGrantsHub::class, 'label' => 'Grassroots Grants hub', 'singleton' => true, 'lookup_fields' => []],
'menu_section' => ['model' => \App\Models\MenuSection::class, 'label' => 'Menu section', 'singleton' => false, 'lookup_fields' => []],
'menu_item' => ['model' => \App\Models\MenuItem::class, 'label' => 'Menu item', 'singleton' => false, 'lookup_fields' => []],
'event' => ['model' => \App\Event::class, 'label' => 'Event', 'singleton' => false, 'lookup_fields' => []],
'podcast' => ['model' => \App\Podcast::class, 'label' => 'Podcast', 'singleton' => false, 'lookup_fields' => []],
'online_course' => ['model' => \App\OnlineCourse::class, 'label' => 'Online course', 'singleton' => false, 'lookup_fields' => []],
'partner' => ['model' => \App\Partner::class, 'label' => 'Partner', 'singleton' => false, 'lookup_fields' => []],
];
}

public function has(string $key): bool
{
return array_key_exists($key, $this->all());
}

/**
* @return array{model: class-string, label: string, singleton: bool, lookup_fields: list<string>}|null
*/
public function get(string $key): ?array
{
return $this->all()[$key] ?? null;
}

/**
* @return list<string>
*/
public function keys(): array
{
return array_keys($this->all());
}
}
Loading
Loading