Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions server/src/modules/leitner_box/__tests__/review_bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals";
import { LeitnerService } from "../service";
import { getCollection } from "@modular-rest/server";

// Mock modular-rest/server
jest.mock("@modular-rest/server", () => ({
getCollection: jest.fn(),
Schema: class { },
defineCollection: jest.fn(),
Permission: class { },
schemas: { file: {} },
}));

// Mock BoardService (referenced by the service module)
jest.mock("../../board/service", () => ({
BoardService: {
refreshActivity: jest.fn(),
},
}));

describe("LeitnerService review bundle — confirmed_chunk + source_sentence", () => {
let mockSystemCollection: any;
let mockPhraseCollection: any;
const userId = "user_123";
const phraseId = "phrase_1";

// A due item pointing at phraseId
const dueItem = {
phraseId,
boxLevel: 3,
nextReviewDate: new Date("2026-01-28T10:00:00Z"),
lastAttemptDate: new Date("2026-01-27T10:00:00Z"),
consecutiveIncorrect: 0,
};

beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-01-28T10:00:00Z"));
jest.clearAllMocks();

mockSystemCollection = {
findOne: jest.fn(),
create: jest.fn(),
updateOne: jest.fn(),
};
// Access through the `any`-typed var so the mock isn't concretely typed to `never`.
mockSystemCollection.findOne.mockResolvedValue({
_id: "sys_1",
userId,
settings: LeitnerService.DEFAULT_SETTINGS,
items: [dueItem],
});

mockPhraseCollection = { find: jest.fn() };

(getCollection as any).mockImplementation((_db: string, col: string) => {
if (col === "leitner_system") return Promise.resolve(mockSystemCollection);
if (col === "phrase") return Promise.resolve(mockPhraseCollection);
return Promise.resolve({});
});
});

afterEach(() => {
jest.useRealTimers();
});

it("picks the highest-confidence chunk's text and mirrors context into source_sentence", async () => {
mockPhraseCollection.find.mockResolvedValue([
{
_id: phraseId,
type: "linguistic",
phrase: "hit the sack",
context: "I'm exhausted, I think I'll hit the sack now.",
chunks: [
{ text: "I think", type: "discourse_marker", confidence: 0.4 },
{ text: "hit the sack", type: "idiom", confidence: 0.95 },
],
},
]);

const items = await LeitnerService.getDueItems(userId);

expect(items).toHaveLength(1);
expect(items[0].confirmed_chunk).toBe("hit the sack");
expect(items[0].source_sentence).toBe("I'm exhausted, I think I'll hit the sack now.");
});

it("tie-breaks equal confidence by earliest chunk", async () => {
mockPhraseCollection.find.mockResolvedValue([
{
_id: phraseId,
type: "linguistic",
context: "some sentence",
chunks: [
{ text: "first", type: "other", confidence: 0.8 },
{ text: "second", type: "other", confidence: 0.8 },
],
},
]);

const items = await LeitnerService.getDueItems(userId);

expect(items[0].confirmed_chunk).toBe("first");
});

it("returns null confirmed_chunk when the phrase has no chunks", async () => {
mockPhraseCollection.find.mockResolvedValue([
{ _id: phraseId, type: "normal", phrase: "cat", translation: "gato" },
]);

const items = await LeitnerService.getDueItems(userId);

expect(items[0].confirmed_chunk).toBeNull();
// normal phrases carry no context → source_sentence is null
expect(items[0].source_sentence).toBeNull();
});

it("returns null confirmed_chunk for an empty chunks array", async () => {
mockPhraseCollection.find.mockResolvedValue([
{ _id: phraseId, type: "linguistic", context: "ctx", chunks: [] },
]);

const items = await LeitnerService.getDueItems(userId);

expect(items[0].confirmed_chunk).toBeNull();
expect(items[0].source_sentence).toBe("ctx");
});
});
15 changes: 15 additions & 0 deletions server/src/modules/leitner_box/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ export interface LeitnerItem {
consecutiveIncorrect: number;
}

/**
* A due/custom review item as returned by the review RPCs. Extends the stored
* {@link LeitnerItem} with the joined phrase document plus the two flat fields the
* L3+ fill-in card needs:
* - `confirmed_chunk` — text of the phrase's primary chunk (highest `confidence`,
* tie-break earliest), or `null` when the phrase has no chunks (renderer falls
* back to the recognition card).
* - `source_sentence` — the phrase's `context` (kept whole), or `null` when absent.
*/
export interface ReviewItem extends LeitnerItem {
phrase: any;
confirmed_chunk: string | null;
source_sentence: string | null;
}

export interface LeitnerSystem {
userId: string;
settings: {
Expand Down
21 changes: 13 additions & 8 deletions server/src/modules/leitner_box/service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { LeitnerItem } from "./db";
import { LeitnerItem, ReviewItem } from "./db";
import { DATABASE, PHRASE_COLLECTION, DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION, BUNDLE_COLLECTION, PROFILE_COLLECTION } from "../../config";
import { getCollection } from "@modular-rest/server";
import { Document } from "mongoose";
import { BoardService } from "../board/service";
import { ScheduleService } from "../schedule/service";
import { pickPrimaryChunkText } from "../../utils/chunk";

// Helper type since modular-rest types are opaque sometimes
type LeitnerSystemDoc = Document & {
Expand Down Expand Up @@ -114,13 +115,15 @@ export class LeitnerService {
const phrases = await phraseCollection.find({ _id: { $in: phraseIds } });

// Join
return selectedItems.map((item: LeitnerItem) => {
const phrase = phrases.find((p: any) => p._id.toString() === item.phraseId.toString());
return selectedItems.map((item: LeitnerItem): ReviewItem => {
const phrase: any = phrases.find((p: any) => p._id.toString() === item.phraseId.toString());
return {
...item,
phrase
phrase,
confirmed_chunk: pickPrimaryChunkText(phrase?.chunks),
source_sentence: phrase?.context ?? null,
}
}).filter((item: any) => item.phrase);
}).filter((item: ReviewItem) => item.phrase);
}

static async getCustomReviewItems(userId: string, phraseIds: string[]) {
Expand All @@ -145,14 +148,16 @@ export class LeitnerService {
const phrases = await phraseCollection.find({ _id: { $in: phraseIds } });

return selectedItems
.map((item: LeitnerItem) => {
const phrase = phrases.find((p: any) => p._id.toString() === item.phraseId.toString());
.map((item: LeitnerItem): ReviewItem => {
const phrase: any = phrases.find((p: any) => p._id.toString() === item.phraseId.toString());
return {
...item,
phrase,
confirmed_chunk: pickPrimaryChunkText(phrase?.chunks),
source_sentence: phrase?.context ?? null,
};
})
.filter((item: any) => item.phrase);
.filter((item: ReviewItem) => item.phrase);
}

static async getDueCount(userId: string): Promise<number> {
Expand Down
41 changes: 41 additions & 0 deletions server/src/utils/__tests__/chunk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from "@jest/globals";
import { pickPrimaryChunkText } from "../chunk";

describe("pickPrimaryChunkText", () => {
it("picks the highest-confidence chunk's text", () => {
const chunks = [
{ text: "I think", confidence: 0.42 },
{ text: "hit the sack", confidence: 0.95 },
];
expect(pickPrimaryChunkText(chunks)).toBe("hit the sack");
});

it("tie-breaks equal confidence by the earliest chunk", () => {
const chunks = [
{ text: "first", confidence: 0.8 },
{ text: "second", confidence: 0.8 },
];
expect(pickPrimaryChunkText(chunks)).toBe("first");
});

it("returns null for an empty array", () => {
expect(pickPrimaryChunkText([])).toBeNull();
});

it("returns null for undefined/null", () => {
expect(pickPrimaryChunkText(undefined)).toBeNull();
expect(pickPrimaryChunkText(null)).toBeNull();
});

it("returns null when the primary chunk has no text", () => {
expect(pickPrimaryChunkText([{ confidence: 0.9 }])).toBeNull();
});

it("treats a missing confidence as 0 when ranking", () => {
const chunks = [
{ text: "no-conf" },
{ text: "has-conf", confidence: 0.1 },
];
expect(pickPrimaryChunkText(chunks)).toBe("has-conf");
});
});
16 changes: 16 additions & 0 deletions server/src/utils/chunk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Text of the "primary chunk" for the L3+ fill-in: the highest-`confidence` chunk,
* tie-broken by earliest (the strict `>` keeps the earlier chunk on ties). Returns
* `null` when there are no chunks so the renderer falls back to the recognition card.
*
* Council 005 — the single source of truth for the primary-chunk rule. Kept generic
* over `{ text?, confidence? }` (like `translation/chunk-filter.ts`) so it doesn't bind
* to either competing `Chunk` type (`translation/schema.ts` vs `phrase_bundle/db.ts`).
*/
export function pickPrimaryChunkText(
chunks?: ReadonlyArray<{ text?: string; confidence?: number }> | null
): string | null {
if (!Array.isArray(chunks) || chunks.length === 0) return null;
const best = chunks.reduce((a, b) => ((b?.confidence ?? 0) > (a?.confidence ?? 0) ? b : a));
return best?.text ?? null;
}
Loading