@semiont/jobs 0.5.24 → 0.5.26

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 } 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';
@@ -446,18 +449,51 @@ async function withTimeout(work, label) {
446
449
  clearTimeout(timer);
447
450
  }
448
451
  }
449
- function boundedGenerate(client, prompt, maxTokens, temperature, options) {
452
+ function boundedGenerate(client, prompt, maxTokens, temperature) {
450
453
  return withTimeout(
451
- client.generateText(prompt, maxTokens, temperature, options),
454
+ client.generateText(prompt, maxTokens, temperature),
452
455
  `${client.type}:${client.modelId}`
453
456
  );
454
457
  }
455
- function boundedGenerateWithMetadata(client, prompt, maxTokens, temperature, options) {
458
+ function boundedGenerateStructured(client, prompt, maxTokens, temperature, elementSchema) {
456
459
  return withTimeout(
457
- client.generateTextWithMetadata(prompt, maxTokens, temperature, options),
460
+ client.generateStructured(prompt, maxTokens, temperature, elementSchema),
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:
@@ -749,32 +791,57 @@ Example format:
749
791
  return prompt;
750
792
  }
751
793
  };
752
- function parseJsonArray(response, motivation) {
753
- let parsed;
754
- try {
755
- parsed = JSON.parse(response.trim());
756
- } catch (error) {
757
- console.error(`[MotivationParsers] Failed to parse AI ${motivation} response:`, error);
758
- console.error("Raw response:", response);
759
- throw error instanceof Error ? error : new Error(String(error));
760
- }
761
- if (!Array.isArray(parsed)) {
762
- console.error(`[MotivationParsers] Expected a JSON array for ${motivation} detection, got ${typeof parsed}:`, response);
763
- throw new Error(`Expected a JSON array for ${motivation} detection, got ${typeof parsed}`);
764
- }
765
- return parsed;
766
- }
794
+ var COMMENT_ELEMENT_SCHEMA = {
795
+ type: "object",
796
+ properties: {
797
+ exact: { type: "string" },
798
+ prefix: { type: "string" },
799
+ suffix: { type: "string" },
800
+ comment: { type: "string" }
801
+ },
802
+ required: ["exact", "comment"],
803
+ additionalProperties: false
804
+ };
805
+ var HIGHLIGHT_ELEMENT_SCHEMA = {
806
+ type: "object",
807
+ properties: {
808
+ exact: { type: "string" },
809
+ prefix: { type: "string" },
810
+ suffix: { type: "string" }
811
+ },
812
+ required: ["exact"],
813
+ additionalProperties: false
814
+ };
815
+ var ASSESSMENT_ELEMENT_SCHEMA = {
816
+ type: "object",
817
+ properties: {
818
+ exact: { type: "string" },
819
+ prefix: { type: "string" },
820
+ suffix: { type: "string" },
821
+ assessment: { type: "string" }
822
+ },
823
+ required: ["exact", "assessment"],
824
+ additionalProperties: false
825
+ };
826
+ var TAG_ELEMENT_SCHEMA = {
827
+ type: "object",
828
+ properties: {
829
+ exact: { type: "string" },
830
+ prefix: { type: "string" },
831
+ suffix: { type: "string" }
832
+ },
833
+ required: ["exact"],
834
+ additionalProperties: false
835
+ };
767
836
  var MotivationParsers = class {
768
837
  /**
769
- * Parse and validate AI response for comment detection
838
+ * Validate and reconcile structured comment elements.
770
839
  *
771
- * @param response - Raw AI response text (a JSON array)
840
+ * @param parsed - Already-parsed elements from the structured surface
772
841
  * @param content - Original content to validate offsets against
773
842
  * @returns Array of validated comment matches
774
- * @throws if the response is not a parseable JSON array
775
843
  */
776
- static parseComments(response, content) {
777
- const parsed = parseJsonArray(response, "comment");
844
+ static parseComments(parsed, content) {
778
845
  const valid = parsed.filter(
779
846
  (c) => isObject(c) && isString(c.exact) && isString(c.comment) && c.comment.trim().length > 0
780
847
  );
@@ -803,15 +870,13 @@ var MotivationParsers = class {
803
870
  return validatedComments;
804
871
  }
805
872
  /**
806
- * Parse and validate AI response for highlight detection
873
+ * Validate and reconcile structured highlight elements.
807
874
  *
808
- * @param response - Raw AI response text (a JSON array)
875
+ * @param parsed - Already-parsed elements from the structured surface
809
876
  * @param content - Original content to validate offsets against
810
877
  * @returns Array of validated highlight matches
811
- * @throws if the response is not a parseable JSON array
812
878
  */
813
- static parseHighlights(response, content) {
814
- const parsed = parseJsonArray(response, "highlight");
879
+ static parseHighlights(parsed, content) {
815
880
  const highlights = parsed.filter(
816
881
  (h) => isObject(h) && isString(h.exact)
817
882
  );
@@ -838,15 +903,13 @@ var MotivationParsers = class {
838
903
  return validatedHighlights;
839
904
  }
840
905
  /**
841
- * Parse and validate AI response for assessment detection
906
+ * Validate and reconcile structured assessment elements.
842
907
  *
843
- * @param response - Raw AI response text (a JSON array)
908
+ * @param parsed - Already-parsed elements from the structured surface
844
909
  * @param content - Original content to validate offsets against
845
910
  * @returns Array of validated assessment matches
846
- * @throws if the response is not a parseable JSON array
847
911
  */
848
- static parseAssessments(response, content) {
849
- const parsed = parseJsonArray(response, "assessment");
912
+ static parseAssessments(parsed, content) {
850
913
  const assessments = parsed.filter(
851
914
  (a) => isObject(a) && isString(a.exact) && isString(a.assessment)
852
915
  );
@@ -874,14 +937,13 @@ var MotivationParsers = class {
874
937
  return validatedAssessments;
875
938
  }
876
939
  /**
877
- * Parse the LLM's tag response into raw, pre-reconciliation tag inputs.
940
+ * Validate structured tag elements into raw, pre-reconciliation tag inputs.
878
941
  * Reconciliation happens in `validateTagOffsets`, which adds `start`/`end`
879
942
  * by anchoring `exact` against the source content.
880
943
  *
881
- * @throws if the response is not a parseable JSON array
944
+ * @param parsed - Already-parsed elements from the structured surface
882
945
  */
883
- static parseTags(response) {
884
- const parsed = parseJsonArray(response, "tag");
946
+ static parseTags(parsed) {
885
947
  const valid = parsed.filter(
886
948
  (t) => isObject(t) && isString(t.exact) && t.exact.trim().length > 0
887
949
  );
@@ -923,10 +985,32 @@ function logAnchorMethod(motivation, exact, anchorMethod) {
923
985
  }
924
986
 
925
987
  // src/workers/annotation-detection.ts
926
- function assertNotTruncated(response, motivation) {
988
+ function assertNotTruncated(response, motivation, chunk, totalChunks, outputBudget) {
927
989
  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.`);
990
+ 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
+ }
992
+ }
993
+ async function detectInChunks(client, content, buildPrompt, temperature, motivation, elementSchema, parse, onChunk) {
994
+ const limits = await client.limits();
995
+ const scaffoldTokens = estimateTokens(buildPrompt(""));
996
+ const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens);
997
+ const chunks = chunkText(content, chunking);
998
+ const collected = [];
999
+ for (let i = 0; i < chunks.length; i++) {
1000
+ const response = await boundedGenerateStructured(
1001
+ client,
1002
+ buildPrompt(chunks[i]),
1003
+ outputBudget,
1004
+ temperature,
1005
+ elementSchema
1006
+ );
1007
+ assertNotTruncated(response, motivation, i + 1, chunks.length, outputBudget);
1008
+ collected.push(...parse(response.items));
1009
+ if (i < chunks.length - 1) {
1010
+ onChunk?.(i + 1, chunks.length);
1011
+ }
929
1012
  }
1013
+ return collected;
930
1014
  }
931
1015
  var AnnotationDetection = class {
932
1016
  /**
@@ -937,11 +1021,17 @@ var AnnotationDetection = class {
937
1021
  * (source-resource locale). See `types.ts` "Locale conventions" for the
938
1022
  * full discussion.
939
1023
  */
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);
1024
+ static async detectComments(content, client, instructions, tone, density, language, sourceLanguage, onChunk) {
1025
+ return detectInChunks(
1026
+ client,
1027
+ content,
1028
+ (chunk) => MotivationPrompts.buildCommentPrompt(chunk, instructions, tone, density, language, sourceLanguage),
1029
+ 0.4,
1030
+ "comment",
1031
+ COMMENT_ELEMENT_SCHEMA,
1032
+ (items) => MotivationParsers.parseComments(items, content),
1033
+ onChunk
1034
+ );
945
1035
  }
946
1036
  /**
947
1037
  * Detect highlights in content.
@@ -950,11 +1040,17 @@ var AnnotationDetection = class {
950
1040
  * applies, used in the prompt so the LLM analyzes non-English source
951
1041
  * correctly.
952
1042
  */
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);
1043
+ static async detectHighlights(content, client, instructions, density, sourceLanguage, onChunk) {
1044
+ return detectInChunks(
1045
+ client,
1046
+ content,
1047
+ (chunk) => MotivationPrompts.buildHighlightPrompt(chunk, instructions, density, sourceLanguage),
1048
+ 0.3,
1049
+ "highlight",
1050
+ HIGHLIGHT_ELEMENT_SCHEMA,
1051
+ (items) => MotivationParsers.parseHighlights(items, content),
1052
+ onChunk
1053
+ );
958
1054
  }
959
1055
  /**
960
1056
  * Detect assessments in content.
@@ -963,11 +1059,17 @@ var AnnotationDetection = class {
963
1059
  * (annotation body locale). `sourceLanguage` is the locale of the content
964
1060
  * being analyzed (source-resource locale).
965
1061
  */
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);
1062
+ static async detectAssessments(content, client, instructions, tone, density, language, sourceLanguage, onChunk) {
1063
+ return detectInChunks(
1064
+ client,
1065
+ content,
1066
+ (chunk) => MotivationPrompts.buildAssessmentPrompt(chunk, instructions, tone, density, language, sourceLanguage),
1067
+ 0.3,
1068
+ "assessment",
1069
+ ASSESSMENT_ELEMENT_SCHEMA,
1070
+ (items) => MotivationParsers.parseAssessments(items, content),
1071
+ onChunk
1072
+ );
971
1073
  }
972
1074
  /**
973
1075
  * Detect tags in content for a specific category.
@@ -981,28 +1083,45 @@ var AnnotationDetection = class {
981
1083
  * identifiers, not LLM-generated text — so it's consumed at the body-stamp
982
1084
  * site, not here.
983
1085
  */
984
- static async detectTags(content, client, schema, category, sourceLanguage) {
1086
+ static async detectTags(content, client, schema, category, sourceLanguage, onChunk) {
985
1087
  const categoryInfo = schema.tags.find((t) => t.name === category);
986
1088
  if (!categoryInfo) {
987
1089
  throw new Error(`Invalid category "${category}" for schema ${schema.id}`);
988
1090
  }
989
- const prompt = MotivationPrompts.buildTagPrompt(
1091
+ const parsedTags = await detectInChunks(
1092
+ client,
990
1093
  content,
991
- category,
992
- schema.name,
993
- schema.description,
994
- schema.domain,
995
- categoryInfo.description,
996
- categoryInfo.examples,
997
- sourceLanguage
1094
+ (chunk) => MotivationPrompts.buildTagPrompt(
1095
+ chunk,
1096
+ category,
1097
+ schema.name,
1098
+ schema.description,
1099
+ schema.domain,
1100
+ categoryInfo.description,
1101
+ categoryInfo.examples,
1102
+ sourceLanguage
1103
+ ),
1104
+ 0.2,
1105
+ "tag",
1106
+ TAG_ELEMENT_SCHEMA,
1107
+ (items) => MotivationParsers.parseTags(items),
1108
+ onChunk
998
1109
  );
999
- const response = await boundedGenerateWithMetadata(client, prompt, 4e3, 0.2, { format: "json" });
1000
- assertNotTruncated(response, "tag");
1001
- const parsedTags = MotivationParsers.parseTags(response.text);
1002
1110
  return MotivationParsers.validateTagOffsets(parsedTags, content, category);
1003
1111
  }
1004
1112
  };
1005
- async function extractEntities(exact, entityTypes, client, includeDescriptiveReferences, logger, sourceLanguage) {
1113
+ var ENTITY_ELEMENT_SCHEMA = {
1114
+ type: "object",
1115
+ properties: {
1116
+ exact: { type: "string" },
1117
+ entityType: { type: "string" },
1118
+ prefix: { type: "string" },
1119
+ suffix: { type: "string" }
1120
+ },
1121
+ required: ["exact", "entityType"],
1122
+ additionalProperties: false
1123
+ };
1124
+ async function extractEntities(exact, entityTypes, client, includeDescriptiveReferences, logger, sourceLanguage, onChunk) {
1006
1125
  const entityTypesDescription = entityTypes.map((et) => {
1007
1126
  if (typeof et === "string") {
1008
1127
  return et;
@@ -1034,11 +1153,11 @@ Find direct mentions only (names, proper nouns). Do not include pronouns or desc
1034
1153
  const sourceLangGuidance = sourceLanguage ? `
1035
1154
  Source text language: ${getLocaleEnglishName(sourceLanguage) || sourceLanguage}.
1036
1155
  ` : "";
1037
- const prompt = `Identify entity references in the following text. Look for mentions of: ${entityTypesDescription}.
1156
+ const buildPrompt = (text) => `Identify entity references in the following text. Look for mentions of: ${entityTypesDescription}.
1038
1157
  ${descriptiveReferenceGuidance}${sourceLangGuidance}
1039
1158
  Text to analyze:
1040
1159
  """
1041
- ${exact}
1160
+ ${text}
1042
1161
  """
1043
1162
 
1044
1163
  Respond with a JSON array of entities found. Each entity should have:
@@ -1051,59 +1170,53 @@ If no entities are found, respond with an empty array [].
1051
1170
 
1052
1171
  Example output:
1053
1172
  [{"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)
1173
+ const limits = await client.limits();
1174
+ const scaffoldTokens = estimateTokens(buildPrompt(""));
1175
+ const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens);
1176
+ const chunks = chunkText(exact, chunking);
1177
+ logger.debug("Sending entity extraction request", {
1178
+ entityTypes: entityTypesDescription,
1179
+ chunks: chunks.length,
1180
+ chunkSizeTokens: chunking.chunkSize,
1181
+ outputBudget
1182
+ });
1183
+ const collected = [];
1184
+ for (let i = 0; i < chunks.length; i++) {
1185
+ const response = await boundedGenerateStructured(
1186
+ client,
1187
+ buildPrompt(chunks[i]),
1188
+ outputBudget,
1189
+ 0.3,
1190
+ // Lower temperature for more consistent extraction
1191
+ ENTITY_ELEMENT_SCHEMA
1192
+ );
1193
+ logger.debug("Got entity extraction response", {
1194
+ chunk: i + 1,
1195
+ chunks: chunks.length,
1196
+ items: response.items.length
1091
1197
  });
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 });
1198
+ if (response.stopReason === "max_tokens") {
1199
+ 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.`;
1200
+ logger.error(errorMsg, { items: response.items.length });
1201
+ throw new Error(errorMsg);
1099
1202
  }
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
- }));
1203
+ for (const e of response.items) {
1204
+ if (isObject(e) && isString(e.exact) && isString(e.entityType)) {
1205
+ collected.push({
1206
+ exact: e.exact,
1207
+ entityType: e.entityType,
1208
+ ...isString(e.prefix) ? { prefix: e.prefix } : {},
1209
+ ...isString(e.suffix) ? { suffix: e.suffix } : {}
1210
+ });
1211
+ } else {
1212
+ logger.debug("Dropped malformed LLM entity", { entity: e });
1213
+ }
1214
+ }
1215
+ if (i < chunks.length - 1) {
1216
+ onChunk?.(i + 1, chunks.length);
1217
+ }
1218
+ }
1219
+ return collected;
1107
1220
  }
1108
1221
  function getLanguageName(locale) {
1109
1222
  return getLocaleEnglishName(locale) || locale;
@@ -1114,7 +1227,7 @@ var SEMANTIC_MATCH_CHARS = 240;
1114
1227
  function idLabel(resourceId, annotationId) {
1115
1228
  return `[${resourceId}${annotationId ? `/${annotationId}` : ""}]`;
1116
1229
  }
1117
- async function generateResourceFromTopic(topic, entityTypes, client, logger, userPrompt, locale, context, temperature, maxTokens, sourceLanguage, outputMediaType = "text/markdown", task = "resource", structure, cite = false) {
1230
+ async function generateResourceFromTopic(topic, entityTypes, client, logger, userPrompt, locale, context, temperature, maxTokens, sourceLanguage, outputMediaType = "text/markdown", task = "resource", structure, cite = false, repair) {
1118
1231
  logger.debug("Generating resource from topic", {
1119
1232
  topicPreview: topic.substring(0, 100),
1120
1233
  entityTypes,
@@ -1230,11 +1343,12 @@ ${parts.join("\n")}`;
1230
1343
  let semanticContextSection = "";
1231
1344
  const similar = context?.semanticContext?.similar ?? [];
1232
1345
  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)}`);
1346
+ 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)}`);
1347
+ 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
1348
  semanticContextSection = `
1235
1349
 
1236
1350
  Related passages from the knowledge base:
1237
- ${lines.join("\n")}`;
1351
+ ${lines.join("\n")}${ocrNote}`;
1238
1352
  }
1239
1353
  let leadLine;
1240
1354
  if (task === "resource") {
@@ -1249,11 +1363,12 @@ ${lines.join("\n")}`;
1249
1363
  Topic: "${topic}"`;
1250
1364
  }
1251
1365
  const isPlainText = outputMediaType === "text/plain";
1366
+ const isPdf = outputMediaType === "application/pdf";
1252
1367
  let structureRequirement = "";
1253
1368
  let titleRequirement = "";
1254
1369
  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) {
1370
+ 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";
1371
+ if (!isPlainText && !isPdf) {
1257
1372
  titleRequirement = "\n- Start with a clear heading (# Title)";
1258
1373
  }
1259
1374
  } else if (structure === "prose") {
@@ -1266,11 +1381,20 @@ Topic: "${topic}"`;
1266
1381
  - Organize the output as: ${structure}`;
1267
1382
  }
1268
1383
  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)
1384
+ const formatRequirements = isPdf ? `- Write the response as Typst markup (the Typst typesetting language \u2014 not markdown, not LaTeX)
1385
+ - Headings are written as = Heading (deeper levels == Subheading); everything else is plain prose paragraphs
1386
+ - 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
1387
  - Begin with the title on its own first line` : `- Use markdown formatting
1271
1388
  - Write the response as markdown`;
1389
+ const repairSection = repair ? `
1390
+
1391
+ Your previous attempt failed to compile. Fix the error and return the complete corrected document \u2014 full source, not a diff.
1392
+ Compile error:
1393
+ ${repair.error}
1394
+ Previous source:
1395
+ ${repair.source}` : "";
1272
1396
  const prompt = `${leadLine}
1273
- ${userPrompt ? `Instruction: ${userPrompt}` : ""}
1397
+ ${userPrompt ? `Instruction: ${userPrompt}` : ""}${repairSection}
1274
1398
  ${entityTypes.length > 0 ? `Focus on these entity types: ${entityTypes.join(", ")}.` : ""}${annotationSection}${contextSection}${resourceSection}${graphSection}${semanticContextSection}${sourceLanguageInstruction}${languageInstruction}
1275
1399
 
1276
1400
  Requirements:
@@ -1279,7 +1403,7 @@ Requirements:
1279
1403
  ${formatRequirements}`;
1280
1404
  const parseResponse = (response2) => {
1281
1405
  let content = response2.trim();
1282
- if (content.startsWith("```markdown") || content.startsWith("```md")) {
1406
+ if (content.startsWith("```markdown") || content.startsWith("```md") || content.startsWith("```typst")) {
1283
1407
  content = content.slice(content.indexOf("\n") + 1);
1284
1408
  const endIndex = content.lastIndexOf("```");
1285
1409
  if (endIndex !== -1) {
@@ -1314,6 +1438,29 @@ ${formatRequirements}`;
1314
1438
  });
1315
1439
  return result;
1316
1440
  }
1441
+ var PINNED_CREATION_TIMESTAMP = 17e8;
1442
+ var MAX_COMPILE_REPAIRS = 2;
1443
+ function compileTypst(source) {
1444
+ const dir = mkdtempSync(join(tmpdir(), "typst-"));
1445
+ try {
1446
+ const inFile = join(dir, "doc.typ");
1447
+ const outFile = join(dir, "doc.pdf");
1448
+ writeFileSync(inFile, source);
1449
+ try {
1450
+ execFileSync(
1451
+ "typst",
1452
+ ["compile", "--creation-timestamp", String(PINNED_CREATION_TIMESTAMP), inFile, outFile],
1453
+ { stdio: ["ignore", "pipe", "pipe"] }
1454
+ );
1455
+ } catch (err) {
1456
+ const stderr = err.stderr;
1457
+ return { error: stderr?.length ? stderr.toString("utf8") : String(err) };
1458
+ }
1459
+ return { pdf: new Uint8Array(readFileSync(outFile)) };
1460
+ } finally {
1461
+ rmSync(dir, { recursive: true, force: true });
1462
+ }
1463
+ }
1317
1464
 
1318
1465
  // src/workers/generation/citation-resolver.ts
1319
1466
  var CITATION_TOKEN = /\[\[([^\s[\]/]+)(?:\/([^\s[\]/]+))?\]\]/g;
@@ -1417,7 +1564,9 @@ async function processHighlightJob(content, inferenceClient, params, buildAnnota
1417
1564
  inferenceClient,
1418
1565
  params.instructions,
1419
1566
  params.density,
1420
- params.sourceLanguage
1567
+ 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")
1421
1570
  );
1422
1571
  onProgress(60, `Creating ${highlights.length} annotations...`, "creating");
1423
1572
  const annotations = dedupeAnnotations(highlights.map(
@@ -1439,7 +1588,9 @@ async function processCommentJob(content, inferenceClient, params, buildAnnotati
1439
1588
  params.tone,
1440
1589
  params.density,
1441
1590
  params.language,
1442
- params.sourceLanguage
1591
+ 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")
1443
1594
  );
1444
1595
  onProgress(60, `Creating ${comments.length} annotations...`, "creating");
1445
1596
  const bodyLanguage = params.language ?? "en";
@@ -1469,7 +1620,9 @@ async function processAssessmentJob(content, inferenceClient, params, buildAnnot
1469
1620
  params.tone,
1470
1621
  params.density,
1471
1622
  params.language,
1472
- params.sourceLanguage
1623
+ 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")
1473
1626
  );
1474
1627
  onProgress(60, `Creating ${assessments.length} annotations...`, "creating");
1475
1628
  const bodyLanguage = params.language ?? "en";
@@ -1525,7 +1678,23 @@ async function processReferenceJob(content, inferenceClient, params, buildAnnota
1525
1678
  inferenceClient,
1526
1679
  params.includeDescriptiveReferences ?? false,
1527
1680
  logger,
1528
- params.sourceLanguage
1681
+ 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.
1686
+ (completed, total) => {
1687
+ 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,
1692
+ entitiesFound: totalFound,
1693
+ entitiesEmitted: totalEmitted,
1694
+ completedEntityTypes: [...completedEntityTypes],
1695
+ requestParams
1696
+ });
1697
+ }
1529
1698
  );
1530
1699
  totalFound += extractedEntities.length;
1531
1700
  completedEntityTypes.push({ entityType: entityTypeName, foundCount: extractedEntities.length });
@@ -1569,13 +1738,21 @@ async function processTagJob(content, inferenceClient, params, buildAnnotation,
1569
1738
  onProgress(10, "Loading resource...", "analyzing");
1570
1739
  onProgress(30, "Analyzing text for tags...", "analyzing");
1571
1740
  const allTags = [];
1572
- for (const category of params.categories) {
1741
+ for (let c = 0; c < params.categories.length; c++) {
1742
+ const category = params.categories[c];
1573
1743
  const categoryTags = await AnnotationDetection.detectTags(
1574
1744
  content,
1575
1745
  inferenceClient,
1576
1746
  params.schema,
1577
1747
  category,
1578
- params.sourceLanguage
1748
+ params.sourceLanguage,
1749
+ // Chunk-boundary heartbeat (liveness): interpolate within this
1750
+ // category's slice of the 30–60 band.
1751
+ (completed, total) => onProgress(
1752
+ 30 + Math.round((c + completed / total) / params.categories.length * 30),
1753
+ "Analyzing text for tags...",
1754
+ "analyzing"
1755
+ )
1579
1756
  );
1580
1757
  allTags.push(...categoryTags);
1581
1758
  }
@@ -1601,8 +1778,14 @@ async function processTagJob(content, inferenceClient, params, buildAnnotation,
1601
1778
  result: { tagsFound: tags.length, tagsCreated: annotations.length, byCategory }
1602
1779
  };
1603
1780
  }
1781
+ function assertWithinOutputBudget(byteLength) {
1782
+ if (!withinByteBudget(byteLength)) {
1783
+ throw new Error(
1784
+ `Generated artifact exceeds the output byte budget: ${byteLength} bytes > ${MAX_PDF_BYTES}. Refusing a runaway generation.`
1785
+ );
1786
+ }
1787
+ }
1604
1788
  async function processGenerationJob(inferenceClient, params, onProgress, logger) {
1605
- const GENERATABLE_MEDIA_TYPES = ["text/markdown", "text/plain"];
1606
1789
  const outputMediaType = params.outputMediaType ?? "text/markdown";
1607
1790
  if (!GENERATABLE_MEDIA_TYPES.includes(outputMediaType)) {
1608
1791
  throw new Error(
@@ -1611,6 +1794,84 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger)
1611
1794
  }
1612
1795
  const title = params.title ?? "Untitled";
1613
1796
  const entityTypes = (params.entityTypes ?? []).map(String);
1797
+ if (outputMediaType === "application/pdf") {
1798
+ onProgress(5, "Generating resource...", "generating");
1799
+ const validIds = params.cite === true ? collectContextResourceIds(params.context) : null;
1800
+ let generated2 = await generateResourceFromTopic(
1801
+ title,
1802
+ entityTypes,
1803
+ inferenceClient,
1804
+ logger,
1805
+ params.prompt,
1806
+ params.language,
1807
+ params.context,
1808
+ params.temperature,
1809
+ params.maxTokens,
1810
+ params.sourceLanguage,
1811
+ outputMediaType,
1812
+ params.task,
1813
+ params.structure,
1814
+ params.cite
1815
+ );
1816
+ let source = generated2.content;
1817
+ let citations2 = [];
1818
+ if (validIds) {
1819
+ const resolved = resolveCitationTokens(generated2.content, validIds, logger);
1820
+ source = resolved.content;
1821
+ citations2 = resolved.citations;
1822
+ }
1823
+ let compiled = compileTypst(source);
1824
+ let repairs = 0;
1825
+ while ("error" in compiled && repairs < MAX_COMPILE_REPAIRS) {
1826
+ repairs++;
1827
+ logger.warn("Typst compile failed \u2014 feeding the error back for repair", {
1828
+ attempt: repairs,
1829
+ error: compiled.error.slice(0, 500)
1830
+ });
1831
+ generated2 = await generateResourceFromTopic(
1832
+ title,
1833
+ entityTypes,
1834
+ inferenceClient,
1835
+ logger,
1836
+ params.prompt,
1837
+ params.language,
1838
+ params.context,
1839
+ params.temperature,
1840
+ params.maxTokens,
1841
+ params.sourceLanguage,
1842
+ outputMediaType,
1843
+ params.task,
1844
+ params.structure,
1845
+ params.cite,
1846
+ { source, error: compiled.error }
1847
+ );
1848
+ if (validIds) {
1849
+ const resolved = resolveCitationTokens(generated2.content, validIds, logger);
1850
+ source = resolved.content;
1851
+ citations2 = resolved.citations;
1852
+ } else {
1853
+ source = generated2.content;
1854
+ }
1855
+ compiled = compileTypst(source);
1856
+ }
1857
+ if ("error" in compiled) {
1858
+ throw new Error(
1859
+ `Typst compilation failed after ${MAX_COMPILE_REPAIRS} repair attempts: ${compiled.error}`
1860
+ );
1861
+ }
1862
+ assertWithinOutputBudget(compiled.pdf.byteLength);
1863
+ onProgress(95, "Creating resource...", "creating");
1864
+ return {
1865
+ content: compiled.pdf,
1866
+ title: generated2.title ?? title,
1867
+ format: outputMediaType,
1868
+ citations: citations2,
1869
+ result: {
1870
+ resourceId: "",
1871
+ resourceName: generated2.title ?? title
1872
+ }
1873
+ };
1874
+ }
1614
1875
  onProgress(5, "Generating resource...", "generating");
1615
1876
  const generated = await generateResourceFromTopic(
1616
1877
  title,
@@ -1636,8 +1897,10 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger)
1636
1897
  citations = resolved.citations;
1637
1898
  }
1638
1899
  onProgress(95, "Creating resource...", "creating");
1900
+ const artifact = new TextEncoder().encode(content);
1901
+ assertWithinOutputBudget(artifact.byteLength);
1639
1902
  return {
1640
- content,
1903
+ content: artifact,
1641
1904
  title: generated.title ?? title,
1642
1905
  format: outputMediaType,
1643
1906
  citations,