isAuthenticated() || !$principal->can(Capabilities::EXPORT_CONVERSATIONS)) { throw new ChatException('forbidden', 403); } } /** * Counts since $since (inclusive). Definitions: conversations = sessions created in the period in which the visitor sent at least one message (a page view that only opened a session is not a conversation); answers = assistant * messages created in the period (a stopped answer counts as an answer); rateable = answers that are not stopped; * rated = answers with a helpful or not-helpful rating; failures = audit events (one per failed request, not per message). * * @return array */ public function summary(Principal $principal, int $since, int $bot = 0, int $auditDays = 30): array { $this->authorize($principal); $site = $principal->siteId; $botArgs = $bot > 0 ? [$bot] : []; $conversations = (int) ($this->db->selectOne( "SELECT COUNT(*) AS n FROM {chat_sessions} cs WHERE cs.site_id = ? AND cs.created_at >= ?" . ($bot > 0 ? ' AND cs.bot_id = ?' : '') . " AND EXISTS (SELECT 1 FROM {messages} um WHERE um.site_id = cs.site_id AND um.session_id = cs.id AND um.role = 'user')", [$site, $since, ...$botArgs] )['n'] ?? 0); $a = $this->db->selectOne( "SELECT COUNT(*) AS answers, COALESCE(SUM(CASE WHEN " . self::SQL_EXACT . " THEN 1 ELSE 0 END), 0) AS exact, COALESCE(SUM(CASE WHEN m.outcome = 'withdrawn' THEN 1 ELSE 0 END), 0) AS withdrawn, COALESCE(SUM(CASE WHEN " . self::SQL_INFERRED . " THEN 1 ELSE 0 END), 0) AS inferred, COALESCE(SUM(CASE WHEN m.outcome = 'stopped' THEN 1 ELSE 0 END), 0) AS stopped FROM {messages} m WHERE m.site_id = ? AND m.role = 'assistant' AND m.created_at >= ?" . ($bot > 0 ? ' AND m.bot_id = ?' : ''), [$site, $since, ...$botArgs] ) ?? []; $rating = [1 => 0, -1 => 0]; foreach ($this->db->select( 'SELECT f.rating, COUNT(*) AS n FROM {messages} m JOIN {message_feedback} f ON f.site_id = m.site_id AND f.message_id = m.id WHERE m.site_id = ? AND m.created_at >= ?' . ($bot > 0 ? ' AND m.bot_id = ?' : '') . ' GROUP BY f.rating', [$site, $since, ...$botArgs] ) as $r) { $rating[(int) $r['rating']] = (int) $r['n']; } $answers = (int) ($a['answers'] ?? 0); $stopped = (int) ($a['stopped'] ?? 0); // Failures come from the activity log, which has its own retention and no assistant column: say how far back it reaches. $now = $this->clock->now(); $failFrom = max($since, $now - max(1, $auditDays) * 86400); $failures = 0; $limits = 0; foreach ($this->db->select("SELECT outcome, COUNT(*) AS n FROM {audit} WHERE site_id = ? AND action = 'chat.send' AND created_at >= ? GROUP BY outcome", [$site, $failFrom]) as $r) { if (in_array($r['outcome'], ['failed_not_dispatched', 'failed_rejected', 'failed_usage_known', 'uncertain'], true)) { $failures += (int) $r['n']; } elseif ($r['outcome'] === 'budget_denied') { $limits += (int) $r['n']; } } return [ 'conversations' => $conversations, 'answers' => $answers, 'no_match_exact' => (int) ($a['exact'] ?? 0), 'withdrawn' => (int) ($a['withdrawn'] ?? 0), 'no_match_inferred' => (int) ($a['inferred'] ?? 0), 'stopped' => $stopped, 'rateable' => $answers - $stopped, 'rated' => $rating[1] + $rating[-1], 'positive' => $rating[1], 'negative' => $rating[-1], 'provider_failures' => $failures, 'limit_refusals' => $limits, 'failure_window_start' => $failFrom, 'failure_partial' => $failFrom > $since, ]; } /** * Recurring unanswered questions: the newest $cap answers that found no source, their questions normalized (lower case, * punctuation and spacing removed; e-mail addresses and long digit runs masked), exact matches after that counted. * Occurrences are questions asked, not people: one visitor can ask the same thing many times and a conversation can * contain several. Nothing is cached or stored. * * @return array{groups: list}>, analysed:int, capped:bool, minimum:int} */ public function recurring(Principal $principal, int $since, int $bot = 0, int $cap = self::SAMPLE_CAP, int $minimum = self::GROUP_MIN): array { $this->authorize($principal); $cap = max(1, min(self::SAMPLE_CAP, $cap)); $args = [$principal->siteId, $since]; $botSql = ''; if ($bot > 0) { $botSql = ' AND m.bot_id = ?'; $args[] = $bot; } $args[] = $cap + 1; $rows = $this->db->select( "SELECT u.content, m.session_id FROM {messages} m JOIN {messages} u ON u.site_id = m.site_id AND u.session_id = m.session_id AND u.role = 'user' AND u.request_id = m.request_id WHERE m.site_id = ? AND m.role = 'assistant' AND m.created_at >= ?$botSql AND ((" . self::SQL_EXACT . ') OR (' . self::SQL_INFERRED . ')) ORDER BY m.id DESC LIMIT ?', $args ); $capped = count($rows) > $cap; $rows = array_slice($rows, 0, $cap); $groups = []; foreach ($rows as $r) { $label = self::normalize((string) $r['content']); if ($label === '') { continue; } $sid = (int) $r['session_id']; $groups[$label]['n'] = ($groups[$label]['n'] ?? 0) + 1; $groups[$label]['sessions'][$sid] = true; } $out = []; foreach ($groups as $label => $g) { if ($g['n'] >= $minimum) { $out[] = ['label' => (string) $label, 'occurrences' => $g['n'], 'conversations' => count($g['sessions']), 'sessions' => array_slice(array_keys($g['sessions']), 0, self::SOURCE_CONVERSATIONS)]; } } usort($out, static fn(array $x, array $y): int => [$y['occurrences'], $x['label']] <=> [$x['occurrences'], $y['label']]); return ['groups' => array_slice($out, 0, self::GROUP_LIMIT), 'analysed' => count($rows), 'capped' => $capped, 'minimum' => $minimum]; } /** Deterministic and local: no stemming, no translation, no similarity, so only questions that match after this step are grouped. */ public static function normalize(string $text): string { $t = mb_strtolower(mb_substr($text, 0, 1000)); $t = (string) preg_replace('/[^\s@]+@[^\s@]+\.[^\s@]+/u', ' email ', $t); $t = (string) preg_replace('/(?:\p{N}[\s().-]*){7,}/u', ' number ', $t); $t = (string) preg_replace('/[^\p{L}\p{N}]+/u', ' ', $t); return trim(mb_substr(trim($t), 0, self::LABEL_CHARS)); } }