init
This commit is contained in:
commit
0964819cdc
34 files changed
+7912
No files matched your search
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Shared test scaffolding.
|
||||
*
|
||||
* The embedding stub is a bag-of-words hash rather than the real model, for two
|
||||
* reasons: the suite must run without a network, and a deterministic vector
|
||||
* makes a ranking assertion a statement about the ranking code rather than a
|
||||
* statement about a model that may change under it. Whether the real model
|
||||
* retrieves the right file is a different question, answered by
|
||||
* `scripts/smoke.mjs` against a live endpoint.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
/** Deterministic unit vector for a piece of text. */
|
||||
export function hashEmbed(text, width) {
|
||||
const vector = new Float32Array(width)
|
||||
for (const token of String(text).toLowerCase().split(/[^a-z0-9]+/)) {
|
||||
if (token.length < 3) continue
|
||||
const digest = createHash('sha1').update(token).digest()
|
||||
const slot = ((digest[0] << 8) | digest[1]) % width
|
||||
vector[slot] += 1
|
||||
}
|
||||
let sum = 0
|
||||
for (let at = 0; at < width; at += 1) sum += vector[at] * vector[at]
|
||||
if (sum === 0) return vector
|
||||
const scale = 1 / Math.sqrt(sum)
|
||||
for (let at = 0; at < width; at += 1) vector[at] *= scale
|
||||
return vector
|
||||
}
|
||||
|
||||
/** A `fetch` that answers the Ollama embedding API deterministically. */
|
||||
export function makeStubFetch(options = {}) {
|
||||
const { width = 768, onCall, unsupportedBatch = false, generate = undefined } = options
|
||||
const calls = []
|
||||
const impl = async (url, init) => {
|
||||
const address = String(url)
|
||||
const body = JSON.parse(init.body)
|
||||
calls.push({ url: address, body })
|
||||
onCall?.(address, body)
|
||||
// The generation endpoint the expander and the re-ranker share. Left
|
||||
// unanswered unless a test supplies a response, so a caller that expects
|
||||
// the local model to be absent gets that for free.
|
||||
if (address.endsWith('/api/generate')) {
|
||||
if (generate === undefined) return { ok: false, status: 404, text: async () => 'no model', json: async () => ({}) }
|
||||
const response = typeof generate === 'function' ? generate(body) : generate
|
||||
return { ok: true, status: 200, json: async () => ({ response }) }
|
||||
}
|
||||
if (address.endsWith('/api/embed')) {
|
||||
if (unsupportedBatch) {
|
||||
return { ok: false, status: 404, text: async () => 'not found', json: async () => ({}) }
|
||||
}
|
||||
const inputs = body.input
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ embeddings: inputs.map((text) => Array.from(hashEmbed(text, width))) }),
|
||||
}
|
||||
}
|
||||
if (address.endsWith('/api/embeddings')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ embedding: Array.from(hashEmbed(body.prompt, width)) }),
|
||||
}
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => 'unknown endpoint', json: async () => ({}) }
|
||||
}
|
||||
impl.calls = calls
|
||||
return impl
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest context shape `apply` actually touches.
|
||||
*
|
||||
* `services` mounts optional dependencies: `ctx.inject` invokes its callback
|
||||
* only when every named dependency is present, which is how the plugin decides
|
||||
* whether it can offer a settings namespace or a projection. Passing none
|
||||
* exercises the composition-config path every minimal profile takes.
|
||||
*/
|
||||
export function makeContext(options = {}) {
|
||||
const { services = {} } = options
|
||||
const tools = []
|
||||
const cleanups = []
|
||||
const ctx = {
|
||||
logger: () => ({ warn() {}, info() {}, error() {}, debug() {} }),
|
||||
effect: (fn) => {
|
||||
const dispose = fn()
|
||||
if (typeof dispose === 'function') cleanups.push(dispose)
|
||||
},
|
||||
get: (name) => services[name],
|
||||
inject: (deps, callback) => {
|
||||
const missing = deps.filter((dep) => services[dep] === undefined)
|
||||
if (missing.length > 0) return
|
||||
callback({ ...ctx, ...Object.fromEntries(deps.map((dep) => [dep, services[dep]])) })
|
||||
},
|
||||
tools: { register: (tool) => tools.push(tool) },
|
||||
}
|
||||
return { ctx, tools, dispose: () => cleanups.forEach((fn) => fn()) }
|
||||
}
|
||||
|
||||
/** An `exec` argument for one tool call. */
|
||||
export function makeExec(cwd, signal) {
|
||||
return { signal: signal ?? new AbortController().signal, agent: { session: { header: { cwd } } } }
|
||||
}
|
||||
|
||||
/** Write a map of relative path to content into a fresh temporary directory. */
|
||||
export async function makeWorkspace(files) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'recall-test-'))
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
const absolute = join(root, path)
|
||||
await mkdir(dirname(absolute), { recursive: true })
|
||||
await writeFile(absolute, content, 'utf8')
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** A fresh temporary directory, for index state. */
|
||||
export async function makeStateDir() {
|
||||
return mkdtemp(join(tmpdir(), 'recall-state-'))
|
||||
}
|
||||
Reference in new issue
Block a user