@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.
package/dist/index.js CHANGED
@@ -2,11 +2,11 @@ import { promises, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs';
2
2
  import * as path from 'path';
3
3
  import { join } from 'path';
4
4
  import { jobId, deriveViews, reconcileSelector, GENERATABLE_MEDIA_TYPES, estimateTokens, chunkText, isObject, isString, getLocaleEnglishName } from '@semiont/core';
5
+ import { withSpan } from '@semiont/observability';
5
6
  import { execFileSync } from 'child_process';
6
7
  import { tmpdir } from 'os';
7
8
  import { withinByteBudget, MAX_PDF_BYTES } from '@semiont/content';
8
9
  import '@semiont/event-sourcing';
9
- import '@semiont/observability';
10
10
  import '@semiont/sdk';
11
11
  import '@semiont/http-transport';
12
12
 
@@ -426,10 +426,18 @@ function isFailedJob(job) {
426
426
  function isCancelledJob(job) {
427
427
  return job.status === "cancelled";
428
428
  }
429
-
430
- // src/workers/inference-call.ts
431
429
  var INFERENCE_TIMEOUT_MS = 10 * 6e4;
432
- async function withTimeout(work, label) {
430
+ var INFERENCE_HEARTBEAT_MS = 15e3;
431
+ function spanned(client, kind, maxTokens, work) {
432
+ return withSpan(`inference:${kind}`, work, {
433
+ attrs: {
434
+ "inference.provider": client.type,
435
+ "inference.model": client.modelId,
436
+ "inference.max_tokens": maxTokens
437
+ }
438
+ });
439
+ }
440
+ async function withTimeout(work, label, onHeartbeat) {
433
441
  let timer;
434
442
  const timedOut = new Promise((_, reject) => {
435
443
  timer = setTimeout(() => {
@@ -439,6 +447,16 @@ async function withTimeout(work, label) {
439
447
  }, INFERENCE_TIMEOUT_MS);
440
448
  timer.unref?.();
441
449
  });
450
+ let heartbeat;
451
+ if (onHeartbeat) {
452
+ heartbeat = setInterval(() => {
453
+ try {
454
+ onHeartbeat();
455
+ } catch {
456
+ }
457
+ }, INFERENCE_HEARTBEAT_MS);
458
+ heartbeat.unref?.();
459
+ }
442
460
  try {
443
461
  return await Promise.race([work, timedOut]);
444
462
  } catch (err) {
@@ -447,19 +465,22 @@ async function withTimeout(work, label) {
447
465
  throw err;
448
466
  } finally {
449
467
  clearTimeout(timer);
468
+ if (heartbeat) clearInterval(heartbeat);
450
469
  }
451
470
  }
452
- function boundedGenerate(client, prompt, maxTokens, temperature) {
453
- return withTimeout(
454
- client.generateText(prompt, maxTokens, temperature),
455
- `${client.type}:${client.modelId}`
456
- );
471
+ function boundedGenerateWithMetadata(client, prompt, maxTokens, temperature, onHeartbeat) {
472
+ return spanned(client, "text", maxTokens, () => withTimeout(
473
+ client.generateTextWithMetadata(prompt, maxTokens, temperature),
474
+ `${client.type}:${client.modelId}`,
475
+ onHeartbeat
476
+ ));
457
477
  }
458
- function boundedGenerateStructured(client, prompt, maxTokens, temperature, elementSchema) {
459
- return withTimeout(
478
+ function boundedGenerateStructured(client, prompt, maxTokens, temperature, elementSchema, onHeartbeat) {
479
+ return spanned(client, "structured", maxTokens, () => withTimeout(
460
480
  client.generateStructured(prompt, maxTokens, temperature, elementSchema),
461
- `${client.type}:${client.modelId}`
462
- );
481
+ `${client.type}:${client.modelId}`,
482
+ onHeartbeat
483
+ ));
463
484
  }
464
485
 
465
486
  // src/workers/detection/detection-chunking.ts
@@ -990,7 +1011,7 @@ function assertNotTruncated(response, motivation, chunk, totalChunks, outputBudg
990
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.`);
991
1012
  }
992
1013
  }
993
- async function detectInChunks(client, content, buildPrompt, temperature, motivation, elementSchema, parse, onChunk) {
1014
+ async function detectInChunks(client, content, buildPrompt, temperature, motivation, elementSchema, parse, onActivity) {
994
1015
  const limits = await client.limits();
995
1016
  const scaffoldTokens = estimateTokens(buildPrompt(""));
996
1017
  const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens);
@@ -1002,12 +1023,14 @@ async function detectInChunks(client, content, buildPrompt, temperature, motivat
1002
1023
  buildPrompt(chunks[i]),
1003
1024
  outputBudget,
1004
1025
  temperature,
1005
- elementSchema
1026
+ elementSchema,
1027
+ // Still alive, same position (a long single call is otherwise silent).
1028
+ () => onActivity?.(i, chunks.length)
1006
1029
  );
1007
1030
  assertNotTruncated(response, motivation, i + 1, chunks.length, outputBudget);
1008
1031
  collected.push(...parse(response.items));
1009
1032
  if (i < chunks.length - 1) {
1010
- onChunk?.(i + 1, chunks.length);
1033
+ onActivity?.(i + 1, chunks.length);
1011
1034
  }
1012
1035
  }
1013
1036
  return collected;
@@ -1021,7 +1044,7 @@ var AnnotationDetection = class {
1021
1044
  * (source-resource locale). See `types.ts` "Locale conventions" for the
1022
1045
  * full discussion.
1023
1046
  */
1024
- static async detectComments(content, client, instructions, tone, density, language, sourceLanguage, onChunk) {
1047
+ static async detectComments(content, client, instructions, tone, density, language, sourceLanguage, onActivity) {
1025
1048
  return detectInChunks(
1026
1049
  client,
1027
1050
  content,
@@ -1030,7 +1053,7 @@ var AnnotationDetection = class {
1030
1053
  "comment",
1031
1054
  COMMENT_ELEMENT_SCHEMA,
1032
1055
  (items) => MotivationParsers.parseComments(items, content),
1033
- onChunk
1056
+ onActivity
1034
1057
  );
1035
1058
  }
1036
1059
  /**
@@ -1040,7 +1063,7 @@ var AnnotationDetection = class {
1040
1063
  * applies, used in the prompt so the LLM analyzes non-English source
1041
1064
  * correctly.
1042
1065
  */
1043
- static async detectHighlights(content, client, instructions, density, sourceLanguage, onChunk) {
1066
+ static async detectHighlights(content, client, instructions, density, sourceLanguage, onActivity) {
1044
1067
  return detectInChunks(
1045
1068
  client,
1046
1069
  content,
@@ -1049,7 +1072,7 @@ var AnnotationDetection = class {
1049
1072
  "highlight",
1050
1073
  HIGHLIGHT_ELEMENT_SCHEMA,
1051
1074
  (items) => MotivationParsers.parseHighlights(items, content),
1052
- onChunk
1075
+ onActivity
1053
1076
  );
1054
1077
  }
1055
1078
  /**
@@ -1059,7 +1082,7 @@ var AnnotationDetection = class {
1059
1082
  * (annotation body locale). `sourceLanguage` is the locale of the content
1060
1083
  * being analyzed (source-resource locale).
1061
1084
  */
1062
- static async detectAssessments(content, client, instructions, tone, density, language, sourceLanguage, onChunk) {
1085
+ static async detectAssessments(content, client, instructions, tone, density, language, sourceLanguage, onActivity) {
1063
1086
  return detectInChunks(
1064
1087
  client,
1065
1088
  content,
@@ -1068,7 +1091,7 @@ var AnnotationDetection = class {
1068
1091
  "assessment",
1069
1092
  ASSESSMENT_ELEMENT_SCHEMA,
1070
1093
  (items) => MotivationParsers.parseAssessments(items, content),
1071
- onChunk
1094
+ onActivity
1072
1095
  );
1073
1096
  }
1074
1097
  /**
@@ -1083,7 +1106,7 @@ var AnnotationDetection = class {
1083
1106
  * identifiers, not LLM-generated text — so it's consumed at the body-stamp
1084
1107
  * site, not here.
1085
1108
  */
1086
- static async detectTags(content, client, schema, category, sourceLanguage, onChunk) {
1109
+ static async detectTags(content, client, schema, category, sourceLanguage, onActivity) {
1087
1110
  const categoryInfo = schema.tags.find((t) => t.name === category);
1088
1111
  if (!categoryInfo) {
1089
1112
  throw new Error(`Invalid category "${category}" for schema ${schema.id}`);
@@ -1105,7 +1128,7 @@ var AnnotationDetection = class {
1105
1128
  "tag",
1106
1129
  TAG_ELEMENT_SCHEMA,
1107
1130
  (items) => MotivationParsers.parseTags(items),
1108
- onChunk
1131
+ onActivity
1109
1132
  );
1110
1133
  return MotivationParsers.validateTagOffsets(parsedTags, content, category);
1111
1134
  }
@@ -1121,7 +1144,7 @@ var ENTITY_ELEMENT_SCHEMA = {
1121
1144
  required: ["exact", "entityType"],
1122
1145
  additionalProperties: false
1123
1146
  };
1124
- async function extractEntities(exact, entityTypes, client, includeDescriptiveReferences, logger, sourceLanguage, onChunk) {
1147
+ async function extractEntities(exact, entityTypes, client, includeDescriptiveReferences, logger, sourceLanguage, onActivity) {
1125
1148
  const entityTypesDescription = entityTypes.map((et) => {
1126
1149
  if (typeof et === "string") {
1127
1150
  return et;
@@ -1188,7 +1211,10 @@ Example output:
1188
1211
  outputBudget,
1189
1212
  0.3,
1190
1213
  // Lower temperature for more consistent extraction
1191
- ENTITY_ELEMENT_SCHEMA
1214
+ ENTITY_ELEMENT_SCHEMA,
1215
+ // Still alive, same position: a long single call would otherwise emit
1216
+ // nothing at all between start and finish.
1217
+ () => onActivity?.(i, chunks.length)
1192
1218
  );
1193
1219
  logger.debug("Got entity extraction response", {
1194
1220
  chunk: i + 1,
@@ -1213,7 +1239,7 @@ Example output:
1213
1239
  }
1214
1240
  }
1215
1241
  if (i < chunks.length - 1) {
1216
- onChunk?.(i + 1, chunks.length);
1242
+ onActivity?.(i + 1, chunks.length);
1217
1243
  }
1218
1244
  }
1219
1245
  return collected;
@@ -1227,6 +1253,7 @@ var SEMANTIC_MATCH_CHARS = 240;
1227
1253
  function idLabel(resourceId, annotationId) {
1228
1254
  return `[${resourceId}${annotationId ? `/${annotationId}` : ""}]`;
1229
1255
  }
1256
+ var DEFAULT_MAX_TOKENS = 500;
1230
1257
  async function generateResourceFromTopic(topic, entityTypes, client, logger, userPrompt, locale, context, temperature, maxTokens, sourceLanguage, outputMediaType = "text/markdown", task = "resource", structure, cite = false, repair) {
1231
1258
  logger.debug("Generating resource from topic", {
1232
1259
  topicPreview: topic.substring(0, 100),
@@ -1242,7 +1269,7 @@ async function generateResourceFromTopic(topic, entityTypes, client, logger, use
1242
1269
  structure
1243
1270
  });
1244
1271
  const finalTemperature = temperature ?? 0.7;
1245
- const finalMaxTokens = maxTokens ?? 500;
1272
+ const finalMaxTokens = maxTokens ?? DEFAULT_MAX_TOKENS;
1246
1273
  const languageInstruction = locale && locale !== "en" ? `
1247
1274
 
1248
1275
  IMPORTANT: Write the entire resource in ${getLanguageName(locale)}.` : "";
@@ -1269,6 +1296,9 @@ The source resource and embedded context are in ${getLanguageName(sourceLanguage
1269
1296
  parts.push(`- ${label}: ${bodyItem.value}`);
1270
1297
  }
1271
1298
  }
1299
+ if (focus.userHint) {
1300
+ parts.push(`- User hint (steers what to generate): ${focus.userHint}`);
1301
+ }
1272
1302
  annotationSection = `
1273
1303
 
1274
1304
  Annotation context:
@@ -1427,16 +1457,16 @@ ${formatRequirements}`;
1427
1457
  temperature: finalTemperature,
1428
1458
  maxTokens: finalMaxTokens
1429
1459
  });
1430
- const response = await boundedGenerate(client, prompt, finalMaxTokens, finalTemperature);
1431
- logger.debug("Got response from inference", { responseLength: response.length });
1432
- const result = parseResponse(response);
1460
+ const response = await boundedGenerateWithMetadata(client, prompt, finalMaxTokens, finalTemperature);
1461
+ logger.debug("Got response from inference", { responseLength: response.text.length, stopReason: response.stopReason });
1462
+ const result = parseResponse(response.text);
1433
1463
  logger.debug("Parsed response", {
1434
1464
  hasTitle: !!result.title,
1435
1465
  titleLength: result.title?.length,
1436
1466
  hasContent: !!result.content,
1437
1467
  contentLength: result.content?.length
1438
1468
  });
1439
- return result;
1469
+ return { ...result, truncated: response.stopReason === "max_tokens" };
1440
1470
  }
1441
1471
  var PINNED_CREATION_TIMESTAMP = 17e8;
1442
1472
  var MAX_COMPILE_REPAIRS = 2;
@@ -1557,30 +1587,39 @@ function dedupeAnnotations(annotations) {
1557
1587
  return out;
1558
1588
  }
1559
1589
  async function processHighlightJob(content, inferenceClient, params, buildAnnotation, onProgress) {
1560
- onProgress(10, "Loading resource...", "analyzing");
1561
- onProgress(30, "Analyzing text...", "analyzing");
1590
+ const echo = detectionEcho(params);
1591
+ onProgress(10, { code: "loading" }, echo);
1592
+ onProgress(30, { code: "analyzing" }, echo);
1562
1593
  const highlights = await AnnotationDetection.detectHighlights(
1563
1594
  content,
1564
1595
  inferenceClient,
1565
1596
  params.instructions,
1566
1597
  params.density,
1567
1598
  params.sourceLanguage,
1568
- // Chunk-boundary heartbeat (liveness): interpolate within the 30–60 band.
1569
- (completed, total) => onProgress(30 + Math.round(completed / total * 30), "Analyzing text...", "analyzing")
1599
+ // Liveness (chunk boundaries + in-flight heartbeat): 30–60 band.
1600
+ (completed, total) => onProgress(30 + Math.round(completed / total * 30), { code: "analyzing" }, echo)
1570
1601
  );
1571
- onProgress(60, `Creating ${highlights.length} annotations...`, "creating");
1602
+ onProgress(60, { code: "creating-annotations", count: highlights.length }, echo);
1572
1603
  const annotations = dedupeAnnotations(highlights.map(
1573
1604
  (h) => buildAnnotation("highlighting", h)
1574
1605
  ));
1575
- onProgress(100, `Complete! Created ${annotations.length} highlights`, "creating");
1606
+ onProgress(100, { code: "complete-created", count: annotations.length, kind: "highlight" }, echo);
1576
1607
  return {
1577
1608
  annotations,
1578
- result: { highlightsFound: highlights.length, highlightsCreated: annotations.length }
1609
+ result: { kind: "highlight-annotation", highlightsFound: highlights.length, highlightsCreated: annotations.length }
1579
1610
  };
1580
1611
  }
1612
+ function detectionEcho(p) {
1613
+ const requestParams = [];
1614
+ if (p.instructions?.trim()) requestParams.push({ label: "instructions", value: p.instructions.trim() });
1615
+ if (p.tone?.trim()) requestParams.push({ label: "tone", value: p.tone.trim() });
1616
+ if (p.density !== void 0) requestParams.push({ label: "density", value: String(p.density) });
1617
+ return requestParams.length > 0 ? { requestParams } : {};
1618
+ }
1581
1619
  async function processCommentJob(content, inferenceClient, params, buildAnnotation, onProgress) {
1582
- onProgress(10, "Loading resource...", "analyzing");
1583
- onProgress(30, "Analyzing text...", "analyzing");
1620
+ const echo = detectionEcho(params);
1621
+ onProgress(10, { code: "loading" }, echo);
1622
+ onProgress(30, { code: "analyzing" }, echo);
1584
1623
  const comments = await AnnotationDetection.detectComments(
1585
1624
  content,
1586
1625
  inferenceClient,
@@ -1589,10 +1628,10 @@ async function processCommentJob(content, inferenceClient, params, buildAnnotati
1589
1628
  params.density,
1590
1629
  params.language,
1591
1630
  params.sourceLanguage,
1592
- // Chunk-boundary heartbeat (liveness): interpolate within the 30–60 band.
1593
- (completed, total) => onProgress(30 + Math.round(completed / total * 30), "Analyzing text...", "analyzing")
1631
+ // Liveness (chunk boundaries + in-flight heartbeat): 30–60 band.
1632
+ (completed, total) => onProgress(30 + Math.round(completed / total * 30), { code: "analyzing" }, echo)
1594
1633
  );
1595
- onProgress(60, `Creating ${comments.length} annotations...`, "creating");
1634
+ onProgress(60, { code: "creating-annotations", count: comments.length }, echo);
1596
1635
  const bodyLanguage = params.language ?? "en";
1597
1636
  const annotations = dedupeAnnotations(comments.map(
1598
1637
  (c) => (
@@ -1604,15 +1643,16 @@ async function processCommentJob(content, inferenceClient, params, buildAnnotati
1604
1643
  ])
1605
1644
  )
1606
1645
  ));
1607
- onProgress(100, `Complete! Created ${annotations.length} comments`, "creating");
1646
+ onProgress(100, { code: "complete-created", count: annotations.length, kind: "comment" }, echo);
1608
1647
  return {
1609
1648
  annotations,
1610
- result: { commentsFound: comments.length, commentsCreated: annotations.length }
1649
+ result: { kind: "comment-annotation", commentsFound: comments.length, commentsCreated: annotations.length }
1611
1650
  };
1612
1651
  }
1613
1652
  async function processAssessmentJob(content, inferenceClient, params, buildAnnotation, onProgress) {
1614
- onProgress(10, "Loading resource...", "analyzing");
1615
- onProgress(30, "Analyzing text...", "analyzing");
1653
+ const echo = detectionEcho(params);
1654
+ onProgress(10, { code: "loading" }, echo);
1655
+ onProgress(30, { code: "analyzing" }, echo);
1616
1656
  const assessments = await AnnotationDetection.detectAssessments(
1617
1657
  content,
1618
1658
  inferenceClient,
@@ -1621,10 +1661,10 @@ async function processAssessmentJob(content, inferenceClient, params, buildAnnot
1621
1661
  params.density,
1622
1662
  params.language,
1623
1663
  params.sourceLanguage,
1624
- // Chunk-boundary heartbeat (liveness): interpolate within the 30–60 band.
1625
- (completed, total) => onProgress(30 + Math.round(completed / total * 30), "Analyzing text...", "analyzing")
1664
+ // Liveness (chunk boundaries + in-flight heartbeat): 30–60 band.
1665
+ (completed, total) => onProgress(30 + Math.round(completed / total * 30), { code: "analyzing" }, echo)
1626
1666
  );
1627
- onProgress(60, `Creating ${assessments.length} annotations...`, "creating");
1667
+ onProgress(60, { code: "creating-annotations", count: assessments.length }, echo);
1628
1668
  const bodyLanguage = params.language ?? "en";
1629
1669
  const annotations = dedupeAnnotations(assessments.map(
1630
1670
  (a) => (
@@ -1643,33 +1683,35 @@ async function processAssessmentJob(content, inferenceClient, params, buildAnnot
1643
1683
  })
1644
1684
  )
1645
1685
  ));
1646
- onProgress(100, `Complete! Created ${annotations.length} assessments`, "creating");
1686
+ onProgress(100, { code: "complete-created", count: annotations.length, kind: "assessment" }, echo);
1647
1687
  return {
1648
1688
  annotations,
1649
- result: { assessmentsFound: assessments.length, assessmentsCreated: annotations.length }
1689
+ result: { kind: "assessment-annotation", assessmentsFound: assessments.length, assessmentsCreated: annotations.length }
1650
1690
  };
1651
1691
  }
1652
1692
  async function processReferenceJob(content, inferenceClient, params, buildAnnotation, onProgress, logger) {
1653
1693
  const entityTypeNames = params.entityTypes.map(String);
1654
- const requestParams = [{ label: "Entity types", value: entityTypeNames.join(", ") }];
1655
- const completedEntityTypes = [];
1694
+ const requestParams = [{ label: "entity-types", value: entityTypeNames.join(", ") }];
1695
+ const completedItems = [];
1656
1696
  let totalFound = 0;
1657
1697
  let totalEmitted = 0;
1658
1698
  let errors = 0;
1659
1699
  const allAnnotations = [];
1660
- onProgress(10, "Loading resource...", "analyzing", { requestParams });
1700
+ onProgress(10, { code: "loading" }, { requestParams });
1661
1701
  const bodyLanguage = params.language ?? "en";
1662
1702
  for (let i = 0; i < entityTypeNames.length; i++) {
1663
1703
  const entityTypeName = entityTypeNames[i];
1664
1704
  if (!entityTypeName) continue;
1665
1705
  const pct = 20 + Math.round(i / entityTypeNames.length * 60);
1666
- onProgress(pct, `Detecting ${entityTypeName} entities...`, "analyzing", {
1667
- currentEntityType: entityTypeName,
1668
- processedEntityTypes: i,
1669
- totalEntityTypes: entityTypeNames.length,
1706
+ 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
+ current: { kind: "entity-type", value: entityTypeName },
1710
+ processed: i,
1711
+ total: entityTypeNames.length,
1670
1712
  entitiesFound: totalFound,
1671
1713
  entitiesEmitted: totalEmitted,
1672
- completedEntityTypes: [...completedEntityTypes],
1714
+ completedItems: [...completedItems],
1673
1715
  requestParams
1674
1716
  });
1675
1717
  const extractedEntities = await extractEntities(
@@ -1679,25 +1721,28 @@ async function processReferenceJob(content, inferenceClient, params, buildAnnota
1679
1721
  params.includeDescriptiveReferences ?? false,
1680
1722
  logger,
1681
1723
  params.sourceLanguage,
1682
- // Chunk-boundary heartbeat: progress is the worker's liveness signal
1683
- // (stall watchdog + backend janitor), so multi-chunk extraction must
1684
- // emit between inference calls. Percentage interpolates within this
1685
- // entity type's band of the 20–80 range.
1724
+ // Liveness: fires at chunk boundaries AND every ~15 s while a single
1725
+ // inference call is in flight (DETECTION-HEARTBEAT). Progress feeds the
1726
+ // stall watchdog, the janitor, AND the client's inter-emission timeout,
1727
+ // so a long single-chunk call must not be silent. Percentage
1728
+ // interpolates within this entity type's band of the 20–80 range; a
1729
+ // heartbeat repeats the current position rather than inventing an
1730
+ // advance.
1686
1731
  (completed, total) => {
1687
1732
  const interpolated = 20 + Math.round((i + completed / total) / entityTypeNames.length * 60);
1688
- onProgress(interpolated, `Detecting ${entityTypeName} entities...`, "analyzing", {
1689
- currentEntityType: entityTypeName,
1690
- processedEntityTypes: i,
1691
- totalEntityTypes: entityTypeNames.length,
1733
+ onProgress(interpolated, { code: "detecting-entities", entityType: entityTypeName }, {
1734
+ current: { kind: "entity-type", value: entityTypeName },
1735
+ processed: i,
1736
+ total: entityTypeNames.length,
1692
1737
  entitiesFound: totalFound,
1693
1738
  entitiesEmitted: totalEmitted,
1694
- completedEntityTypes: [...completedEntityTypes],
1739
+ completedItems: [...completedItems],
1695
1740
  requestParams
1696
1741
  });
1697
1742
  }
1698
1743
  );
1699
1744
  totalFound += extractedEntities.length;
1700
- completedEntityTypes.push({ entityType: entityTypeName, foundCount: extractedEntities.length });
1745
+ completedItems.push({ value: entityTypeName, foundCount: extractedEntities.length });
1701
1746
  const unresolvedBody = [
1702
1747
  { type: "TextualBody", value: entityTypeName, purpose: "tagging", format: "text/plain", language: bodyLanguage }
1703
1748
  ];
@@ -1728,36 +1773,49 @@ async function processReferenceJob(content, inferenceClient, params, buildAnnota
1728
1773
  }
1729
1774
  }
1730
1775
  const annotations = dedupeAnnotations(allAnnotations);
1731
- onProgress(100, `Complete! Created ${annotations.length} references`, "creating");
1776
+ onProgress(100, { code: "complete-created", count: annotations.length, kind: "reference" }, { requestParams });
1732
1777
  return {
1733
1778
  annotations,
1734
- result: { totalFound, totalEmitted: annotations.length, errors }
1779
+ result: { kind: "reference-annotation", totalFound, totalEmitted: annotations.length, errors }
1735
1780
  };
1736
1781
  }
1737
1782
  async function processTagJob(content, inferenceClient, params, buildAnnotation, onProgress) {
1738
- onProgress(10, "Loading resource...", "analyzing");
1739
- onProgress(30, "Analyzing text for tags...", "analyzing");
1783
+ onProgress(10, { code: "loading" });
1784
+ onProgress(30, { code: "analyzing-tags" });
1740
1785
  const allTags = [];
1786
+ const completedItems = [];
1741
1787
  for (let c = 0; c < params.categories.length; c++) {
1742
1788
  const category = params.categories[c];
1789
+ const position = () => ({
1790
+ current: { kind: "category", value: category },
1791
+ processed: c,
1792
+ total: params.categories.length,
1793
+ completedItems: [...completedItems]
1794
+ });
1795
+ onProgress(
1796
+ 30 + Math.round(c / params.categories.length * 30),
1797
+ { code: "analyzing-tags" },
1798
+ position()
1799
+ );
1743
1800
  const categoryTags = await AnnotationDetection.detectTags(
1744
1801
  content,
1745
1802
  inferenceClient,
1746
1803
  params.schema,
1747
1804
  category,
1748
1805
  params.sourceLanguage,
1749
- // Chunk-boundary heartbeat (liveness): interpolate within this
1750
- // category's slice of the 30–60 band.
1806
+ // Liveness (chunk boundaries + in-flight heartbeat): this category's
1807
+ // slice of the 30–60 band.
1751
1808
  (completed, total) => onProgress(
1752
1809
  30 + Math.round((c + completed / total) / params.categories.length * 30),
1753
- "Analyzing text for tags...",
1754
- "analyzing"
1810
+ { code: "analyzing-tags" },
1811
+ position()
1755
1812
  )
1756
1813
  );
1814
+ completedItems.push({ value: category, foundCount: categoryTags.length });
1757
1815
  allTags.push(...categoryTags);
1758
1816
  }
1759
1817
  const tags = allTags;
1760
- onProgress(60, `Creating ${tags.length} tag annotations...`, "creating");
1818
+ onProgress(60, { code: "creating-tag-annotations", count: tags.length });
1761
1819
  const bodyLanguage = params.language ?? "en";
1762
1820
  const annotations = dedupeAnnotations(tags.map((t) => {
1763
1821
  const category = t.category ?? "unknown";
@@ -1772,10 +1830,10 @@ async function processTagJob(content, inferenceClient, params, buildAnnotation,
1772
1830
  const category = Array.isArray(body) && typeof body[0]?.value === "string" ? body[0].value : "unknown";
1773
1831
  byCategory[category] = (byCategory[category] ?? 0) + 1;
1774
1832
  }
1775
- onProgress(100, `Complete! Created ${annotations.length} tags`, "creating");
1833
+ onProgress(100, { code: "complete-created", count: annotations.length, kind: "tag" });
1776
1834
  return {
1777
1835
  annotations,
1778
- result: { tagsFound: tags.length, tagsCreated: annotations.length, byCategory }
1836
+ result: { kind: "tag-annotation", tagsFound: tags.length, tagsCreated: annotations.length, byCategory }
1779
1837
  };
1780
1838
  }
1781
1839
  function assertWithinOutputBudget(byteLength) {
@@ -1795,7 +1853,7 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger)
1795
1853
  const title = params.title ?? "Untitled";
1796
1854
  const entityTypes = (params.entityTypes ?? []).map(String);
1797
1855
  if (outputMediaType === "application/pdf") {
1798
- onProgress(5, "Generating resource...", "generating");
1856
+ onProgress(5, { code: "generating-resource" });
1799
1857
  const validIds = params.cite === true ? collectContextResourceIds(params.context) : null;
1800
1858
  let generated2 = await generateResourceFromTopic(
1801
1859
  title,
@@ -1822,7 +1880,17 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger)
1822
1880
  }
1823
1881
  let compiled = compileTypst(source);
1824
1882
  let repairs = 0;
1825
- while ("error" in compiled && repairs < MAX_COMPILE_REPAIRS) {
1883
+ while ("error" in compiled) {
1884
+ if (generated2.truncated) {
1885
+ throw new Error(
1886
+ `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}`
1887
+ );
1888
+ }
1889
+ if (repairs >= MAX_COMPILE_REPAIRS) {
1890
+ throw new Error(
1891
+ `Typst compilation failed after ${MAX_COMPILE_REPAIRS} repair attempts: ${compiled.error}`
1892
+ );
1893
+ }
1826
1894
  repairs++;
1827
1895
  logger.warn("Typst compile failed \u2014 feeding the error back for repair", {
1828
1896
  attempt: repairs,
@@ -1854,25 +1922,23 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger)
1854
1922
  }
1855
1923
  compiled = compileTypst(source);
1856
1924
  }
1857
- if ("error" in compiled) {
1858
- throw new Error(
1859
- `Typst compilation failed after ${MAX_COMPILE_REPAIRS} repair attempts: ${compiled.error}`
1860
- );
1861
- }
1862
1925
  assertWithinOutputBudget(compiled.pdf.byteLength);
1863
- onProgress(95, "Creating resource...", "creating");
1926
+ onProgress(95, { code: "creating-resource" });
1927
+ onProgress(100, { code: "complete-generated", truncated: generated2.truncated });
1864
1928
  return {
1865
1929
  content: compiled.pdf,
1866
- title: generated2.title ?? title,
1930
+ title,
1867
1931
  format: outputMediaType,
1868
1932
  citations: citations2,
1869
1933
  result: {
1934
+ kind: "generation",
1870
1935
  resourceId: "",
1871
- resourceName: generated2.title ?? title
1936
+ resourceName: title,
1937
+ truncated: generated2.truncated
1872
1938
  }
1873
1939
  };
1874
1940
  }
1875
- onProgress(5, "Generating resource...", "generating");
1941
+ onProgress(5, { code: "generating-resource" });
1876
1942
  const generated = await generateResourceFromTopic(
1877
1943
  title,
1878
1944
  entityTypes,
@@ -1896,17 +1962,20 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger)
1896
1962
  content = resolved.content;
1897
1963
  citations = resolved.citations;
1898
1964
  }
1899
- onProgress(95, "Creating resource...", "creating");
1965
+ onProgress(95, { code: "creating-resource" });
1900
1966
  const artifact = new TextEncoder().encode(content);
1901
1967
  assertWithinOutputBudget(artifact.byteLength);
1968
+ onProgress(100, { code: "complete-generated", truncated: generated.truncated });
1902
1969
  return {
1903
1970
  content: artifact,
1904
- title: generated.title ?? title,
1971
+ title,
1905
1972
  format: outputMediaType,
1906
1973
  citations,
1907
1974
  result: {
1975
+ kind: "generation",
1908
1976
  resourceId: "",
1909
- resourceName: generated.title ?? title
1977
+ resourceName: title,
1978
+ truncated: generated.truncated
1910
1979
  }
1911
1980
  };
1912
1981
  }