UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

142 lines • 6.64 kB
import { EmbeddingRetriever } from '../rag/embedding-retriever.js'; import { ingestDocuments } from '../rag/ingest.js'; import { MemoryVectorStore } from '../rag/memory-vector-store.js'; /** * A deterministic, offline {@link EmbeddingProvider} — no API key, no network. Each text is embedded as * a fixed-length bag-of-words vector: every whitespace/punctuation-delimited token is lower-cased and * hashed into a dimension whose count it increments. Texts that share words land close under cosine * similarity, so retrieval ranks the relevant chunk first, while the mapping stays a pure function of * the input (replay-safe, reproducible across runs). Mirrors the reference deterministic fake. */ export class FakeEmbeddingProvider { dimensions; reportUsage; /** * @param dimensions tamanho do vetor. * @param reportUsage se deve reportar consumo. Default `true`, para que o caminho contabilizado * seja o que os testes exercitam por padrão. Passe `false` para dublar um provider ANTIGO, sem * `embedWithUsage` — é assim que se prova que o fallback continua de pé. */ constructor(dimensions = 64, reportUsage = true) { this.dimensions = dimensions; this.reportUsage = reportUsage; } async embed(texts) { return texts.map((text) => this.embedOne(text)); } async embedWithUsage(texts) { if (!this.reportUsage) { // Um provider sem a capacidade não teria o método; aqui ele existe mas se recusa a contar, // que é o mesmo efeito para o chamador e mantém o dublê numa classe só. return { vectors: await this.embed(texts) }; } return { vectors: await this.embed(texts), usage: { // Uma aproximação determinística: um "token" por palavra, somando o lote inteiro. Não // precisa bater com tokenizador nenhum — o que os testes verificam é que a contagem // ATRAVESSA até o ledger, não qual é o número. inputTokens: texts.reduce((total, text) => total + (text.toLowerCase().match(/[a-z0-9]+/g) ?? []).length, 0), modelId: 'fake-embedding', }, }; } embedOne(text) { const vector = new Array(this.dimensions).fill(0); for (const token of text.toLowerCase().split(/[^a-z0-9]+/)) { if (token.length === 0) { continue; } const index = hashToken(token, this.dimensions); vector[index] = (vector[index] ?? 0) + 1; } return vector; } } /** * A deterministic hashed bag-of-words {@link EmbeddingProvider}: Unicode tokens (runs of any letter or * digit, so "coração" and "東京" stay whole), FNV-1a buckets, L2-normalized. No model and no network — * similar *wording* ranks close, meaning does not. For tests, and for a "no embedding model configured" * dev mode that still exercises ingest → embed → retrieve end to end. Reports one token per word as * usage (model `hashed-<dimensions>`), so the accounted path runs too. * * ```ts * retriever: retrievers.pgvector({ embedder: hashedEmbeddings(256), dimension: 256 }) * ``` */ export function hashedEmbeddings(dimensions = 64) { const tokensOf = (text) => text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []; const embed = async (texts) => texts.map((text) => { const vector = new Array(dimensions).fill(0); for (const token of tokensOf(text)) { const index = hashToken(token, dimensions); vector[index] = (vector[index] ?? 0) + 1; } const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)) || 1; return vector.map((value) => value / norm); }); return { embed, async embedWithUsage(texts) { return { vectors: await embed(texts), usage: { inputTokens: texts.reduce((total, text) => total + tokensOf(text).length, 0), modelId: `hashed-${dimensions}`, }, }; }, }; } /** FNV-1a-ish token hash folded into `[0, dimensions)`. Deterministic and dependency-free. */ function hashToken(token, dimensions) { let hash = 2166136261; for (let index = 0; index < token.length; index += 1) { hash ^= token.charCodeAt(index); hash = Math.imul(hash, 16777619); } return (hash >>> 0) % dimensions; } /** * A deterministic, offline {@link Reranker} for tests: re-scores each passage by how many distinct * query terms it contains (lexical overlap), then sorts descending and truncates to `topK`. No model, * no randomness — enough to prove {@link import('../rag/reranking-retriever.js').RerankingRetriever} * genuinely RE-ORDERS candidates (not just truncates) against a known-relevant passage. Stands in for a * real cross-encoder rerank endpoint (provider impls deferred this round). Mirrors the reference fake. */ export class FakeReranker { async rerank(query, passages, options = {}) { const terms = new Set(query.toLowerCase().match(/[a-z0-9]+/g) ?? []); const scored = passages.map((passage) => { const words = new Set(passage.text.toLowerCase().match(/[a-z0-9]+/g) ?? []); let overlap = 0; for (const term of terms) { if (words.has(term)) { overlap += 1; } } return { ...passage, score: overlap }; }); scored.sort((a, b) => b.score - a.score); return options.topK !== undefined ? scored.slice(0, options.topK) : scored; } } /** * Build a ready-to-use in-memory {@link EmbeddingRetriever} for tests: a {@link MemoryVectorStore} fed * by the given (or a fresh {@link FakeEmbeddingProvider}) embedder, with `documents` ingested. Wire the * result as the config's `retriever` to exercise inject-mode retrieval offline. */ export async function inMemoryRetriever(options) { const embedder = options.embedder ?? new FakeEmbeddingProvider(); const store = new MemoryVectorStore(); if (options.documents !== undefined && options.documents.length > 0) { await ingestDocuments(options.documents, { embedder, store, ...(options.chunkSize !== undefined ? { chunkSize: options.chunkSize } : {}), ...(options.overlap !== undefined ? { overlap: options.overlap } : {}), }); } return new EmbeddingRetriever(embedder, store); } //# sourceMappingURL=fake-embedding-provider.js.map