fix(drug-reference): address review — defer FULLTEXT, fallback remedies prop, strip fork refs
- migration: wrap the FULLTEXT ALTER in this.defer so it runs after the deferred createTable (was silently swallowed, index never created) - controller: add remedies:[] to the index() error fallback (required prop) - strip fork-internal issue/spec references from ported comments - tsconfig: exclude tests/standalone (node --experimental-strip-types only) - correct the varchar(768) byte-math comment; extend remedy-spine test
This commit is contained in:
parent
5696c14bbe
commit
cda8f740af
|
|
@ -49,6 +49,7 @@ export default class DrugReferenceController {
|
|||
ingestStatus: null,
|
||||
rowCount: 0,
|
||||
conditions: [],
|
||||
remedies: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
/**
|
||||
* "When to use what" — curated condition spine (Phase 1, runtime source of truth).
|
||||
*
|
||||
* A bounded, hand-curated list of common first-aid / emergency situations — the
|
||||
* "small booklet" the upstream #664 requester described. Each entry's
|
||||
* `searchTerms` drive the FULLTEXT search over `drug_labels.indications`.
|
||||
* A bounded, hand-curated list of common first-aid / emergency situations — a
|
||||
* "small booklet" of when-to-use-what. Each entry's `searchTerms` drive the
|
||||
* FULLTEXT search over `drug_labels.indications`.
|
||||
*
|
||||
* WHY a TS constant (not a JSON file read at runtime):
|
||||
* The repo-root `collections/conditions.json` mirror exists for parity with
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
* image (`ADD admin/ ./`) and ships only the compiled `build/` output, so a
|
||||
* repo-root JSON file never reaches the container filesystem. Bundling the
|
||||
* spine as a compiled module guarantees it is always present at runtime with
|
||||
* no path-resolution fragility. The spec explicitly allows "JSON/TS constant".
|
||||
* no path-resolution fragility.
|
||||
*
|
||||
* Keep this file and `collections/conditions.json` in sync (same `version`,
|
||||
* same entries) — the JSON is the public/browseable copy, this is the canonical
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* is what the running app reads (the image ships only compiled admin/build/).
|
||||
* Keep the two in sync. Sources are US-government public-domain pages (CDC,
|
||||
* NIH/NLM MedlinePlus summaries, FDA consumer updates) — each entry carries its
|
||||
* exact sourceUrl. See GitHub issue #23.
|
||||
* exact sourceUrl.
|
||||
*/
|
||||
|
||||
import type { NaturalRemediesFile } from '../../types/conditions.js'
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
* into the image (`ADD admin/ ./`) and ships only the compiled `build/` output,
|
||||
* so a repo-root JSON file never reaches the container filesystem. Bundling the
|
||||
* remedies as a compiled module guarantees they are always present at runtime
|
||||
* with no path-resolution fragility. The spec explicitly allows "JSON/TS constant".
|
||||
* with no path-resolution fragility.
|
||||
*
|
||||
* Keep this file and `collections/natural_remedies.json` in sync — same `version`,
|
||||
* same remedy slugs, same remedy count. `parseNaturalRemediesFile` (admin/util/conditions.ts)
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ import type { DrugSearchResult } from '../../types/drug_reference.js'
|
|||
|
||||
/**
|
||||
* Module-level merged remedies corpus (fail-soft): the NCCIH herbs plus the
|
||||
* non-herbal home-care measures (CDC/NIH/FDA, issue #23), each entry tagged
|
||||
* with its kind so the UI can badge them apart. Slugs are disjoint between the
|
||||
* two files (validated at curation time).
|
||||
* non-herbal home-care measures (CDC/NIH/FDA), each entry tagged with its kind
|
||||
* so the UI can badge them apart. Slugs are disjoint between the two files
|
||||
* (validated at curation time).
|
||||
*/
|
||||
const HERB_FILE = parseNaturalRemediesFile(NATURAL_REMEDIES_FILE)
|
||||
const HOME_FILE = parseNaturalRemediesFile(HOME_REMEDIES_FILE)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import { BaseSchema } from '@adonisjs/lucid/schema'
|
|||
* the ingest refreshes existing rows in place; no manual purge needed.
|
||||
*
|
||||
* Section text columns use mediumtext (up to 16 MB) so no openFDA section is
|
||||
* truncated. The `searchable_name` varchar(768) stays within InnoDB's index
|
||||
* key-length budget under utf8mb4 (191 chars × 4 bytes = 764 < 767 limit for
|
||||
* a single column; 768 bytes here fits because MySQL counts bytes for the
|
||||
* key-length limit when the column is declared as varchar, not character count
|
||||
* for the prefix approach). If a tighter budget is needed in a utf8mb4_bin
|
||||
* collation, use varchar(191) — but the utf8mb4 default collation is fine.
|
||||
* truncated. The `searchable_name` varchar(768) is indexed (idx_drug_labels_searchable_name).
|
||||
* Under utf8mb4 that column is 768 × 4 = 3072 bytes, which is exactly the InnoDB
|
||||
* index-prefix limit for the DYNAMIC / COMPRESSED row formats (DYNAMIC is the
|
||||
* MySQL 8.0 default). It would overflow the 767-byte limit of the older
|
||||
* REDUNDANT / COMPACT row formats, so this depends on the 8.0 default row format.
|
||||
* If a deployment forces an older row format, drop the column to varchar(191)
|
||||
* (191 × 4 = 764 ≤ 767) to stay within the 767-byte budget.
|
||||
*
|
||||
* The FULLTEXT index is created in a guarded try/catch so a non-InnoDB engine
|
||||
* or an older MySQL version that doesn't support FULLTEXT doesn't break the
|
||||
|
|
@ -49,7 +50,8 @@ export default class extends BaseSchema {
|
|||
table.string('product_type', 32).nullable()
|
||||
|
||||
// Normalized brand+generic blob — computed once at ingest, never on read.
|
||||
// 768 chars stays within InnoDB's utf8mb4 index key-length budget.
|
||||
// 768 chars × 4 bytes (utf8mb4) = 3072 = the InnoDB DYNAMIC/COMPRESSED
|
||||
// index-prefix limit (see header note).
|
||||
table.string('searchable_name', 768).nullable()
|
||||
|
||||
// Section text — mediumtext so even the longest FDA label bodies are stored
|
||||
|
|
@ -95,13 +97,20 @@ export default class extends BaseSchema {
|
|||
// name+indications index (search-by-what-it-treats) is deferred: FULLTEXT
|
||||
// can't take a prefix length, and indexing the full mediumtext body adds heavy
|
||||
// index weight v1 doesn't use.
|
||||
try {
|
||||
await this.db.rawQuery(
|
||||
`ALTER TABLE drug_labels ADD FULLTEXT INDEX ft_drug_labels_name (searchable_name)`
|
||||
)
|
||||
} catch {
|
||||
// Non-InnoDB or FULLTEXT unsupported — search falls back to LIKE.
|
||||
}
|
||||
//
|
||||
// Deferred so it runs AFTER createTable executes — Lucid's schema builder
|
||||
// is deferred, so a bare ALTER here would hit a not-yet-created table. The
|
||||
// this.defer(db => …) pattern (see 1775100000001_create_custom_library_sources_table.ts)
|
||||
// queues it to run on the live connection once the table exists.
|
||||
this.defer(async (db) => {
|
||||
try {
|
||||
await db.rawQuery(
|
||||
`ALTER TABLE drug_labels ADD FULLTEXT INDEX ft_drug_labels_name (searchable_name)`
|
||||
)
|
||||
} catch {
|
||||
// Non-InnoDB or FULLTEXT unsupported — search falls back to LIKE.
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { BaseSchema } from '@adonisjs/lucid/schema'
|
|||
* Design notes:
|
||||
* - FULLTEXT indexes cannot take a column prefix length, so the full
|
||||
* mediumtext body of `indications` is indexed. On ~259k rows this adds
|
||||
* meaningful index weight (tracked in issue #11).
|
||||
* meaningful index weight.
|
||||
* - The guard mirrors the existing ft_drug_labels_name guard in migration
|
||||
* 1778600000004: a non-InnoDB engine or a MySQL version without FULLTEXT
|
||||
* support must not block the migration runner. The indication-search path
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { IconAlertTriangle } from '@tabler/icons-react'
|
|||
* "When to use what" — top-of-page safety banner.
|
||||
*
|
||||
* A prominent amber callout that renders at the TOP of both the condition index
|
||||
* and detail pages. This is a hard ship requirement (per the design spec), not a
|
||||
* footnote: results are FDA label-indication matches, NOT recommendations, NOT
|
||||
* an FDA endorsement, and NOT a drug-interaction checker.
|
||||
* and detail pages: results are FDA label-indication matches, NOT
|
||||
* recommendations, NOT an FDA endorsement, and NOT a drug-interaction checker.
|
||||
* It leads the page (not a footnote) so the caveat is read before any result.
|
||||
*/
|
||||
export default function SafetyBanner() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ function drugKey(d: DrugSearchResult): string {
|
|||
* Once data is loaded: chips + dual-section results, with the FDA-data update control
|
||||
* and source citation at the foot.
|
||||
*/
|
||||
export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions, remedies }: PageProps) {
|
||||
export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions, remedies = [] }: PageProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [productType, setProductType] = useState<string | null>(null)
|
||||
const [route, setRoute] = useState<string | null>(null)
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ export default function DrugReferenceShow({ label, situations = [] }: PageProps)
|
|||
<LabelSection
|
||||
title="Drug Interactions"
|
||||
body={label.drug_interactions}
|
||||
footnote="Single-drug label information — not a cross-drug interaction checker (see issue #9)"
|
||||
footnote="Single-drug label information — not a cross-drug interaction checker"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
* node --experimental-strip-types tests/standalone/conditions.standalone.ts
|
||||
*
|
||||
* Also smoke-checks the shipped curated spine (admin/app/data/conditions.ts)
|
||||
* parses cleanly and stays in the 30–50 range the spec calls for.
|
||||
* parses cleanly and stays a bounded curated list of ~30–50 conditions.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
|
|
@ -276,7 +276,7 @@ check('shipped CONDITIONS_FILE parses cleanly with no dropped entries', () => {
|
|||
assert.equal(parsed.conditions.length, CONDITIONS_FILE.conditions.length)
|
||||
})
|
||||
|
||||
check('shipped spine holds 30–50 curated conditions (spec range)', () => {
|
||||
check('shipped spine holds a bounded curated list of ~30–50 conditions', () => {
|
||||
const n = CONDITIONS_FILE.conditions.length
|
||||
assert.ok(n >= 30 && n <= 50, `expected 30–50 conditions, got ${n}`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ check('every remedy condition slug exists in the conditions spine', () => {
|
|||
)
|
||||
})
|
||||
|
||||
check('shipped NATURAL_REMEDIES_FILE has 18 remedies (spec count)', () => {
|
||||
check('shipped NATURAL_REMEDIES_FILE has the 18 curated remedies', () => {
|
||||
const n = NATURAL_REMEDIES_FILE.remedies.length
|
||||
assert.equal(n, 18, `expected 18 remedies, got ${n}`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,5 +4,5 @@
|
|||
"rootDir": "./",
|
||||
"outDir": "./build"
|
||||
},
|
||||
"exclude": ["./inertia/**/*", "node_modules", "build"]
|
||||
"exclude": ["./inertia/**/*", "tests/standalone/**/*", "node_modules", "build"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* A condition (situation) is a curated first-aid / emergency scenario the user
|
||||
* browses or searches. Each carries `searchTerms` (synonyms) that drive the
|
||||
* FULLTEXT search over `drug_labels.indications` — the same machinery the
|
||||
* Drug Reference indication search (#11) uses.
|
||||
* indication FULLTEXT search uses.
|
||||
*
|
||||
* Phase 1 maps conditions → OTC drugs.
|
||||
* Phase 2 adds natural remedies from NCCIH, resolved against the same condition
|
||||
|
|
@ -78,8 +78,9 @@ export interface NaturalRemedy {
|
|||
/**
|
||||
* Which curated corpus the entry came from. 'herb' = NCCIH herbal fact
|
||||
* sheets; 'self-care' = non-herbal home-care measures from CDC/NIH/FDA pages
|
||||
* (issue #23). Assigned at merge time in condition_service — the JSON files
|
||||
* don't carry it. Optional so older data parses; absent means 'herb'.
|
||||
* (the non-herbal home-care entries). Assigned at merge time in
|
||||
* condition_service — the JSON files don't carry it. Optional so older data
|
||||
* parses; absent means 'herb'.
|
||||
*/
|
||||
kind?: 'herb' | 'self-care'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,8 +165,8 @@ export function situationsForIndications(
|
|||
* searchTerms.
|
||||
*
|
||||
* - Multi-word terms are wrapped in double quotes so they match as a phrase
|
||||
* ("sore throat") rather than as two loose tokens — this tightens precision,
|
||||
* the trade-off the spec calls out for relevance-vs-precision.
|
||||
* ("sore throat") rather than as two loose tokens — this trades a little
|
||||
* recall for precision on multi-word conditions.
|
||||
* - Single-word terms are passed through bare.
|
||||
* - Internal double-quotes inside a term are stripped (they would break the
|
||||
* phrase quoting).
|
||||
|
|
|
|||
Loading…
Reference in New Issue