@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.
- package/lib/activities/generateDocumentProperties.d.ts +54 -13
- package/lib/activities/generateDocumentProperties.d.ts.map +1 -1
- package/lib/activities/generateDocumentProperties.js +109 -34
- package/lib/activities/generateDocumentProperties.js.map +1 -1
- package/lib/activities/generateEmbeddings.d.ts.map +1 -1
- package/lib/activities/generateEmbeddings.js +14 -22
- package/lib/activities/generateEmbeddings.js.map +1 -1
- package/lib/activities/generateOrAssignContentType.d.ts +6 -16
- package/lib/activities/generateOrAssignContentType.d.ts.map +1 -1
- package/lib/activities/generateOrAssignContentType.js +70 -19
- package/lib/activities/generateOrAssignContentType.js.map +1 -1
- package/lib/activities/media/exec.d.ts +60 -0
- package/lib/activities/media/exec.d.ts.map +1 -0
- package/lib/activities/media/exec.js +212 -0
- package/lib/activities/media/exec.js.map +1 -0
- package/lib/activities/media/prepareAudio.d.ts.map +1 -1
- package/lib/activities/media/prepareAudio.js +7 -6
- package/lib/activities/media/prepareAudio.js.map +1 -1
- package/lib/activities/media/prepareVideo.d.ts +5 -0
- package/lib/activities/media/prepareVideo.d.ts.map +1 -1
- package/lib/activities/media/prepareVideo.js +37 -13
- package/lib/activities/media/prepareVideo.js.map +1 -1
- package/lib/activities/media/probeMediaStreams.d.ts.map +1 -1
- package/lib/activities/media/probeMediaStreams.js +7 -6
- package/lib/activities/media/probeMediaStreams.js.map +1 -1
- package/lib/activities/renditions/generateVideoRendition.d.ts.map +1 -1
- package/lib/activities/renditions/generateVideoRendition.js +7 -5
- package/lib/activities/renditions/generateVideoRendition.js.map +1 -1
- package/lib/dsl/dsl-workflow.js +3 -0
- package/lib/dsl/dsl-workflow.js.map +1 -1
- package/lib/dsl/dslProxyActivities.d.ts +1 -0
- package/lib/dsl/dslProxyActivities.d.ts.map +1 -1
- package/lib/dsl/dslProxyActivities.js +9 -0
- package/lib/dsl/dslProxyActivities.js.map +1 -1
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +5 -0
- package/lib/errors.js.map +1 -1
- package/lib/workflows-bundle.js +16111 -6668
- package/package.json +17 -16
- package/src/activities/generateDocumentProperties.test.ts +642 -0
- package/src/activities/generateDocumentProperties.ts +187 -43
- package/src/activities/generateEmbeddings.test.ts +114 -0
- package/src/activities/generateEmbeddings.ts +17 -24
- package/src/activities/generateOrAssignContentType.test.ts +196 -0
- package/src/activities/generateOrAssignContentType.ts +115 -30
- package/src/activities/media/exec.test.ts +194 -0
- package/src/activities/media/exec.ts +265 -0
- package/src/activities/media/prepareAudio.ts +12 -7
- package/src/activities/media/prepareVideo.test.ts +27 -0
- package/src/activities/media/prepareVideo.ts +54 -18
- package/src/activities/media/probeMediaStreams.test.ts +7 -8
- package/src/activities/media/probeMediaStreams.ts +11 -7
- package/src/activities/renditions/generateVideoRendition.ts +12 -6
- package/src/dsl/dsl-workflow.test.ts +4 -0
- package/src/dsl/dsl-workflow.ts +3 -0
- package/src/dsl/dslProxyActivities.test.ts +23 -0
- package/src/dsl/dslProxyActivities.ts +11 -0
- package/src/errors.ts +5 -0
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import { ApplicationFailure, log } from '@temporalio/activity';
|
|
2
|
-
import
|
|
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
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
-
`
|
|
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:
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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 &&
|
|
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
|
-
|
|
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(
|
|
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:
|
|
245
|
+
etag: sourceEtag,
|
|
253
246
|
});
|
|
254
247
|
|
|
255
248
|
return {
|