- Extract store.ts: database operations, search, document retrieval - createStore() factory pattern for clean DB lifecycle management - Unified DocumentResult type with optional body loading - Snippet extraction with diff-style headers (@@ -line,count @@) - Extract llm.ts: LLM abstraction layer with Ollama implementation - Clean interface for embed, generate, rerank operations - High-level rerankerLogprobsCheck with logprob-based scoring - Query expansion support - Extract formatter.ts: output formatting utilities - Support for CLI, JSON, CSV, MD, XML formats - MCP-specific CSV formatting - Extract mcp.ts: MCP server using createStore() pattern - Single DB connection for server lifetime (fixes closed DB errors) - URL-decode resource paths for proper space/special char handling - Add comprehensive test suites (215 tests total) - store.test.ts: 96 tests covering all store operations - llm.test.ts: 60 tests for LLM abstraction - mcp.test.ts: 59 tests for MCP endpoints and resources - All tests use mocked Ollama (errors on unmocked calls) - Add bun run inspector script for MCP debugging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
903 lines
28 KiB
TypeScript
903 lines
28 KiB
TypeScript
/**
|
|
* llm.test.ts - Comprehensive unit tests for the LLM abstraction layer
|
|
*
|
|
* Run with: bun test llm.test.ts
|
|
*
|
|
* Tests use a mock HTTP server to simulate Ollama responses.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
|
|
import {
|
|
Ollama,
|
|
getDefaultOllama,
|
|
setDefaultOllama,
|
|
formatQueryForEmbedding,
|
|
formatDocForEmbedding,
|
|
type EmbeddingResult,
|
|
type GenerateResult,
|
|
type RerankDocumentResult,
|
|
type TokenLogProb,
|
|
} from "./llm.js";
|
|
|
|
// =============================================================================
|
|
// Mock Server Setup
|
|
// =============================================================================
|
|
|
|
type MockHandler = (body: unknown) => {
|
|
status: number;
|
|
body: unknown;
|
|
};
|
|
|
|
const mockHandlers: Map<string, MockHandler> = new Map();
|
|
let mockServerUrl: string;
|
|
let mockCallLog: Array<{ path: string; body: unknown }> = [];
|
|
|
|
// Track original fetch
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
function installMockFetch(): void {
|
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
|
|
// Only intercept calls to our mock server URL
|
|
if (!url.startsWith(mockServerUrl)) {
|
|
throw new Error(`TEST ERROR: Unexpected fetch to: ${url}`);
|
|
}
|
|
|
|
const path = url.replace(mockServerUrl, "");
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
|
|
// Log the call
|
|
mockCallLog.push({ path, body });
|
|
|
|
const handler = mockHandlers.get(path);
|
|
if (!handler) {
|
|
return new Response(JSON.stringify({ error: "Not found" }), {
|
|
status: 404,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const result = handler(body);
|
|
return new Response(JSON.stringify(result.body), {
|
|
status: result.status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
}
|
|
|
|
function restoreFetch(): void {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
// Setup before all tests
|
|
beforeAll(() => {
|
|
mockServerUrl = "http://mock-ollama:11434";
|
|
installMockFetch();
|
|
});
|
|
|
|
// Restore after all tests
|
|
afterAll(() => {
|
|
restoreFetch();
|
|
});
|
|
|
|
// Clear call log and handlers before each test
|
|
beforeEach(() => {
|
|
mockCallLog = [];
|
|
mockHandlers.clear();
|
|
});
|
|
|
|
// =============================================================================
|
|
// Helper Functions
|
|
// =============================================================================
|
|
|
|
function createOllama(): Ollama {
|
|
return new Ollama({ baseUrl: mockServerUrl });
|
|
}
|
|
|
|
function setEmbedHandler(embeddings: number[][]): void {
|
|
mockHandlers.set("/api/embed", () => ({
|
|
status: 200,
|
|
body: { embeddings },
|
|
}));
|
|
}
|
|
|
|
function setGenerateHandler(
|
|
response: string,
|
|
logprobs?: { tokens: string[]; token_logprobs: number[] }
|
|
): void {
|
|
mockHandlers.set("/api/generate", () => ({
|
|
status: 200,
|
|
body: {
|
|
response,
|
|
done: true,
|
|
...(logprobs && { logprobs }),
|
|
},
|
|
}));
|
|
}
|
|
|
|
function setModelShowHandler(exists: boolean, size?: number): void {
|
|
mockHandlers.set("/api/show", () => {
|
|
if (exists) {
|
|
return {
|
|
status: 200,
|
|
body: { size: size ?? 1000000, modified_at: "2024-01-01T00:00:00Z" },
|
|
};
|
|
}
|
|
return { status: 404, body: { error: "model not found" } };
|
|
});
|
|
}
|
|
|
|
function setPullHandler(success: boolean): void {
|
|
mockHandlers.set("/api/pull", () => ({
|
|
status: success ? 200 : 500,
|
|
body: success ? { status: "success" } : { error: "failed" },
|
|
}));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Formatting Tests
|
|
// =============================================================================
|
|
|
|
describe("Formatting Functions", () => {
|
|
test("formatQueryForEmbedding adds search task prefix", () => {
|
|
const result = formatQueryForEmbedding("how to deploy");
|
|
expect(result).toBe("task: search result | query: how to deploy");
|
|
});
|
|
|
|
test("formatQueryForEmbedding handles empty query", () => {
|
|
const result = formatQueryForEmbedding("");
|
|
expect(result).toBe("task: search result | query: ");
|
|
});
|
|
|
|
test("formatDocForEmbedding adds title and text prefix", () => {
|
|
const result = formatDocForEmbedding("Document content", "My Title");
|
|
expect(result).toBe("title: My Title | text: Document content");
|
|
});
|
|
|
|
test("formatDocForEmbedding handles missing title", () => {
|
|
const result = formatDocForEmbedding("Document content");
|
|
expect(result).toBe("title: none | text: Document content");
|
|
});
|
|
|
|
test("formatDocForEmbedding handles empty content", () => {
|
|
const result = formatDocForEmbedding("", "Title");
|
|
expect(result).toBe("title: Title | text: ");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Ollama Constructor Tests
|
|
// =============================================================================
|
|
|
|
describe("Ollama Constructor", () => {
|
|
test("uses default URL when not specified", () => {
|
|
const ollama = new Ollama();
|
|
expect(ollama.getBaseUrl()).toBe("http://localhost:11434");
|
|
});
|
|
|
|
test("uses custom URL when specified", () => {
|
|
const ollama = new Ollama({ baseUrl: "http://custom:9999" });
|
|
expect(ollama.getBaseUrl()).toBe("http://custom:9999");
|
|
});
|
|
|
|
test("respects OLLAMA_URL environment variable", () => {
|
|
const originalEnv = process.env.OLLAMA_URL;
|
|
process.env.OLLAMA_URL = "http://env-url:8888";
|
|
|
|
const ollama = new Ollama();
|
|
expect(ollama.getBaseUrl()).toBe("http://env-url:8888");
|
|
|
|
// Restore
|
|
if (originalEnv) {
|
|
process.env.OLLAMA_URL = originalEnv;
|
|
} else {
|
|
delete process.env.OLLAMA_URL;
|
|
}
|
|
});
|
|
|
|
test("explicit baseUrl overrides environment variable", () => {
|
|
const originalEnv = process.env.OLLAMA_URL;
|
|
process.env.OLLAMA_URL = "http://env-url:8888";
|
|
|
|
const ollama = new Ollama({ baseUrl: "http://explicit:7777" });
|
|
expect(ollama.getBaseUrl()).toBe("http://explicit:7777");
|
|
|
|
// Restore
|
|
if (originalEnv) {
|
|
process.env.OLLAMA_URL = originalEnv;
|
|
} else {
|
|
delete process.env.OLLAMA_URL;
|
|
}
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Embed Tests
|
|
// =============================================================================
|
|
|
|
describe("Ollama.embed", () => {
|
|
test("returns embedding for query", async () => {
|
|
const ollama = createOllama();
|
|
const embedding = [0.1, 0.2, 0.3, 0.4, 0.5];
|
|
setEmbedHandler([embedding]);
|
|
|
|
const result = await ollama.embed("test query", { model: "test-model", isQuery: true });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.embedding).toEqual(embedding);
|
|
expect(result!.model).toBe("test-model");
|
|
|
|
// Verify the request was formatted correctly
|
|
expect(mockCallLog).toHaveLength(1);
|
|
expect(mockCallLog[0].path).toBe("/api/embed");
|
|
expect((mockCallLog[0].body as { input: string }).input).toContain("task: search result");
|
|
});
|
|
|
|
test("returns embedding for document", async () => {
|
|
const ollama = createOllama();
|
|
const embedding = [0.5, 0.4, 0.3, 0.2, 0.1];
|
|
setEmbedHandler([embedding]);
|
|
|
|
const result = await ollama.embed("doc content", {
|
|
model: "test-model",
|
|
isQuery: false,
|
|
title: "Doc Title",
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.embedding).toEqual(embedding);
|
|
|
|
// Verify document formatting
|
|
expect((mockCallLog[0].body as { input: string }).input).toContain("title: Doc Title");
|
|
expect((mockCallLog[0].body as { input: string }).input).toContain("text: doc content");
|
|
});
|
|
|
|
test("returns null on API error", async () => {
|
|
const ollama = createOllama();
|
|
mockHandlers.set("/api/embed", () => ({ status: 500, body: { error: "Server error" } }));
|
|
|
|
const result = await ollama.embed("test", { model: "test-model" });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("returns null on empty embeddings", async () => {
|
|
const ollama = createOllama();
|
|
setEmbedHandler([]);
|
|
|
|
const result = await ollama.embed("test", { model: "test-model" });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("returns null on network error", async () => {
|
|
const ollama = new Ollama({ baseUrl: "http://nonexistent:99999" });
|
|
|
|
// This will throw because our mock doesn't handle this URL
|
|
const result = await ollama.embed("test", { model: "test-model" }).catch(() => null);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("handles high-dimensional embeddings", async () => {
|
|
const ollama = createOllama();
|
|
const embedding = Array(768).fill(0).map((_, i) => i / 768);
|
|
setEmbedHandler([embedding]);
|
|
|
|
const result = await ollama.embed("test", { model: "test-model" });
|
|
expect(result!.embedding).toHaveLength(768);
|
|
expect(result!.embedding[0]).toBeCloseTo(0, 5);
|
|
expect(result!.embedding[767]).toBeCloseTo(767 / 768, 5);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Generate Tests
|
|
// =============================================================================
|
|
|
|
describe("Ollama.generate", () => {
|
|
test("returns generated text", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("Generated response text");
|
|
|
|
const result = await ollama.generate("prompt", { model: "test-model" });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.text).toBe("Generated response text");
|
|
expect(result!.model).toBe("test-model");
|
|
expect(result!.done).toBe(true);
|
|
});
|
|
|
|
test("includes logprobs when requested", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", {
|
|
tokens: ["yes"],
|
|
token_logprobs: [-0.1],
|
|
});
|
|
|
|
const result = await ollama.generate("prompt", { model: "test-model", logprobs: true });
|
|
|
|
expect(result!.logprobs).toBeDefined();
|
|
expect(result!.logprobs).toHaveLength(1);
|
|
expect(result!.logprobs![0].token).toBe("yes");
|
|
expect(result!.logprobs![0].logprob).toBe(-0.1);
|
|
});
|
|
|
|
test("handles multiple logprob tokens", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("hello world", {
|
|
tokens: ["hello", " world"],
|
|
token_logprobs: [-0.5, -0.3],
|
|
});
|
|
|
|
const result = await ollama.generate("prompt", { model: "test-model", logprobs: true });
|
|
|
|
expect(result!.logprobs).toHaveLength(2);
|
|
expect(result!.logprobs![0]).toEqual({ token: "hello", logprob: -0.5 });
|
|
expect(result!.logprobs![1]).toEqual({ token: " world", logprob: -0.3 });
|
|
});
|
|
|
|
test("sends maxTokens option", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("response");
|
|
|
|
await ollama.generate("prompt", { model: "test-model", maxTokens: 50 });
|
|
|
|
const body = mockCallLog[0].body as { options: { num_predict: number } };
|
|
expect(body.options.num_predict).toBe(50);
|
|
});
|
|
|
|
test("sends temperature option", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("response");
|
|
|
|
await ollama.generate("prompt", { model: "test-model", temperature: 0.7 });
|
|
|
|
const body = mockCallLog[0].body as { options: { temperature: number } };
|
|
expect(body.options.temperature).toBe(0.7);
|
|
});
|
|
|
|
test("sends raw option", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("response");
|
|
|
|
await ollama.generate("prompt", { model: "test-model", raw: true });
|
|
|
|
const body = mockCallLog[0].body as { raw: boolean };
|
|
expect(body.raw).toBe(true);
|
|
});
|
|
|
|
test("returns null on API error", async () => {
|
|
const ollama = createOllama();
|
|
mockHandlers.set("/api/generate", () => ({ status: 500, body: { error: "Error" } }));
|
|
|
|
const result = await ollama.generate("prompt", { model: "test-model" });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("handles empty response", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("");
|
|
|
|
const result = await ollama.generate("prompt", { model: "test-model" });
|
|
expect(result!.text).toBe("");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Model Management Tests
|
|
// =============================================================================
|
|
|
|
describe("Ollama.modelExists", () => {
|
|
test("returns true for existing model", async () => {
|
|
const ollama = createOllama();
|
|
setModelShowHandler(true, 5000000);
|
|
|
|
const result = await ollama.modelExists("test-model");
|
|
|
|
expect(result.exists).toBe(true);
|
|
expect(result.name).toBe("test-model");
|
|
expect(result.size).toBe(5000000);
|
|
expect(result.modifiedAt).toBeDefined();
|
|
});
|
|
|
|
test("returns false for non-existing model", async () => {
|
|
const ollama = createOllama();
|
|
setModelShowHandler(false);
|
|
|
|
const result = await ollama.modelExists("nonexistent-model");
|
|
|
|
expect(result.exists).toBe(false);
|
|
expect(result.name).toBe("nonexistent-model");
|
|
});
|
|
|
|
test("sends correct model name in request", async () => {
|
|
const ollama = createOllama();
|
|
setModelShowHandler(true);
|
|
|
|
await ollama.modelExists("specific-model:v1");
|
|
|
|
expect(mockCallLog[0].path).toBe("/api/show");
|
|
expect((mockCallLog[0].body as { name: string }).name).toBe("specific-model:v1");
|
|
});
|
|
});
|
|
|
|
describe("Ollama.pullModel", () => {
|
|
test("returns true on successful pull", async () => {
|
|
const ollama = createOllama();
|
|
setPullHandler(true);
|
|
|
|
const result = await ollama.pullModel("new-model");
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockCallLog[0].path).toBe("/api/pull");
|
|
expect((mockCallLog[0].body as { name: string }).name).toBe("new-model");
|
|
});
|
|
|
|
test("returns false on failed pull", async () => {
|
|
const ollama = createOllama();
|
|
setPullHandler(false);
|
|
|
|
const result = await ollama.pullModel("bad-model");
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test("calls progress callback", async () => {
|
|
const ollama = createOllama();
|
|
setPullHandler(true);
|
|
|
|
let progressCalled = false;
|
|
await ollama.pullModel("model", (progress) => {
|
|
progressCalled = true;
|
|
expect(progress).toBe(100);
|
|
});
|
|
|
|
expect(progressCalled).toBe(true);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Query Expansion Tests
|
|
// =============================================================================
|
|
|
|
describe("Ollama.expandQuery", () => {
|
|
test("returns original query plus expansions", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("variation one\nvariation two");
|
|
|
|
const result = await ollama.expandQuery("original query", "test-model");
|
|
|
|
expect(result).toContain("original query");
|
|
expect(result[0]).toBe("original query");
|
|
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test("returns only original query on API failure", async () => {
|
|
const ollama = createOllama();
|
|
mockHandlers.set("/api/generate", () => ({ status: 500, body: { error: "Error" } }));
|
|
|
|
const result = await ollama.expandQuery("query", "test-model");
|
|
|
|
expect(result).toEqual(["query"]);
|
|
});
|
|
|
|
test("filters out thinking tags from response", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("<think>some thinking</think>\nvariation one\nvariation two");
|
|
|
|
const result = await ollama.expandQuery("query", "test-model");
|
|
|
|
expect(result).not.toContain("<think>");
|
|
expect(result.some((r) => r.includes("think"))).toBe(false);
|
|
});
|
|
|
|
test("filters out very long variations", async () => {
|
|
const ollama = createOllama();
|
|
const longLine = "a".repeat(150);
|
|
setGenerateHandler(`short variation\n${longLine}\nanother short`);
|
|
|
|
const result = await ollama.expandQuery("query", "test-model");
|
|
|
|
// Long variations (>100 chars) should be filtered
|
|
expect(result.every((r) => r.length < 100)).toBe(true);
|
|
});
|
|
|
|
test("respects numVariations parameter", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("one\ntwo\nthree\nfour\nfive");
|
|
|
|
const result = await ollama.expandQuery("query", "test-model", 3);
|
|
|
|
// Original + up to 3 variations
|
|
expect(result.length).toBeLessThanOrEqual(4);
|
|
});
|
|
|
|
test("sends correct prompt format", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("variation");
|
|
|
|
await ollama.expandQuery("test query", "test-model", 2);
|
|
|
|
const body = mockCallLog[0].body as { prompt: string };
|
|
expect(body.prompt).toContain('Query: "test query"');
|
|
expect(body.prompt).toContain("generate 2 alternative queries");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Reranking Tests
|
|
// =============================================================================
|
|
|
|
describe("Ollama.rerankerLogprobsCheck", () => {
|
|
test("returns relevance judgments for documents", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
|
|
|
|
const docs = [
|
|
{ file: "doc1.md", text: "Relevant content" },
|
|
{ file: "doc2.md", text: "Other content" },
|
|
];
|
|
|
|
const results = await ollama.rerankerLogprobsCheck("query", docs, { model: "test-model" });
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0].file).toBe("doc1.md");
|
|
expect(results[0].relevant).toBe(true);
|
|
expect(results[0].rawToken).toBe("yes");
|
|
});
|
|
|
|
test("parses yes with high confidence correctly", async () => {
|
|
const ollama = createOllama();
|
|
// -0.1 logprob = ~0.905 confidence
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results[0].relevant).toBe(true);
|
|
expect(results[0].confidence).toBeCloseTo(Math.exp(-0.1), 3);
|
|
expect(results[0].score).toBeGreaterThan(0.9);
|
|
expect(results[0].logprob).toBe(-0.1);
|
|
});
|
|
|
|
test("parses yes with low confidence correctly", async () => {
|
|
const ollama = createOllama();
|
|
// -2.0 logprob = ~0.135 confidence
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-2.0] });
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results[0].relevant).toBe(true);
|
|
expect(results[0].confidence).toBeCloseTo(Math.exp(-2.0), 3);
|
|
expect(results[0].score).toBeLessThan(0.6);
|
|
});
|
|
|
|
test("parses no with high confidence correctly", async () => {
|
|
const ollama = createOllama();
|
|
// -0.05 logprob = ~0.95 confidence
|
|
setGenerateHandler("no", { tokens: ["no"], token_logprobs: [-0.05] });
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results[0].relevant).toBe(false);
|
|
expect(results[0].confidence).toBeCloseTo(Math.exp(-0.05), 3);
|
|
expect(results[0].score).toBeLessThan(0.1); // Low score for confident "no"
|
|
});
|
|
|
|
test("parses no with low confidence correctly", async () => {
|
|
const ollama = createOllama();
|
|
// -1.5 logprob = ~0.22 confidence
|
|
setGenerateHandler("no", { tokens: ["no"], token_logprobs: [-1.5] });
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results[0].relevant).toBe(false);
|
|
expect(results[0].score).toBeGreaterThan(0.3); // Higher score for uncertain "no"
|
|
});
|
|
|
|
test("handles unknown token", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("maybe", { tokens: ["maybe"], token_logprobs: [-0.5] });
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results[0].relevant).toBe(false);
|
|
expect(results[0].score).toBe(0.3); // Neutral score
|
|
});
|
|
|
|
test("handles API failure gracefully", async () => {
|
|
const ollama = createOllama();
|
|
mockHandlers.set("/api/generate", () => ({ status: 500, body: { error: "Error" } }));
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results[0].relevant).toBe(false);
|
|
expect(results[0].score).toBe(0);
|
|
expect(results[0].confidence).toBe(0);
|
|
});
|
|
|
|
test("respects batchSize option", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
|
|
|
|
const docs = Array(10).fill(null).map((_, i) => ({
|
|
file: `doc${i}.md`,
|
|
text: `content ${i}`,
|
|
}));
|
|
|
|
await ollama.rerankerLogprobsCheck("query", docs, { model: "test-model", batchSize: 3 });
|
|
|
|
// Should process in batches: 3 + 3 + 3 + 1 = 10 calls
|
|
expect(mockCallLog).toHaveLength(10);
|
|
});
|
|
|
|
test("sends correct prompt format", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
|
|
|
|
await ollama.rerankerLogprobsCheck(
|
|
"search query",
|
|
[{ file: "test.md", text: "document content", title: "Test Doc" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
const body = mockCallLog[0].body as { prompt: string; raw: boolean; logprobs: boolean };
|
|
expect(body.prompt).toContain("<Query>: search query");
|
|
expect(body.prompt).toContain("<Document Title>: Test Doc");
|
|
expect(body.prompt).toContain("document content");
|
|
expect(body.raw).toBe(true);
|
|
expect(body.logprobs).toBe(true);
|
|
});
|
|
|
|
test("uses filename as title when title not provided", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
|
|
|
|
await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "path/to/document.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
const body = mockCallLog[0].body as { prompt: string };
|
|
expect(body.prompt).toContain("<Document Title>: document");
|
|
});
|
|
|
|
test("truncates long documents", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
|
|
|
|
const longText = "x".repeat(10000);
|
|
await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: longText }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
const body = mockCallLog[0].body as { prompt: string };
|
|
// Should be truncated to ~4000 chars + "..."
|
|
expect(body.prompt.length).toBeLessThan(10000);
|
|
expect(body.prompt).toContain("...");
|
|
});
|
|
});
|
|
|
|
describe("Ollama.rerank", () => {
|
|
test("returns sorted results by score", async () => {
|
|
const ollama = createOllama();
|
|
|
|
// First call returns "no", second returns "yes"
|
|
let callCount = 0;
|
|
mockHandlers.set("/api/generate", () => {
|
|
callCount++;
|
|
if (callCount === 1) {
|
|
return { status: 200, body: { response: "no", done: true, logprobs: { tokens: ["no"], token_logprobs: [-0.1] } } };
|
|
}
|
|
return { status: 200, body: { response: "yes", done: true, logprobs: { tokens: ["yes"], token_logprobs: [-0.1] } } };
|
|
});
|
|
|
|
const docs = [
|
|
{ file: "low.md", text: "irrelevant" },
|
|
{ file: "high.md", text: "relevant" },
|
|
];
|
|
|
|
const result = await ollama.rerank("query", docs, { model: "test-model" });
|
|
|
|
expect(result.results).toHaveLength(2);
|
|
expect(result.results[0].file).toBe("high.md"); // Higher score first
|
|
expect(result.results[0].score).toBeGreaterThan(result.results[1].score);
|
|
});
|
|
|
|
test("includes model in result", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
|
|
|
|
const result = await ollama.rerank("query", [{ file: "doc.md", text: "content" }], {
|
|
model: "custom-reranker",
|
|
});
|
|
|
|
expect(result.model).toBe("custom-reranker");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Default Ollama Singleton Tests
|
|
// =============================================================================
|
|
|
|
describe("Default Ollama Singleton", () => {
|
|
afterEach(() => {
|
|
setDefaultOllama(null);
|
|
});
|
|
|
|
test("getDefaultOllama creates instance on first call", () => {
|
|
const ollama = getDefaultOllama();
|
|
expect(ollama).toBeInstanceOf(Ollama);
|
|
});
|
|
|
|
test("getDefaultOllama returns same instance on subsequent calls", () => {
|
|
const ollama1 = getDefaultOllama();
|
|
const ollama2 = getDefaultOllama();
|
|
expect(ollama1).toBe(ollama2);
|
|
});
|
|
|
|
test("setDefaultOllama allows replacing the singleton", () => {
|
|
const custom = new Ollama({ baseUrl: "http://custom:1234" });
|
|
setDefaultOllama(custom);
|
|
|
|
const result = getDefaultOllama();
|
|
expect(result).toBe(custom);
|
|
expect(result.getBaseUrl()).toBe("http://custom:1234");
|
|
});
|
|
|
|
test("setDefaultOllama with null resets singleton", () => {
|
|
const original = getDefaultOllama();
|
|
setDefaultOllama(null);
|
|
const newInstance = getDefaultOllama();
|
|
|
|
expect(newInstance).not.toBe(original);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Logprob Math Tests
|
|
// =============================================================================
|
|
|
|
describe("Logprob Mathematics", () => {
|
|
test("logprob 0 = 100% confidence", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [0] });
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results[0].confidence).toBe(1.0);
|
|
expect(results[0].score).toBe(1.0); // 0.5 + 0.5 * 1.0
|
|
});
|
|
|
|
test("logprob -ln(2) ≈ 50% confidence", async () => {
|
|
const ollama = createOllama();
|
|
const logprob = -Math.log(2); // ≈ -0.693
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [logprob] });
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results[0].confidence).toBeCloseTo(0.5, 3);
|
|
expect(results[0].score).toBeCloseTo(0.75, 3); // 0.5 + 0.5 * 0.5
|
|
});
|
|
|
|
test("very negative logprob = very low confidence", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-10] });
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results[0].confidence).toBeLessThan(0.0001);
|
|
expect(results[0].score).toBeCloseTo(0.5, 2); // Nearly just the base 0.5
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Edge Cases
|
|
// =============================================================================
|
|
|
|
describe("Edge Cases", () => {
|
|
test("handles empty document list", async () => {
|
|
const ollama = createOllama();
|
|
|
|
const results = await ollama.rerankerLogprobsCheck("query", [], { model: "test-model" });
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
test("handles very short document text", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: "x" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results).toHaveLength(1);
|
|
});
|
|
|
|
test("handles unicode in queries and documents", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"日本語クエリ",
|
|
[{ file: "doc.md", text: "日本語コンテンツ 🎉" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results).toHaveLength(1);
|
|
|
|
const body = mockCallLog[0].body as { prompt: string };
|
|
expect(body.prompt).toContain("日本語クエリ");
|
|
expect(body.prompt).toContain("日本語コンテンツ");
|
|
});
|
|
|
|
test("handles special characters in file paths", async () => {
|
|
const ollama = createOllama();
|
|
setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "path/to/file with spaces.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
expect(results[0].file).toBe("path/to/file with spaces.md");
|
|
});
|
|
|
|
test("handles missing logprobs in response", async () => {
|
|
const ollama = createOllama();
|
|
// Response without logprobs
|
|
mockHandlers.set("/api/generate", () => ({
|
|
status: 200,
|
|
body: { response: "yes", done: true },
|
|
}));
|
|
|
|
const results = await ollama.rerankerLogprobsCheck(
|
|
"query",
|
|
[{ file: "doc.md", text: "content" }],
|
|
{ model: "test-model" }
|
|
);
|
|
|
|
// Should still work, with logprob defaulting to 0
|
|
expect(results[0].logprob).toBe(0);
|
|
});
|
|
});
|