Skip to content
Open
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
18 changes: 3 additions & 15 deletions server/src/modules/leitner_box/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 All @@ -28,19 +29,6 @@ export class LeitnerService {
return (await col.findOne({ userId })) as unknown as LeitnerSystemDoc;
}

/**
* Text of the phrase's 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 the phrase has no chunks so the renderer falls back to the
* recognition card. Read-path only — no schema change.
*/
private static pickConfirmedChunk(phrase: any): string | null {
const chunks = phrase?.chunks;
if (!Array.isArray(chunks) || chunks.length === 0) return null;
const best = chunks.reduce((a: any, b: any) => ((b?.confidence ?? 0) > (a?.confidence ?? 0) ? b : a));
return best?.text ?? null;
}

/** Coerce to an integer in [min, max]; throw on anything out of range. Guards the
* Pool/review settings so a bad value can't break the age-out sweep or the cron. */
private static validateInt(value: number, min: number, max: number, field: string): number {
Expand Down Expand Up @@ -150,7 +138,7 @@ export class LeitnerService {
return {
...item,
phrase,
confirmed_chunk: this.pickConfirmedChunk(phrase),
confirmed_chunk: pickPrimaryChunkText(phrase?.chunks),
source_sentence: phrase?.context ?? null,
}
}).filter((item: ReviewItem) => item.phrase);
Expand Down Expand Up @@ -183,7 +171,7 @@ export class LeitnerService {
return {
...item,
phrase,
confirmed_chunk: this.pickConfirmedChunk(phrase),
confirmed_chunk: pickPrimaryChunkText(phrase?.chunks),
source_sentence: phrase?.context ?? null,
};
})
Expand Down
16 changes: 2 additions & 14 deletions server/src/modules/pool/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
PROFILE_COLLECTION,
} from "../../config";
import { PoolItem } from "./db";
import { pickPrimaryChunkText } from "../../utils/chunk";

// Default age cut-off (days) when the user has no `poolAgeCutoffDays` on their profile.
const DEFAULT_AGE_CUTOFF_DAYS = 7;
Expand All @@ -16,19 +17,6 @@ export class PoolService {
return getCollection(DATABASE_POOL, POOL_COLLECTION);
}

/**
* Text of the phrase's primary chunk for the encode cloze: the highest-`confidence`
* chunk (tie-broken by earliest). Mirrors LeitnerService.pickConfirmedChunk so the
* Pool review item and the Leitner L3+ card render the same way. `null` when there
* are no chunks.
*/
private static pickConfirmedChunk(phrase: any): string | null {
const chunks = phrase?.chunks;
if (!Array.isArray(chunks) || chunks.length === 0) return null;
const best = chunks.reduce((a: any, b: any) => ((b?.confidence ?? 0) > (a?.confidence ?? 0) ? b : a));
return best?.text ?? null;
}

/**
* Add a phrase to the user's Pool with a server-set `pooled_at`. Idempotent: a
* phrase already pooled is left untouched (its original `pooled_at` is preserved).
Expand Down Expand Up @@ -95,7 +83,7 @@ export class PoolService {
pooled_at: item.pooled_at,
encountered: item.encountered,
phrase,
confirmed_chunk: this.pickConfirmedChunk(phrase),
confirmed_chunk: pickPrimaryChunkText(phrase?.chunks),
source_sentence: phrase?.context ?? null,
};
})
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