@vertesia/workflow 1.5.0-dev.20260714.072725Z → 1.5.0-dev.20260722.120446Z

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.
Files changed (58) hide show
  1. package/lib/activities/generateDocumentProperties.d.ts +54 -13
  2. package/lib/activities/generateDocumentProperties.d.ts.map +1 -1
  3. package/lib/activities/generateDocumentProperties.js +109 -34
  4. package/lib/activities/generateDocumentProperties.js.map +1 -1
  5. package/lib/activities/generateEmbeddings.d.ts.map +1 -1
  6. package/lib/activities/generateEmbeddings.js +14 -22
  7. package/lib/activities/generateEmbeddings.js.map +1 -1
  8. package/lib/activities/generateOrAssignContentType.d.ts +6 -16
  9. package/lib/activities/generateOrAssignContentType.d.ts.map +1 -1
  10. package/lib/activities/generateOrAssignContentType.js +70 -19
  11. package/lib/activities/generateOrAssignContentType.js.map +1 -1
  12. package/lib/activities/media/exec.d.ts +60 -0
  13. package/lib/activities/media/exec.d.ts.map +1 -0
  14. package/lib/activities/media/exec.js +212 -0
  15. package/lib/activities/media/exec.js.map +1 -0
  16. package/lib/activities/media/prepareAudio.d.ts.map +1 -1
  17. package/lib/activities/media/prepareAudio.js +7 -6
  18. package/lib/activities/media/prepareAudio.js.map +1 -1
  19. package/lib/activities/media/prepareVideo.d.ts +5 -0
  20. package/lib/activities/media/prepareVideo.d.ts.map +1 -1
  21. package/lib/activities/media/prepareVideo.js +37 -13
  22. package/lib/activities/media/prepareVideo.js.map +1 -1
  23. package/lib/activities/media/probeMediaStreams.d.ts.map +1 -1
  24. package/lib/activities/media/probeMediaStreams.js +7 -6
  25. package/lib/activities/media/probeMediaStreams.js.map +1 -1
  26. package/lib/activities/renditions/generateVideoRendition.d.ts.map +1 -1
  27. package/lib/activities/renditions/generateVideoRendition.js +7 -5
  28. package/lib/activities/renditions/generateVideoRendition.js.map +1 -1
  29. package/lib/dsl/dsl-workflow.js +3 -0
  30. package/lib/dsl/dsl-workflow.js.map +1 -1
  31. package/lib/dsl/dslProxyActivities.d.ts +1 -0
  32. package/lib/dsl/dslProxyActivities.d.ts.map +1 -1
  33. package/lib/dsl/dslProxyActivities.js +9 -0
  34. package/lib/dsl/dslProxyActivities.js.map +1 -1
  35. package/lib/errors.d.ts.map +1 -1
  36. package/lib/errors.js +5 -0
  37. package/lib/errors.js.map +1 -1
  38. package/lib/workflows-bundle.js +16111 -6668
  39. package/package.json +17 -16
  40. package/src/activities/generateDocumentProperties.test.ts +642 -0
  41. package/src/activities/generateDocumentProperties.ts +187 -43
  42. package/src/activities/generateEmbeddings.test.ts +114 -0
  43. package/src/activities/generateEmbeddings.ts +17 -24
  44. package/src/activities/generateOrAssignContentType.test.ts +196 -0
  45. package/src/activities/generateOrAssignContentType.ts +115 -30
  46. package/src/activities/media/exec.test.ts +194 -0
  47. package/src/activities/media/exec.ts +265 -0
  48. package/src/activities/media/prepareAudio.ts +12 -7
  49. package/src/activities/media/prepareVideo.test.ts +27 -0
  50. package/src/activities/media/prepareVideo.ts +54 -18
  51. package/src/activities/media/probeMediaStreams.test.ts +7 -8
  52. package/src/activities/media/probeMediaStreams.ts +11 -7
  53. package/src/activities/renditions/generateVideoRendition.ts +12 -6
  54. package/src/dsl/dsl-workflow.test.ts +4 -0
  55. package/src/dsl/dsl-workflow.ts +3 -0
  56. package/src/dsl/dslProxyActivities.test.ts +23 -0
  57. package/src/dsl/dslProxyActivities.ts +11 -0
  58. package/src/errors.ts +5 -0
@@ -1,20 +1,28 @@
1
1
  import { ApplicationFailure, log } from '@temporalio/activity';
2
- import type { DSLActivityExecutionPayload, DSLActivitySpec, JSONObject } from '@vertesia/common';
2
+ import {
3
+ type ContentMetadata,
4
+ type ContentSource,
5
+ type DSLActivityExecutionPayload,
6
+ type DSLActivitySpec,
7
+ type JSONObject,
8
+ mergePreservingNonExtractable,
9
+ PDF_RENDITION_NAME,
10
+ type Rendition,
11
+ schemaForExtraction,
12
+ } from '@vertesia/common';
3
13
  import { setupActivity } from '../dsl/setup/ActivityContext.js';
14
+ import { md5 } from '../utils/blobs.js';
4
15
  import { type TruncateSpec, truncByMaxTokens } from '../utils/tokens.js';
5
16
  import { executeInteractionFromActivity, type InteractionExecutionParams } from './executeInteraction.js';
6
17
 
7
18
  const INT_EXTRACT_INFORMATION = 'sys:ExtractInformation';
8
19
 
20
+ export type GenerateDocumentPropertiesSource = 'auto' | 'text' | 'vision' | 'mixed';
21
+
9
22
  interface RetryableError extends Error {
10
23
  retryable?: boolean;
11
24
  }
12
25
 
13
- interface GeneratedPropertiesSummary {
14
- title?: unknown;
15
- description?: unknown;
16
- }
17
-
18
26
  function toRetryableError(error: unknown): RetryableError {
19
27
  if (error instanceof Error) {
20
28
  return error as RetryableError;
@@ -31,57 +39,209 @@ export interface GenerateDocumentPropertiesParams extends InteractionExecutionPa
31
39
 
32
40
  interactionName?: string;
33
41
 
42
+ /**
43
+ * @deprecated Use source: 'mixed'. Accepted for backward compatibility with existing DSL callers.
44
+ */
34
45
  use_vision?: boolean;
46
+
47
+ source?: GenerateDocumentPropertiesSource;
48
+
49
+ instructions?: string;
50
+
51
+ /**
52
+ * Scoped page-image evidence prepared by intake (`prepareVisionEvidence` scratch refs).
53
+ * When the param is PRESENT (even as an empty array) and the source calls for visual
54
+ * evidence, these refs are the ONLY visual evidence used — never the whole-object
55
+ * `store:{id}` ref, which resolves to ALL pages near-full-res through the rendition
56
+ * generate-and-poll path. An empty array means intake prepared no usable evidence
57
+ * (budget-impossible or no renderable source): extraction then runs text-only or fails
58
+ * with no-source instead of shipping an over-budget payload.
59
+ * Absent = legacy behavior for existing DSL callers.
60
+ */
61
+ evidence_images?: ContentSource[];
62
+
63
+ /**
64
+ * Opt-in freshness guard. When true, the activity computes a fingerprint of the extraction
65
+ * inputs (content etag, type + its object schema, source, instructions, interaction name)
66
+ * and returns `{ status: 'skipped' }` without executing the interaction when the fingerprint
67
+ * stored by a previous successful extraction matches and the object already has non-empty
68
+ * properties. Defaults to false so shared DSL callers keep the always-extract behavior.
69
+ * `payload.vars.forceGeneration` bypasses the skip.
70
+ */
71
+ skip_if_fresh?: boolean;
35
72
  }
36
73
  export interface GenerateDocumentProperties extends DSLActivitySpec<GenerateDocumentPropertiesParams> {
37
74
  name: 'generateDocumentProperties';
38
75
  }
76
+ export type GenerateDocumentPropertiesResult =
77
+ | { status: 'completed' }
78
+ | { status: 'failed'; error: string }
79
+ | { document: string; status: 'skipped'; message: string };
80
+
81
+ /**
82
+ * Stable fingerprint of the inputs that determine the extraction output. Key order is fixed by
83
+ * the object literal so the hash is deterministic.
84
+ */
85
+ export function computeExtractionFingerprint(input: {
86
+ content_etag?: string;
87
+ type_id?: string;
88
+ source: GenerateDocumentPropertiesSource;
89
+ instructions?: string;
90
+ interactionName: string;
91
+ /** The type's object_schema: a schema edit under the same type id must invalidate the fingerprint. */
92
+ object_schema?: unknown;
93
+ /** Scoped vision evidence refs: a different page/profile selection must invalidate the fingerprint.
94
+ * Undefined (legacy callers) keeps previously stored hashes valid. */
95
+ evidence?: string[];
96
+ /** The object's text etag: text can change under the same content etag (re-render,
97
+ * re-conversion, manual edit) and text-consuming extractions must invalidate.
98
+ * Undefined (vision-only callers) keeps previously stored hashes valid. */
99
+ text_etag?: string | null;
100
+ }): string {
101
+ return md5(
102
+ JSON.stringify({
103
+ content_etag: input.content_etag,
104
+ type_id: input.type_id,
105
+ source: input.source,
106
+ instructions: input.instructions,
107
+ interactionName: input.interactionName,
108
+ object_schema: input.object_schema,
109
+ ...(input.evidence ? { evidence: input.evidence } : {}),
110
+ ...(input.text_etag !== undefined ? { text_etag: input.text_etag } : {}),
111
+ }),
112
+ );
113
+ }
114
+
115
+ /**
116
+ * The fingerprint is persisted inside the generation run info of the last successful extraction
117
+ * (the server appends it to `metadata.generation_runs`). Other writers append runs without a
118
+ * fingerprint, so scan backwards for the most recent extraction entry.
119
+ */
120
+ function getStoredExtractionFingerprint(metadata: ContentMetadata | undefined): string | undefined {
121
+ const runs = metadata?.generation_runs;
122
+ if (!Array.isArray(runs)) {
123
+ return undefined;
124
+ }
125
+ for (let i = runs.length - 1; i >= 0; i--) {
126
+ if (runs[i]?.extraction_fingerprint) {
127
+ return runs[i].extraction_fingerprint;
128
+ }
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ function getPdfRenditionContent(doc: { metadata?: { renditions?: unknown } }): ContentSource | undefined {
134
+ const renditions = doc.metadata?.renditions;
135
+ if (!Array.isArray(renditions)) {
136
+ return undefined;
137
+ }
138
+ const pdfRendition = renditions.find(
139
+ (rendition): rendition is Rendition =>
140
+ typeof rendition === 'object' &&
141
+ rendition !== null &&
142
+ (rendition as { name?: unknown }).name === PDF_RENDITION_NAME &&
143
+ typeof (rendition as { content?: { source?: unknown } }).content?.source === 'string',
144
+ );
145
+ return pdfRendition?.content;
146
+ }
39
147
 
40
148
  export async function generateDocumentProperties(
41
149
  payload: DSLActivityExecutionPayload<GenerateDocumentPropertiesParams>,
42
- ) {
150
+ ): Promise<GenerateDocumentPropertiesResult> {
43
151
  const context = await setupActivity<GenerateDocumentPropertiesParams>(payload);
44
152
  const { params, client, objectId } = context;
45
153
  const interactionName = params.interactionName ?? INT_EXTRACT_INFORMATION;
46
154
 
47
155
  const project = await context.fetchProject();
48
156
 
49
- const doc = await client.objects.retrieve(objectId, '+text');
157
+ const doc = await client.objects.retrieve(objectId, '+text +metadata +content');
50
158
  const type = doc.type ? await client.types.catalog.resolve(doc.type) : undefined;
51
159
 
52
- if (!doc?.text && !params.use_vision && !doc?.content?.type?.startsWith('image/')) {
53
- log.warn(`Object ${objectId} not found or text is empty`);
54
- return { status: 'failed', error: 'no-text' };
55
- }
56
-
57
160
  if (!type?.object_schema) {
58
161
  log.info(`Object ${objectId} has no schema`);
59
162
  return { document: objectId, status: 'skipped', message: 'no schema defined on type' };
60
163
  }
61
164
 
62
- const getImageRef = () => {
165
+ const source = params.source ?? (params.use_vision ? 'mixed' : 'auto');
166
+ const evidenceProvided = Array.isArray(params.evidence_images);
167
+ const evidenceImages = params.evidence_images?.length ? params.evidence_images : undefined;
168
+
169
+ const extractionFingerprint = computeExtractionFingerprint({
170
+ content_etag: doc.content?.etag,
171
+ type_id: type.id,
172
+ source,
173
+ instructions: params.instructions,
174
+ interactionName,
175
+ object_schema: type.object_schema,
176
+ evidence: evidenceImages?.map((image) => image.source ?? image.name ?? ''),
177
+ // text may change under the same content etag; only vision-only extraction ignores it
178
+ text_etag: source === 'vision' ? undefined : (doc.text_etag ?? null),
179
+ });
180
+
181
+ if (params.skip_if_fresh && !payload.vars?.forceGeneration) {
182
+ const storedFingerprint = getStoredExtractionFingerprint(doc.metadata);
183
+ const hasProperties = !!doc.properties && Object.keys(doc.properties).length > 0;
184
+ if (storedFingerprint === extractionFingerprint && hasProperties) {
185
+ log.info(`Skipping property extraction for ${objectId}: extraction inputs unchanged`, {
186
+ extractionFingerprint,
187
+ });
188
+ return {
189
+ document: objectId,
190
+ status: 'skipped',
191
+ message: 'properties are fresh (extraction fingerprint matches)',
192
+ };
193
+ }
194
+ }
195
+
196
+ const getImageRef = (): string | ContentSource | undefined => {
63
197
  if (doc.content?.type?.startsWith('image/')) {
64
198
  return `store:${doc.id}`;
65
199
  }
66
200
 
67
- if (params.use_vision && doc.content?.type?.startsWith('application/pdf')) {
201
+ if (doc.content?.type?.startsWith('application/pdf')) {
68
202
  return `store:${doc.id}`;
69
203
  }
70
204
 
205
+ const pdfRendition = getPdfRenditionContent(doc);
206
+ if (pdfRendition?.type?.startsWith('application/pdf')) {
207
+ return pdfRendition;
208
+ }
209
+
71
210
  log.info(`Object ${objectId} is not an image or pdf`);
72
211
  return undefined;
73
212
  };
74
213
 
75
- const content = doc.text ? truncByMaxTokens(doc.text, params.truncate || 30000) : undefined;
214
+ const hasRealText = !!doc.text?.trim();
215
+ const content =
216
+ hasRealText && (source === 'auto' || source === 'text' || source === 'mixed')
217
+ ? truncByMaxTokens(doc.text ?? '', params.truncate || 30000)
218
+ : undefined;
219
+ const needsVisualEvidence = source === 'vision' || source === 'mixed' || (source === 'auto' && !content);
220
+ // Scoped scratch page refs win over the whole-object store: ref (which resolves to ALL
221
+ // pages near-full-res through the rendition generate-and-poll path). A PRESENT param —
222
+ // even empty — disables the store-ref fallback entirely: intake decided what evidence
223
+ // (if any) may be sent.
224
+ const scopedImages = needsVisualEvidence ? evidenceImages : undefined;
225
+ const imageRef = needsVisualEvidence && !evidenceProvided ? getImageRef() : undefined;
226
+
227
+ if (!content && !imageRef && !scopedImages) {
228
+ log.warn(`Object ${objectId} has no text or supported vision source`);
229
+ return { status: 'failed', error: 'no-source' };
230
+ }
76
231
 
77
232
  const promptData = {
78
233
  content: content,
79
- image: getImageRef() ?? undefined,
234
+ image: imageRef,
235
+ images: scopedImages,
236
+ extraction_instructions: params.instructions,
80
237
  human_context: project?.configuration?.human_context ?? undefined,
81
238
  };
82
239
 
240
+ // Strip properties marked x-extract:false so constrained decoding cannot invent them.
241
+ const extractionSchema = schemaForExtraction(type.object_schema);
242
+
83
243
  log.info(
84
- ` Extracting information from object ${objectId} with type ${type.name}`,
244
+ `Extracting information from object ${objectId} with type ${type.name}`,
85
245
  payload.debug_mode ? { params } : undefined,
86
246
  );
87
247
 
@@ -93,7 +253,7 @@ export async function generateDocumentProperties(
93
253
  {
94
254
  ...params,
95
255
  include_previous_error: true,
96
- result_schema: type.object_schema,
256
+ result_schema: extractionSchema,
97
257
  validate_result: type.strict_mode,
98
258
  },
99
259
  promptData,
@@ -125,38 +285,22 @@ export async function generateDocumentProperties(
125
285
  throw error;
126
286
  }
127
287
 
128
- const getText = () => {
129
- if (doc.text) {
130
- return undefined;
131
- }
132
- let text = '';
133
- const jsonResult = infoRes.result.object<GeneratedPropertiesSummary>();
134
- if (typeof jsonResult.title === 'string') {
135
- text += `${jsonResult.title}\n`;
136
- }
137
- if (typeof jsonResult.description === 'string') {
138
- text += jsonResult.description;
139
- }
140
- if (text) {
141
- return text;
142
- } else {
143
- return undefined;
144
- }
145
- };
146
-
147
288
  log.info(`Extracted information from object ${objectId} with type ${type.name}`, { runId: infoRes.id });
289
+ const extracted = infoRes.result.object<JSONObject>() ?? {};
290
+ const existing =
291
+ doc.properties && typeof doc.properties === 'object' && !Array.isArray(doc.properties)
292
+ ? (doc.properties as Record<string, unknown>)
293
+ : {};
294
+ const properties = mergePreservingNonExtractable(existing, extracted, type.object_schema) as JSONObject;
148
295
  await client.objects.update(
149
296
  doc.id,
150
297
  {
151
- properties: {
152
- ...infoRes.result.object<JSONObject>(),
153
- ...(doc.text_etag !== undefined ? { etag: doc.text_etag } : {}),
154
- },
155
- text: getText(),
298
+ properties,
156
299
  generation_run_info: {
157
300
  id: infoRes.id,
158
301
  date: new Date().toISOString(),
159
302
  model: infoRes.modelId ?? '',
303
+ extraction_fingerprint: extractionFingerprint,
160
304
  },
161
305
  },
162
306
  { suppressWorkflows: true },
@@ -0,0 +1,114 @@
1
+ import { MockActivityEnvironment } from '@temporalio/testing';
2
+ import type { VertesiaClient } from '@vertesia/client';
3
+ import {
4
+ ContentEventName,
5
+ type ContentObject,
6
+ type DSLActivityExecutionPayload,
7
+ SupportedEmbeddingTypes,
8
+ } from '@vertesia/common';
9
+ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import type { ActivityContext } from '../dsl/setup/ActivityContext.js';
11
+ import { type GenerateEmbeddingsParams, generateEmbeddings } from './generateEmbeddings.js';
12
+
13
+ vi.mock('../dsl/setup/ActivityContext.js', async (importOriginal) => {
14
+ const actual = await importOriginal<typeof import('../dsl/setup/ActivityContext.js')>();
15
+ return { ...actual, setupActivity: vi.fn() };
16
+ });
17
+
18
+ let testEnv: MockActivityEnvironment;
19
+
20
+ beforeAll(() => {
21
+ testEnv = new MockActivityEnvironment();
22
+ });
23
+
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ });
27
+
28
+ const createPayload = (params: GenerateEmbeddingsParams): DSLActivityExecutionPayload<GenerateEmbeddingsParams> => {
29
+ const activityParams: GenerateEmbeddingsParams & Record<string, unknown> = { ...params };
30
+ return {
31
+ auth_token: 'mock-token',
32
+ account_id: 'test-account',
33
+ project_id: 'test-project',
34
+ params,
35
+ config: { studio_url: 'http://mock-studio', store_url: 'http://mock-store' },
36
+ workflow_name: 'StandardDocumentIntake',
37
+ event: ContentEventName.create,
38
+ objectIds: ['properties-only-object'],
39
+ input: { inputType: 'objectIds', objectIds: ['properties-only-object'] },
40
+ vars: {},
41
+ activity: { name: 'generateEmbeddings', params: activityParams },
42
+ };
43
+ };
44
+
45
+ describe('generateEmbeddings', () => {
46
+ it('should generate property embeddings for an object without content', async () => {
47
+ const { setupActivity } = await import('../dsl/setup/ActivityContext.js');
48
+ const document = {
49
+ id: 'properties-only-object',
50
+ properties: {
51
+ title: 'Properties-only object',
52
+ category: 'metadata',
53
+ },
54
+ } satisfies Partial<ContentObject>;
55
+ const embeddingResponse = {
56
+ model: 'embedding-model',
57
+ results: [{ outputs: [{ values: [0.1, 0.2, 0.3] }] }],
58
+ };
59
+ const client = {
60
+ objects: {
61
+ retrieve: vi.fn().mockResolvedValue(document),
62
+ setEmbedding: vi.fn().mockResolvedValue(undefined),
63
+ },
64
+ environments: {
65
+ embeddings: vi.fn().mockResolvedValue(embeddingResponse),
66
+ },
67
+ } as unknown as VertesiaClient;
68
+ const params = {
69
+ type: SupportedEmbeddingTypes.properties,
70
+ force: false,
71
+ } satisfies GenerateEmbeddingsParams;
72
+
73
+ vi.mocked(setupActivity).mockResolvedValue({
74
+ client,
75
+ objectId: document.id,
76
+ params,
77
+ fetchProject: vi.fn().mockResolvedValue({
78
+ name: 'Test Project',
79
+ namespace: 'test-project',
80
+ configuration: {
81
+ embeddings: {
82
+ [SupportedEmbeddingTypes.properties]: {
83
+ enabled: true,
84
+ environment: 'test-environment',
85
+ max_tokens: 8000,
86
+ },
87
+ },
88
+ },
89
+ }),
90
+ } as unknown as ActivityContext<GenerateEmbeddingsParams>);
91
+
92
+ const result = await testEnv.run(generateEmbeddings, createPayload(params));
93
+
94
+ expect(result).toEqual({
95
+ id: document.id,
96
+ type: SupportedEmbeddingTypes.properties,
97
+ status: 'completed',
98
+ len: 3,
99
+ });
100
+ expect(client.objects.retrieve).toHaveBeenCalledWith(
101
+ document.id,
102
+ '+text +parts +embeddings +tokens +properties',
103
+ );
104
+ expect(client.environments.embeddings).toHaveBeenCalledWith('test-environment', {
105
+ inputs: [{ type: 'text', text: JSON.stringify(document.properties) }],
106
+ model: undefined,
107
+ });
108
+ expect(client.objects.setEmbedding).toHaveBeenCalledWith(document.id, SupportedEmbeddingTypes.properties, {
109
+ values: embeddingResponse.results[0].outputs[0].values,
110
+ model: embeddingResponse.model,
111
+ etag: expect.any(String),
112
+ });
113
+ });
114
+ });
@@ -113,10 +113,6 @@ export async function generateEmbeddings(payload: DSLActivityExecutionPayload<Ge
113
113
  throw new DocumentNotFoundError('Document not found', [objectId]);
114
114
  }
115
115
 
116
- if (!document.content) {
117
- throw new DocumentNotFoundError('Document content not found', [objectId]);
118
- }
119
-
120
116
  let res:
121
117
  | Awaited<ReturnType<typeof generateTextEmbeddings>>
122
118
  | Awaited<ReturnType<typeof generateImageEmbeddings>>
@@ -174,14 +170,19 @@ async function generateTextEmbeddings({ document, client, type, config, force }:
174
170
  };
175
171
  }
176
172
 
177
- if (type === SupportedEmbeddingTypes.text && !document.text) {
178
- return { id: document.id, status: 'failed', message: 'no text found' };
179
- }
180
- if (type === SupportedEmbeddingTypes.properties && !document?.properties) {
173
+ const sourceText =
174
+ type === SupportedEmbeddingTypes.text
175
+ ? document.text
176
+ : document.properties
177
+ ? JSON.stringify(document.properties)
178
+ : undefined;
179
+
180
+ if (!sourceText) {
181
181
  return {
182
182
  id: document.id,
183
- status: 'failed',
184
- message: 'no properties found',
183
+ type,
184
+ status: 'skipped',
185
+ message: type === SupportedEmbeddingTypes.text ? 'no text found' : 'no properties found',
185
186
  };
186
187
  }
187
188
 
@@ -192,12 +193,12 @@ async function generateTextEmbeddings({ document, client, type, config, force }:
192
193
  );
193
194
  }
194
195
 
195
- // Compute text etag for comparison
196
- const textEtag = document.text_etag ?? (document.text ? md5(document.text) : undefined);
196
+ const sourceEtag =
197
+ type === SupportedEmbeddingTypes.text ? (document.text_etag ?? md5(sourceText)) : md5(sourceText);
197
198
 
198
199
  // Skip if embeddings already exist with matching etag (unless force=true)
199
200
  const existingEmbedding = document.embeddings?.[type];
200
- if (!force && existingEmbedding?.etag && textEtag && existingEmbedding.etag === textEtag) {
201
+ if (!force && existingEmbedding?.etag && existingEmbedding.etag === sourceEtag) {
201
202
  log.debug(`Skipping ${type} embeddings for document ${document.id} - etag unchanged`);
202
203
  return {
203
204
  id: document.id,
@@ -208,15 +209,7 @@ async function generateTextEmbeddings({ document, client, type, config, force }:
208
209
  }
209
210
 
210
211
  // Count tokens if needed, do not rely on existing token count
211
- let tokenCount: number | undefined;
212
- if (type === SupportedEmbeddingTypes.text && document.text) {
213
- tokenCount = countTokens(document.text).count;
214
- }
215
-
216
- if (type === SupportedEmbeddingTypes.properties && document.properties) {
217
- const propertiesText = JSON.stringify(document.properties);
218
- tokenCount = countTokens(propertiesText).count;
219
- }
212
+ const tokenCount = countTokens(sourceText).count;
220
213
 
221
214
  const maxTokens = config.max_tokens ?? 8000;
222
215
 
@@ -233,7 +226,7 @@ async function generateTextEmbeddings({ document, client, type, config, force }:
233
226
  } else {
234
227
  log.debug(`Generating ${type} embeddings for document`);
235
228
 
236
- const res = await generateEmbeddingsFromStudio(JSON.stringify(document[type]), environment, client);
229
+ const res = await generateEmbeddingsFromStudio(sourceText, environment, client);
237
230
  const values = res?.results?.[0]?.outputs?.[0]?.values;
238
231
  if (!values) {
239
232
  return {
@@ -249,7 +242,7 @@ async function generateTextEmbeddings({ document, client, type, config, force }:
249
242
  await client.objects.setEmbedding(document.id, type, {
250
243
  values,
251
244
  model: res.model,
252
- etag: textEtag,
245
+ etag: sourceEtag,
253
246
  });
254
247
 
255
248
  return {