retriever->usesMeteredEmbeddings(); } public function enabledFor(int $siteId, int $botId): bool { return $this->sources->botHasSources($siteId, $botId); } /** * @param string|null $requestId the chat request's ID: it names the query-embedding operation of a remote embedder, so a * repeated request can never be billed or dispatched twice. Unused by lexical retrieval. * @return list */ public function retrieve(Principal $principal, int $botId, string $question, ?string $requestId = null): array { return $this->retriever->retrieve($principal, $botId, $question, $this->topK, $this->minScore, $requestId)['chunks']; } /** @param list $chunks @return list */ public function recheck(int $siteId, int $botId, array $chunks): array { return $this->retriever->recheck($siteId, $botId, $chunks); } /** * @param list $chunks * @return list */ public static function context(array $chunks): array { $out = []; foreach (array_values($chunks) as $i => $c) { $out[] = ['n' => $i + 1, 'title' => $c->title, 'heading' => $c->heading, 'text' => $c->text]; } return $out; } /** * Keeps only [n] markers that refer to provided context; returns the * cleaned text and the citations in order of first use. * * @param list $chunks * @return array{text:string, citations: list} */ public static function resolve(string $answer, array $chunks): array { $chunks = array_values($chunks); $used = []; $text = (string) preg_replace_callback('/\s?\[(\d{1,3})\]/', function (array $m) use ($chunks, &$used): string { $n = (int) $m[1]; if ($n < 1 || $n > count($chunks)) { return ''; // fabricated or out-of-range reference: drop it } $used[$n] = true; return $m[0]; }, $answer); $citations = []; foreach (array_keys($used) as $n) { $c = $chunks[$n - 1]; $citations[] = [ 'n' => $n, 'source_id' => $c->sourceId, 'version_id' => $c->versionId, 'chunk_id' => $c->chunkId, 'title' => $c->title, 'url' => self::citationUrl($c->url, $c->anchor), 'anchor' => $c->anchor, ]; } return ['text' => trim($text), 'citations' => $citations]; } /** * Provenance validation (not semantic support): each citation must point to a chunk that * still exists, belongs to the cited source and version, has the cited anchor, and whose * source is still public, ready, on this bot's allowlist and on that same current version. * Citations failing any check are dropped and their [n] markers removed from the text. * Whether the cited text actually supports the claim is NOT checked here. * * @param \Aiwend\Db\Connection $db * @param array{text:string, citations: list>} $resolved * @return array{text:string, citations: list>, dropped:int} */ public static function validate(\Aiwend\Db\Connection $db, int $siteId, int $botId, array $resolved): array { $kept = []; $dropped = []; foreach ($resolved['citations'] as $c) { $ok = $db->selectOne( "SELECT c.id FROM {knowledge_chunks} c JOIN {knowledge_sources} s ON s.id = c.source_id JOIN {bot_sources} b ON b.source_id = s.id AND b.bot_id = ? WHERE c.id = ? AND c.source_id = ? AND c.version_id = ? AND c.anchor = ? AND c.site_id = ? AND s.site_id = ? AND s.current_version_id = c.version_id AND s.status = 'ready' AND s.visibility = 'public'", [$botId, $c['chunk_id'], $c['source_id'], $c['version_id'], $c['anchor'], $siteId, $siteId] ) !== null; if ($ok) { $kept[] = $c + ['provenance' => 'verified', 'support' => 'not_evaluated']; } else { $dropped[] = (int) $c['n']; } } $text = $resolved['text']; foreach ($dropped as $n) { $text = (string) preg_replace('/\s?\[' . $n . '\]/', '', $text); } return ['text' => trim($text), 'citations' => $kept, 'dropped' => count($dropped)]; } /** Only http(s) URLs are linked; the anchor is appended as a fragment. */ private static function citationUrl(?string $url, string $anchor): ?string { if ($url === null || !preg_match('#^https?://#i', $url)) { return null; } $base = explode('#', $url, 2)[0]; return preg_match('/^[A-Za-z][\w\-:.]*$/', $anchor) && !str_starts_with($anchor, 'section-') ? $base . '#' . $anchor : $base; } }