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
26 changes: 14 additions & 12 deletions docs/branch-review-ledger.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions src/components/factsheets/factsheets-data.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { fuzzySearchTokenCount } from "@/lib/catalog-search";

/**
* Patient factsheet library — content model and helpers.
*
Expand Down Expand Up @@ -659,9 +661,8 @@ export function filterFactsheets(query: string, category?: string): Factsheet[]
if (!q) return true;
// Include the brand suffix (e.g. "(Zoloft)") so brand-name searches resolve
// even though it is stored separately from the title.
return `${sheet.title} ${sheet.brand ?? ""} ${sheet.summary} ${sheet.category} ${sheet.audience}`
.toLowerCase()
.includes(q);
const text = `${sheet.title} ${sheet.brand ?? ""} ${sheet.summary} ${sheet.category} ${sheet.audience}`;
return text.toLowerCase().includes(q) || fuzzySearchTokenCount(q, text) > 0;
});
}

Expand Down
10 changes: 10 additions & 0 deletions src/components/therapy-compass/data/select.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Therapy } from "./types";
import { fuzzySearchTokenCount } from "@/lib/catalog-search";

// ---- text helpers -------------------------------------------------------

Expand Down Expand Up @@ -153,6 +154,15 @@ function scoreTherapy(t: Therapy, q: string): number {
if (lc(t.targetSymptoms).includes(q)) score += 5;
if (lc(t.clinicalSummary).includes(q)) score += 3;
if (lc(t.indications).includes(q)) score += 3;
if (score === 0) {
score +=
fuzzySearchTokenCount(
q,
[t.name, ...t.aliases, ...t.tags, t.category, t.bestUsedFor, t.targetSymptoms, t.clinicalSummary, t.indications]
.filter(Boolean)
.join(" "),
) * 2;
}
return score;
}

Expand Down
77 changes: 76 additions & 1 deletion src/lib/catalog-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,66 @@ export function compactSearchText(value: string) {
return value.replace(/\s+/g, "");
}

function typoDistanceLimit(term: string) {
if (term.length >= 8) return 2;
// Four-character clinical abbreviations (SSRI/SNRI, ADHD/ODD, etc.) are
// often one edit apart; require five characters before allowing a typo.
if (term.length >= 5) return 1;
return 0;
Comment thread
cursor[bot] marked this conversation as resolved.
}

/**
* Bounded Damerau-Levenshtein distance for catalogue search. The short-token
* guard prevents clinically meaningful abbreviations (for example, MDD/GAD
* and four-character medication-class labels such as SSRI/SNRI) from being
* broadened, while the transposition case catches common typing errors
* without involving document retrieval or answer-mode RAG.
*/
function boundedTypoDistance(left: string, right: string, limit: number) {
if (Math.abs(left.length - right.length) > limit) return limit + 1;
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
let previousPrevious: number[] | undefined;

for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
const current = [leftIndex];
let rowMinimum = current[0];
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
let distance = Math.min(
current[rightIndex - 1] + 1,
previous[rightIndex] + 1,
previous[rightIndex - 1] + substitutionCost,
);
if (
previousPrevious &&
leftIndex > 1 &&
rightIndex > 1 &&
left[leftIndex - 1] === right[rightIndex - 2] &&
left[leftIndex - 2] === right[rightIndex - 1]
) {
distance = Math.min(distance, previousPrevious[rightIndex - 2] + 1);
}
current[rightIndex] = distance;
rowMinimum = Math.min(rowMinimum, distance);
}
if (rowMinimum > limit) return limit + 1;
previousPrevious = previous.slice();
previous.splice(0, previous.length, ...current);
}
return previous[right.length];
}

/** Number of query tokens with a conservative near-word match in normalized text. */
export function fuzzySearchTokenCount(query: string, text: string) {
const queryTokens = normalizeSearchText(query).split(/\s+/).filter(Boolean);
const words = Array.from(new Set(normalizeSearchText(text).split(/\s+/).filter(Boolean)));
return queryTokens.filter((term) => {
if (words.some((word) => word.includes(term))) return false;
const limit = typoDistanceLimit(term);
return limit > 0 && words.some((word) => boundedTypoDistance(term, word, limit) <= limit);
}).length;
}

export type CatalogField<T> = {
// Wrapper-facing key used to build human-readable reasons (e.g. "title", "contact").
id: string;
Expand All @@ -39,6 +99,7 @@ export type CatalogMatchSignals = {
// Matched term count for terms introduced by expandTokens (e.g. symptom
// aliases) that were not part of the raw query.
expanded: number;
fuzzy: number;
compact: boolean;
phrase: boolean;
prefix: boolean;
Expand Down Expand Up @@ -130,6 +191,10 @@ export function rankCatalogRecords<T>(
score += content * contentWeight;

const expanded = expandedTerms.filter((term) => text.includes(term)).length;
const fuzzy = score === 0 ? fuzzySearchTokenCount(normalizedQuery, text) : 0;
Comment thread
cursor[bot] marked this conversation as resolved.
// Fuzzy evidence is deliberately weaker than a literal content hit. It
// rescues misspellings but cannot outrank a correctly matched title.
score += fuzzy * Math.max(1, contentWeight * 0.5);
Comment thread
cursor[bot] marked this conversation as resolved.

Comment thread
BigSimmo marked this conversation as resolved.
const compact =
compactBonus > 0 &&
Expand Down Expand Up @@ -158,7 +223,17 @@ export function rankCatalogRecords<T>(
record,
index,
score,
signals: { fields, content, expanded, compact, phrase, prefix, exact, broad } satisfies CatalogMatchSignals,
signals: {
fields,
content,
expanded,
fuzzy,
compact,
phrase,
prefix,
exact,
broad,
} satisfies CatalogMatchSignals,
};
})
.filter((match) => match.score > 0)
Expand Down
2 changes: 2 additions & 0 deletions src/lib/formulation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import formulationContentJson from "@/data/formulation-content.json";
import { fuzzySearchTokenCount } from "@/lib/catalog-search";

export type FormulationMechanism = {
id: string;
Expand Down Expand Up @@ -228,6 +229,7 @@ export function searchFormulationMechanisms(query: string, options: { domain?: s
if (clues.includes(token)) score += 8;
if (haystack.includes(token)) score += 3;
}
if (score === 0) score += fuzzySearchTokenCount(normalizedQuery, haystack) * 2;
}

return score > 0 ? { mechanism, score } : null;
Expand Down
3 changes: 3 additions & 0 deletions src/lib/specifiers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { fuzzySearchTokenCount } from "@/lib/catalog-search";

export type SpecifierFamily = "episode-features" | "course-onset" | "severity-remission";

export type SpecifierBuilderDiagnosis =
Expand Down Expand Up @@ -712,6 +714,7 @@ export function searchSpecifiers(
if (keywords.includes(token)) score += 10;
if (haystack.includes(token)) score += 3;
}
if (normalizedQuery && score === 0) score += fuzzySearchTokenCount(normalizedQuery, haystack) * 2;

return { record, score };
})
Expand Down
2 changes: 2 additions & 0 deletions src/lib/therapies.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import therapiesIndexJson from "@/data/therapies-index.json";
import { fuzzySearchTokenCount } from "@/lib/catalog-search";

// Server-side therapy catalogue. Backed by src/data/therapies-index.json — a trimmed,
// rankable projection of the ~2.5 MB public Therapy Compass dataset (regenerated by
Expand Down Expand Up @@ -119,6 +120,7 @@ export function searchTherapyRecords(query: string): TherapySearchMatch[] {
if (tags.includes(token)) score += 6;
if (haystack.includes(token)) score += 3;
}
if (score === 0) score += fuzzySearchTokenCount(normalizedQuery, haystack) * 2;
}

return score > 0 ? { record, score } : null;
Expand Down
21 changes: 20 additions & 1 deletion tests/catalog-search.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { compactSearchText, normalizeSearchText, rankCatalogRecords } from "../src/lib/catalog-search";
import {
compactSearchText,
fuzzySearchTokenCount,
normalizeSearchText,
rankCatalogRecords,
} from "../src/lib/catalog-search";

type Item = { title: string; slug: string; tags: string[]; body: string };

Expand Down Expand Up @@ -56,6 +61,20 @@ describe("rankCatalogRecords", () => {
expect(results.some((match) => match.record.slug === "lithium-levels")).toBe(false);
});

it("finds close catalogue words after an insertion, omission, or transposition", () => {
expect(rank("clozpaine")[0]?.record.slug).toBe("clozapine-monitoring");
expect(rank("lithum")[0]?.record.slug).toBe("lithium-levels");
expect(fuzzySearchTokenCount("monitroing", "Clozapine monitoring guidance")).toBe(1);
});

it("does not fuzz short clinical abbreviations or unrelated words", () => {
expect(fuzzySearchTokenCount("GAD", "Major depressive disorder")).toBe(0);
// Four-character medication-class abbreviations are one edit apart and
// must not fuzzy-match each other (CodeRabbit on PR #1800).
expect(fuzzySearchTokenCount("SSRI", "SNRI")).toBe(0);
expect(rank("transport").some((match) => match.record.slug === "clozapine-monitoring")).toBe(false);
});

it("applies the whole-phrase bonus on top of term matches", () => {
const [top] = rank("clozapine monitoring");
// 2 title terms (12) + 2 content terms (4) + phrase (4).
Expand Down
1 change: 1 addition & 0 deletions tests/factsheets-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe("factsheet library", () => {
expect(filterFactsheets("sertraline").map((sheet) => sheet.slug)).toContain("sertraline");
// Brand suffix ("(Zoloft)") is indexed even though it is stored separately from the title.
expect(filterFactsheets("Zoloft").map((sheet) => sheet.slug)).toContain("sertraline");
expect(filterFactsheets("sertralne").map((sheet) => sheet.slug)).toContain("sertraline");
const conditions = filterFactsheets("", "Conditions");
expect(conditions.length).toBeGreaterThan(0);
expect(conditions.every((sheet) => sheet.category === "Conditions")).toBe(true);
Expand Down
4 changes: 4 additions & 0 deletions tests/formulation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ describe("clinical formulation content", () => {
expect(searchFormulationMechanisms("If it is not perfect it is a failure")[0]?.mechanism.id).toBe("perfectionism");
});

it("recovers a close mechanism-name typo", () => {
expect(searchFormulationMechanisms("rumiantion")[0]?.mechanism.id).toBe("rumination");
});

it("filters the mechanism catalogue by formulation domain", () => {
const trauma = searchFormulationMechanisms("", { domain: "Trauma" });
expect(trauma.length).toBeGreaterThan(0);
Expand Down
4 changes: 4 additions & 0 deletions tests/specifiers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ describe("psychiatric specifier catalogue", () => {
expect(searchSpecifiers("much better but not fully recovered")[0]?.record.slug).toBe("in-partial-remission");
});

it("recovers a close specifier typo", () => {
expect(searchSpecifiers("melancholc")[0]?.record.slug).toBe("with-melancholic-features");
});

it("filters by diagnostic role and diagnosis context", () => {
const courseResults = searchSpecifiers("", { family: "course-onset" });
expect(courseResults.length).toBeGreaterThan(0);
Expand Down
31 changes: 30 additions & 1 deletion tests/therapy-card-preview.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
import { describe, expect, it } from "vitest";

import { cardPreviewText, prioritiseTherapyTags } from "@/components/therapy-compass/data/select";
import { cardPreviewText, prioritiseTherapyTags, searchTherapies } from "@/components/therapy-compass/data/select";
import type { Therapy } from "@/components/therapy-compass/data/types";

const searchableTherapy = {
slug: "behavioural-activation",
name: "Behavioural activation",
aliases: [],
tags: ["depression"],
category: "Behavioural",
bestUsedFor: "Low mood",
targetSymptoms: "withdrawal",
clinicalSummary: "A structured activity-based therapy.",
indications: "Depression",
briefInterventionAvailable: false,
patientSheetAvailable: false,
reviewStatus: "reviewed",
} as unknown as Therapy;

describe("searchTherapies", () => {
it("recovers a close therapy-name typo", () => {
const results = searchTherapies([searchableTherapy], {
query: "behavoural activaton",
tags: [],
briefOnly: false,
sheetOnly: false,
reviewedOnly: false,
});
expect(results[0]?.slug).toBe("behavioural-activation");
});
});

describe("cardPreviewText", () => {
it("skips a leading sentence that restates the therapy name", () => {
Expand Down
Loading