@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.d.ts +15 -20
- package/dist/index.js +364 -111
- package/dist/index.js.map +1 -1
- package/dist/worker-main.js +523 -196
- package/dist/worker-main.js.map +1 -1
- package/package.json +10 -9
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
|
1068
|
+
const parsedTags = await detectInChunks(
|
|
1069
|
+
client,
|
|
990
1070
|
content,
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
|
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
|
-
${
|
|
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
|
-
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
logger.
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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 =
|
|
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 (
|
|
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,
|