Spaces:
Sleeping
Sleeping
| /** | |
| * In-memory drug interaction engine. | |
| * | |
| * On startup, loads three CSVs from /data/: | |
| * - 1mg_medicines_normalized.csv (252,997 rows) | |
| * - canonical_generics.csv (3,780 rows) | |
| * - interactions.csv (224,449 rows) | |
| * | |
| * Memory footprint is ~150 MB. Initial load takes ~3-5 seconds. | |
| * | |
| * For production (recordrx), use the Postgres-backed version in /node/src. | |
| */ | |
| ; | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { parse } = require('csv-parse/sync'); | |
| // ---- India -> DDInter name normalization ---- | |
| const INDIA_TO_DDINTER = { | |
| 'paracetamol': 'acetaminophen', | |
| 'lignocaine': 'lidocaine', | |
| 'lignocain': 'lidocaine', | |
| 'glibenclamide': 'glyburide', | |
| 'frusemide': 'furosemide', | |
| 'adrenaline': 'epinephrine', | |
| 'noradrenaline': 'norepinephrine', | |
| 'tazobactum': 'tazobactam', | |
| 'amoxycillin': 'amoxicillin', | |
| 'amoxycillin trihydrate': 'amoxicillin', | |
| 'cyclosporin': 'cyclosporine', | |
| 'ciclosporin': 'cyclosporine', | |
| 'phenobarbitone': 'phenobarbital', | |
| 'thiopentone': 'thiopental', | |
| 'pethidine': 'meperidine', | |
| 'rifampicin': 'rifampin', | |
| 'sulphamethoxazole': 'sulfamethoxazole', | |
| 'aspirin': 'acetylsalicylic acid', | |
| }; | |
| const SALT_SUFFIXES = [ | |
| 'Hydrochloride','Dihydrochloride','Trihydrate','Monohydrate','Dihydrate', | |
| 'Hydrobromide','Mesylate','Maleate','Tartrate','Succinate','Acetate', | |
| 'Phosphate','Citrate','Sulphate','Sulfate','Bromide','Chloride','Iodide', | |
| 'Fumarate','Oxalate','Furoate','Aceponate','Propionate','Valerate', | |
| 'Pivalate','Pamoate','Lactate','Gluconate','Sodium','Potassium', | |
| 'Calcium','Magnesium','Zinc','HCl','HBr','Besylate','Tosylate', | |
| 'Nitrate','Bicarbonate','Carbonate','Stearate','Palmitate', | |
| 'Hemifumarate','Decanoate','Enanthate','Cypionate', | |
| 'Disodium','Dipotassium','Camsylate', | |
| ].sort((a, b) => b.length - a.length); | |
| function stripSalt(name) { | |
| let s = String(name || '').trim(); | |
| let changed = true; | |
| while (changed) { | |
| changed = false; | |
| for (const salt of SALT_SUFFIXES) { | |
| if (s.endsWith(' ' + salt) || s.endsWith(' ' + salt.toLowerCase())) { | |
| s = s.slice(0, s.length - salt.length - 1).replace(/\s+$/, ''); | |
| changed = true; | |
| break; | |
| } | |
| } | |
| } | |
| return s; | |
| } | |
| function normalizeToDDInter(indianName) { | |
| if (!indianName) return ''; | |
| let s = String(indianName).trim().replace(/\s+/g, ' '); | |
| const base = stripSalt(s); | |
| for (const c of [s.toLowerCase(), base.toLowerCase()]) { | |
| if (INDIA_TO_DDINTER[c]) return INDIA_TO_DDINTER[c]; | |
| } | |
| return base.toLowerCase(); | |
| } | |
| // ---- Brand-family extraction ---- | |
| const FORM_TOKENS = new Set([ | |
| 'tablet','tablets','capsule','capsules','syrup','injection','cream','ointment','gel', | |
| 'lotion','drops','suspension','spray','powder','sachet','patch','lozenge','solution', | |
| 'shampoo','soap','infusion','elixir','inhaler','rotacaps','respules','granules', | |
| 'er','sr','mr','dt','pr','cr','od','xl','duo', | |
| ]); | |
| function brandFamily(name) { | |
| let s = String(name || '').toLowerCase(); | |
| s = s.replace(/\d+(?:\.\d+)?\s*(mg|mcg|ml|g|gm|iu|%|w\/w|w\/v)\b/g, ''); | |
| s = s.replace(/[\(\)\|/]/g, ' '); | |
| return s | |
| .split(/\s+/) | |
| .filter(t => t && !FORM_TOKENS.has(t) && !/^\d+(?:\.\d+)?$/.test(t)) | |
| .join(' ') | |
| .trim(); | |
| } | |
| // ---- Engine ---- | |
| class InteractionEngine { | |
| constructor(dataDir) { | |
| this.dataDir = dataDir; | |
| this.medicines = []; // [{brand_name, generic_name, ...}] | |
| this.byBrandLc = new Map(); // brand_lc -> medicine record (first match) | |
| this.byFamily = new Map(); // family -> medicine record[] (all SKUs in family) | |
| this.allBrandsLc = []; // for substring scan | |
| this.familyKeys = []; // for prefix scan / fuzzy | |
| this.knownDDInterDrugs = new Set(); | |
| this.pairIndex = new Map(); // 'a|b' (alpha sorted) -> { severity, clinical_effect } | |
| } | |
| load() { | |
| const t0 = Date.now(); | |
| // ---- medicines ---- | |
| const medsRaw = fs.readFileSync(path.join(this.dataDir, '1mg_medicines_normalized.csv')); | |
| const meds = parse(medsRaw, { columns: true, skip_empty_lines: true }); | |
| for (const r of meds) { | |
| r.brand_lc = String(r.brand_name || '').toLowerCase(); | |
| r.brand_family = brandFamily(r.brand_name); | |
| this.medicines.push(r); | |
| if (!this.byBrandLc.has(r.brand_lc)) this.byBrandLc.set(r.brand_lc, r); | |
| if (!this.byFamily.has(r.brand_family)) this.byFamily.set(r.brand_family, []); | |
| this.byFamily.get(r.brand_family).push(r); | |
| } | |
| this.allBrandsLc = this.medicines.map(m => m.brand_lc); | |
| this.familyKeys = [...this.byFamily.keys()]; | |
| // ---- interactions ---- | |
| const interFile = fs.existsSync(path.join(this.dataDir, 'interactions_full.csv')) | |
| ? 'interactions_full.csv' : 'interactions.csv'; | |
| const interRaw = fs.readFileSync(path.join(this.dataDir, interFile)); | |
| const inter = parse(interRaw, { columns: true, skip_empty_lines: true }); | |
| const NO_INTERACTION_RE = new RegExp( | |
| [ | |
| // "no <anything up to 10 words> interaction(s)" | |
| '\\bno\\s+(\\w+\\s+){0,10}interactions?\\b', | |
| // "not clinically significant" / "not expected to interact" / "not known to interact" | |
| '\\bnot\\s+(clinically\\s+)?(significant|expected\\s+to\\s+interact|known\\s+to\\s+interact)\\b', | |
| // "does not / do not (clinically) interact" | |
| 'do(es)?\\s+not\\s+(clinically\\s+)?interact\\b', | |
| // "are not known to interact" | |
| 'are\\s+not\\s+known\\s+to\\s+interact\\b', | |
| // "no ... interaction(s) are/is expected/known/reported" | |
| 'no\\s+(direct\\s+|known\\s+|notable\\s+|major\\s+|relevant\\s+|important\\s+|clinically\\s+|pharmacokinetic\\s+|pharmacodynamic\\s+)*interactions?\\s+(are\\s+|is\\s+|have\\s+been\\s+)?(expected|known|reported|noted|anticipated|documented|established)\\b', | |
| ].join('|'), | |
| 'i' | |
| ); | |
| let skipped = 0; | |
| for (const r of inter) { | |
| const a = r.drug_a; | |
| const b = r.drug_b; | |
| const effect = r.clinical_effect || ''; | |
| if (effect && NO_INTERACTION_RE.test(effect)) { | |
| this.knownDDInterDrugs.add(a); | |
| this.knownDDInterDrugs.add(b); | |
| skipped++; | |
| continue; | |
| } | |
| const key = a < b ? `${a}|${b}` : `${b}|${a}`; | |
| this.pairIndex.set(key, { severity: r.severity, clinical_effect: effect }); | |
| this.knownDDInterDrugs.add(a); | |
| this.knownDDInterDrugs.add(b); | |
| } | |
| if (skipped) console.log(`[engine] skipped ${skipped} pairs with "no significant interaction" text`); | |
| console.log(`[engine] loaded interactions from ${interFile}`); | |
| const ms = Date.now() - t0; | |
| console.log(`[engine] loaded in ${ms} ms — ${this.medicines.length.toLocaleString()} meds, ${this.pairIndex.size.toLocaleString()} pairs, ${this.knownDDInterDrugs.size.toLocaleString()} DDInter drugs`); | |
| } | |
| // ---- search (autocomplete) ---- | |
| search(q, limit = 10) { | |
| q = String(q || '').trim().toLowerCase(); | |
| if (q.length < 2) return []; | |
| const out = []; | |
| const seen = new Set(); | |
| // Prefer family-prefix | |
| for (const m of this.medicines) { | |
| if (m.brand_family.startsWith(q)) { | |
| if (!seen.has(m.brand_name)) { out.push(m); seen.add(m.brand_name); } | |
| if (out.length >= limit) break; | |
| } | |
| } | |
| // Fill with substring | |
| if (out.length < limit) { | |
| for (const m of this.medicines) { | |
| if (m.brand_lc.includes(q) && !seen.has(m.brand_name)) { | |
| out.push(m); seen.add(m.brand_name); | |
| if (out.length >= limit) break; | |
| } | |
| } | |
| } | |
| return out.map(m => ({ | |
| brand_name: m.brand_name, | |
| generic_name: m.generic_name, | |
| dosage: m.dosage || '', | |
| dosage_form: m.dosage_form || '', | |
| manufacturer: m.manufacturer || '', | |
| })); | |
| } | |
| // ---- brand lookup (tiered) ---- | |
| lookupBrand(query) { | |
| const q = String(query || '').trim(); | |
| if (!q) return null; | |
| const ql = q.toLowerCase(); | |
| const qf = brandFamily(q); | |
| // Tier 1: exact | |
| const exact = this.byBrandLc.get(ql); | |
| if (exact) return this._fill(q, exact, 'exact'); | |
| // Tier 2: family equality | |
| if (qf && this.byFamily.has(qf)) { | |
| const list = this.byFamily.get(qf); | |
| return this._fill(q, list[0], 'family'); | |
| } | |
| // Tier 3: substring on brand_lc | |
| const sub = this.medicines.find(m => m.brand_lc.includes(ql)); | |
| if (sub) return this._fill(q, sub, 'substring'); | |
| // Tier 4: substring on family | |
| if (qf) { | |
| const subFam = this.medicines.find(m => m.brand_family.includes(qf)); | |
| if (subFam) return this._fill(q, subFam, 'family-substring'); | |
| } | |
| return null; | |
| } | |
| _fill(input, m, confidence) { | |
| return { | |
| input, | |
| matched_brand: m.brand_name, | |
| generic_name: m.generic_name, | |
| ingredients: String(m.generic_name).split('+').map(s => s.trim()).filter(Boolean), | |
| dosage: m.dosage || '', | |
| dosage_form: m.dosage_form || '', | |
| manufacturer: m.manufacturer || '', | |
| confidence, | |
| }; | |
| } | |
| // ---- main check (sync, DDInter only) ---- | |
| check(brands) { | |
| const result = { | |
| inputs: [...brands], | |
| resolved_brands: [], | |
| unresolved_brands: [], | |
| no_data_ingredients: [], | |
| interactions: [], | |
| severity_summary: { Major: 0, Moderate: 0, Minor: 0, Unknown: 0 }, | |
| }; | |
| const perBrand = []; | |
| for (const b of brands) { | |
| const r = this.lookupBrand(b); | |
| if (!r) { result.unresolved_brands.push(b); continue; } | |
| const ings = r.ingredients.map(name => { | |
| const ddi = normalizeToDDInter(name); | |
| const known = this.knownDDInterDrugs.has(ddi); | |
| if (!known && !result.no_data_ingredients.includes(name)) { | |
| result.no_data_ingredients.push(name); | |
| } | |
| return { ingredient: name, ddinter_name: known ? ddi : null }; | |
| }); | |
| result.resolved_brands.push({ | |
| input: r.input, | |
| matched_brand: r.matched_brand, | |
| ingredients: ings, | |
| }); | |
| perBrand.push({ brand: r.matched_brand, ings }); | |
| } | |
| // pairwise check | |
| for (let i = 0; i < perBrand.length; i++) { | |
| for (let j = i + 1; j < perBrand.length; j++) { | |
| for (const a of perBrand[i].ings) { | |
| if (!a.ddinter_name) continue; | |
| for (const b of perBrand[j].ings) { | |
| if (!b.ddinter_name || a.ddinter_name === b.ddinter_name) continue; | |
| const [da, db] = a.ddinter_name < b.ddinter_name | |
| ? [a.ddinter_name, b.ddinter_name] | |
| : [b.ddinter_name, a.ddinter_name]; | |
| const pair = this.pairIndex.get(`${da}|${db}`); | |
| if (pair) { | |
| result.interactions.push({ | |
| severity: pair.severity, | |
| clinical_effect: pair.clinical_effect, | |
| brand_a: perBrand[i].brand, | |
| brand_b: perBrand[j].brand, | |
| ingredient_a: a.ingredient, | |
| ingredient_b: b.ingredient, | |
| drug_a: da, drug_b: db, | |
| }); | |
| result.severity_summary[pair.severity] = (result.severity_summary[pair.severity] || 0) + 1; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| const rank = { Major: 0, Moderate: 1, Minor: 2, Unknown: 3 }; | |
| result.interactions.sort((x, y) => rank[x.severity] - rank[y.severity]); | |
| return result; | |
| } | |
| // ---- Gap 1: async check with Gemini fallback for unresolved brands ---- | |
| async checkWithGap1Fallback(brands, gap1Cache, geminiEnricher, gap3Cache) { | |
| // Step 1: run normal DDInter check | |
| const result = this.check(brands); | |
| // Step 2: check gap3_cache for no_data_ingredients (free, instant) | |
| if (gap3Cache && result.no_data_ingredients.length > 0) { | |
| for (const noDataIng of result.no_data_ingredients) { | |
| const cached = gap3Cache.getIngredient(noDataIng); | |
| if (cached && cached.interactions.size > 0) { | |
| for (const [drugB, interaction] of cached.interactions) { | |
| if (interaction.severity === 'Safe' || !interaction.severity) continue; | |
| // find resolved brands that contain drugB as an ingredient | |
| for (const resolvedBrand of result.resolved_brands) { | |
| const hasIngredient = resolvedBrand.ingredients && | |
| resolvedBrand.ingredients.some(ing => | |
| ing.ingredient.toLowerCase().includes(drugB.toLowerCase()) || | |
| drugB.toLowerCase().includes(ing.ingredient.toLowerCase()) | |
| ); | |
| if (hasIngredient) { | |
| result.interactions.push({ | |
| severity: interaction.severity, | |
| brand_a: result.resolved_brands.find(r => r.ingredients.some(ing => ing.ingredient === noDataIng))?.matched_brand || noDataIng, | |
| brand_b: resolvedBrand.matched_brand, | |
| ingredient_a: noDataIng, | |
| ingredient_b: drugB, | |
| side_effects: interaction.side_effects, | |
| mechanism: interaction.mechanism, | |
| clinical_action: interaction.clinical_action, | |
| source: 'gap3', | |
| }); | |
| result.severity_summary[interaction.severity] = | |
| (result.severity_summary[interaction.severity] || 0) + 1; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Step 3: if no unresolved brands, we're done | |
| if (result.unresolved_brands.length === 0) { | |
| const rank = { Major: 0, Moderate: 1, Minor: 2, Unknown: 3 }; | |
| result.interactions.sort((x, y) => rank[x.severity] - rank[y.severity]); | |
| return result; | |
| } | |
| const resolvedDrugNames = result.resolved_brands | |
| .map(r => r.matched_brand); | |
| for (const unresolvedBrand of result.unresolved_brands) { | |
| // Step 4: check gap1 cache first (free, instant) | |
| const cached = gap1Cache.getMedicine(unresolvedBrand); | |
| let medicineData; | |
| let source; | |
| if (cached) { | |
| medicineData = cached; | |
| source = 'cache'; | |
| console.log(`[gap1] cache hit for "${unresolvedBrand}"`); | |
| } else { | |
| // Step 5: cache miss → call Gemini (~₹0.06) | |
| console.log(`[gap1] cache miss → calling Gemini for "${unresolvedBrand}"`); | |
| try { | |
| const geminiResult = await geminiEnricher.enrichMedicine( | |
| unresolvedBrand, | |
| resolvedDrugNames | |
| ); | |
| const hasUsableData = (geminiResult.active_ingredients?.length || 0) > 0 | |
| && (geminiResult.confidence || 0) >= 0.5; | |
| if (!geminiResult.is_real_medicine && !hasUsableData) { | |
| console.log(`[gap1] Gemini says "${unresolvedBrand}" is not a real medicine (confidence=${geminiResult.confidence || 0})`); | |
| continue; | |
| } | |
| if (!geminiResult.is_real_medicine && hasUsableData) { | |
| console.log(`[gap1] Gemini flagged "${unresolvedBrand}" not-real but returned usable data (confidence=${geminiResult.confidence}) — accepting`); | |
| } | |
| // Step 6: save to cache so future calls are free | |
| gap1Cache.save(unresolvedBrand, geminiResult); | |
| medicineData = gap1Cache.getMedicine(unresolvedBrand); | |
| source = 'gemini'; | |
| } catch (e) { | |
| console.error(`[gap1] Gemini call failed for "${unresolvedBrand}":`, e.message); | |
| continue; | |
| } | |
| } | |
| if (!medicineData) continue; | |
| // Step 7: move from unresolved → resolved | |
| result.unresolved_brands = result.unresolved_brands.filter(b => b !== unresolvedBrand); | |
| result.resolved_brands.push({ | |
| input: unresolvedBrand, | |
| matched_brand: unresolvedBrand, | |
| source, | |
| generic_name: medicineData.generic_name, | |
| ingredients: medicineData.active_ingredients.map(i => ({ | |
| ingredient: i, | |
| ddinter_name: null, | |
| })), | |
| }); | |
| // Step 8: add interactions from cache/Gemini to result | |
| for (const [drugB, interaction] of medicineData.interactions) { | |
| if (interaction.severity === 'Safe' || !interaction.severity) continue; | |
| // check if drugB matches any resolved brand | |
| const matchedBrand = result.resolved_brands.find(r => | |
| r.matched_brand.toLowerCase() === drugB || | |
| (r.generic_name && r.generic_name.toLowerCase().includes(drugB)) | |
| ); | |
| if (matchedBrand) { | |
| result.interactions.push({ | |
| severity: interaction.severity, | |
| brand_a: unresolvedBrand, | |
| brand_b: matchedBrand.matched_brand, | |
| ingredient_a: medicineData.generic_name, | |
| ingredient_b: matchedBrand.generic_name || drugB, | |
| side_effects: interaction.side_effects, | |
| mechanism: interaction.mechanism, | |
| clinical_action: interaction.clinical_action, | |
| source: 'gap1', | |
| }); | |
| result.severity_summary[interaction.severity] = | |
| (result.severity_summary[interaction.severity] || 0) + 1; | |
| } | |
| } | |
| } | |
| const rank = { Major: 0, Moderate: 1, Minor: 2, Unknown: 3 }; | |
| result.interactions.sort((x, y) => rank[x.severity] - rank[y.severity]); | |
| return result; | |
| } | |
| } | |
| module.exports = { InteractionEngine, normalizeToDDInter, brandFamily }; | |