import { expect } from "chai"; import { prisma } from "../../src/db/prisma.js"; import { normalizeText, splitIntoClauses, type TechniqueCandidate, techStepClassifier, } from "../../src/lib/recipe-matching/tech-step-matcher.js"; import { resetDatabase } from "../../test-support/reset-db.js"; describe("tech-step-matcher", () => { describe("normalizeText", () => { it("lowercases and strips accents", () => { expect(normalizeText("Déglacer AU FOUR")).to.equal("deglacer au four"); }); it("strips a variety of diacritics, including cedilla", () => { expect(normalizeText("Façon Œuf à l'Étouffée")).to.equal("facon œuf a l'etouffee"); }); it("leaves already-plain text unchanged, aside from casing", () => { expect(normalizeText("Mix everything")).to.equal("mix everything"); }); it("returns an empty string for an empty input", () => { expect(normalizeText("")).to.equal(""); }); }); describe("splitIntoClauses", () => { // A candidate's own `uid` doesn't matter to the splitting logic itself // (it's opaque, carried through as `anchor`) — kept short and // arbitrary across these fixtures. function candidate(uid: string, start: number, end: number): TechniqueCandidate { return { uid, start, end }; } it("returns the whole description as one anchor-less clause when there are no candidates", () => { const text = "Servir immédiatement"; const result = splitIntoClauses(text, []); expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: null }]); }); it("returns the whole description as one clause anchored on the single candidate", () => { const melt = candidate("melt", 6, 13); const text = "Faire fondre le beurre"; const result = splitIntoClauses(text, [melt]); expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: melt }]); }); it("splits into two clauses at the whitespace nearest the gap's midpoint between two candidates", () => { // "Préchauffer la poêle, puis faire fondre le beurre" // 0 1 2 3 4 // 0123456789012345678901234567890123456789012345678901 const preheat = candidate("preheat", 0, 11); // "Préchauffer" const melt = candidate("melt", 27, 39); // "faire fondre" const text = "Préchauffer la poêle, puis faire fondre le beurre"; const result = splitIntoClauses(text, [preheat, melt]); expect(result).to.have.length(2); // The gap between the two candidates is [11, 27) — its raw midpoint // (19) falls inside "poêle" (see findGapSplitPoint's doc comment for // why that's specifically what this snaps away from); the nearest // actual whitespace to that midpoint is the space at 21, right after // the comma. expect(result[0]).to.deep.equal({ start: 0, end: 21, anchor: preheat }); expect(result[1]).to.deep.equal({ start: 21, end: text.length, anchor: melt }); // The two clauses are contiguous and cover the whole text. expect( text.slice(result[0].start, result[0].end) + text.slice(result[1].start, result[1].end), ).to.equal(text); }); it("sorts out-of-order candidates before splitting, and anchors each clause on the matching one", () => { const preheat = candidate("preheat", 0, 11); const melt = candidate("melt", 27, 39); // Passed in reverse — the function must still produce clauses in // reading order, each anchored on the right candidate. const result = splitIntoClauses("Préchauffer la poêle, puis faire fondre le beurre", [ melt, preheat, ]); expect(result.map((clause) => clause.anchor?.uid)).to.deep.equal(["preheat", "melt"]); }); it("produces N contiguous clauses for N candidates, each anchored on its own", () => { const a = candidate("a", 0, 3); const b = candidate("b", 10, 13); const c = candidate("c", 20, 23); const text = "x".repeat(30); const result = splitIntoClauses(text, [a, b, c]); expect(result).to.have.length(3); expect(result.map((clause) => clause.anchor?.uid)).to.deep.equal(["a", "b", "c"]); // Contiguous: each clause's end is the next one's start. expect(result[0].start).to.equal(0); expect(result[0].end).to.equal(result[1].start); expect(result[1].end).to.equal(result[2].start); expect(result[2].end).to.equal(text.length); }); it("clamps the split point to the earlier candidate's own end when two candidates are adjacent/overlapping", () => { // Gap midpoint would fall *before* `a`'s own end here — must not // produce a clause that cuts into `a`'s own anchor span. const a = candidate("a", 0, 10); const b = candidate("b", 8, 15); const result = splitIntoClauses("x".repeat(20), [a, b]); expect(result[0].end).to.be.at.least(a.end); expect(result[1].start).to.equal(result[0].end); }); }); describe("techStepClassifier", () => { // `techStepClassifier` is the one shared singleton (see // tech-step-matcher.ts's own doc comment on why) — these tests // exercise it against the real training corpus // (`services/tech-step-intent-service`'s `training_data.py`) and the // real seeded `TechStep` catalog, rather than synthetic injectable // fixtures the old regex-based `matchTechStepSpans(description, // mappings)` allowed. Every call round-trips over HTTP to a real, // locally running `services/tech-step-intent-service` (see that // service's own README and `apps/api/.env.test`) — that service trains // itself once at its own startup (`test-support/mocha-root-hooks.ts`'s // root hook doesn't wait on it, CI's own "wait for /health" step // already does), so calls here are just a normal HTTP round-trip, // comfortably inside this suite's default 10s timeout (.mocharc.json). let simmerId: number; let cookId: number; let bakeId: number; let preheatId: number; let meltId: number; let boilId: number; let chopId: number; beforeEach(async () => { await resetDatabase(); const [simmer, cook, bake, preheat, melt, boil, chop] = await Promise.all([ prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }), prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }), prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }), prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }), prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }), prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }), prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }), ]); simmerId = simmer.id; cookId = cook.id; bakeId = bake.id; preheatId = preheat.id; meltId = melt.id; boilId = boil.id; chopId = chop.id; }); after(async () => { await prisma.$disconnect(); }); describe("matchTechSteps", () => { it("matches an exact expression", async () => { expect( await techStepClassifier.matchTechSteps("Faire mijoter à feu doux", "fr"), ).to.deep.equal([simmerId]); }); it("is case- and accent-insensitive", async () => { expect(await techStepClassifier.matchTechSteps("FAIRE MIJOTER", "fr")).to.deep.equal([ simmerId, ]); }); it("returns an empty sequence when nothing matches", async () => { expect( await techStepClassifier.matchTechSteps("Ranger les couverts dans le tiroir", "fr"), ).to.deep.equal([]); }); it("returns an empty sequence for an empty description", async () => { expect(await techStepClassifier.matchTechSteps("", "fr")).to.deep.equal([]); }); it("returns an empty sequence for a locale nothing was trained on", async () => { expect( await techStepClassifier.matchTechSteps("Faire mijoter à feu doux", "de"), ).to.deep.equal([]); }); it("detects several distinct techniques in one step, in reading order", async () => { expect( await techStepClassifier.matchTechSteps( "Préchauffer la poêle, puis faire fondre le beurre", "fr", ), ).to.deep.equal([preheatId, meltId]); }); it("reverses the sequence when the techniques are mentioned in the opposite order", async () => { expect( await techStepClassifier.matchTechSteps( "Faire fondre le beurre puis préchauffer le four", "fr", ), ).to.deep.equal([meltId, preheatId]); }); it("still matches the generic technique on its own when the more specific one isn't implied", async () => { expect( await techStepClassifier.matchTechSteps("Faire cuire à feu moyen", "fr"), ).to.deep.equal([cookId]); }); it("resolves the more specific technique when a generic one's own vocabulary is embedded in it", async () => { // "Cuire au four" literally contains "cuire" (the generic `cook` // verb) but means the more specific `bake` — the classifier (not // a weight table) is what has to get this right now. expect( await techStepClassifier.matchTechSteps("Cuire au four pendant 30 minutes", "fr"), ).to.deep.equal([bakeId]); }); it("understands a technique described without ever naming it — the whole point of moving off pure keyword matching", async () => { // No literal "fondre"/"fondu" anywhere in this sentence, yet it // unambiguously means `melt` — this is the exact motivating case // (see this module's own doc comment) a regex could never catch. expect( await techStepClassifier.matchTechSteps( "jusqu'à ce que le beurre ait disparu dans la poêle", "fr", ), ).to.deep.equal([meltId]); }); it("understands preheating described without the verb 'préchauffer'", async () => { expect( await techStepClassifier.matchTechSteps("mettre la poêle sur feu vif", "fr"), ).to.deep.equal([preheatId]); }); it("matches English text against the English-trained vocabulary", async () => { expect( await techStepClassifier.matchTechSteps( "Bring a large saucepan of salted water to the boil", "en", ), ).to.deep.equal([boilId]); }); }); describe("matchTechStepSpans", () => { it("returns a tight keyword span, and a wider context span that's the whole description when there's only one candidate", async () => { const text = "Faire mijoter à feu doux"; const result = await techStepClassifier.matchTechStepSpans(text, "fr"); expect(result).to.deep.equal([ { techStepId: simmerId, start: 6, end: 13, contextStart: 0, contextEnd: text.length }, ]); expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter"); }); it("returns an empty list when nothing matches", async () => { expect( await techStepClassifier.matchTechStepSpans("Ranger les couverts dans le tiroir", "fr"), ).to.deep.equal([]); }); it("returns each distinct technique's own tight keyword span and its own wider context span, in reading order", async () => { const text = "Préchauffer la poêle, puis faire fondre le beurre"; const result = await techStepClassifier.matchTechStepSpans(text, "fr"); expect(result).to.have.length(2); expect(result[0].techStepId).to.equal(preheatId); expect(result[1].techStepId).to.equal(meltId); // Each keyword span, sliced back out of the original text, is // exactly the word(s) that anchored that match — what the frontend // needs to highlight the exact right characters. expect(text.slice(result[0].start, result[0].end).toLowerCase()).to.equal("préchauffer"); expect(text.slice(result[1].start, result[1].end).toLowerCase()).to.equal("faire fondre"); // Each context span is the wider clause the keyword was found in — // the two are contiguous and cover the whole description between // them (see splitIntoClauses, which computed these). expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal( "Préchauffer la poêle,", ); expect(text.slice(result[1].contextStart, result[1].contextEnd)).to.equal( " puis faire fondre le beurre", ); expect(result[0].contextEnd).to.equal(result[1].contextStart); }); it("understands both techniques in the classic 'Dans une poêle chaude, faire chauffer une noix de beurre' example, each with its own keyword and context", async () => { // The motivating example for context spans in the first place: // `preheat`'s keyword is a noun phrase ("poêle chaude"), not a // verb — its context ("Dans une poêle chaude") is what actually // shows this is about preparing the pan, not (say) deglazing one. const text = "Dans une poêle chaude, faire chauffer une noix de beurre"; const result = await techStepClassifier.matchTechStepSpans(text, "fr"); expect(result).to.have.length(2); expect(result[0]).to.deep.equal({ techStepId: preheatId, start: 9, end: 21, contextStart: 0, contextEnd: 22, }); expect(result[1]).to.deep.equal({ techStepId: meltId, start: 23, end: 37, contextStart: 22, contextEnd: text.length, }); expect(text.slice(result[0].start, result[0].end)).to.equal("poêle chaude"); expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal( "Dans une poêle chaude,", ); expect(text.slice(result[1].start, result[1].end)).to.equal("faire chauffer"); expect(text.slice(result[1].contextStart, result[1].contextEnd)).to.equal( " faire chauffer une noix de beurre", ); }); it("falls back to highlighting the whole clause for both spans when a technique was found with no literal anchor word", async () => { const text = "jusqu'à ce que le beurre ait disparu dans la poêle"; const result = await techStepClassifier.matchTechStepSpans(text, "fr"); expect(result).to.deep.equal([ { techStepId: meltId, start: 0, end: text.length, contextStart: 0, contextEnd: text.length, }, ]); }); it("chop matches English text against the English-trained vocabulary, tight keyword span", async () => { const text = "Chop the onions finely"; const result = await techStepClassifier.matchTechStepSpans(text, "en"); expect(result).to.deep.equal([ { techStepId: chopId, start: 0, end: 4, contextStart: 0, contextEnd: text.length }, ]); expect(text.slice(0, 4)).to.equal("Chop"); }); }); }); });