Ctrl K
ComunidadGuías…

tests/askIndex.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// The meaning index (server/embeddings.ts), the Ollama client
// (server/ollama.ts) against a FAKE Ollama over real HTTP, and the Anthropic
// stream reader (server/anthropic.ts) against a recorded stream. No model,
// no network: the fakes stand where the services would.

import assert from "node:assert/strict";
import { createServer, type Server } from "node:http";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import { SemanticIndex, contentHash } from "../server/embeddings.ts";
import { hasModel, ollamaChat, ollamaEmbed, ollamaModels, OllamaDown, OllamaError } from "../server/ollama.ts";
import { readAnthropicStream } from "../server/anthropic.ts";
import { normalize } from "../shared/semantic.ts";

/** A deterministic 26-dimensional "embedding": letter counts. Passages that
 *  share words point the same way, which is all the ranking tests need. */
function fakeVector(text: string): number[] {
  const v = new Array(26).fill(0);
  for (const ch of text.toLowerCase()) {
    const i = ch.charCodeAt(0) - 97;
    if (i >= 0 && i < 26) v[i]++;
  }
  return v;
}

interface FakeVault {
  files: Map<string, { text: string; mtimeMs: number }>;
  calls: string[][];
  model: string;
  fail: boolean;
}

function makeIndex(dbFile: string | null, vault: FakeVault): SemanticIndex {
  return new SemanticIndex({
    dbFile,
    sources: () => [...vault.files].map(([p, f]) => ({ path: p, title: path.basename(p, ".md"), mtimeMs: f.mtimeMs })),
    read: async (p) => vault.files.get(p)!.text,
    model: () => vault.model,
    embed: async (_model, inputs) => {
      if (vault.fail) throw new Error("connect ECONNREFUSED");
      vault.calls.push(inputs);
      return inputs.map(fakeVector);
    },
  });
}

const embeddedCount = (v: FakeVault): number => v.calls.reduce((n, c) => n + c.length, 0);

describe("the hash-keyed meaning index", () => {
  let dir: string;
  before(() => {
    dir = mkdtempSync(path.join(tmpdir(), "astrolabe-ask-"));
  });
  after(() => rmSync(dir, { recursive: true, force: true }));

  const vault = (): FakeVault => ({
    files: new Map([
      ["Naps.md", { text: "# Naps\n\nA short nap after lunch.\n\n## Coffee nap\n\nEspresso then sleep.\n", mtimeMs: 1 }],
      ["Olive.md", { text: "# Olive\n\nOlives want sun and little water.\n\n## Pruning\n\nPrune in late winter.\n", mtimeMs: 1 }],
      ["Twin.md", { text: "# Twin\n\nEspresso then sleep.\n", mtimeMs: 1 }],
    ]),
    calls: [],
    model: "fake-embed",
    fail: false,
  });

  it("embeds every passage once, and an unchanged vault never again", async () => {
    const v = vault();
    const idx = makeIndex(path.join(dir, "a.db"), v);
    await idx.refresh();
    assert.equal(embeddedCount(v), 5);
    assert.deepEqual(idx.counts(), { notes: 3, indexedNotes: 3, chunks: 5, pending: 0 });
    await idx.refresh();
    assert.equal(embeddedCount(v), 5);
    idx.close();
  });

  it("re-embeds only the passage that changed when a note is saved", async () => {
    const v = vault();
    const idx = makeIndex(path.join(dir, "b.db"), v);
    await idx.refresh();
    v.calls = [];
    v.files.set("Olive.md", { text: "# Olive\n\nOlives want sun and little water.\n\n## Pruning\n\nPrune in February, before the sap rises.\n", mtimeMs: 2 });
    await idx.refresh();
    assert.equal(v.calls.length, 1);
    assert.deepEqual(v.calls[0], ["Olive › Pruning\n\nPrune in February, before the sap rises."]);
    idx.close();
  });

  it("finds its vectors in the store after a restart and embeds nothing", async () => {
    const v = vault();
    const file = path.join(dir, "c.db");
    const first = makeIndex(file, v);
    await first.refresh();
    first.close();
    v.calls = [];
    const second = makeIndex(file, v);
    await second.refresh();
    assert.equal(embeddedCount(v), 0);
    assert.equal(second.counts().pending, 0);
    assert.equal(second.stored("fake-embed"), 5);
    second.close();
  });

  it("keys by model too: a new embedding model re-embeds, and the old vectors wait for a switch back", async () => {
    const v = vault();
    const idx = makeIndex(path.join(dir, "d.db"), v);
    await idx.refresh();
    v.model = "other-embed";
    v.calls = [];
    await idx.refresh();
    assert.equal(embeddedCount(v), 5);
    v.model = "fake-embed";
    v.calls = [];
    await idx.refresh();
    assert.equal(embeddedCount(v), 0);
    idx.close();
  });

  it("forgets a deleted note, and the prune takes its vectors out of the store", async () => {
    const v = vault();
    const idx = makeIndex(path.join(dir, "e.db"), v);
    await idx.refresh();
    v.files.delete("Olive.md");
    await idx.refresh();
    assert.equal(idx.counts().notes, 2);
    assert.equal(idx.prune(), 2);
    assert.equal(idx.stored("fake-embed"), 3);
    idx.close();
  });

  it("an embedder that is down leaves the passages pending and says why", async () => {
    const v = vault();
    v.fail = true;
    const idx = makeIndex(null, v);
    await idx.refresh();
    assert.equal(idx.counts().pending, 5);
    assert.match(idx.lastError ?? "", /ECONNREFUSED/);
    v.fail = false;
    await idx.refresh();
    assert.equal(idx.counts().pending, 0);
    assert.equal(idx.lastError, null);
  });

  it("the hash is of what the model is shown, so a renamed heading is a new vector", () => {
    assert.notEqual(contentHash("Olive › Pruning\n\nx"), contentHash("Olive › Trimming\n\nx"));
    assert.equal(contentHash("same"), contentHash("same"));
  });

  it("ranks by cosine: search, retrieval, related and suggestions", async () => {
    const v = vault();
    const idx = makeIndex(null, v);
    await idx.refresh();
    const q = normalize(Float32Array.from(fakeVector("prune winter")));
    const hits = idx.searchVector(q, 2);
    assert.equal(hits[0].note.path, "Olive.md");
    assert.equal(hits[0].chunk.heading, "Pruning");
    const got = idx.retrieve(q, 3, 1);
    assert.equal(new Set(got.map((g) => g.note.path)).size, got.length); // one passage per note
    assert.equal(idx.related("Naps.md", 1)[0].note.path, "Twin.md");
    // Twin says what Naps says; once they are linked it is not a suggestion.
    assert.equal(idx.suggest("Naps.md", 1, new Set())[0].note.path, "Twin.md");
    assert.notEqual(idx.suggest("Naps.md", 1, new Set(["Twin.md"]))[0]?.note.path, "Twin.md");
  });
});

describe("Ollama, over its HTTP API (a fake one)", () => {
  let server: Server;
  let saved: string | undefined;
  const seen: { path: string; body: unknown }[] = [];
  before(async () => {
    server = createServer((req, res) => {
      let raw = "";
      req.on("data", (c) => (raw += c));
      req.on("end", () => {
        const body = raw ? JSON.parse(raw) : null;
        seen.push({ path: req.url ?? "", body });
        if (req.url === "/api/tags") {
          res.end(JSON.stringify({ models: [{ name: "embeddinggemma:latest" }, { name: "qwen3.5:9b" }] }));
        } else if (req.url === "/api/embed") {
          if (body.model !== "embeddinggemma") {
            res.statusCode = 404;
            res.end(JSON.stringify({ error: `model "${body.model}" not found, try pulling it first` }));
            return;
          }
          res.end(JSON.stringify({ embeddings: body.input.map(fakeVector) }));
        } else if (req.url === "/api/chat") {
          res.setHeader("Content-Type", "application/x-ndjson");
          for (const piece of ["The vault ", "says [1]", "."]) res.write(`${JSON.stringify({ message: { role: "assistant", content: piece }, done: false })}\n`);
          res.end(`${JSON.stringify({ message: { role: "assistant", content: "" }, done: true })}\n`);
        } else {
          res.statusCode = 404;
          res.end("{}");
        }
      });
    });
    await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
    saved = process.env.OLLAMA_HOST;
    const addr = server.address();
    process.env.OLLAMA_HOST = typeof addr === "object" && addr ? `127.0.0.1:${addr.port}` : "";
  });
  after(async () => {
    if (saved === undefined) delete process.env.OLLAMA_HOST;
    else process.env.OLLAMA_HOST = saved;
    await new Promise<void>((ok) => server.close(() => ok()));
  });

  it("lists the pulled models, and `name` means `name:latest`", async () => {
    const names = await ollamaModels();
    assert.ok(hasModel(names, "embeddinggemma"));
    assert.ok(hasModel(names, "qwen3.5:9b"));
    assert.ok(!hasModel(names, "bge-m3"));
  });

  it("embeds a batch, one vector per input, asking the model to truncate", async () => {
    const out = await ollamaEmbed("embeddinggemma", ["abc", "zz"]);
    assert.equal(out.length, 2);
    assert.equal(out[1][25], 2);
    const call = seen.find((s) => s.path === "/api/embed") as { body: { truncate: boolean } };
    assert.equal(call.body.truncate, true);
  });

  it("a model that is not pulled is an OllamaError 404, which the routes name noEmbedModel", async () => {
    await assert.rejects(ollamaEmbed("bge-m3", ["x"]), (e: unknown) => e instanceof OllamaError && e.status === 404);
  });

  it("streams a chat answer with thinking off", async () => {
    let text = "";
    await ollamaChat("qwen3.5:9b", "system", "user", (t) => (text += t));
    assert.equal(text, "The vault says [1].");
    const call = seen.find((s) => s.path === "/api/chat") as { body: { think: boolean; stream: boolean; messages: { role: string }[] } };
    assert.equal(call.body.think, false);
    assert.equal(call.body.stream, true);
    assert.deepEqual(call.body.messages.map((m) => m.role), ["system", "user"]);
  });

  it("nobody listening is OllamaDown, the one sentence every door says", async () => {
    const was = process.env.OLLAMA_HOST;
    process.env.OLLAMA_HOST = "127.0.0.1:9"; // discard port: nothing answers
    try {
      await assert.rejects(ollamaModels(1_000), (e: unknown) => e instanceof OllamaDown);
    } finally {
      process.env.OLLAMA_HOST = was;
    }
  });
});

describe("the Anthropic stream", () => {
  const stream = (text: string): ReadableStream<Uint8Array> =>
    new ReadableStream({
      start(c) {
        // Split mid-event, as a network does.
        const bytes = new TextEncoder().encode(text);
        c.enqueue(bytes.slice(0, 37));
        c.enqueue(bytes.slice(37));
        c.close();
      },
    });

  it("hands on text deltas and nothing else", async () => {
    const recorded = [
      'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1"}}',
      'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}',
      'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}',
      'event: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Olives "}}',
      'event: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"fruit [2]."}}',
      'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}',
      'event: message_stop\ndata: {"type":"message_stop"}',
      "",
    ].join("\n\n");
    let out = "";
    await readAnthropicStream(stream(recorded), (t) => (out += t));
    assert.equal(out, "Olives fruit [2].");
  });

  it("an error event mid-stream is an error", async () => {
    const recorded = 'event: error\ndata: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n';
    await assert.rejects(readAnthropicStream(stream(recorded), () => {}), /overloaded_error/);
  });
});