@fnndsc/brasa 0.16.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/index.js +1 -0
- package/dist/api/index.js.map +1 -1
- package/dist/buildinfo.json +1 -1
- package/dist/builtins/fs/acl.js +57 -1
- package/dist/builtins/fs/acl.js.map +1 -1
- package/dist/builtins/fs/cat.js +46 -9
- package/dist/builtins/fs/cat.js.map +1 -1
- package/dist/builtins/fs/cp.js +42 -2
- package/dist/builtins/fs/cp.js.map +1 -1
- package/dist/builtins/fs/destination.d.ts +52 -0
- package/dist/builtins/fs/destination.js +52 -0
- package/dist/builtins/fs/destination.js.map +1 -0
- package/dist/builtins/fs/download.js +5 -2
- package/dist/builtins/fs/download.js.map +1 -1
- package/dist/builtins/fs/mv.js +41 -1
- package/dist/builtins/fs/mv.js.map +1 -1
- package/dist/builtins/fs/rm.d.ts +6 -0
- package/dist/builtins/fs/rm.js +35 -17
- package/dist/builtins/fs/rm.js.map +1 -1
- package/dist/builtins/fs/upload.js +15 -4
- package/dist/builtins/fs/upload.js.map +1 -1
- package/dist/builtins/help.js +43 -6
- package/dist/builtins/help.js.map +1 -1
- package/dist/builtins/net/pacs.js +37 -1
- package/dist/builtins/net/pacs.js.map +1 -1
- package/dist/builtins/net/query.csv.d.ts +73 -0
- package/dist/builtins/net/query.csv.js +267 -0
- package/dist/builtins/net/query.csv.js.map +1 -0
- package/dist/builtins/net/query.d.ts +28 -1
- package/dist/builtins/net/query.fanout.d.ts +92 -0
- package/dist/builtins/net/query.fanout.js +171 -0
- package/dist/builtins/net/query.fanout.js.map +1 -0
- package/dist/builtins/net/query.js +709 -20
- package/dist/builtins/net/query.js.map +1 -1
- package/dist/builtins/net/status.js +4 -1
- package/dist/builtins/net/status.js.map +1 -1
- package/dist/builtins/proc.js +36 -6
- package/dist/builtins/proc.js.map +1 -1
- package/dist/builtins/procWatch.js +42 -2
- package/dist/builtins/procWatch.js.map +1 -1
- package/dist/builtins/res/feed.diagram.d.ts +13 -0
- package/dist/builtins/res/feed.diagram.js +34 -3
- package/dist/builtins/res/feed.diagram.js.map +1 -1
- package/dist/builtins/res/feed.js +43 -2
- package/dist/builtins/res/feed.js.map +1 -1
- package/dist/builtins/res/feed.tree.js +4 -1
- package/dist/builtins/res/feed.tree.js.map +1 -1
- package/dist/builtins/res/plugin.info.d.ts +32 -0
- package/dist/builtins/res/plugin.info.js +167 -0
- package/dist/builtins/res/plugin.info.js.map +1 -0
- package/dist/builtins/res/plugin.js +41 -1
- package/dist/builtins/res/plugin.js.map +1 -1
- package/dist/core/engine.d.ts +21 -0
- package/dist/core/engine.js +27 -0
- package/dist/core/engine.js.map +1 -1
- package/dist/core/promptContext.d.ts +1 -1
- package/dist/core/promptContext.js +9 -3
- package/dist/core/promptContext.js.map +1 -1
- package/dist/core/question.d.ts +27 -0
- package/dist/core/question.js +38 -0
- package/dist/core/question.js.map +1 -1
- package/dist/core/surface.d.ts +16 -3
- package/dist/core/surface.js.map +1 -1
- package/dist/lib/vfs/providers/static_content.js +10 -81
- package/dist/lib/vfs/providers/static_content.js.map +1 -1
- package/dist/lib/vfs/vfs.js +13 -1
- package/dist/lib/vfs/vfs.js.map +1 -1
- package/package.json +5 -5
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
11
|
import chalk from 'chalk';
|
|
12
|
-
import { errorStack, chrisContext, Context, pacsQuery_get, pacsQuery_resultDecode, pacsQueries_create, pacsServers_list, envelope_ok, envelope_error, listCache_get, } from '@fnndsc/cumin';
|
|
12
|
+
import { errorStack, chrisContext, Context, pacsQuery_get, pacsQuery_resultDecode, pacsQueries_create, pacsServers_list, envelope_ok, envelope_error, listCache_get, queryIndex_get, } from '@fnndsc/cumin';
|
|
13
13
|
import { queryFolderName_build } from '@fnndsc/salsa';
|
|
14
|
-
import { PACS_QUERY_MODEL_KIND } from '@fnndsc/menu';
|
|
14
|
+
import { PACS_QUERY_MODEL_KIND, } from '@fnndsc/menu';
|
|
15
|
+
import { PATIENT_KEY, QUERY_COHORT_MAX, QUERY_INLINE_FANOUT_MAX, fanout_permit, patients_read, queryTerms_expand, queryTerms_parse, } from './query.fanout.js';
|
|
16
|
+
import { sink_get } from '../../core/sink.js';
|
|
17
|
+
import { csvFile_write, pacsAnswer_toCsv } from './query.csv.js';
|
|
15
18
|
import { series_cubePathGet } from './pacsUtils.js';
|
|
16
19
|
import { screen } from '@fnndsc/chili/screen/screen.js';
|
|
17
20
|
import { spinner } from '../../lib/spinner.js';
|
|
@@ -91,7 +94,12 @@ export async function pacsQuery_createAndWait(queryExpr, title, pacsserver, onSt
|
|
|
91
94
|
// so an immediate `ls` shows the query instead of a stale directory.
|
|
92
95
|
listCache_get().cache_invalidate('/net/pacs/queries');
|
|
93
96
|
const queryId = createResult.value.id;
|
|
94
|
-
|
|
97
|
+
// CUBE does not always name the owner on the create response, and the
|
|
98
|
+
// index is keyed by it — a query filed under nobody is a query the next
|
|
99
|
+
// identical ask cannot find. The session's own identity is the fallback,
|
|
100
|
+
// since it is who just asked.
|
|
101
|
+
const ownerUsername = (typeof createResult.value.owner_username === 'string' ? createResult.value.owner_username : undefined)
|
|
102
|
+
?? (await askingIdentity_get()) ?? undefined;
|
|
95
103
|
const vfsPath = queryVfsPath_build(queryId, queryObj, ownerUsername);
|
|
96
104
|
const deadline = Date.now() + QUERY_TIMEOUT_MS;
|
|
97
105
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
@@ -102,6 +110,7 @@ export async function pacsQuery_createAndWait(queryExpr, title, pacsserver, onSt
|
|
|
102
110
|
onStatus?.(`Query ${queryId} — ${status}`);
|
|
103
111
|
const decodeResult = await pacsQuery_resultDecode(queryId);
|
|
104
112
|
if (decodeResult.ok && decodeResult.value.json !== undefined) {
|
|
113
|
+
queryIndex_file(queryId, queryObj, await pacsIdentifier_resolve(pacsserver), ownerUsername, true);
|
|
105
114
|
return { queryId, vfsPath, decoded: decodeResult.value };
|
|
106
115
|
}
|
|
107
116
|
if (!decodeResult.ok) {
|
|
@@ -113,6 +122,9 @@ export async function pacsQuery_createAndWait(queryExpr, title, pacsserver, onSt
|
|
|
113
122
|
// A succeeded query with no stored payload means the PACS matched
|
|
114
123
|
// nothing; a couple of grace polls cover result-write lag.
|
|
115
124
|
if (status === 'succeeded' && ++succeededWithoutPayload >= 2) {
|
|
125
|
+
// Filed as a no-hit. A replay must know this question was asked and
|
|
126
|
+
// found nothing, so it can decline to serve the emptiness back.
|
|
127
|
+
queryIndex_file(queryId, queryObj, await pacsIdentifier_resolve(pacsserver), ownerUsername, false);
|
|
116
128
|
return { queryId, vfsPath, decoded: { raw: '' } };
|
|
117
129
|
}
|
|
118
130
|
await sleep(QUERY_POLL_INTERVAL_MS);
|
|
@@ -120,6 +132,30 @@ export async function pacsQuery_createAndWait(queryExpr, title, pacsserver, onSt
|
|
|
120
132
|
errorStack.stack_push('error', `query: Timed out waiting for query ${queryId} result.`);
|
|
121
133
|
return null;
|
|
122
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Files a finished query in the replay index.
|
|
137
|
+
*
|
|
138
|
+
* Every query mise runs indexes itself, so the next identical question is
|
|
139
|
+
* answered without waiting for the background sweep to have walked this far
|
|
140
|
+
* back. The server is recorded because the same criteria asked of a
|
|
141
|
+
* different PACS is a different question.
|
|
142
|
+
*
|
|
143
|
+
* @param queryId - The stored query's id.
|
|
144
|
+
* @param criteria - The criteria as asked.
|
|
145
|
+
* @param server - The PACS identifier it was asked of.
|
|
146
|
+
* @param owner - Who asked, when CUBE said.
|
|
147
|
+
* @param hasResult - Whether it found anything.
|
|
148
|
+
*/
|
|
149
|
+
function queryIndex_file(queryId, criteria, server, owner, hasResult) {
|
|
150
|
+
queryIndex_get().entry_note({
|
|
151
|
+
queryId,
|
|
152
|
+
server,
|
|
153
|
+
criteria,
|
|
154
|
+
owner: owner ?? '',
|
|
155
|
+
answeredAt: new Date().toISOString(),
|
|
156
|
+
hasResult,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
123
159
|
/**
|
|
124
160
|
* Renders decoded PACS query JSON into a human-readable study/series summary.
|
|
125
161
|
*
|
|
@@ -323,6 +359,494 @@ async function modelPulledState_fill(model) {
|
|
|
323
359
|
}));
|
|
324
360
|
}
|
|
325
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Who is asking, as the index keys it.
|
|
364
|
+
*
|
|
365
|
+
* `current_get(ChRISuser)` reads a per-session context snapshot, and a
|
|
366
|
+
* surface attached to a running calypso does not necessarily have one —
|
|
367
|
+
* which silently keyed every lookup under an empty owner and made the
|
|
368
|
+
* whole stored back-catalogue unreachable from argus while chell found it
|
|
369
|
+
* perfectly. `ChRISuser_get` reads the authenticated login instead, which
|
|
370
|
+
* is the same thing the sweep files records under.
|
|
371
|
+
*
|
|
372
|
+
* @returns The CUBE username, or an empty string when nobody is logged in.
|
|
373
|
+
*/
|
|
374
|
+
async function askingIdentity_get() {
|
|
375
|
+
return (await chrisContext.ChRISuser_get()) ?? '';
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* What each way of naming a server resolves to. Server identifiers do not
|
|
379
|
+
* change under a session, and this is on the path of every query.
|
|
380
|
+
*/
|
|
381
|
+
const pacsIdentifiers = new Map();
|
|
382
|
+
/**
|
|
383
|
+
* Resolves whatever names a PACS server into its canonical identifier.
|
|
384
|
+
*
|
|
385
|
+
* The context may hold a numeric id, a `--pacsserver` may be either, and
|
|
386
|
+
* CUBE files a query under the identifier. The index is keyed on what CUBE
|
|
387
|
+
* stores, so both the write and the lookup have to speak that.
|
|
388
|
+
*
|
|
389
|
+
* @param pacsserver - An id or an identifier.
|
|
390
|
+
* @returns The identifier, or the input unchanged when it cannot be resolved.
|
|
391
|
+
*/
|
|
392
|
+
export async function pacsIdentifier_resolve(pacsserver) {
|
|
393
|
+
const remembered = pacsIdentifiers.get(pacsserver);
|
|
394
|
+
if (remembered !== undefined)
|
|
395
|
+
return remembered;
|
|
396
|
+
const servers = await pacsServers_list();
|
|
397
|
+
// Best effort by design: this only decides what a lookup is filed under,
|
|
398
|
+
// so an unreachable or unhelpful CUBE must never fail the query itself.
|
|
399
|
+
if (!servers?.ok)
|
|
400
|
+
return pacsserver;
|
|
401
|
+
let resolved = pacsserver;
|
|
402
|
+
for (const server of servers.value) {
|
|
403
|
+
if (server.identifier === pacsserver) {
|
|
404
|
+
resolved = pacsserver;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
if (String(server.id) === pacsserver && server.identifier) {
|
|
408
|
+
resolved = server.identifier;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
pacsIdentifiers.set(pacsserver, resolved);
|
|
413
|
+
return resolved;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Says how long ago something was answered, in the coarsest true unit.
|
|
417
|
+
*
|
|
418
|
+
* A replay states its age because mise refuses to decide staleness on the
|
|
419
|
+
* operator's behalf: an accession names a study that will not change, an
|
|
420
|
+
* MRN can gain one tomorrow, and no heuristic tells those apart reliably.
|
|
421
|
+
*
|
|
422
|
+
* @param at - An ISO timestamp.
|
|
423
|
+
* @returns A phrase like `3 months ago`, or null when the stamp is unusable.
|
|
424
|
+
*/
|
|
425
|
+
export function age_describe(at) {
|
|
426
|
+
const then = Date.parse(at);
|
|
427
|
+
if (Number.isNaN(then))
|
|
428
|
+
return null;
|
|
429
|
+
const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
|
|
430
|
+
const units = [
|
|
431
|
+
{ limit: 60, size: 1, name: 'second' },
|
|
432
|
+
{ limit: 3600, size: 60, name: 'minute' },
|
|
433
|
+
{ limit: 86400, size: 3600, name: 'hour' },
|
|
434
|
+
{ limit: 2592000, size: 86400, name: 'day' },
|
|
435
|
+
{ limit: 31536000, size: 2592000, name: 'month' },
|
|
436
|
+
{ limit: Infinity, size: 31536000, name: 'year' },
|
|
437
|
+
];
|
|
438
|
+
for (const unit of units) {
|
|
439
|
+
if (seconds >= unit.limit)
|
|
440
|
+
continue;
|
|
441
|
+
const count = Math.max(1, Math.floor(seconds / unit.size));
|
|
442
|
+
return `${count} ${unit.name}${count === 1 ? '' : 's'} ago`;
|
|
443
|
+
}
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Serves a stored answer when this exact question already has one.
|
|
448
|
+
*
|
|
449
|
+
* The rules were settled in #379 and each earns its place:
|
|
450
|
+
*
|
|
451
|
+
* - the criteria, the server AND the asking identity must match, because
|
|
452
|
+
* another identity's answer is not an answer to this question;
|
|
453
|
+
* - a query that found NOTHING is never replayed. A hit is evidence that
|
|
454
|
+
* persists — the study existed and still does — while an absence decays,
|
|
455
|
+
* and "no imaging found" is the answer a clinician acts on;
|
|
456
|
+
* - the index is advisory, so a hit is confirmed by actually decoding the
|
|
457
|
+
* stored result. Anything that fails — the query deleted, the payload
|
|
458
|
+
* unreadable — drops the entry and falls through to a fresh query.
|
|
459
|
+
*
|
|
460
|
+
* @param criteria - The criteria as asked.
|
|
461
|
+
* @param identifier - The PACS being asked.
|
|
462
|
+
* @param owner - The identity asking.
|
|
463
|
+
* @returns The stored answer and its age, or null to ask the PACS.
|
|
464
|
+
*/
|
|
465
|
+
async function replay_attempt(criteria, identifier, owner) {
|
|
466
|
+
const entry = queryIndex_get().entry_find(criteria, identifier, owner);
|
|
467
|
+
if (entry === null || !entry.hasResult)
|
|
468
|
+
return null;
|
|
469
|
+
const decoded = await pacsQuery_resultDecode(entry.queryId);
|
|
470
|
+
if (!decoded.ok || decoded.value.json === undefined) {
|
|
471
|
+
// The index promised something CUBE no longer has. Forget it and ask.
|
|
472
|
+
errorStack.stack_pop();
|
|
473
|
+
queryIndex_get().entry_drop(criteria, identifier, owner);
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
result: {
|
|
478
|
+
queryId: entry.queryId,
|
|
479
|
+
vfsPath: queryVfsPath_build(entry.queryId, entry.criteria, entry.owner || undefined),
|
|
480
|
+
decoded: decoded.value,
|
|
481
|
+
},
|
|
482
|
+
answeredAt: entry.answeredAt,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Reads a JSON expression as single-valued terms.
|
|
487
|
+
*
|
|
488
|
+
* The JSON form predates multi-value and stays what it was: one question,
|
|
489
|
+
* spelled as an object.
|
|
490
|
+
*
|
|
491
|
+
* @param expr - The expression, starting with `{`.
|
|
492
|
+
* @returns Terms with one value each, or null when the JSON is not an object.
|
|
493
|
+
*/
|
|
494
|
+
function termsFromJson_read(expr) {
|
|
495
|
+
const parsed = queryExpr_parse(expr);
|
|
496
|
+
if (parsed === null)
|
|
497
|
+
return null;
|
|
498
|
+
return Object.fromEntries(Object.entries(parsed).map(([key, value]) => [key, [value]]));
|
|
499
|
+
}
|
|
500
|
+
/** How many patient queries are in flight at once. */
|
|
501
|
+
const QUERY_FANOUT_CONCURRENCY = 4;
|
|
502
|
+
/**
|
|
503
|
+
* Renders criteria back into the expression syntax they were parsed from,
|
|
504
|
+
* so a single question inside a fan-out is asked exactly as one typed by
|
|
505
|
+
* hand — the create path takes an expression, not a record.
|
|
506
|
+
*
|
|
507
|
+
* @param criteria - One question's criteria.
|
|
508
|
+
* @returns The `Key:Value,Key:Value` form.
|
|
509
|
+
*/
|
|
510
|
+
function criteria_toExpression(criteria) {
|
|
511
|
+
return Object.entries(criteria)
|
|
512
|
+
.map(([key, value]) => `${key}:${value}`)
|
|
513
|
+
.join(',');
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Asks one question, replaying it when the same question has been asked
|
|
517
|
+
* before.
|
|
518
|
+
*
|
|
519
|
+
* @param ask - The question.
|
|
520
|
+
* @param title - Title for the PACSQuery record this may create.
|
|
521
|
+
* @param pacsserver - The server to ask.
|
|
522
|
+
* @param identifier - That server as the replay index keys it.
|
|
523
|
+
* @param owner - The identity asking.
|
|
524
|
+
* @param fresh - True to skip the stored answer and trouble the PACS.
|
|
525
|
+
* @returns What came back, including the reason when nothing did.
|
|
526
|
+
*/
|
|
527
|
+
async function ask_run(ask, title, owner, fresh) {
|
|
528
|
+
if (!fresh) {
|
|
529
|
+
const replayed = await replay_attempt(ask.criteria, ask.identifier, owner);
|
|
530
|
+
if (replayed !== null) {
|
|
531
|
+
return { ask, result: replayed.result, answeredAt: replayed.answeredAt };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const checkpoint = errorStack.checkpoint_mark();
|
|
535
|
+
const expression = criteria_toExpression(ask.criteria);
|
|
536
|
+
// Each question gets its own title. CUBE refuses a second PACSQuery with
|
|
537
|
+
// a title it already holds for that server — measured live, where a
|
|
538
|
+
// fan-out under one title had every question after the first come back
|
|
539
|
+
// "You have already registered a PACS query with title=..." — so a
|
|
540
|
+
// cohort sharing one title would report a room full of unasked patients.
|
|
541
|
+
const result = await pacsQuery_createAndWait(expression, `${title} · ${expression}`, ask.server);
|
|
542
|
+
if (result !== null)
|
|
543
|
+
return { ask, result, answeredAt: null };
|
|
544
|
+
// A question that could not be asked has told us nothing. Its reason is
|
|
545
|
+
// carried onto the row rather than left on the stack, where a later
|
|
546
|
+
// question's success would bury it.
|
|
547
|
+
const reasons = errorStack.checkpoint_drain(checkpoint);
|
|
548
|
+
const reason = reasons[reasons.length - 1]?.message;
|
|
549
|
+
// Stripped where the reason becomes data: this message is read in a
|
|
550
|
+
// table and on a graphical surface, and `[pacsServer_resolve ] |` is a
|
|
551
|
+
// debugging artefact in both. Imported here rather than at the top so
|
|
552
|
+
// this module keeps its narrow graph.
|
|
553
|
+
const { error_stripDebugPrefix } = await import('../utils.js');
|
|
554
|
+
return {
|
|
555
|
+
ask,
|
|
556
|
+
result: null,
|
|
557
|
+
answeredAt: null,
|
|
558
|
+
error: reason === undefined ? 'the query could not be completed' : error_stripDebugPrefix(reason),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Runs a fan-out, at most four questions in flight.
|
|
563
|
+
*
|
|
564
|
+
* Not operator-settable: nobody inside mise knows the right number for a
|
|
565
|
+
* given hospital, and a flag that can hurt a shared clinical system will
|
|
566
|
+
* eventually be set to fifty.
|
|
567
|
+
*
|
|
568
|
+
* @param asks - Every question to ask, each naming its own server.
|
|
569
|
+
* @param title - Title for the PACSQuery records created.
|
|
570
|
+
* @param owner - The identity asking.
|
|
571
|
+
* @param fresh - True to skip stored answers.
|
|
572
|
+
* @returns The answers, in the order the questions were given.
|
|
573
|
+
*/
|
|
574
|
+
async function fanout_run(asks, title, owner, fresh) {
|
|
575
|
+
const answers = new Array(asks.length);
|
|
576
|
+
let done = 0;
|
|
577
|
+
// What is being counted: patients when every question names one, plain
|
|
578
|
+
// questions otherwise — a fan-out over servers or dates is not MRNs.
|
|
579
|
+
const label = asks.every((ask) => ask.patientId !== undefined)
|
|
580
|
+
? 'MRNS QUERIED'
|
|
581
|
+
: 'QUERIES';
|
|
582
|
+
const progress_say = (phase) => {
|
|
583
|
+
sink_get().progress_write({
|
|
584
|
+
operation: 'task',
|
|
585
|
+
kind: 'inspection',
|
|
586
|
+
phase,
|
|
587
|
+
label,
|
|
588
|
+
current: done,
|
|
589
|
+
total: asks.length,
|
|
590
|
+
percent: asks.length > 0 ? Math.min(100, (done / asks.length) * 100) : undefined,
|
|
591
|
+
status: phase === 'complete' ? 'done' : 'running',
|
|
592
|
+
});
|
|
593
|
+
};
|
|
594
|
+
progress_say('working');
|
|
595
|
+
let next = 0;
|
|
596
|
+
const worker = async () => {
|
|
597
|
+
while (next < asks.length) {
|
|
598
|
+
const index = next++;
|
|
599
|
+
answers[index] = await ask_run(asks[index], title, owner, fresh);
|
|
600
|
+
done++;
|
|
601
|
+
progress_say('working');
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
await Promise.all(Array.from({ length: Math.min(QUERY_FANOUT_CONCURRENCY, asks.length) }, worker));
|
|
605
|
+
progress_say('complete');
|
|
606
|
+
return answers;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Unions a fan-out's answers into one model.
|
|
610
|
+
*
|
|
611
|
+
* The studies keep their own identities and paths, each already built
|
|
612
|
+
* against the query that found it. The patient level records what was
|
|
613
|
+
* ASKED — including the questions that found nothing and the ones that
|
|
614
|
+
* could not be asked at all, which is the half of a cohort answer that
|
|
615
|
+
* cannot be derived from studies nobody has.
|
|
616
|
+
*
|
|
617
|
+
* @param answers - What every question came back with.
|
|
618
|
+
* @param facts - The command's identity facts.
|
|
619
|
+
* @returns The union model.
|
|
620
|
+
*/
|
|
621
|
+
function fanoutModel_build(answers, facts) {
|
|
622
|
+
// A row says which PACS answered only when more than one could have. On
|
|
623
|
+
// a single-server query the column would repeat one value down the page
|
|
624
|
+
// and tell an operator nothing.
|
|
625
|
+
const manyServers = new Set(answers.map((answer) => answer.ask.identifier)).size > 1;
|
|
626
|
+
const studies = [];
|
|
627
|
+
const patients = [];
|
|
628
|
+
let anchor = null;
|
|
629
|
+
let oldest = null;
|
|
630
|
+
let allReplayed = true;
|
|
631
|
+
for (const answer of answers) {
|
|
632
|
+
const found = answer.result === null ? [] : pacsQueryModel_build(answer.result.decoded, {
|
|
633
|
+
queryId: answer.result.queryId,
|
|
634
|
+
vfsPath: answer.result.vfsPath,
|
|
635
|
+
pacsName: answer.ask.server,
|
|
636
|
+
expression: criteria_toExpression(answer.ask.criteria),
|
|
637
|
+
}).studies.map((study) => (manyServers ? { ...study, server: answer.ask.identifier } : study));
|
|
638
|
+
studies.push(...found);
|
|
639
|
+
if (answer.result !== null && (anchor === null || found.length > 0)) {
|
|
640
|
+
if (anchor === null || anchor.decoded.json === undefined)
|
|
641
|
+
anchor = answer.result;
|
|
642
|
+
}
|
|
643
|
+
const answeredAt = answer.answeredAt ?? new Date().toISOString();
|
|
644
|
+
if (answer.answeredAt === null)
|
|
645
|
+
allReplayed = false;
|
|
646
|
+
if (answer.result !== null && (oldest === null || answeredAt < oldest))
|
|
647
|
+
oldest = answeredAt;
|
|
648
|
+
if (answer.ask.patientId === undefined)
|
|
649
|
+
continue;
|
|
650
|
+
const seriesCount = found.reduce((total, study) => total + study.series.length, 0);
|
|
651
|
+
patients.push({
|
|
652
|
+
patientId: answer.ask.patientId,
|
|
653
|
+
...(manyServers ? { server: answer.ask.identifier } : {}),
|
|
654
|
+
...(found[0]?.patientName ? { patientName: found[0].patientName } : {}),
|
|
655
|
+
status: answer.result === null ? 'unasked' : (found.length > 0 ? 'found' : 'none'),
|
|
656
|
+
studyCount: found.length,
|
|
657
|
+
seriesCount,
|
|
658
|
+
...(answer.result === null ? {} : { queryId: answer.result.queryId }),
|
|
659
|
+
...(answer.result === null ? {} : {
|
|
660
|
+
provenance: { replayed: answer.answeredAt !== null, answeredAt },
|
|
661
|
+
}),
|
|
662
|
+
...(answer.error === undefined ? {} : { error: answer.error }),
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
queryId: anchor?.queryId ?? 0,
|
|
667
|
+
vfsPath: anchor?.vfsPath ?? '',
|
|
668
|
+
pacsName: facts.pacsName,
|
|
669
|
+
expression: facts.expression,
|
|
670
|
+
studies,
|
|
671
|
+
// The set's provenance is its OLDEST row: the worst row is what decides
|
|
672
|
+
// whether an audit table can be trusted, and a set is only "replayed"
|
|
673
|
+
// when nothing in it troubled the PACS.
|
|
674
|
+
...(oldest === null ? {} : { provenance: { replayed: allReplayed, answeredAt: oldest } }),
|
|
675
|
+
...(patients.length > 0 ? { patients } : {}),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Renders a cohort as the audit table it is: one row per patient asked.
|
|
680
|
+
*
|
|
681
|
+
* A miss reads as a zero and a failure reads as a dash in an error hue,
|
|
682
|
+
* because a server that timed out has told us nothing while a PACS that
|
|
683
|
+
* answered with nothing has told us something, and a clinician acts on the
|
|
684
|
+
* difference.
|
|
685
|
+
*
|
|
686
|
+
* @param patients - Every patient asked.
|
|
687
|
+
* @returns The rendered table.
|
|
688
|
+
*/
|
|
689
|
+
function cohort_render(patients) {
|
|
690
|
+
const server_of = (patient) => patient.server ?? '';
|
|
691
|
+
const manyServers = new Set(patients.map(server_of)).size > 1;
|
|
692
|
+
const serverWidth = 14;
|
|
693
|
+
const lines = [''];
|
|
694
|
+
const serverCap = manyServers ? chalk.bold.white('SERVER'.padEnd(serverWidth)) : '';
|
|
695
|
+
lines.push(` ${chalk.bold.white('MRN'.padEnd(18))}${serverCap}${chalk.bold.white('STUDIES'.padEnd(10))}${chalk.bold.white('SERIES'.padEnd(9))}${chalk.bold.white('ANSWERED')}`);
|
|
696
|
+
lines.push(` ${chalk.gray('─'.repeat(manyServers ? 74 : 60))}`);
|
|
697
|
+
for (const patient of patients) {
|
|
698
|
+
const answered = patient.provenance === undefined
|
|
699
|
+
? null
|
|
700
|
+
: age_describe(patient.provenance.answeredAt);
|
|
701
|
+
const counts = patient.status === 'unasked'
|
|
702
|
+
? ['—', '—']
|
|
703
|
+
: [String(patient.studyCount), String(patient.seriesCount)];
|
|
704
|
+
const hue = patient.status === 'unasked'
|
|
705
|
+
? chalk.red
|
|
706
|
+
: (patient.status === 'none' ? chalk.gray : chalk.white);
|
|
707
|
+
const note = patient.status === 'unasked'
|
|
708
|
+
? chalk.red(patient.error ?? 'unasked')
|
|
709
|
+
: chalk.gray(answered ?? 'just now');
|
|
710
|
+
const server = manyServers ? server_of(patient).padEnd(serverWidth) : '';
|
|
711
|
+
lines.push(` ${hue(patient.patientId.padEnd(18))}${server}${counts[0].padEnd(10)}${counts[1].padEnd(9)}${note}`);
|
|
712
|
+
}
|
|
713
|
+
const tally = (state) => patients.filter((patient) => patient.status === state).length;
|
|
714
|
+
lines.push('');
|
|
715
|
+
lines.push(` ${chalk.green(`FOUND ${tally('found')}`)} ${chalk.gray('·')} ${chalk.gray(`NONE ${tally('none')}`)} ${chalk.gray('·')} ${chalk.red(`UNASKED ${tally('unasked')}`)}`);
|
|
716
|
+
lines.push('');
|
|
717
|
+
return lines.join('\n');
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Renders an answer as CSV, and writes it into ChRIS storage when asked.
|
|
721
|
+
*
|
|
722
|
+
* Two flags rather than one with an optional value: `--csv PatientID:1234`
|
|
723
|
+
* could not tell a destination from a query expression, and guessing wrong
|
|
724
|
+
* writes a file named after a patient.
|
|
725
|
+
*
|
|
726
|
+
* A local terminal needs no destination — `--csv > audit.csv` writes the
|
|
727
|
+
* operator's own disk, because engine and operator share a filesystem. A
|
|
728
|
+
* detached surface does not: the engine runs on the daemon's host, so a
|
|
729
|
+
* redirect lands on somebody else's machine. `--csv-to` puts the table in
|
|
730
|
+
* CFS instead, where a cohort's MRNs and study descriptions stay inside
|
|
731
|
+
* ChRIS rather than on whatever laptop the operator is sitting at.
|
|
732
|
+
*
|
|
733
|
+
* @param model - The answer.
|
|
734
|
+
* @param destination - A CFS path to write to, or null to render only.
|
|
735
|
+
* @returns The CSV text and where it went, or a failure already explained.
|
|
736
|
+
*/
|
|
737
|
+
async function csv_deliver(model, destination, ask = false, force = false) {
|
|
738
|
+
const csv = pacsAnswer_toCsv(model);
|
|
739
|
+
let target = destination;
|
|
740
|
+
if (target === null && ask) {
|
|
741
|
+
// Asked only once there is something to write: a question raised before
|
|
742
|
+
// the PACS answered would be a question about a file that may never
|
|
743
|
+
// exist.
|
|
744
|
+
const chosen = await csvDestination_ask();
|
|
745
|
+
if (chosen === '')
|
|
746
|
+
return { ok: false, message: 'query: no destination given; nothing written.' };
|
|
747
|
+
target = chosen;
|
|
748
|
+
}
|
|
749
|
+
if (target === null)
|
|
750
|
+
return { ok: true, rendered: csv };
|
|
751
|
+
const written = await csvFile_write(csv, target, force);
|
|
752
|
+
if (!written.ok)
|
|
753
|
+
return { ok: false, message: `query: ${written.message}` };
|
|
754
|
+
// A folder that had to be made is said, not discovered later.
|
|
755
|
+
const made = written.created === undefined
|
|
756
|
+
? ''
|
|
757
|
+
: `${chalk.gray(` made ${written.created}`)}\n`;
|
|
758
|
+
// A file that was there and is not any more is said, for the same reason
|
|
759
|
+
// the folder is: an act nobody reported is an act nobody can check.
|
|
760
|
+
const gone = written.replaced === true
|
|
761
|
+
? `${chalk.gray(` replaced the table that was there`)}\n`
|
|
762
|
+
: '';
|
|
763
|
+
return { ok: true, rendered: `${made}${gone}${chalk.green(`✓ wrote ${written.path}`)}\n` };
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Asks where the table should go.
|
|
767
|
+
*
|
|
768
|
+
* The anchor is the session's own cwd — a fact, not a guess. Inventing a
|
|
769
|
+
* directory here would mean creating one behind the operator's back, which
|
|
770
|
+
* is what the surface used to do.
|
|
771
|
+
*
|
|
772
|
+
* @returns The location given, or '' when the operator abandoned the ask.
|
|
773
|
+
*/
|
|
774
|
+
async function csvDestination_ask() {
|
|
775
|
+
const { session } = await import('../../session/index.js');
|
|
776
|
+
const { repl_questionPath } = await import('../../core/question.js');
|
|
777
|
+
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
778
|
+
// Where the operator is, unless they are somewhere nothing can be
|
|
779
|
+
// written: a session sitting in `/bin` or `/proc` is browsing a provider,
|
|
780
|
+
// not a place a file lands, and an errand that opens there offers a
|
|
781
|
+
// destination that is refused the moment it is committed.
|
|
782
|
+
const cwd = await session.getCWD();
|
|
783
|
+
const virtual = ['/bin', '/usr', '/etc', '/proc', '/net']
|
|
784
|
+
.some((prefix) => cwd === prefix || cwd.startsWith(`${prefix}/`));
|
|
785
|
+
try {
|
|
786
|
+
const answer = await repl_questionPath('Where should the table go? ', {
|
|
787
|
+
anchor: virtual ? '~' : cwd,
|
|
788
|
+
wantsDirectory: false,
|
|
789
|
+
suggest: `pacs-${stamp}.csv`,
|
|
790
|
+
}, 'EXPORT HERE');
|
|
791
|
+
return answer.trim();
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
// A refused or abandoned ask is not a failure of the query: the answer
|
|
795
|
+
// stands, and only the writing of it does not happen.
|
|
796
|
+
return '';
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Answers a cohort: many questions, one model, one table.
|
|
801
|
+
*
|
|
802
|
+
* @param asks - The questions.
|
|
803
|
+
* @param facts - Title, server, identity and how the answer should read.
|
|
804
|
+
* @returns One envelope carrying every answer.
|
|
805
|
+
*/
|
|
806
|
+
async function cohort_answer(asks, facts) {
|
|
807
|
+
spinner.start(`Querying PACS for ${asks.length} questions...`, true);
|
|
808
|
+
const answers = await fanout_run(asks, facts.title, facts.owner, facts.fresh);
|
|
809
|
+
spinner.stop();
|
|
810
|
+
const model = fanoutModel_build(answers, {
|
|
811
|
+
pacsName: facts.pacsserver,
|
|
812
|
+
expression: facts.expression,
|
|
813
|
+
});
|
|
814
|
+
await modelPulledState_fill(model);
|
|
815
|
+
const patients = model.patients ?? [];
|
|
816
|
+
const unasked = patients.filter((patient) => patient.status === 'unasked').length;
|
|
817
|
+
if (facts.csv || facts.csvTo !== null || facts.csvToAsk) {
|
|
818
|
+
const delivered = await csv_deliver(model, facts.csvTo, facts.csvToAsk, facts.force);
|
|
819
|
+
if (!delivered.ok) {
|
|
820
|
+
process.exitCode = 1;
|
|
821
|
+
// The ANSWER stands: only the writing of it did not happen. An
|
|
822
|
+
// abandoned question, or a refused destination, is no reason to
|
|
823
|
+
// throw away a PACS answer the operator waited for — the model
|
|
824
|
+
// crosses either way, and a surface still shows what was found.
|
|
825
|
+
return {
|
|
826
|
+
...envelope_error('', undefined, `${chalk.red(delivered.message)}\n`),
|
|
827
|
+
model: { kind: PACS_QUERY_MODEL_KIND, data: model },
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
return envelope_ok(delivered.rendered, { kind: PACS_QUERY_MODEL_KIND, data: model });
|
|
831
|
+
}
|
|
832
|
+
// A fan-out over something other than patients has no audit table to
|
|
833
|
+
// show; its studies are the answer, and they are already in the model.
|
|
834
|
+
const rendered = patients.length > 0
|
|
835
|
+
? cohort_render(patients)
|
|
836
|
+
: `${chalk.green(`✓ ${asks.length} queries complete — ${model.studies.length} studies`)}\n`;
|
|
837
|
+
// Every question failing is a failure of the command; some failing is an
|
|
838
|
+
// answer with holes in it, which the table states and the operator reads.
|
|
839
|
+
if (unasked > 0 && unasked === patients.length) {
|
|
840
|
+
process.exitCode = 1;
|
|
841
|
+
// The model still crosses. A surface showing which MRNs went unasked,
|
|
842
|
+
// and why, is more use than an empty pane beside a red line.
|
|
843
|
+
return {
|
|
844
|
+
...envelope_error(rendered, undefined, `${chalk.red(`query: none of the ${unasked} questions could be asked.`)}\n`),
|
|
845
|
+
model: { kind: PACS_QUERY_MODEL_KIND, data: model },
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
return envelope_ok(rendered, { kind: PACS_QUERY_MODEL_KIND, data: model });
|
|
849
|
+
}
|
|
326
850
|
/**
|
|
327
851
|
* Creates a PACS query, waits for results, displays findings, and prints the VFS path.
|
|
328
852
|
*
|
|
@@ -339,6 +863,14 @@ export async function builtin_query(args) {
|
|
|
339
863
|
let title = `Query ${Date.now()}`;
|
|
340
864
|
let pacsserverOverride = null;
|
|
341
865
|
let tableMode = false;
|
|
866
|
+
let fresh = false;
|
|
867
|
+
let patientsArg = null;
|
|
868
|
+
let csv = false;
|
|
869
|
+
let csvTo = null;
|
|
870
|
+
/** True when `--csv-to` was given no value: the destination is asked for. */
|
|
871
|
+
let csvToAsk = false;
|
|
872
|
+
/** True when the operator said to overwrite a table that already exists. */
|
|
873
|
+
let force = false;
|
|
342
874
|
const positional = [];
|
|
343
875
|
for (let i = 0; i < args.length; i++) {
|
|
344
876
|
if (args[i] === '--title' && i + 1 < args.length) {
|
|
@@ -350,35 +882,164 @@ export async function builtin_query(args) {
|
|
|
350
882
|
else if (args[i] === '--table') {
|
|
351
883
|
tableMode = true;
|
|
352
884
|
}
|
|
885
|
+
else if (args[i] === '--patients' && i + 1 < args.length) {
|
|
886
|
+
patientsArg = args[++i];
|
|
887
|
+
}
|
|
888
|
+
else if (args[i] === '--csv') {
|
|
889
|
+
csv = true;
|
|
890
|
+
}
|
|
891
|
+
else if (args[i] === '--csv-to') {
|
|
892
|
+
// A value-taking flag given no value is an ASK. `--csv-to` with
|
|
893
|
+
// nothing after it means "ask me where", which is how a graphical
|
|
894
|
+
// surface opens its errand and how a terminal gets a prompt. Given
|
|
895
|
+
// nothing and treated as nothing — which is what happened before —
|
|
896
|
+
// the operator asked for a CSV and silently got none.
|
|
897
|
+
const value = args[i + 1];
|
|
898
|
+
if (value !== undefined && !value.startsWith('--')) {
|
|
899
|
+
csvTo = args[++i];
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
csvToAsk = true;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
else if (args[i] === '--force') {
|
|
906
|
+
force = true;
|
|
907
|
+
}
|
|
908
|
+
else if (args[i] === '--fresh') {
|
|
909
|
+
fresh = true;
|
|
910
|
+
}
|
|
353
911
|
else if (!args[i].startsWith('--')) {
|
|
354
912
|
positional.push(args[i]);
|
|
355
913
|
}
|
|
356
914
|
}
|
|
357
|
-
if (positional.length === 0) {
|
|
915
|
+
if (positional.length === 0 && patientsArg === null) {
|
|
358
916
|
process.exitCode = 1;
|
|
359
|
-
return envelope_error('', undefined, `${chalk.red('query: Missing query expression. Usage: query <Key:Value[,...]> [--title <title>]')}\n`);
|
|
917
|
+
return envelope_error('', undefined, `${chalk.red('query: Missing query expression. Usage: query <Key:Value[,...]> [--patients <list|@file>] [--title <title>]')}\n`);
|
|
360
918
|
}
|
|
361
919
|
const queryExpr = positional.join(' ');
|
|
362
|
-
// Validate expression early
|
|
363
|
-
|
|
920
|
+
// Validate expression early. A cohort may narrow with terms or with none
|
|
921
|
+
// at all — `--patients @mrns.txt` is a complete question.
|
|
922
|
+
const terms = queryExpr === '' ? {} : (queryExpr.trimStart().startsWith('{')
|
|
923
|
+
? termsFromJson_read(queryExpr)
|
|
924
|
+
: queryTerms_parse(queryExpr));
|
|
925
|
+
if (terms === null) {
|
|
364
926
|
process.exitCode = 1;
|
|
365
927
|
return envelope_error('', undefined, `${chalk.red(`query: Invalid expression: "${queryExpr}". Use Key:Value pairs (e.g. PatientID:1234) or JSON.`)}\n`);
|
|
366
928
|
}
|
|
367
|
-
// Resolve PACS
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
929
|
+
// Resolve the PACS servers. Several is a QUERY across them; one is the
|
|
930
|
+
// ordinary case. There is no sweep-everything: a fan-out is always
|
|
931
|
+
// something the operator named, because thirteen registrations of
|
|
932
|
+
// unknown liveness is thousands of C-FINDs mostly into the void.
|
|
933
|
+
const named = (pacsserverOverride ?? '')
|
|
934
|
+
.split(',')
|
|
935
|
+
.map((entry) => entry.trim())
|
|
936
|
+
.filter((entry) => entry !== '');
|
|
937
|
+
let servers = named;
|
|
938
|
+
if (servers.length === 0) {
|
|
939
|
+
const inContext = await chrisContext.current_get(Context.PACSserver);
|
|
940
|
+
if (inContext) {
|
|
941
|
+
servers = [inContext];
|
|
373
942
|
}
|
|
374
943
|
else {
|
|
944
|
+
const serversResult = await pacsServers_list();
|
|
945
|
+
if (serversResult.ok && serversResult.value.length > 0) {
|
|
946
|
+
servers = [String(serversResult.value[0].id)];
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
process.exitCode = 1;
|
|
950
|
+
return envelope_error('', undefined, `${chalk.red('query: No PACS server available. Set one with: pacs connect <id>')}\n`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const pacsserver = servers[0];
|
|
955
|
+
// The index is keyed on what CUBE stores, which is the identifier.
|
|
956
|
+
const identifiers = await Promise.all(servers.map((server) => pacsIdentifier_resolve(server)));
|
|
957
|
+
const identifier = identifiers[0];
|
|
958
|
+
const owner = await askingIdentity_get();
|
|
959
|
+
// What the operator asked for, as the questions it stands for. A PACS
|
|
960
|
+
// will not match a list, so several patients is several C-FINDs.
|
|
961
|
+
const asks = [];
|
|
962
|
+
if (patientsArg !== null) {
|
|
963
|
+
const cohort = await patients_read(patientsArg);
|
|
964
|
+
if (!cohort.ok) {
|
|
965
|
+
const problem = errorStack.stack_pop();
|
|
966
|
+
process.exitCode = 1;
|
|
967
|
+
return envelope_error('', undefined, `${chalk.red(problem?.message ?? 'query: --patients could not be read.')}\n`);
|
|
968
|
+
}
|
|
969
|
+
if (!fanout_permit(cohort.value.length, QUERY_COHORT_MAX)) {
|
|
970
|
+
const problem = errorStack.stack_pop();
|
|
971
|
+
process.exitCode = 1;
|
|
972
|
+
return envelope_error('', undefined, `${chalk.red(problem?.message ?? 'query: too many patients.')}\n`);
|
|
973
|
+
}
|
|
974
|
+
const base = Object.fromEntries(Object.entries(terms).map(([key, values]) => [key, values[0]]));
|
|
975
|
+
if (!fanout_permit(cohort.value.length * servers.length, QUERY_COHORT_MAX)) {
|
|
976
|
+
const problem = errorStack.stack_pop();
|
|
375
977
|
process.exitCode = 1;
|
|
376
|
-
return envelope_error('', undefined, `${chalk.red('query:
|
|
978
|
+
return envelope_error('', undefined, `${chalk.red(problem?.message ?? 'query: too many questions.')}\n`);
|
|
979
|
+
}
|
|
980
|
+
for (const mrn of cohort.value) {
|
|
981
|
+
for (let index = 0; index < servers.length; index++) {
|
|
982
|
+
asks.push({
|
|
983
|
+
criteria: { ...base, [PATIENT_KEY]: mrn },
|
|
984
|
+
patientId: mrn,
|
|
985
|
+
server: servers[index],
|
|
986
|
+
identifier: identifiers[index],
|
|
987
|
+
});
|
|
988
|
+
}
|
|
377
989
|
}
|
|
378
990
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
991
|
+
else {
|
|
992
|
+
const expanded = queryTerms_expand(terms);
|
|
993
|
+
if (!fanout_permit(expanded.length * servers.length, QUERY_INLINE_FANOUT_MAX)) {
|
|
994
|
+
const problem = errorStack.stack_pop();
|
|
995
|
+
process.exitCode = 1;
|
|
996
|
+
return envelope_error('', undefined, `${chalk.red(problem?.message ?? 'query: too many queries.')}\n`);
|
|
997
|
+
}
|
|
998
|
+
const asksPerServer = expanded.length * servers.length;
|
|
999
|
+
for (const one of expanded) {
|
|
1000
|
+
for (let index = 0; index < servers.length; index++) {
|
|
1001
|
+
asks.push({
|
|
1002
|
+
criteria: one,
|
|
1003
|
+
// A single question put to a single server is not a cohort: it
|
|
1004
|
+
// needs no patient level, and inventing one would put an audit
|
|
1005
|
+
// table where a study list belongs.
|
|
1006
|
+
...(one[PATIENT_KEY] === undefined || asksPerServer === 1
|
|
1007
|
+
? {}
|
|
1008
|
+
: { patientId: one[PATIENT_KEY] }),
|
|
1009
|
+
server: servers[index],
|
|
1010
|
+
identifier: identifiers[index],
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (asks.length > 1) {
|
|
1016
|
+
return await cohort_answer(asks, {
|
|
1017
|
+
// The model names the server the way CUBE does. `--pacsserver 1` and
|
|
1018
|
+
// `--pacsserver PACSDCM` are the same server, and a table that says
|
|
1019
|
+
// "1" tells an operator nothing about which PACS answered.
|
|
1020
|
+
title, pacsserver: identifier, owner, fresh, csv, csvTo, csvToAsk, force,
|
|
1021
|
+
expression: patientsArg === null ? queryExpr : `${queryExpr} --patients ${patientsArg}`.trim(),
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
const criteria = asks[0]?.criteria ?? {};
|
|
1025
|
+
// One question, spelled as the create path wants it — which is not
|
|
1026
|
+
// necessarily what was typed: `--patients 1234` names a patient without
|
|
1027
|
+
// an expression to carry it.
|
|
1028
|
+
const askExpr = criteria_toExpression(criteria);
|
|
1029
|
+
let result = null;
|
|
1030
|
+
let answeredAt = null;
|
|
1031
|
+
if (!fresh) {
|
|
1032
|
+
const replayed = await replay_attempt(criteria, identifier, owner);
|
|
1033
|
+
if (replayed !== null) {
|
|
1034
|
+
result = replayed.result;
|
|
1035
|
+
answeredAt = replayed.answeredAt;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
if (result === null) {
|
|
1039
|
+
spinner.start(`Querying PACS for ${askExpr}...`, true);
|
|
1040
|
+
result = await pacsQuery_createAndWait(askExpr, title, pacsserver, (msg) => spinner.updateMessage(msg));
|
|
1041
|
+
spinner.stop();
|
|
1042
|
+
}
|
|
382
1043
|
if (!result) {
|
|
383
1044
|
let errOut = '';
|
|
384
1045
|
const errs = errorStack.stack_getAll?.() ?? [];
|
|
@@ -401,23 +1062,51 @@ export async function builtin_query(args) {
|
|
|
401
1062
|
const model = pacsQueryModel_build(result.decoded, {
|
|
402
1063
|
queryId: result.queryId,
|
|
403
1064
|
vfsPath: result.vfsPath,
|
|
404
|
-
pacsName:
|
|
405
|
-
expression:
|
|
1065
|
+
pacsName: identifier,
|
|
1066
|
+
expression: askExpr,
|
|
1067
|
+
provenance: {
|
|
1068
|
+
replayed: answeredAt !== null,
|
|
1069
|
+
answeredAt: answeredAt ?? new Date().toISOString(),
|
|
1070
|
+
},
|
|
406
1071
|
});
|
|
407
1072
|
// Mark what CUBE already holds: a surface then offers gather instead of
|
|
408
1073
|
// pull for series that are already home. One bounded sweep, no retries.
|
|
409
1074
|
await modelPulledState_fill(model);
|
|
1075
|
+
if (csv || csvTo !== null || csvToAsk) {
|
|
1076
|
+
const delivered = await csv_deliver(model, csvTo, csvToAsk, force);
|
|
1077
|
+
if (!delivered.ok) {
|
|
1078
|
+
process.exitCode = 1;
|
|
1079
|
+
// The answer stands; only the writing of it did not happen.
|
|
1080
|
+
return {
|
|
1081
|
+
...envelope_error('', undefined, `${chalk.red(delivered.message)}\n`),
|
|
1082
|
+
model: { kind: PACS_QUERY_MODEL_KIND, data: model },
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
return envelope_ok(delivered.rendered, { kind: PACS_QUERY_MODEL_KIND, data: model });
|
|
1086
|
+
}
|
|
410
1087
|
if (!renderedResult) {
|
|
411
1088
|
// Nothing matched: browsing or pulling the empty query would be
|
|
412
1089
|
// meaningless, so no hints. The empty model still crosses, so a
|
|
413
1090
|
// graphical surface can say "no studies" in its own voice.
|
|
414
1091
|
return envelope_ok(`${chalk.yellow(`⚠ Query ${result.queryId} complete — no studies found.`)}\n`, { kind: PACS_QUERY_MODEL_KIND, data: model });
|
|
415
1092
|
}
|
|
416
|
-
|
|
1093
|
+
// Rendered FROM the model's provenance, not from a second copy of the
|
|
1094
|
+
// same fact: one timestamp, one claim, however many surfaces say it.
|
|
1095
|
+
const replayed = model.provenance?.replayed === true ? model.provenance : undefined;
|
|
1096
|
+
const age = replayed === undefined ? null : age_describe(replayed.answeredAt);
|
|
1097
|
+
let rendered = replayed === undefined
|
|
1098
|
+
? `${chalk.green(`✓ Query ${result.queryId} complete`)}\n`
|
|
1099
|
+
: `${chalk.green(`✓ Query ${result.queryId} — answered ${age ?? 'earlier'}`)}\n`;
|
|
417
1100
|
rendered += `${renderedResult}\n`;
|
|
418
1101
|
rendered += `${chalk.bold(` VFS path: ${chalk.cyan(result.vfsPath)}`)}\n`;
|
|
419
1102
|
rendered += `${chalk.gray(` cd ${result.vfsPath}`)}\n`;
|
|
420
1103
|
rendered += `${chalk.gray(` pull ${result.vfsPath}`)}\n`;
|
|
1104
|
+
if (replayed !== undefined) {
|
|
1105
|
+
// Said, never decided: mise states the age and leaves the judgement
|
|
1106
|
+
// with the operator, who knows whether this question can gain an
|
|
1107
|
+
// answer between then and now.
|
|
1108
|
+
rendered += `${chalk.gray(` query ${askExpr} --fresh # ask the PACS again`)}\n`;
|
|
1109
|
+
}
|
|
421
1110
|
return envelope_ok(rendered, { kind: PACS_QUERY_MODEL_KIND, data: model });
|
|
422
1111
|
}
|
|
423
1112
|
//# sourceMappingURL=query.js.map
|