@semiont/jobs 0.5.26 → 0.5.28

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.
@@ -1,11 +1,11 @@
1
- import { createTomlConfigLoader, didToAgent, baseUrl, STARTUP_FETCH_RETRY, retryWithBackoff, isTransientFetchError, busRequest, resourceId, getPrimaryMediaType, assembleAnnotation, findClaimSpan, textExtractionOf, reconcileSelector, GENERATABLE_MEDIA_TYPES, locate, createFragmentSelector, getLocaleEnglishName, estimateTokens, chunkText, isObject, isString, deriveViews } from '@semiont/core';
1
+ import { createTomlConfigLoader, didToAgent, baseUrl, STARTUP_FETCH_RETRY, retryWithBackoff, isTransientFetchError, busRequest, resourceId, getPrimaryMediaType, isGenerationJobParams, assembleAnnotation, findClaimSpan, textExtractionOf, reconcileSelector, GENERATABLE_MEDIA_TYPES, locate, createFragmentSelector, getLocaleEnglishName, estimateTokens, chunkText, isObject, isString, deriveViews } from '@semiont/core';
2
2
  import { anchoredTextStoreOverTransport, deriveStorageUri, extractPdfTextLayer, EXTRACTORS, calculateChecksum, withinByteBudget, MAX_PDF_BYTES } from '@semiont/content';
3
+ import { withSpan, SpanKind, recordJobOutcome } from '@semiont/observability';
3
4
  import { execFileSync } from 'child_process';
4
5
  import { existsSync, readFileSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
5
6
  import { homedir, hostname, tmpdir } from 'os';
6
7
  import { join } from 'path';
7
8
  import { generateAnnotationId } from '@semiont/event-sourcing';
8
- import { withSpan, SpanKind, recordJobOutcome } from '@semiont/observability';
9
9
  import { InMemorySessionStorage, setStoredSession, kbBackendUrl, SemiontClient, SemiontSession } from '@semiont/sdk';
10
10
  import { HttpTransport, HttpContentTransport } from '@semiont/http-transport';
11
11
  import { createInferenceClient } from '@semiont/inference';
@@ -9348,10 +9348,18 @@ function asJobParams(params) {
9348
9348
  }
9349
9349
  return params;
9350
9350
  }
9351
-
9352
- // src/workers/inference-call.ts
9353
9351
  var INFERENCE_TIMEOUT_MS = 10 * 6e4;
9354
- async function withTimeout(work, label) {
9352
+ var INFERENCE_HEARTBEAT_MS = 15e3;
9353
+ function spanned(client, kind, maxTokens, work) {
9354
+ return withSpan(`inference:${kind}`, work, {
9355
+ attrs: {
9356
+ "inference.provider": client.type,
9357
+ "inference.model": client.modelId,
9358
+ "inference.max_tokens": maxTokens
9359
+ }
9360
+ });
9361
+ }
9362
+ async function withTimeout(work, label, onHeartbeat) {
9355
9363
  let timer;
9356
9364
  const timedOut = new Promise((_, reject) => {
9357
9365
  timer = setTimeout(() => {
@@ -9361,6 +9369,16 @@ async function withTimeout(work, label) {
9361
9369
  }, INFERENCE_TIMEOUT_MS);
9362
9370
  timer.unref?.();
9363
9371
  });
9372
+ let heartbeat;
9373
+ if (onHeartbeat) {
9374
+ heartbeat = setInterval(() => {
9375
+ try {
9376
+ onHeartbeat();
9377
+ } catch {
9378
+ }
9379
+ }, INFERENCE_HEARTBEAT_MS);
9380
+ heartbeat.unref?.();
9381
+ }
9364
9382
  try {
9365
9383
  return await Promise.race([work, timedOut]);
9366
9384
  } catch (err) {
@@ -9369,19 +9387,22 @@ async function withTimeout(work, label) {
9369
9387
  throw err;
9370
9388
  } finally {
9371
9389
  clearTimeout(timer);
9390
+ if (heartbeat) clearInterval(heartbeat);
9372
9391
  }
9373
9392
  }
9374
- function boundedGenerate(client, prompt, maxTokens, temperature) {
9375
- return withTimeout(
9376
- client.generateText(prompt, maxTokens, temperature),
9377
- `${client.type}:${client.modelId}`
9378
- );
9393
+ function boundedGenerateWithMetadata(client, prompt, maxTokens, temperature, onHeartbeat) {
9394
+ return spanned(client, "text", maxTokens, () => withTimeout(
9395
+ client.generateTextWithMetadata(prompt, maxTokens, temperature),
9396
+ `${client.type}:${client.modelId}`,
9397
+ onHeartbeat
9398
+ ));
9379
9399
  }
9380
- function boundedGenerateStructured(client, prompt, maxTokens, temperature, elementSchema) {
9381
- return withTimeout(
9400
+ function boundedGenerateStructured(client, prompt, maxTokens, temperature, elementSchema, onHeartbeat) {
9401
+ return spanned(client, "structured", maxTokens, () => withTimeout(
9382
9402
  client.generateStructured(prompt, maxTokens, temperature, elementSchema),
9383
- `${client.type}:${client.modelId}`
9384
- );
9403
+ `${client.type}:${client.modelId}`,
9404
+ onHeartbeat
9405
+ ));
9385
9406
  }
9386
9407
 
9387
9408
  // src/workers/detection/detection-chunking.ts
@@ -9912,7 +9933,7 @@ function assertNotTruncated(response, motivation, chunk, totalChunks, outputBudg
9912
9933
  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.`);
9913
9934
  }
9914
9935
  }
9915
- async function detectInChunks(client, content, buildPrompt, temperature, motivation, elementSchema, parse, onChunk) {
9936
+ async function detectInChunks(client, content, buildPrompt, temperature, motivation, elementSchema, parse, onActivity) {
9916
9937
  const limits = await client.limits();
9917
9938
  const scaffoldTokens = estimateTokens(buildPrompt(""));
9918
9939
  const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens);
@@ -9924,12 +9945,14 @@ async function detectInChunks(client, content, buildPrompt, temperature, motivat
9924
9945
  buildPrompt(chunks[i]),
9925
9946
  outputBudget,
9926
9947
  temperature,
9927
- elementSchema
9948
+ elementSchema,
9949
+ // Still alive, same position (a long single call is otherwise silent).
9950
+ () => onActivity?.(i, chunks.length)
9928
9951
  );
9929
9952
  assertNotTruncated(response, motivation, i + 1, chunks.length, outputBudget);
9930
9953
  collected.push(...parse(response.items));
9931
9954
  if (i < chunks.length - 1) {
9932
- onChunk?.(i + 1, chunks.length);
9955
+ onActivity?.(i + 1, chunks.length);
9933
9956
  }
9934
9957
  }
9935
9958
  return collected;
@@ -9943,7 +9966,7 @@ var AnnotationDetection = class {
9943
9966
  * (source-resource locale). See `types.ts` "Locale conventions" for the
9944
9967
  * full discussion.
9945
9968
  */
9946
- static async detectComments(content, client, instructions, tone, density, language, sourceLanguage, onChunk) {
9969
+ static async detectComments(content, client, instructions, tone, density, language, sourceLanguage, onActivity) {
9947
9970
  return detectInChunks(
9948
9971
  client,
9949
9972
  content,
@@ -9952,7 +9975,7 @@ var AnnotationDetection = class {
9952
9975
  "comment",
9953
9976
  COMMENT_ELEMENT_SCHEMA,
9954
9977
  (items) => MotivationParsers.parseComments(items, content),
9955
- onChunk
9978
+ onActivity
9956
9979
  );
9957
9980
  }
9958
9981
  /**
@@ -9962,7 +9985,7 @@ var AnnotationDetection = class {
9962
9985
  * applies, used in the prompt so the LLM analyzes non-English source
9963
9986
  * correctly.
9964
9987
  */
9965
- static async detectHighlights(content, client, instructions, density, sourceLanguage, onChunk) {
9988
+ static async detectHighlights(content, client, instructions, density, sourceLanguage, onActivity) {
9966
9989
  return detectInChunks(
9967
9990
  client,
9968
9991
  content,
@@ -9971,7 +9994,7 @@ var AnnotationDetection = class {
9971
9994
  "highlight",
9972
9995
  HIGHLIGHT_ELEMENT_SCHEMA,
9973
9996
  (items) => MotivationParsers.parseHighlights(items, content),
9974
- onChunk
9997
+ onActivity
9975
9998
  );
9976
9999
  }
9977
10000
  /**
@@ -9981,7 +10004,7 @@ var AnnotationDetection = class {
9981
10004
  * (annotation body locale). `sourceLanguage` is the locale of the content
9982
10005
  * being analyzed (source-resource locale).
9983
10006
  */
9984
- static async detectAssessments(content, client, instructions, tone, density, language, sourceLanguage, onChunk) {
10007
+ static async detectAssessments(content, client, instructions, tone, density, language, sourceLanguage, onActivity) {
9985
10008
  return detectInChunks(
9986
10009
  client,
9987
10010
  content,
@@ -9990,7 +10013,7 @@ var AnnotationDetection = class {
9990
10013
  "assessment",
9991
10014
  ASSESSMENT_ELEMENT_SCHEMA,
9992
10015
  (items) => MotivationParsers.parseAssessments(items, content),
9993
- onChunk
10016
+ onActivity
9994
10017
  );
9995
10018
  }
9996
10019
  /**
@@ -10005,7 +10028,7 @@ var AnnotationDetection = class {
10005
10028
  * identifiers, not LLM-generated text — so it's consumed at the body-stamp
10006
10029
  * site, not here.
10007
10030
  */
10008
- static async detectTags(content, client, schema, category, sourceLanguage, onChunk) {
10031
+ static async detectTags(content, client, schema, category, sourceLanguage, onActivity) {
10009
10032
  const categoryInfo = schema.tags.find((t) => t.name === category);
10010
10033
  if (!categoryInfo) {
10011
10034
  throw new Error(`Invalid category "${category}" for schema ${schema.id}`);
@@ -10027,7 +10050,7 @@ var AnnotationDetection = class {
10027
10050
  "tag",
10028
10051
  TAG_ELEMENT_SCHEMA,
10029
10052
  (items) => MotivationParsers.parseTags(items),
10030
- onChunk
10053
+ onActivity
10031
10054
  );
10032
10055
  return MotivationParsers.validateTagOffsets(parsedTags, content, category);
10033
10056
  }
@@ -10043,7 +10066,7 @@ var ENTITY_ELEMENT_SCHEMA = {
10043
10066
  required: ["exact", "entityType"],
10044
10067
  additionalProperties: false
10045
10068
  };
10046
- async function extractEntities(exact, entityTypes, client, includeDescriptiveReferences, logger2, sourceLanguage, onChunk) {
10069
+ async function extractEntities(exact, entityTypes, client, includeDescriptiveReferences, logger2, sourceLanguage, onActivity) {
10047
10070
  const entityTypesDescription = entityTypes.map((et) => {
10048
10071
  if (typeof et === "string") {
10049
10072
  return et;
@@ -10110,7 +10133,10 @@ Example output:
10110
10133
  outputBudget,
10111
10134
  0.3,
10112
10135
  // Lower temperature for more consistent extraction
10113
- ENTITY_ELEMENT_SCHEMA
10136
+ ENTITY_ELEMENT_SCHEMA,
10137
+ // Still alive, same position: a long single call would otherwise emit
10138
+ // nothing at all between start and finish.
10139
+ () => onActivity?.(i, chunks.length)
10114
10140
  );
10115
10141
  logger2.debug("Got entity extraction response", {
10116
10142
  chunk: i + 1,
@@ -10135,7 +10161,7 @@ Example output:
10135
10161
  }
10136
10162
  }
10137
10163
  if (i < chunks.length - 1) {
10138
- onChunk?.(i + 1, chunks.length);
10164
+ onActivity?.(i + 1, chunks.length);
10139
10165
  }
10140
10166
  }
10141
10167
  return collected;
@@ -10149,6 +10175,7 @@ var SEMANTIC_MATCH_CHARS = 240;
10149
10175
  function idLabel(resourceId, annotationId) {
10150
10176
  return `[${resourceId}${annotationId ? `/${annotationId}` : ""}]`;
10151
10177
  }
10178
+ var DEFAULT_MAX_TOKENS = 500;
10152
10179
  async function generateResourceFromTopic(topic, entityTypes, client, logger2, userPrompt, locale, context, temperature, maxTokens, sourceLanguage, outputMediaType = "text/markdown", task = "resource", structure, cite = false, repair) {
10153
10180
  logger2.debug("Generating resource from topic", {
10154
10181
  topicPreview: topic.substring(0, 100),
@@ -10164,7 +10191,7 @@ async function generateResourceFromTopic(topic, entityTypes, client, logger2, us
10164
10191
  structure
10165
10192
  });
10166
10193
  const finalTemperature = temperature ?? 0.7;
10167
- const finalMaxTokens = maxTokens ?? 500;
10194
+ const finalMaxTokens = maxTokens ?? DEFAULT_MAX_TOKENS;
10168
10195
  const languageInstruction = locale && locale !== "en" ? `
10169
10196
 
10170
10197
  IMPORTANT: Write the entire resource in ${getLanguageName(locale)}.` : "";
@@ -10191,6 +10218,9 @@ The source resource and embedded context are in ${getLanguageName(sourceLanguage
10191
10218
  parts.push(`- ${label}: ${bodyItem.value}`);
10192
10219
  }
10193
10220
  }
10221
+ if (focus.userHint) {
10222
+ parts.push(`- User hint (steers what to generate): ${focus.userHint}`);
10223
+ }
10194
10224
  annotationSection = `
10195
10225
 
10196
10226
  Annotation context:
@@ -10349,16 +10379,16 @@ ${formatRequirements}`;
10349
10379
  temperature: finalTemperature,
10350
10380
  maxTokens: finalMaxTokens
10351
10381
  });
10352
- const response = await boundedGenerate(client, prompt, finalMaxTokens, finalTemperature);
10353
- logger2.debug("Got response from inference", { responseLength: response.length });
10354
- const result = parseResponse(response);
10382
+ const response = await boundedGenerateWithMetadata(client, prompt, finalMaxTokens, finalTemperature);
10383
+ logger2.debug("Got response from inference", { responseLength: response.text.length, stopReason: response.stopReason });
10384
+ const result = parseResponse(response.text);
10355
10385
  logger2.debug("Parsed response", {
10356
10386
  hasTitle: !!result.title,
10357
10387
  titleLength: result.title?.length,
10358
10388
  hasContent: !!result.content,
10359
10389
  contentLength: result.content?.length
10360
10390
  });
10361
- return result;
10391
+ return { ...result, truncated: response.stopReason === "max_tokens" };
10362
10392
  }
10363
10393
  var PINNED_CREATION_TIMESTAMP = 17e8;
10364
10394
  var MAX_COMPILE_REPAIRS = 2;
@@ -10571,30 +10601,39 @@ function buildPdfAnnotation(anchored, resourceId, userId, generator, motivation,
10571
10601
  };
10572
10602
  }
10573
10603
  async function processHighlightJob(content, inferenceClient, params, buildAnnotation, onProgress) {
10574
- onProgress(10, "Loading resource...", "analyzing");
10575
- onProgress(30, "Analyzing text...", "analyzing");
10604
+ const echo = detectionEcho(params);
10605
+ onProgress(10, { code: "loading" }, echo);
10606
+ onProgress(30, { code: "analyzing" }, echo);
10576
10607
  const highlights = await AnnotationDetection.detectHighlights(
10577
10608
  content,
10578
10609
  inferenceClient,
10579
10610
  params.instructions,
10580
10611
  params.density,
10581
10612
  params.sourceLanguage,
10582
- // Chunk-boundary heartbeat (liveness): interpolate within the 30–60 band.
10583
- (completed, total) => onProgress(30 + Math.round(completed / total * 30), "Analyzing text...", "analyzing")
10613
+ // Liveness (chunk boundaries + in-flight heartbeat): 30–60 band.
10614
+ (completed, total) => onProgress(30 + Math.round(completed / total * 30), { code: "analyzing" }, echo)
10584
10615
  );
10585
- onProgress(60, `Creating ${highlights.length} annotations...`, "creating");
10616
+ onProgress(60, { code: "creating-annotations", count: highlights.length }, echo);
10586
10617
  const annotations = dedupeAnnotations(highlights.map(
10587
10618
  (h) => buildAnnotation("highlighting", h)
10588
10619
  ));
10589
- onProgress(100, `Complete! Created ${annotations.length} highlights`, "creating");
10620
+ onProgress(100, { code: "complete-created", count: annotations.length, kind: "highlight" }, echo);
10590
10621
  return {
10591
10622
  annotations,
10592
- result: { highlightsFound: highlights.length, highlightsCreated: annotations.length }
10623
+ result: { kind: "highlight-annotation", highlightsFound: highlights.length, highlightsCreated: annotations.length }
10593
10624
  };
10594
10625
  }
10626
+ function detectionEcho(p) {
10627
+ const requestParams = [];
10628
+ if (p.instructions?.trim()) requestParams.push({ label: "instructions", value: p.instructions.trim() });
10629
+ if (p.tone?.trim()) requestParams.push({ label: "tone", value: p.tone.trim() });
10630
+ if (p.density !== void 0) requestParams.push({ label: "density", value: String(p.density) });
10631
+ return requestParams.length > 0 ? { requestParams } : {};
10632
+ }
10595
10633
  async function processCommentJob(content, inferenceClient, params, buildAnnotation, onProgress) {
10596
- onProgress(10, "Loading resource...", "analyzing");
10597
- onProgress(30, "Analyzing text...", "analyzing");
10634
+ const echo = detectionEcho(params);
10635
+ onProgress(10, { code: "loading" }, echo);
10636
+ onProgress(30, { code: "analyzing" }, echo);
10598
10637
  const comments = await AnnotationDetection.detectComments(
10599
10638
  content,
10600
10639
  inferenceClient,
@@ -10603,10 +10642,10 @@ async function processCommentJob(content, inferenceClient, params, buildAnnotati
10603
10642
  params.density,
10604
10643
  params.language,
10605
10644
  params.sourceLanguage,
10606
- // Chunk-boundary heartbeat (liveness): interpolate within the 30–60 band.
10607
- (completed, total) => onProgress(30 + Math.round(completed / total * 30), "Analyzing text...", "analyzing")
10645
+ // Liveness (chunk boundaries + in-flight heartbeat): 30–60 band.
10646
+ (completed, total) => onProgress(30 + Math.round(completed / total * 30), { code: "analyzing" }, echo)
10608
10647
  );
10609
- onProgress(60, `Creating ${comments.length} annotations...`, "creating");
10648
+ onProgress(60, { code: "creating-annotations", count: comments.length }, echo);
10610
10649
  const bodyLanguage = params.language ?? "en";
10611
10650
  const annotations = dedupeAnnotations(comments.map(
10612
10651
  (c) => (
@@ -10618,15 +10657,16 @@ async function processCommentJob(content, inferenceClient, params, buildAnnotati
10618
10657
  ])
10619
10658
  )
10620
10659
  ));
10621
- onProgress(100, `Complete! Created ${annotations.length} comments`, "creating");
10660
+ onProgress(100, { code: "complete-created", count: annotations.length, kind: "comment" }, echo);
10622
10661
  return {
10623
10662
  annotations,
10624
- result: { commentsFound: comments.length, commentsCreated: annotations.length }
10663
+ result: { kind: "comment-annotation", commentsFound: comments.length, commentsCreated: annotations.length }
10625
10664
  };
10626
10665
  }
10627
10666
  async function processAssessmentJob(content, inferenceClient, params, buildAnnotation, onProgress) {
10628
- onProgress(10, "Loading resource...", "analyzing");
10629
- onProgress(30, "Analyzing text...", "analyzing");
10667
+ const echo = detectionEcho(params);
10668
+ onProgress(10, { code: "loading" }, echo);
10669
+ onProgress(30, { code: "analyzing" }, echo);
10630
10670
  const assessments = await AnnotationDetection.detectAssessments(
10631
10671
  content,
10632
10672
  inferenceClient,
@@ -10635,10 +10675,10 @@ async function processAssessmentJob(content, inferenceClient, params, buildAnnot
10635
10675
  params.density,
10636
10676
  params.language,
10637
10677
  params.sourceLanguage,
10638
- // Chunk-boundary heartbeat (liveness): interpolate within the 30–60 band.
10639
- (completed, total) => onProgress(30 + Math.round(completed / total * 30), "Analyzing text...", "analyzing")
10678
+ // Liveness (chunk boundaries + in-flight heartbeat): 30–60 band.
10679
+ (completed, total) => onProgress(30 + Math.round(completed / total * 30), { code: "analyzing" }, echo)
10640
10680
  );
10641
- onProgress(60, `Creating ${assessments.length} annotations...`, "creating");
10681
+ onProgress(60, { code: "creating-annotations", count: assessments.length }, echo);
10642
10682
  const bodyLanguage = params.language ?? "en";
10643
10683
  const annotations = dedupeAnnotations(assessments.map(
10644
10684
  (a) => (
@@ -10657,33 +10697,35 @@ async function processAssessmentJob(content, inferenceClient, params, buildAnnot
10657
10697
  })
10658
10698
  )
10659
10699
  ));
10660
- onProgress(100, `Complete! Created ${annotations.length} assessments`, "creating");
10700
+ onProgress(100, { code: "complete-created", count: annotations.length, kind: "assessment" }, echo);
10661
10701
  return {
10662
10702
  annotations,
10663
- result: { assessmentsFound: assessments.length, assessmentsCreated: annotations.length }
10703
+ result: { kind: "assessment-annotation", assessmentsFound: assessments.length, assessmentsCreated: annotations.length }
10664
10704
  };
10665
10705
  }
10666
10706
  async function processReferenceJob(content, inferenceClient, params, buildAnnotation, onProgress, logger2) {
10667
10707
  const entityTypeNames = params.entityTypes.map(String);
10668
- const requestParams = [{ label: "Entity types", value: entityTypeNames.join(", ") }];
10669
- const completedEntityTypes = [];
10708
+ const requestParams = [{ label: "entity-types", value: entityTypeNames.join(", ") }];
10709
+ const completedItems = [];
10670
10710
  let totalFound = 0;
10671
10711
  let totalEmitted = 0;
10672
10712
  let errors = 0;
10673
10713
  const allAnnotations = [];
10674
- onProgress(10, "Loading resource...", "analyzing", { requestParams });
10714
+ onProgress(10, { code: "loading" }, { requestParams });
10675
10715
  const bodyLanguage = params.language ?? "en";
10676
10716
  for (let i = 0; i < entityTypeNames.length; i++) {
10677
10717
  const entityTypeName = entityTypeNames[i];
10678
10718
  if (!entityTypeName) continue;
10679
10719
  const pct = 20 + Math.round(i / entityTypeNames.length * 60);
10680
- onProgress(pct, `Detecting ${entityTypeName} entities...`, "analyzing", {
10681
- currentEntityType: entityTypeName,
10682
- processedEntityTypes: i,
10683
- totalEntityTypes: entityTypeNames.length,
10720
+ onProgress(pct, { code: "detecting-entities", entityType: entityTypeName }, {
10721
+ // One vocabulary for "what is in flight" (CLEAN-PROGRESS D2): the entity
10722
+ // type is KB data, `kind` is the code the client localizes around it.
10723
+ current: { kind: "entity-type", value: entityTypeName },
10724
+ processed: i,
10725
+ total: entityTypeNames.length,
10684
10726
  entitiesFound: totalFound,
10685
10727
  entitiesEmitted: totalEmitted,
10686
- completedEntityTypes: [...completedEntityTypes],
10728
+ completedItems: [...completedItems],
10687
10729
  requestParams
10688
10730
  });
10689
10731
  const extractedEntities = await extractEntities(
@@ -10693,25 +10735,28 @@ async function processReferenceJob(content, inferenceClient, params, buildAnnota
10693
10735
  params.includeDescriptiveReferences ?? false,
10694
10736
  logger2,
10695
10737
  params.sourceLanguage,
10696
- // Chunk-boundary heartbeat: progress is the worker's liveness signal
10697
- // (stall watchdog + backend janitor), so multi-chunk extraction must
10698
- // emit between inference calls. Percentage interpolates within this
10699
- // entity type's band of the 20–80 range.
10738
+ // Liveness: fires at chunk boundaries AND every ~15 s while a single
10739
+ // inference call is in flight (DETECTION-HEARTBEAT). Progress feeds the
10740
+ // stall watchdog, the janitor, AND the client's inter-emission timeout,
10741
+ // so a long single-chunk call must not be silent. Percentage
10742
+ // interpolates within this entity type's band of the 20–80 range; a
10743
+ // heartbeat repeats the current position rather than inventing an
10744
+ // advance.
10700
10745
  (completed, total) => {
10701
10746
  const interpolated = 20 + Math.round((i + completed / total) / entityTypeNames.length * 60);
10702
- onProgress(interpolated, `Detecting ${entityTypeName} entities...`, "analyzing", {
10703
- currentEntityType: entityTypeName,
10704
- processedEntityTypes: i,
10705
- totalEntityTypes: entityTypeNames.length,
10747
+ onProgress(interpolated, { code: "detecting-entities", entityType: entityTypeName }, {
10748
+ current: { kind: "entity-type", value: entityTypeName },
10749
+ processed: i,
10750
+ total: entityTypeNames.length,
10706
10751
  entitiesFound: totalFound,
10707
10752
  entitiesEmitted: totalEmitted,
10708
- completedEntityTypes: [...completedEntityTypes],
10753
+ completedItems: [...completedItems],
10709
10754
  requestParams
10710
10755
  });
10711
10756
  }
10712
10757
  );
10713
10758
  totalFound += extractedEntities.length;
10714
- completedEntityTypes.push({ entityType: entityTypeName, foundCount: extractedEntities.length });
10759
+ completedItems.push({ value: entityTypeName, foundCount: extractedEntities.length });
10715
10760
  const unresolvedBody = [
10716
10761
  { type: "TextualBody", value: entityTypeName, purpose: "tagging", format: "text/plain", language: bodyLanguage }
10717
10762
  ];
@@ -10742,36 +10787,49 @@ async function processReferenceJob(content, inferenceClient, params, buildAnnota
10742
10787
  }
10743
10788
  }
10744
10789
  const annotations = dedupeAnnotations(allAnnotations);
10745
- onProgress(100, `Complete! Created ${annotations.length} references`, "creating");
10790
+ onProgress(100, { code: "complete-created", count: annotations.length, kind: "reference" }, { requestParams });
10746
10791
  return {
10747
10792
  annotations,
10748
- result: { totalFound, totalEmitted: annotations.length, errors }
10793
+ result: { kind: "reference-annotation", totalFound, totalEmitted: annotations.length, errors }
10749
10794
  };
10750
10795
  }
10751
10796
  async function processTagJob(content, inferenceClient, params, buildAnnotation, onProgress) {
10752
- onProgress(10, "Loading resource...", "analyzing");
10753
- onProgress(30, "Analyzing text for tags...", "analyzing");
10797
+ onProgress(10, { code: "loading" });
10798
+ onProgress(30, { code: "analyzing-tags" });
10754
10799
  const allTags = [];
10800
+ const completedItems = [];
10755
10801
  for (let c = 0; c < params.categories.length; c++) {
10756
10802
  const category = params.categories[c];
10803
+ const position = () => ({
10804
+ current: { kind: "category", value: category },
10805
+ processed: c,
10806
+ total: params.categories.length,
10807
+ completedItems: [...completedItems]
10808
+ });
10809
+ onProgress(
10810
+ 30 + Math.round(c / params.categories.length * 30),
10811
+ { code: "analyzing-tags" },
10812
+ position()
10813
+ );
10757
10814
  const categoryTags = await AnnotationDetection.detectTags(
10758
10815
  content,
10759
10816
  inferenceClient,
10760
10817
  params.schema,
10761
10818
  category,
10762
10819
  params.sourceLanguage,
10763
- // Chunk-boundary heartbeat (liveness): interpolate within this
10764
- // category's slice of the 30–60 band.
10820
+ // Liveness (chunk boundaries + in-flight heartbeat): this category's
10821
+ // slice of the 30–60 band.
10765
10822
  (completed, total) => onProgress(
10766
10823
  30 + Math.round((c + completed / total) / params.categories.length * 30),
10767
- "Analyzing text for tags...",
10768
- "analyzing"
10824
+ { code: "analyzing-tags" },
10825
+ position()
10769
10826
  )
10770
10827
  );
10828
+ completedItems.push({ value: category, foundCount: categoryTags.length });
10771
10829
  allTags.push(...categoryTags);
10772
10830
  }
10773
10831
  const tags = allTags;
10774
- onProgress(60, `Creating ${tags.length} tag annotations...`, "creating");
10832
+ onProgress(60, { code: "creating-tag-annotations", count: tags.length });
10775
10833
  const bodyLanguage = params.language ?? "en";
10776
10834
  const annotations = dedupeAnnotations(tags.map((t) => {
10777
10835
  const category = t.category ?? "unknown";
@@ -10786,10 +10844,10 @@ async function processTagJob(content, inferenceClient, params, buildAnnotation,
10786
10844
  const category = Array.isArray(body) && typeof body[0]?.value === "string" ? body[0].value : "unknown";
10787
10845
  byCategory[category] = (byCategory[category] ?? 0) + 1;
10788
10846
  }
10789
- onProgress(100, `Complete! Created ${annotations.length} tags`, "creating");
10847
+ onProgress(100, { code: "complete-created", count: annotations.length, kind: "tag" });
10790
10848
  return {
10791
10849
  annotations,
10792
- result: { tagsFound: tags.length, tagsCreated: annotations.length, byCategory }
10850
+ result: { kind: "tag-annotation", tagsFound: tags.length, tagsCreated: annotations.length, byCategory }
10793
10851
  };
10794
10852
  }
10795
10853
  function assertWithinOutputBudget(byteLength) {
@@ -10809,7 +10867,7 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger2
10809
10867
  const title = params.title ?? "Untitled";
10810
10868
  const entityTypes = (params.entityTypes ?? []).map(String);
10811
10869
  if (outputMediaType === "application/pdf") {
10812
- onProgress(5, "Generating resource...", "generating");
10870
+ onProgress(5, { code: "generating-resource" });
10813
10871
  const validIds = params.cite === true ? collectContextResourceIds(params.context) : null;
10814
10872
  let generated2 = await generateResourceFromTopic(
10815
10873
  title,
@@ -10836,7 +10894,17 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger2
10836
10894
  }
10837
10895
  let compiled = compileTypst(source);
10838
10896
  let repairs = 0;
10839
- while ("error" in compiled && repairs < MAX_COMPILE_REPAIRS) {
10897
+ while ("error" in compiled) {
10898
+ if (generated2.truncated) {
10899
+ throw new Error(
10900
+ `Generation stopped at the maxTokens ceiling (${params.maxTokens ?? DEFAULT_MAX_TOKENS} tokens) and the cut-off Typst source does not compile \u2014 repair cannot help; raise maxTokens. Compile error: ${compiled.error}`
10901
+ );
10902
+ }
10903
+ if (repairs >= MAX_COMPILE_REPAIRS) {
10904
+ throw new Error(
10905
+ `Typst compilation failed after ${MAX_COMPILE_REPAIRS} repair attempts: ${compiled.error}`
10906
+ );
10907
+ }
10840
10908
  repairs++;
10841
10909
  logger2.warn("Typst compile failed \u2014 feeding the error back for repair", {
10842
10910
  attempt: repairs,
@@ -10868,25 +10936,23 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger2
10868
10936
  }
10869
10937
  compiled = compileTypst(source);
10870
10938
  }
10871
- if ("error" in compiled) {
10872
- throw new Error(
10873
- `Typst compilation failed after ${MAX_COMPILE_REPAIRS} repair attempts: ${compiled.error}`
10874
- );
10875
- }
10876
10939
  assertWithinOutputBudget(compiled.pdf.byteLength);
10877
- onProgress(95, "Creating resource...", "creating");
10940
+ onProgress(95, { code: "creating-resource" });
10941
+ onProgress(100, { code: "complete-generated", truncated: generated2.truncated });
10878
10942
  return {
10879
10943
  content: compiled.pdf,
10880
- title: generated2.title ?? title,
10944
+ title,
10881
10945
  format: outputMediaType,
10882
10946
  citations: citations2,
10883
10947
  result: {
10948
+ kind: "generation",
10884
10949
  resourceId: "",
10885
- resourceName: generated2.title ?? title
10950
+ resourceName: title,
10951
+ truncated: generated2.truncated
10886
10952
  }
10887
10953
  };
10888
10954
  }
10889
- onProgress(5, "Generating resource...", "generating");
10955
+ onProgress(5, { code: "generating-resource" });
10890
10956
  const generated = await generateResourceFromTopic(
10891
10957
  title,
10892
10958
  entityTypes,
@@ -10910,17 +10976,20 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger2
10910
10976
  content = resolved.content;
10911
10977
  citations = resolved.citations;
10912
10978
  }
10913
- onProgress(95, "Creating resource...", "creating");
10979
+ onProgress(95, { code: "creating-resource" });
10914
10980
  const artifact = new TextEncoder().encode(content);
10915
10981
  assertWithinOutputBudget(artifact.byteLength);
10982
+ onProgress(100, { code: "complete-generated", truncated: generated.truncated });
10916
10983
  return {
10917
10984
  content: artifact,
10918
- title: generated.title ?? title,
10985
+ title,
10919
10986
  format: outputMediaType,
10920
10987
  citations,
10921
10988
  result: {
10989
+ kind: "generation",
10922
10990
  resourceId: "",
10923
- resourceName: generated.title ?? title
10991
+ resourceName: title,
10992
+ truncated: generated.truncated
10924
10993
  }
10925
10994
  };
10926
10995
  }
@@ -10935,7 +11004,7 @@ async function prepareDetection(mediaType, session, resourceId, userId, generato
10935
11004
  key: calculateChecksum(bytes),
10936
11005
  store
10937
11006
  });
10938
- if ("declined" in extracted) return extracted;
11007
+ if (extracted.kind === "declined") return extracted;
10939
11008
  if (!extracted.text.trim()) return { declined: "empty" };
10940
11009
  const items = extracted.items;
10941
11010
  if (items && items.length > 0) {
@@ -10950,13 +11019,18 @@ async function prepareDetection(mediaType, session, resourceId, userId, generato
10950
11019
  buildAnnotation: (motivation, match, body) => buildTextAnnotation(extracted.text, resourceId, userId, generator, motivation, match, body)
10951
11020
  };
10952
11021
  }
10953
- var DECLINE_MESSAGES = {
10954
- "no-text-layer": "This PDF is a scan whose text could not be recognized; there is nothing to detect over.",
10955
- "encrypted": "This PDF is password-protected, so its text cannot be read.",
10956
- "corrupt": "This PDF could not be parsed \u2014 the file may be damaged or truncated.",
10957
- "too-large": "This document is too large to extract text from.",
10958
- "empty": "This document contains no text to detect over."
10959
- };
11022
+ function referenceIdOf(job) {
11023
+ if (job.type === "generation") {
11024
+ const context = job.params.context;
11025
+ const focus = context?.focus;
11026
+ if (focus?.kind === "annotation" && typeof focus.annotation?.id === "string") {
11027
+ return focus.annotation.id;
11028
+ }
11029
+ return void 0;
11030
+ }
11031
+ const ref = job.params.referenceId;
11032
+ return typeof ref === "string" ? ref : void 0;
11033
+ }
10960
11034
  async function emitEvent(session, channel, payload) {
10961
11035
  await session.client.transport.emit(channel, payload);
10962
11036
  }
@@ -10973,7 +11047,7 @@ function startWorkerProcess(config) {
10973
11047
  handleJob(adapter, config, job).catch((error) => {
10974
11048
  const message = error instanceof Error ? error.message : String(error);
10975
11049
  logger2.error("Job failed", { jobId: job.jobId, error: message, stack: error instanceof Error ? error.stack : void 0 });
10976
- const failAnnotationId = job.params.referenceId;
11050
+ const failAnnotationId = referenceIdOf(job);
10977
11051
  if (isJobType(job.type)) {
10978
11052
  emitEvent(session, "job:fail", {
10979
11053
  resourceId: job.resourceId,
@@ -11022,7 +11096,7 @@ async function handleJobInner(adapter, config, job) {
11022
11096
  }
11023
11097
  const jobType = job.type;
11024
11098
  const resourceId$1 = resourceId(job.resourceId);
11025
- const annotationId = job.params.referenceId;
11099
+ const annotationId = referenceIdOf(job);
11026
11100
  const lifecycleBase = {
11027
11101
  resourceId: resourceId$1,
11028
11102
  jobId,
@@ -11038,7 +11112,11 @@ async function handleJobInner(adapter, config, job) {
11038
11112
  if (jobType !== "generation") {
11039
11113
  const descriptor = await session.client.browse.resource(resourceId$1).fresh();
11040
11114
  const mediaType = getPrimaryMediaType(descriptor);
11041
- const source = await prepareDetection(mediaType ?? "", session, resourceId$1, userId, generator, config.anchoredTextStore);
11115
+ const source = await withSpan(
11116
+ "detection:prepare",
11117
+ () => prepareDetection(mediaType ?? "", session, resourceId$1, userId, generator, config.anchoredTextStore),
11118
+ { attrs: { "resource.id": resourceId$1, "media.type": mediaType ?? "unknown" } }
11119
+ );
11042
11120
  if ("declined" in source) {
11043
11121
  if (source.declined === "no-extractor") {
11044
11122
  throw new Error(`Cannot run ${jobType} on resource ${resourceId$1}: media type '${mediaType ?? "unknown"}' has no extractable text to analyze`);
@@ -11046,9 +11124,9 @@ async function handleJobInner(adapter, config, job) {
11046
11124
  await emitEvent(session, "job:complete", {
11047
11125
  ...lifecycleBase,
11048
11126
  result: {
11127
+ kind: "declined",
11049
11128
  declined: true,
11050
- reason: source.declined,
11051
- message: DECLINE_MESSAGES[source.declined]
11129
+ reason: source.declined
11052
11130
  }
11053
11131
  });
11054
11132
  adapter.completeJob();
@@ -11056,13 +11134,12 @@ async function handleJobInner(adapter, config, job) {
11056
11134
  }
11057
11135
  ready = source;
11058
11136
  }
11059
- const onProgress = (percentage, message, stage, extra) => {
11137
+ const onProgress = (percentage, message, extra) => {
11060
11138
  adapter.touchActivity();
11061
11139
  emitEvent(session, "job:report-progress", {
11062
11140
  ...lifecycleBase,
11063
11141
  percentage,
11064
11142
  progress: {
11065
- stage,
11066
11143
  percentage,
11067
11144
  message,
11068
11145
  ...annotationId ? { annotationId } : {},
@@ -11153,6 +11230,11 @@ async function handleJobInner(adapter, config, job) {
11153
11230
  });
11154
11231
  adapter.completeJob();
11155
11232
  } else if (jobType === "generation") {
11233
+ if (!isGenerationJobParams(job.params)) {
11234
+ throw new Error(
11235
+ `generation job ${job.jobId}: params do not satisfy GenerationJobParams (title, storageUri, and context are required)`
11236
+ );
11237
+ }
11156
11238
  const genResult = await processGenerationJob(
11157
11239
  inferenceClient,
11158
11240
  job.params,
@@ -11160,6 +11242,7 @@ async function handleJobInner(adapter, config, job) {
11160
11242
  config.logger
11161
11243
  );
11162
11244
  const genParams = job.params;
11245
+ const genReferenceId = referenceIdOf(job);
11163
11246
  const storageUri = deriveStorageUri(genResult.title, genResult.format);
11164
11247
  const { resourceId: newResourceId } = await session.client.yield.resource({
11165
11248
  name: genResult.title,
@@ -11167,13 +11250,13 @@ async function handleJobInner(adapter, config, job) {
11167
11250
  format: genResult.format,
11168
11251
  storageUri,
11169
11252
  sourceResourceId: resourceId$1,
11170
- ...genParams.referenceId ? { sourceAnnotationId: genParams.referenceId } : {},
11253
+ ...genReferenceId ? { sourceAnnotationId: genReferenceId } : {},
11171
11254
  ...genParams.prompt ? { generationPrompt: genParams.prompt } : {},
11172
11255
  ...genParams.language ? { language: genParams.language } : {},
11173
11256
  ...genParams.entityTypes && genParams.entityTypes.length > 0 ? { entityTypes: genParams.entityTypes } : {},
11174
11257
  generator
11175
11258
  });
11176
- if (!genParams.referenceId) {
11259
+ if (!genReferenceId) {
11177
11260
  const { annotation: provenanceRef } = assembleAnnotation(
11178
11261
  {
11179
11262
  motivation: "linking",
@@ -11237,7 +11320,7 @@ async function handleJobInner(adapter, config, job) {
11237
11320
  }
11238
11321
  await emitEvent(session, "job:complete", {
11239
11322
  ...lifecycleBase,
11240
- result: { resourceId: newResourceId, resourceName: genResult.title }
11323
+ result: { kind: "generation", resourceId: newResourceId, resourceName: genResult.title, truncated: genResult.result.truncated }
11241
11324
  });
11242
11325
  adapter.completeJob();
11243
11326
  } else {