@semiont/jobs 0.5.28 → 0.5.30
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/README.md +20 -4
- package/dist/index.d.ts +73 -8
- package/dist/index.js +348 -116
- package/dist/index.js.map +1 -1
- package/dist/worker-main.js +499 -176
- package/dist/worker-main.js.map +1 -1
- package/package.json +11 -11
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { promises, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
import { jobId, deriveViews,
|
|
5
|
-
import { withSpan } from '@semiont/observability';
|
|
4
|
+
import { replyChannelsFor, jobId, deriveViews, GENERATABLE_MEDIA_TYPES, estimateTokens, chunkText, isObject, isString, reconcileSelector, getLocaleEnglishName } from '@semiont/core';
|
|
5
|
+
import { withSpan, recordAnchorOutcome, recordDetectionCall } from '@semiont/observability';
|
|
6
|
+
import { StructuredReadError } from '@semiont/inference';
|
|
6
7
|
import { execFileSync } from 'child_process';
|
|
7
8
|
import { tmpdir } from 'os';
|
|
8
9
|
import { withinByteBudget, MAX_PDF_BYTES } from '@semiont/content';
|
|
@@ -10,6 +11,13 @@ import '@semiont/event-sourcing';
|
|
|
10
11
|
import '@semiont/sdk';
|
|
11
12
|
import '@semiont/http-transport';
|
|
12
13
|
|
|
14
|
+
// src/fs-job-queue.ts
|
|
15
|
+
|
|
16
|
+
// src/will-retry.ts
|
|
17
|
+
function willRetryAfter(metadata, failureClass) {
|
|
18
|
+
return failureClass !== "deterministic" && metadata.retryCount < metadata.maxRetries;
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
// src/fs-job-queue.ts
|
|
14
22
|
var REANNOUNCE_INTERVAL_MS = 3e4;
|
|
15
23
|
var STALE_RUNNING_MS = 30 * 6e4;
|
|
@@ -17,9 +25,9 @@ var PROGRESS_WRITE_MIN_INTERVAL_MS = 5e3;
|
|
|
17
25
|
var RETENTION_HOURS = 24;
|
|
18
26
|
var CLEANUP_INTERVAL_MS = 36e5;
|
|
19
27
|
var FsJobQueue = class {
|
|
20
|
-
constructor(
|
|
28
|
+
constructor(state, logger, eventBus) {
|
|
21
29
|
this.eventBus = eventBus;
|
|
22
|
-
this.jobsDir =
|
|
30
|
+
this.jobsDir = state.jobsDir;
|
|
23
31
|
this.logger = logger;
|
|
24
32
|
}
|
|
25
33
|
eventBus;
|
|
@@ -194,24 +202,29 @@ var FsJobQueue = class {
|
|
|
194
202
|
* re-announced); after that it lands in `failed` with the error.
|
|
195
203
|
* Returns null (and changes nothing) if the job isn't running.
|
|
196
204
|
*/
|
|
197
|
-
async failJob(jobId, error) {
|
|
205
|
+
async failJob(jobId, error, completedUnits, failureClass) {
|
|
198
206
|
const job = await this.getJob(jobId);
|
|
199
207
|
if (!job || job.status !== "running") {
|
|
200
208
|
return null;
|
|
201
209
|
}
|
|
202
210
|
this.lastProgressWrite.delete(jobId);
|
|
203
|
-
|
|
211
|
+
const checkpoint = [.../* @__PURE__ */ new Set([...job.metadata.completedUnits ?? [], ...completedUnits ?? []])];
|
|
212
|
+
const metadata = checkpoint.length > 0 ? { ...job.metadata, completedUnits: checkpoint } : job.metadata;
|
|
213
|
+
if (willRetryAfter(job.metadata, failureClass)) {
|
|
204
214
|
const retried = {
|
|
205
215
|
status: "pending",
|
|
206
|
-
metadata: { ...
|
|
216
|
+
metadata: { ...metadata, retryCount: job.metadata.retryCount + 1 },
|
|
207
217
|
params: job.params
|
|
208
218
|
};
|
|
209
219
|
await this.updateJob(retried, "running");
|
|
210
220
|
return "retried";
|
|
211
221
|
}
|
|
222
|
+
if (failureClass === "deterministic" && job.metadata.retryCount < job.metadata.maxRetries) {
|
|
223
|
+
this.logger.info("Job failed without retry \u2014 deterministic failure", { jobId, error });
|
|
224
|
+
}
|
|
212
225
|
const failed = {
|
|
213
226
|
status: "failed",
|
|
214
|
-
metadata
|
|
227
|
+
metadata,
|
|
215
228
|
params: job.params,
|
|
216
229
|
startedAt: job.startedAt,
|
|
217
230
|
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -220,6 +233,30 @@ var FsJobQueue = class {
|
|
|
220
233
|
await this.updateJob(failed, "running");
|
|
221
234
|
return "failed";
|
|
222
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Persist a running job's completed-unit checkpoint at unit completion
|
|
238
|
+
* (JOB-RESTART-SAFETY P2). `failJob` writes this checkpoint on a clean
|
|
239
|
+
* failure, but a worker that dies without emitting `job:fail` would lose
|
|
240
|
+
* its finished units — so the janitor's recovery (which re-queues with
|
|
241
|
+
* whatever `metadata.completedUnits` the running file holds) would redo
|
|
242
|
+
* them. Writing it HERE, as each unit lands, makes a crash lose at most
|
|
243
|
+
* the in-flight unit. Unioned with any existing checkpoint, unthrottled
|
|
244
|
+
* (a unit completion is rare and must never be dropped), a no-op for
|
|
245
|
+
* non-running jobs. Written directly, like `recordProgress`, so it also
|
|
246
|
+
* refreshes the mtime heartbeat.
|
|
247
|
+
*/
|
|
248
|
+
async checkpointUnits(jobId, completedUnits) {
|
|
249
|
+
const job = await this.getJob(jobId);
|
|
250
|
+
if (!job || job.status !== "running") {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const merged = [.../* @__PURE__ */ new Set([...job.metadata.completedUnits ?? [], ...completedUnits])];
|
|
254
|
+
const updated = {
|
|
255
|
+
...job,
|
|
256
|
+
metadata: { ...job.metadata, completedUnits: merged }
|
|
257
|
+
};
|
|
258
|
+
await promises.writeFile(this.getJobPath(jobId, "running"), JSON.stringify(updated, null, 2), "utf-8");
|
|
259
|
+
}
|
|
223
260
|
/**
|
|
224
261
|
* Write progress into a running job's file. Throttled per job, and
|
|
225
262
|
* a no-op for jobs that aren't running. Beyond surfacing live
|
|
@@ -427,6 +464,9 @@ function isCancelledJob(job) {
|
|
|
427
464
|
return job.status === "cancelled";
|
|
428
465
|
}
|
|
429
466
|
var INFERENCE_TIMEOUT_MS = 10 * 6e4;
|
|
467
|
+
var InferenceTimeoutError = class extends Error {
|
|
468
|
+
name = "InferenceTimeoutError";
|
|
469
|
+
};
|
|
430
470
|
var INFERENCE_HEARTBEAT_MS = 15e3;
|
|
431
471
|
function spanned(client, kind, maxTokens, work) {
|
|
432
472
|
return withSpan(`inference:${kind}`, work, {
|
|
@@ -437,12 +477,20 @@ function spanned(client, kind, maxTokens, work) {
|
|
|
437
477
|
}
|
|
438
478
|
});
|
|
439
479
|
}
|
|
440
|
-
async function withTimeout(work,
|
|
480
|
+
async function withTimeout(work, meta, onHeartbeat, logger) {
|
|
481
|
+
const controller = new AbortController();
|
|
441
482
|
let timer;
|
|
442
483
|
const timedOut = new Promise((_, reject) => {
|
|
443
484
|
timer = setTimeout(() => {
|
|
444
|
-
|
|
445
|
-
|
|
485
|
+
logger?.warn("Aborting in-flight inference call at the timeout bound", {
|
|
486
|
+
provider: meta.provider,
|
|
487
|
+
model: meta.model,
|
|
488
|
+
label: meta.label,
|
|
489
|
+
boundMs: INFERENCE_TIMEOUT_MS
|
|
490
|
+
});
|
|
491
|
+
controller.abort();
|
|
492
|
+
reject(new InferenceTimeoutError(
|
|
493
|
+
`Inference call timed out after ${INFERENCE_TIMEOUT_MS / 6e4} minutes (${meta.label}) \u2014 failing the job to keep the claim loop live`
|
|
446
494
|
));
|
|
447
495
|
}, INFERENCE_TIMEOUT_MS);
|
|
448
496
|
timer.unref?.();
|
|
@@ -457,10 +505,11 @@ async function withTimeout(work, label, onHeartbeat) {
|
|
|
457
505
|
}, INFERENCE_HEARTBEAT_MS);
|
|
458
506
|
heartbeat.unref?.();
|
|
459
507
|
}
|
|
508
|
+
const pending = work(controller.signal);
|
|
460
509
|
try {
|
|
461
|
-
return await Promise.race([
|
|
510
|
+
return await Promise.race([pending, timedOut]);
|
|
462
511
|
} catch (err) {
|
|
463
|
-
|
|
512
|
+
pending.catch(() => {
|
|
464
513
|
});
|
|
465
514
|
throw err;
|
|
466
515
|
} finally {
|
|
@@ -468,28 +517,55 @@ async function withTimeout(work, label, onHeartbeat) {
|
|
|
468
517
|
if (heartbeat) clearInterval(heartbeat);
|
|
469
518
|
}
|
|
470
519
|
}
|
|
471
|
-
function boundedGenerateWithMetadata(client, prompt, maxTokens, temperature, onHeartbeat) {
|
|
520
|
+
function boundedGenerateWithMetadata(client, prompt, maxTokens, temperature, onHeartbeat, logger) {
|
|
472
521
|
return spanned(client, "text", maxTokens, () => withTimeout(
|
|
473
|
-
client.generateTextWithMetadata(prompt, maxTokens, temperature),
|
|
474
|
-
`${client.type}:${client.modelId}
|
|
475
|
-
onHeartbeat
|
|
522
|
+
(signal) => client.generateTextWithMetadata(prompt, maxTokens, temperature, signal),
|
|
523
|
+
{ provider: client.type, model: client.modelId, label: `${client.type}:${client.modelId}` },
|
|
524
|
+
onHeartbeat,
|
|
525
|
+
logger
|
|
476
526
|
));
|
|
477
527
|
}
|
|
478
|
-
function boundedGenerateStructured(client, prompt, maxTokens, temperature, elementSchema, onHeartbeat) {
|
|
528
|
+
function boundedGenerateStructured(client, prompt, maxTokens, temperature, elementSchema, onHeartbeat, logger) {
|
|
479
529
|
return spanned(client, "structured", maxTokens, () => withTimeout(
|
|
480
|
-
client.generateStructured(prompt, maxTokens, temperature, elementSchema),
|
|
481
|
-
`${client.type}:${client.modelId}
|
|
482
|
-
onHeartbeat
|
|
530
|
+
(signal) => client.generateStructured(prompt, maxTokens, temperature, elementSchema, signal),
|
|
531
|
+
{ provider: client.type, model: client.modelId, label: `${client.type}:${client.modelId}` },
|
|
532
|
+
onHeartbeat,
|
|
533
|
+
logger
|
|
483
534
|
));
|
|
484
535
|
}
|
|
536
|
+
var DeterministicJobError = class extends Error {
|
|
537
|
+
// Typed string, not the literal: subclasses (YieldCollapseError) carry
|
|
538
|
+
// their own name — classification is instanceof, never name-matching.
|
|
539
|
+
name = "DeterministicJobError";
|
|
540
|
+
};
|
|
485
541
|
|
|
486
542
|
// src/workers/detection/detection-chunking.ts
|
|
543
|
+
function assertNotTruncated(response, label, chunk, totalChunks, outputBudget) {
|
|
544
|
+
if (response.stopReason === "max_tokens") {
|
|
545
|
+
throw new DeterministicJobError(`${label} response truncated (max_tokens) on chunk ${chunk}/${totalChunks} despite the derived output budget of ${outputBudget} tokens \u2014 failing the job rather than under-reporting annotations.`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
487
548
|
var SELECTOR_CONTEXT_CHARS = 64;
|
|
488
549
|
var OVERLAP_CHARS = SELECTOR_CONTEXT_CHARS + // prefix
|
|
489
550
|
SELECTOR_CONTEXT_CHARS + // suffix
|
|
490
551
|
2 * SELECTOR_CONTEXT_CHARS;
|
|
491
552
|
var OVERLAP_TOKENS = Math.ceil(OVERLAP_CHARS / 4);
|
|
492
|
-
|
|
553
|
+
var DETECTION_TEMPERATURE = 0;
|
|
554
|
+
var ASSUMED_OUTPUT_TOKENS_PER_HOUR = 108e3;
|
|
555
|
+
var YIELD_COLLAPSE_BAND = 2;
|
|
556
|
+
var YieldCollapseError = class extends DeterministicJobError {
|
|
557
|
+
/** What the flagged extraction DID find — every span write-time-verified,
|
|
558
|
+
* so discarding it at the floor would add loss on top of the under-report.
|
|
559
|
+
* Carried on the error because the flag site cannot know whether descent
|
|
560
|
+
* remains possible; the floor is the subdivider's knowledge. */
|
|
561
|
+
constructor(message, salvage = []) {
|
|
562
|
+
super(message);
|
|
563
|
+
this.salvage = salvage;
|
|
564
|
+
}
|
|
565
|
+
salvage;
|
|
566
|
+
name = "YieldCollapseError";
|
|
567
|
+
};
|
|
568
|
+
function deriveDetectionBudget(limits, scaffoldTokens, typesPerCall) {
|
|
493
569
|
const { contextTokens, maxOutputTokens } = limits;
|
|
494
570
|
const available = contextTokens - scaffoldTokens;
|
|
495
571
|
let inputBudget;
|
|
@@ -505,6 +581,15 @@ function deriveDetectionBudget(limits, scaffoldTokens) {
|
|
|
505
581
|
outputBudget = available - inputBudget;
|
|
506
582
|
}
|
|
507
583
|
}
|
|
584
|
+
const outputTokensPerHour = limits.outputTokensPerHour ?? ASSUMED_OUTPUT_TOKENS_PER_HOUR;
|
|
585
|
+
{
|
|
586
|
+
const durationSafeOutput = Math.floor(outputTokensPerHour * (INFERENCE_TIMEOUT_MS / 2) / 36e5);
|
|
587
|
+
if (outputBudget > durationSafeOutput) {
|
|
588
|
+
inputBudget = Math.floor(inputBudget * (durationSafeOutput / outputBudget));
|
|
589
|
+
outputBudget = durationSafeOutput;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
inputBudget = Math.min(inputBudget, Math.floor(outputBudget / (2 * typesPerCall)));
|
|
508
593
|
if (inputBudget <= OVERLAP_TOKENS) {
|
|
509
594
|
throw new Error(
|
|
510
595
|
`Inference window too small for detection: context ${contextTokens} tokens minus scaffold ${scaffoldTokens} leaves an input budget of ${inputBudget} (need > ${OVERLAP_TOKENS}). Use a model with a larger context window or reduce the prompt scaffold.`
|
|
@@ -515,6 +600,91 @@ function deriveDetectionBudget(limits, scaffoldTokens) {
|
|
|
515
600
|
outputBudget
|
|
516
601
|
};
|
|
517
602
|
}
|
|
603
|
+
var MAX_SUBDIVISION_DEPTH = 2;
|
|
604
|
+
function unknownUnreadable(error) {
|
|
605
|
+
return error instanceof StructuredReadError && error.stopReason === "unknown";
|
|
606
|
+
}
|
|
607
|
+
function subdividable(error) {
|
|
608
|
+
return error instanceof InferenceTimeoutError || truncation(error) || unknownUnreadable(error);
|
|
609
|
+
}
|
|
610
|
+
function truncation(error) {
|
|
611
|
+
return error instanceof DeterministicJobError || error instanceof StructuredReadError && error.stopReason === "max_tokens";
|
|
612
|
+
}
|
|
613
|
+
function outcomeOf(error) {
|
|
614
|
+
if (error instanceof YieldCollapseError) return "collapsed";
|
|
615
|
+
if (truncation(error)) return "truncated";
|
|
616
|
+
if (error instanceof InferenceTimeoutError) return "timeout";
|
|
617
|
+
return "error";
|
|
618
|
+
}
|
|
619
|
+
async function callChunkSubdividing(label, chunk, chunking, call, logger) {
|
|
620
|
+
async function recorded(piece, depth, reroll) {
|
|
621
|
+
const start = performance.now();
|
|
622
|
+
try {
|
|
623
|
+
const result = await call(piece);
|
|
624
|
+
recordDetectionCall({
|
|
625
|
+
label,
|
|
626
|
+
pieceChars: piece.length,
|
|
627
|
+
durationMs: performance.now() - start,
|
|
628
|
+
items: result.items.length,
|
|
629
|
+
depth,
|
|
630
|
+
reroll,
|
|
631
|
+
outcome: "success",
|
|
632
|
+
...result.usage ? { inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens } : {}
|
|
633
|
+
});
|
|
634
|
+
return result;
|
|
635
|
+
} catch (error) {
|
|
636
|
+
recordDetectionCall({
|
|
637
|
+
label,
|
|
638
|
+
pieceChars: piece.length,
|
|
639
|
+
durationMs: performance.now() - start,
|
|
640
|
+
items: 0,
|
|
641
|
+
depth,
|
|
642
|
+
reroll,
|
|
643
|
+
outcome: outcomeOf(error)
|
|
644
|
+
});
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async function attempt(piece, chunkSize, depth) {
|
|
649
|
+
try {
|
|
650
|
+
return (await recorded(piece, depth, false)).items;
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (!subdividable(error)) throw error;
|
|
653
|
+
const half = Math.floor(chunkSize / 2);
|
|
654
|
+
const pieces = chunkText(piece, { chunkSize: half, overlap: chunking.overlap });
|
|
655
|
+
const shrinks = pieces.length > 1 || pieces[0] !== piece;
|
|
656
|
+
const canDescend = shrinks && (truncation(error) || unknownUnreadable(error) ? half > 2 * OVERLAP_TOKENS : depth < MAX_SUBDIVISION_DEPTH);
|
|
657
|
+
if (!canDescend) {
|
|
658
|
+
if (error instanceof YieldCollapseError) {
|
|
659
|
+
logger?.warn("Floor-size piece still flagged as collapsed \u2014 accepting its under-reported salvage and continuing", {
|
|
660
|
+
pieceChars: piece.length,
|
|
661
|
+
salvaged: error.salvage.length,
|
|
662
|
+
error: error.message
|
|
663
|
+
});
|
|
664
|
+
return error.salvage;
|
|
665
|
+
}
|
|
666
|
+
if (!truncation(error)) throw error;
|
|
667
|
+
logger?.warn("Floor-size piece truncated \u2014 re-rolling once before giving up", {
|
|
668
|
+
pieceChars: piece.length,
|
|
669
|
+
error: error instanceof Error ? error.message : String(error)
|
|
670
|
+
});
|
|
671
|
+
return (await recorded(piece, depth, true)).items;
|
|
672
|
+
}
|
|
673
|
+
logger?.warn("Chunk call failed at a size-shaped bound \u2014 subdividing and retrying smaller", {
|
|
674
|
+
depth: depth + 1,
|
|
675
|
+
pieceChars: piece.length,
|
|
676
|
+
nextChunkSizeTokens: half,
|
|
677
|
+
error: error instanceof Error ? error.message : String(error)
|
|
678
|
+
});
|
|
679
|
+
const collected = [];
|
|
680
|
+
for (const p of pieces) {
|
|
681
|
+
collected.push(...await attempt(p, half, depth + 1));
|
|
682
|
+
}
|
|
683
|
+
return collected;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return attempt(chunk, chunking.chunkSize, 0);
|
|
687
|
+
}
|
|
518
688
|
function languageName(tag) {
|
|
519
689
|
return getLocaleEnglishName(tag) || tag;
|
|
520
690
|
}
|
|
@@ -812,6 +982,16 @@ Example format:
|
|
|
812
982
|
return prompt;
|
|
813
983
|
}
|
|
814
984
|
};
|
|
985
|
+
var DEGRADED = /* @__PURE__ */ new Set(["first-of-many", "fuzzy-match"]);
|
|
986
|
+
function noteAnchor(label, exact, method, logger) {
|
|
987
|
+
recordAnchorOutcome(label, method);
|
|
988
|
+
if (!DEGRADED.has(method)) return;
|
|
989
|
+
const detail = { text: exact, anchorMethod: method };
|
|
990
|
+
if (logger) logger.warn("Annotation anchored via degraded method", { label, ...detail });
|
|
991
|
+
else console.warn(`[${label}] anchored via ${method}: "${exact}"`);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// src/workers/detection/motivation-parsers.ts
|
|
815
995
|
var COMMENT_ELEMENT_SCHEMA = {
|
|
816
996
|
type: "object",
|
|
817
997
|
properties: {
|
|
@@ -1000,35 +1180,31 @@ var MotivationParsers = class {
|
|
|
1000
1180
|
}
|
|
1001
1181
|
};
|
|
1002
1182
|
function logAnchorMethod(motivation, exact, anchorMethod) {
|
|
1003
|
-
|
|
1004
|
-
console.warn(`[MotivationParsers] ${motivation} anchored via ${anchorMethod}: "${exact}"`);
|
|
1005
|
-
}
|
|
1183
|
+
noteAnchor(motivation, exact, anchorMethod);
|
|
1006
1184
|
}
|
|
1007
1185
|
|
|
1008
1186
|
// src/workers/annotation-detection.ts
|
|
1009
|
-
function
|
|
1010
|
-
if (response.stopReason === "max_tokens") {
|
|
1011
|
-
throw new Error(`${motivation} detection response truncated (max_tokens) on chunk ${chunk}/${totalChunks} despite the derived output budget of ${outputBudget} tokens \u2014 failing the job rather than under-reporting annotations.`);
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
async function detectInChunks(client, content, buildPrompt, temperature, motivation, elementSchema, parse, onActivity) {
|
|
1187
|
+
async function detectInChunks(client, content, buildPrompt, motivation, elementSchema, parse, onActivity) {
|
|
1015
1188
|
const limits = await client.limits();
|
|
1016
1189
|
const scaffoldTokens = estimateTokens(buildPrompt(""));
|
|
1017
|
-
const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens);
|
|
1190
|
+
const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens, 1);
|
|
1018
1191
|
const chunks = chunkText(content, chunking);
|
|
1019
1192
|
const collected = [];
|
|
1020
1193
|
for (let i = 0; i < chunks.length; i++) {
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1194
|
+
const items = await callChunkSubdividing(motivation, chunks[i], chunking, async (piece) => {
|
|
1195
|
+
const response = await boundedGenerateStructured(
|
|
1196
|
+
client,
|
|
1197
|
+
buildPrompt(piece),
|
|
1198
|
+
outputBudget,
|
|
1199
|
+
DETECTION_TEMPERATURE,
|
|
1200
|
+
elementSchema,
|
|
1201
|
+
// Still alive, same position (a long single call is otherwise silent).
|
|
1202
|
+
() => onActivity?.(i, chunks.length)
|
|
1203
|
+
);
|
|
1204
|
+
assertNotTruncated(response, `${motivation} detection`, i + 1, chunks.length, outputBudget);
|
|
1205
|
+
return { items: response.items, ...response.usage ? { usage: response.usage } : {} };
|
|
1206
|
+
});
|
|
1207
|
+
collected.push(...parse(items));
|
|
1032
1208
|
if (i < chunks.length - 1) {
|
|
1033
1209
|
onActivity?.(i + 1, chunks.length);
|
|
1034
1210
|
}
|
|
@@ -1049,7 +1225,6 @@ var AnnotationDetection = class {
|
|
|
1049
1225
|
client,
|
|
1050
1226
|
content,
|
|
1051
1227
|
(chunk) => MotivationPrompts.buildCommentPrompt(chunk, instructions, tone, density, language, sourceLanguage),
|
|
1052
|
-
0.4,
|
|
1053
1228
|
"comment",
|
|
1054
1229
|
COMMENT_ELEMENT_SCHEMA,
|
|
1055
1230
|
(items) => MotivationParsers.parseComments(items, content),
|
|
@@ -1068,7 +1243,6 @@ var AnnotationDetection = class {
|
|
|
1068
1243
|
client,
|
|
1069
1244
|
content,
|
|
1070
1245
|
(chunk) => MotivationPrompts.buildHighlightPrompt(chunk, instructions, density, sourceLanguage),
|
|
1071
|
-
0.3,
|
|
1072
1246
|
"highlight",
|
|
1073
1247
|
HIGHLIGHT_ELEMENT_SCHEMA,
|
|
1074
1248
|
(items) => MotivationParsers.parseHighlights(items, content),
|
|
@@ -1087,7 +1261,6 @@ var AnnotationDetection = class {
|
|
|
1087
1261
|
client,
|
|
1088
1262
|
content,
|
|
1089
1263
|
(chunk) => MotivationPrompts.buildAssessmentPrompt(chunk, instructions, tone, density, language, sourceLanguage),
|
|
1090
|
-
0.3,
|
|
1091
1264
|
"assessment",
|
|
1092
1265
|
ASSESSMENT_ELEMENT_SCHEMA,
|
|
1093
1266
|
(items) => MotivationParsers.parseAssessments(items, content),
|
|
@@ -1124,7 +1297,6 @@ var AnnotationDetection = class {
|
|
|
1124
1297
|
categoryInfo.examples,
|
|
1125
1298
|
sourceLanguage
|
|
1126
1299
|
),
|
|
1127
|
-
0.2,
|
|
1128
1300
|
"tag",
|
|
1129
1301
|
TAG_ELEMENT_SCHEMA,
|
|
1130
1302
|
(items) => MotivationParsers.parseTags(items),
|
|
@@ -1144,6 +1316,40 @@ var ENTITY_ELEMENT_SCHEMA = {
|
|
|
1144
1316
|
required: ["exact", "entityType"],
|
|
1145
1317
|
additionalProperties: false
|
|
1146
1318
|
};
|
|
1319
|
+
var COUNT_MAX_TOKENS = 16;
|
|
1320
|
+
function parseCount(text) {
|
|
1321
|
+
const m = text.trim().match(/\d+/);
|
|
1322
|
+
return m ? Number(m[0]) : void 0;
|
|
1323
|
+
}
|
|
1324
|
+
async function assertYieldNotCollapsed(client, piece, items, entityTypesDescription, logger) {
|
|
1325
|
+
const prompt = `Count every mention of: ${entityTypesDescription} in the following text. Repeated mentions of the same entity count separately. Respond with only the number.
|
|
1326
|
+
|
|
1327
|
+
Text:
|
|
1328
|
+
"""
|
|
1329
|
+
${piece}
|
|
1330
|
+
"""`;
|
|
1331
|
+
let counted;
|
|
1332
|
+
try {
|
|
1333
|
+
const response = await boundedGenerateWithMetadata(client, prompt, COUNT_MAX_TOKENS, DETECTION_TEMPERATURE, void 0, logger);
|
|
1334
|
+
counted = parseCount(response.text);
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
logger.warn("Count-verifier call failed \u2014 yield check skipped for this chunk", {
|
|
1337
|
+
pieceChars: piece.length,
|
|
1338
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1339
|
+
});
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (counted === void 0) {
|
|
1343
|
+
logger.warn("Count-verifier answer carried no number \u2014 yield check skipped for this chunk", { pieceChars: piece.length });
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
if (items.length * YIELD_COLLAPSE_BAND < counted) {
|
|
1347
|
+
throw new YieldCollapseError(
|
|
1348
|
+
`Extraction found ${items.length} entities where a count call reports ~${counted} mentions (band \xD7${YIELD_COLLAPSE_BAND}) on a ${piece.length}-char chunk \u2014 silent yield collapse (F7): deterministic \u2014 a same-size retry returns the identical under-report.`,
|
|
1349
|
+
[...items]
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1147
1353
|
async function extractEntities(exact, entityTypes, client, includeDescriptiveReferences, logger, sourceLanguage, onActivity) {
|
|
1148
1354
|
const entityTypesDescription = entityTypes.map((et) => {
|
|
1149
1355
|
if (typeof et === "string") {
|
|
@@ -1194,8 +1400,9 @@ If no entities are found, respond with an empty array [].
|
|
|
1194
1400
|
Example output:
|
|
1195
1401
|
[{"exact":"Alice","entityType":"Person","prefix":"","suffix":" went to"},{"exact":"Paris","entityType":"Location","prefix":"went to ","suffix":" yesterday"}]`;
|
|
1196
1402
|
const limits = await client.limits();
|
|
1403
|
+
const verifyYield = client.verifyDetectionYield;
|
|
1197
1404
|
const scaffoldTokens = estimateTokens(buildPrompt(""));
|
|
1198
|
-
const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens);
|
|
1405
|
+
const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens, entityTypes.length);
|
|
1199
1406
|
const chunks = chunkText(exact, chunking);
|
|
1200
1407
|
logger.debug("Sending entity extraction request", {
|
|
1201
1408
|
entityTypes: entityTypesDescription,
|
|
@@ -1205,28 +1412,31 @@ Example output:
|
|
|
1205
1412
|
});
|
|
1206
1413
|
const collected = [];
|
|
1207
1414
|
for (let i = 0; i < chunks.length; i++) {
|
|
1208
|
-
const
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1415
|
+
const items = await callChunkSubdividing("reference", chunks[i], chunking, async (piece) => {
|
|
1416
|
+
const response = await boundedGenerateStructured(
|
|
1417
|
+
client,
|
|
1418
|
+
buildPrompt(piece),
|
|
1419
|
+
outputBudget,
|
|
1420
|
+
DETECTION_TEMPERATURE,
|
|
1421
|
+
ENTITY_ELEMENT_SCHEMA,
|
|
1422
|
+
// Still alive, same position: a long single call would otherwise emit
|
|
1423
|
+
// nothing at all between start and finish.
|
|
1424
|
+
() => onActivity?.(i, chunks.length),
|
|
1425
|
+
logger
|
|
1426
|
+
);
|
|
1427
|
+
logger.debug("Got entity extraction response", {
|
|
1428
|
+
chunk: i + 1,
|
|
1429
|
+
chunks: chunks.length,
|
|
1430
|
+
pieceChars: piece.length,
|
|
1431
|
+
items: response.items.length
|
|
1432
|
+
});
|
|
1433
|
+
assertNotTruncated(response, "Entity extraction", i + 1, chunks.length, outputBudget);
|
|
1434
|
+
if (verifyYield) {
|
|
1435
|
+
await assertYieldNotCollapsed(client, piece, response.items, entityTypesDescription, logger);
|
|
1436
|
+
}
|
|
1437
|
+
return { items: response.items, ...response.usage ? { usage: response.usage } : {} };
|
|
1438
|
+
}, logger);
|
|
1439
|
+
for (const e of items) {
|
|
1230
1440
|
if (isObject(e) && isString(e.exact) && isString(e.entityType)) {
|
|
1231
1441
|
collected.push({
|
|
1232
1442
|
exact: e.exact,
|
|
@@ -1457,7 +1667,7 @@ ${formatRequirements}`;
|
|
|
1457
1667
|
temperature: finalTemperature,
|
|
1458
1668
|
maxTokens: finalMaxTokens
|
|
1459
1669
|
});
|
|
1460
|
-
const response = await boundedGenerateWithMetadata(client, prompt, finalMaxTokens, finalTemperature);
|
|
1670
|
+
const response = await boundedGenerateWithMetadata(client, prompt, finalMaxTokens, finalTemperature, void 0, logger);
|
|
1461
1671
|
logger.debug("Got response from inference", { responseLength: response.text.length, stopReason: response.stopReason });
|
|
1462
1672
|
const result = parseResponse(response.text);
|
|
1463
1673
|
logger.debug("Parsed response", {
|
|
@@ -1552,6 +1762,24 @@ function resolveCitationTokens(content, validResourceIds, logger) {
|
|
|
1552
1762
|
clean += content.slice(last);
|
|
1553
1763
|
return { content: clean, citations };
|
|
1554
1764
|
}
|
|
1765
|
+
|
|
1766
|
+
// src/workers/detection/bounded-concurrency.ts
|
|
1767
|
+
async function runBounded(items, limit, worker) {
|
|
1768
|
+
const results = new Array(items.length);
|
|
1769
|
+
let next = 0;
|
|
1770
|
+
async function pump() {
|
|
1771
|
+
while (true) {
|
|
1772
|
+
const i = next++;
|
|
1773
|
+
if (i >= items.length) return;
|
|
1774
|
+
results[i] = await worker(items[i], i);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
const poolSize = Math.max(1, Math.min(limit, items.length));
|
|
1778
|
+
await Promise.all(Array.from({ length: poolSize }, () => pump()));
|
|
1779
|
+
return results;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// src/processors.ts
|
|
1555
1783
|
function toMatch(r) {
|
|
1556
1784
|
return {
|
|
1557
1785
|
exact: r.exact,
|
|
@@ -1689,31 +1917,33 @@ async function processAssessmentJob(content, inferenceClient, params, buildAnnot
|
|
|
1689
1917
|
result: { kind: "assessment-annotation", assessmentsFound: assessments.length, assessmentsCreated: annotations.length }
|
|
1690
1918
|
};
|
|
1691
1919
|
}
|
|
1692
|
-
async function processReferenceJob(content, inferenceClient, params, buildAnnotation, onProgress, logger) {
|
|
1920
|
+
async function processReferenceJob(content, inferenceClient, params, buildAnnotation, onProgress, logger, onUnitComplete, signal) {
|
|
1693
1921
|
const entityTypeNames = params.entityTypes.map(String);
|
|
1694
1922
|
const requestParams = [{ label: "entity-types", value: entityTypeNames.join(", ") }];
|
|
1695
1923
|
const completedItems = [];
|
|
1696
1924
|
let totalFound = 0;
|
|
1697
1925
|
let totalEmitted = 0;
|
|
1698
1926
|
let errors = 0;
|
|
1699
|
-
const allAnnotations = [];
|
|
1700
1927
|
onProgress(10, { code: "loading" }, { requestParams });
|
|
1701
1928
|
const bodyLanguage = params.language ?? "en";
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
const pct = 20 + Math.round(
|
|
1929
|
+
let completed = 0;
|
|
1930
|
+
const total = entityTypeNames.length;
|
|
1931
|
+
const emitTypeProgress = (entityTypeName) => {
|
|
1932
|
+
const pct = 20 + Math.round(completed / total * 60);
|
|
1706
1933
|
onProgress(pct, { code: "detecting-entities", entityType: entityTypeName }, {
|
|
1707
|
-
// One vocabulary for "what is in flight" (CLEAN-PROGRESS D2): the entity
|
|
1708
|
-
// type is KB data, `kind` is the code the client localizes around it.
|
|
1709
1934
|
current: { kind: "entity-type", value: entityTypeName },
|
|
1710
|
-
processed:
|
|
1711
|
-
total
|
|
1935
|
+
processed: completed,
|
|
1936
|
+
total,
|
|
1712
1937
|
entitiesFound: totalFound,
|
|
1713
1938
|
entitiesEmitted: totalEmitted,
|
|
1714
1939
|
completedItems: [...completedItems],
|
|
1715
1940
|
requestParams
|
|
1716
1941
|
});
|
|
1942
|
+
};
|
|
1943
|
+
await runBounded(entityTypeNames, inferenceClient.maxConcurrency, async (entityTypeName) => {
|
|
1944
|
+
if (!entityTypeName) return;
|
|
1945
|
+
if (signal?.aborted) return;
|
|
1946
|
+
emitTypeProgress(entityTypeName);
|
|
1717
1947
|
const extractedEntities = await extractEntities(
|
|
1718
1948
|
content,
|
|
1719
1949
|
[entityTypeName],
|
|
@@ -1721,31 +1951,17 @@ async function processReferenceJob(content, inferenceClient, params, buildAnnota
|
|
|
1721
1951
|
params.includeDescriptiveReferences ?? false,
|
|
1722
1952
|
logger,
|
|
1723
1953
|
params.sourceLanguage,
|
|
1724
|
-
// Liveness: fires at chunk boundaries
|
|
1725
|
-
//
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
|
|
1730
|
-
// advance.
|
|
1731
|
-
(completed, total) => {
|
|
1732
|
-
const interpolated = 20 + Math.round((i + completed / total) / entityTypeNames.length * 60);
|
|
1733
|
-
onProgress(interpolated, { code: "detecting-entities", entityType: entityTypeName }, {
|
|
1734
|
-
current: { kind: "entity-type", value: entityTypeName },
|
|
1735
|
-
processed: i,
|
|
1736
|
-
total: entityTypeNames.length,
|
|
1737
|
-
entitiesFound: totalFound,
|
|
1738
|
-
entitiesEmitted: totalEmitted,
|
|
1739
|
-
completedItems: [...completedItems],
|
|
1740
|
-
requestParams
|
|
1741
|
-
});
|
|
1742
|
-
}
|
|
1954
|
+
// Liveness heartbeat (DETECTION-HEARTBEAT): fires at chunk boundaries and
|
|
1955
|
+
// every ~15 s while a call is in flight, so a long single-chunk call is
|
|
1956
|
+
// not silent. It repeats the current position rather than inventing an
|
|
1957
|
+
// advance — the stall watchdog, janitor and client timeout need a signal,
|
|
1958
|
+
// not a monotone.
|
|
1959
|
+
() => emitTypeProgress(entityTypeName)
|
|
1743
1960
|
);
|
|
1744
|
-
totalFound += extractedEntities.length;
|
|
1745
|
-
completedItems.push({ value: entityTypeName, foundCount: extractedEntities.length });
|
|
1746
1961
|
const unresolvedBody = [
|
|
1747
1962
|
{ type: "TextualBody", value: entityTypeName, purpose: "tagging", format: "text/plain", language: bodyLanguage }
|
|
1748
1963
|
];
|
|
1964
|
+
const built = [];
|
|
1749
1965
|
for (const entity of extractedEntities) {
|
|
1750
1966
|
const reconciled = reconcileSelector(content, {
|
|
1751
1967
|
exact: entity.exact,
|
|
@@ -1760,23 +1976,28 @@ async function processReferenceJob(content, inferenceClient, params, buildAnnota
|
|
|
1760
1976
|
errors++;
|
|
1761
1977
|
continue;
|
|
1762
1978
|
}
|
|
1763
|
-
|
|
1764
|
-
logger.warn("Entity anchored via degraded method", {
|
|
1765
|
-
text: entity.exact,
|
|
1766
|
-
entityType: entity.entityType,
|
|
1767
|
-
anchorMethod: reconciled.anchorMethod
|
|
1768
|
-
});
|
|
1769
|
-
}
|
|
1979
|
+
noteAnchor("reference", entity.exact, reconciled.anchorMethod, logger);
|
|
1770
1980
|
const ann = buildAnnotation("linking", toMatch(reconciled), unresolvedBody);
|
|
1771
|
-
|
|
1772
|
-
totalEmitted++;
|
|
1981
|
+
built.push(ann);
|
|
1773
1982
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1983
|
+
const unitAnnotations = dedupeAnnotations(built);
|
|
1984
|
+
await onUnitComplete(entityTypeName, unitAnnotations);
|
|
1985
|
+
totalEmitted += unitAnnotations.length;
|
|
1986
|
+
totalFound += extractedEntities.length;
|
|
1987
|
+
completedItems.push({
|
|
1988
|
+
value: entityTypeName,
|
|
1989
|
+
foundCount: extractedEntities.length,
|
|
1990
|
+
persistedCount: unitAnnotations.length
|
|
1991
|
+
});
|
|
1992
|
+
completed++;
|
|
1993
|
+
emitTypeProgress(entityTypeName);
|
|
1994
|
+
});
|
|
1995
|
+
onProgress(100, { code: "complete-created", count: totalEmitted, kind: "reference" }, {
|
|
1996
|
+
completedItems: [...completedItems],
|
|
1997
|
+
requestParams
|
|
1998
|
+
});
|
|
1777
1999
|
return {
|
|
1778
|
-
|
|
1779
|
-
result: { kind: "reference-annotation", totalFound, totalEmitted: annotations.length, errors }
|
|
2000
|
+
result: { kind: "reference-annotation", totalFound, totalEmitted, errors }
|
|
1780
2001
|
};
|
|
1781
2002
|
}
|
|
1782
2003
|
async function processTagJob(content, inferenceClient, params, buildAnnotation, onProgress) {
|
|
@@ -1980,6 +2201,17 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger)
|
|
|
1980
2201
|
};
|
|
1981
2202
|
}
|
|
1982
2203
|
var STALL_THRESHOLD_MS = 15 * 6e4;
|
|
2204
|
+
var WORKER_AWAITED_OPERATIONS = [
|
|
2205
|
+
"job:claim",
|
|
2206
|
+
"browse:resource-requested",
|
|
2207
|
+
"browse:anchored-text-by-checksum-requested",
|
|
2208
|
+
// Durability acknowledgement for a unit's annotations (JOB-RESTART-SAFETY
|
|
2209
|
+
// P6). The worker AWAITS this one — a unit may not advance until its
|
|
2210
|
+
// annotations are in the event log — so its replies must be in the narrow
|
|
2211
|
+
// channel set or every commit fails fast with `bus.unsubscribed`.
|
|
2212
|
+
"mark:commit"
|
|
2213
|
+
];
|
|
2214
|
+
replyChannelsFor(WORKER_AWAITED_OPERATIONS);
|
|
1983
2215
|
|
|
1984
2216
|
export { AnnotationDetection, FsJobQueue, STALL_THRESHOLD_MS, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
|
|
1985
2217
|
//# sourceMappingURL=index.js.map
|