project-nomad/admin/util/drug_interactions.ts

117 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Parse a flattened FDA label section (drug interactions, dosage, warnings,
* contraindications, indications, ...) into ordered blocks for readable
* rendering. The split is purely structural: every word of the original text is
* preserved in order — only the numbered-subsection and bullet markers move into
* block metadata. No FDA wording is altered, summarized, or dropped. The
* content-fidelity invariant is enforced by the unit tests.
*/
/** One renderable block of parsed FDA label text. */
export interface LabelBlock {
/** Subsection label like "7.1", or null for intro/header text. */
label: string | null
/** Verbatim text (subsection label stripped from the front), or null for a bullet block. */
text: string | null
/** Verbatim bullet items, or null for a text block. */
bullets: string[] | null
}
/** Backward-compatible alias — the interaction comparison view's original name. */
export type InteractionBlock = LabelBlock
// A section's own leading header: a number then an ALL-CAPS section name, e.g.
// "7 DRUG INTERACTIONS", "2 DOSAGE AND ADMINISTRATION". Anchoring subsection
// detection to THIS section's number means inline cross-refs like "(12.3)" or
// "( 7.1 )" are never mistaken for a subsection heading.
const HEADER_RE = /^\s*(\d{1,2})\s+[A-Z][A-Z][A-Z &,/()'.-]*/
// "<major>.<n> <Capital><lowercase>" subsection number, for the no-header fallback.
const SUBSECTION_RE = /\b(\d{1,2})\.\d{1,2}\s+[A-Z][a-z]/g
/** This section's major number — from its leading header, else the dominant subsection major. */
function detectMajor(text: string): string | null {
const header = text.match(HEADER_RE)
if (header) return header[1]
const counts = new Map<string, number>()
for (const m of text.matchAll(SUBSECTION_RE)) {
counts.set(m[1], (counts.get(m[1]) ?? 0) + 1)
}
let best: string | null = null
let bestN = 0
for (const [major, n] of counts) {
if (n > bestN) {
best = major
bestN = n
}
}
return best
}
export function parseLabelSection(raw: string | null | undefined): LabelBlock[] {
if (!raw) return []
const text = raw.trim()
if (!text) return []
const major = detectMajor(text)
let pieces: string[]
let labelRe: RegExp | null = null
if (major) {
// Split before "<major>.<n> <Capital><lowercase>". The \b prevents matching
// inside a larger number (e.g. "17.1"); the [A-Z][a-z] requirement excludes
// parenthetical refs like "( 7.1 )" (those are followed by ")"). Lookahead
// only — no lookbehind — for broad browser support.
const splitRe = new RegExp(`(?=\\b${major}\\.\\d{1,2}\\s+[A-Z][a-z])`)
pieces = text.split(splitRe)
labelRe = new RegExp(`^(${major}\\.\\d{1,2})\\s+`)
} else {
pieces = [text]
}
const blocks: LabelBlock[] = []
for (const rawPiece of pieces) {
const piece = rawPiece.trim()
if (!piece) continue
let label: string | null = null
let body = piece
if (labelRe) {
const m = piece.match(labelRe)
if (m) {
label = m[1]
body = piece.slice(m[0].length).trim()
}
}
if (body.includes('•')) {
const parts = body.split('•')
const lead = parts[0].trim()
const items = parts
.slice(1)
.map((s) => s.trim())
.filter(Boolean)
if (lead) {
blocks.push({ label, text: lead, bullets: null })
if (items.length) blocks.push({ label: null, text: null, bullets: items })
} else if (items.length) {
blocks.push({ label, text: null, bullets: items })
}
} else if (body) {
blocks.push({ label, text: body, bullets: null })
}
}
return blocks
}
/** Backward-compatible alias for the interaction comparison view. */
export const parseInteractions = parseLabelSection
/** True when a text block is just a "N SECTION NAME" FDA section header. */
export function isLabelSectionHeader(text: string | null): boolean {
return !!text && /^\s*\d{1,2}\s+[A-Z][A-Z][A-Z &,/()'.-]*$/.test(text)
}
/** Backward-compatible alias. */
export const isSectionHeader = isLabelSectionHeader