on()) { return $this->off(); } return $this->noStore($this->ok([ 'stages' => Stages::all(), 'defaults' => (object) Stages::defaults(), ])); } public function research(Request $request, ModelGateway $gateway): JsonResponse { $session = $this->open($request); if ($session instanceof JsonResponse) { return $session; } $state = $this->state($session); if (!in_array(Stages::RESEARCH, $state['stages'], true)) { return $this->validationError(['stage' => [__('This document has no research stage.', 'maze-graph')]]); } $instruction = Redactor::cleanText((string) $request->input('instruction', '')); if (mb_strlen($instruction) > 500) { return $this->validationError(['instruction' => [__('Use at most 500 characters.', 'maze-graph')]]); } $refused = $this->refusal($gateway); if ($refused !== null) { return $refused; } $message = DraftingPrompts::lead($state, Stages::RESEARCH) . DraftingPrompts::research($state['brief'], $instruction); $started = $this->runs->start($session->id, $message, $this->userId(), $this->clientId($request)); if ($started === null) { return $this->busy(); } $this->store->append($session->id, Transcript::event('studio_expect', ['kind' => 'research'])); return $this->reply($session, $started); } public function notes(Request $request): JsonResponse { $session = $this->open($request); if ($session instanceof JsonResponse) { return $session; } if ($session->run_status === 'running') { return $this->error(__('The assistant is writing; wait for it to finish.', 'maze-graph'), 409); } $text = Redactor::cleanText((string) $request->input('text', '')); if (mb_strlen($text) > StudioState::MAX_NOTES) { /* translators: %s: maximum number of characters. */ return $this->validationError(['text' => [sprintf(__('Notes can have at most %s characters.', 'maze-graph'), number_format_i18n(StudioState::MAX_NOTES))]]); } if ($this->state($session)['notes'] !== $text) { $this->store->append($session->id, Transcript::event('studio_notes', ['text' => $text])); } return $this->noStore($this->ok($this->document($session))); } public function progress(Request $request): JsonResponse { $session = $this->open($request); if ($session instanceof JsonResponse) { return $session; } $stage = (string) $request->route('stage'); $action = (string) $request->input('action', ''); $state = $this->state($session); if (!in_array($stage, $state['stages'], true) || in_array($stage, [Stages::BRIEF, Stages::HANDOFF], true)) { return $this->validationError(['stage' => [__('That stage cannot be changed.', 'maze-graph')]]); } $required = false; foreach (Stages::all() as $spec) { $required = $spec['id'] === $stage ? $spec['required'] : $required; } if (!in_array($action, ['done', 'skip', 'reopen'], true) || ($action === 'skip' && $required)) { return $this->validationError(['action' => [__('Choose done, skip or reopen; a required stage cannot be skipped.', 'maze-graph')]]); } if ($session->run_status === 'running') { return $this->error(__('The assistant is working; wait for it to finish.', 'maze-graph'), 409); } $status = ['done' => 'done', 'skip' => 'skipped', 'reopen' => 'open'][$action]; if (($state['progress'][$stage] ?? '') !== $status) { $this->store->append($session->id, Transcript::event('studio_stage', ['stage' => $stage, 'status' => $status])); } return $this->noStore($this->ok($this->document($session))); } public function instructions(Request $request): JsonResponse { $session = $this->open($request); if ($session instanceof JsonResponse) { return $session; } $stage = (string) $request->input('stage', ''); if (!in_array($stage, Stages::withSlots(), true)) { return $this->validationError(['stage' => [__('That stage takes no instructions.', 'maze-graph')]]); } $raw = $request->input('slots'); if (is_array($raw)) { foreach (InstructionSet::SLOTS as $slot) { if (is_scalar($raw[$slot] ?? null) && mb_strlen((string) $raw[$slot]) > InstructionSet::MAX) { /* translators: %s: maximum number of characters. */ return $this->validationError(["slots.{$slot}" => [sprintf(__('Use at most %s characters.', 'maze-graph'), number_format_i18n(InstructionSet::MAX))]]); } } } $slots = InstructionSet::clean($raw); if (($this->state($session)['instructions'][$stage] ?? []) !== $slots) { $this->store->append($session->id, Transcript::event('studio_instructions', ['stage' => $stage, 'slots' => $slots])); } return $this->noStore($this->ok($this->document($session))); } public function checks(Request $request): JsonResponse { $session = $this->open($request); if ($session instanceof JsonResponse) { return $session; } $state = $this->state($session); $doc = $state['working']; if ($doc === null) { return $this->noStore($this->ok(['findings' => [], 'metrics' => (object) [], 'ready' => false])); } $result = ContentChecks::analyze($doc, [ 'keywords' => ContentChecks::keywords($state['fields']['keywords'] ?? ''), 'target_words' => ContentChecks::targetWords($state['fields']['length'] ?? ''), ]); // The one check that needs WordPress: is there already a post with this title? $title = $doc['title']; if ($title !== '') { // Only posts this person may read count, so the check never reveals a draft or private post they cannot see. $same = array_filter( get_posts(['title' => $title, 'post_type' => $state['post_type'], 'post_status' => ['publish', 'draft', 'pending', 'future', 'private'], 'numberposts' => 20, 'fields' => 'ids', 'no_found_rows' => true]), static fn ($id): bool => current_user_can('read_post', (int) $id) ); if ($same !== []) { $result['findings'][] = ['id' => 'duplicate', 'level' => ContentChecks::WARN, 'label' => __('Title already used', 'maze-graph'), 'detail' => __('A post or page on this site already has exactly this title.', 'maze-graph'), 'fix' => 'Change the title so it is distinct from the site\'s other posts, keeping its meaning.']; } } return $this->noStore($this->ok($result + ['ready' => true])); } }