diff --git a/.env.example b/.env.example index 0ddacb9c3..709b64bd9 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/app/Console/Commands/Support/AiSetupCheckCommand.php b/app/Console/Commands/Support/AiSetupCheckCommand.php index dc0626a63..71fe0df9b 100644 --- a/app/Console/Commands/Support/AiSetupCheckCommand.php +++ b/app/Console/Commands/Support/AiSetupCheckCommand.php @@ -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', '')); @@ -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.'; } diff --git a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php index 4ae253471..68974390e 100644 --- a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php +++ b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php @@ -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; @@ -31,6 +32,7 @@ public function handle( SupportActionLogger $logger, CursorAgentService $cursorAgent, ArtisanCommandRunner $artisanRunner, + ContentUpdateService $contentUpdate, ): void { $approval = SupportApproval::findOrFail($this->supportApprovalId); @@ -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, diff --git a/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php b/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php index 0546fd17b..7ac1b6900 100644 --- a/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php +++ b/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php @@ -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; @@ -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']); @@ -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, diff --git a/app/Services/Support/Agents/CursorCliTriageProvider.php b/app/Services/Support/Agents/CursorCliTriageProvider.php index 73c2d4c18..615e7721f 100644 --- a/app/Services/Support/Agents/CursorCliTriageProvider.php +++ b/app/Services/Support/Agents/CursorCliTriageProvider.php @@ -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; @@ -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 @@ -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 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 @@ -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 <<", @@ -138,7 +151,11 @@ private function buildPrompt(SupportCase $case, string $rawText): string "cursor_prompt": "", "artisan_command_name": "", "artisan_args": {}, - "artisan_raw_command": "" + "artisan_raw_command": "", + "content_model": "", + "content_identifier": "", + "content_changes": {}, + "content_summary": "" } Ticket subject: {$case->subject} @@ -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 << 'user_restore', 'code_change' => 'code_change', 'artisan_command' => 'artisan_command', + 'content_update' => 'content_update', default => null, }; @@ -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, @@ -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', ]; } diff --git a/app/Services/Support/Content/ContentActionRegistry.php b/app/Services/Support/Content/ContentActionRegistry.php new file mode 100644 index 000000000..8e1628d0b --- /dev/null +++ b/app/Services/Support/Content/ContentActionRegistry.php @@ -0,0 +1,65 @@ +}> + */ + 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}|null + */ + public function get(string $key): ?array + { + return $this->all()[$key] ?? null; + } + + /** + * @return list + */ + public function keys(): array + { + return array_keys($this->all()); + } +} diff --git a/app/Services/Support/Content/ContentFieldResolver.php b/app/Services/Support/Content/ContentFieldResolver.php new file mode 100644 index 000000000..d148d7174 --- /dev/null +++ b/app/Services/Support/Content/ContentFieldResolver.php @@ -0,0 +1,105 @@ + + */ + public function editableFields(Model $model): array + { + $table = $model->getTable(); + $casts = $model->getCasts(); + $denylist = array_map('strtolower', (array) config('support_ai.content.field_denylist', [])); + $primaryKey = $model->getKeyName(); + + $fields = []; + foreach ($this->stringColumns($table) as $column) { + if ($column === $primaryKey) { + continue; + } + if ($this->isDenied($column, $denylist)) { + continue; + } + // Drop anything with an explicit non-string cast (array/json/bool/date/int). + if (isset($casts[$column]) && strtolower((string) $casts[$column]) !== 'string') { + continue; + } + $fields[] = $column; + } + + return array_values(array_unique($fields)); + } + + public function isEditable(Model $model, string $field): bool + { + return in_array($field, $this->editableFields($model), true); + } + + /** + * String/text columns of a table (DB-portable across MySQL + SQLite). + * + * @return list + */ + private function stringColumns(string $table): array + { + $columns = []; + + if (method_exists(Schema::class, 'getColumns')) { + foreach (Schema::getColumns($table) as $column) { + $typeName = strtolower((string) ($column['type_name'] ?? $column['type'] ?? '')); + if ($this->isTextType($typeName)) { + $columns[] = (string) $column['name']; + } + } + + return $columns; + } + + foreach (Schema::getColumnListing($table) as $name) { + $type = strtolower((string) Schema::getColumnType($table, $name)); + if ($this->isTextType($type)) { + $columns[] = $name; + } + } + + return $columns; + } + + private function isTextType(string $type): bool + { + if ($type === 'string') { + return true; + } + + return str_contains($type, 'char') || str_contains($type, 'text'); + } + + /** + * @param list $denylist + */ + private function isDenied(string $column, array $denylist): bool + { + $parts = preg_split('/[_\s]+/', strtolower($column)) ?: []; + + foreach ($parts as $part) { + if (in_array($part, $denylist, true)) { + return true; + } + } + + return false; + } +} diff --git a/app/Services/Support/Content/ContentUpdateService.php b/app/Services/Support/Content/ContentUpdateService.php new file mode 100644 index 000000000..1613257fe --- /dev/null +++ b/app/Services/Support/Content/ContentUpdateService.php @@ -0,0 +1,234 @@ + $triage + * @return array SupportJson envelope. + */ + public function planFromTriage(array $triage): array + { + $model = is_string($triage['content_model'] ?? null) ? trim($triage['content_model']) : ''; + $identifier = is_string($triage['content_identifier'] ?? null) ? trim($triage['content_identifier']) : null; + $changes = (array) ($triage['content_changes'] ?? []); + + return $this->plan($model, $identifier !== '' ? $identifier : null, $changes); + } + + /** + * Build a validated before/after plan (this is also the dry run — nothing saved). + * + * @param array $changes + * @return array SupportJson envelope. + */ + public function plan(string $modelKey, ?string $identifier, array $changes): array + { + $input = ['model' => $modelKey, 'identifier' => $identifier]; + + if (!$this->enabled()) { + return SupportJson::fail('content_update', $input, 'content_edits_disabled'); + } + + $spec = $this->registry->get($modelKey); + if ($spec === null) { + return SupportJson::fail('content_update', $input, 'model_not_in_allowlist'); + } + + if ($changes === []) { + return SupportJson::fail('content_update', $input, 'no_changes_proposed'); + } + + $record = $this->resolveRecord($spec, $identifier); + if ($record === null) { + return SupportJson::fail('content_update', $input, 'record_not_found'); + } + + $editable = $this->resolver->editableFields($record); + $before = []; + $after = []; + $errors = []; + + foreach ($changes as $field => $value) { + $field = (string) $field; + + if (!in_array($field, $editable, true)) { + $errors[] = 'field_not_editable:'.$field; + continue; + } + + $current = $record->getAttribute($field); + if ($this->looksLikeTranslationKey((string) ($current ?? ''))) { + $errors[] = 'field_is_translation_key:'.$field; + continue; + } + + $valueError = $this->validateValue($value); + if ($valueError !== null) { + $errors[] = $valueError.':'.$field; + continue; + } + + $newValue = (string) $value; + if ((string) ($current ?? '') === $newValue) { + continue; // no-op + } + + $before[$field] = $current; + $after[$field] = $newValue; + } + + if ($errors !== []) { + return SupportJson::fail('content_update', $input, $errors); + } + + if ($after === []) { + return SupportJson::fail('content_update', $input, 'no_effective_change'); + } + + return SupportJson::ok('content_update', $input, [ + 'model_key' => $modelKey, + 'label' => $spec['label'], + 'record_id' => $record->getKey(), + 'identifier' => $identifier, + 'before' => $before, + 'after' => $after, + 'fields' => array_keys($after), + ]); + } + + /** + * Apply the change (post-APPROVE). Re-validates by re-planning first. + * + * @param array $changes + * @return array SupportJson envelope. + */ + public function execute(string $modelKey, ?string $identifier, array $changes): array + { + $plan = $this->plan($modelKey, $identifier, $changes); + if (!($plan['ok'] ?? false)) { + return $plan; + } + + $spec = $this->registry->get($modelKey); + $record = $this->resolveRecord((array) $spec, $identifier); + if ($record === null) { + return SupportJson::fail('content_update', ['model' => $modelKey], 'record_not_found'); + } + + $after = (array) $plan['result']['after']; + foreach ($after as $field => $value) { + $record->setAttribute((string) $field, (string) $value); + } + $record->save(); + + return SupportJson::ok('content_update', ['model' => $modelKey, 'identifier' => $identifier], [ + 'model_key' => $modelKey, + 'label' => $plan['result']['label'], + 'record_id' => $record->getKey(), + 'before' => $plan['result']['before'], + 'after' => $after, + 'fields' => array_keys($after), + ]); + } + + /** + * Reject URLs, HTML/scripts, non-strings, and over-length values. + */ + public function validateValue(mixed $value): ?string + { + if (!is_string($value)) { + return 'value_not_text'; + } + + $max = (int) config('support_ai.content.max_field_length', 5000); + if (mb_strlen($value) > $max) { + return 'value_too_long'; + } + + if (preg_match('#https?://#i', $value) || preg_match('#\bwww\.[a-z0-9-]+\.#i', $value)) { + return 'value_contains_url'; + } + + // Plain text only — reject HTML/script-like markup. + if (preg_match('#<\s*[a-z!/]#i', $value)) { + return 'value_contains_markup'; + } + + return null; + } + + /** + * Heuristic: dotted token with no spaces (e.g. "hackathons.hero.title") is a + * Laravel translation key, not literal copy — do not overwrite it. + */ + private function looksLikeTranslationKey(string $value): bool + { + $value = trim($value); + if ($value === '' || str_contains($value, ' ')) { + return false; + } + + return str_contains($value, '.') && (bool) preg_match('/^[a-z0-9_.-]+$/i', $value); + } + + /** + * @param array{model: class-string, singleton: bool, lookup_fields: list} $spec + */ + private function resolveRecord(array $spec, ?string $identifier): ?Model + { + /** @var class-string $class */ + $class = $spec['model']; + /** @var Model $model */ + $model = new $class(); + + if (($spec['singleton'] ?? false) && ($identifier === null || $identifier === '')) { + return $model->newQuery()->orderBy($model->getKeyName())->first(); + } + + if ($identifier === null || $identifier === '') { + return null; + } + + if (ctype_digit($identifier)) { + $byId = $model->newQuery()->find((int) $identifier); + if ($byId !== null) { + return $byId; + } + } + + foreach ((array) ($spec['lookup_fields'] ?? []) as $lookupField) { + $found = $model->newQuery()->where($lookupField, $identifier)->first(); + if ($found !== null) { + return $found; + } + } + + return null; + } +} diff --git a/app/Services/Support/SupportApprovalEmailService.php b/app/Services/Support/SupportApprovalEmailService.php index 7715e344b..2cca8cfbc 100644 --- a/app/Services/Support/SupportApprovalEmailService.php +++ b/app/Services/Support/SupportApprovalEmailService.php @@ -26,6 +26,7 @@ public function approvalSubject(SupportCase $case): string 'account_restore' => 'Please review — account restore', 'code_change' => 'Please review — proposed code fix (PR into dev)', 'artisan_command' => 'Please review — proposed server maintenance command', + 'content_update' => 'Please review — proposed content/copy change', default => 'Please review before we make changes', }; @@ -310,6 +311,21 @@ private function proposedActionForCase(SupportCase $case): array } } + if ($case->case_type === 'content_update') { + $plan = $this->contentDiagnostics($case); + $result = is_array($plan['result'] ?? null) ? $plan['result'] : []; + if (($plan['ok'] ?? false) && !empty($result['after'])) { + return [ + 'action' => 'content_update', + 'payload' => [ + 'label' => (string) ($result['label'] ?? 'content'), + 'model_key' => (string) ($result['model_key'] ?? ''), + 'record_id' => $result['record_id'] ?? null, + ], + ]; + } + } + return ['action' => 'none', 'payload' => []]; } @@ -329,6 +345,22 @@ private function artisanDiagnostics(SupportCase $case): array return is_array($output) ? $output : []; } + /** + * Read the content_update dry-run plan from diagnostics. + * + * @return array + */ + private function contentDiagnostics(SupportCase $case): array + { + $output = $case->actions() + ->where('action_name', 'content_update') + ->where('action_type', 'write') + ->latest() + ->first()?->output_json; + + return is_array($output) ? $output : []; + } + /** * Read the code_change dry-run plan recorded during diagnostics. * @@ -462,6 +494,10 @@ private function dryRunPlannedChangeLines(SupportCase $case, array $proposedActi return $this->dryRunArtisanLines($case); } + if ($action === 'content_update') { + return $this->dryRunContentLines($case); + } + return [ '', 'We could not determine an automatic change from this email.', @@ -510,6 +546,43 @@ private function dryRunArtisanLines(SupportCase $case): array return $lines; } + /** + * @return list + */ + private function dryRunContentLines(SupportCase $case): array + { + $plan = $this->contentDiagnostics($case); + $result = is_array($plan['result'] ?? null) ? $plan['result'] : []; + $label = (string) ($result['label'] ?? 'content record'); + $before = (array) ($result['before'] ?? []); + $after = (array) ($result['after'] ?? []); + + $lines = ['', 'We will update text on the '.$label.':']; + + foreach ($after as $field => $newValue) { + $oldValue = (string) ($before[$field] ?? ''); + $lines[] = ''; + $lines[] = ' Field: '.$field; + $lines[] = ' From: '.$this->truncateForEmail($oldValue); + $lines[] = ' To: '.$this->truncateForEmail((string) $newValue); + } + + $lines[] = ''; + $lines[] = 'Only the text above changes. The change is editable in Nova after it is applied.'; + + return $lines; + } + + private function truncateForEmail(string $value, int $limit = 300): string + { + $value = trim(preg_replace('/\s+/', ' ', $value) ?? $value); + if ($value === '') { + return '(empty)'; + } + + return mb_strlen($value) > $limit ? mb_substr($value, 0, $limit).'…' : $value; + } + /** * @param array $payload * @return list @@ -614,6 +687,7 @@ private function completionHeadline(SupportCase $case, string $action, bool $suc 'user_restore' => 'Done — CodeWeek account reactivated', 'code_change' => 'Started — AI coding agent is preparing a PR into dev', 'artisan_command' => 'Done — maintenance command completed on the server', + 'content_update' => 'Done — content updated', default => 'Done — your approved request was completed', }; } @@ -771,6 +845,23 @@ private function completionSuccessLines(SupportCase $case, string $action, array return $lines; } + if ($action === 'content_update') { + $label = (string) ($inner['label'] ?? 'content record'); + $before = (array) ($inner['before'] ?? []); + $after = (array) ($inner['after'] ?? []); + $lines = ['We updated the text on the '.$label.'.']; + foreach ($after as $field => $newValue) { + $lines[] = ''; + $lines[] = ' Field: '.$field; + $lines[] = ' From: '.$this->truncateForEmail((string) ($before[$field] ?? '')); + $lines[] = ' To: '.$this->truncateForEmail((string) $newValue); + } + $lines[] = ''; + $lines[] = 'The change is live and can be further edited in Nova.'; + + return $lines; + } + return [ 'The approved request for case #'.$case->id.' was completed successfully.', ]; @@ -850,6 +941,15 @@ private function humanizeError(string $code, string $action, int $caseId): strin str_contains($code, 'command_not_in_allowlist') => 'The proposed command is not on the approved list and was not run.', str_contains($code, 'shell_metacharacters_rejected') => 'The proposed command contained unsafe characters and was not run.', str_starts_with($code, 'exit_code_') => 'The command ran but reported an error (exit '.str_replace('exit_code_', '', $code).'). Please check the case in Nova.', + str_contains($code, 'content_edits_disabled') => 'Content editing is currently disabled.', + str_contains($code, 'model_not_in_allowlist') => 'That content type cannot be edited automatically.', + str_contains($code, 'record_not_found') => 'We could not find the content record to edit. Please check the reference in Nova.', + str_starts_with($code, 'field_not_editable') => 'One of the requested fields is not an editable text field.', + str_starts_with($code, 'field_is_translation_key') => 'One of the fields holds a translation key and was left unchanged.', + str_starts_with($code, 'value_contains_url') => 'The new text contained a link/URL, which is not allowed for content edits.', + str_starts_with($code, 'value_contains_markup') => 'The new text contained HTML/markup, which is not allowed for content edits.', + str_starts_with($code, 'value_too_long') => 'The new text was too long.', + str_contains($code, 'no_effective_change') => 'The content already matched the requested text — no change was needed.', $action === 'user_restore' && str_contains($code, 'verification') => 'The account was changed but we could not confirm it is fully active. Please verify in Nova.', default => 'Technical detail: '.$code, }; diff --git a/config/support_ai.php b/config/support_ai.php index bee90bd10..389724ffd 100644 --- a/config/support_ai.php +++ b/config/support_ai.php @@ -84,4 +84,30 @@ 'config:cache', 'route:cache', 'optimize', ], ], + + /* + |-------------------------------------------------------------------------- + | Phase 3 — AI content edits on Nova-managed records + |-------------------------------------------------------------------------- + | Editorial copy changes to allowlisted content models + | (App\Services\Support\Content\ContentActionRegistry). Text fields only: + | the field resolver keeps string/text columns and drops URLs, slugs, flags, + | relations, and JSON/boolean/date casts. Same dry-run + APPROVE flow; the + | records are also editable in Nova for manual review. + */ + 'content' => [ + 'enabled' => env('SUPPORT_AI_CONTENT_ENABLED', false), + 'max_field_length' => (int) env('SUPPORT_AI_CONTENT_MAX_FIELD_LENGTH', 5000), + + // Column name fragments that are never editable, even if string/text. + 'field_denylist' => [ + 'id', 'slug', 'path', 'url', 'uri', 'link', 'href', 'image', 'img', + 'thumbnail', 'icon', 'file', 'photo', 'media', 'locale', 'lang', + 'language', 'status', 'state', 'type', 'code', 'token', 'secret', + 'password', 'email', 'position', 'sort', 'order', 'active', 'enabled', + 'published', 'visible', 'identifier', 'keywords', 'color', 'colour', + 'class', 'css', 'html', 'script', 'embed', 'json', 'overrides', + 'created', 'updated', 'deleted', 'category', 'key', 'ref', 'uuid', + ], + ], ]; diff --git a/config/support_gmail.php b/config/support_gmail.php index 8a165ecd2..7dc849d59 100644 --- a/config/support_gmail.php +++ b/config/support_gmail.php @@ -79,6 +79,7 @@ 'user_profile_update', 'code_change', 'artisan_command', + 'content_update', ], // Send a follow-up email after an APPROVE action runs (success or failure). diff --git a/docs/support-copilot-ai.md b/docs/support-copilot-ai.md index de7acd4d8..daa7ab512 100644 --- a/docs/support-copilot-ai.md +++ b/docs/support-copilot-ai.md @@ -2,6 +2,7 @@ **Status:** Phase 1 · AI triage + frontend code fixes as PRs into `dev` **Phase 2:** AI-driven `artisan` changes on the server (allowlist-first, dry-run + APPROVE) +**Phase 3:** AI content/copy edits on Nova-managed records (text fields only, dry-run + APPROVE) This builds on the email pipeline in [support-copilot-stakeholder-guide.md](./support-copilot-stakeholder-guide.md) and the action matrix in [support-copilot-allowed-actions.md](./support-copilot-allowed-actions.md). @@ -141,3 +142,38 @@ SUPPORT_AI_ARTISAN_OUTPUT_LIMIT=8000 # captured output cap (characters) `artisan_command` must also be present in `support_gmail.allowed_write_actions` (it is by default) and `support:ai:setup-check` verifies this. + +--- + +## Phase 3 — AI content edits on Nova-managed records + +When triage classifies a ticket as `content_update`, the bot proposes an editorial +text change to an allowlisted content record and runs it through the same dry-run → +APPROVE → execute → report pipeline. The records are Nova resources, so a reviewer +can also adjust them by hand. **Disabled by default** (`SUPPORT_AI_CONTENT_ENABLED=false`). + +- **Model allowlist** (`App\Services\Support\Content\ContentActionRegistry`): the AI may + only edit listed content models (pages, homepage slides, FAQ items, menus, events, + podcasts, partners, …). Page-style singletons are looked up automatically; other + records are referenced by id or a unique field. +- **Text fields only** (`ContentFieldResolver`): editable columns are resolved at runtime + to string/text columns **minus** a structural deny-list (URLs, slugs, flags, relations, + identifiers, SEO/keyword/category fields) and minus any non-string cast (boolean / array + / date / int). No hand-maintained per-model column list. +- **Value guards** (`ContentUpdateService::validateValue`): each new value must be plain + text — URLs, `www.` references, and HTML/markup are rejected, length is capped at + `SUPPORT_AI_CONTENT_MAX_FIELD_LENGTH`. Fields whose current value is a Laravel + translation key (e.g. `hackathons.hero.title`) are left untouched. +- **Exact diff + re-validation:** diagnostics computes a before→after diff (shown in the + approval email); execution re-runs the full plan/validation before saving — it never + trusts the stored payload. The completion email shows the applied before→after. + +### Phase 3 env + +```dotenv +SUPPORT_AI_CONTENT_ENABLED=false # master switch for content edits +SUPPORT_AI_CONTENT_MAX_FIELD_LENGTH=5000 +``` + +`content_update` must also be present in `support_gmail.allowed_write_actions` +(it is by default) and `support:ai:setup-check` verifies this. diff --git a/docs/support-copilot-allowed-actions.md b/docs/support-copilot-allowed-actions.md index b1b945cac..9e65ea5dc 100644 --- a/docs/support-copilot-allowed-actions.md +++ b/docs/support-copilot-allowed-actions.md @@ -38,6 +38,7 @@ These are the **only** actions that can change production data via the email pip | `user_profile_update` | `profile_update` | Updates `firstname` and/or `lastname` on `users` | User email + requested first/last name | | `code_change` | `code_change` | AI cloud agent implements a frontend/code fix and opens a **PR into `dev`** (never deploys) | Description of the bug/change in the website code | | `artisan_command` | `artisan_command` | Runs an allowlisted (or guarded AI-proposed) `artisan` maintenance command on the server after dry-run + APPROVE | Request needing a server maintenance command; gated by `SUPPORT_AI_ARTISAN_ENABLED` | +| `content_update` | `content_update` | Edits **text fields only** on allowlisted Nova-managed content records (pages, slides, FAQs, …) after dry-run + APPROVE; never touches URLs/flags/relations | Request to fix/reword copy on an existing page or record; gated by `SUPPORT_AI_CONTENT_ENABLED` | Configured in `config/support_gmail.php` → `allowed_write_actions`. AI features are configured in `config/support_ai.php` — see [support-copilot-ai.md](./support-copilot-ai.md). diff --git a/tests/Unit/Support/ContentUpdateServiceTest.php b/tests/Unit/Support/ContentUpdateServiceTest.php new file mode 100644 index 000000000..99bdeb0f8 --- /dev/null +++ b/tests/Unit/Support/ContentUpdateServiceTest.php @@ -0,0 +1,90 @@ +set('support_ai.enabled', true); + config()->set('support_ai.content.enabled', true); + config()->set('support_ai.content.max_field_length', 100); + + $this->service = new ContentUpdateService( + new ContentActionRegistry(), + new ContentFieldResolver(), + ); + } + + public function test_value_guard_accepts_plain_text(): void + { + $this->assertNull($this->service->validateValue('A normal heading, with punctuation!')); + } + + public function test_value_guard_rejects_non_string(): void + { + $this->assertSame('value_not_text', $this->service->validateValue(42)); + } + + public function test_value_guard_rejects_urls(): void + { + $this->assertSame('value_contains_url', $this->service->validateValue('See https://example.com')); + $this->assertSame('value_contains_url', $this->service->validateValue('visit www.example.org today')); + } + + public function test_value_guard_rejects_markup(): void + { + $this->assertSame('value_contains_markup', $this->service->validateValue('Hello ')); + $this->assertSame('value_contains_markup', $this->service->validateValue('link')); + } + + public function test_value_guard_rejects_over_length(): void + { + $this->assertSame('value_too_long', $this->service->validateValue(str_repeat('a', 101))); + } + + public function test_plan_disabled_when_flag_off(): void + { + config()->set('support_ai.content.enabled', false); + + $plan = $this->service->plan('static_page', '1', ['title' => 'x']); + + $this->assertFalse($plan['ok']); + $this->assertContains('content_edits_disabled', $plan['errors']); + } + + public function test_plan_rejects_unknown_model(): void + { + $plan = $this->service->plan('not_a_model', '1', ['title' => 'x']); + + $this->assertFalse($plan['ok']); + $this->assertContains('model_not_in_allowlist', $plan['errors']); + } + + public function test_plan_rejects_empty_changes(): void + { + $plan = $this->service->plan('static_page', '1', []); + + $this->assertFalse($plan['ok']); + $this->assertContains('no_changes_proposed', $plan['errors']); + } + + public function test_registry_exposes_known_content_models(): void + { + $registry = new ContentActionRegistry(); + + $this->assertTrue($registry->has('hackathons_page')); + $this->assertFalse($registry->has('user')); + $this->assertSame(\App\HackathonsPage::class, $registry->get('hackathons_page')['model']); + $this->assertContains('home_slide', $registry->keys()); + } +}