Files
STCourses/packages/feature-ai/test/retrieval.test.ts
T

84 lines
4.2 KiB
TypeScript

import { afterAll, beforeAll, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCourseRepository, type LoadedCourse } from "@stc/course-format";
import { Bm25, tokenize } from "../src/retrieval/bm25";
import { buildCourseIndex, getCourseIndex, isSolutionPath, noteChunks } from "../src/retrieval/course-index";
const FIXTURES = join(import.meta.dir, "../../../fixtures/courses");
let course: LoadedCourse;
let dataDir: string;
beforeAll(async () => {
const repo = await createCourseRepository([FIXTURES]);
course = repo.get("sample")!;
dataDir = mkdtempSync(join(tmpdir(), "stc-ai-"));
mkdirSync(join(dataDir, "cache/sample/decks/d1"), { recursive: true });
writeFileSync(join(dataDir, "cache/sample/decks/d1/page-3.txt"), "Container images are layered filesystems built from a Dockerfile.");
mkdirSync(join(dataDir, "cache/sample/transcripts"), { recursive: true });
writeFileSync(join(dataDir, "cache/sample/transcripts/m1-video.txt"), "In this clip the presenter explains the test pattern calibration.");
});
afterAll(() => rmSync(dataDir, { recursive: true, force: true }));
test("tokenize lowercases, drops stopwords and splits identifiers", () => {
expect(tokenize("The inBounds function, and dropLast!")).toEqual(["inbound", "in", "bound", "function", "droplast", "drop", "last"]);
expect(tokenize("Æble på dansk")).toEqual(["æble", "på", "dansk"]);
});
test("BM25 ranks the more relevant document first", () => {
const b = new Bm25<string>();
b.add("snake moves one cell per tick", "a");
b.add("apples spawn at random free cells", "b");
b.add("the snake grows when it eats an apple apple", "c");
expect(b.search("apple", 5).map((r) => r.item)).toEqual(["c", "b"]);
expect(b.search("zebra", 5)).toEqual([]);
});
test("finds lesson text and maps it to a lesson", async () => {
const idx = await buildCourseIndex(course, dataDir);
const hits = idx.search("sum of its arguments", 3);
expect(hits[0]!.path).toBe("lessons/04-exercise.md");
expect(["m2-text", "m2-ex-add"]).toContain(hits[0]!.lessonId!);
expect(hits[0]!.lessonTitle).toBeTruthy();
});
test("indexes code, transcripts and deck text from the cache", async () => {
const idx = await buildCourseIndex(course, dataDir);
const code = idx.search("hello.sh", 5).find((c) => c.kind === "code");
expect(code?.path).toBe("code/S01 - First steps/hello.sh");
const deck = idx.search("Dockerfile layered images", 3)[0]!;
expect(deck.kind).toBe("deck");
expect(deck.path).toBe("media/deck.pdf");
expect(deck.heading).toBe("Slide 3");
expect(deck.lessonId).toBe("m1-video");
const tr = idx.search("calibration presenter", 3)[0]!;
expect(tr.kind).toBe("transcript");
expect(tr.lessonId).toBe("m1-video");
});
test("solution files are never indexed", async () => {
const idx = await buildCourseIndex(course, dataDir);
for (const q of ["add", "return a + b", "export const add"]) {
expect(idx.search(q, 50).some((c) => c.path.includes("solution"))).toBe(false);
}
expect(isSolutionPath(course, "exercises/add/solution.js")).toBe(true);
expect(isSolutionPath(course, "js/solutions/rules.js")).toBe(true);
expect(isSolutionPath(course, "course/01-handles/reference/ts/src/engine.ts")).toBe(true);
expect(isSolutionPath(course, "course/01-handles/expansion/solution/x.rs")).toBe(true);
expect(isSolutionPath(course, "exercises/add/starter.js")).toBe(false);
});
test("the mentor file and the manifest are not retrievable", async () => {
const idx = await buildCourseIndex(course, dataDir);
expect(idx.search("mentor hints never full solutions", 50).some((c) => c.path === "MENTOR.md" || c.path === "stcourse.yaml")).toBe(false);
});
test("notes are searched alongside the course", async () => {
const idx = await getCourseIndex(course, dataDir);
expect(await getCourseIndex(course, dataDir)).toBe(idx);
const extra = noteChunks(course, [{ id: "n1", lessonId: "m1-intro", body: "Remember: zebras are striped horses", anchorKind: "none", anchorValue: null }]);
const hits = idx.search("zebras striped", 3, extra);
expect(hits[0]).toMatchObject({ kind: "note", lessonId: "m1-intro", lessonTitle: "Introduction" });
});