@semiont/jobs 0.5.23 → 0.5.25

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
@@ -1,8 +1,11 @@
1
- import { promises } from 'fs';
1
+ import { promises, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs';
2
2
  import * as path from 'path';
3
- import { jobId, deriveViews, reconcileSelector, isObject, isString, getLocaleEnglishName, isArray } from '@semiont/core';
3
+ import { join } from 'path';
4
+ import { jobId, deriveViews, reconcileSelector, GENERATABLE_MEDIA_TYPES, estimateTokens, chunkText, isObject, isString, getLocaleEnglishName, isArray } from '@semiont/core';
5
+ import { execFileSync } from 'child_process';
6
+ import { tmpdir } from 'os';
7
+ import { withinByteBudget, MAX_PDF_BYTES } from '@semiont/content';
4
8
  import '@semiont/event-sourcing';
5
- import '@semiont/content';
6
9
  import '@semiont/observability';
7
10
  import '@semiont/sdk';
8
11
  import '@semiont/http-transport';
@@ -458,6 +461,39 @@ function boundedGenerateWithMetadata(client, prompt, maxTokens, temperature, opt
458
461
  `${client.type}:${client.modelId}`
459
462
  );
460
463
  }
464
+
465
+ // src/workers/detection/detection-chunking.ts
466
+ var SELECTOR_CONTEXT_CHARS = 64;
467
+ var OVERLAP_CHARS = SELECTOR_CONTEXT_CHARS + // prefix
468
+ SELECTOR_CONTEXT_CHARS + // suffix
469
+ 2 * SELECTOR_CONTEXT_CHARS;
470
+ var OVERLAP_TOKENS = Math.ceil(OVERLAP_CHARS / 4);
471
+ function deriveDetectionBudget(limits, scaffoldTokens) {
472
+ const { contextTokens, maxOutputTokens } = limits;
473
+ const available = contextTokens - scaffoldTokens;
474
+ let inputBudget;
475
+ let outputBudget;
476
+ if (maxOutputTokens >= contextTokens) {
477
+ inputBudget = Math.floor(available / 3);
478
+ outputBudget = available - inputBudget;
479
+ } else {
480
+ outputBudget = maxOutputTokens;
481
+ inputBudget = contextTokens - outputBudget - scaffoldTokens;
482
+ if (inputBudget <= 0) {
483
+ inputBudget = Math.floor(available / 3);
484
+ outputBudget = available - inputBudget;
485
+ }
486
+ }
487
+ if (inputBudget <= OVERLAP_TOKENS) {
488
+ throw new Error(
489
+ `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.`
490
+ );
491
+ }
492
+ return {
493
+ chunking: { chunkSize: inputBudget, overlap: OVERLAP_TOKENS },
494
+ outputBudget
495
+ };
496
+ }
461
497
  function languageName(tag) {
462
498
  return getLocaleEnglishName(tag) || tag;
463
499
  }
@@ -477,7 +513,9 @@ var MotivationPrompts = class {
477
513
  /**
478
514
  * Build a prompt for detecting comment-worthy passages
479
515
  *
480
- * @param content - The text content to analyze (will be truncated to 8000 chars)
516
+ * @param content - The text content to analyze — a chunk sized by the
517
+ * caller from derived provider limits; NEVER re-truncated here (a
518
+ * builder-level clip is silent input loss — see #738)
481
519
  * @param instructions - Optional user-provided instructions
482
520
  * @param tone - Optional tone guidance (e.g., "academic", "conversational")
483
521
  * @param density - Optional target number of comments per 2000 words
@@ -498,7 +536,7 @@ ${instructions}${toneGuidance}${densityGuidance}${sourceLang}${bodyLang}
498
536
 
499
537
  Text to analyze:
500
538
  ---
501
- ${content.substring(0, 8e3)}
539
+ ${content}
502
540
  ---
503
541
 
504
542
  Return a JSON array of comments. Each comment must have:
@@ -532,7 +570,7 @@ Guidelines:
532
570
 
533
571
  Text to analyze:
534
572
  ---
535
- ${content.substring(0, 8e3)}
573
+ ${content}
536
574
  ---
537
575
 
538
576
  Return a JSON array of comments. Each comment should have:
@@ -553,7 +591,9 @@ Example format:
553
591
  /**
554
592
  * Build a prompt for detecting highlight-worthy passages
555
593
  *
556
- * @param content - The text content to analyze (will be truncated to 8000 chars)
594
+ * @param content - The text content to analyze — a chunk sized by the
595
+ * caller from derived provider limits; NEVER re-truncated here (a
596
+ * builder-level clip is silent input loss — see #738)
557
597
  * @param instructions - Optional user-provided instructions
558
598
  * @param density - Optional target number of highlights per 2000 words
559
599
  * @returns Formatted prompt string
@@ -571,7 +611,7 @@ ${instructions}${densityGuidance}${sourceLang}
571
611
 
572
612
  Text to analyze:
573
613
  ---
574
- ${content.substring(0, 8e3)}
614
+ ${content}
575
615
  ---
576
616
 
577
617
  Return a JSON array of highlights. Each highlight must have:
@@ -602,7 +642,7 @@ Guidelines:
602
642
 
603
643
  Text to analyze:
604
644
  ---
605
- ${content.substring(0, 8e3)}
645
+ ${content}
606
646
  ---
607
647
 
608
648
  Return a JSON array of highlights. Each highlight should have:
@@ -622,7 +662,9 @@ Example format:
622
662
  /**
623
663
  * Build a prompt for detecting assessment-worthy passages
624
664
  *
625
- * @param content - The text content to analyze (will be truncated to 8000 chars)
665
+ * @param content - The text content to analyze — a chunk sized by the
666
+ * caller from derived provider limits; NEVER re-truncated here (a
667
+ * builder-level clip is silent input loss — see #738)
626
668
  * @param instructions - Optional user-provided instructions
627
669
  * @param tone - Optional tone guidance (e.g., "critical", "supportive")
628
670
  * @param density - Optional target number of assessments per 2000 words
@@ -643,7 +685,7 @@ ${instructions}${toneGuidance}${densityGuidance}${sourceLang}${bodyLang}
643
685
 
644
686
  Text to analyze:
645
687
  ---
646
- ${content.substring(0, 8e3)}
688
+ ${content}
647
689
  ---
648
690
 
649
691
  Return a JSON array of assessments. Each assessment must have:
@@ -677,7 +719,7 @@ Guidelines:
677
719
 
678
720
  Text to analyze:
679
721
  ---
680
- ${content.substring(0, 8e3)}
722
+ ${content}
681
723
  ---
682
724
 
683
725
  Return a JSON array of assessments. Each assessment should have:
@@ -923,10 +965,32 @@ function logAnchorMethod(motivation, exact, anchorMethod) {
923
965
  }
924
966
 
925
967
  // src/workers/annotation-detection.ts
926
- function assertNotTruncated(response, motivation) {
968
+ function assertNotTruncated(response, motivation, chunk, totalChunks, outputBudget) {
927
969
  if (response.stopReason === "max_tokens") {
928
- throw new Error(`${motivation} detection response truncated (max_tokens) \u2014 increase max_tokens or reduce resource size; failing the job rather than under-reporting annotations.`);
970
+ 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.`);
971
+ }
972
+ }
973
+ async function detectInChunks(client, content, buildPrompt, temperature, motivation, parse, onChunk) {
974
+ const limits = await client.limits();
975
+ const scaffoldTokens = estimateTokens(buildPrompt(""));
976
+ const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens);
977
+ const chunks = chunkText(content, chunking);
978
+ const collected = [];
979
+ for (let i = 0; i < chunks.length; i++) {
980
+ const response = await boundedGenerateWithMetadata(
981
+ client,
982
+ buildPrompt(chunks[i]),
983
+ outputBudget,
984
+ temperature,
985
+ { format: "json" }
986
+ );
987
+ assertNotTruncated(response, motivation, i + 1, chunks.length, outputBudget);
988
+ collected.push(...parse(response.text));
989
+ if (i < chunks.length - 1) {
990
+ onChunk?.(i + 1, chunks.length);
991
+ }
929
992
  }
993
+ return collected;
930
994
  }
931
995
  var AnnotationDetection = class {
932
996
  /**
@@ -937,11 +1001,16 @@ var AnnotationDetection = class {
937
1001
  * (source-resource locale). See `types.ts` "Locale conventions" for the
938
1002
  * full discussion.
939
1003
  */
940
- static async detectComments(content, client, instructions, tone, density, language, sourceLanguage) {
941
- const prompt = MotivationPrompts.buildCommentPrompt(content, instructions, tone, density, language, sourceLanguage);
942
- const response = await boundedGenerateWithMetadata(client, prompt, 3e3, 0.4, { format: "json" });
943
- assertNotTruncated(response, "comment");
944
- return MotivationParsers.parseComments(response.text, content);
1004
+ static async detectComments(content, client, instructions, tone, density, language, sourceLanguage, onChunk) {
1005
+ return detectInChunks(
1006
+ client,
1007
+ content,
1008
+ (chunk) => MotivationPrompts.buildCommentPrompt(chunk, instructions, tone, density, language, sourceLanguage),
1009
+ 0.4,
1010
+ "comment",
1011
+ (text) => MotivationParsers.parseComments(text, content),
1012
+ onChunk
1013
+ );
945
1014
  }
946
1015
  /**
947
1016
  * Detect highlights in content.
@@ -950,11 +1019,16 @@ var AnnotationDetection = class {
950
1019
  * applies, used in the prompt so the LLM analyzes non-English source
951
1020
  * correctly.
952
1021
  */
953
- static async detectHighlights(content, client, instructions, density, sourceLanguage) {
954
- const prompt = MotivationPrompts.buildHighlightPrompt(content, instructions, density, sourceLanguage);
955
- const response = await boundedGenerateWithMetadata(client, prompt, 2e3, 0.3, { format: "json" });
956
- assertNotTruncated(response, "highlight");
957
- return MotivationParsers.parseHighlights(response.text, content);
1022
+ static async detectHighlights(content, client, instructions, density, sourceLanguage, onChunk) {
1023
+ return detectInChunks(
1024
+ client,
1025
+ content,
1026
+ (chunk) => MotivationPrompts.buildHighlightPrompt(chunk, instructions, density, sourceLanguage),
1027
+ 0.3,
1028
+ "highlight",
1029
+ (text) => MotivationParsers.parseHighlights(text, content),
1030
+ onChunk
1031
+ );
958
1032
  }
959
1033
  /**
960
1034
  * Detect assessments in content.
@@ -963,11 +1037,16 @@ var AnnotationDetection = class {
963
1037
  * (annotation body locale). `sourceLanguage` is the locale of the content
964
1038
  * being analyzed (source-resource locale).
965
1039
  */
966
- static async detectAssessments(content, client, instructions, tone, density, language, sourceLanguage) {
967
- const prompt = MotivationPrompts.buildAssessmentPrompt(content, instructions, tone, density, language, sourceLanguage);
968
- const response = await boundedGenerateWithMetadata(client, prompt, 3e3, 0.3, { format: "json" });
969
- assertNotTruncated(response, "assessment");
970
- return MotivationParsers.parseAssessments(response.text, content);
1040
+ static async detectAssessments(content, client, instructions, tone, density, language, sourceLanguage, onChunk) {
1041
+ return detectInChunks(
1042
+ client,
1043
+ content,
1044
+ (chunk) => MotivationPrompts.buildAssessmentPrompt(chunk, instructions, tone, density, language, sourceLanguage),
1045
+ 0.3,
1046
+ "assessment",
1047
+ (text) => MotivationParsers.parseAssessments(text, content),
1048
+ onChunk
1049
+ );
971
1050
  }
972
1051
  /**
973
1052
  * Detect tags in content for a specific category.
@@ -981,28 +1060,33 @@ var AnnotationDetection = class {
981
1060
  * identifiers, not LLM-generated text — so it's consumed at the body-stamp
982
1061
  * site, not here.
983
1062
  */
984
- static async detectTags(content, client, schema, category, sourceLanguage) {
1063
+ static async detectTags(content, client, schema, category, sourceLanguage, onChunk) {
985
1064
  const categoryInfo = schema.tags.find((t) => t.name === category);
986
1065
  if (!categoryInfo) {
987
1066
  throw new Error(`Invalid category "${category}" for schema ${schema.id}`);
988
1067
  }
989
- const prompt = MotivationPrompts.buildTagPrompt(
1068
+ const parsedTags = await detectInChunks(
1069
+ client,
990
1070
  content,
991
- category,
992
- schema.name,
993
- schema.description,
994
- schema.domain,
995
- categoryInfo.description,
996
- categoryInfo.examples,
997
- sourceLanguage
1071
+ (chunk) => MotivationPrompts.buildTagPrompt(
1072
+ chunk,
1073
+ category,
1074
+ schema.name,
1075
+ schema.description,
1076
+ schema.domain,
1077
+ categoryInfo.description,
1078
+ categoryInfo.examples,
1079
+ sourceLanguage
1080
+ ),
1081
+ 0.2,
1082
+ "tag",
1083
+ (text) => MotivationParsers.parseTags(text),
1084
+ onChunk
998
1085
  );
999
- const response = await boundedGenerateWithMetadata(client, prompt, 4e3, 0.2, { format: "json" });
1000
- assertNotTruncated(response, "tag");
1001
- const parsedTags = MotivationParsers.parseTags(response.text);
1002
1086
  return MotivationParsers.validateTagOffsets(parsedTags, content, category);
1003
1087
  }
1004
1088
  };
1005
- async function extractEntities(exact, entityTypes, client, includeDescriptiveReferences, logger, sourceLanguage) {
1089
+ async function extractEntities(exact, entityTypes, client, includeDescriptiveReferences, logger, sourceLanguage, onChunk) {
1006
1090
  const entityTypesDescription = entityTypes.map((et) => {
1007
1091
  if (typeof et === "string") {
1008
1092
  return et;
@@ -1034,11 +1118,11 @@ Find direct mentions only (names, proper nouns). Do not include pronouns or desc
1034
1118
  const sourceLangGuidance = sourceLanguage ? `
1035
1119
  Source text language: ${getLocaleEnglishName(sourceLanguage) || sourceLanguage}.
1036
1120
  ` : "";
1037
- const prompt = `Identify entity references in the following text. Look for mentions of: ${entityTypesDescription}.
1121
+ const buildPrompt = (text) => `Identify entity references in the following text. Look for mentions of: ${entityTypesDescription}.
1038
1122
  ${descriptiveReferenceGuidance}${sourceLangGuidance}
1039
1123
  Text to analyze:
1040
1124
  """
1041
- ${exact}
1125
+ ${text}
1042
1126
  """
1043
1127
 
1044
1128
  Respond with a JSON array of entities found. Each entity should have:
@@ -1051,59 +1135,78 @@ If no entities are found, respond with an empty array [].
1051
1135
 
1052
1136
  Example output:
1053
1137
  [{"exact":"Alice","entityType":"Person","prefix":"","suffix":" went to"},{"exact":"Paris","entityType":"Location","prefix":"went to ","suffix":" yesterday"}]`;
1054
- logger.debug("Sending entity extraction request", { entityTypes: entityTypesDescription });
1055
- const response = await boundedGenerateWithMetadata(
1056
- client,
1057
- prompt,
1058
- 4e3,
1059
- // Increased to handle many entities without truncation
1060
- 0.3,
1061
- // Lower temperature for more consistent extraction
1062
- // Force grammar-constrained JSON output. Without this, Ollama models
1063
- // periodically emit malformed JSON (truncated brackets, mid-token
1064
- // breaks at higher token counts) which silently parse-fails into
1065
- // [] downstream. The prompt's schema (which keys, what types) still
1066
- // governs *what* the JSON contains; `format: 'json'` governs that
1067
- // it's syntactically valid.
1068
- { format: "json" }
1069
- );
1070
- logger.debug("Got entity extraction response", { responseLength: response.text.length });
1071
- if (response.stopReason === "max_tokens") {
1072
- const errorMsg = "Entity extraction response truncated (max_tokens) \u2014 increase max_tokens or reduce resource size; failing the job rather than dropping annotations.";
1073
- logger.error(errorMsg, { responseLength: response.text.length });
1074
- throw new Error(errorMsg);
1075
- }
1076
- let entities;
1077
- try {
1078
- entities = JSON.parse(response.text.trim());
1079
- } catch (error) {
1080
- logger.error("Failed to parse entity extraction response", {
1081
- error: error instanceof Error ? error.message : String(error),
1082
- response: response.text.slice(0, 500)
1083
- });
1084
- throw new Error("Failed to parse entity extraction response", {
1085
- cause: error instanceof Error ? error : new Error(String(error))
1086
- });
1087
- }
1088
- if (!isArray(entities)) {
1089
- logger.error("Failed to parse entity extraction response: expected a JSON array", {
1090
- response: response.text.slice(0, 500)
1138
+ const limits = await client.limits();
1139
+ const scaffoldTokens = estimateTokens(buildPrompt(""));
1140
+ const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens);
1141
+ const chunks = chunkText(exact, chunking);
1142
+ logger.debug("Sending entity extraction request", {
1143
+ entityTypes: entityTypesDescription,
1144
+ chunks: chunks.length,
1145
+ chunkSizeTokens: chunking.chunkSize,
1146
+ outputBudget
1147
+ });
1148
+ const collected = [];
1149
+ for (let i = 0; i < chunks.length; i++) {
1150
+ const response = await boundedGenerateWithMetadata(
1151
+ client,
1152
+ buildPrompt(chunks[i]),
1153
+ outputBudget,
1154
+ 0.3,
1155
+ // Lower temperature for more consistent extraction
1156
+ // Force grammar-constrained JSON output. Without this, Ollama models
1157
+ // periodically emit malformed JSON (truncated brackets, mid-token
1158
+ // breaks at higher token counts) which silently parse-fails into
1159
+ // [] downstream. The prompt's schema (which keys, what types) still
1160
+ // governs *what* the JSON contains; `format: 'json'` governs that
1161
+ // it's syntactically valid.
1162
+ { format: "json" }
1163
+ );
1164
+ logger.debug("Got entity extraction response", {
1165
+ chunk: i + 1,
1166
+ chunks: chunks.length,
1167
+ responseLength: response.text.length
1091
1168
  });
1092
- throw new Error("Failed to parse entity extraction response: expected a JSON array");
1093
- }
1094
- logger.debug("Parsed entities from AI response", { count: entities.length });
1095
- return entities.filter((e) => {
1096
- const ok = isObject(e) && isString(e.exact) && isString(e.entityType);
1097
- if (!ok) {
1098
- logger.debug("Dropped malformed LLM entity", { entity: e });
1169
+ if (response.stopReason === "max_tokens") {
1170
+ const errorMsg = `Entity extraction response truncated (max_tokens) on chunk ${i + 1}/${chunks.length} despite the derived output budget of ${outputBudget} tokens \u2014 failing the job rather than dropping annotations.`;
1171
+ logger.error(errorMsg, { responseLength: response.text.length });
1172
+ throw new Error(errorMsg);
1099
1173
  }
1100
- return ok;
1101
- }).map((entity) => ({
1102
- exact: entity.exact,
1103
- entityType: entity.entityType,
1104
- ...isString(entity.prefix) ? { prefix: entity.prefix } : {},
1105
- ...isString(entity.suffix) ? { suffix: entity.suffix } : {}
1106
- }));
1174
+ let entities;
1175
+ try {
1176
+ entities = JSON.parse(response.text.trim());
1177
+ } catch (error) {
1178
+ logger.error("Failed to parse entity extraction response", {
1179
+ error: error instanceof Error ? error.message : String(error),
1180
+ response: response.text.slice(0, 500)
1181
+ });
1182
+ throw new Error("Failed to parse entity extraction response", {
1183
+ cause: error instanceof Error ? error : new Error(String(error))
1184
+ });
1185
+ }
1186
+ if (!isArray(entities)) {
1187
+ logger.error("Failed to parse entity extraction response: expected a JSON array", {
1188
+ response: response.text.slice(0, 500)
1189
+ });
1190
+ throw new Error("Failed to parse entity extraction response: expected a JSON array");
1191
+ }
1192
+ logger.debug("Parsed entities from AI response", { chunk: i + 1, count: entities.length });
1193
+ for (const e of entities) {
1194
+ if (isObject(e) && isString(e.exact) && isString(e.entityType)) {
1195
+ collected.push({
1196
+ exact: e.exact,
1197
+ entityType: e.entityType,
1198
+ ...isString(e.prefix) ? { prefix: e.prefix } : {},
1199
+ ...isString(e.suffix) ? { suffix: e.suffix } : {}
1200
+ });
1201
+ } else {
1202
+ logger.debug("Dropped malformed LLM entity", { entity: e });
1203
+ }
1204
+ }
1205
+ if (i < chunks.length - 1) {
1206
+ onChunk?.(i + 1, chunks.length);
1207
+ }
1208
+ }
1209
+ return collected;
1107
1210
  }
1108
1211
  function getLanguageName(locale) {
1109
1212
  return getLocaleEnglishName(locale) || locale;
@@ -1114,7 +1217,7 @@ var SEMANTIC_MATCH_CHARS = 240;
1114
1217
  function idLabel(resourceId, annotationId) {
1115
1218
  return `[${resourceId}${annotationId ? `/${annotationId}` : ""}]`;
1116
1219
  }
1117
- async function generateResourceFromTopic(topic, entityTypes, client, logger, userPrompt, locale, context, temperature, maxTokens, sourceLanguage, outputMediaType = "text/markdown", task = "resource", structure, cite = false) {
1220
+ async function generateResourceFromTopic(topic, entityTypes, client, logger, userPrompt, locale, context, temperature, maxTokens, sourceLanguage, outputMediaType = "text/markdown", task = "resource", structure, cite = false, repair) {
1118
1221
  logger.debug("Generating resource from topic", {
1119
1222
  topicPreview: topic.substring(0, 100),
1120
1223
  entityTypes,
@@ -1230,11 +1333,12 @@ ${parts.join("\n")}`;
1230
1333
  let semanticContextSection = "";
1231
1334
  const similar = context?.semanticContext?.similar ?? [];
1232
1335
  if (similar.length > 0) {
1233
- const lines = [...similar].sort((a, b) => b.score - a.score).slice(0, SEMANTIC_MATCH_LIMIT).map((m) => `- ${idLabel(m.resourceId, m.annotationId)} (${m.score.toFixed(2)}) ${m.text.slice(0, SEMANTIC_MATCH_CHARS)}`);
1336
+ const lines = [...similar].sort((a, b) => b.score - a.score).slice(0, SEMANTIC_MATCH_LIMIT).map((m) => `- ${idLabel(m.resourceId, m.annotationId)} (${m.score.toFixed(2)})${m.machineRead ? " [OCR]" : ""} ${m.text.slice(0, SEMANTIC_MATCH_CHARS)}`);
1337
+ const ocrNote = similar.some((m) => m.machineRead) ? "\nPassages marked [OCR] were read from scanned images by character recognition; treat their exact wording and numbers as uncertain, and say so if you rely on one." : "";
1234
1338
  semanticContextSection = `
1235
1339
 
1236
1340
  Related passages from the knowledge base:
1237
- ${lines.join("\n")}`;
1341
+ ${lines.join("\n")}${ocrNote}`;
1238
1342
  }
1239
1343
  let leadLine;
1240
1344
  if (task === "resource") {
@@ -1249,11 +1353,12 @@ ${lines.join("\n")}`;
1249
1353
  Topic: "${topic}"`;
1250
1354
  }
1251
1355
  const isPlainText = outputMediaType === "text/plain";
1356
+ const isPdf = outputMediaType === "application/pdf";
1252
1357
  let structureRequirement = "";
1253
1358
  let titleRequirement = "";
1254
1359
  if (structure === "sections") {
1255
- structureRequirement = isPlainText ? "\n- Organize the content into titled sections with well-structured paragraphs" : "\n- Organize the content into titled sections (## Section) with well-structured paragraphs";
1256
- if (!isPlainText) {
1360
+ structureRequirement = isPdf ? "\n- Organize the content into titled sections (= Heading) with well-structured paragraphs" : isPlainText ? "\n- Organize the content into titled sections with well-structured paragraphs" : "\n- Organize the content into titled sections (## Section) with well-structured paragraphs";
1361
+ if (!isPlainText && !isPdf) {
1257
1362
  titleRequirement = "\n- Start with a clear heading (# Title)";
1258
1363
  }
1259
1364
  } else if (structure === "prose") {
@@ -1266,11 +1371,20 @@ Topic: "${topic}"`;
1266
1371
  - Organize the output as: ${structure}`;
1267
1372
  }
1268
1373
  const citeRequirement = cite ? "\n- Ground every claim in the provided context. Immediately after each claim, cite its source by emitting [[<id>]], where <id> is an id shown in square brackets in the context above (for a passage labeled [abc], emit [[abc]]). Cite only ids that appear in the context." : "";
1269
- const formatRequirements = isPlainText ? `- Write the response as plain text \u2014 no formatting markup (no #, *, backticks, headings, or links)
1374
+ const formatRequirements = isPdf ? `- Write the response as Typst markup (the Typst typesetting language \u2014 not markdown, not LaTeX)
1375
+ - Headings are written as = Heading (deeper levels == Subheading); everything else is plain prose paragraphs
1376
+ - Do not emit markdown syntax or code fences` : isPlainText ? `- Write the response as plain text \u2014 no formatting markup (no #, *, backticks, headings, or links)
1270
1377
  - Begin with the title on its own first line` : `- Use markdown formatting
1271
1378
  - Write the response as markdown`;
1379
+ const repairSection = repair ? `
1380
+
1381
+ Your previous attempt failed to compile. Fix the error and return the complete corrected document \u2014 full source, not a diff.
1382
+ Compile error:
1383
+ ${repair.error}
1384
+ Previous source:
1385
+ ${repair.source}` : "";
1272
1386
  const prompt = `${leadLine}
1273
- ${userPrompt ? `Instruction: ${userPrompt}` : ""}
1387
+ ${userPrompt ? `Instruction: ${userPrompt}` : ""}${repairSection}
1274
1388
  ${entityTypes.length > 0 ? `Focus on these entity types: ${entityTypes.join(", ")}.` : ""}${annotationSection}${contextSection}${resourceSection}${graphSection}${semanticContextSection}${sourceLanguageInstruction}${languageInstruction}
1275
1389
 
1276
1390
  Requirements:
@@ -1279,7 +1393,7 @@ Requirements:
1279
1393
  ${formatRequirements}`;
1280
1394
  const parseResponse = (response2) => {
1281
1395
  let content = response2.trim();
1282
- if (content.startsWith("```markdown") || content.startsWith("```md")) {
1396
+ if (content.startsWith("```markdown") || content.startsWith("```md") || content.startsWith("```typst")) {
1283
1397
  content = content.slice(content.indexOf("\n") + 1);
1284
1398
  const endIndex = content.lastIndexOf("```");
1285
1399
  if (endIndex !== -1) {
@@ -1314,6 +1428,29 @@ ${formatRequirements}`;
1314
1428
  });
1315
1429
  return result;
1316
1430
  }
1431
+ var PINNED_CREATION_TIMESTAMP = 17e8;
1432
+ var MAX_COMPILE_REPAIRS = 2;
1433
+ function compileTypst(source) {
1434
+ const dir = mkdtempSync(join(tmpdir(), "typst-"));
1435
+ try {
1436
+ const inFile = join(dir, "doc.typ");
1437
+ const outFile = join(dir, "doc.pdf");
1438
+ writeFileSync(inFile, source);
1439
+ try {
1440
+ execFileSync(
1441
+ "typst",
1442
+ ["compile", "--creation-timestamp", String(PINNED_CREATION_TIMESTAMP), inFile, outFile],
1443
+ { stdio: ["ignore", "pipe", "pipe"] }
1444
+ );
1445
+ } catch (err) {
1446
+ const stderr = err.stderr;
1447
+ return { error: stderr?.length ? stderr.toString("utf8") : String(err) };
1448
+ }
1449
+ return { pdf: new Uint8Array(readFileSync(outFile)) };
1450
+ } finally {
1451
+ rmSync(dir, { recursive: true, force: true });
1452
+ }
1453
+ }
1317
1454
 
1318
1455
  // src/workers/generation/citation-resolver.ts
1319
1456
  var CITATION_TOKEN = /\[\[([^\s[\]/]+)(?:\/([^\s[\]/]+))?\]\]/g;
@@ -1417,7 +1554,9 @@ async function processHighlightJob(content, inferenceClient, params, buildAnnota
1417
1554
  inferenceClient,
1418
1555
  params.instructions,
1419
1556
  params.density,
1420
- params.sourceLanguage
1557
+ params.sourceLanguage,
1558
+ // Chunk-boundary heartbeat (liveness): interpolate within the 30–60 band.
1559
+ (completed, total) => onProgress(30 + Math.round(completed / total * 30), "Analyzing text...", "analyzing")
1421
1560
  );
1422
1561
  onProgress(60, `Creating ${highlights.length} annotations...`, "creating");
1423
1562
  const annotations = dedupeAnnotations(highlights.map(
@@ -1439,7 +1578,9 @@ async function processCommentJob(content, inferenceClient, params, buildAnnotati
1439
1578
  params.tone,
1440
1579
  params.density,
1441
1580
  params.language,
1442
- params.sourceLanguage
1581
+ params.sourceLanguage,
1582
+ // Chunk-boundary heartbeat (liveness): interpolate within the 30–60 band.
1583
+ (completed, total) => onProgress(30 + Math.round(completed / total * 30), "Analyzing text...", "analyzing")
1443
1584
  );
1444
1585
  onProgress(60, `Creating ${comments.length} annotations...`, "creating");
1445
1586
  const bodyLanguage = params.language ?? "en";
@@ -1469,7 +1610,9 @@ async function processAssessmentJob(content, inferenceClient, params, buildAnnot
1469
1610
  params.tone,
1470
1611
  params.density,
1471
1612
  params.language,
1472
- params.sourceLanguage
1613
+ params.sourceLanguage,
1614
+ // Chunk-boundary heartbeat (liveness): interpolate within the 30–60 band.
1615
+ (completed, total) => onProgress(30 + Math.round(completed / total * 30), "Analyzing text...", "analyzing")
1473
1616
  );
1474
1617
  onProgress(60, `Creating ${assessments.length} annotations...`, "creating");
1475
1618
  const bodyLanguage = params.language ?? "en";
@@ -1525,7 +1668,23 @@ async function processReferenceJob(content, inferenceClient, params, buildAnnota
1525
1668
  inferenceClient,
1526
1669
  params.includeDescriptiveReferences ?? false,
1527
1670
  logger,
1528
- params.sourceLanguage
1671
+ params.sourceLanguage,
1672
+ // Chunk-boundary heartbeat: progress is the worker's liveness signal
1673
+ // (stall watchdog + backend janitor), so multi-chunk extraction must
1674
+ // emit between inference calls. Percentage interpolates within this
1675
+ // entity type's band of the 20–80 range.
1676
+ (completed, total) => {
1677
+ const interpolated = 20 + Math.round((i + completed / total) / entityTypeNames.length * 60);
1678
+ onProgress(interpolated, `Detecting ${entityTypeName} entities...`, "analyzing", {
1679
+ currentEntityType: entityTypeName,
1680
+ processedEntityTypes: i,
1681
+ totalEntityTypes: entityTypeNames.length,
1682
+ entitiesFound: totalFound,
1683
+ entitiesEmitted: totalEmitted,
1684
+ completedEntityTypes: [...completedEntityTypes],
1685
+ requestParams
1686
+ });
1687
+ }
1529
1688
  );
1530
1689
  totalFound += extractedEntities.length;
1531
1690
  completedEntityTypes.push({ entityType: entityTypeName, foundCount: extractedEntities.length });
@@ -1569,13 +1728,21 @@ async function processTagJob(content, inferenceClient, params, buildAnnotation,
1569
1728
  onProgress(10, "Loading resource...", "analyzing");
1570
1729
  onProgress(30, "Analyzing text for tags...", "analyzing");
1571
1730
  const allTags = [];
1572
- for (const category of params.categories) {
1731
+ for (let c = 0; c < params.categories.length; c++) {
1732
+ const category = params.categories[c];
1573
1733
  const categoryTags = await AnnotationDetection.detectTags(
1574
1734
  content,
1575
1735
  inferenceClient,
1576
1736
  params.schema,
1577
1737
  category,
1578
- params.sourceLanguage
1738
+ params.sourceLanguage,
1739
+ // Chunk-boundary heartbeat (liveness): interpolate within this
1740
+ // category's slice of the 30–60 band.
1741
+ (completed, total) => onProgress(
1742
+ 30 + Math.round((c + completed / total) / params.categories.length * 30),
1743
+ "Analyzing text for tags...",
1744
+ "analyzing"
1745
+ )
1579
1746
  );
1580
1747
  allTags.push(...categoryTags);
1581
1748
  }
@@ -1601,8 +1768,14 @@ async function processTagJob(content, inferenceClient, params, buildAnnotation,
1601
1768
  result: { tagsFound: tags.length, tagsCreated: annotations.length, byCategory }
1602
1769
  };
1603
1770
  }
1771
+ function assertWithinOutputBudget(byteLength) {
1772
+ if (!withinByteBudget(byteLength)) {
1773
+ throw new Error(
1774
+ `Generated artifact exceeds the output byte budget: ${byteLength} bytes > ${MAX_PDF_BYTES}. Refusing a runaway generation.`
1775
+ );
1776
+ }
1777
+ }
1604
1778
  async function processGenerationJob(inferenceClient, params, onProgress, logger) {
1605
- const GENERATABLE_MEDIA_TYPES = ["text/markdown", "text/plain"];
1606
1779
  const outputMediaType = params.outputMediaType ?? "text/markdown";
1607
1780
  if (!GENERATABLE_MEDIA_TYPES.includes(outputMediaType)) {
1608
1781
  throw new Error(
@@ -1611,6 +1784,84 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger)
1611
1784
  }
1612
1785
  const title = params.title ?? "Untitled";
1613
1786
  const entityTypes = (params.entityTypes ?? []).map(String);
1787
+ if (outputMediaType === "application/pdf") {
1788
+ onProgress(5, "Generating resource...", "generating");
1789
+ const validIds = params.cite === true ? collectContextResourceIds(params.context) : null;
1790
+ let generated2 = await generateResourceFromTopic(
1791
+ title,
1792
+ entityTypes,
1793
+ inferenceClient,
1794
+ logger,
1795
+ params.prompt,
1796
+ params.language,
1797
+ params.context,
1798
+ params.temperature,
1799
+ params.maxTokens,
1800
+ params.sourceLanguage,
1801
+ outputMediaType,
1802
+ params.task,
1803
+ params.structure,
1804
+ params.cite
1805
+ );
1806
+ let source = generated2.content;
1807
+ let citations2 = [];
1808
+ if (validIds) {
1809
+ const resolved = resolveCitationTokens(generated2.content, validIds, logger);
1810
+ source = resolved.content;
1811
+ citations2 = resolved.citations;
1812
+ }
1813
+ let compiled = compileTypst(source);
1814
+ let repairs = 0;
1815
+ while ("error" in compiled && repairs < MAX_COMPILE_REPAIRS) {
1816
+ repairs++;
1817
+ logger.warn("Typst compile failed \u2014 feeding the error back for repair", {
1818
+ attempt: repairs,
1819
+ error: compiled.error.slice(0, 500)
1820
+ });
1821
+ generated2 = await generateResourceFromTopic(
1822
+ title,
1823
+ entityTypes,
1824
+ inferenceClient,
1825
+ logger,
1826
+ params.prompt,
1827
+ params.language,
1828
+ params.context,
1829
+ params.temperature,
1830
+ params.maxTokens,
1831
+ params.sourceLanguage,
1832
+ outputMediaType,
1833
+ params.task,
1834
+ params.structure,
1835
+ params.cite,
1836
+ { source, error: compiled.error }
1837
+ );
1838
+ if (validIds) {
1839
+ const resolved = resolveCitationTokens(generated2.content, validIds, logger);
1840
+ source = resolved.content;
1841
+ citations2 = resolved.citations;
1842
+ } else {
1843
+ source = generated2.content;
1844
+ }
1845
+ compiled = compileTypst(source);
1846
+ }
1847
+ if ("error" in compiled) {
1848
+ throw new Error(
1849
+ `Typst compilation failed after ${MAX_COMPILE_REPAIRS} repair attempts: ${compiled.error}`
1850
+ );
1851
+ }
1852
+ assertWithinOutputBudget(compiled.pdf.byteLength);
1853
+ onProgress(95, "Creating resource...", "creating");
1854
+ return {
1855
+ content: compiled.pdf,
1856
+ title: generated2.title ?? title,
1857
+ format: outputMediaType,
1858
+ citations: citations2,
1859
+ result: {
1860
+ resourceId: "",
1861
+ resourceName: generated2.title ?? title
1862
+ }
1863
+ };
1864
+ }
1614
1865
  onProgress(5, "Generating resource...", "generating");
1615
1866
  const generated = await generateResourceFromTopic(
1616
1867
  title,
@@ -1636,8 +1887,10 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger)
1636
1887
  citations = resolved.citations;
1637
1888
  }
1638
1889
  onProgress(95, "Creating resource...", "creating");
1890
+ const artifact = new TextEncoder().encode(content);
1891
+ assertWithinOutputBudget(artifact.byteLength);
1639
1892
  return {
1640
- content,
1893
+ content: artifact,
1641
1894
  title: generated.title ?? title,
1642
1895
  format: outputMediaType,
1643
1896
  citations,