bounded_text((string) ($session['session_id'] ?? ''), 64); if ($session_id === '') { return []; } return [ 'session_id' => $session_id, 'active' => !empty($session['active']), 'started_at' => max(0, (int) ($session['started_at'] ?? 0)), 'started_by' => max(0, (int) ($session['started_by'] ?? 0)), 'review_count' => max(0, (int) ($session['review_count'] ?? 0)), 'last_review_at' => max(0, (int) ($session['last_review_at'] ?? 0)), 'reminder_sent_at' => max(0, (int) ($session['reminder_sent_at'] ?? 0)), 'reminder_after_review_seq' => max(0, (int) ($session['reminder_after_review_seq'] ?? 0)), ]; } public function is_active(): bool { return !empty($this->get_session()['active']); } public function start_session(int $user_id): array { $user_id = max(0, $user_id); $now = time(); $session = [ 'session_id' => AIFQ_SR_Memory_Identity::uuid(), 'active' => true, 'started_at' => $now, 'started_by' => $user_id, 'review_count' => 0, 'last_review_at' => 0, 'reminder_sent_at' => 0, 'reminder_after_review_seq' => 0, ]; update_option(self::OPTION_SESSION, $session, false); update_option(self::OPTION_EVENTS, [], false); $this->record('session_started', []); return $this->get_session(); } public function mark_reminder_sent(int $user_id): bool { $session = $this->get_session(); if (empty($session['active'])) { return false; } // Gate B protocol: one controlled reminder is valid only after the // baseline review and before Review 2. Refuse late/duplicate markers // so a later organic review cannot be mislabeled as prompted. if ((int) ($session['review_count'] ?? 0) !== 1 || (int) ($session['reminder_sent_at'] ?? 0) > 0) { return false; } $session['reminder_sent_at'] = time(); $session['reminder_after_review_seq'] = (int) ($session['review_count'] ?? 0); update_option(self::OPTION_SESSION, $session, false); return $this->record('reminder_sent', [ 'review_seq' => (int) ($session['review_count'] ?? 0), 'actor_id' => max(0, $user_id), ]); } public function record_review_completed(array $opportunity_keys, int $attention_count, int $suggestion_count): bool { $session = $this->get_session(); if (empty($session['active'])) { return false; } $previous_review_at = max(0, (int) ($session['last_review_at'] ?? 0)); $review_seq = max(0, (int) ($session['review_count'] ?? 0)) + 1; $now = time(); $origin = 'baseline'; if ($review_seq > 1) { $reminder_sent_at = max(0, (int) ($session['reminder_sent_at'] ?? 0)); $reminder_after_review_seq = max(0, (int) ($session['reminder_after_review_seq'] ?? 0)); $origin = ($reminder_sent_at > 0 && $reminder_sent_at <= $now && $reminder_after_review_seq === ($review_seq - 1)) ? 'prompted' : 'organic'; } $session['review_count'] = $review_seq; $session['last_review_at'] = $now; update_option(self::OPTION_SESSION, $session, false); $keys = $this->sanitize_keys($opportunity_keys); return $this->record('review_completed', [ 'review_seq' => $review_seq, 'return_origin' => $origin, 'opportunity_count' => count($keys), 'opportunity_keys' => $keys, 'attention_count' => max(0, $attention_count), 'suggestion_count' => max(0, $suggestion_count), ]); } public function record(string $event_type, array $context = []): bool { $event_type = sanitize_key($event_type); if (!in_array($event_type, self::EVENT_TYPES, true)) { return false; } $session = $this->get_session(); if (empty($session['active'])) { return false; } $events = get_option(self::OPTION_EVENTS, []); if (!is_array($events)) { $events = []; } $events[] = [ 'event_id' => AIFQ_SR_Memory_Identity::uuid(), 'session_id' => (string) $session['session_id'], 'event_type' => $event_type, 'event_at' => time(), 'user_id' => function_exists('get_current_user_id') ? max(0, (int) get_current_user_id()) : 0, 'context' => $this->sanitize_context($context), ]; if (count($events) > self::MAX_EVENTS) { $events = array_slice($events, -self::MAX_EVENTS); } return (bool) update_option(self::OPTION_EVENTS, array_values($events), false); } public function get_events(): array { $session = $this->get_session(); if (empty($session['session_id'])) { return []; } $events = get_option(self::OPTION_EVENTS, []); if (!is_array($events)) { return []; } $current_session_id = (string) $session['session_id']; return array_values(array_filter($events, static function ($event) use ($current_session_id): bool { return is_array($event) && (string) ($event['session_id'] ?? '') === $current_session_id; })); } public function summary(): array { $events = $this->get_events(); $summary = [ 'reviews_completed' => 0, 'review_2_reached' => false, 'review_3_reached' => false, 'decision_opportunities' => 0, 'picker_opened' => 0, 'picker_cancelled' => 0, 'decisions_saved' => 0, 'picker_completion_percent' => null, 'notes_used' => 0, 'superseding_decisions' => 0, 'manual_reactivations' => 0, 'previous_decision_shown' => 0, 'reviewed_decision_shown' => 0, 'copy_summary' => 0, 'copy_handoff' => 0, 'organic_returns' => 0, 'prompted_returns' => 0, 'decision_types' => [], 'finding_stats' => [], ]; foreach ($events as $event) { $type = (string) ($event['event_type'] ?? ''); $context = is_array($event['context'] ?? null) ? $event['context'] : []; $key = sanitize_key((string) ($context['finding_key'] ?? '')); if ($type === 'review_completed') { $seq = max(0, (int) ($context['review_seq'] ?? 0)); $summary['reviews_completed']++; $summary['review_2_reached'] = $summary['review_2_reached'] || $seq >= 2; $summary['review_3_reached'] = $summary['review_3_reached'] || $seq >= 3; $opportunity_keys = $this->sanitize_keys($context['opportunity_keys'] ?? []); $summary['decision_opportunities'] += count($opportunity_keys); foreach ($opportunity_keys as $opportunity_key) { $this->increment_finding_stat($summary['finding_stats'], $opportunity_key, 'opportunities'); } if ($seq > 1 && ($context['return_origin'] ?? '') === 'organic') { $summary['organic_returns']++; } if ($seq > 1 && ($context['return_origin'] ?? '') === 'prompted') { $summary['prompted_returns']++; } continue; } if ($type === 'decision_picker_opened') { $summary['picker_opened']++; $this->increment_finding_stat($summary['finding_stats'], $key, 'picker_opened'); } elseif ($type === 'decision_picker_cancelled') { $summary['picker_cancelled']++; $this->increment_finding_stat($summary['finding_stats'], $key, 'picker_cancelled'); } elseif ($type === 'decision_saved') { $summary['decisions_saved']++; if (!empty($context['note_used'])) { $summary['notes_used']++; } if (!empty($context['superseding'])) { $summary['superseding_decisions']++; } $decision_type = sanitize_key((string) ($context['decision_type'] ?? '')); if ($decision_type !== '') { $summary['decision_types'][$decision_type] = ($summary['decision_types'][$decision_type] ?? 0) + 1; } $this->increment_finding_stat($summary['finding_stats'], $key, 'decisions_saved'); } elseif ($type === 'manual_reactivation') { $summary['manual_reactivations']++; $this->increment_finding_stat($summary['finding_stats'], $key, 'manual_reactivations'); } elseif ($type === 'previous_decision_shown') { $summary['previous_decision_shown']++; $this->increment_finding_stat($summary['finding_stats'], $key, 'previous_decision_shown'); } elseif ($type === 'reviewed_decision_shown') { $summary['reviewed_decision_shown']++; $this->increment_finding_stat($summary['finding_stats'], $key, 'reviewed_decision_shown'); } elseif ($type === 'copy_summary') { $summary['copy_summary']++; } elseif ($type === 'copy_handoff') { $summary['copy_handoff']++; } } if ($summary['picker_opened'] > 0) { $summary['picker_completion_percent'] = round(($summary['decisions_saved'] / $summary['picker_opened']) * 100, 1); } ksort($summary['decision_types'], SORT_STRING); ksort($summary['finding_stats'], SORT_STRING); return $summary; } public static function record_memory_error(string $stage, string $message = ''): void { $stage = sanitize_key($stage); $message = self::static_bounded_text($message, 180); $current = get_option(self::OPTION_LAST_MEMORY_ERROR, []); if (is_array($current) && (string) ($current['stage'] ?? '') === $stage && (string) ($current['message'] ?? '') === $message) { return; } update_option(self::OPTION_LAST_MEMORY_ERROR, [ 'stage' => $stage, 'message' => $message, 'recorded_at' => time(), ], false); } public static function clear_memory_error(): void { delete_option(self::OPTION_LAST_MEMORY_ERROR); } public static function get_last_memory_error(): array { $error = get_option(self::OPTION_LAST_MEMORY_ERROR, []); return is_array($error) ? $error : []; } public static function uninstall(): void { if (!is_multisite() || !function_exists('get_sites') || !function_exists('switch_to_blog')) { self::uninstall_current_site_options(); return; } $site_ids = get_sites([ 'fields' => 'ids', 'number' => 0, ]); if (!is_array($site_ids) || empty($site_ids)) { self::uninstall_current_site_options(); return; } foreach ($site_ids as $site_id) { switch_to_blog((int) $site_id); self::uninstall_current_site_options(); restore_current_blog(); } } private static function uninstall_current_site_options(): void { delete_option(self::OPTION_SESSION); delete_option(self::OPTION_EVENTS); delete_option(self::OPTION_LAST_MEMORY_ERROR); } private function sanitize_context(array $context): array { $clean = []; if (isset($context['finding_key'])) { $clean['finding_key'] = sanitize_key((string) $context['finding_key']); } if (isset($context['presentation'])) { $clean['presentation'] = sanitize_key((string) $context['presentation']); } if (isset($context['has_current'])) { $clean['has_current'] = (bool) $context['has_current']; } if (isset($context['decision_type'])) { $clean['decision_type'] = sanitize_key((string) $context['decision_type']); } if (isset($context['note_used'])) { $clean['note_used'] = (bool) $context['note_used']; } if (isset($context['superseding'])) { $clean['superseding'] = (bool) $context['superseding']; } if (isset($context['review_seq'])) { $clean['review_seq'] = max(0, (int) $context['review_seq']); } if (isset($context['return_origin'])) { $origin = sanitize_key((string) $context['return_origin']); $clean['return_origin'] = in_array($origin, ['baseline', 'organic', 'prompted'], true) ? $origin : ''; } if (isset($context['opportunity_count'])) { $clean['opportunity_count'] = max(0, (int) $context['opportunity_count']); } if (isset($context['opportunity_keys'])) { $clean['opportunity_keys'] = $this->sanitize_keys($context['opportunity_keys']); } if (isset($context['attention_count'])) { $clean['attention_count'] = max(0, (int) $context['attention_count']); } if (isset($context['suggestion_count'])) { $clean['suggestion_count'] = max(0, (int) $context['suggestion_count']); } if (isset($context['actor_id'])) { $clean['actor_id'] = max(0, (int) $context['actor_id']); } return $clean; } private function sanitize_keys($keys): array { if (!is_array($keys)) { return []; } $clean = []; foreach ($keys as $key) { $key = sanitize_key((string) $key); if ($key !== '') { $clean[$key] = true; } } return array_keys($clean); } private function increment_finding_stat(array &$stats, string $key, string $metric): void { if ($key === '') { return; } if (!isset($stats[$key]) || !is_array($stats[$key])) { $stats[$key] = [ 'opportunities' => 0, 'picker_opened' => 0, 'picker_cancelled' => 0, 'decisions_saved' => 0, 'manual_reactivations' => 0, 'previous_decision_shown' => 0, 'reviewed_decision_shown' => 0, ]; } if (array_key_exists($metric, $stats[$key])) { $stats[$key][$metric]++; } } private function bounded_text(string $value, int $length): string { return self::static_bounded_text($value, $length); } private static function static_bounded_text(string $value, int $length): string { $value = trim($value); if (function_exists('mb_substr')) { return (string) mb_substr($value, 0, $length); } return substr($value, 0, $length); } }