'local', 'reason' => 'option'); } if ('' === AnswerRails_Utils::get_api_key_for('openai')) { return array('engine' => 'local', 'reason' => 'no_key'); } if ('' === (string) get_option('answerrails_knowledge_vector_store_id', '')) { return array('engine' => 'local', 'reason' => 'no_store'); } return array('engine' => 'vector', 'reason' => 'default'); } /** * Whether the explicit Local override is active WHILE vector search is * fully configured — i.e. testing mode, not supported keyless local * mode. Drives the unmissable admin indicator + notice (#92). */ public static function override_active(): bool { $resolved = self::engine(); return 'option' === $resolved['reason'] && '' !== AnswerRails_Utils::get_api_key_for('openai') && '' !== (string) get_option('answerrails_knowledge_vector_store_id', ''); } /** * A chat message's text — THE one content-parts extraction (#364 D3): * array content joins its `text` parts / string parts with a space; * string content is used as-is; the result is trimmed. Role-agnostic — * callers choose the role. A non-array message has no text. * * @param mixed $message One `$messages` entry. */ public static function message_text($message): string { if (!is_array($message)) { return ''; } $content = $message['content'] ?? ''; if (is_array($content)) { $parts = array(); foreach ($content as $part) { if (is_array($part) && isset($part['text'])) { $parts[] = (string) $part['text']; } elseif (is_string($part)) { $parts[] = $part; } } $content = implode(' ', $parts); } return trim((string) $content); } /** * Remove contact details from prior-turn text before it joins a * retrieval query (#364 D7). Every email-shaped match is removed * (valid or not — broader than capture, which keeps only is_email() * survivors), and every phone-shaped match carrying 7+ digits; each is * replaced by a single space, then the result is trimmed. One pass * suffices: each phone match is a maximal run of its character class * (a leading `+` can only open one), so a removal cannot bring digit * runs together, and emails are removed before phones. * * The grammar is AT LEAST AS BROAD as * AnswerRails_Pro_Escalations::extract_contact() * (pro/class-answerrails-pro-escalations.php:897): spaces, hyphens, * periods, parentheses and a leading `+` all separate phone digits. * It lives free-side because the free plugin ships without `pro/`, and * it deliberately does not reference the Pro pattern — parity is pinned * BEHAVIOURALLY by test (extract_contact() on scrubbed text finds * nothing). Why it exists: contact-capture turns skip retrieval, so * their text never reached the vector-search endpoint; the follow-up * window would otherwise send it there on a later miss turn. */ public static function scrub_contact(string $text): string { $text = (string) preg_replace('/[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/i', ' ', $text); $text = (string) preg_replace_callback( '/\+?\d[\d\s().\-]{6,}\d/', static function (array $m): string { return strlen((string) preg_replace('/\D+/', '', $m[0])) >= 7 ? ' ' : $m[0]; }, $text ); return trim($text); } /** * Build the follow-up context-pass query (#364 D3, Amendment A1). * Pure: reads nothing but its argument. * * - current: the last `user` entry with text — unchanged, uncapped, NOT * scrubbed (pass 1 already sends it; D2 keeps pass 1 byte-identical). * - prior: the nearest EARLIER `user` entry with text — ONE turn only * (A1/Q4: if it is empty after the scrub, there is no prior; nothing * further back is consulted). Scrubbed, then capped from its END. * - assistant: the nearest `assistant` entry between prior and current * with text (A1/Q2(B)) — scrubbed, then capped from its START. * * A turn is non-first iff the scrubbed prior is non-empty; an assistant * answer alone never makes it so. The query joins the non-empty parts * in conversation order (prior, assistant, current). `turns` counts * prior VISITOR turns only — the assistant excerpt is never counted. * * @param array $messages The normalised chat history. * @return array{query: string, turns: int} */ public static function context_query(array $messages): array { $none = array('query' => '', 'turns' => 0); $entries = array_values($messages); $current = -1; for ($i = count($entries) - 1; $i >= 0; $i--) { if (self::has_role($entries[$i], 'user') && '' !== self::message_text($entries[$i])) { $current = $i; break; } } if ($current < 0) { return $none; } $prior_at = -1; for ($i = $current - 1; $i >= 0; $i--) { if (self::has_role($entries[$i], 'user') && '' !== self::message_text($entries[$i])) { $prior_at = $i; break; } } if ($prior_at < 0) { return $none; } $prior = self::cap_keep_end(self::scrub_contact(self::message_text($entries[$prior_at]))); if ('' === $prior) { return $none; } $assistant = ''; for ($i = $current - 1; $i > $prior_at; $i--) { if (self::has_role($entries[$i], 'assistant') && '' !== self::message_text($entries[$i])) { $assistant = self::cap_keep_start(self::scrub_contact(self::message_text($entries[$i]))); break; } } $parts = array_filter( array($prior, $assistant, self::message_text($entries[$current])), static function (string $part): bool { return '' !== $part; } ); return array('query' => implode("\n", $parts), 'turns' => 1); } /** * Whether a `$messages` entry carries the given role (case-insensitive). * * @param mixed $message One `$messages` entry. */ private static function has_role($message, string $role): bool { return is_array($message) && strtolower((string) ($message['role'] ?? '')) === $role; } /** * Cap to CONTEXT_PRIOR_CHARS keeping the END; a word cut by the * boundary is dropped whole (unless the tail has no whitespace at all, * when the hard cut stands). */ private static function cap_keep_end(string $text): string { if (mb_strlen($text) <= self::CONTEXT_PRIOR_CHARS) { return $text; } $tail = mb_substr($text, -self::CONTEXT_PRIOR_CHARS); if (!self::is_space(mb_substr($text, -self::CONTEXT_PRIOR_CHARS - 1, 1))) { $trimmed = preg_replace('/^\S*\s/u', '', $tail, 1); if (is_string($trimmed) && $trimmed !== $tail) { $tail = $trimmed; } } return trim($tail); } /** * Cap to CONTEXT_PRIOR_CHARS keeping the START; a word cut by the * boundary is dropped whole (unless the head has no whitespace at all, * when the hard cut stands). */ private static function cap_keep_start(string $text): string { if (mb_strlen($text) <= self::CONTEXT_PRIOR_CHARS) { return $text; } $head = mb_substr($text, 0, self::CONTEXT_PRIOR_CHARS); if (!self::is_space(mb_substr($text, self::CONTEXT_PRIOR_CHARS, 1))) { $trimmed = preg_replace('/\s\S*$/u', '', $head, 1); if (is_string($trimmed) && $trimmed !== $head) { $head = $trimmed; } } return trim($head); } /** * Whether a single character is whitespace (Unicode-aware). */ private static function is_space(string $char): bool { return 1 === preg_match('/^\s$/u', $char); } }