@semiont/jobs 0.5.8 → 0.5.10

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,6 +1,6 @@
1
1
  import { promises } from 'fs';
2
2
  import * as path from 'path';
3
- import { jobId, reconcileSelector, getLocaleEnglishName, didToAgent } from '@semiont/core';
3
+ import { jobId, deriveViews, reconcileSelector, isObject, isString, getLocaleEnglishName, didToAgent, isArray } from '@semiont/core';
4
4
  import { generateAnnotationId } from '@semiont/event-sourcing';
5
5
 
6
6
  // src/fs-job-queue.ts
@@ -710,197 +710,144 @@ Example format:
710
710
  return prompt;
711
711
  }
712
712
  };
713
- function extractObjectsFromArray(response) {
714
- let cleaned = response.trim();
715
- if (cleaned.startsWith("```")) {
716
- cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
717
- }
713
+ function parseJsonArray(response, motivation) {
714
+ let parsed;
718
715
  try {
719
- const parsed = JSON.parse(cleaned);
720
- return Array.isArray(parsed) ? parsed : [];
721
- } catch {
716
+ parsed = JSON.parse(response.trim());
717
+ } catch (error) {
718
+ console.error(`[MotivationParsers] Failed to parse AI ${motivation} response:`, error);
719
+ console.error("Raw response:", response);
720
+ throw error instanceof Error ? error : new Error(String(error));
722
721
  }
723
- const start = cleaned.indexOf("[");
724
- if (start === -1) return [];
725
- const endBracket = cleaned.lastIndexOf("]");
726
- const end = endBracket > start ? endBracket : cleaned.length;
727
- const inner = cleaned.slice(start + 1, end);
728
- const objects = [];
729
- let depth = 0;
730
- let objStart = -1;
731
- let inString = false;
732
- let escape = false;
733
- for (let i = 0; i < inner.length; i++) {
734
- const ch = inner[i];
735
- if (escape) {
736
- escape = false;
737
- continue;
738
- }
739
- if (ch === "\\") {
740
- escape = true;
741
- continue;
742
- }
743
- if (ch === '"') {
744
- inString = !inString;
745
- continue;
746
- }
747
- if (inString) continue;
748
- if (ch === "{") {
749
- if (depth === 0) objStart = i;
750
- depth++;
751
- } else if (ch === "}") {
752
- depth--;
753
- if (depth === 0 && objStart !== -1) {
754
- try {
755
- objects.push(JSON.parse(inner.slice(objStart, i + 1)));
756
- } catch {
757
- }
758
- objStart = -1;
759
- }
760
- }
722
+ if (!Array.isArray(parsed)) {
723
+ console.error(`[MotivationParsers] Expected a JSON array for ${motivation} detection, got ${typeof parsed}:`, response);
724
+ throw new Error(`Expected a JSON array for ${motivation} detection, got ${typeof parsed}`);
761
725
  }
762
- return objects;
726
+ return parsed;
763
727
  }
764
728
  var MotivationParsers = class {
765
729
  /**
766
730
  * Parse and validate AI response for comment detection
767
731
  *
768
- * @param response - Raw AI response string (may include markdown code fences)
732
+ * @param response - Raw AI response text (a JSON array)
769
733
  * @param content - Original content to validate offsets against
770
734
  * @returns Array of validated comment matches
735
+ * @throws if the response is not a parseable JSON array
771
736
  */
772
737
  static parseComments(response, content) {
773
- try {
774
- const parsed = extractObjectsFromArray(response);
775
- const valid = parsed.filter(
776
- (c) => !!c && typeof c === "object" && typeof c.exact === "string" && typeof c.comment === "string" && c.comment.trim().length > 0
777
- );
778
- console.log(`[MotivationParsers] Parsed ${valid.length} valid comments from ${parsed.length} total`);
779
- const validatedComments = [];
780
- for (const comment of valid) {
781
- const reconciled = reconcileSelector(content, {
782
- exact: comment.exact,
783
- ...typeof comment.prefix === "string" ? { prefix: comment.prefix } : {},
784
- ...typeof comment.suffix === "string" ? { suffix: comment.suffix } : {}
785
- });
786
- if (!reconciled) {
787
- console.warn(`[MotivationParsers] Dropped hallucinated comment "${comment.exact}"`);
788
- continue;
789
- }
790
- logAnchorMethod("comment", comment.exact, reconciled.anchorMethod);
791
- validatedComments.push({
792
- comment: comment.comment,
793
- exact: reconciled.exact,
794
- start: reconciled.start,
795
- end: reconciled.end,
796
- ...reconciled.prefix !== void 0 ? { prefix: reconciled.prefix } : {},
797
- ...reconciled.suffix !== void 0 ? { suffix: reconciled.suffix } : {}
798
- });
738
+ const parsed = parseJsonArray(response, "comment");
739
+ const valid = parsed.filter(
740
+ (c) => isObject(c) && isString(c.exact) && isString(c.comment) && c.comment.trim().length > 0
741
+ );
742
+ console.log(`[MotivationParsers] Parsed ${valid.length} valid comments from ${parsed.length} total`);
743
+ const validatedComments = [];
744
+ for (const comment of valid) {
745
+ const reconciled = reconcileSelector(content, {
746
+ exact: comment.exact,
747
+ ...typeof comment.prefix === "string" ? { prefix: comment.prefix } : {},
748
+ ...typeof comment.suffix === "string" ? { suffix: comment.suffix } : {}
749
+ });
750
+ if (!reconciled) {
751
+ console.warn(`[MotivationParsers] Dropped hallucinated comment "${comment.exact}"`);
752
+ continue;
799
753
  }
800
- return validatedComments;
801
- } catch (error) {
802
- console.error("[MotivationParsers] Failed to parse AI comment response:", error);
803
- return [];
754
+ logAnchorMethod("comment", comment.exact, reconciled.anchorMethod);
755
+ validatedComments.push({
756
+ comment: comment.comment,
757
+ exact: reconciled.exact,
758
+ start: reconciled.start,
759
+ end: reconciled.end,
760
+ ...reconciled.prefix !== void 0 ? { prefix: reconciled.prefix } : {},
761
+ ...reconciled.suffix !== void 0 ? { suffix: reconciled.suffix } : {}
762
+ });
804
763
  }
764
+ return validatedComments;
805
765
  }
806
766
  /**
807
767
  * Parse and validate AI response for highlight detection
808
768
  *
809
- * @param response - Raw AI response string (may include markdown code fences)
769
+ * @param response - Raw AI response text (a JSON array)
810
770
  * @param content - Original content to validate offsets against
811
771
  * @returns Array of validated highlight matches
772
+ * @throws if the response is not a parseable JSON array
812
773
  */
813
774
  static parseHighlights(response, content) {
814
- try {
815
- const parsed = extractObjectsFromArray(response);
816
- const highlights = parsed.filter(
817
- (h) => !!h && typeof h === "object" && typeof h.exact === "string"
818
- );
819
- const validatedHighlights = [];
820
- for (const highlight of highlights) {
821
- const reconciled = reconcileSelector(content, {
822
- exact: highlight.exact,
823
- ...typeof highlight.prefix === "string" ? { prefix: highlight.prefix } : {},
824
- ...typeof highlight.suffix === "string" ? { suffix: highlight.suffix } : {}
825
- });
826
- if (!reconciled) {
827
- console.warn(`[MotivationParsers] Dropped hallucinated highlight "${highlight.exact}"`);
828
- continue;
829
- }
830
- logAnchorMethod("highlight", highlight.exact, reconciled.anchorMethod);
831
- validatedHighlights.push({
832
- exact: reconciled.exact,
833
- start: reconciled.start,
834
- end: reconciled.end,
835
- ...reconciled.prefix !== void 0 ? { prefix: reconciled.prefix } : {},
836
- ...reconciled.suffix !== void 0 ? { suffix: reconciled.suffix } : {}
837
- });
775
+ const parsed = parseJsonArray(response, "highlight");
776
+ const highlights = parsed.filter(
777
+ (h) => isObject(h) && isString(h.exact)
778
+ );
779
+ const validatedHighlights = [];
780
+ for (const highlight of highlights) {
781
+ const reconciled = reconcileSelector(content, {
782
+ exact: highlight.exact,
783
+ ...typeof highlight.prefix === "string" ? { prefix: highlight.prefix } : {},
784
+ ...typeof highlight.suffix === "string" ? { suffix: highlight.suffix } : {}
785
+ });
786
+ if (!reconciled) {
787
+ console.warn(`[MotivationParsers] Dropped hallucinated highlight "${highlight.exact}"`);
788
+ continue;
838
789
  }
839
- return validatedHighlights;
840
- } catch (error) {
841
- console.error("[MotivationParsers] Failed to parse AI highlight response:", error);
842
- console.error("Raw response:", response);
843
- return [];
790
+ logAnchorMethod("highlight", highlight.exact, reconciled.anchorMethod);
791
+ validatedHighlights.push({
792
+ exact: reconciled.exact,
793
+ start: reconciled.start,
794
+ end: reconciled.end,
795
+ ...reconciled.prefix !== void 0 ? { prefix: reconciled.prefix } : {},
796
+ ...reconciled.suffix !== void 0 ? { suffix: reconciled.suffix } : {}
797
+ });
844
798
  }
799
+ return validatedHighlights;
845
800
  }
846
801
  /**
847
802
  * Parse and validate AI response for assessment detection
848
803
  *
849
- * @param response - Raw AI response string (may include markdown code fences)
804
+ * @param response - Raw AI response text (a JSON array)
850
805
  * @param content - Original content to validate offsets against
851
806
  * @returns Array of validated assessment matches
807
+ * @throws if the response is not a parseable JSON array
852
808
  */
853
809
  static parseAssessments(response, content) {
854
- try {
855
- const parsed = extractObjectsFromArray(response);
856
- const assessments = parsed.filter(
857
- (a) => !!a && typeof a === "object" && typeof a.exact === "string" && typeof a.assessment === "string"
858
- );
859
- const validatedAssessments = [];
860
- for (const assessment of assessments) {
861
- const reconciled = reconcileSelector(content, {
862
- exact: assessment.exact,
863
- ...typeof assessment.prefix === "string" ? { prefix: assessment.prefix } : {},
864
- ...typeof assessment.suffix === "string" ? { suffix: assessment.suffix } : {}
865
- });
866
- if (!reconciled) {
867
- console.warn(`[MotivationParsers] Dropped hallucinated assessment "${assessment.exact}"`);
868
- continue;
869
- }
870
- logAnchorMethod("assessment", assessment.exact, reconciled.anchorMethod);
871
- validatedAssessments.push({
872
- assessment: assessment.assessment,
873
- exact: reconciled.exact,
874
- start: reconciled.start,
875
- end: reconciled.end,
876
- ...reconciled.prefix !== void 0 ? { prefix: reconciled.prefix } : {},
877
- ...reconciled.suffix !== void 0 ? { suffix: reconciled.suffix } : {}
878
- });
810
+ const parsed = parseJsonArray(response, "assessment");
811
+ const assessments = parsed.filter(
812
+ (a) => isObject(a) && isString(a.exact) && isString(a.assessment)
813
+ );
814
+ const validatedAssessments = [];
815
+ for (const assessment of assessments) {
816
+ const reconciled = reconcileSelector(content, {
817
+ exact: assessment.exact,
818
+ ...typeof assessment.prefix === "string" ? { prefix: assessment.prefix } : {},
819
+ ...typeof assessment.suffix === "string" ? { suffix: assessment.suffix } : {}
820
+ });
821
+ if (!reconciled) {
822
+ console.warn(`[MotivationParsers] Dropped hallucinated assessment "${assessment.exact}"`);
823
+ continue;
879
824
  }
880
- return validatedAssessments;
881
- } catch (error) {
882
- console.error("[MotivationParsers] Failed to parse AI assessment response:", error);
883
- console.error("Raw response:", response);
884
- return [];
825
+ logAnchorMethod("assessment", assessment.exact, reconciled.anchorMethod);
826
+ validatedAssessments.push({
827
+ assessment: assessment.assessment,
828
+ exact: reconciled.exact,
829
+ start: reconciled.start,
830
+ end: reconciled.end,
831
+ ...reconciled.prefix !== void 0 ? { prefix: reconciled.prefix } : {},
832
+ ...reconciled.suffix !== void 0 ? { suffix: reconciled.suffix } : {}
833
+ });
885
834
  }
835
+ return validatedAssessments;
886
836
  }
887
837
  /**
888
838
  * Parse the LLM's tag response into raw, pre-reconciliation tag inputs.
889
839
  * Reconciliation happens in `validateTagOffsets`, which adds `start`/`end`
890
840
  * by anchoring `exact` against the source content.
841
+ *
842
+ * @throws if the response is not a parseable JSON array
891
843
  */
892
844
  static parseTags(response) {
893
- try {
894
- const parsed = extractObjectsFromArray(response);
895
- const valid = parsed.filter(
896
- (t) => !!t && typeof t === "object" && typeof t.exact === "string" && t.exact.trim().length > 0
897
- );
898
- console.log(`[MotivationParsers] Parsed ${valid.length} valid tags from ${parsed.length} total`);
899
- return valid;
900
- } catch (error) {
901
- console.error("[MotivationParsers] Failed to parse AI tag response:", error);
902
- return [];
903
- }
845
+ const parsed = parseJsonArray(response, "tag");
846
+ const valid = parsed.filter(
847
+ (t) => isObject(t) && isString(t.exact) && t.exact.trim().length > 0
848
+ );
849
+ console.log(`[MotivationParsers] Parsed ${valid.length} valid tags from ${parsed.length} total`);
850
+ return valid;
904
851
  }
905
852
  /**
906
853
  * Anchor raw tag inputs against source content and add category.
@@ -937,6 +884,11 @@ function logAnchorMethod(motivation, exact, anchorMethod) {
937
884
  }
938
885
 
939
886
  // src/workers/annotation-detection.ts
887
+ function assertNotTruncated(response, motivation) {
888
+ if (response.stopReason === "max_tokens") {
889
+ 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.`);
890
+ }
891
+ }
940
892
  var AnnotationDetection = class {
941
893
  /**
942
894
  * Detect comments in content.
@@ -948,8 +900,9 @@ var AnnotationDetection = class {
948
900
  */
949
901
  static async detectComments(content, client, instructions, tone, density, language, sourceLanguage) {
950
902
  const prompt = MotivationPrompts.buildCommentPrompt(content, instructions, tone, density, language, sourceLanguage);
951
- const response = await client.generateText(prompt, 3e3, 0.4, { format: "json" });
952
- return MotivationParsers.parseComments(response, content);
903
+ const response = await client.generateTextWithMetadata(prompt, 3e3, 0.4, { format: "json" });
904
+ assertNotTruncated(response, "comment");
905
+ return MotivationParsers.parseComments(response.text, content);
953
906
  }
954
907
  /**
955
908
  * Detect highlights in content.
@@ -960,8 +913,9 @@ var AnnotationDetection = class {
960
913
  */
961
914
  static async detectHighlights(content, client, instructions, density, sourceLanguage) {
962
915
  const prompt = MotivationPrompts.buildHighlightPrompt(content, instructions, density, sourceLanguage);
963
- const response = await client.generateText(prompt, 2e3, 0.3, { format: "json" });
964
- return MotivationParsers.parseHighlights(response, content);
916
+ const response = await client.generateTextWithMetadata(prompt, 2e3, 0.3, { format: "json" });
917
+ assertNotTruncated(response, "highlight");
918
+ return MotivationParsers.parseHighlights(response.text, content);
965
919
  }
966
920
  /**
967
921
  * Detect assessments in content.
@@ -972,8 +926,9 @@ var AnnotationDetection = class {
972
926
  */
973
927
  static async detectAssessments(content, client, instructions, tone, density, language, sourceLanguage) {
974
928
  const prompt = MotivationPrompts.buildAssessmentPrompt(content, instructions, tone, density, language, sourceLanguage);
975
- const response = await client.generateText(prompt, 3e3, 0.3, { format: "json" });
976
- return MotivationParsers.parseAssessments(response, content);
929
+ const response = await client.generateTextWithMetadata(prompt, 3e3, 0.3, { format: "json" });
930
+ assertNotTruncated(response, "assessment");
931
+ return MotivationParsers.parseAssessments(response.text, content);
977
932
  }
978
933
  /**
979
934
  * Detect tags in content for a specific category.
@@ -1002,8 +957,9 @@ var AnnotationDetection = class {
1002
957
  categoryInfo.examples,
1003
958
  sourceLanguage
1004
959
  );
1005
- const response = await client.generateText(prompt, 4e3, 0.2, { format: "json" });
1006
- const parsedTags = MotivationParsers.parseTags(response);
960
+ const response = await client.generateTextWithMetadata(prompt, 4e3, 0.2, { format: "json" });
961
+ assertNotTruncated(response, "tag");
962
+ const parsedTags = MotivationParsers.parseTags(response.text);
1007
963
  return MotivationParsers.validateTagOffsets(parsedTags, content, category);
1008
964
  }
1009
965
  };
@@ -1072,41 +1028,47 @@ Example output:
1072
1028
  { format: "json" }
1073
1029
  );
1074
1030
  logger.debug("Got entity extraction response", { responseLength: response.text.length });
1031
+ if (response.stopReason === "max_tokens") {
1032
+ const errorMsg = "Entity extraction response truncated (max_tokens) \u2014 increase max_tokens or reduce resource size; failing the job rather than dropping annotations.";
1033
+ logger.error(errorMsg, { responseLength: response.text.length });
1034
+ throw new Error(errorMsg);
1035
+ }
1036
+ let entities;
1075
1037
  try {
1076
- let jsonStr = response.text.trim();
1077
- if (jsonStr.startsWith("```")) {
1078
- jsonStr = jsonStr.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
1079
- }
1080
- const entities = JSON.parse(jsonStr);
1081
- logger.debug("Parsed entities from AI response", { count: entities.length });
1082
- if (response.stopReason === "max_tokens") {
1083
- const errorMsg = `AI response truncated: Found ${entities.length} entities but response hit max_tokens limit. Increase max_tokens or reduce resource size.`;
1084
- logger.error(errorMsg);
1085
- throw new Error(errorMsg);
1086
- }
1087
- return entities.filter((e) => {
1088
- const ok = e && typeof e === "object" && typeof e.exact === "string" && typeof e.entityType === "string";
1089
- if (!ok) {
1090
- logger.debug("Dropped malformed LLM entity", { entity: e });
1091
- }
1092
- return ok;
1093
- }).map((entity) => ({
1094
- exact: entity.exact,
1095
- entityType: entity.entityType,
1096
- ...typeof entity.prefix === "string" ? { prefix: entity.prefix } : {},
1097
- ...typeof entity.suffix === "string" ? { suffix: entity.suffix } : {}
1098
- }));
1038
+ entities = JSON.parse(response.text.trim());
1099
1039
  } catch (error) {
1100
1040
  logger.error("Failed to parse entity extraction response", {
1101
- error: error instanceof Error ? error.message : String(error)
1041
+ error: error instanceof Error ? error.message : String(error),
1042
+ response: response.text.slice(0, 500)
1043
+ });
1044
+ throw new Error("Failed to parse entity extraction response", {
1045
+ cause: error instanceof Error ? error : new Error(String(error))
1046
+ });
1047
+ }
1048
+ if (!isArray(entities)) {
1049
+ logger.error("Failed to parse entity extraction response: expected a JSON array", {
1050
+ response: response.text.slice(0, 500)
1102
1051
  });
1103
- return [];
1052
+ throw new Error("Failed to parse entity extraction response: expected a JSON array");
1104
1053
  }
1054
+ logger.debug("Parsed entities from AI response", { count: entities.length });
1055
+ return entities.filter((e) => {
1056
+ const ok = isObject(e) && isString(e.exact) && isString(e.entityType);
1057
+ if (!ok) {
1058
+ logger.debug("Dropped malformed LLM entity", { entity: e });
1059
+ }
1060
+ return ok;
1061
+ }).map((entity) => ({
1062
+ exact: entity.exact,
1063
+ entityType: entity.entityType,
1064
+ ...isString(entity.prefix) ? { prefix: entity.prefix } : {},
1065
+ ...isString(entity.suffix) ? { suffix: entity.suffix } : {}
1066
+ }));
1105
1067
  }
1106
1068
  function getLanguageName(locale) {
1107
1069
  return getLocaleEnglishName(locale) || locale;
1108
1070
  }
1109
- async function generateResourceFromTopic(topic, entityTypes, client, logger, userPrompt, locale, context, temperature, maxTokens, sourceLanguage) {
1071
+ async function generateResourceFromTopic(topic, entityTypes, client, logger, userPrompt, locale, context, temperature, maxTokens, sourceLanguage, outputMediaType = "text/markdown") {
1110
1072
  logger.debug("Generating resource from topic", {
1111
1073
  topicPreview: topic.substring(0, 100),
1112
1074
  entityTypes,
@@ -1126,61 +1088,95 @@ IMPORTANT: Write the entire resource in ${getLanguageName(locale)}.` : "";
1126
1088
 
1127
1089
  The source resource and embedded context are in ${getLanguageName(sourceLanguage)}.` : "";
1128
1090
  let annotationSection = "";
1091
+ let contextSection = "";
1092
+ let resourceSection = "";
1093
+ let graphSection = "";
1129
1094
  if (context) {
1130
- const parts = [];
1131
- parts.push(`- Annotation motivation: ${context.annotation.motivation}`);
1132
- parts.push(`- Source resource: ${context.sourceResource.name}`);
1133
- const { motivation, body } = context.annotation;
1134
- if (motivation === "commenting" || motivation === "assessing") {
1135
- const bodyItem = Array.isArray(body) ? body[0] : body;
1136
- if (bodyItem && "value" in bodyItem && bodyItem.value) {
1137
- const label = motivation === "commenting" ? "Comment" : "Assessment";
1138
- parts.push(`- ${label}: ${bodyItem.value}`);
1095
+ const { focus } = context;
1096
+ const mainResourceId = focus.kind === "annotation" ? focus.sourceResource["@id"] : focus.resource["@id"];
1097
+ const focalAnnotationId = focus.kind === "annotation" ? focus.annotation.id : void 0;
1098
+ if (focus.kind === "annotation") {
1099
+ const parts = [];
1100
+ parts.push(`- Annotation motivation: ${focus.annotation.motivation}`);
1101
+ parts.push(`- Source resource: ${focus.sourceResource.name}`);
1102
+ const { motivation, body } = focus.annotation;
1103
+ if (motivation === "commenting" || motivation === "assessing") {
1104
+ const bodyItem = Array.isArray(body) ? body[0] : body;
1105
+ if (bodyItem && "value" in bodyItem && bodyItem.value) {
1106
+ const label = motivation === "commenting" ? "Comment" : "Assessment";
1107
+ parts.push(`- ${label}: ${bodyItem.value}`);
1108
+ }
1139
1109
  }
1140
- }
1141
- annotationSection = `
1110
+ annotationSection = `
1142
1111
 
1143
1112
  Annotation context:
1144
1113
  ${parts.join("\n")}`;
1145
- }
1146
- let contextSection = "";
1147
- if (context?.sourceContext) {
1148
- const { before, selected, after } = context.sourceContext;
1149
- contextSection = `
1114
+ if (focus.selected) {
1115
+ const { before, text, after } = focus.selected;
1116
+ contextSection = `
1150
1117
 
1151
1118
  Source document context:
1152
1119
  ---
1153
1120
  ${before ? `...${before}` : ""}
1154
- **[${selected}]**
1121
+ **[${text}]**
1155
1122
  ${after ? `${after}...` : ""}
1156
1123
  ---
1157
1124
  `;
1158
- }
1159
- let graphContextSection = "";
1160
- if (context?.graphContext) {
1161
- const gc = context.graphContext;
1162
- const connections = gc.connections ?? [];
1163
- const citedBy = gc.citedBy ?? [];
1164
- const parts = [];
1165
- if (connections.length > 0) {
1166
- const connList = connections.map((c) => `${c.resourceName}${c.entityTypes?.length ? ` (${c.entityTypes.join(", ")})` : ""}`).join(", ");
1167
- parts.push(`- Connected resources: ${connList}`);
1168
- }
1169
- if (gc.citedByCount && gc.citedByCount > 0) {
1170
- const citedNames = citedBy.map((c) => c.resourceName).join(", ");
1171
- parts.push(`- This resource is cited by ${gc.citedByCount} other resource${gc.citedByCount > 1 ? "s" : ""}${citedNames ? `: ${citedNames}` : ""}`);
1172
- }
1173
- if (gc.siblingEntityTypes && gc.siblingEntityTypes.length > 0) {
1174
- parts.push(`- Related entity types in this document: ${gc.siblingEntityTypes.join(", ")}`);
1175
- }
1176
- if (gc.inferredRelationshipSummary) {
1177
- parts.push(`- Relationship summary: ${gc.inferredRelationshipSummary}`);
1125
+ }
1126
+ } else {
1127
+ const RESOURCE_CONTENT_CAP = 4e3;
1128
+ const parts = [`- Resource: ${focus.resource.name}`];
1129
+ if (focus.summary) parts.push(`- Summary: ${focus.summary}`);
1130
+ if (focus.suggestedReferences && focus.suggestedReferences.length > 0) {
1131
+ parts.push(`- Suggested references: ${focus.suggestedReferences.join(", ")}`);
1132
+ }
1133
+ resourceSection = `
1134
+
1135
+ Resource context:
1136
+ ${parts.join("\n")}`;
1137
+ if (focus.content?.main) {
1138
+ resourceSection += `
1139
+
1140
+ Resource content:
1141
+ ---
1142
+ ${focus.content.main.slice(0, RESOURCE_CONTENT_CAP)}
1143
+ ---`;
1144
+ }
1145
+ const related = Object.entries(focus.content?.related ?? {});
1146
+ if (related.length > 0) {
1147
+ const blocks = related.map(([id, text]) => `[${id}]
1148
+ ${text.slice(0, RESOURCE_CONTENT_CAP)}`).join("\n\n");
1149
+ resourceSection += `
1150
+
1151
+ Related resource content:
1152
+ ---
1153
+ ${blocks}
1154
+ ---`;
1155
+ }
1178
1156
  }
1179
- if (parts.length > 0) {
1180
- graphContextSection = `
1157
+ if (mainResourceId) {
1158
+ const views = deriveViews(context.graph, mainResourceId, focalAnnotationId);
1159
+ const parts = [];
1160
+ if (views.connections.length > 0) {
1161
+ const connList = views.connections.map((c) => `${c.resourceName}${c.entityTypes.length ? ` (${c.entityTypes.join(", ")})` : ""}`).join(", ");
1162
+ parts.push(`- Connected resources: ${connList}`);
1163
+ }
1164
+ if (views.citedByCount > 0) {
1165
+ const citedNames = views.citedBy.map((c) => c.resourceName).join(", ");
1166
+ parts.push(`- This resource is cited by ${views.citedByCount} other resource${views.citedByCount > 1 ? "s" : ""}${citedNames ? `: ${citedNames}` : ""}`);
1167
+ }
1168
+ if (views.siblingEntityTypes.length > 0) {
1169
+ parts.push(`- Related entity types in this document: ${views.siblingEntityTypes.join(", ")}`);
1170
+ }
1171
+ if (context.inferredRelationshipSummary) {
1172
+ parts.push(`- Relationship summary: ${context.inferredRelationshipSummary}`);
1173
+ }
1174
+ if (parts.length > 0) {
1175
+ graphSection = `
1181
1176
 
1182
1177
  Knowledge graph context:
1183
1178
  ${parts.join("\n")}`;
1179
+ }
1184
1180
  }
1185
1181
  }
1186
1182
  let semanticContextSection = "";
@@ -1192,17 +1188,20 @@ ${parts.join("\n")}`;
1192
1188
  Related passages from the knowledge base:
1193
1189
  ${lines.join("\n")}`;
1194
1190
  }
1195
- const structureGuidance = finalMaxTokens >= 1e3 ? "organized into titled sections (## Section) with well-structured paragraphs" : "organized into well-structured paragraphs";
1191
+ const isPlainText = outputMediaType === "text/plain";
1192
+ const structureGuidance = !isPlainText && finalMaxTokens >= 1e3 ? "organized into titled sections (## Section) with well-structured paragraphs" : "organized into well-structured paragraphs";
1193
+ const formatRequirements = isPlainText ? `- Write the response as plain text \u2014 no formatting markup (no #, *, backticks, headings, or links)
1194
+ - Begin with the title on its own first line` : `- Start with a clear heading (# Title)
1195
+ - Use markdown formatting
1196
+ - Write the response as markdown`;
1196
1197
  const prompt = `Generate a concise, informative resource about "${topic}".
1197
1198
  ${entityTypes.length > 0 ? `Focus on these entity types: ${entityTypes.join(", ")}.` : ""}
1198
- ${userPrompt ? `Additional context: ${userPrompt}` : ""}${annotationSection}${contextSection}${graphContextSection}${semanticContextSection}${sourceLanguageInstruction}${languageInstruction}
1199
+ ${userPrompt ? `Additional context: ${userPrompt}` : ""}${annotationSection}${contextSection}${resourceSection}${graphSection}${semanticContextSection}${sourceLanguageInstruction}${languageInstruction}
1199
1200
 
1200
1201
  Requirements:
1201
- - Start with a clear heading (# Title)
1202
1202
  - Aim for approximately ${finalMaxTokens} tokens of content, ${structureGuidance}
1203
1203
  - Be factual and informative
1204
- - Use markdown formatting
1205
- - Write the response as markdown`;
1204
+ ${formatRequirements}`;
1206
1205
  const parseResponse = (response2) => {
1207
1206
  let content = response2.trim();
1208
1207
  if (content.startsWith("```markdown") || content.startsWith("```md")) {
@@ -1521,6 +1520,13 @@ async function processTagJob(content, inferenceClient, params, userId, generator
1521
1520
  };
1522
1521
  }
1523
1522
  async function processGenerationJob(inferenceClient, params, onProgress, logger) {
1523
+ const GENERATABLE_MEDIA_TYPES = ["text/markdown", "text/plain"];
1524
+ const outputMediaType = params.outputMediaType ?? "text/markdown";
1525
+ if (!GENERATABLE_MEDIA_TYPES.includes(outputMediaType)) {
1526
+ throw new Error(
1527
+ `Unsupported outputMediaType for generation: ${outputMediaType}. Generation produces ${GENERATABLE_MEDIA_TYPES.join(" or ")}.`
1528
+ );
1529
+ }
1524
1530
  onProgress(20, "Fetching context...", "fetching");
1525
1531
  const title = params.title ?? "Untitled";
1526
1532
  const entityTypes = (params.entityTypes ?? []).map(String);
@@ -1535,13 +1541,14 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger)
1535
1541
  params.context,
1536
1542
  params.temperature,
1537
1543
  params.maxTokens,
1538
- params.sourceLanguage
1544
+ params.sourceLanguage,
1545
+ outputMediaType
1539
1546
  );
1540
1547
  onProgress(85, "Creating resource...", "creating");
1541
1548
  return {
1542
1549
  content: generated.content,
1543
1550
  title: generated.title ?? title,
1544
- format: "text/markdown",
1551
+ format: outputMediaType,
1545
1552
  result: {
1546
1553
  resourceId: "",
1547
1554
  resourceName: generated.title ?? title