geotechcli 0.4.22 → 0.4.24
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/commands/ai.d.ts.map +1 -1
- package/dist/commands/ai.js +85 -92
- package/dist/commands/ai.js.map +1 -1
- package/dist/commands/ingest.d.ts +3 -0
- package/dist/commands/ingest.d.ts.map +1 -0
- package/dist/commands/ingest.js +1720 -0
- package/dist/commands/ingest.js.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/util/vision-output.d.ts +9 -14
- package/dist/util/vision-output.d.ts.map +1 -1
- package/dist/util/vision-output.js +7 -44
- package/dist/util/vision-output.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1720 @@
|
|
|
1
|
+
import { basename, parse } from 'node:path';
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { approvePersistedBoreholeIngestReview, buildIngestDossier, buildLLMConfig, cancelPersistedIngestJob, computeWeightedPdfPageCost, createAndStartPersistedIngestJob, DEFAULT_LLM_VISION_MODEL, ingestBoreholeLogDocument, ingestGeotechDocument, inspectPdfDocument, listPersistedBoreholeIngestReviewApprovals, listPersistedBoreholeIngestReviews, loadLatestPersistedBoreholeIngestReviewApproval, loadLatestPersistedBoreholeIngestReview, loadPersistedIngestJob, loadPersistedIngestJobResult, loadPersistedBoreholeIngestReviewApproval, loadPersistedBoreholeIngestReview, persistBoreholeIngestReview, promotePersistedBoreholeIngestReview, resolvePersistedIngestJobExtractionConcurrency, renderIngestDossierAsHtml, resumePersistedIngestJob, shouldUseAsyncIngestJob, waitForPersistedIngestJob, } from '@geotechcli/core';
|
|
6
|
+
import { heading, keyValue, renderJSON, renderTable, success, error, info, warn } from '../ui/terminal.js';
|
|
7
|
+
import { addGlobalFlags, getGlobalFlags } from '../util/flags.js';
|
|
8
|
+
import { estimateHostedBetaVisionBodyBytes, formatByteSize, HOSTED_BETA_REQUEST_LIMIT_BYTES, HOSTED_BETA_REQUEST_SAFE_BYTES, countPdfPages, readVisionInput, readVisionPdfPageInputs, } from '../util/vision-output.js';
|
|
9
|
+
function formatMaybe(value, suffix = '') {
|
|
10
|
+
if (value == null || value === '')
|
|
11
|
+
return 'Unavailable';
|
|
12
|
+
return `${value}${suffix}`;
|
|
13
|
+
}
|
|
14
|
+
function resolveIngestPresentationFormat(value) {
|
|
15
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
16
|
+
if (!normalized || normalized === 'plain') {
|
|
17
|
+
return 'plain';
|
|
18
|
+
}
|
|
19
|
+
if (normalized === 'html') {
|
|
20
|
+
return 'html';
|
|
21
|
+
}
|
|
22
|
+
throw new Error(`Unsupported ingest format "${String(value)}". Supported formats: plain, html.`);
|
|
23
|
+
}
|
|
24
|
+
function assertIngestPresentationMode(flags, format) {
|
|
25
|
+
if (flags.json && format === 'html') {
|
|
26
|
+
throw new Error('Use either --json or --format html, not both.');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function shouldRenderHtmlDossier(format, outputPath) {
|
|
30
|
+
return format === 'html' || (typeof outputPath === 'string' && /\.html?$/i.test(outputPath));
|
|
31
|
+
}
|
|
32
|
+
function slugifyOutputStem(value) {
|
|
33
|
+
const parsed = parse(value);
|
|
34
|
+
const stem = (parsed.name || parsed.base || value)
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
37
|
+
.replace(/^-|-$/g, '');
|
|
38
|
+
return stem || 'geotech-ingest';
|
|
39
|
+
}
|
|
40
|
+
function defaultDossierOutputPath(sourceLabel) {
|
|
41
|
+
return `${slugifyOutputStem(sourceLabel)}.ingest-dossier.html`;
|
|
42
|
+
}
|
|
43
|
+
function writeHtmlDossier(result, options) {
|
|
44
|
+
const dossier = buildIngestDossier(result, {
|
|
45
|
+
sourceLabel: options.sourceLabel,
|
|
46
|
+
storedReview: options.storedReview
|
|
47
|
+
? {
|
|
48
|
+
projectId: options.storedReview.projectId,
|
|
49
|
+
datasetName: options.storedReview.datasetName,
|
|
50
|
+
reviewId: options.storedReview.reviewId,
|
|
51
|
+
createdAt: options.storedReview.createdAt,
|
|
52
|
+
}
|
|
53
|
+
: null,
|
|
54
|
+
approval: options.approval
|
|
55
|
+
? {
|
|
56
|
+
datasetName: options.approval.datasetName,
|
|
57
|
+
approvedAt: options.approval.approvedAt,
|
|
58
|
+
approvedBy: options.approval.approvedBy,
|
|
59
|
+
rationale: options.approval.rationale,
|
|
60
|
+
}
|
|
61
|
+
: null,
|
|
62
|
+
});
|
|
63
|
+
const outputPath = options.outputPath ?? defaultDossierOutputPath(options.sourceLabel);
|
|
64
|
+
writeFileSync(outputPath, renderIngestDossierAsHtml(dossier));
|
|
65
|
+
success(`HTML ingest dossier saved to ${outputPath}`);
|
|
66
|
+
return outputPath;
|
|
67
|
+
}
|
|
68
|
+
function startProgress(flags, text) {
|
|
69
|
+
if (flags.json || flags.quiet) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
info(text);
|
|
73
|
+
return {
|
|
74
|
+
succeed(message) {
|
|
75
|
+
success(message);
|
|
76
|
+
},
|
|
77
|
+
fail(message) {
|
|
78
|
+
error(message);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function describeVisionInput(file, flags) {
|
|
83
|
+
if (flags.json || flags.quiet) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (file.kind !== 'pdf') {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log(chalk.yellow(' PDF input detected.'));
|
|
91
|
+
console.log(chalk.gray(' geotech ingest will pre-scan the PDF, split it into page-level requests, and preserve document-level warnings.'));
|
|
92
|
+
console.log(chalk.gray(' Scanned/image-only pages are routed through raster-image recovery when possible, but ambiguous pages may still require manual review.'));
|
|
93
|
+
console.log('');
|
|
94
|
+
}
|
|
95
|
+
function ensureHostedBetaVisionPayloadWithinLimit(file, details) {
|
|
96
|
+
const estimatedBytes = estimateHostedBetaVisionBodyBytes({
|
|
97
|
+
prompt: details.prompt,
|
|
98
|
+
systemPrompt: details.systemPrompt,
|
|
99
|
+
imageBase64: file.base64,
|
|
100
|
+
mimeType: file.mimeType,
|
|
101
|
+
model: details.model,
|
|
102
|
+
temperature: details.temperature,
|
|
103
|
+
maxTokens: details.maxTokens,
|
|
104
|
+
jsonMode: false,
|
|
105
|
+
});
|
|
106
|
+
if (estimatedBytes <= HOSTED_BETA_REQUEST_SAFE_BYTES) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const fileSize = formatByteSize(file.fileBytes);
|
|
110
|
+
const payloadSize = formatByteSize(estimatedBytes);
|
|
111
|
+
const limitSize = formatByteSize(HOSTED_BETA_REQUEST_SAFE_BYTES);
|
|
112
|
+
const capSize = formatByteSize(HOSTED_BETA_REQUEST_LIMIT_BYTES);
|
|
113
|
+
const baseMessage = file.kind === 'pdf'
|
|
114
|
+
? 'PDF ingest inputs are uploaded as a single base64 payload and are too large for the hosted beta proxy.'
|
|
115
|
+
: 'Image ingest inputs are uploaded as a single base64 payload and are too large for the hosted beta proxy.';
|
|
116
|
+
const mitigation = file.kind === 'pdf'
|
|
117
|
+
? 'Split the PDF into smaller files or export the relevant page as PNG/JPG, then retry.'
|
|
118
|
+
: 'Resize or crop the image to the relevant region, then retry.';
|
|
119
|
+
throw new Error(`${baseMessage} File: ${file.filePath} (${fileSize}). Estimated request body: ${payloadSize}. Safe limit: ${limitSize}. Hosted beta cap: ${capSize}.\n${mitigation}`);
|
|
120
|
+
}
|
|
121
|
+
function maybeCheckHostedBetaVisionPayload(config, file, details) {
|
|
122
|
+
if (config.provider !== 'hosted-beta') {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
ensureHostedBetaVisionPayloadWithinLimit(file, {
|
|
126
|
+
...details,
|
|
127
|
+
model: config.visionModelId ?? DEFAULT_LLM_VISION_MODEL,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function formatCoordinateSummary(borehole) {
|
|
131
|
+
if (!borehole.location) {
|
|
132
|
+
return 'Unavailable';
|
|
133
|
+
}
|
|
134
|
+
const rawCoordinateText = typeof borehole.location.raw?.rawCoordinateText === 'string'
|
|
135
|
+
? borehole.location.raw.rawCoordinateText
|
|
136
|
+
: null;
|
|
137
|
+
const parts = [
|
|
138
|
+
borehole.location.crs?.code ?? borehole.location.crs?.name ?? null,
|
|
139
|
+
rawCoordinateText,
|
|
140
|
+
borehole.location.wgs84
|
|
141
|
+
? `lat ${borehole.location.wgs84.latitude}, lon ${borehole.location.wgs84.longitude}`
|
|
142
|
+
: null,
|
|
143
|
+
borehole.location.projected
|
|
144
|
+
? `E ${borehole.location.projected.easting}, N ${borehole.location.projected.northing}`
|
|
145
|
+
: null,
|
|
146
|
+
].filter((value) => Boolean(value));
|
|
147
|
+
return parts.length > 0 ? parts.join(' | ') : 'Unavailable';
|
|
148
|
+
}
|
|
149
|
+
function formatClassificationSummary(counts) {
|
|
150
|
+
if (!counts) {
|
|
151
|
+
return 'Unavailable';
|
|
152
|
+
}
|
|
153
|
+
const entries = Object.entries(counts)
|
|
154
|
+
.filter(([, count]) => (count ?? 0) > 0)
|
|
155
|
+
.sort((left, right) => left[0].localeCompare(right[0]));
|
|
156
|
+
if (entries.length === 0) {
|
|
157
|
+
return 'Unavailable';
|
|
158
|
+
}
|
|
159
|
+
return entries.map(([classification, count]) => `${classification}: ${count}`).join(', ');
|
|
160
|
+
}
|
|
161
|
+
const REVIEW_SEVERITY_ORDER = ['blocking', 'review', 'advisory'];
|
|
162
|
+
const REVIEW_SEVERITY_LABELS = {
|
|
163
|
+
advisory: 'Advisory',
|
|
164
|
+
review: 'Needs review',
|
|
165
|
+
blocking: 'Blocking',
|
|
166
|
+
};
|
|
167
|
+
function isRecord(value) {
|
|
168
|
+
return typeof value === 'object' && value !== null;
|
|
169
|
+
}
|
|
170
|
+
function isReviewSeverity(value) {
|
|
171
|
+
return value === 'advisory' || value === 'review' || value === 'blocking';
|
|
172
|
+
}
|
|
173
|
+
function isReviewScope(value) {
|
|
174
|
+
return value === 'document' || value === 'page' || value === 'borehole' || value === 'material';
|
|
175
|
+
}
|
|
176
|
+
function asOptionalPositiveInteger(value) {
|
|
177
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
178
|
+
}
|
|
179
|
+
function asOptionalTrimmedString(value) {
|
|
180
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
181
|
+
}
|
|
182
|
+
function getCommandOptionValue(commandLike, key) {
|
|
183
|
+
return typeof commandLike?.getOptionValue === 'function'
|
|
184
|
+
? commandLike.getOptionValue(key)
|
|
185
|
+
: undefined;
|
|
186
|
+
}
|
|
187
|
+
function toLongOptionFlag(key) {
|
|
188
|
+
return `--${key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)}`;
|
|
189
|
+
}
|
|
190
|
+
function getRawOptionValue(commandLike, key) {
|
|
191
|
+
const rawArgs = Array.isArray(commandLike?.rawArgs)
|
|
192
|
+
? (commandLike.rawArgs)
|
|
193
|
+
: [];
|
|
194
|
+
if (rawArgs.length === 0) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
const flag = toLongOptionFlag(key);
|
|
198
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
199
|
+
const current = rawArgs[index];
|
|
200
|
+
if (typeof current !== 'string') {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (current === flag) {
|
|
204
|
+
const next = rawArgs[index + 1];
|
|
205
|
+
return typeof next === 'string' && !next.startsWith('--') ? next : true;
|
|
206
|
+
}
|
|
207
|
+
if (current.startsWith(`${flag}=`)) {
|
|
208
|
+
return current.slice(flag.length + 1);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
function getReviewFindings(result) {
|
|
214
|
+
if (!isRecord(result)) {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
const structured = Array.isArray(result.reviewFindings)
|
|
218
|
+
? result.reviewFindings.flatMap((finding) => {
|
|
219
|
+
if (!isRecord(finding)) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
const severity = finding.severity;
|
|
223
|
+
const scope = finding.scope;
|
|
224
|
+
const message = asOptionalTrimmedString(finding.message);
|
|
225
|
+
if (!isReviewSeverity(severity) || !isReviewScope(scope) || !message) {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
return [{
|
|
229
|
+
severity,
|
|
230
|
+
scope,
|
|
231
|
+
message,
|
|
232
|
+
pageNumber: asOptionalPositiveInteger(finding.pageNumber),
|
|
233
|
+
boreholeId: asOptionalTrimmedString(finding.boreholeId),
|
|
234
|
+
materialDescription: asOptionalTrimmedString(finding.materialDescription),
|
|
235
|
+
}];
|
|
236
|
+
})
|
|
237
|
+
: [];
|
|
238
|
+
if (structured.length > 0) {
|
|
239
|
+
return structured;
|
|
240
|
+
}
|
|
241
|
+
return Array.isArray(result.reviewReasons)
|
|
242
|
+
? result.reviewReasons.flatMap((reason) => {
|
|
243
|
+
const message = asOptionalTrimmedString(reason);
|
|
244
|
+
return message
|
|
245
|
+
? [{ severity: 'review', scope: 'document', message }]
|
|
246
|
+
: [];
|
|
247
|
+
})
|
|
248
|
+
: [];
|
|
249
|
+
}
|
|
250
|
+
function formatReviewFinding(finding) {
|
|
251
|
+
const context = [];
|
|
252
|
+
if (finding.scope === 'document') {
|
|
253
|
+
context.push('Document');
|
|
254
|
+
}
|
|
255
|
+
if (finding.scope === 'page') {
|
|
256
|
+
context.push(finding.pageNumber != null ? `Page ${finding.pageNumber}` : 'Page');
|
|
257
|
+
if (finding.boreholeId) {
|
|
258
|
+
context.push(`Borehole ${finding.boreholeId}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (finding.scope === 'borehole') {
|
|
262
|
+
if (finding.pageNumber != null) {
|
|
263
|
+
context.push(`Page ${finding.pageNumber}`);
|
|
264
|
+
}
|
|
265
|
+
context.push(finding.boreholeId ? `Borehole ${finding.boreholeId}` : 'Borehole');
|
|
266
|
+
}
|
|
267
|
+
if (finding.scope === 'material') {
|
|
268
|
+
if (finding.pageNumber != null) {
|
|
269
|
+
context.push(`Page ${finding.pageNumber}`);
|
|
270
|
+
}
|
|
271
|
+
context.push(finding.materialDescription ? `Material ${finding.materialDescription}` : 'Material');
|
|
272
|
+
}
|
|
273
|
+
return `${context.join(' | ')}: ${finding.message}`;
|
|
274
|
+
}
|
|
275
|
+
function renderReviewFindings(reviewFindings) {
|
|
276
|
+
if (reviewFindings.length === 0) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
console.log('');
|
|
280
|
+
console.log(chalk.white(' Review findings:'));
|
|
281
|
+
for (const severity of REVIEW_SEVERITY_ORDER) {
|
|
282
|
+
const findings = reviewFindings.filter((finding) => finding.severity === severity);
|
|
283
|
+
if (findings.length === 0) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
console.log(chalk.white(` ${REVIEW_SEVERITY_LABELS[severity]}:`));
|
|
287
|
+
const render = severity === 'blocking' ? error : severity === 'review' ? warn : info;
|
|
288
|
+
for (const finding of findings) {
|
|
289
|
+
render(formatReviewFinding(finding));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function renderPersistedReviewDetails(details, approval) {
|
|
294
|
+
if (!details) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
console.log('');
|
|
298
|
+
console.log(chalk.white(' Stored review:'));
|
|
299
|
+
keyValue(' Project', details.projectId);
|
|
300
|
+
keyValue(' Dataset', details.datasetName);
|
|
301
|
+
keyValue(' Review ID', details.reviewId);
|
|
302
|
+
if (details.createdAt) {
|
|
303
|
+
keyValue(' Created', details.createdAt);
|
|
304
|
+
}
|
|
305
|
+
if (!approval) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
console.log('');
|
|
309
|
+
console.log(chalk.white(' Approval:'));
|
|
310
|
+
keyValue(' Dataset', approval.datasetName);
|
|
311
|
+
keyValue(' Approved', approval.approvedAt);
|
|
312
|
+
keyValue(' Approved by', approval.approvedBy ?? 'Unspecified');
|
|
313
|
+
if (approval.rationale) {
|
|
314
|
+
console.log(` ${approval.rationale}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function isGeotechDocumentReviewResult(result) {
|
|
318
|
+
return result.documentType === 'geotech-document';
|
|
319
|
+
}
|
|
320
|
+
function renderIngestResultReport(result, options) {
|
|
321
|
+
heading(options?.title ?? 'Geotechnical Ingest');
|
|
322
|
+
keyValue('Document type', 'borehole-log');
|
|
323
|
+
keyValue('Source', options?.sourceLabel ?? result.source.fileName ?? result.source.filePath ?? 'Unknown');
|
|
324
|
+
keyValue('Pages processed', `${result.source.successfulPages}/${result.source.totalPages}`);
|
|
325
|
+
keyValue('Boreholes extracted', String(result.boreholes.length));
|
|
326
|
+
keyValue('Confidence', `${result.confidence}%`);
|
|
327
|
+
keyValue('Review required', result.reviewRequired ? 'Yes' : 'No');
|
|
328
|
+
keyValue('Auto proceed', result.canAutoProceed ? 'Yes' : 'No');
|
|
329
|
+
if (result.inspectionSummary) {
|
|
330
|
+
keyValue('PDF classes', formatClassificationSummary(result.inspectionSummary.pageClassificationCounts));
|
|
331
|
+
keyValue('Image-heavy pages', String(result.inspectionSummary.imageHeavyPageCount));
|
|
332
|
+
keyValue('Recovered OCR hints', String(result.inspectionSummary.ocrRecoveredPageCount));
|
|
333
|
+
}
|
|
334
|
+
renderPersistedReviewDetails(options?.persistedReview, options?.approval ?? null);
|
|
335
|
+
console.log('');
|
|
336
|
+
renderTable(['Borehole', 'Total depth (m)', 'Water table (m)', 'Coordinates', 'Confidence', 'Status'], result.boreholes.map((borehole) => [
|
|
337
|
+
borehole.boreholeId,
|
|
338
|
+
borehole.totalDepth ?? '-',
|
|
339
|
+
borehole.waterTableDepth ?? '-',
|
|
340
|
+
formatCoordinateSummary(borehole),
|
|
341
|
+
`${borehole.confidence}%`,
|
|
342
|
+
borehole.parseStatus,
|
|
343
|
+
]));
|
|
344
|
+
const reviewFindings = getReviewFindings(result);
|
|
345
|
+
renderReviewFindings(reviewFindings);
|
|
346
|
+
if (result.pageFailures.length > 0) {
|
|
347
|
+
console.log('');
|
|
348
|
+
console.log(chalk.white(' Page failures:'));
|
|
349
|
+
for (const failure of result.pageFailures) {
|
|
350
|
+
warn(failure);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const reviewFindingMessages = new Set(reviewFindings.map((finding) => finding.message));
|
|
354
|
+
const standaloneWarnings = result.warnings.filter((warning) => !result.pageFailures.includes(warning) && !reviewFindingMessages.has(warning));
|
|
355
|
+
if (standaloneWarnings.length > 0) {
|
|
356
|
+
console.log('');
|
|
357
|
+
console.log(chalk.white(' Warnings:'));
|
|
358
|
+
for (const warningMessage of standaloneWarnings.slice(0, 8)) {
|
|
359
|
+
warn(warningMessage);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (result.boreholes.length > 0) {
|
|
363
|
+
const first = result.boreholes[0];
|
|
364
|
+
console.log('');
|
|
365
|
+
console.log(chalk.white(` First borehole summary (${first.boreholeId}):`));
|
|
366
|
+
keyValue(' Project', formatMaybe(first.projectName));
|
|
367
|
+
keyValue(' Ground elevation', first.groundElevation != null ? `${first.groundElevation} m` : 'Unavailable');
|
|
368
|
+
keyValue(' Coordinates', formatCoordinateSummary(first));
|
|
369
|
+
console.log(` ${formatMaybe(first.summary)}`);
|
|
370
|
+
}
|
|
371
|
+
console.log('');
|
|
372
|
+
}
|
|
373
|
+
function renderGeotechDocumentResultReport(result, options) {
|
|
374
|
+
heading(options?.title ?? 'Geotechnical Document Ingest');
|
|
375
|
+
keyValue('Document type', 'geotech-document');
|
|
376
|
+
keyValue('Source', options?.sourceLabel ?? result.source.fileName ?? result.source.filePath ?? 'Unknown');
|
|
377
|
+
keyValue('Pages processed', `${result.source.successfulPages}/${result.source.totalPages}`);
|
|
378
|
+
keyValue('Document class', result.documentClass ?? 'Unavailable');
|
|
379
|
+
keyValue('Materials extracted', String(result.materials.length));
|
|
380
|
+
keyValue('Parameters extracted', String(result.parameters.length));
|
|
381
|
+
keyValue('Confidence', `${result.confidence}%`);
|
|
382
|
+
if (result.parseStatus) {
|
|
383
|
+
keyValue('Parse status', result.parseStatus);
|
|
384
|
+
}
|
|
385
|
+
keyValue('Review required', result.reviewRequired ? 'Yes' : 'No');
|
|
386
|
+
keyValue('Auto proceed', result.canAutoProceed ? 'Yes' : 'No');
|
|
387
|
+
if (result.inspectionSummary) {
|
|
388
|
+
keyValue('PDF classes', formatClassificationSummary(result.inspectionSummary.pageClassificationCounts));
|
|
389
|
+
keyValue('Image-heavy pages', String(result.inspectionSummary.imageHeavyPageCount));
|
|
390
|
+
keyValue('Recovered OCR hints', String(result.inspectionSummary.ocrRecoveredPageCount));
|
|
391
|
+
}
|
|
392
|
+
renderPersistedReviewDetails(options?.persistedReview, options?.approval ?? null);
|
|
393
|
+
if (result.title || result.summary) {
|
|
394
|
+
console.log('');
|
|
395
|
+
console.log(chalk.white(' Document summary:'));
|
|
396
|
+
keyValue(' Title', formatMaybe(result.title));
|
|
397
|
+
console.log(` ${formatMaybe(result.summary)}`);
|
|
398
|
+
}
|
|
399
|
+
if (result.materials.length > 0) {
|
|
400
|
+
console.log('');
|
|
401
|
+
console.log(chalk.white(' Materials:'));
|
|
402
|
+
renderTable(['Kind', 'Description', 'USCS', 'Lithology'], result.materials.slice(0, 10).map((material) => [
|
|
403
|
+
material.kind,
|
|
404
|
+
material.description,
|
|
405
|
+
material.uscsSymbol ?? '-',
|
|
406
|
+
material.lithology ?? '-',
|
|
407
|
+
]));
|
|
408
|
+
}
|
|
409
|
+
if (result.classifications.length > 0) {
|
|
410
|
+
console.log('');
|
|
411
|
+
console.log(chalk.white(' Classifications:'));
|
|
412
|
+
renderTable(['System', 'Value', 'Context'], result.classifications.slice(0, 10).map((classification) => [
|
|
413
|
+
classification.system,
|
|
414
|
+
classification.value,
|
|
415
|
+
classification.context ?? '-',
|
|
416
|
+
]));
|
|
417
|
+
}
|
|
418
|
+
if (result.parameters.length > 0) {
|
|
419
|
+
console.log('');
|
|
420
|
+
console.log(chalk.white(' Parameters:'));
|
|
421
|
+
renderTable(['Parameter', 'Value', 'Unit', 'Material', 'Context'], result.parameters.slice(0, 12).map((parameter) => [
|
|
422
|
+
parameter.name,
|
|
423
|
+
parameter.valueText,
|
|
424
|
+
parameter.unit ?? '-',
|
|
425
|
+
parameter.material ?? '-',
|
|
426
|
+
parameter.context ?? '-',
|
|
427
|
+
]));
|
|
428
|
+
}
|
|
429
|
+
const reviewFindings = getReviewFindings(result);
|
|
430
|
+
renderReviewFindings(reviewFindings);
|
|
431
|
+
if (result.pageFailures.length > 0) {
|
|
432
|
+
console.log('');
|
|
433
|
+
console.log(chalk.white(' Page failures:'));
|
|
434
|
+
for (const failure of result.pageFailures) {
|
|
435
|
+
warn(failure);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const reviewFindingMessages = new Set(reviewFindings.map((finding) => finding.message));
|
|
439
|
+
const standaloneWarnings = result.warnings.filter((warning) => !result.pageFailures.includes(warning) && !reviewFindingMessages.has(warning));
|
|
440
|
+
if (standaloneWarnings.length > 0) {
|
|
441
|
+
console.log('');
|
|
442
|
+
console.log(chalk.white(' Warnings:'));
|
|
443
|
+
for (const warningMessage of standaloneWarnings.slice(0, 8)) {
|
|
444
|
+
warn(warningMessage);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if ((result.risks?.length ?? 0) > 0) {
|
|
448
|
+
console.log('');
|
|
449
|
+
console.log(chalk.white(' Risks:'));
|
|
450
|
+
for (const risk of (result.risks ?? []).slice(0, 6)) {
|
|
451
|
+
warn(risk);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if ((result.recommendations?.length ?? 0) > 0) {
|
|
455
|
+
console.log('');
|
|
456
|
+
console.log(chalk.white(' Recommendations:'));
|
|
457
|
+
for (const recommendation of (result.recommendations ?? []).slice(0, 6)) {
|
|
458
|
+
info(recommendation);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if ((result.contentChunks?.length ?? 0) > 0) {
|
|
462
|
+
console.log('');
|
|
463
|
+
console.log(chalk.white(' Report sections:'));
|
|
464
|
+
renderTable(['Pages', 'Section', 'Scope', 'Signal', 'Heading'], (result.contentChunks ?? []).slice(0, 8).map((chunk) => [
|
|
465
|
+
chunk.pageRange[0] === chunk.pageRange[1]
|
|
466
|
+
? String(chunk.pageRange[0])
|
|
467
|
+
: `${chunk.pageRange[0]}-${chunk.pageRange[1]}`,
|
|
468
|
+
chunk.sectionType ?? '-',
|
|
469
|
+
chunk.scope,
|
|
470
|
+
chunk.significance != null ? String(chunk.significance) : '-',
|
|
471
|
+
chunk.headingAncestry[0] ?? '-',
|
|
472
|
+
]));
|
|
473
|
+
}
|
|
474
|
+
console.log('');
|
|
475
|
+
}
|
|
476
|
+
function renderPersistedReviewRecord(record, projectId) {
|
|
477
|
+
const persistedReview = {
|
|
478
|
+
projectId,
|
|
479
|
+
datasetName: String(record.datasetName),
|
|
480
|
+
reviewId: String(record.reviewId),
|
|
481
|
+
createdAt: asOptionalTrimmedString(record.createdAt),
|
|
482
|
+
};
|
|
483
|
+
const approval = record.approval
|
|
484
|
+
? {
|
|
485
|
+
datasetName: record.approval.datasetName,
|
|
486
|
+
approvedAt: record.approval.approvedAt,
|
|
487
|
+
approvedBy: record.approval.approvedBy,
|
|
488
|
+
rationale: record.approval.rationale,
|
|
489
|
+
}
|
|
490
|
+
: null;
|
|
491
|
+
if (isGeotechDocumentReviewResult(record.result)) {
|
|
492
|
+
renderGeotechDocumentResultReport(record.result, {
|
|
493
|
+
title: 'Geotechnical Document Ingest Review',
|
|
494
|
+
sourceLabel: record.title,
|
|
495
|
+
persistedReview,
|
|
496
|
+
approval,
|
|
497
|
+
});
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
renderIngestResultReport(record.result, {
|
|
501
|
+
title: 'Geotechnical Ingest Review',
|
|
502
|
+
sourceLabel: record.title,
|
|
503
|
+
persistedReview,
|
|
504
|
+
approval,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
function buildPersistedReviewDossierDetails(record, projectId) {
|
|
508
|
+
return {
|
|
509
|
+
sourceLabel: record.title,
|
|
510
|
+
storedReview: {
|
|
511
|
+
projectId,
|
|
512
|
+
datasetName: String(record.datasetName),
|
|
513
|
+
reviewId: String(record.reviewId),
|
|
514
|
+
createdAt: asOptionalTrimmedString(record.createdAt),
|
|
515
|
+
},
|
|
516
|
+
approval: record.approval
|
|
517
|
+
? {
|
|
518
|
+
datasetName: record.approval.datasetName,
|
|
519
|
+
approvedAt: record.approval.approvedAt,
|
|
520
|
+
approvedBy: record.approval.approvedBy,
|
|
521
|
+
rationale: record.approval.rationale,
|
|
522
|
+
}
|
|
523
|
+
: null,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
function persistProjectIngestReview(projectId, result) {
|
|
527
|
+
// The store accepts both result shapes in source, but the local package types can lag that source during monorepo edits.
|
|
528
|
+
const persist = persistBoreholeIngestReview;
|
|
529
|
+
return persist(projectId, result);
|
|
530
|
+
}
|
|
531
|
+
function createPersistedReviewDryRun(projectId, datasetName) {
|
|
532
|
+
return {
|
|
533
|
+
kind: 'geotech-ingest-review-dry-run',
|
|
534
|
+
projectId,
|
|
535
|
+
datasetName,
|
|
536
|
+
sourceSelection: datasetName ? 'specific-dataset' : 'latest',
|
|
537
|
+
wouldLoadPersistedReview: true,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function createPersistedReviewPromotionDryRun(projectId, datasetName) {
|
|
541
|
+
return {
|
|
542
|
+
kind: 'geotech-ingest-review-promotion-dry-run',
|
|
543
|
+
projectId,
|
|
544
|
+
datasetName,
|
|
545
|
+
sourceSelection: datasetName ? 'specific-dataset' : 'latest',
|
|
546
|
+
wouldLoadPersistedReview: true,
|
|
547
|
+
wouldPromotePersistedReview: true,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function createPersistedReviewApprovalDryRun(projectId, datasetName, note, approvedBy) {
|
|
551
|
+
return {
|
|
552
|
+
kind: 'geotech-ingest-review-approval-dry-run',
|
|
553
|
+
projectId,
|
|
554
|
+
datasetName,
|
|
555
|
+
sourceSelection: datasetName ? 'specific-dataset' : 'latest',
|
|
556
|
+
wouldLoadPersistedReview: true,
|
|
557
|
+
wouldRecordApproval: true,
|
|
558
|
+
note,
|
|
559
|
+
approvedBy,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function createPersistedReviewApprovalLookupDryRun(projectId, reviewDatasetName, approvalDatasetName, latest = false) {
|
|
563
|
+
return {
|
|
564
|
+
kind: 'geotech-ingest-review-approval-lookup-dry-run',
|
|
565
|
+
projectId,
|
|
566
|
+
reviewDatasetName,
|
|
567
|
+
approvalDatasetName,
|
|
568
|
+
latest: latest || undefined,
|
|
569
|
+
wouldLoadPersistedReviewApproval: Boolean(approvalDatasetName || latest),
|
|
570
|
+
wouldListPersistedReviewApprovals: !approvalDatasetName && !latest,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function resolveCommandOptions(opts, commandLike, extraKeys = []) {
|
|
574
|
+
const resolvedOpts = typeof commandLike?.optsWithGlobals === 'function'
|
|
575
|
+
? commandLike.optsWithGlobals()
|
|
576
|
+
: undefined;
|
|
577
|
+
const resolved = {
|
|
578
|
+
...(isRecord(opts) ? opts : {}),
|
|
579
|
+
...(isRecord(resolvedOpts) ? resolvedOpts : {}),
|
|
580
|
+
};
|
|
581
|
+
for (const key of ['json', 'quiet', 'dryRun', 'output', ...extraKeys]) {
|
|
582
|
+
const value = getRawOptionValue(commandLike, key) ?? getCommandOptionValue(opts, key) ?? getCommandOptionValue(commandLike, key);
|
|
583
|
+
if (value !== undefined) {
|
|
584
|
+
resolved[key] = value;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return resolved;
|
|
588
|
+
}
|
|
589
|
+
function asStringArray(value) {
|
|
590
|
+
if (!Array.isArray(value)) {
|
|
591
|
+
return [];
|
|
592
|
+
}
|
|
593
|
+
return value.flatMap((item) => {
|
|
594
|
+
const normalized = asOptionalTrimmedString(item);
|
|
595
|
+
return normalized ? [normalized] : [];
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
function getNamedStringList(record, keys) {
|
|
599
|
+
for (const key of keys) {
|
|
600
|
+
const values = asStringArray(record[key]);
|
|
601
|
+
if (values.length > 0) {
|
|
602
|
+
return values;
|
|
603
|
+
}
|
|
604
|
+
const value = asOptionalTrimmedString(record[key]);
|
|
605
|
+
if (value) {
|
|
606
|
+
return [value];
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return [];
|
|
610
|
+
}
|
|
611
|
+
function getPromotionWarnings(record) {
|
|
612
|
+
const warnings = new Set(asStringArray(record.warnings));
|
|
613
|
+
if (Array.isArray(record.promotedBoreholes)) {
|
|
614
|
+
for (const item of record.promotedBoreholes) {
|
|
615
|
+
if (!isRecord(item)) {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
for (const warningMessage of asStringArray(item.warnings)) {
|
|
619
|
+
warnings.add(warningMessage);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return [...warnings];
|
|
624
|
+
}
|
|
625
|
+
function normalizePromotionResult(result) {
|
|
626
|
+
if (!isRecord(result)) {
|
|
627
|
+
return {
|
|
628
|
+
promotedDatasetNames: [],
|
|
629
|
+
promotedBoreholeIds: [],
|
|
630
|
+
supersededDatasetNames: [],
|
|
631
|
+
snapshotDatasetNames: [],
|
|
632
|
+
warnings: [],
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const promotedDatasetNames = getNamedStringList(result, [
|
|
636
|
+
'promotedDatasetNames',
|
|
637
|
+
'datasetNames',
|
|
638
|
+
]);
|
|
639
|
+
const promotedBoreholeIds = getNamedStringList(result, [
|
|
640
|
+
'promotedBoreholeIds',
|
|
641
|
+
'boreholeIds',
|
|
642
|
+
]);
|
|
643
|
+
const supersededDatasetNames = getNamedStringList(result, [
|
|
644
|
+
'supersededDatasetNames',
|
|
645
|
+
'supersededDatasets',
|
|
646
|
+
]);
|
|
647
|
+
const snapshotDatasetNames = getNamedStringList(result, [
|
|
648
|
+
'snapshotDatasetNames',
|
|
649
|
+
'snapshotDatasets',
|
|
650
|
+
]);
|
|
651
|
+
if (promotedDatasetNames.length === 0 && Array.isArray(result.promotedBoreholes)) {
|
|
652
|
+
for (const item of result.promotedBoreholes) {
|
|
653
|
+
if (!isRecord(item)) {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const rawDatasetName = asOptionalTrimmedString(item.rawDatasetName);
|
|
657
|
+
const soilProfileDatasetName = asOptionalTrimmedString(item.soilProfileDatasetName);
|
|
658
|
+
if (rawDatasetName) {
|
|
659
|
+
promotedDatasetNames.push(rawDatasetName);
|
|
660
|
+
}
|
|
661
|
+
if (soilProfileDatasetName) {
|
|
662
|
+
promotedDatasetNames.push(soilProfileDatasetName);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (promotedBoreholeIds.length === 0 && Array.isArray(result.promotedBoreholes)) {
|
|
667
|
+
for (const item of result.promotedBoreholes) {
|
|
668
|
+
if (!isRecord(item)) {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
const boreholeId = asOptionalTrimmedString(item.boreholeId);
|
|
672
|
+
if (boreholeId) {
|
|
673
|
+
promotedBoreholeIds.push(boreholeId);
|
|
674
|
+
}
|
|
675
|
+
for (const supersededDatasetName of asStringArray(item.supersededDatasetNames)) {
|
|
676
|
+
supersededDatasetNames.push(supersededDatasetName);
|
|
677
|
+
}
|
|
678
|
+
for (const snapshotDatasetName of asStringArray(item.snapshotDatasetNames)) {
|
|
679
|
+
snapshotDatasetNames.push(snapshotDatasetName);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
projectId: asOptionalTrimmedString(result.projectId),
|
|
685
|
+
sourceDatasetName: asOptionalTrimmedString(result.sourceDatasetName ?? result.sourceReviewDatasetName ?? result.datasetName),
|
|
686
|
+
approvalDatasetName: asOptionalTrimmedString(result.approvalDatasetName),
|
|
687
|
+
approvedAt: asOptionalTrimmedString(result.approvedAt),
|
|
688
|
+
approvedBy: asOptionalTrimmedString(result.approvedBy),
|
|
689
|
+
approvalRationale: asOptionalTrimmedString(result.approvalRationale),
|
|
690
|
+
promotedDatasetNames: [...new Set(promotedDatasetNames)],
|
|
691
|
+
promotedBoreholeIds: [...new Set(promotedBoreholeIds)],
|
|
692
|
+
supersededDatasetNames: [...new Set(supersededDatasetNames)],
|
|
693
|
+
snapshotDatasetNames: [...new Set(snapshotDatasetNames)],
|
|
694
|
+
warnings: getPromotionWarnings(result),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
function renderPromotionResult(result, details) {
|
|
698
|
+
const normalized = normalizePromotionResult(result);
|
|
699
|
+
heading('Persisted Ingest Review Promotion');
|
|
700
|
+
keyValue('Project', normalized.projectId ?? details.projectId);
|
|
701
|
+
keyValue('Review dataset', normalized.sourceDatasetName ?? details.datasetName ?? 'Latest persisted ingest review');
|
|
702
|
+
if (normalized.approvalDatasetName) {
|
|
703
|
+
keyValue('Approval dataset', normalized.approvalDatasetName);
|
|
704
|
+
keyValue('Approved', normalized.approvedAt ?? 'Unknown');
|
|
705
|
+
keyValue('Approved by', normalized.approvedBy ?? 'Unspecified');
|
|
706
|
+
}
|
|
707
|
+
keyValue('Promoted boreholes', String(normalized.promotedBoreholeIds.length));
|
|
708
|
+
keyValue('Promoted datasets', String(normalized.promotedDatasetNames.length));
|
|
709
|
+
if (normalized.supersededDatasetNames.length > 0) {
|
|
710
|
+
keyValue('Superseded datasets', String(normalized.supersededDatasetNames.length));
|
|
711
|
+
}
|
|
712
|
+
if (normalized.snapshotDatasetNames.length > 0) {
|
|
713
|
+
keyValue('Rollback snapshots', String(normalized.snapshotDatasetNames.length));
|
|
714
|
+
}
|
|
715
|
+
if (normalized.promotedDatasetNames.length > 0) {
|
|
716
|
+
console.log('');
|
|
717
|
+
console.log(chalk.white(' Project datasets:'));
|
|
718
|
+
renderTable(['Dataset'], normalized.promotedDatasetNames.map((datasetName) => [datasetName]));
|
|
719
|
+
}
|
|
720
|
+
if (normalized.promotedBoreholeIds.length > 0) {
|
|
721
|
+
console.log('');
|
|
722
|
+
console.log(chalk.white(' Promoted boreholes:'));
|
|
723
|
+
renderTable(['Borehole'], normalized.promotedBoreholeIds.map((boreholeId) => [boreholeId]));
|
|
724
|
+
}
|
|
725
|
+
if (normalized.snapshotDatasetNames.length > 0) {
|
|
726
|
+
console.log('');
|
|
727
|
+
console.log(chalk.white(' Snapshot datasets:'));
|
|
728
|
+
renderTable(['Dataset'], normalized.snapshotDatasetNames.map((datasetName) => [datasetName]));
|
|
729
|
+
}
|
|
730
|
+
if (normalized.warnings.length > 0) {
|
|
731
|
+
console.log('');
|
|
732
|
+
console.log(chalk.white(' Warnings:'));
|
|
733
|
+
for (const warningMessage of normalized.warnings.slice(0, 10)) {
|
|
734
|
+
warn(warningMessage);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (normalized.approvalRationale) {
|
|
738
|
+
console.log('');
|
|
739
|
+
console.log(chalk.white(' Approval rationale:'));
|
|
740
|
+
console.log(` ${normalized.approvalRationale}`);
|
|
741
|
+
}
|
|
742
|
+
console.log('');
|
|
743
|
+
}
|
|
744
|
+
function renderApprovalResult(approval, details) {
|
|
745
|
+
heading('Persisted Ingest Review Approval');
|
|
746
|
+
keyValue('Project', approval.projectId ?? details.projectId);
|
|
747
|
+
keyValue('Review dataset', approval.reviewDatasetName ?? details.datasetName ?? 'Latest persisted ingest review');
|
|
748
|
+
keyValue('Approval dataset', approval.datasetName);
|
|
749
|
+
keyValue('Approval ID', approval.approvalId);
|
|
750
|
+
keyValue('Approved', approval.approvedAt);
|
|
751
|
+
keyValue('Approved by', approval.approvedBy ?? 'Unspecified');
|
|
752
|
+
console.log('');
|
|
753
|
+
console.log(chalk.white(' Rationale:'));
|
|
754
|
+
console.log(` ${approval.rationale}`);
|
|
755
|
+
console.log('');
|
|
756
|
+
}
|
|
757
|
+
function renderApprovalHistory(approvals, details) {
|
|
758
|
+
heading('Persisted Ingest Review Approvals');
|
|
759
|
+
keyValue('Project', details.projectId);
|
|
760
|
+
if (details.reviewDatasetName) {
|
|
761
|
+
keyValue('Review dataset', details.reviewDatasetName);
|
|
762
|
+
}
|
|
763
|
+
keyValue('Count', String(approvals.length));
|
|
764
|
+
console.log('');
|
|
765
|
+
renderTable(['Approval dataset', 'Review dataset', 'Approved', 'By', 'Latest'], approvals.map((approval) => [
|
|
766
|
+
approval.datasetName,
|
|
767
|
+
approval.reviewDatasetName,
|
|
768
|
+
approval.approvedAt,
|
|
769
|
+
approval.approvedBy ?? 'Unspecified',
|
|
770
|
+
approval.isLatestForReview ? 'Yes' : 'No',
|
|
771
|
+
]));
|
|
772
|
+
if (approvals.length > 0) {
|
|
773
|
+
console.log('');
|
|
774
|
+
console.log(chalk.white(' Latest rationale:'));
|
|
775
|
+
console.log(` ${approvals[0].rationale}`);
|
|
776
|
+
}
|
|
777
|
+
console.log('');
|
|
778
|
+
}
|
|
779
|
+
function isIngestJobStatus(value) {
|
|
780
|
+
return value === 'queued' || value === 'running' || value === 'completed' || value === 'failed' || value === 'canceled';
|
|
781
|
+
}
|
|
782
|
+
function isIngestJobPageStatus(value) {
|
|
783
|
+
return value === 'pending' || value === 'completed' || value === 'failed';
|
|
784
|
+
}
|
|
785
|
+
function normalizeIngestJobRecord(value) {
|
|
786
|
+
if (!isRecord(value)) {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
const jobId = asOptionalTrimmedString(value.jobId);
|
|
790
|
+
const documentType = asOptionalTrimmedString(value.documentType);
|
|
791
|
+
const status = value.status;
|
|
792
|
+
const source = isRecord(value.source) ? value.source : null;
|
|
793
|
+
const processing = isRecord(value.processing) ? value.processing : null;
|
|
794
|
+
const request = isRecord(value.request) ? value.request : {};
|
|
795
|
+
const execution = isRecord(value.execution) ? value.execution : {};
|
|
796
|
+
const checkpoints = isRecord(value.checkpoints) ? value.checkpoints : null;
|
|
797
|
+
if (!jobId
|
|
798
|
+
|| (documentType !== 'borehole-log' && documentType !== 'geotech-document')
|
|
799
|
+
|| !isIngestJobStatus(status)
|
|
800
|
+
|| !source
|
|
801
|
+
|| !processing
|
|
802
|
+
|| !checkpoints) {
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
const pages = Array.isArray(checkpoints.pages)
|
|
806
|
+
? checkpoints.pages.flatMap((page) => {
|
|
807
|
+
if (!isRecord(page) || !isIngestJobPageStatus(page.status)) {
|
|
808
|
+
return [];
|
|
809
|
+
}
|
|
810
|
+
const pageNumber = asOptionalPositiveInteger(page.pageNumber);
|
|
811
|
+
if (pageNumber == null) {
|
|
812
|
+
return [];
|
|
813
|
+
}
|
|
814
|
+
return [{
|
|
815
|
+
pageNumber,
|
|
816
|
+
status: page.status,
|
|
817
|
+
classification: asOptionalTrimmedString(page.classification),
|
|
818
|
+
downgraded: page.downgraded === true,
|
|
819
|
+
error: asOptionalTrimmedString(page.error),
|
|
820
|
+
}];
|
|
821
|
+
})
|
|
822
|
+
: [];
|
|
823
|
+
const pageCounts = { pending: 0, completed: 0, failed: 0 };
|
|
824
|
+
for (const page of pages) {
|
|
825
|
+
pageCounts[page.status] += 1;
|
|
826
|
+
}
|
|
827
|
+
const result = isRecord(value.result)
|
|
828
|
+
? {
|
|
829
|
+
ingestResult: isRecord(value.result.ingestResult)
|
|
830
|
+
? value.result.ingestResult
|
|
831
|
+
: undefined,
|
|
832
|
+
persistedReview: isRecord(value.result.persistedReview)
|
|
833
|
+
? {
|
|
834
|
+
datasetName: asOptionalTrimmedString(value.result.persistedReview.datasetName) ?? '',
|
|
835
|
+
reviewId: asOptionalTrimmedString(value.result.persistedReview.reviewId) ?? '',
|
|
836
|
+
createdAt: asOptionalTrimmedString(value.result.persistedReview.createdAt),
|
|
837
|
+
}
|
|
838
|
+
: undefined,
|
|
839
|
+
}
|
|
840
|
+
: undefined;
|
|
841
|
+
return {
|
|
842
|
+
jobId,
|
|
843
|
+
documentType,
|
|
844
|
+
status,
|
|
845
|
+
createdAt: asOptionalTrimmedString(value.createdAt),
|
|
846
|
+
updatedAt: asOptionalTrimmedString(value.updatedAt),
|
|
847
|
+
startedAt: asOptionalTrimmedString(value.startedAt),
|
|
848
|
+
completedAt: asOptionalTrimmedString(value.completedAt),
|
|
849
|
+
canceledAt: asOptionalTrimmedString(value.canceledAt),
|
|
850
|
+
source: {
|
|
851
|
+
filePath: asOptionalTrimmedString(source.filePath),
|
|
852
|
+
fileName: asOptionalTrimmedString(source.fileName),
|
|
853
|
+
totalPages: asOptionalPositiveInteger(source.totalPages) ?? 0,
|
|
854
|
+
weightedPageCost: typeof source.weightedPageCost === 'number' && Number.isFinite(source.weightedPageCost)
|
|
855
|
+
? source.weightedPageCost
|
|
856
|
+
: 0,
|
|
857
|
+
},
|
|
858
|
+
processing: {
|
|
859
|
+
pagePreprocessingConcurrency: asOptionalPositiveInteger(processing.pagePreprocessingConcurrency) ?? 0,
|
|
860
|
+
chunkExtractionConcurrency: asOptionalPositiveInteger(processing.chunkExtractionConcurrency) ?? 0,
|
|
861
|
+
},
|
|
862
|
+
request: {
|
|
863
|
+
projectId: asOptionalTrimmedString(request.projectId),
|
|
864
|
+
overrideBoreholeId: asOptionalTrimmedString(request.overrideBoreholeId),
|
|
865
|
+
},
|
|
866
|
+
execution: {
|
|
867
|
+
pid: asOptionalPositiveInteger(execution.pid),
|
|
868
|
+
runCount: asOptionalPositiveInteger(execution.runCount) ?? 0,
|
|
869
|
+
lastHeartbeatAt: asOptionalTrimmedString(execution.lastHeartbeatAt),
|
|
870
|
+
lastError: asOptionalTrimmedString(execution.lastError),
|
|
871
|
+
cancelRequested: execution.cancelRequested === true,
|
|
872
|
+
},
|
|
873
|
+
pageCounts,
|
|
874
|
+
pages,
|
|
875
|
+
result,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
function renderIngestJobRecord(job, options) {
|
|
879
|
+
heading(options?.title ?? 'Geotechnical Ingest Job');
|
|
880
|
+
keyValue('Job ID', job.jobId);
|
|
881
|
+
keyValue('Status', job.status);
|
|
882
|
+
keyValue('Document type', job.documentType);
|
|
883
|
+
keyValue('Source', job.source.fileName ?? job.source.filePath ?? 'Unknown');
|
|
884
|
+
keyValue('Pages', String(job.source.totalPages));
|
|
885
|
+
keyValue('Weighted page cost', String(job.source.weightedPageCost));
|
|
886
|
+
keyValue('Completed pages', String(job.pageCounts.completed));
|
|
887
|
+
keyValue('Failed pages', String(job.pageCounts.failed));
|
|
888
|
+
keyValue('Pending pages', String(job.pageCounts.pending));
|
|
889
|
+
if (job.request.projectId) {
|
|
890
|
+
keyValue('Project', job.request.projectId);
|
|
891
|
+
}
|
|
892
|
+
if (job.request.overrideBoreholeId) {
|
|
893
|
+
keyValue('Override borehole ID', job.request.overrideBoreholeId);
|
|
894
|
+
}
|
|
895
|
+
if (job.processing.pagePreprocessingConcurrency > 0) {
|
|
896
|
+
keyValue('Preprocess concurrency', String(job.processing.pagePreprocessingConcurrency));
|
|
897
|
+
}
|
|
898
|
+
if (job.processing.chunkExtractionConcurrency > 0) {
|
|
899
|
+
keyValue('Extraction concurrency', String(job.processing.chunkExtractionConcurrency));
|
|
900
|
+
}
|
|
901
|
+
if (job.createdAt) {
|
|
902
|
+
keyValue('Created', job.createdAt);
|
|
903
|
+
}
|
|
904
|
+
if (job.startedAt) {
|
|
905
|
+
keyValue('Started', job.startedAt);
|
|
906
|
+
}
|
|
907
|
+
if (job.completedAt) {
|
|
908
|
+
keyValue('Completed', job.completedAt);
|
|
909
|
+
}
|
|
910
|
+
if (job.canceledAt) {
|
|
911
|
+
keyValue('Canceled', job.canceledAt);
|
|
912
|
+
}
|
|
913
|
+
if (job.execution.pid) {
|
|
914
|
+
keyValue('Worker PID', String(job.execution.pid));
|
|
915
|
+
}
|
|
916
|
+
if (job.execution.runCount > 0) {
|
|
917
|
+
keyValue('Run count', String(job.execution.runCount));
|
|
918
|
+
}
|
|
919
|
+
if (job.execution.lastHeartbeatAt) {
|
|
920
|
+
keyValue('Last heartbeat', job.execution.lastHeartbeatAt);
|
|
921
|
+
}
|
|
922
|
+
if (job.result?.persistedReview?.datasetName) {
|
|
923
|
+
keyValue('Stored review', job.result.persistedReview.datasetName);
|
|
924
|
+
}
|
|
925
|
+
const downgradedPages = job.pages.filter((page) => page.downgraded);
|
|
926
|
+
const failedPages = job.pages.filter((page) => page.status === 'failed');
|
|
927
|
+
if (failedPages.length > 0) {
|
|
928
|
+
console.log('');
|
|
929
|
+
console.log(chalk.white(' Failed pages:'));
|
|
930
|
+
for (const page of failedPages.slice(0, 10)) {
|
|
931
|
+
const message = page.downgraded
|
|
932
|
+
? `Page ${page.pageNumber} downgraded to manual review${page.error ? `: ${page.error}` : ''}`
|
|
933
|
+
: `Page ${page.pageNumber}${page.error ? `: ${page.error}` : ''}`;
|
|
934
|
+
(page.downgraded ? warn : error)(message);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (job.execution.lastError) {
|
|
938
|
+
console.log('');
|
|
939
|
+
console.log(chalk.white(' Worker error:'));
|
|
940
|
+
error(job.execution.lastError);
|
|
941
|
+
}
|
|
942
|
+
else if (downgradedPages.length > 0) {
|
|
943
|
+
console.log('');
|
|
944
|
+
console.log(chalk.white(' Downgraded pages:'));
|
|
945
|
+
warn(`${downgradedPages.length} slow visual page(s) were downgraded to manual review.`);
|
|
946
|
+
}
|
|
947
|
+
if (options?.includeCommands) {
|
|
948
|
+
console.log('');
|
|
949
|
+
console.log(chalk.white(' Next steps:'));
|
|
950
|
+
info(`geotech ingest status ${job.jobId}`);
|
|
951
|
+
info(`geotech ingest wait ${job.jobId}`);
|
|
952
|
+
info(`geotech ingest result ${job.jobId}`);
|
|
953
|
+
info(`geotech ingest wait ${job.jobId} --format html`);
|
|
954
|
+
info(`geotech ingest result ${job.jobId} --format html`);
|
|
955
|
+
info(`geotech ingest resume ${job.jobId}`);
|
|
956
|
+
info(`geotech ingest cancel ${job.jobId}`);
|
|
957
|
+
}
|
|
958
|
+
console.log('');
|
|
959
|
+
}
|
|
960
|
+
function renderIngestJobResult(job) {
|
|
961
|
+
if (!job.result?.ingestResult) {
|
|
962
|
+
throw new Error(`Persisted ingest job "${job.jobId}" does not have a completed result yet.`);
|
|
963
|
+
}
|
|
964
|
+
const persistedReview = job.result.persistedReview
|
|
965
|
+
? {
|
|
966
|
+
projectId: job.request.projectId ?? 'Unknown',
|
|
967
|
+
datasetName: job.result.persistedReview.datasetName,
|
|
968
|
+
reviewId: job.result.persistedReview.reviewId,
|
|
969
|
+
createdAt: job.result.persistedReview.createdAt,
|
|
970
|
+
}
|
|
971
|
+
: null;
|
|
972
|
+
if (job.result.ingestResult.documentType === 'geotech-document') {
|
|
973
|
+
renderGeotechDocumentResultReport(job.result.ingestResult, {
|
|
974
|
+
title: 'Geotechnical Document Ingest Result',
|
|
975
|
+
sourceLabel: job.result.ingestResult.source.fileName ?? job.result.ingestResult.source.filePath,
|
|
976
|
+
persistedReview,
|
|
977
|
+
});
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
renderIngestResultReport(job.result.ingestResult, {
|
|
981
|
+
title: 'Geotechnical Ingest Result',
|
|
982
|
+
sourceLabel: job.result.ingestResult.source.fileName ?? job.result.ingestResult.source.filePath,
|
|
983
|
+
persistedReview,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
export function registerIngestCommand(program) {
|
|
987
|
+
const cmd = new Command('ingest')
|
|
988
|
+
.description('Extract structured geotechnical data from image/PDF documents')
|
|
989
|
+
.enablePositionalOptions()
|
|
990
|
+
.argument('<file>', 'Path to a geotechnical image or PDF document')
|
|
991
|
+
.option('--type <type>', 'Document type to ingest', 'borehole-log')
|
|
992
|
+
.option('--format <format>', 'Result presentation format: plain or html', 'plain')
|
|
993
|
+
.option('--borehole-id <id>', 'Override borehole ID for a single continuous borehole log')
|
|
994
|
+
.option('--project <id>', 'Persist the ingest review into a stored project')
|
|
995
|
+
.action(async (filePath, opts) => {
|
|
996
|
+
const flags = getGlobalFlags(opts);
|
|
997
|
+
const outputFormat = resolveIngestPresentationFormat(opts.format);
|
|
998
|
+
assertIngestPresentationMode(flags, outputFormat);
|
|
999
|
+
const wantsHtmlDossier = shouldRenderHtmlDossier(outputFormat, flags.output);
|
|
1000
|
+
const documentType = String(opts.type ?? 'borehole-log').toLowerCase();
|
|
1001
|
+
const supportedTypes = new Set(['borehole-log', 'geotech-document']);
|
|
1002
|
+
if (!supportedTypes.has(documentType)) {
|
|
1003
|
+
throw new Error(`Unsupported ingest type "${documentType}". This MVP currently supports --type borehole-log and --type geotech-document.`);
|
|
1004
|
+
}
|
|
1005
|
+
let spinner = null;
|
|
1006
|
+
try {
|
|
1007
|
+
const file = readVisionInput(filePath);
|
|
1008
|
+
describeVisionInput(file, flags);
|
|
1009
|
+
let countedPdfPages = null;
|
|
1010
|
+
if (file.kind === 'pdf') {
|
|
1011
|
+
try {
|
|
1012
|
+
countedPdfPages = await countPdfPages(filePath);
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
countedPdfPages = null;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
const shouldShortCircuitAsyncInspection = file.kind === 'pdf'
|
|
1019
|
+
&& countedPdfPages != null
|
|
1020
|
+
&& countedPdfPages > 5;
|
|
1021
|
+
const inspection = file.kind === 'pdf' && (!shouldShortCircuitAsyncInspection || countedPdfPages == null)
|
|
1022
|
+
? inspectPdfDocument(filePath)
|
|
1023
|
+
: null;
|
|
1024
|
+
const effectiveInspection = inspection && inspection.totalPages > 0 ? inspection : null;
|
|
1025
|
+
const totalPages = countedPdfPages
|
|
1026
|
+
?? effectiveInspection?.totalPages
|
|
1027
|
+
?? 1;
|
|
1028
|
+
const weightedPageCost = file.kind === 'pdf'
|
|
1029
|
+
? (effectiveInspection ? computeWeightedPdfPageCost(effectiveInspection) : totalPages)
|
|
1030
|
+
: 1;
|
|
1031
|
+
const shouldRunAsJob = file.kind === 'pdf'
|
|
1032
|
+
&& (shouldShortCircuitAsyncInspection || shouldUseAsyncIngestJob(effectiveInspection, totalPages));
|
|
1033
|
+
if (flags.dryRun) {
|
|
1034
|
+
if (shouldRunAsJob) {
|
|
1035
|
+
const dryRun = {
|
|
1036
|
+
kind: 'geotech-ingest-job-dry-run',
|
|
1037
|
+
documentType,
|
|
1038
|
+
source: {
|
|
1039
|
+
filePath,
|
|
1040
|
+
inputKind: 'pdf',
|
|
1041
|
+
},
|
|
1042
|
+
projectId: opts.project,
|
|
1043
|
+
totalPages,
|
|
1044
|
+
weightedPageCost,
|
|
1045
|
+
wouldCreateBackgroundJob: true,
|
|
1046
|
+
pagePreprocessingConcurrency: 2,
|
|
1047
|
+
chunkExtractionConcurrency: resolvePersistedIngestJobExtractionConcurrency(buildLLMConfig()),
|
|
1048
|
+
pageClassifications: effectiveInspection?.pages.map((page) => ({
|
|
1049
|
+
pageNumber: page.pageNumber,
|
|
1050
|
+
classification: page.classification,
|
|
1051
|
+
})) ?? [],
|
|
1052
|
+
overrideBoreholeId: opts.boreholeId,
|
|
1053
|
+
};
|
|
1054
|
+
if (flags.json) {
|
|
1055
|
+
renderJSON(dryRun);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
heading('Geotechnical Ingest Job Dry Run');
|
|
1059
|
+
keyValue('Document type', documentType);
|
|
1060
|
+
keyValue('Source', filePath);
|
|
1061
|
+
keyValue('Input kind', 'pdf');
|
|
1062
|
+
keyValue('Pages', String(totalPages));
|
|
1063
|
+
keyValue('Weighted page cost', String(weightedPageCost));
|
|
1064
|
+
keyValue('Would create background job', 'Yes');
|
|
1065
|
+
if (opts.project) {
|
|
1066
|
+
keyValue('Project', String(opts.project));
|
|
1067
|
+
}
|
|
1068
|
+
if (opts.boreholeId) {
|
|
1069
|
+
keyValue('Override borehole ID', String(opts.boreholeId));
|
|
1070
|
+
}
|
|
1071
|
+
console.log('');
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
const dryRun = {
|
|
1075
|
+
kind: 'geotech-ingest-dry-run',
|
|
1076
|
+
documentType,
|
|
1077
|
+
source: {
|
|
1078
|
+
filePath,
|
|
1079
|
+
inputKind: file.kind === 'pdf' ? 'pdf' : 'image',
|
|
1080
|
+
},
|
|
1081
|
+
wouldUseHostedVision: true,
|
|
1082
|
+
projectId: opts.project,
|
|
1083
|
+
totalPages,
|
|
1084
|
+
pageClassifications: effectiveInspection?.pages.map((page) => ({
|
|
1085
|
+
pageNumber: page.pageNumber,
|
|
1086
|
+
classification: page.classification,
|
|
1087
|
+
})) ?? Array.from({ length: totalPages }, (_, index) => ({
|
|
1088
|
+
pageNumber: index + 1,
|
|
1089
|
+
classification: 'n/a',
|
|
1090
|
+
})),
|
|
1091
|
+
overrideBoreholeId: opts.boreholeId,
|
|
1092
|
+
};
|
|
1093
|
+
if (flags.json) {
|
|
1094
|
+
renderJSON(dryRun);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
heading('Geotechnical Ingest Dry Run');
|
|
1098
|
+
keyValue('Document type', documentType);
|
|
1099
|
+
keyValue('Source', filePath);
|
|
1100
|
+
keyValue('Input kind', file.kind === 'pdf' ? 'pdf' : 'image');
|
|
1101
|
+
keyValue('Pages', String(dryRun.totalPages));
|
|
1102
|
+
if (opts.project) {
|
|
1103
|
+
keyValue('Project', String(opts.project));
|
|
1104
|
+
}
|
|
1105
|
+
if (effectiveInspection) {
|
|
1106
|
+
keyValue('PDF classes', formatClassificationSummary(Object.fromEntries(effectiveInspection.pages.reduce((map, page) => {
|
|
1107
|
+
map.set(page.classification, (map.get(page.classification) ?? 0) + 1);
|
|
1108
|
+
return map;
|
|
1109
|
+
}, new Map()))));
|
|
1110
|
+
}
|
|
1111
|
+
if (opts.boreholeId) {
|
|
1112
|
+
keyValue('Override borehole ID', String(opts.boreholeId));
|
|
1113
|
+
}
|
|
1114
|
+
console.log('');
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (shouldRunAsJob) {
|
|
1118
|
+
if (wantsHtmlDossier && flags.output) {
|
|
1119
|
+
throw new Error('HTML ingest dossiers are generated from completed results. Start the job first, then run geotech ingest wait <jobId> --format html --output <file>.');
|
|
1120
|
+
}
|
|
1121
|
+
spinner = startProgress(flags, 'Creating resumable ingest job...');
|
|
1122
|
+
const config = buildLLMConfig();
|
|
1123
|
+
const job = createAndStartPersistedIngestJob({
|
|
1124
|
+
documentType: documentType,
|
|
1125
|
+
filePath,
|
|
1126
|
+
inspection: effectiveInspection,
|
|
1127
|
+
config,
|
|
1128
|
+
projectId: opts.project,
|
|
1129
|
+
overrideBoreholeId: opts.boreholeId,
|
|
1130
|
+
});
|
|
1131
|
+
const normalizedJob = normalizeIngestJobRecord(job);
|
|
1132
|
+
spinner?.succeed(`Ingest job started: ${job.jobId}`);
|
|
1133
|
+
if (flags.json) {
|
|
1134
|
+
renderJSON(job);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (normalizedJob) {
|
|
1138
|
+
renderIngestJobRecord(normalizedJob, {
|
|
1139
|
+
title: 'Geotechnical Ingest Job Started',
|
|
1140
|
+
includeCommands: true,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
if (flags.output) {
|
|
1144
|
+
writeFileSync(flags.output, JSON.stringify(job, null, 2));
|
|
1145
|
+
success(`Job details saved to ${flags.output}`);
|
|
1146
|
+
}
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
spinner = startProgress(flags, 'Running geotechnical ingest...');
|
|
1150
|
+
const config = buildLLMConfig();
|
|
1151
|
+
const requestDetails = {
|
|
1152
|
+
prompt: documentType === 'borehole-log'
|
|
1153
|
+
? 'Extract structured borehole log data.'
|
|
1154
|
+
: 'Extract structured geology, lithology, and geotechnical engineering parameters from this document.',
|
|
1155
|
+
systemPrompt: 'You are analyzing a geotechnical image.',
|
|
1156
|
+
temperature: 0.1,
|
|
1157
|
+
maxTokens: 1500,
|
|
1158
|
+
};
|
|
1159
|
+
const result = file.kind === 'pdf'
|
|
1160
|
+
? await (async () => {
|
|
1161
|
+
const pageInputs = await readVisionPdfPageInputs(filePath, { inspection: effectiveInspection });
|
|
1162
|
+
for (const pageInput of pageInputs) {
|
|
1163
|
+
maybeCheckHostedBetaVisionPayload(config, pageInput, requestDetails);
|
|
1164
|
+
}
|
|
1165
|
+
return documentType === 'borehole-log'
|
|
1166
|
+
? ingestBoreholeLogDocument({
|
|
1167
|
+
config,
|
|
1168
|
+
source: {
|
|
1169
|
+
filePath,
|
|
1170
|
+
fileName: basename(filePath),
|
|
1171
|
+
inputKind: 'pdf',
|
|
1172
|
+
},
|
|
1173
|
+
overrideBoreholeId: opts.boreholeId,
|
|
1174
|
+
inspection: effectiveInspection,
|
|
1175
|
+
pages: pageInputs,
|
|
1176
|
+
})
|
|
1177
|
+
: ingestGeotechDocument({
|
|
1178
|
+
config,
|
|
1179
|
+
source: {
|
|
1180
|
+
filePath,
|
|
1181
|
+
fileName: basename(filePath),
|
|
1182
|
+
inputKind: 'pdf',
|
|
1183
|
+
},
|
|
1184
|
+
inspection: effectiveInspection,
|
|
1185
|
+
pages: pageInputs,
|
|
1186
|
+
});
|
|
1187
|
+
})()
|
|
1188
|
+
: await (async () => {
|
|
1189
|
+
maybeCheckHostedBetaVisionPayload(config, file, requestDetails);
|
|
1190
|
+
return documentType === 'borehole-log'
|
|
1191
|
+
? ingestBoreholeLogDocument({
|
|
1192
|
+
config,
|
|
1193
|
+
source: {
|
|
1194
|
+
filePath,
|
|
1195
|
+
fileName: basename(filePath),
|
|
1196
|
+
inputKind: 'image',
|
|
1197
|
+
},
|
|
1198
|
+
overrideBoreholeId: opts.boreholeId,
|
|
1199
|
+
image: file,
|
|
1200
|
+
})
|
|
1201
|
+
: ingestGeotechDocument({
|
|
1202
|
+
config,
|
|
1203
|
+
source: {
|
|
1204
|
+
filePath,
|
|
1205
|
+
fileName: basename(filePath),
|
|
1206
|
+
inputKind: 'image',
|
|
1207
|
+
},
|
|
1208
|
+
image: file,
|
|
1209
|
+
});
|
|
1210
|
+
})();
|
|
1211
|
+
const boreholeResult = documentType === 'borehole-log'
|
|
1212
|
+
? result
|
|
1213
|
+
: null;
|
|
1214
|
+
const geotechDocumentResult = documentType === 'geotech-document'
|
|
1215
|
+
? result
|
|
1216
|
+
: null;
|
|
1217
|
+
const persistedReview = opts.project
|
|
1218
|
+
? persistProjectIngestReview(String(opts.project), result)
|
|
1219
|
+
: null;
|
|
1220
|
+
spinner?.succeed(boreholeResult
|
|
1221
|
+
? `Ingest complete: ${boreholeResult.boreholes.length} borehole(s), ${boreholeResult.source.successfulPages}/${boreholeResult.source.totalPages} page(s) processed`
|
|
1222
|
+
: `Ingest complete: ${geotechDocumentResult?.materials.length ?? 0} material observation(s), ${geotechDocumentResult?.parameters.length ?? 0} parameter(s), ${geotechDocumentResult?.source.successfulPages ?? 0}/${geotechDocumentResult?.source.totalPages ?? 0} page(s) processed`);
|
|
1223
|
+
if (flags.json) {
|
|
1224
|
+
renderJSON(result);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const persistedReviewDetails = persistedReview
|
|
1228
|
+
? {
|
|
1229
|
+
projectId: String(opts.project),
|
|
1230
|
+
datasetName: persistedReview.datasetName,
|
|
1231
|
+
reviewId: persistedReview.reviewId,
|
|
1232
|
+
createdAt: persistedReview.createdAt,
|
|
1233
|
+
}
|
|
1234
|
+
: null;
|
|
1235
|
+
if (boreholeResult) {
|
|
1236
|
+
renderIngestResultReport(boreholeResult, {
|
|
1237
|
+
sourceLabel: boreholeResult.source.fileName ?? boreholeResult.source.filePath ?? filePath,
|
|
1238
|
+
persistedReview: persistedReviewDetails,
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
else if (geotechDocumentResult) {
|
|
1242
|
+
renderGeotechDocumentResultReport(geotechDocumentResult, {
|
|
1243
|
+
sourceLabel: geotechDocumentResult.source.fileName ?? geotechDocumentResult.source.filePath ?? filePath,
|
|
1244
|
+
persistedReview: persistedReviewDetails,
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
if (wantsHtmlDossier) {
|
|
1248
|
+
writeHtmlDossier(result, {
|
|
1249
|
+
outputPath: flags.output,
|
|
1250
|
+
sourceLabel: result.source.fileName ?? result.source.filePath ?? filePath,
|
|
1251
|
+
storedReview: persistedReviewDetails,
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
else if (flags.output) {
|
|
1255
|
+
writeFileSync(flags.output, JSON.stringify(result, null, 2));
|
|
1256
|
+
success(`Results saved to ${flags.output}`);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
catch (err) {
|
|
1260
|
+
spinner?.fail('Geotechnical ingest failed');
|
|
1261
|
+
throw err;
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
const reviewCmd = new Command('review')
|
|
1265
|
+
.description('Inspect a persisted geotechnical ingest review from a stored project')
|
|
1266
|
+
.argument('<projectId>', 'Stored project id containing persisted ingest reviews')
|
|
1267
|
+
.option('--format <format>', 'Result presentation format: plain or html', 'plain')
|
|
1268
|
+
.option('--dataset <name>', 'Specific persisted ingest review dataset name; defaults to the latest saved review in the project')
|
|
1269
|
+
.option('--list', 'List persisted ingest reviews in the project')
|
|
1270
|
+
.action(async (...args) => {
|
|
1271
|
+
const [projectId, opts, command] = args;
|
|
1272
|
+
const resolvedOpts = resolveCommandOptions(opts, command, ['list', 'dataset', 'format']);
|
|
1273
|
+
const flags = getGlobalFlags(resolvedOpts);
|
|
1274
|
+
const outputFormat = resolveIngestPresentationFormat(resolvedOpts.format);
|
|
1275
|
+
assertIngestPresentationMode(flags, outputFormat);
|
|
1276
|
+
const wantsHtmlDossier = shouldRenderHtmlDossier(outputFormat, flags.output);
|
|
1277
|
+
const resolvedProjectId = String(projectId);
|
|
1278
|
+
const datasetName = asOptionalTrimmedString(resolvedOpts.dataset);
|
|
1279
|
+
if (flags.dryRun) {
|
|
1280
|
+
const dryRun = createPersistedReviewDryRun(resolvedProjectId, datasetName);
|
|
1281
|
+
if (flags.json) {
|
|
1282
|
+
renderJSON(dryRun);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
heading('Geotechnical Ingest Review Dry Run');
|
|
1286
|
+
keyValue('Project', resolvedProjectId);
|
|
1287
|
+
keyValue('Review dataset', datasetName ?? 'Latest persisted ingest review');
|
|
1288
|
+
keyValue('Would load persisted review', 'Yes');
|
|
1289
|
+
console.log('');
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
try {
|
|
1293
|
+
if (resolvedOpts.list) {
|
|
1294
|
+
if (wantsHtmlDossier) {
|
|
1295
|
+
throw new Error('HTML ingest dossiers are only available for a single persisted review. Remove --list or use plain/json output.');
|
|
1296
|
+
}
|
|
1297
|
+
const reviews = listPersistedBoreholeIngestReviews(resolvedProjectId);
|
|
1298
|
+
if (flags.json) {
|
|
1299
|
+
renderJSON(reviews);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
heading('Persisted Ingest Reviews');
|
|
1303
|
+
keyValue('Project', resolvedProjectId);
|
|
1304
|
+
keyValue('Count', String(reviews.length));
|
|
1305
|
+
console.log('');
|
|
1306
|
+
renderTable(['Dataset', 'Type', 'Created', 'Source', 'Confidence', 'Review', 'Auto', 'Approved'], reviews.map((record) => [
|
|
1307
|
+
record.datasetName,
|
|
1308
|
+
record.result.documentType,
|
|
1309
|
+
record.createdAt,
|
|
1310
|
+
record.result.source.fileName ?? record.result.source.filePath ?? record.title ?? record.result.documentType,
|
|
1311
|
+
`${record.summary.confidence}%`,
|
|
1312
|
+
record.summary.reviewRequired ? 'Yes' : 'No',
|
|
1313
|
+
record.summary.canAutoProceed ? 'Yes' : 'No',
|
|
1314
|
+
record.approval ? 'Yes' : 'No',
|
|
1315
|
+
]));
|
|
1316
|
+
console.log('');
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
const record = datasetName
|
|
1320
|
+
? loadPersistedBoreholeIngestReview(resolvedProjectId, datasetName)
|
|
1321
|
+
: loadLatestPersistedBoreholeIngestReview(resolvedProjectId);
|
|
1322
|
+
if (!record) {
|
|
1323
|
+
throw new Error(datasetName
|
|
1324
|
+
? `No persisted ingest review named "${datasetName}" was found in project "${resolvedProjectId}".`
|
|
1325
|
+
: `No persisted ingest reviews were found in project "${resolvedProjectId}".`);
|
|
1326
|
+
}
|
|
1327
|
+
if (flags.json) {
|
|
1328
|
+
renderJSON(record);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
renderPersistedReviewRecord(record, resolvedProjectId);
|
|
1332
|
+
if (wantsHtmlDossier) {
|
|
1333
|
+
const dossierDetails = buildPersistedReviewDossierDetails(record, resolvedProjectId);
|
|
1334
|
+
writeHtmlDossier(record.result, {
|
|
1335
|
+
outputPath: flags.output,
|
|
1336
|
+
sourceLabel: dossierDetails.sourceLabel,
|
|
1337
|
+
storedReview: dossierDetails.storedReview,
|
|
1338
|
+
approval: dossierDetails.approval,
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
else if (flags.output) {
|
|
1342
|
+
writeFileSync(flags.output, JSON.stringify(record, null, 2));
|
|
1343
|
+
success(`Review details saved to ${flags.output}`);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
catch (err) {
|
|
1347
|
+
throw err;
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
const approveCmd = new Command('approve')
|
|
1351
|
+
.description('Record approval for a persisted geotechnical ingest review')
|
|
1352
|
+
.argument('<projectId>', 'Stored project id containing persisted ingest reviews')
|
|
1353
|
+
.option('--dataset <name>', 'Specific persisted ingest review dataset name; defaults to the latest saved review in the project')
|
|
1354
|
+
.requiredOption('--note <text>', 'Approval rationale to store with the review')
|
|
1355
|
+
.option('--by <name>', 'Optional reviewer or approver label for the audit trail')
|
|
1356
|
+
.action(async (...args) => {
|
|
1357
|
+
const [projectId, opts, command] = args;
|
|
1358
|
+
const resolvedOpts = resolveCommandOptions(opts, command, ['dataset', 'note', 'by']);
|
|
1359
|
+
const flags = getGlobalFlags(resolvedOpts);
|
|
1360
|
+
const resolvedProjectId = String(projectId);
|
|
1361
|
+
const datasetName = asOptionalTrimmedString(resolvedOpts.dataset);
|
|
1362
|
+
const note = asOptionalTrimmedString(resolvedOpts.note);
|
|
1363
|
+
const approvedBy = asOptionalTrimmedString(resolvedOpts.by);
|
|
1364
|
+
if (flags.dryRun) {
|
|
1365
|
+
const dryRun = createPersistedReviewApprovalDryRun(resolvedProjectId, datasetName, note, approvedBy);
|
|
1366
|
+
if (flags.json) {
|
|
1367
|
+
renderJSON(dryRun);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
heading('Persisted Ingest Review Approval Dry Run');
|
|
1371
|
+
keyValue('Project', resolvedProjectId);
|
|
1372
|
+
keyValue('Review dataset', datasetName ?? 'Latest persisted ingest review');
|
|
1373
|
+
keyValue('Would load persisted review', 'Yes');
|
|
1374
|
+
keyValue('Would record approval', 'Yes');
|
|
1375
|
+
if (approvedBy) {
|
|
1376
|
+
keyValue('Approved by', approvedBy);
|
|
1377
|
+
}
|
|
1378
|
+
if (note) {
|
|
1379
|
+
console.log('');
|
|
1380
|
+
console.log(chalk.white(' Rationale:'));
|
|
1381
|
+
console.log(` ${note}`);
|
|
1382
|
+
}
|
|
1383
|
+
console.log('');
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
const approval = approvePersistedBoreholeIngestReview(resolvedProjectId, datasetName, {
|
|
1387
|
+
rationale: note ?? '',
|
|
1388
|
+
approvedBy,
|
|
1389
|
+
});
|
|
1390
|
+
if (flags.json) {
|
|
1391
|
+
renderJSON(approval);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
renderApprovalResult(approval, {
|
|
1395
|
+
projectId: resolvedProjectId,
|
|
1396
|
+
datasetName,
|
|
1397
|
+
});
|
|
1398
|
+
if (flags.output) {
|
|
1399
|
+
writeFileSync(flags.output, JSON.stringify(approval, null, 2));
|
|
1400
|
+
success(`Approval details saved to ${flags.output}`);
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
const approvalsCmd = new Command('approvals')
|
|
1404
|
+
.description('Inspect approval history for persisted geotechnical ingest reviews')
|
|
1405
|
+
.argument('<projectId>', 'Stored project id containing persisted ingest review approvals')
|
|
1406
|
+
.option('--dataset <name>', 'Specific persisted ingest review dataset name to filter approval history')
|
|
1407
|
+
.option('--approval <name>', 'Specific approval dataset name to load instead of listing approval history')
|
|
1408
|
+
.option('--latest', 'Load the latest approval for the selected review dataset instead of listing approval history')
|
|
1409
|
+
.action(async (...args) => {
|
|
1410
|
+
const [projectId, opts, command] = args;
|
|
1411
|
+
const resolvedOpts = resolveCommandOptions(opts, command, ['dataset', 'approval', 'latest']);
|
|
1412
|
+
const flags = getGlobalFlags(resolvedOpts);
|
|
1413
|
+
const resolvedProjectId = String(projectId);
|
|
1414
|
+
const reviewDatasetName = asOptionalTrimmedString(resolvedOpts.dataset);
|
|
1415
|
+
const approvalDatasetName = asOptionalTrimmedString(resolvedOpts.approval);
|
|
1416
|
+
const latestOnly = resolvedOpts.latest === true;
|
|
1417
|
+
if (latestOnly && !reviewDatasetName) {
|
|
1418
|
+
throw new Error('Loading the latest approval requires --dataset <review-dataset>.');
|
|
1419
|
+
}
|
|
1420
|
+
if (latestOnly && approvalDatasetName) {
|
|
1421
|
+
throw new Error('Use either --latest or --approval <name>, not both.');
|
|
1422
|
+
}
|
|
1423
|
+
if (flags.dryRun) {
|
|
1424
|
+
const dryRun = createPersistedReviewApprovalLookupDryRun(resolvedProjectId, reviewDatasetName, approvalDatasetName, latestOnly);
|
|
1425
|
+
if (flags.json) {
|
|
1426
|
+
renderJSON(dryRun);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
heading('Persisted Ingest Review Approval Lookup Dry Run');
|
|
1430
|
+
keyValue('Project', resolvedProjectId);
|
|
1431
|
+
if (reviewDatasetName) {
|
|
1432
|
+
keyValue('Review dataset', reviewDatasetName);
|
|
1433
|
+
}
|
|
1434
|
+
if (approvalDatasetName) {
|
|
1435
|
+
keyValue('Approval dataset', approvalDatasetName);
|
|
1436
|
+
}
|
|
1437
|
+
keyValue('Would list approvals', approvalDatasetName || latestOnly ? 'No' : 'Yes');
|
|
1438
|
+
keyValue('Would load approval', approvalDatasetName || latestOnly ? 'Yes' : 'No');
|
|
1439
|
+
console.log('');
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
if (approvalDatasetName || latestOnly) {
|
|
1443
|
+
const approval = approvalDatasetName
|
|
1444
|
+
? loadPersistedBoreholeIngestReviewApproval(resolvedProjectId, approvalDatasetName)
|
|
1445
|
+
: loadLatestPersistedBoreholeIngestReviewApproval(resolvedProjectId, reviewDatasetName);
|
|
1446
|
+
if (!approval) {
|
|
1447
|
+
throw new Error(approvalDatasetName
|
|
1448
|
+
? `No persisted ingest review approval named "${approvalDatasetName}" was found in project "${resolvedProjectId}".`
|
|
1449
|
+
: `No persisted ingest review approvals were found for "${reviewDatasetName}" in project "${resolvedProjectId}".`);
|
|
1450
|
+
}
|
|
1451
|
+
if (flags.json) {
|
|
1452
|
+
renderJSON(approval);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
renderApprovalResult(approval, {
|
|
1456
|
+
projectId: resolvedProjectId,
|
|
1457
|
+
datasetName: reviewDatasetName,
|
|
1458
|
+
});
|
|
1459
|
+
if (flags.output) {
|
|
1460
|
+
writeFileSync(flags.output, JSON.stringify(approval, null, 2));
|
|
1461
|
+
success(`Approval details saved to ${flags.output}`);
|
|
1462
|
+
}
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
const approvals = listPersistedBoreholeIngestReviewApprovals(resolvedProjectId, reviewDatasetName);
|
|
1466
|
+
const latestByReviewDataset = new Map();
|
|
1467
|
+
for (const approval of approvals) {
|
|
1468
|
+
if (!latestByReviewDataset.has(approval.reviewDatasetName)) {
|
|
1469
|
+
latestByReviewDataset.set(approval.reviewDatasetName, approval.datasetName);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
const approvalSummaries = approvals.map((approval) => ({
|
|
1473
|
+
datasetName: approval.datasetName,
|
|
1474
|
+
reviewDatasetName: approval.reviewDatasetName,
|
|
1475
|
+
approvedAt: approval.approvedAt,
|
|
1476
|
+
approvedBy: approval.approvedBy,
|
|
1477
|
+
rationale: approval.rationale,
|
|
1478
|
+
isLatestForReview: latestByReviewDataset.get(approval.reviewDatasetName) === approval.datasetName,
|
|
1479
|
+
}));
|
|
1480
|
+
if (flags.json) {
|
|
1481
|
+
renderJSON(approvalSummaries);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
renderApprovalHistory(approvalSummaries, {
|
|
1485
|
+
projectId: resolvedProjectId,
|
|
1486
|
+
reviewDatasetName,
|
|
1487
|
+
});
|
|
1488
|
+
if (flags.output) {
|
|
1489
|
+
writeFileSync(flags.output, JSON.stringify(approvalSummaries, null, 2));
|
|
1490
|
+
success(`Approval history saved to ${flags.output}`);
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
const promoteCmd = new Command('promote')
|
|
1494
|
+
.description('Promote a persisted borehole-log ingest review into project datasets')
|
|
1495
|
+
.argument('<projectId>', 'Stored project id containing persisted ingest reviews')
|
|
1496
|
+
.option('--dataset <name>', 'Specific persisted ingest review dataset name; defaults to the latest saved review in the project')
|
|
1497
|
+
.action(async (...args) => {
|
|
1498
|
+
const [projectId, opts, command] = args;
|
|
1499
|
+
const resolvedOpts = resolveCommandOptions(opts, command, ['dataset']);
|
|
1500
|
+
const flags = getGlobalFlags(resolvedOpts);
|
|
1501
|
+
const resolvedProjectId = String(projectId);
|
|
1502
|
+
const datasetName = asOptionalTrimmedString(resolvedOpts.dataset);
|
|
1503
|
+
if (flags.dryRun) {
|
|
1504
|
+
const dryRun = createPersistedReviewPromotionDryRun(resolvedProjectId, datasetName);
|
|
1505
|
+
if (flags.json) {
|
|
1506
|
+
renderJSON(dryRun);
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
heading('Persisted Ingest Review Promotion Dry Run');
|
|
1510
|
+
keyValue('Project', resolvedProjectId);
|
|
1511
|
+
keyValue('Review dataset', datasetName ?? 'Latest persisted ingest review');
|
|
1512
|
+
keyValue('Would load persisted review', 'Yes');
|
|
1513
|
+
keyValue('Would promote persisted review', 'Yes');
|
|
1514
|
+
console.log('');
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
const promotion = await Promise.resolve(promotePersistedBoreholeIngestReview(resolvedProjectId, datasetName));
|
|
1518
|
+
if (flags.json) {
|
|
1519
|
+
renderJSON(promotion);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
renderPromotionResult(promotion, {
|
|
1523
|
+
projectId: resolvedProjectId,
|
|
1524
|
+
datasetName,
|
|
1525
|
+
});
|
|
1526
|
+
if (flags.output) {
|
|
1527
|
+
writeFileSync(flags.output, JSON.stringify(promotion, null, 2));
|
|
1528
|
+
success(`Promotion details saved to ${flags.output}`);
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
const statusCmd = new Command('status')
|
|
1532
|
+
.description('Inspect a persisted geotechnical ingest job')
|
|
1533
|
+
.argument('<jobId>', 'Persisted ingest job id')
|
|
1534
|
+
.action(async (...args) => {
|
|
1535
|
+
const [jobId, opts, command] = args;
|
|
1536
|
+
const resolvedOpts = resolveCommandOptions(opts, command);
|
|
1537
|
+
const flags = getGlobalFlags(resolvedOpts);
|
|
1538
|
+
const record = loadPersistedIngestJob(String(jobId));
|
|
1539
|
+
if (!record) {
|
|
1540
|
+
throw new Error(`No persisted ingest job named "${jobId}" was found.`);
|
|
1541
|
+
}
|
|
1542
|
+
if (flags.json) {
|
|
1543
|
+
renderJSON(record);
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
const normalized = normalizeIngestJobRecord(record);
|
|
1547
|
+
if (!normalized) {
|
|
1548
|
+
throw new Error(`Persisted ingest job "${jobId}" could not be normalized.`);
|
|
1549
|
+
}
|
|
1550
|
+
renderIngestJobRecord(normalized, { title: 'Geotechnical Ingest Job Status', includeCommands: true });
|
|
1551
|
+
if (flags.output) {
|
|
1552
|
+
writeFileSync(flags.output, JSON.stringify(record, null, 2));
|
|
1553
|
+
success(`Job details saved to ${flags.output}`);
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
const waitCmd = new Command('wait')
|
|
1557
|
+
.description('Wait for a persisted geotechnical ingest job to finish')
|
|
1558
|
+
.argument('<jobId>', 'Persisted ingest job id')
|
|
1559
|
+
.option('--format <format>', 'Result presentation format: plain or html', 'plain')
|
|
1560
|
+
.action(async (...args) => {
|
|
1561
|
+
const [jobId, opts, command] = args;
|
|
1562
|
+
const resolvedOpts = resolveCommandOptions(opts, command, ['format']);
|
|
1563
|
+
const flags = getGlobalFlags(resolvedOpts);
|
|
1564
|
+
const outputFormat = resolveIngestPresentationFormat(resolvedOpts.format);
|
|
1565
|
+
assertIngestPresentationMode(flags, outputFormat);
|
|
1566
|
+
const wantsHtmlDossier = shouldRenderHtmlDossier(outputFormat, flags.output);
|
|
1567
|
+
const record = await waitForPersistedIngestJob(String(jobId));
|
|
1568
|
+
const normalized = normalizeIngestJobRecord(record);
|
|
1569
|
+
if (!normalized) {
|
|
1570
|
+
throw new Error(`Persisted ingest job "${jobId}" could not be normalized.`);
|
|
1571
|
+
}
|
|
1572
|
+
if (record.status !== 'completed' || !normalized.result?.ingestResult) {
|
|
1573
|
+
if (flags.json) {
|
|
1574
|
+
renderJSON(record);
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
renderIngestJobRecord(normalized, { title: 'Geotechnical Ingest Job Status', includeCommands: true });
|
|
1578
|
+
throw new Error(`Persisted ingest job "${jobId}" finished with status "${record.status}".`);
|
|
1579
|
+
}
|
|
1580
|
+
if (flags.json) {
|
|
1581
|
+
renderJSON(record.result);
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
const completedResult = normalized.result.ingestResult;
|
|
1585
|
+
const persistedReview = normalized.result.persistedReview
|
|
1586
|
+
? {
|
|
1587
|
+
projectId: normalized.request.projectId ?? 'Unknown',
|
|
1588
|
+
datasetName: normalized.result.persistedReview.datasetName,
|
|
1589
|
+
reviewId: normalized.result.persistedReview.reviewId,
|
|
1590
|
+
createdAt: normalized.result.persistedReview.createdAt,
|
|
1591
|
+
}
|
|
1592
|
+
: null;
|
|
1593
|
+
renderIngestJobResult(normalized);
|
|
1594
|
+
if (wantsHtmlDossier) {
|
|
1595
|
+
writeHtmlDossier(completedResult, {
|
|
1596
|
+
outputPath: flags.output,
|
|
1597
|
+
sourceLabel: completedResult.source.fileName ?? completedResult.source.filePath ?? normalized.jobId,
|
|
1598
|
+
storedReview: persistedReview,
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
else if (flags.output) {
|
|
1602
|
+
writeFileSync(flags.output, JSON.stringify(record.result, null, 2));
|
|
1603
|
+
success(`Results saved to ${flags.output}`);
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
const resumeCmd = new Command('resume')
|
|
1607
|
+
.description('Resume a persisted geotechnical ingest job from completed checkpoints')
|
|
1608
|
+
.argument('<jobId>', 'Persisted ingest job id')
|
|
1609
|
+
.action(async (...args) => {
|
|
1610
|
+
const [jobId, opts, command] = args;
|
|
1611
|
+
const resolvedOpts = resolveCommandOptions(opts, command);
|
|
1612
|
+
const flags = getGlobalFlags(resolvedOpts);
|
|
1613
|
+
const record = resumePersistedIngestJob(String(jobId));
|
|
1614
|
+
if (flags.json) {
|
|
1615
|
+
renderJSON(record);
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
const normalized = normalizeIngestJobRecord(record);
|
|
1619
|
+
if (!normalized) {
|
|
1620
|
+
throw new Error(`Persisted ingest job "${jobId}" could not be normalized.`);
|
|
1621
|
+
}
|
|
1622
|
+
renderIngestJobRecord(normalized, { title: 'Geotechnical Ingest Job Resumed', includeCommands: true });
|
|
1623
|
+
if (flags.output) {
|
|
1624
|
+
writeFileSync(flags.output, JSON.stringify(record, null, 2));
|
|
1625
|
+
success(`Job details saved to ${flags.output}`);
|
|
1626
|
+
}
|
|
1627
|
+
});
|
|
1628
|
+
const resultCmd = new Command('result')
|
|
1629
|
+
.description('Load the completed result for a persisted geotechnical ingest job')
|
|
1630
|
+
.argument('<jobId>', 'Persisted ingest job id')
|
|
1631
|
+
.option('--format <format>', 'Result presentation format: plain or html', 'plain')
|
|
1632
|
+
.action(async (...args) => {
|
|
1633
|
+
const [jobId, opts, command] = args;
|
|
1634
|
+
const resolvedOpts = resolveCommandOptions(opts, command, ['format']);
|
|
1635
|
+
const flags = getGlobalFlags(resolvedOpts);
|
|
1636
|
+
const outputFormat = resolveIngestPresentationFormat(resolvedOpts.format);
|
|
1637
|
+
assertIngestPresentationMode(flags, outputFormat);
|
|
1638
|
+
const wantsHtmlDossier = shouldRenderHtmlDossier(outputFormat, flags.output);
|
|
1639
|
+
const record = loadPersistedIngestJob(String(jobId));
|
|
1640
|
+
const result = loadPersistedIngestJobResult(String(jobId));
|
|
1641
|
+
if (!record || !result) {
|
|
1642
|
+
throw new Error(`Persisted ingest job "${jobId}" does not have a completed result yet.`);
|
|
1643
|
+
}
|
|
1644
|
+
if (flags.json) {
|
|
1645
|
+
renderJSON(result);
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
const normalized = normalizeIngestJobRecord(record);
|
|
1649
|
+
if (!normalized) {
|
|
1650
|
+
throw new Error(`Persisted ingest job "${jobId}" could not be normalized.`);
|
|
1651
|
+
}
|
|
1652
|
+
if (!normalized.result?.ingestResult) {
|
|
1653
|
+
throw new Error(`Persisted ingest job "${jobId}" does not have a normalized completed result.`);
|
|
1654
|
+
}
|
|
1655
|
+
const completedResult = normalized.result.ingestResult;
|
|
1656
|
+
const persistedReview = normalized.result.persistedReview
|
|
1657
|
+
? {
|
|
1658
|
+
projectId: normalized.request.projectId ?? 'Unknown',
|
|
1659
|
+
datasetName: normalized.result.persistedReview.datasetName,
|
|
1660
|
+
reviewId: normalized.result.persistedReview.reviewId,
|
|
1661
|
+
createdAt: normalized.result.persistedReview.createdAt,
|
|
1662
|
+
}
|
|
1663
|
+
: null;
|
|
1664
|
+
renderIngestJobResult(normalized);
|
|
1665
|
+
if (wantsHtmlDossier) {
|
|
1666
|
+
writeHtmlDossier(completedResult, {
|
|
1667
|
+
outputPath: flags.output,
|
|
1668
|
+
sourceLabel: completedResult.source.fileName ?? completedResult.source.filePath ?? normalized.jobId,
|
|
1669
|
+
storedReview: persistedReview,
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
else if (flags.output) {
|
|
1673
|
+
writeFileSync(flags.output, JSON.stringify(result, null, 2));
|
|
1674
|
+
success(`Results saved to ${flags.output}`);
|
|
1675
|
+
}
|
|
1676
|
+
});
|
|
1677
|
+
const cancelCmd = new Command('cancel')
|
|
1678
|
+
.description('Cancel a persisted geotechnical ingest job')
|
|
1679
|
+
.argument('<jobId>', 'Persisted ingest job id')
|
|
1680
|
+
.action(async (...args) => {
|
|
1681
|
+
const [jobId, opts, command] = args;
|
|
1682
|
+
const resolvedOpts = resolveCommandOptions(opts, command);
|
|
1683
|
+
const flags = getGlobalFlags(resolvedOpts);
|
|
1684
|
+
const record = cancelPersistedIngestJob(String(jobId));
|
|
1685
|
+
if (flags.json) {
|
|
1686
|
+
renderJSON(record);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
const normalized = normalizeIngestJobRecord(record);
|
|
1690
|
+
if (!normalized) {
|
|
1691
|
+
throw new Error(`Persisted ingest job "${jobId}" could not be normalized.`);
|
|
1692
|
+
}
|
|
1693
|
+
renderIngestJobRecord(normalized, { title: 'Geotechnical Ingest Job Canceled', includeCommands: true });
|
|
1694
|
+
if (flags.output) {
|
|
1695
|
+
writeFileSync(flags.output, JSON.stringify(record, null, 2));
|
|
1696
|
+
success(`Job details saved to ${flags.output}`);
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
addGlobalFlags(cmd);
|
|
1700
|
+
addGlobalFlags(reviewCmd);
|
|
1701
|
+
addGlobalFlags(approveCmd);
|
|
1702
|
+
addGlobalFlags(approvalsCmd);
|
|
1703
|
+
addGlobalFlags(promoteCmd);
|
|
1704
|
+
addGlobalFlags(statusCmd);
|
|
1705
|
+
addGlobalFlags(waitCmd);
|
|
1706
|
+
addGlobalFlags(resumeCmd);
|
|
1707
|
+
addGlobalFlags(resultCmd);
|
|
1708
|
+
addGlobalFlags(cancelCmd);
|
|
1709
|
+
reviewCmd.addCommand(approveCmd);
|
|
1710
|
+
reviewCmd.addCommand(approvalsCmd);
|
|
1711
|
+
reviewCmd.addCommand(promoteCmd);
|
|
1712
|
+
cmd.addCommand(reviewCmd);
|
|
1713
|
+
cmd.addCommand(statusCmd);
|
|
1714
|
+
cmd.addCommand(waitCmd);
|
|
1715
|
+
cmd.addCommand(resumeCmd);
|
|
1716
|
+
cmd.addCommand(resultCmd);
|
|
1717
|
+
cmd.addCommand(cancelCmd);
|
|
1718
|
+
program.addCommand(cmd);
|
|
1719
|
+
}
|
|
1720
|
+
//# sourceMappingURL=ingest.js.map
|