mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-07-28 03:04:41 +02:00
* feat(drug-reference): offline FDA drug labels, conditions, and remedies
Adds an offline medical-reference feature with three coupled layers:
- Drug Reference: full-text search over openFDA drug-label indications,
a detail page per label, and a side-by-side single-drug comparison view.
A two-phase background pipeline downloads the openFDA label parts to the
storage volume (resumable) and ingests them into the search table.
- Conditions ("When to use what"): a curated spine of first-aid situations
that maps each situation to matching OTC drugs, each linking back to its
Drug Reference detail page.
- Curated remedies: hand-authored natural and home-remedy entries drawn from
US-government public-domain sources (NCCIH, CDC, MedlinePlus, FDA), shown
with their source links and the same safety disclaimers as the rest of the
feature.
The drug-reference and conditions layers are intentionally coupled: the drug
detail page shows the situations a drug treats, and the conditions controller
reads the same drug_labels table.
Safety surfaces ship as written. The amber SafetyBanner ("informational only,
not medical advice, not an FDA endorsement, not a drug-interaction checker, in
an emergency call emergency services") renders on the condition pages and the
search page; the detail and comparison pages carry their own "not a cross-drug
interaction checker" callout; and every page carries the openFDA CC0 source
citation and no-FDA-affiliation footer.
Wiring on this branch:
- start/routes.ts: the /drug-reference and /conditions page GETs plus their
/api/* groups.
- commands/queue/work.ts: the drug-download and drug-ingest queues, both at
concurrency 1. The two drug queues get a per-queue stall override
(lockDuration 1_800_000, maxStalledCount 3) because each part is one long
stream; every other queue keeps the existing 300000 default.
- inertia/pages/home.tsx: Drug Reference and "When to use what" tiles. The
icon and display_order are a starting point, open to change.
- types/kv_store.ts: the two drugReference.* keys the pipeline reads and writes.
- package.json: yauzl and stream-json (plus their @types), used by the ingest
job to stream the label JSON out of the downloaded zips.
The app reads the conditions and remedy data from the compiled TS constants in
app/data/; the repo-root collections/*.json files are the browseable mirrors.
The natural-remedies standalone test reads collections/natural_remedies.json to
assert the two stay in sync, so that file is also a test fixture.
The four standalone tests pass (drug_interactions, drug_ingest_status,
conditions, natural_remedies). tsc reports no errors in the feature code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(drug-reference): drop a fork-internal comment reference
* 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
* fix(drug-reference): make the interaction comparison readable at five drugs
The comparison view laid its columns out on an equal-fraction CSS grid
(repeat(N, minmax(0, 1fr))), so each added drug shrank every column; at the
five-drug maximum the FDA interaction text was squeezed into unreadable slivers.
Lay the columns out with flex instead: full-width and stacked on phones, then
fixed-width columns that scroll sideways from the sm: breakpoint up, so they
never shrink below a readable width. Theme the columns with the same palette as
the rest of the page (they were on stock gray), and give the headers a fixed
min-height so columns line up when drug names wrap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(collections): route a 'dataset' tier resource to the drug pipeline
Add an optional `type` discriminator to SpecResource ('zim' | 'dataset',
absent == 'zim'), so the tier installer can carry a DB-ingested resource
alongside ZIM files. ZimService.downloadCategoryTier branches on it: a
'dataset' resource dispatches the existing FDA download+ingest pipeline
instead of RunDownloadJob, guarded against duplicate dispatch by the drug
ingest status. Every existing manifest entry has no `type` and keeps the
exact ZIM path.
Widen InstalledResource.resource_type to include 'dataset' and exclude
dataset rows from the ZIM/map catalog-update scan (datasets aren't
filename-versioned; their freshness path is separate). No dataset rows are
written yet: the InstalledResource 'dataset' row on ingest-ready, the
manifest entry, install-gating, and the downloads-aggregator integration
are follow-up commits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): rework into an opt-in medicine-standard tier dataset
Reshapes the offline FDA drug reference from a built-in feature into curated
content installed by selecting the Medicine / Standard tier, per maintainer
direction.
- install-state: the ingest writes an installed_resources 'dataset' row on
ready (version = the openFDA export_date), threaded installer to download to
ingest; the tier-status math and the home-tile gate read it. Manual ingests
write no row, so install-state stays tied to the curated path.
- manifest: declare the dataset in the medicine-standard tier (runtime fetches
the remote manifest, so this also needs to land upstream).
- install-gating: the drug-reference home tiles render only when installed.
- uninstall: DrugReferenceService.uninstall() stops the two drug queues, deletes
the on-disk parts, truncates drug_labels (schema kept), clears the KV markers,
and drops the install row. Best-effort, logged, scoped to drug data only.
- downloads: the download phase reports the canonical {percent, downloadedBytes,
totalBytes} shape as one drug-data card in the Active Downloads aggregator with
cancel/remove; the heavy ingest stays in the IngestStatus surface with an
Indexing handoff on the card.
- auto-update: a daily DrugAutoUpdateJob compares the manifest export_date and
re-downloads when newer, gated on installed + no active job.
typecheck clean; the drug standalone suites pass. Three points are flagged in
code for the maintainer: the InstalledResource 'dataset' approach, the tier
home, and the export_date string format.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(downloads): keep the drug download card live across all parts
The Active Downloads card filtered to the deterministic jobId, but the
download's continuations run under auto-generated jobIds (only part 0 uses the
deterministic one). So the card tracked part 0 and then vanished while parts
2..N kept downloading. The queue is concurrency 1, so collapse to whichever
single part is in flight and report the deterministic jobId: one card tracks
aggregate progress through the whole download and cancel/remove still routes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): co-locate a persistent safety note with affirmative remedy guidance
Add RemedySafetyNote at the head of every natural-remedy section — the two on
Drug Reference and the one on "When to use what" — so the "informational only,
not medical advice, seek real medical care in an emergency" framing appears with
the guidance itself, not only in the page-top banner. Replaces the terse
per-section caveat with the same amber alert language as SafetyBanner.
Addresses the upstream #1040 review request that the disclaimer be unmistakable
and present wherever affirmative self-care guidance appears, not a one-time banner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drug-reference): fold dataset freshness into the content-auto-update path
The drug dataset auto-updated on its own daily cron that ignored the
contentAutoUpdate.* master switch, so it would refresh even with content
auto-update turned off, and it didn't ride the content-update path the way
ZIMs and maps do.
Move the export_date freshness/apply orchestration onto
DrugReferenceService.attemptAutoUpdate(), add
ContentAutoUpdateService.attemptDrugDataset() gated on the same enabled +
window config, and have the hourly ContentAutoUpdateJob drive both. Retire
the standalone DrugAutoUpdateJob. The ZIM/map attempt() path is unchanged.
Addresses the upstream #1040 request to wire the openFDA export_date check
into the content updater so the dataset updates alongside ZIMs and maps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): strengthen the remedy note to consult a clinician before combining with meds
Widen the affirmative-remedy safety note from "talk to a clinician before use"
to explicitly cover using a remedy AND combining one with a medication the user
already takes — the interaction case is the higher-risk path for an off-grid
user self-treating.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): gate affirmative remedy content behind an off-by-default flag
Add drugReference.remediesEnabled (default off), independent of the tier
install. When off, the server emits no remedy data at any boundary — the
drug-reference page prop, conditions show, and the /api/conditions/drugs
situation search — and the "Natural" filter is hidden, so installing the
medicine-standard tier lights up the verbatim FDA label search and the
condition-to-OTC matching but not the hand-authored self-care and herbal
sections. No user-facing toggle: it is flipped on after a clinician content-pass.
Implements the upstream #1040 split-by-risk request: the regulated label content
ships with the tier; the authored remedy guidance stays gated until sign-off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jake Turner <52841588+jakeaturner@users.noreply.github.com>
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
|
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
|
import { Worker } from 'bullmq'
|
|
import queueConfig from '#config/queue'
|
|
import { RunDownloadJob } from '#jobs/run_download_job'
|
|
import { RunExtractPmtilesJob } from '#jobs/run_extract_pmtiles_job'
|
|
import { DownloadModelJob } from '#jobs/download_model_job'
|
|
import { RunBenchmarkJob } from '#jobs/run_benchmark_job'
|
|
import { EmbedFileJob } from '#jobs/embed_file_job'
|
|
import { CheckUpdateJob } from '#jobs/check_update_job'
|
|
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
|
|
import { AutoUpdateJob } from '#jobs/auto_update_job'
|
|
import { AppAutoUpdateJob } from '#jobs/app_auto_update_job'
|
|
import { ContentAutoUpdateJob } from '#jobs/content_auto_update_job'
|
|
import { DownloadDrugDataJob } from '#jobs/download_drug_data_job'
|
|
import { IngestDrugDataJob } from '#jobs/ingest_drug_data_job'
|
|
|
|
export default class QueueWork extends BaseCommand {
|
|
static commandName = 'queue:work'
|
|
static description = 'Start processing jobs from the queue'
|
|
|
|
@flags.string({ description: 'Queue name to process' })
|
|
declare queue: string
|
|
|
|
@flags.boolean({ description: 'Process all queues automatically' })
|
|
declare all: boolean
|
|
|
|
static options: CommandOptions = {
|
|
startApp: true,
|
|
staysAlive: true,
|
|
}
|
|
|
|
async run() {
|
|
// Validate that either --queue or --all is provided
|
|
if (!this.queue && !this.all) {
|
|
this.logger.error('You must specify either --queue=<name> or --all')
|
|
process.exit(1)
|
|
}
|
|
|
|
if (this.queue && this.all) {
|
|
this.logger.error('Cannot specify both --queue and --all flags')
|
|
process.exit(1)
|
|
}
|
|
|
|
const [jobHandlers, allQueues] = await this.loadJobHandlers()
|
|
|
|
// Determine which queues to process
|
|
const queuesToProcess = this.all ? Array.from(allQueues.values()) : [this.queue]
|
|
|
|
this.logger.info(`Starting workers for queues: ${queuesToProcess.join(', ')}`)
|
|
|
|
const workers: Worker[] = []
|
|
|
|
// Create a worker for each queue
|
|
for (const queueName of queuesToProcess) {
|
|
const stall = this.getStallOptionsForQueue(queueName)
|
|
const worker = new Worker(
|
|
queueName,
|
|
async (job) => {
|
|
this.logger.info(`[${queueName}] Processing job: ${job.id} of type: ${job.name}`)
|
|
const jobHandler = jobHandlers.get(job.name)
|
|
if (!jobHandler) {
|
|
throw new Error(`No handler found for job: ${job.name}`)
|
|
}
|
|
|
|
return await jobHandler.handle(job)
|
|
},
|
|
{
|
|
connection: queueConfig.connection,
|
|
concurrency: this.getConcurrencyForQueue(queueName),
|
|
// lockDuration/maxStalledCount are per-queue. Non-drug queues keep
|
|
// the existing default (300000, BullMQ's default maxStalledCount).
|
|
// The drug download/ingest queues are NEW per-queue overrides
|
|
// (1_800_000 / 3) — see getStallOptionsForQueue — not a change to
|
|
// the default applied to every other queue.
|
|
lockDuration: stall.lockDuration,
|
|
...(stall.maxStalledCount !== undefined
|
|
? { maxStalledCount: stall.maxStalledCount }
|
|
: {}),
|
|
autorun: true,
|
|
}
|
|
)
|
|
|
|
// Required to prevent Node from treating BullMQ internal errors as unhandled
|
|
// EventEmitter errors that crash the process.
|
|
worker.on('error', (err) => {
|
|
this.logger.error(`[${queueName}] Worker error: ${err.message}`)
|
|
})
|
|
|
|
worker.on('failed', async (job, err) => {
|
|
this.logger.error(`[${queueName}] Job failed: ${job?.id}, Error: ${err.message}`)
|
|
|
|
// If this was a Wikipedia download, mark it as failed in the DB
|
|
if (job?.data?.filetype === 'zim' && job?.data?.url?.includes('wikipedia_en_')) {
|
|
try {
|
|
const { DockerService } = await import('#services/docker_service')
|
|
const { ZimService } = await import('#services/zim_service')
|
|
const dockerService = new DockerService()
|
|
const zimService = new ZimService(dockerService)
|
|
await zimService.onWikipediaDownloadComplete(job.data.url, false)
|
|
} catch (e: any) {
|
|
this.logger.error(
|
|
`[${queueName}] Failed to update Wikipedia status: ${e.message}`
|
|
)
|
|
}
|
|
}
|
|
|
|
// Terminal failure of an AUTO content update → advance that resource's
|
|
// backoff (self-disables after MAX_CONSECUTIVE_FAILURES). BullMQ emits
|
|
// `failed` on every attempt, so gate on the final attempt to count each
|
|
// doomed download once, not once per retry. Manual downloads (auto !== true)
|
|
// are deliberately excluded.
|
|
const meta = job?.data?.resourceMetadata
|
|
const isTerminal = (job?.attemptsMade ?? 0) >= (job?.opts?.attempts ?? 1)
|
|
if (job?.name === RunDownloadJob.key && meta?.auto === true && isTerminal) {
|
|
try {
|
|
const { default: InstalledResource } = await import('#models/installed_resource')
|
|
const { recordResourceUpdateFailure } = await import(
|
|
'../../app/utils/content_auto_update_backoff.js'
|
|
)
|
|
const resource = await InstalledResource.query()
|
|
.where('resource_id', meta.resource_id)
|
|
.where('resource_type', job.data.filetype)
|
|
.first()
|
|
if (resource) {
|
|
await recordResourceUpdateFailure(
|
|
resource,
|
|
err instanceof Error ? err.message : String(err)
|
|
)
|
|
}
|
|
} catch (e: any) {
|
|
this.logger.error(
|
|
`[${queueName}] Failed to record content auto-update backoff: ${e.message}`
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
worker.on('completed', (job) => {
|
|
this.logger.info(`[${queueName}] Job completed: ${job.id}`)
|
|
})
|
|
|
|
workers.push(worker)
|
|
this.logger.info(`Worker started for queue: ${queueName}`)
|
|
}
|
|
|
|
// Schedule nightly update checks (idempotent, will persist over restarts)
|
|
await CheckUpdateJob.scheduleNightly()
|
|
await CheckServiceUpdatesJob.scheduleNightly()
|
|
await AutoUpdateJob.schedule()
|
|
await AppAutoUpdateJob.schedule()
|
|
await ContentAutoUpdateJob.schedule()
|
|
|
|
// Safety net: log unhandled rejections instead of crashing the worker process.
|
|
// Individual job errors are already caught by BullMQ; this catches anything that
|
|
// escapes (e.g. a fire-and-forget promise in a callback that rejects unexpectedly).
|
|
process.on('unhandledRejection', (reason) => {
|
|
this.logger.error(
|
|
`Unhandled promise rejection in worker process: ${reason instanceof Error ? reason.message : String(reason)}`
|
|
)
|
|
})
|
|
|
|
// Graceful shutdown for all workers
|
|
process.on('SIGTERM', async () => {
|
|
this.logger.info('SIGTERM received. Shutting down workers...')
|
|
await Promise.all(workers.map((worker) => worker.close()))
|
|
this.logger.info('All workers shut down gracefully.')
|
|
process.exit(0)
|
|
})
|
|
}
|
|
|
|
private async loadJobHandlers(): Promise<[Map<string, any>, Map<string, string>]> {
|
|
const handlers = new Map<string, any>()
|
|
const queues = new Map<string, string>()
|
|
|
|
handlers.set(RunDownloadJob.key, new RunDownloadJob())
|
|
handlers.set(RunExtractPmtilesJob.key, new RunExtractPmtilesJob())
|
|
handlers.set(DownloadModelJob.key, new DownloadModelJob())
|
|
handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob())
|
|
handlers.set(EmbedFileJob.key, new EmbedFileJob())
|
|
handlers.set(CheckUpdateJob.key, new CheckUpdateJob())
|
|
handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob())
|
|
handlers.set(AutoUpdateJob.key, new AutoUpdateJob())
|
|
handlers.set(AppAutoUpdateJob.key, new AppAutoUpdateJob())
|
|
handlers.set(ContentAutoUpdateJob.key, new ContentAutoUpdateJob())
|
|
handlers.set(DownloadDrugDataJob.key, new DownloadDrugDataJob())
|
|
handlers.set(IngestDrugDataJob.key, new IngestDrugDataJob())
|
|
|
|
queues.set(RunDownloadJob.key, RunDownloadJob.queue)
|
|
queues.set(RunExtractPmtilesJob.key, RunExtractPmtilesJob.queue)
|
|
queues.set(DownloadModelJob.key, DownloadModelJob.queue)
|
|
queues.set(RunBenchmarkJob.key, RunBenchmarkJob.queue)
|
|
queues.set(EmbedFileJob.key, EmbedFileJob.queue)
|
|
queues.set(CheckUpdateJob.key, CheckUpdateJob.queue)
|
|
queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue)
|
|
queues.set(AutoUpdateJob.key, AutoUpdateJob.queue)
|
|
queues.set(AppAutoUpdateJob.key, AppAutoUpdateJob.queue)
|
|
queues.set(ContentAutoUpdateJob.key, ContentAutoUpdateJob.queue)
|
|
queues.set(DownloadDrugDataJob.key, DownloadDrugDataJob.queue)
|
|
queues.set(IngestDrugDataJob.key, IngestDrugDataJob.queue)
|
|
|
|
return [handlers, queues]
|
|
}
|
|
|
|
/**
|
|
* Per-queue BullMQ stall-recovery options.
|
|
*
|
|
* Every queue except the two drug queues keeps the branch default
|
|
* (lockDuration 300000, and BullMQ's default maxStalledCount of 1 — left
|
|
* unset). The drug download/ingest queues are the ONLY per-queue override:
|
|
* each part is a long single stream (a ~150 MB resumable HTTP pull, then an
|
|
* unzip + JSON-stream ingest at concurrency 1), so a longer lock plus a
|
|
* higher stalled tolerance keeps a transient lock-renewal miss from killing
|
|
* the continuation chain ("job stalled more than allowable limit").
|
|
*/
|
|
private getStallOptionsForQueue(
|
|
queueName: string
|
|
): { lockDuration: number; maxStalledCount?: number } {
|
|
if (
|
|
queueName === DownloadDrugDataJob.queue ||
|
|
queueName === IngestDrugDataJob.queue
|
|
) {
|
|
return { lockDuration: 1_800_000, maxStalledCount: 3 }
|
|
}
|
|
return { lockDuration: 300000 }
|
|
}
|
|
|
|
/**
|
|
* Get concurrency setting for a specific queue
|
|
* Can be customized per queue based on workload characteristics
|
|
*/
|
|
private getConcurrencyForQueue(queueName: string): number {
|
|
const concurrencyMap: Record<string, number> = {
|
|
[RunDownloadJob.queue]: 3,
|
|
// pmtiles extract hits the Protomaps CDN with many parallel range reads per job;
|
|
// cap concurrency at 2 so a second extract doesn't starve the first.
|
|
[RunExtractPmtilesJob.queue]: 2,
|
|
[DownloadModelJob.queue]: 2, // Lower concurrency for resource-intensive model downloads
|
|
[RunBenchmarkJob.queue]: 1, // Run benchmarks one at a time for accurate results
|
|
[EmbedFileJob.queue]: 2, // Lower concurrency for embedding jobs, can be resource intensive
|
|
[CheckUpdateJob.queue]: 1, // No need to run more than one update check at a time
|
|
// Drug download: one part at a time — a ~150 MB resumable HTTP pull per
|
|
// part, no benefit to parallelism and easier on the storage volume.
|
|
[DownloadDrugDataJob.queue]: 1,
|
|
// Drug ingest: one heavy stream at a time — unzipping + parsing ~150 MB
|
|
// of JSON into batched DB inserts; serial keeps memory bounded.
|
|
[IngestDrugDataJob.queue]: 1,
|
|
default: 3,
|
|
}
|
|
|
|
return concurrencyMap[queueName] || concurrencyMap.default
|
|
}
|
|
}
|