Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
21 changes: 14 additions & 7 deletions docs/branch-review-ledger.md

Large diffs are not rendered by default.

53 changes: 44 additions & 9 deletions src/app/api/medications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import {
rowGovernance,
rowToMedicationRecord,
} from "@/lib/medication-records";
import { medicationCatalogInterpretation, searchMedicationCatalog } from "@/lib/medication-query";
import {
medicationBrandNames,
medicationToSearchResult,
rankMedicationRecords,
type MedicationRecord,
type MedicationSearchMatch,
} from "@/lib/medications";
Expand All @@ -44,8 +45,20 @@ const medicationListQuerySchema = z.object({

// `fields=index` strips the heavy per-record content (stats/sections/quick are
// ~99% of the ~3.4 MB catalog) for callers that only need identity-level
// ranking, e.g. the answer surface's cross-mode links. The records keep the
// full MedicationRecord shape so rankers and badge helpers work unchanged.
// ranking, e.g. the answer surface's cross-mode links. Brand Names rows are
// retained so prescription-name search still scores at name weight.
function brandIdentitySections(record: MedicationRecord): MedicationRecord["sections"] {
const brands = medicationBrandNames(record);
if (!brands.length) return [];
return [
{
title: "Formulation & Access",
type: "form",
rows: [{ key: "Brand Names", val: brands.join(", ") }],
},
];
}

function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] {
return records.map((record) => ({
slug: record.slug,
Expand All @@ -57,11 +70,27 @@ function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] {
tag: record.tag,
schedule: record.schedule,
stats: [],
sections: [],
sections: brandIdentitySections(record),
quick: [],
}));
}

function rankCatalogMatches(records: MedicationRecord[], q: string, limit: number, projectIndex = false) {
const { matches, analysis } = searchMedicationCatalog(records, q, limit);
// Rank on full records for vocabulary, but serialize the slim identity shape
// when fields=index so matches do not reintroduce stats/sections/quick.
const serialized = projectIndex
? matches.map((match) => ({
...match,
medication: toIndexRecords([match.medication])[0]!,
}))
: matches;
return {
matches: matchesPayload(serialized),
interpretation: medicationCatalogInterpretation(analysis),
};
}

function medicationResponse(payload: Record<string, unknown>, options: { request?: Request; fixture?: boolean } = {}) {
return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) });
}
Expand Down Expand Up @@ -108,12 +137,16 @@ function publicGovernance(records: MedicationRecord[]) {
}

function publicMedicationPayload(q: string | undefined, limit: number, fields?: "index") {
const records = fields === "index" ? publicIndexRecords() : defaultMedicationRecords();
const governance = publicGovernance(records);
const matches = q ? rankMedicationRecords(records, q, limit) : undefined;
// Rank against the full snapshot even for fields=index so typo/brand vocabulary
// is complete; response records still use the slim identity projection.
const fullRecords = defaultMedicationRecords();
const records = fields === "index" ? publicIndexRecords() : fullRecords;
const governance = publicGovernance(fullRecords);
const ranked = q ? rankCatalogMatches(fullRecords, q, limit, fields === "index") : undefined;
return {
records,
matches: matches ? matchesPayload(matches) : undefined,
matches: ranked?.matches,
Comment thread
cursor[bot] marked this conversation as resolved.
interpretation: ranked?.interpretation,
total: records.length,
governance,
};
Expand Down Expand Up @@ -163,10 +196,12 @@ export async function GET(request: Request) {
const fullRecords = rows.map(rowToMedicationRecord);
const records = fields === "index" ? toIndexRecords(fullRecords) : fullRecords;
const governanceBySlug = Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)]));
const ranked = q ? rankCatalogMatches(fullRecords, q, limit, fields === "index") : undefined;

return medicationResponse({
records,
matches: q ? matchesPayload(rankMedicationRecords(records, q, limit)) : undefined,
matches: ranked?.matches,
interpretation: ranked?.interpretation,
total: rows.length,
governance: governanceBySlug,
});
Expand Down
6 changes: 4 additions & 2 deletions src/lib/medication-entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,10 @@ zuclopenthixol decanoate

// This is deliberately a compact, static safety catalogue derived from the
// medication snapshot. Importing the full snapshot would add several megabytes
// of presentation data to the answer path. Aliases only establish identity for
// safety checks; they do not expand retrieval queries or alter ranking.
// of presentation data to the answer path. Aliases establish identity for
// safety checks and are not used to expand RAG retrieval queries. Medication
// catalog search may borrow this list for brand↔generic recall via
// medication-query.ts only.
const medicationIdentityGroups = [
["adrenaline", "epinephrine"],
["aripiprazole", "abilify"],
Expand Down
266 changes: 266 additions & 0 deletions src/lib/medication-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
// Medications-catalog query understanding: typo correction, brand↔generic
// expansion, and conservative near-miss matching for /api/medications and the
// medications universal-search domain. Kept separate from clinical-search /
// RAG so catalog recall can grow without changing retrieval analysis.

import { medicationAliasesForEntity, medicationEntitiesInText } from "@/lib/medication-entities";
import {
medicationBrandNames,
normalizeSearchText,
rankMedicationRecords,
type MedicationRecord,
type MedicationSearchMatch,
} from "@/lib/medications";

export type MedicationCatalogCorrection = {
from: string;
to: string;
};

export type MedicationCatalogQueryAnalysis = {
originalQuery: string;
correctedQuery: string;
corrections: MedicationCatalogCorrection[];
expansions: string[];
};

// Curated psychotropic / common prescribing typos. Local to the catalog path so
// expanding this map cannot change RAG query analysis in clinical-search.
const medicationTypoCorrections = new Map<string, string>([
["sertaline", "sertraline"],
["sertralne", "sertraline"],
["sertralin", "sertraline"],
["olanzpine", "olanzapine"],
["olanzapin", "olanzapine"],
["quietapine", "quetiapine"],
["quetiapin", "quetiapine"],
["quetiapne", "quetiapine"],
["risperdone", "risperidone"],
["risperidon", "risperidone"],
["aripiprazol", "aripiprazole"],
["aripiprazloe", "aripiprazole"],
["clozapin", "clozapine"],
["clozapene", "clozapine"],
["clozapinw", "clozapine"],
["mirtazpine", "mirtazapine"],
["mirtazapin", "mirtazapine"],
["venlafaxne", "venlafaxine"],
["duloxetin", "duloxetine"],
["escitaloprame", "escitalopram"],
["fluoxetin", "fluoxetine"],
["paroxetin", "paroxetine"],
["haloperidl", "haloperidol"],
["lorazapam", "lorazepam"],
["diazapam", "diazepam"],
["valporate", "valproate"],
["valproat", "valproate"],
["lithum", "lithium"],
["acamprosat", "acamprosate"],
["camprl", "campral"],
["zolof", "zoloft"],
["zyprex", "zyprexa"],
["seroqel", "seroquel"],
]);

const fuzzyMinTokenLength = 6;
const maxExpansions = 16;

function queryTokens(query: string): string[] {
return normalizeSearchText(query)
.split(/\s+/)
.filter((token) => token.length > 0);
}

function addNormalizedTokens(target: Set<string>, value: string) {
for (const token of queryTokens(value)) {
if (token.length > 1) target.add(token);
}
}

/** Vocabulary of generic names, slugs, brand tokens, and safety-identity aliases. */
export function buildMedicationCatalogVocabulary(records: MedicationRecord[]): Set<string> {
const vocab = new Set<string>();
for (const record of records) {
addNormalizedTokens(vocab, record.name);
addNormalizedTokens(vocab, record.slug.replace(/-/g, " "));
for (const brand of medicationBrandNames(record)) {
addNormalizedTokens(vocab, brand);
}
for (const alias of medicationAliasesForEntity(record.name)) {
addNormalizedTokens(vocab, alias);
}
}
// Also index identity aliases that may not share a catalog name token
// (e.g. "zyprexa" when only formulation-named records exist).
for (const record of records) {
for (const brand of medicationBrandNames(record)) {
for (const alias of medicationAliasesForEntity(brand)) {
addNormalizedTokens(vocab, alias);
}
}
}
return vocab;
}

/** True when Levenshtein distance is exactly 1 (substitution, insertion, or deletion). */
export function isEditDistanceOne(left: string, right: string): boolean {
if (left === right) return false;
const a = left;
const b = right;
const lenA = a.length;
const lenB = b.length;
if (Math.abs(lenA - lenB) > 1) return false;

let i = 0;
let j = 0;
let edits = 0;
while (i < lenA && j < lenB) {
if (a[i] === b[j]) {
i += 1;
j += 1;
continue;
}
edits += 1;
if (edits > 1) return false;
if (lenA > lenB) i += 1;
else if (lenB > lenA) j += 1;
else {
i += 1;
j += 1;
}
}
if (i < lenA || j < lenB) edits += 1;
return edits === 1;
}

function uniqueEditDistanceOneMatch(token: string, vocabulary: Set<string>): string | undefined {
if (token.length < fuzzyMinTokenLength || vocabulary.has(token)) return undefined;
let match: string | undefined;
for (const candidate of vocabulary) {
if (Math.abs(candidate.length - token.length) > 1) continue;
if (!isEditDistanceOne(token, candidate)) continue;
if (match && match !== candidate) return undefined;
match = candidate;
}
return match;
}

function brandOrNameExpansions(token: string, records: MedicationRecord[]): string[] {
const expansions: string[] = [];
const seen = new Set<string>();
const push = (value: string) => {
const normalized = normalizeSearchText(value);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
expansions.push(normalized);
};

for (const entity of medicationEntitiesInText(token)) {
push(entity);
for (const alias of medicationAliasesForEntity(entity)) push(alias);
}

const needle = normalizeSearchText(token);
if (!needle) return expansions;

for (const record of records) {
const name = normalizeSearchText(record.name);
const slug = normalizeSearchText(record.slug.replace(/-/g, " "));
const brands = medicationBrandNames(record).map((brand) => normalizeSearchText(brand));
const hitsName = name === needle || name.split(/\s+/).includes(needle) || slug.split(/\s+/).includes(needle);
const hitsBrand = brands.some((brand) => brand === needle || brand.split(/\s+/).includes(needle));
if (!hitsName && !hitsBrand) continue;
// Expand to the canonical identity only. Sibling brands must not enter the
// expanded content lane — substring includes() would let "eleva" match
// "elevated" and pull unrelated records into brand queries like "zoloft".
push(record.name);
push(record.slug.replace(/-/g, " "));
for (const alias of medicationAliasesForEntity(record.name)) push(alias);
Comment thread
cursor[bot] marked this conversation as resolved.
}

return expansions;
}

/**
* Correct typos and collect brand/generic expansions for medication catalog ranking.
* Does not call clinical-search or RAG helpers.
*/
export function analyzeMedicationCatalogQuery(
query: string,
records: MedicationRecord[],
): MedicationCatalogQueryAnalysis {
const originalQuery = query.trim();
if (!originalQuery) {
return { originalQuery: "", correctedQuery: "", corrections: [], expansions: [] };
}

const vocabulary = buildMedicationCatalogVocabulary(records);
const rawTokens = originalQuery.split(/\s+/).filter(Boolean);
const corrections: MedicationCatalogCorrection[] = [];
const correctedTokens: string[] = [];

for (const raw of rawTokens) {
const lower = raw.toLowerCase();
const normalized = normalizeSearchText(raw);
const mapped = medicationTypoCorrections.get(lower) ?? medicationTypoCorrections.get(normalized);
if (mapped && mapped !== normalized && mapped !== lower) {
corrections.push({ from: lower, to: mapped });
correctedTokens.push(mapped);
continue;
}
const fuzzy = normalized ? uniqueEditDistanceOneMatch(normalized, vocabulary) : undefined;
if (fuzzy) {
corrections.push({ from: normalized, to: fuzzy });
correctedTokens.push(fuzzy);
continue;
}
correctedTokens.push(raw);
}

const correctedQuery = correctedTokens.join(" ");
const baseTokens = new Set(queryTokens(correctedQuery));
const expansionSeen = new Set<string>(baseTokens);
const expansions: string[] = [];

for (const token of queryTokens(correctedQuery)) {
for (const term of brandOrNameExpansions(token, records)) {
for (const piece of queryTokens(term)) {
if (piece.length < 2 || expansionSeen.has(piece)) continue;
expansionSeen.add(piece);
expansions.push(piece);
if (expansions.length >= maxExpansions) {
return { originalQuery, correctedQuery, corrections, expansions };
}
}
}
}

return { originalQuery, correctedQuery, corrections, expansions };
}

export function searchMedicationCatalog(
records: MedicationRecord[],
query: string,
limit = 50,
): {
matches: MedicationSearchMatch[];
analysis: MedicationCatalogQueryAnalysis;
} {
const analysis = analyzeMedicationCatalogQuery(query, records);
const matches = analysis.correctedQuery
? rankMedicationRecords(records, analysis.correctedQuery, limit, analysis.expansions)
: [];
return { matches, analysis };
}

export function medicationCatalogInterpretation(analysis: MedicationCatalogQueryAnalysis) {
if (!analysis.corrections.length && !analysis.expansions.length) return undefined;
return {
correctedQuery:
analysis.corrections.length && analysis.correctedQuery !== analysis.originalQuery
? analysis.correctedQuery
: undefined,
corrections: analysis.corrections.length ? analysis.corrections : undefined,
appliedExpansions: analysis.expansions.length ? analysis.expansions : undefined,
};
}
Loading
Loading