From d13e952d3c541fdb511b43822c27115091b697b0 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Fri, 10 Jul 2026 18:59:59 +0200 Subject: [PATCH] refactor(chunk): extract primary-chunk rule to shared util #86exnxph5 Both LeitnerService and PoolService carried a byte-for-byte identical pickConfirmedChunk (highest-confidence, tie-break earliest). Move the rule into server/src/utils/chunk.ts (pickPrimaryChunkText) and have both services call it, so the Council-005 rule lives in one place and can't diverge. Generic over { text?, confidence? } to avoid binding to either Chunk type. No behavior change; adds unit tests for the util. Co-Authored-By: Claude Opus 4.8 --- server/src/modules/leitner_box/service.ts | 18 ++-------- server/src/modules/pool/service.ts | 16 ++------- server/src/utils/__tests__/chunk.test.ts | 41 +++++++++++++++++++++++ server/src/utils/chunk.ts | 16 +++++++++ 4 files changed, 62 insertions(+), 29 deletions(-) create mode 100644 server/src/utils/__tests__/chunk.test.ts create mode 100644 server/src/utils/chunk.ts diff --git a/server/src/modules/leitner_box/service.ts b/server/src/modules/leitner_box/service.ts index 1a510e6..5406cc7 100644 --- a/server/src/modules/leitner_box/service.ts +++ b/server/src/modules/leitner_box/service.ts @@ -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 & { @@ -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 { @@ -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); @@ -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, }; }) diff --git a/server/src/modules/pool/service.ts b/server/src/modules/pool/service.ts index e92b15b..b762e24 100644 --- a/server/src/modules/pool/service.ts +++ b/server/src/modules/pool/service.ts @@ -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; @@ -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). @@ -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, }; }) diff --git a/server/src/utils/__tests__/chunk.test.ts b/server/src/utils/__tests__/chunk.test.ts new file mode 100644 index 0000000..843be6d --- /dev/null +++ b/server/src/utils/__tests__/chunk.test.ts @@ -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"); + }); +}); diff --git a/server/src/utils/chunk.ts b/server/src/utils/chunk.ts new file mode 100644 index 0000000..4eef125 --- /dev/null +++ b/server/src/utils/chunk.ts @@ -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; +}