@ossy/media-tasks 3.18.0 → 3.19.0
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/README.md +29 -3
- package/package.json +16 -6
- package/src/Definition.js +7 -0
- package/src/convert-common-audio.action.js +5 -0
- package/src/convert-common-audio.js +243 -0
- package/src/convert-common-audio.schema.js +41 -0
- package/src/convert-common-audio.spec.js +188 -0
- package/src/convert-common-audio.task.js +142 -0
- package/src/convert-common-image.action.js +5 -0
- package/src/convert-common-image.js +143 -0
- package/src/convert-common-image.schema.js +46 -0
- package/src/convert-common-image.spec.js +101 -0
- package/src/convert-common-image.task.js +155 -0
- package/src/embed-resource.action.js +5 -0
- package/src/embed-resource.js +111 -0
- package/src/embed-resource.schema.js +46 -0
- package/src/embed-resource.spec.js +179 -0
- package/src/embed-resource.task.js +199 -0
- package/src/en.translations.json +4 -0
- package/src/extract-colors.action.js +5 -0
- package/src/extract-colors.task.js +13 -7
- package/src/openrouter.integration.js +2 -1
- package/src/resize-common-web.action.js +5 -0
- package/src/resize-common-web.task.js +13 -7
- package/src/run-prompt.task.js +6 -0
- package/src/sv.translations.json +4 -0
- package/src/text-to-voice.action.js +5 -0
- package/src/text-to-voice.js +160 -0
- package/src/text-to-voice.schema.js +51 -0
- package/src/text-to-voice.spec.js +102 -0
- package/src/text-to-voice.task.js +191 -0
- package/src/visual-content-descriptors.action.js +5 -0
- package/src/visual-content-descriptors.task.js +8 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embed-resource task (#952).
|
|
3
|
+
*
|
|
4
|
+
* Converts a resource's text content into a dense vector via OpenRouter's
|
|
5
|
+
* embeddings API and stores it for semantic search and clustering.
|
|
6
|
+
*
|
|
7
|
+
* Text source resolution (priority order):
|
|
8
|
+
* 1. Explicit `text` input
|
|
9
|
+
* 2. Markdown `content.body`
|
|
10
|
+
* 3. text/* file bytes (via originalObjectKey)
|
|
11
|
+
* 4. VCD descriptor — `taskArtifacts.visual-content-descriptors.descriptors.body.description`
|
|
12
|
+
* 5. run-prompt completion — `taskArtifacts.run-prompt.completion.body.text`
|
|
13
|
+
*
|
|
14
|
+
* This means image resources get embedded once VCD or run-prompt has run on them.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { MARKDOWN_DOCUMENT_TYPE, isTextContentType } from './run-prompt.js'
|
|
18
|
+
|
|
19
|
+
export { MARKDOWN_DOCUMENT_TYPE, isTextContentType }
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_EMBED_MODEL = 'openai/text-embedding-3-small'
|
|
22
|
+
export const DEFAULT_EMBED_DIMENSIONS = 1536
|
|
23
|
+
/** Soft character ceiling — text-embedding-3-small supports ~8k tokens (~32k chars). */
|
|
24
|
+
export const MAX_EMBED_CHARS = 32_000
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {unknown} value
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeEmbedModel (value) {
|
|
31
|
+
const raw = typeof value === 'string' ? value.trim() : ''
|
|
32
|
+
return raw || DEFAULT_EMBED_MODEL
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} text
|
|
37
|
+
* @returns {{ text: string, truncated: boolean }}
|
|
38
|
+
*/
|
|
39
|
+
export function clampEmbedText (text) {
|
|
40
|
+
const t = typeof text === 'string' ? text.trim() : ''
|
|
41
|
+
if (t.length <= MAX_EMBED_CHARS) return { text: t, truncated: false }
|
|
42
|
+
return { text: t.slice(0, MAX_EMBED_CHARS), truncated: true }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the text to embed from the resource and optional inputs.
|
|
47
|
+
*
|
|
48
|
+
* @param {{
|
|
49
|
+
* inputText?: unknown,
|
|
50
|
+
* resource?: object | null,
|
|
51
|
+
* fileText?: string | null,
|
|
52
|
+
* }} opts
|
|
53
|
+
* @returns {{ text: string, source: string }}
|
|
54
|
+
*/
|
|
55
|
+
export function resolveEmbedText ({ inputText, resource, fileText } = {}) {
|
|
56
|
+
const fromInput = typeof inputText === 'string' ? inputText.trim() : ''
|
|
57
|
+
if (fromInput) return { text: fromInput, source: 'input' }
|
|
58
|
+
|
|
59
|
+
if (resource?.type === MARKDOWN_DOCUMENT_TYPE) {
|
|
60
|
+
const body = resource?.content?.body
|
|
61
|
+
if (typeof body === 'string' && body.trim()) {
|
|
62
|
+
return { text: body.trim(), source: 'markdown-body' }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof fileText === 'string' && fileText.trim()) {
|
|
67
|
+
return { text: fileText.trim(), source: 'file-bytes' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// VCD: description from visual-content-descriptors artifact
|
|
71
|
+
const vcdDescription = resource?.content?.taskArtifacts?.['visual-content-descriptors']?.descriptors?.body?.description
|
|
72
|
+
if (typeof vcdDescription === 'string' && vcdDescription.trim()) {
|
|
73
|
+
return { text: vcdDescription.trim(), source: 'vcd-description' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// run-prompt completion text
|
|
77
|
+
const promptText = resource?.content?.taskArtifacts?.['run-prompt']?.completion?.body?.text
|
|
78
|
+
if (typeof promptText === 'string' && promptText.trim()) {
|
|
79
|
+
return { text: promptText.trim(), source: 'run-prompt-completion' }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { text: '', source: 'none' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Call OpenRouter embeddings and return the float vector.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ embeddings: { create: Function } }} client OpenAI-compatible client
|
|
89
|
+
* @param {{ text: string, model?: string }} opts
|
|
90
|
+
* @returns {Promise<{ vector: number[], dimensions: number, inputTokens: number }>}
|
|
91
|
+
*/
|
|
92
|
+
export async function createEmbedding (client, { text, model }) {
|
|
93
|
+
if (!text || !String(text).trim()) throw new Error('[embed-resource] text is required')
|
|
94
|
+
const resolvedModel = normalizeEmbedModel(model)
|
|
95
|
+
|
|
96
|
+
const response = await client.embeddings.create({
|
|
97
|
+
model: resolvedModel,
|
|
98
|
+
input: text,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const vector = response.data?.[0]?.embedding
|
|
102
|
+
if (!Array.isArray(vector) || !vector.length) {
|
|
103
|
+
throw new Error('[embed-resource] empty embedding returned')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
vector,
|
|
108
|
+
dimensions: vector.length,
|
|
109
|
+
inputTokens: response.usage?.prompt_tokens ?? 0,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { TASK_OUTPUT_PROVENANCE_FIELDS } from '@ossy/schema'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Embed resource',
|
|
5
|
+
id: '@ossy/media-tasks/schema/embed-resource',
|
|
6
|
+
categoryName: 'Media processing',
|
|
7
|
+
icon: 'cpu',
|
|
8
|
+
fields: [
|
|
9
|
+
{
|
|
10
|
+
name: 'model',
|
|
11
|
+
type: 'text',
|
|
12
|
+
description: 'Embedding model slug (e.g. openai/text-embedding-3-small)',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'dimensions',
|
|
16
|
+
type: 'number',
|
|
17
|
+
description: 'Vector dimensionality',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'textSource',
|
|
21
|
+
type: 'text',
|
|
22
|
+
description: 'Where the embedded text came from: input | markdown-body | file-bytes | vcd-description | run-prompt-completion',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'textLength',
|
|
26
|
+
type: 'number',
|
|
27
|
+
description: 'Character count of the embedded text (after clamping)',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'truncated',
|
|
31
|
+
type: 'boolean',
|
|
32
|
+
description: 'True when the text was truncated to the embedding character ceiling',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'inputTokens',
|
|
36
|
+
type: 'number',
|
|
37
|
+
description: 'Tokens consumed by the embedding call',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'vectorKey',
|
|
41
|
+
type: 'text',
|
|
42
|
+
description: 'Storage key for the raw embedding JSON artifact',
|
|
43
|
+
},
|
|
44
|
+
...TASK_OUTPUT_PROVENANCE_FIELDS,
|
|
45
|
+
],
|
|
46
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_EMBED_MODEL,
|
|
3
|
+
DEFAULT_EMBED_DIMENSIONS,
|
|
4
|
+
MAX_EMBED_CHARS,
|
|
5
|
+
clampEmbedText,
|
|
6
|
+
normalizeEmbedModel,
|
|
7
|
+
resolveEmbedText,
|
|
8
|
+
createEmbedding,
|
|
9
|
+
} from './embed-resource.js'
|
|
10
|
+
import { MARKDOWN_DOCUMENT_TYPE } from './run-prompt.js'
|
|
11
|
+
|
|
12
|
+
describe('embed-resource helpers', () => {
|
|
13
|
+
describe('normalizeEmbedModel', () => {
|
|
14
|
+
it('returns the model unchanged when valid', () => {
|
|
15
|
+
expect(normalizeEmbedModel('openai/text-embedding-3-small')).toBe('openai/text-embedding-3-small')
|
|
16
|
+
expect(normalizeEmbedModel('openai/text-embedding-3-large')).toBe('openai/text-embedding-3-large')
|
|
17
|
+
})
|
|
18
|
+
it('falls back to default for blank input', () => {
|
|
19
|
+
expect(normalizeEmbedModel('')).toBe(DEFAULT_EMBED_MODEL)
|
|
20
|
+
expect(normalizeEmbedModel(' ')).toBe(DEFAULT_EMBED_MODEL)
|
|
21
|
+
expect(normalizeEmbedModel(undefined)).toBe(DEFAULT_EMBED_MODEL)
|
|
22
|
+
expect(normalizeEmbedModel(null)).toBe(DEFAULT_EMBED_MODEL)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('clampEmbedText', () => {
|
|
27
|
+
it('does not truncate short text', () => {
|
|
28
|
+
expect(clampEmbedText('hello world')).toEqual({ text: 'hello world', truncated: false })
|
|
29
|
+
})
|
|
30
|
+
it('trims surrounding whitespace', () => {
|
|
31
|
+
expect(clampEmbedText(' hi ')).toEqual({ text: 'hi', truncated: false })
|
|
32
|
+
})
|
|
33
|
+
it('truncates to MAX_EMBED_CHARS', () => {
|
|
34
|
+
const long = 'x'.repeat(MAX_EMBED_CHARS + 100)
|
|
35
|
+
const result = clampEmbedText(long)
|
|
36
|
+
expect(result.truncated).toBe(true)
|
|
37
|
+
expect(result.text).toHaveLength(MAX_EMBED_CHARS)
|
|
38
|
+
})
|
|
39
|
+
it('handles non-string gracefully', () => {
|
|
40
|
+
expect(clampEmbedText(undefined)).toEqual({ text: '', truncated: false })
|
|
41
|
+
expect(clampEmbedText(null)).toEqual({ text: '', truncated: false })
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('resolveEmbedText', () => {
|
|
46
|
+
it('prefers explicit text input', () => {
|
|
47
|
+
const resource = {
|
|
48
|
+
type: MARKDOWN_DOCUMENT_TYPE,
|
|
49
|
+
content: { body: 'md body' },
|
|
50
|
+
}
|
|
51
|
+
expect(resolveEmbedText({ inputText: ' from input ', resource })).toEqual({
|
|
52
|
+
text: 'from input',
|
|
53
|
+
source: 'input',
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('uses markdown body for markdown documents', () => {
|
|
58
|
+
const resource = {
|
|
59
|
+
type: MARKDOWN_DOCUMENT_TYPE,
|
|
60
|
+
content: { body: ' # Hello ' },
|
|
61
|
+
}
|
|
62
|
+
expect(resolveEmbedText({ resource })).toEqual({
|
|
63
|
+
text: '# Hello',
|
|
64
|
+
source: 'markdown-body',
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('uses file text when resource is not markdown', () => {
|
|
69
|
+
const resource = { type: '@ossy/platform/schema/file', content: { ContentType: 'text/plain' } }
|
|
70
|
+
expect(resolveEmbedText({ resource, fileText: ' file bytes ' })).toEqual({
|
|
71
|
+
text: 'file bytes',
|
|
72
|
+
source: 'file-bytes',
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('falls back to VCD description', () => {
|
|
77
|
+
const resource = {
|
|
78
|
+
type: '@ossy/platform/schema/file',
|
|
79
|
+
content: {
|
|
80
|
+
ContentType: 'image/jpeg',
|
|
81
|
+
taskArtifacts: {
|
|
82
|
+
'visual-content-descriptors': {
|
|
83
|
+
descriptors: { body: { description: 'A sunny beach' } },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
expect(resolveEmbedText({ resource })).toEqual({
|
|
89
|
+
text: 'A sunny beach',
|
|
90
|
+
source: 'vcd-description',
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('falls back to run-prompt completion', () => {
|
|
95
|
+
const resource = {
|
|
96
|
+
type: '@ossy/platform/schema/file',
|
|
97
|
+
content: {
|
|
98
|
+
ContentType: 'image/png',
|
|
99
|
+
taskArtifacts: {
|
|
100
|
+
'run-prompt': {
|
|
101
|
+
completion: { body: { text: 'AI summary of content' } },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
expect(resolveEmbedText({ resource })).toEqual({
|
|
107
|
+
text: 'AI summary of content',
|
|
108
|
+
source: 'run-prompt-completion',
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('returns source=none when nothing is found', () => {
|
|
113
|
+
expect(resolveEmbedText({})).toEqual({ text: '', source: 'none' })
|
|
114
|
+
expect(resolveEmbedText({ resource: {} })).toEqual({ text: '', source: 'none' })
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('createEmbedding', () => {
|
|
120
|
+
const FAKE_VECTOR = Array.from({ length: DEFAULT_EMBED_DIMENSIONS }, (_, i) => i / DEFAULT_EMBED_DIMENSIONS)
|
|
121
|
+
|
|
122
|
+
it('calls embeddings.create with the right params and returns vector + metadata', async () => {
|
|
123
|
+
const calls = []
|
|
124
|
+
const client = {
|
|
125
|
+
embeddings: {
|
|
126
|
+
create: async (args) => {
|
|
127
|
+
calls.push(args)
|
|
128
|
+
return {
|
|
129
|
+
data: [{ embedding: FAKE_VECTOR }],
|
|
130
|
+
usage: { prompt_tokens: 42 },
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
const result = await createEmbedding(client, {
|
|
136
|
+
text: 'semantic knowledge base',
|
|
137
|
+
model: 'openai/text-embedding-3-small',
|
|
138
|
+
})
|
|
139
|
+
expect(calls[0]).toEqual({
|
|
140
|
+
model: 'openai/text-embedding-3-small',
|
|
141
|
+
input: 'semantic knowledge base',
|
|
142
|
+
})
|
|
143
|
+
expect(result.vector).toBe(FAKE_VECTOR)
|
|
144
|
+
expect(result.dimensions).toBe(DEFAULT_EMBED_DIMENSIONS)
|
|
145
|
+
expect(result.inputTokens).toBe(42)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('uses the default model when none is provided', async () => {
|
|
149
|
+
const calls = []
|
|
150
|
+
const client = {
|
|
151
|
+
embeddings: {
|
|
152
|
+
create: async (args) => {
|
|
153
|
+
calls.push(args)
|
|
154
|
+
return {
|
|
155
|
+
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
|
156
|
+
usage: { prompt_tokens: 3 },
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
await createEmbedding(client, { text: 'test' })
|
|
162
|
+
expect(calls[0].model).toBe(DEFAULT_EMBED_MODEL)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('throws on empty text', async () => {
|
|
166
|
+
const client = { embeddings: { create: async () => ({}) } }
|
|
167
|
+
await expect(createEmbedding(client, { text: '' })).rejects.toThrow(/text is required/)
|
|
168
|
+
await expect(createEmbedding(client, { text: ' ' })).rejects.toThrow(/text is required/)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('throws when the API returns an empty embedding', async () => {
|
|
172
|
+
const client = {
|
|
173
|
+
embeddings: {
|
|
174
|
+
create: async () => ({ data: [{ embedding: [] }], usage: { prompt_tokens: 1 } }),
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
await expect(createEmbedding(client, { text: 'test' })).rejects.toThrow(/empty embedding/)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
import { StorageClient } from '@ossy/platform'
|
|
3
|
+
import { originalObjectKey, taskArtifactObjectKey } from '@ossy/platform/storage-keys'
|
|
4
|
+
import { GetResource, placeTaskArtifacts } from '@ossy/resources'
|
|
5
|
+
import { upsertEmbedding } from '@ossy/platform/vector/vector-store'
|
|
6
|
+
import { loadSourceBuffer } from './load-source-buffer.js'
|
|
7
|
+
import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
|
|
8
|
+
import {
|
|
9
|
+
MARKDOWN_DOCUMENT_TYPE,
|
|
10
|
+
isTextContentType,
|
|
11
|
+
markdownDocumentBody,
|
|
12
|
+
} from './run-prompt.js'
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_EMBED_MODEL,
|
|
15
|
+
MAX_EMBED_CHARS,
|
|
16
|
+
clampEmbedText,
|
|
17
|
+
createEmbedding,
|
|
18
|
+
normalizeEmbedModel,
|
|
19
|
+
resolveEmbedText,
|
|
20
|
+
} from './embed-resource.js'
|
|
21
|
+
|
|
22
|
+
const log = createLogger('media-tasks')
|
|
23
|
+
|
|
24
|
+
const TASK_ID = '@ossy/media-tasks/tasks/embed-resource'
|
|
25
|
+
const SCHEMA_ID = '@ossy/media-tasks/schema/embed-resource'
|
|
26
|
+
const CONCEPT = 'embed-resource'
|
|
27
|
+
const VECTOR_ARTIFACT = 'vector'
|
|
28
|
+
const OUTPUT = {
|
|
29
|
+
name: 'embedding',
|
|
30
|
+
kind: 'location',
|
|
31
|
+
schemaId: SCHEMA_ID,
|
|
32
|
+
contentType: 'application/json',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const metadata = {
|
|
36
|
+
id: TASK_ID,
|
|
37
|
+
triggers: [
|
|
38
|
+
{
|
|
39
|
+
kind: 'on_action',
|
|
40
|
+
action: '@ossy/media-tasks/actions/embed-resource',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: MARKDOWN_DOCUMENT_TYPE,
|
|
44
|
+
event: 'Created',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: '@ossy/platform/schema/file',
|
|
48
|
+
event: 'Created',
|
|
49
|
+
match: { 'content.ContentType': 'text/*' },
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
outputs: [OUTPUT],
|
|
53
|
+
inputs: [
|
|
54
|
+
{
|
|
55
|
+
name: 'text',
|
|
56
|
+
type: 'text',
|
|
57
|
+
multiline: true,
|
|
58
|
+
sources: ['payload', 'config'],
|
|
59
|
+
description: 'Text to embed. When empty, uses the Markdown body, text file, VCD description, or run-prompt completion.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'model',
|
|
63
|
+
type: 'text',
|
|
64
|
+
default: DEFAULT_EMBED_MODEL,
|
|
65
|
+
sources: ['payload', 'config'],
|
|
66
|
+
description: `OpenRouter embedding model slug (default ${DEFAULT_EMBED_MODEL})`,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
configurable: true,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function run ({ event, payload, sdk, integrations, inputs }) {
|
|
73
|
+
const openrouter = integrations?.get?.('openrouter')
|
|
74
|
+
if (!openrouter) {
|
|
75
|
+
log.warn('[media-tasks/tasks/embed-resource] openrouter integration not connected')
|
|
76
|
+
throw Object.assign(new Error('OpenRouter integration not available'), { status: 503 })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const resourceId = event?.resourceId || payload?.resourceId || null
|
|
80
|
+
log.info(`[media-tasks/tasks/embed-resource] starting${resourceId ? ` for resource ${resourceId}` : ''}`)
|
|
81
|
+
|
|
82
|
+
let resource = null
|
|
83
|
+
let fileText = null
|
|
84
|
+
if (resourceId) {
|
|
85
|
+
resource = await sdk.invoke(GetResource, { resourceId })
|
|
86
|
+
fileText = await loadFileText(resource)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { text: rawText, source: textSource } = resolveEmbedText({
|
|
90
|
+
inputText: inputs?.text,
|
|
91
|
+
resource,
|
|
92
|
+
fileText,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
if (!rawText) {
|
|
96
|
+
throw Object.assign(
|
|
97
|
+
new Error('[media-tasks/tasks/embed-resource] no text found to embed (provide text input, or ensure resource has Markdown body, text content, VCD, or run-prompt output)'),
|
|
98
|
+
{ status: 400 },
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { text, truncated } = clampEmbedText(rawText)
|
|
103
|
+
const model = normalizeEmbedModel(inputs?.model)
|
|
104
|
+
|
|
105
|
+
const { vector, dimensions, inputTokens } = await createEmbedding(openrouter, { text, model })
|
|
106
|
+
log.info(`[media-tasks/tasks/embed-resource] embedded ${text.length} chars → ${dimensions}d vector (${inputTokens} tokens, source: ${textSource})`)
|
|
107
|
+
|
|
108
|
+
// Always store in MongoDB vector store when we have a resource.
|
|
109
|
+
if (resource?.id) {
|
|
110
|
+
await upsertEmbedding({
|
|
111
|
+
resourceId: resource.id,
|
|
112
|
+
workspaceId: resource.belongsTo,
|
|
113
|
+
model,
|
|
114
|
+
dimensions,
|
|
115
|
+
vector,
|
|
116
|
+
textSource,
|
|
117
|
+
textLength: text.length,
|
|
118
|
+
})
|
|
119
|
+
log.info(`[media-tasks/tasks/embed-resource] upserted vector for resource ${resource.id}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!resource?.id) {
|
|
123
|
+
return {
|
|
124
|
+
output: OUTPUT.name,
|
|
125
|
+
model,
|
|
126
|
+
dimensions,
|
|
127
|
+
textSource,
|
|
128
|
+
textLength: text.length,
|
|
129
|
+
truncated,
|
|
130
|
+
inputTokens,
|
|
131
|
+
placedResourceIds: [],
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const body = {
|
|
136
|
+
model,
|
|
137
|
+
dimensions,
|
|
138
|
+
textSource,
|
|
139
|
+
textLength: text.length,
|
|
140
|
+
truncated,
|
|
141
|
+
inputTokens,
|
|
142
|
+
vectorKey: taskArtifactObjectKey(resource.id, CONCEPT, VECTOR_ARTIFACT),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Persist the raw vector as a JSON artifact for potential offline analysis.
|
|
146
|
+
const vectorKey = body.vectorKey
|
|
147
|
+
await StorageClient.save(vectorKey, Buffer.from(JSON.stringify({ vector }), 'utf8'))
|
|
148
|
+
log.info(`[media-tasks/tasks/embed-resource] saved vector artifact ${vectorKey}`)
|
|
149
|
+
|
|
150
|
+
const metaKey = taskArtifactObjectKey(resource.id, CONCEPT, OUTPUT.name)
|
|
151
|
+
await StorageClient.save(metaKey, Buffer.from(JSON.stringify(body), 'utf8'))
|
|
152
|
+
log.info(`[media-tasks/tasks/embed-resource] saved ${metaKey}`)
|
|
153
|
+
|
|
154
|
+
const placementLocations = await resolveConfiguredPlacements(sdk, TASK_ID, OUTPUT.name)
|
|
155
|
+
const placed = await placeTaskArtifacts({
|
|
156
|
+
sdk,
|
|
157
|
+
sourceResource: resource,
|
|
158
|
+
taskId: TASK_ID,
|
|
159
|
+
output: OUTPUT,
|
|
160
|
+
body,
|
|
161
|
+
artifactKey: metaKey,
|
|
162
|
+
placementLocations,
|
|
163
|
+
})
|
|
164
|
+
if (placed.length) {
|
|
165
|
+
log.info(`[media-tasks/tasks/embed-resource] placed ${placed.length} resource(s)`)
|
|
166
|
+
} else {
|
|
167
|
+
log.info('[media-tasks/tasks/embed-resource] artifact only (no placement location configured)')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
output: OUTPUT.name,
|
|
172
|
+
key: metaKey,
|
|
173
|
+
vectorKey,
|
|
174
|
+
model,
|
|
175
|
+
dimensions,
|
|
176
|
+
textSource,
|
|
177
|
+
textLength: text.length,
|
|
178
|
+
truncated,
|
|
179
|
+
inputTokens,
|
|
180
|
+
placedResourceIds: placed.map((r) => r.id),
|
|
181
|
+
placedResourceId: placed[0]?.id ?? null,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {object | null | undefined} resource
|
|
187
|
+
* @returns {Promise<string | null>}
|
|
188
|
+
*/
|
|
189
|
+
async function loadFileText (resource) {
|
|
190
|
+
if (!resource) return null
|
|
191
|
+
if (resource.type === MARKDOWN_DOCUMENT_TYPE) {
|
|
192
|
+
return markdownDocumentBody(resource) || null
|
|
193
|
+
}
|
|
194
|
+
const contentType = resource?.content?.ContentType || ''
|
|
195
|
+
if (!isTextContentType(contentType)) return null
|
|
196
|
+
const key = originalObjectKey(resource.id)
|
|
197
|
+
const buffer = await loadSourceBuffer(key)
|
|
198
|
+
return buffer ? buffer.toString('utf8') : null
|
|
199
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createLogger } from '@ossy/observability'
|
|
2
2
|
import { StorageClient } from '@ossy/platform'
|
|
3
|
-
import { taskArtifactObjectKey } from '@ossy/platform/storage-keys'
|
|
3
|
+
import { originalObjectKey, taskArtifactObjectKey } from '@ossy/platform/storage-keys'
|
|
4
4
|
import { GetResource, placeTaskArtifacts } from '@ossy/resources'
|
|
5
5
|
import { extractColorsFromBuffer } from './extract-colors.js'
|
|
6
6
|
import { loadSourceBuffer } from './load-source-buffer.js'
|
|
@@ -27,22 +27,28 @@ export const metadata = {
|
|
|
27
27
|
event: 'Created',
|
|
28
28
|
match: { 'content.ContentType': 'image/*' },
|
|
29
29
|
},
|
|
30
|
+
{
|
|
31
|
+
kind: 'on_action',
|
|
32
|
+
action: '@ossy/media-tasks/actions/extract-colors',
|
|
33
|
+
},
|
|
30
34
|
],
|
|
31
35
|
outputs: [OUTPUT],
|
|
32
36
|
configurable: true,
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
export async function run ({ event, sdk }) {
|
|
39
|
+
export async function run ({ event, payload, sdk }) {
|
|
36
40
|
const { default: sharp } = await import('sharp')
|
|
37
|
-
const resourceId = event
|
|
41
|
+
const resourceId = event?.resourceId || payload?.resourceId
|
|
42
|
+
if (!resourceId) {
|
|
43
|
+
throw new Error('[media-tasks/tasks/extract-colors] resourceId is required')
|
|
44
|
+
}
|
|
38
45
|
|
|
39
46
|
log.info(`[media-tasks/tasks/extract-colors] starting for resource ${resourceId}`)
|
|
40
47
|
|
|
41
48
|
const resource = await sdk.invoke(GetResource, { resourceId })
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
49
|
+
// content.Key is stripped by the API media layer (replaced with src URL);
|
|
50
|
+
// derive the storage key deterministically from the resource ID instead.
|
|
51
|
+
const key = originalObjectKey(resource.id)
|
|
46
52
|
|
|
47
53
|
const sourceBuffer = await loadSourceBuffer(key)
|
|
48
54
|
if (!sourceBuffer) {
|
|
@@ -10,7 +10,8 @@ const DEFAULT_APP_TITLE = 'Ossy'
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Returns an OpenAI-compatible client pointed at OpenRouter.
|
|
13
|
-
* Tasks receive this via `integrations.get('openrouter')
|
|
13
|
+
* Tasks receive this via `integrations.get('openrouter')` for chat completions
|
|
14
|
+
* and TTS (`audio.speech`).
|
|
14
15
|
*
|
|
15
16
|
* @param {{ env: NodeJS.ProcessEnv }} opts
|
|
16
17
|
* @returns {import('openai').OpenAI}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createLogger } from '@ossy/observability'
|
|
2
2
|
import { StorageClient } from '@ossy/platform'
|
|
3
|
-
import { taskArtifactObjectKey } from '@ossy/platform/storage-keys'
|
|
3
|
+
import { originalObjectKey, taskArtifactObjectKey } from '@ossy/platform/storage-keys'
|
|
4
4
|
import { GetResource, placeTaskArtifacts } from '@ossy/resources'
|
|
5
5
|
import { encodeBlurhashFromBuffer } from './encode-blurhash.js'
|
|
6
6
|
import { loadSourceBuffer } from './load-source-buffer.js'
|
|
@@ -35,22 +35,28 @@ export const metadata = {
|
|
|
35
35
|
event: 'Created',
|
|
36
36
|
match: { 'content.ContentType': 'image/*' },
|
|
37
37
|
},
|
|
38
|
+
{
|
|
39
|
+
kind: 'on_action',
|
|
40
|
+
action: '@ossy/media-tasks/actions/resize-common-web',
|
|
41
|
+
},
|
|
38
42
|
],
|
|
39
43
|
outputs: [OUTPUT],
|
|
40
44
|
configurable: true,
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
export async function run ({ event, sdk }) {
|
|
47
|
+
export async function run ({ event, payload, sdk }) {
|
|
44
48
|
const { default: sharp } = await import('sharp')
|
|
45
|
-
const resourceId = event
|
|
49
|
+
const resourceId = event?.resourceId || payload?.resourceId
|
|
50
|
+
if (!resourceId) {
|
|
51
|
+
throw new Error('[media-tasks/tasks/resize-common-web] resourceId is required')
|
|
52
|
+
}
|
|
46
53
|
|
|
47
54
|
log.info(`[media-tasks/tasks/resize-common-web] starting for resource ${resourceId}`)
|
|
48
55
|
|
|
49
56
|
const resource = await sdk.invoke(GetResource, { resourceId })
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
57
|
+
// content.Key is stripped by the API media layer (replaced with src URL);
|
|
58
|
+
// derive the storage key deterministically from the resource ID instead.
|
|
59
|
+
const key = originalObjectKey(resource.id)
|
|
54
60
|
|
|
55
61
|
const sourceBuffer = await loadSourceBuffer(key)
|
|
56
62
|
if (!sourceBuffer) {
|
package/src/run-prompt.task.js
CHANGED
|
@@ -53,6 +53,12 @@ export const metadata = {
|
|
|
53
53
|
type: MARKDOWN_DOCUMENT_TYPE,
|
|
54
54
|
event: 'Created',
|
|
55
55
|
},
|
|
56
|
+
// Ceiling for workspace on_action authoring / ADR 0003 primary-action parity.
|
|
57
|
+
// Storage → Tasks still omits this task until a prompt UI exists (required input).
|
|
58
|
+
{
|
|
59
|
+
kind: 'on_action',
|
|
60
|
+
action: '@ossy/media-tasks/actions/run-prompt',
|
|
61
|
+
},
|
|
56
62
|
],
|
|
57
63
|
outputs: [OUTPUT],
|
|
58
64
|
inputs: [
|