llm.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902
  1. /**
  2. * llm.test.ts - Comprehensive unit tests for the LLM abstraction layer
  3. *
  4. * Run with: bun test llm.test.ts
  5. *
  6. * Tests use a mock HTTP server to simulate Ollama responses.
  7. */
  8. import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
  9. import {
  10. Ollama,
  11. getDefaultOllama,
  12. setDefaultOllama,
  13. formatQueryForEmbedding,
  14. formatDocForEmbedding,
  15. type EmbeddingResult,
  16. type GenerateResult,
  17. type RerankDocumentResult,
  18. type TokenLogProb,
  19. } from "./llm.js";
  20. // =============================================================================
  21. // Mock Server Setup
  22. // =============================================================================
  23. type MockHandler = (body: unknown) => {
  24. status: number;
  25. body: unknown;
  26. };
  27. const mockHandlers: Map<string, MockHandler> = new Map();
  28. let mockServerUrl: string;
  29. let mockCallLog: Array<{ path: string; body: unknown }> = [];
  30. // Track original fetch
  31. const originalFetch = globalThis.fetch;
  32. function installMockFetch(): void {
  33. globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
  34. const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
  35. // Only intercept calls to our mock server URL
  36. if (!url.startsWith(mockServerUrl)) {
  37. throw new Error(`TEST ERROR: Unexpected fetch to: ${url}`);
  38. }
  39. const path = url.replace(mockServerUrl, "");
  40. const body = init?.body ? JSON.parse(init.body as string) : {};
  41. // Log the call
  42. mockCallLog.push({ path, body });
  43. const handler = mockHandlers.get(path);
  44. if (!handler) {
  45. return new Response(JSON.stringify({ error: "Not found" }), {
  46. status: 404,
  47. headers: { "Content-Type": "application/json" },
  48. });
  49. }
  50. const result = handler(body);
  51. return new Response(JSON.stringify(result.body), {
  52. status: result.status,
  53. headers: { "Content-Type": "application/json" },
  54. });
  55. };
  56. }
  57. function restoreFetch(): void {
  58. globalThis.fetch = originalFetch;
  59. }
  60. // Setup before all tests
  61. beforeAll(() => {
  62. mockServerUrl = "http://mock-ollama:11434";
  63. installMockFetch();
  64. });
  65. // Restore after all tests
  66. afterAll(() => {
  67. restoreFetch();
  68. });
  69. // Clear call log and handlers before each test
  70. beforeEach(() => {
  71. mockCallLog = [];
  72. mockHandlers.clear();
  73. });
  74. // =============================================================================
  75. // Helper Functions
  76. // =============================================================================
  77. function createOllama(): Ollama {
  78. return new Ollama({ baseUrl: mockServerUrl });
  79. }
  80. function setEmbedHandler(embeddings: number[][]): void {
  81. mockHandlers.set("/api/embed", () => ({
  82. status: 200,
  83. body: { embeddings },
  84. }));
  85. }
  86. function setGenerateHandler(
  87. response: string,
  88. logprobs?: { tokens: string[]; token_logprobs: number[] }
  89. ): void {
  90. mockHandlers.set("/api/generate", () => ({
  91. status: 200,
  92. body: {
  93. response,
  94. done: true,
  95. ...(logprobs && { logprobs }),
  96. },
  97. }));
  98. }
  99. function setModelShowHandler(exists: boolean, size?: number): void {
  100. mockHandlers.set("/api/show", () => {
  101. if (exists) {
  102. return {
  103. status: 200,
  104. body: { size: size ?? 1000000, modified_at: "2024-01-01T00:00:00Z" },
  105. };
  106. }
  107. return { status: 404, body: { error: "model not found" } };
  108. });
  109. }
  110. function setPullHandler(success: boolean): void {
  111. mockHandlers.set("/api/pull", () => ({
  112. status: success ? 200 : 500,
  113. body: success ? { status: "success" } : { error: "failed" },
  114. }));
  115. }
  116. // =============================================================================
  117. // Formatting Tests
  118. // =============================================================================
  119. describe("Formatting Functions", () => {
  120. test("formatQueryForEmbedding adds search task prefix", () => {
  121. const result = formatQueryForEmbedding("how to deploy");
  122. expect(result).toBe("task: search result | query: how to deploy");
  123. });
  124. test("formatQueryForEmbedding handles empty query", () => {
  125. const result = formatQueryForEmbedding("");
  126. expect(result).toBe("task: search result | query: ");
  127. });
  128. test("formatDocForEmbedding adds title and text prefix", () => {
  129. const result = formatDocForEmbedding("Document content", "My Title");
  130. expect(result).toBe("title: My Title | text: Document content");
  131. });
  132. test("formatDocForEmbedding handles missing title", () => {
  133. const result = formatDocForEmbedding("Document content");
  134. expect(result).toBe("title: none | text: Document content");
  135. });
  136. test("formatDocForEmbedding handles empty content", () => {
  137. const result = formatDocForEmbedding("", "Title");
  138. expect(result).toBe("title: Title | text: ");
  139. });
  140. });
  141. // =============================================================================
  142. // Ollama Constructor Tests
  143. // =============================================================================
  144. describe("Ollama Constructor", () => {
  145. test("uses default URL when not specified", () => {
  146. const ollama = new Ollama();
  147. expect(ollama.getBaseUrl()).toBe("http://localhost:11434");
  148. });
  149. test("uses custom URL when specified", () => {
  150. const ollama = new Ollama({ baseUrl: "http://custom:9999" });
  151. expect(ollama.getBaseUrl()).toBe("http://custom:9999");
  152. });
  153. test("respects OLLAMA_URL environment variable", () => {
  154. const originalEnv = process.env.OLLAMA_URL;
  155. process.env.OLLAMA_URL = "http://env-url:8888";
  156. const ollama = new Ollama();
  157. expect(ollama.getBaseUrl()).toBe("http://env-url:8888");
  158. // Restore
  159. if (originalEnv) {
  160. process.env.OLLAMA_URL = originalEnv;
  161. } else {
  162. delete process.env.OLLAMA_URL;
  163. }
  164. });
  165. test("explicit baseUrl overrides environment variable", () => {
  166. const originalEnv = process.env.OLLAMA_URL;
  167. process.env.OLLAMA_URL = "http://env-url:8888";
  168. const ollama = new Ollama({ baseUrl: "http://explicit:7777" });
  169. expect(ollama.getBaseUrl()).toBe("http://explicit:7777");
  170. // Restore
  171. if (originalEnv) {
  172. process.env.OLLAMA_URL = originalEnv;
  173. } else {
  174. delete process.env.OLLAMA_URL;
  175. }
  176. });
  177. });
  178. // =============================================================================
  179. // Embed Tests
  180. // =============================================================================
  181. describe("Ollama.embed", () => {
  182. test("returns embedding for query", async () => {
  183. const ollama = createOllama();
  184. const embedding = [0.1, 0.2, 0.3, 0.4, 0.5];
  185. setEmbedHandler([embedding]);
  186. const result = await ollama.embed("test query", { model: "test-model", isQuery: true });
  187. expect(result).not.toBeNull();
  188. expect(result!.embedding).toEqual(embedding);
  189. expect(result!.model).toBe("test-model");
  190. // Verify the request was formatted correctly
  191. expect(mockCallLog).toHaveLength(1);
  192. expect(mockCallLog[0].path).toBe("/api/embed");
  193. expect((mockCallLog[0].body as { input: string }).input).toContain("task: search result");
  194. });
  195. test("returns embedding for document", async () => {
  196. const ollama = createOllama();
  197. const embedding = [0.5, 0.4, 0.3, 0.2, 0.1];
  198. setEmbedHandler([embedding]);
  199. const result = await ollama.embed("doc content", {
  200. model: "test-model",
  201. isQuery: false,
  202. title: "Doc Title",
  203. });
  204. expect(result).not.toBeNull();
  205. expect(result!.embedding).toEqual(embedding);
  206. // Verify document formatting
  207. expect((mockCallLog[0].body as { input: string }).input).toContain("title: Doc Title");
  208. expect((mockCallLog[0].body as { input: string }).input).toContain("text: doc content");
  209. });
  210. test("returns null on API error", async () => {
  211. const ollama = createOllama();
  212. mockHandlers.set("/api/embed", () => ({ status: 500, body: { error: "Server error" } }));
  213. const result = await ollama.embed("test", { model: "test-model" });
  214. expect(result).toBeNull();
  215. });
  216. test("returns null on empty embeddings", async () => {
  217. const ollama = createOllama();
  218. setEmbedHandler([]);
  219. const result = await ollama.embed("test", { model: "test-model" });
  220. expect(result).toBeNull();
  221. });
  222. test("returns null on network error", async () => {
  223. const ollama = new Ollama({ baseUrl: "http://nonexistent:99999" });
  224. // This will throw because our mock doesn't handle this URL
  225. const result = await ollama.embed("test", { model: "test-model" }).catch(() => null);
  226. expect(result).toBeNull();
  227. });
  228. test("handles high-dimensional embeddings", async () => {
  229. const ollama = createOllama();
  230. const embedding = Array(768).fill(0).map((_, i) => i / 768);
  231. setEmbedHandler([embedding]);
  232. const result = await ollama.embed("test", { model: "test-model" });
  233. expect(result!.embedding).toHaveLength(768);
  234. expect(result!.embedding[0]).toBeCloseTo(0, 5);
  235. expect(result!.embedding[767]).toBeCloseTo(767 / 768, 5);
  236. });
  237. });
  238. // =============================================================================
  239. // Generate Tests
  240. // =============================================================================
  241. describe("Ollama.generate", () => {
  242. test("returns generated text", async () => {
  243. const ollama = createOllama();
  244. setGenerateHandler("Generated response text");
  245. const result = await ollama.generate("prompt", { model: "test-model" });
  246. expect(result).not.toBeNull();
  247. expect(result!.text).toBe("Generated response text");
  248. expect(result!.model).toBe("test-model");
  249. expect(result!.done).toBe(true);
  250. });
  251. test("includes logprobs when requested", async () => {
  252. const ollama = createOllama();
  253. setGenerateHandler("yes", {
  254. tokens: ["yes"],
  255. token_logprobs: [-0.1],
  256. });
  257. const result = await ollama.generate("prompt", { model: "test-model", logprobs: true });
  258. expect(result!.logprobs).toBeDefined();
  259. expect(result!.logprobs).toHaveLength(1);
  260. expect(result!.logprobs![0].token).toBe("yes");
  261. expect(result!.logprobs![0].logprob).toBe(-0.1);
  262. });
  263. test("handles multiple logprob tokens", async () => {
  264. const ollama = createOllama();
  265. setGenerateHandler("hello world", {
  266. tokens: ["hello", " world"],
  267. token_logprobs: [-0.5, -0.3],
  268. });
  269. const result = await ollama.generate("prompt", { model: "test-model", logprobs: true });
  270. expect(result!.logprobs).toHaveLength(2);
  271. expect(result!.logprobs![0]).toEqual({ token: "hello", logprob: -0.5 });
  272. expect(result!.logprobs![1]).toEqual({ token: " world", logprob: -0.3 });
  273. });
  274. test("sends maxTokens option", async () => {
  275. const ollama = createOllama();
  276. setGenerateHandler("response");
  277. await ollama.generate("prompt", { model: "test-model", maxTokens: 50 });
  278. const body = mockCallLog[0].body as { options: { num_predict: number } };
  279. expect(body.options.num_predict).toBe(50);
  280. });
  281. test("sends temperature option", async () => {
  282. const ollama = createOllama();
  283. setGenerateHandler("response");
  284. await ollama.generate("prompt", { model: "test-model", temperature: 0.7 });
  285. const body = mockCallLog[0].body as { options: { temperature: number } };
  286. expect(body.options.temperature).toBe(0.7);
  287. });
  288. test("sends raw option", async () => {
  289. const ollama = createOllama();
  290. setGenerateHandler("response");
  291. await ollama.generate("prompt", { model: "test-model", raw: true });
  292. const body = mockCallLog[0].body as { raw: boolean };
  293. expect(body.raw).toBe(true);
  294. });
  295. test("returns null on API error", async () => {
  296. const ollama = createOllama();
  297. mockHandlers.set("/api/generate", () => ({ status: 500, body: { error: "Error" } }));
  298. const result = await ollama.generate("prompt", { model: "test-model" });
  299. expect(result).toBeNull();
  300. });
  301. test("handles empty response", async () => {
  302. const ollama = createOllama();
  303. setGenerateHandler("");
  304. const result = await ollama.generate("prompt", { model: "test-model" });
  305. expect(result!.text).toBe("");
  306. });
  307. });
  308. // =============================================================================
  309. // Model Management Tests
  310. // =============================================================================
  311. describe("Ollama.modelExists", () => {
  312. test("returns true for existing model", async () => {
  313. const ollama = createOllama();
  314. setModelShowHandler(true, 5000000);
  315. const result = await ollama.modelExists("test-model");
  316. expect(result.exists).toBe(true);
  317. expect(result.name).toBe("test-model");
  318. expect(result.size).toBe(5000000);
  319. expect(result.modifiedAt).toBeDefined();
  320. });
  321. test("returns false for non-existing model", async () => {
  322. const ollama = createOllama();
  323. setModelShowHandler(false);
  324. const result = await ollama.modelExists("nonexistent-model");
  325. expect(result.exists).toBe(false);
  326. expect(result.name).toBe("nonexistent-model");
  327. });
  328. test("sends correct model name in request", async () => {
  329. const ollama = createOllama();
  330. setModelShowHandler(true);
  331. await ollama.modelExists("specific-model:v1");
  332. expect(mockCallLog[0].path).toBe("/api/show");
  333. expect((mockCallLog[0].body as { name: string }).name).toBe("specific-model:v1");
  334. });
  335. });
  336. describe("Ollama.pullModel", () => {
  337. test("returns true on successful pull", async () => {
  338. const ollama = createOllama();
  339. setPullHandler(true);
  340. const result = await ollama.pullModel("new-model");
  341. expect(result).toBe(true);
  342. expect(mockCallLog[0].path).toBe("/api/pull");
  343. expect((mockCallLog[0].body as { name: string }).name).toBe("new-model");
  344. });
  345. test("returns false on failed pull", async () => {
  346. const ollama = createOllama();
  347. setPullHandler(false);
  348. const result = await ollama.pullModel("bad-model");
  349. expect(result).toBe(false);
  350. });
  351. test("calls progress callback", async () => {
  352. const ollama = createOllama();
  353. setPullHandler(true);
  354. let progressCalled = false;
  355. await ollama.pullModel("model", (progress) => {
  356. progressCalled = true;
  357. expect(progress).toBe(100);
  358. });
  359. expect(progressCalled).toBe(true);
  360. });
  361. });
  362. // =============================================================================
  363. // Query Expansion Tests
  364. // =============================================================================
  365. describe("Ollama.expandQuery", () => {
  366. test("returns original query plus expansions", async () => {
  367. const ollama = createOllama();
  368. setGenerateHandler("variation one\nvariation two");
  369. const result = await ollama.expandQuery("original query", "test-model");
  370. expect(result).toContain("original query");
  371. expect(result[0]).toBe("original query");
  372. expect(result.length).toBeGreaterThanOrEqual(1);
  373. });
  374. test("returns only original query on API failure", async () => {
  375. const ollama = createOllama();
  376. mockHandlers.set("/api/generate", () => ({ status: 500, body: { error: "Error" } }));
  377. const result = await ollama.expandQuery("query", "test-model");
  378. expect(result).toEqual(["query"]);
  379. });
  380. test("filters out thinking tags from response", async () => {
  381. const ollama = createOllama();
  382. setGenerateHandler("<think>some thinking</think>\nvariation one\nvariation two");
  383. const result = await ollama.expandQuery("query", "test-model");
  384. expect(result).not.toContain("<think>");
  385. expect(result.some((r) => r.includes("think"))).toBe(false);
  386. });
  387. test("filters out very long variations", async () => {
  388. const ollama = createOllama();
  389. const longLine = "a".repeat(150);
  390. setGenerateHandler(`short variation\n${longLine}\nanother short`);
  391. const result = await ollama.expandQuery("query", "test-model");
  392. // Long variations (>100 chars) should be filtered
  393. expect(result.every((r) => r.length < 100)).toBe(true);
  394. });
  395. test("respects numVariations parameter", async () => {
  396. const ollama = createOllama();
  397. setGenerateHandler("one\ntwo\nthree\nfour\nfive");
  398. const result = await ollama.expandQuery("query", "test-model", 3);
  399. // Original + up to 3 variations
  400. expect(result.length).toBeLessThanOrEqual(4);
  401. });
  402. test("sends correct prompt format", async () => {
  403. const ollama = createOllama();
  404. setGenerateHandler("variation");
  405. await ollama.expandQuery("test query", "test-model", 2);
  406. const body = mockCallLog[0].body as { prompt: string };
  407. expect(body.prompt).toContain('Query: "test query"');
  408. expect(body.prompt).toContain("generate 2 alternative queries");
  409. });
  410. });
  411. // =============================================================================
  412. // Reranking Tests
  413. // =============================================================================
  414. describe("Ollama.rerankerLogprobsCheck", () => {
  415. test("returns relevance judgments for documents", async () => {
  416. const ollama = createOllama();
  417. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
  418. const docs = [
  419. { file: "doc1.md", text: "Relevant content" },
  420. { file: "doc2.md", text: "Other content" },
  421. ];
  422. const results = await ollama.rerankerLogprobsCheck("query", docs, { model: "test-model" });
  423. expect(results).toHaveLength(2);
  424. expect(results[0].file).toBe("doc1.md");
  425. expect(results[0].relevant).toBe(true);
  426. expect(results[0].rawToken).toBe("yes");
  427. });
  428. test("parses yes with high confidence correctly", async () => {
  429. const ollama = createOllama();
  430. // -0.1 logprob = ~0.905 confidence
  431. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
  432. const results = await ollama.rerankerLogprobsCheck(
  433. "query",
  434. [{ file: "doc.md", text: "content" }],
  435. { model: "test-model" }
  436. );
  437. expect(results[0].relevant).toBe(true);
  438. expect(results[0].confidence).toBeCloseTo(Math.exp(-0.1), 3);
  439. expect(results[0].score).toBeGreaterThan(0.9);
  440. expect(results[0].logprob).toBe(-0.1);
  441. });
  442. test("parses yes with low confidence correctly", async () => {
  443. const ollama = createOllama();
  444. // -2.0 logprob = ~0.135 confidence
  445. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-2.0] });
  446. const results = await ollama.rerankerLogprobsCheck(
  447. "query",
  448. [{ file: "doc.md", text: "content" }],
  449. { model: "test-model" }
  450. );
  451. expect(results[0].relevant).toBe(true);
  452. expect(results[0].confidence).toBeCloseTo(Math.exp(-2.0), 3);
  453. expect(results[0].score).toBeLessThan(0.6);
  454. });
  455. test("parses no with high confidence correctly", async () => {
  456. const ollama = createOllama();
  457. // -0.05 logprob = ~0.95 confidence
  458. setGenerateHandler("no", { tokens: ["no"], token_logprobs: [-0.05] });
  459. const results = await ollama.rerankerLogprobsCheck(
  460. "query",
  461. [{ file: "doc.md", text: "content" }],
  462. { model: "test-model" }
  463. );
  464. expect(results[0].relevant).toBe(false);
  465. expect(results[0].confidence).toBeCloseTo(Math.exp(-0.05), 3);
  466. expect(results[0].score).toBeLessThan(0.1); // Low score for confident "no"
  467. });
  468. test("parses no with low confidence correctly", async () => {
  469. const ollama = createOllama();
  470. // -1.5 logprob = ~0.22 confidence
  471. setGenerateHandler("no", { tokens: ["no"], token_logprobs: [-1.5] });
  472. const results = await ollama.rerankerLogprobsCheck(
  473. "query",
  474. [{ file: "doc.md", text: "content" }],
  475. { model: "test-model" }
  476. );
  477. expect(results[0].relevant).toBe(false);
  478. expect(results[0].score).toBeGreaterThan(0.3); // Higher score for uncertain "no"
  479. });
  480. test("handles unknown token", async () => {
  481. const ollama = createOllama();
  482. setGenerateHandler("maybe", { tokens: ["maybe"], token_logprobs: [-0.5] });
  483. const results = await ollama.rerankerLogprobsCheck(
  484. "query",
  485. [{ file: "doc.md", text: "content" }],
  486. { model: "test-model" }
  487. );
  488. expect(results[0].relevant).toBe(false);
  489. expect(results[0].score).toBe(0.3); // Neutral score
  490. });
  491. test("handles API failure gracefully", async () => {
  492. const ollama = createOllama();
  493. mockHandlers.set("/api/generate", () => ({ status: 500, body: { error: "Error" } }));
  494. const results = await ollama.rerankerLogprobsCheck(
  495. "query",
  496. [{ file: "doc.md", text: "content" }],
  497. { model: "test-model" }
  498. );
  499. expect(results[0].relevant).toBe(false);
  500. expect(results[0].score).toBe(0);
  501. expect(results[0].confidence).toBe(0);
  502. });
  503. test("respects batchSize option", async () => {
  504. const ollama = createOllama();
  505. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
  506. const docs = Array(10).fill(null).map((_, i) => ({
  507. file: `doc${i}.md`,
  508. text: `content ${i}`,
  509. }));
  510. await ollama.rerankerLogprobsCheck("query", docs, { model: "test-model", batchSize: 3 });
  511. // Should process in batches: 3 + 3 + 3 + 1 = 10 calls
  512. expect(mockCallLog).toHaveLength(10);
  513. });
  514. test("sends correct prompt format", async () => {
  515. const ollama = createOllama();
  516. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
  517. await ollama.rerankerLogprobsCheck(
  518. "search query",
  519. [{ file: "test.md", text: "document content", title: "Test Doc" }],
  520. { model: "test-model" }
  521. );
  522. const body = mockCallLog[0].body as { prompt: string; raw: boolean; logprobs: boolean };
  523. expect(body.prompt).toContain("<Query>: search query");
  524. expect(body.prompt).toContain("<Document Title>: Test Doc");
  525. expect(body.prompt).toContain("document content");
  526. expect(body.raw).toBe(true);
  527. expect(body.logprobs).toBe(true);
  528. });
  529. test("uses filename as title when title not provided", async () => {
  530. const ollama = createOllama();
  531. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
  532. await ollama.rerankerLogprobsCheck(
  533. "query",
  534. [{ file: "path/to/document.md", text: "content" }],
  535. { model: "test-model" }
  536. );
  537. const body = mockCallLog[0].body as { prompt: string };
  538. expect(body.prompt).toContain("<Document Title>: document");
  539. });
  540. test("truncates long documents", async () => {
  541. const ollama = createOllama();
  542. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
  543. const longText = "x".repeat(10000);
  544. await ollama.rerankerLogprobsCheck(
  545. "query",
  546. [{ file: "doc.md", text: longText }],
  547. { model: "test-model" }
  548. );
  549. const body = mockCallLog[0].body as { prompt: string };
  550. // Should be truncated to ~4000 chars + "..."
  551. expect(body.prompt.length).toBeLessThan(10000);
  552. expect(body.prompt).toContain("...");
  553. });
  554. });
  555. describe("Ollama.rerank", () => {
  556. test("returns sorted results by score", async () => {
  557. const ollama = createOllama();
  558. // First call returns "no", second returns "yes"
  559. let callCount = 0;
  560. mockHandlers.set("/api/generate", () => {
  561. callCount++;
  562. if (callCount === 1) {
  563. return { status: 200, body: { response: "no", done: true, logprobs: { tokens: ["no"], token_logprobs: [-0.1] } } };
  564. }
  565. return { status: 200, body: { response: "yes", done: true, logprobs: { tokens: ["yes"], token_logprobs: [-0.1] } } };
  566. });
  567. const docs = [
  568. { file: "low.md", text: "irrelevant" },
  569. { file: "high.md", text: "relevant" },
  570. ];
  571. const result = await ollama.rerank("query", docs, { model: "test-model" });
  572. expect(result.results).toHaveLength(2);
  573. expect(result.results[0].file).toBe("high.md"); // Higher score first
  574. expect(result.results[0].score).toBeGreaterThan(result.results[1].score);
  575. });
  576. test("includes model in result", async () => {
  577. const ollama = createOllama();
  578. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
  579. const result = await ollama.rerank("query", [{ file: "doc.md", text: "content" }], {
  580. model: "custom-reranker",
  581. });
  582. expect(result.model).toBe("custom-reranker");
  583. });
  584. });
  585. // =============================================================================
  586. // Default Ollama Singleton Tests
  587. // =============================================================================
  588. describe("Default Ollama Singleton", () => {
  589. afterEach(() => {
  590. setDefaultOllama(null);
  591. });
  592. test("getDefaultOllama creates instance on first call", () => {
  593. const ollama = getDefaultOllama();
  594. expect(ollama).toBeInstanceOf(Ollama);
  595. });
  596. test("getDefaultOllama returns same instance on subsequent calls", () => {
  597. const ollama1 = getDefaultOllama();
  598. const ollama2 = getDefaultOllama();
  599. expect(ollama1).toBe(ollama2);
  600. });
  601. test("setDefaultOllama allows replacing the singleton", () => {
  602. const custom = new Ollama({ baseUrl: "http://custom:1234" });
  603. setDefaultOllama(custom);
  604. const result = getDefaultOllama();
  605. expect(result).toBe(custom);
  606. expect(result.getBaseUrl()).toBe("http://custom:1234");
  607. });
  608. test("setDefaultOllama with null resets singleton", () => {
  609. const original = getDefaultOllama();
  610. setDefaultOllama(null);
  611. const newInstance = getDefaultOllama();
  612. expect(newInstance).not.toBe(original);
  613. });
  614. });
  615. // =============================================================================
  616. // Logprob Math Tests
  617. // =============================================================================
  618. describe("Logprob Mathematics", () => {
  619. test("logprob 0 = 100% confidence", async () => {
  620. const ollama = createOllama();
  621. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [0] });
  622. const results = await ollama.rerankerLogprobsCheck(
  623. "query",
  624. [{ file: "doc.md", text: "content" }],
  625. { model: "test-model" }
  626. );
  627. expect(results[0].confidence).toBe(1.0);
  628. expect(results[0].score).toBe(1.0); // 0.5 + 0.5 * 1.0
  629. });
  630. test("logprob -ln(2) ≈ 50% confidence", async () => {
  631. const ollama = createOllama();
  632. const logprob = -Math.log(2); // ≈ -0.693
  633. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [logprob] });
  634. const results = await ollama.rerankerLogprobsCheck(
  635. "query",
  636. [{ file: "doc.md", text: "content" }],
  637. { model: "test-model" }
  638. );
  639. expect(results[0].confidence).toBeCloseTo(0.5, 3);
  640. expect(results[0].score).toBeCloseTo(0.75, 3); // 0.5 + 0.5 * 0.5
  641. });
  642. test("very negative logprob = very low confidence", async () => {
  643. const ollama = createOllama();
  644. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-10] });
  645. const results = await ollama.rerankerLogprobsCheck(
  646. "query",
  647. [{ file: "doc.md", text: "content" }],
  648. { model: "test-model" }
  649. );
  650. expect(results[0].confidence).toBeLessThan(0.0001);
  651. expect(results[0].score).toBeCloseTo(0.5, 2); // Nearly just the base 0.5
  652. });
  653. });
  654. // =============================================================================
  655. // Edge Cases
  656. // =============================================================================
  657. describe("Edge Cases", () => {
  658. test("handles empty document list", async () => {
  659. const ollama = createOllama();
  660. const results = await ollama.rerankerLogprobsCheck("query", [], { model: "test-model" });
  661. expect(results).toHaveLength(0);
  662. });
  663. test("handles very short document text", async () => {
  664. const ollama = createOllama();
  665. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
  666. const results = await ollama.rerankerLogprobsCheck(
  667. "query",
  668. [{ file: "doc.md", text: "x" }],
  669. { model: "test-model" }
  670. );
  671. expect(results).toHaveLength(1);
  672. });
  673. test("handles unicode in queries and documents", async () => {
  674. const ollama = createOllama();
  675. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
  676. const results = await ollama.rerankerLogprobsCheck(
  677. "日本語クエリ",
  678. [{ file: "doc.md", text: "日本語コンテンツ 🎉" }],
  679. { model: "test-model" }
  680. );
  681. expect(results).toHaveLength(1);
  682. const body = mockCallLog[0].body as { prompt: string };
  683. expect(body.prompt).toContain("日本語クエリ");
  684. expect(body.prompt).toContain("日本語コンテンツ");
  685. });
  686. test("handles special characters in file paths", async () => {
  687. const ollama = createOllama();
  688. setGenerateHandler("yes", { tokens: ["yes"], token_logprobs: [-0.1] });
  689. const results = await ollama.rerankerLogprobsCheck(
  690. "query",
  691. [{ file: "path/to/file with spaces.md", text: "content" }],
  692. { model: "test-model" }
  693. );
  694. expect(results[0].file).toBe("path/to/file with spaces.md");
  695. });
  696. test("handles missing logprobs in response", async () => {
  697. const ollama = createOllama();
  698. // Response without logprobs
  699. mockHandlers.set("/api/generate", () => ({
  700. status: 200,
  701. body: { response: "yes", done: true },
  702. }));
  703. const results = await ollama.rerankerLogprobsCheck(
  704. "query",
  705. [{ file: "doc.md", text: "content" }],
  706. { model: "test-model" }
  707. );
  708. // Should still work, with logprob defaulting to 0
  709. expect(results[0].logprob).toBe(0);
  710. });
  711. });