@ossy/platform 3.24.1 → 3.26.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/package.json +7 -7
- package/src/runtime.js +2 -1
- package/src/server.js +2 -1
- package/src/ses/poll-inbound-mail.js +7 -3
- package/src/vector/vector-store.js +62 -19
- package/src/vector/vector-store.spec.js +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.26.0",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -49,18 +49,18 @@
|
|
|
49
49
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
50
50
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
51
51
|
"@ossy/config": "^3.0.9",
|
|
52
|
-
"@ossy/email": "^3.
|
|
53
|
-
"@ossy/event-store": "^3.
|
|
52
|
+
"@ossy/email": "^3.26.0",
|
|
53
|
+
"@ossy/event-store": "^3.26.0",
|
|
54
54
|
"@ossy/locale": "^3.4.0",
|
|
55
55
|
"@ossy/manifest": "^3.24.0",
|
|
56
56
|
"@ossy/observability": "^3.24.1",
|
|
57
57
|
"@ossy/policies": "^3.24.1",
|
|
58
|
-
"@ossy/router": "^3.0
|
|
58
|
+
"@ossy/router": "^3.25.0",
|
|
59
59
|
"@ossy/schema": "^3.24.0",
|
|
60
|
-
"@ossy/sdk": "^3.
|
|
60
|
+
"@ossy/sdk": "^3.26.0",
|
|
61
61
|
"@ossy/tokens": "^3.24.1",
|
|
62
62
|
"@ossy/users": "^3.24.1",
|
|
63
|
-
"@ossy/workspaces": "^3.
|
|
63
|
+
"@ossy/workspaces": "^3.26.0",
|
|
64
64
|
"cookie-parser": "^1.4.7",
|
|
65
65
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
66
66
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -81,5 +81,5 @@
|
|
|
81
81
|
"Dockerfile",
|
|
82
82
|
"docker-healthcheck.js"
|
|
83
83
|
],
|
|
84
|
-
"gitHead": "
|
|
84
|
+
"gitHead": "0df308a330150a890b9dd4d6ffc2e9f28bd31966"
|
|
85
85
|
}
|
package/src/runtime.js
CHANGED
|
@@ -187,7 +187,8 @@ export async function startRuntime ({ port } = {}) {
|
|
|
187
187
|
const { language, messages, fallbackMessages } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
|
|
188
188
|
if (typeof pageEntry.redirect === 'string' && pageEntry.redirect.trim()) {
|
|
189
189
|
const targetId = pageEntry.redirect.trim().replace(/^@/, '')
|
|
190
|
-
const
|
|
190
|
+
const params = pageRouter.getParamsFromUrl(requestUrl)
|
|
191
|
+
const targetPath = pageRouter.getPathname({ id: targetId, language, params })
|
|
191
192
|
if (targetPath) {
|
|
192
193
|
const search = new URL(requestUrl, 'http://localhost').search || ''
|
|
193
194
|
res.redirect(302, `${targetPath}${search}`)
|
package/src/server.js
CHANGED
|
@@ -530,7 +530,8 @@ export async function startServer (options = {}) {
|
|
|
530
530
|
if (typeof pageEntry.redirect === 'string' && pageEntry.redirect.trim()) {
|
|
531
531
|
const { language } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
|
|
532
532
|
const targetId = pageEntry.redirect.trim().replace(/^@/, '')
|
|
533
|
-
const
|
|
533
|
+
const params = pageRouter.getParamsFromUrl(requestUrl)
|
|
534
|
+
const targetPath = pageRouter.getPathname({ id: targetId, language, params })
|
|
534
535
|
if (targetPath) {
|
|
535
536
|
const search = new URL(requestUrl, 'http://localhost').search || ''
|
|
536
537
|
res.redirect(302, `${targetPath}${search}`)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DeleteMessageCommand, ReceiveMessageCommand, SQSClient } from '@aws-sdk/client-sqs'
|
|
2
2
|
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
|
3
3
|
import { fileSesReceipt, sesReceiptFromBody } from '@ossy/email/ses-inbound'
|
|
4
|
+
import { canonicalWorkspaceId } from '@ossy/workspaces/server'
|
|
4
5
|
import { createInProcessSdk } from '../tasks/in-process-sdk.js'
|
|
5
6
|
import { inboundAwsClientConfig } from './inbound-aws-credentials.js'
|
|
6
7
|
|
|
@@ -47,9 +48,12 @@ export function startInboundMailPoller ({ queueUrl, bucket, log } = {}) {
|
|
|
47
48
|
const file = (body) => fileSesReceipt(sesReceiptFromBody(body), {
|
|
48
49
|
bucket,
|
|
49
50
|
getObject: (location) => readObject(objects, location),
|
|
50
|
-
invoke: ({ mime, workspaceId }) =>
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
invoke: async ({ mime, workspaceId }) => {
|
|
52
|
+
const canonical = await canonicalWorkspaceId(workspaceId)
|
|
53
|
+
return createInProcessSdk()
|
|
54
|
+
.forWorkspace(canonical)
|
|
55
|
+
.invoke(ReceiveInbound, { mime, workspaceId: canonical })
|
|
56
|
+
},
|
|
53
57
|
})
|
|
54
58
|
|
|
55
59
|
const run = (async () => {
|
|
@@ -2,21 +2,22 @@
|
|
|
2
2
|
* Vector embedding store backed by MongoDB.
|
|
3
3
|
*
|
|
4
4
|
* Collection: `resource_embeddings`
|
|
5
|
-
* Schema: { resourceId, workspaceId, model, dimensions, vector, textSource, textLength, updatedAt }
|
|
5
|
+
* Schema: { resourceId, workspaceId, kind, model, dimensions, vector, textSource, textLength, textHash, updatedAt }
|
|
6
6
|
*
|
|
7
7
|
* Search strategy:
|
|
8
8
|
* - If Atlas Vector Search index `resource_embedding_vector` is present → $vectorSearch
|
|
9
|
-
* - Otherwise →
|
|
9
|
+
* - Otherwise → cosine similarity over the workspace (and kind, when set)
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* `workspaceId` and `kind` must be filter fields on that index. A filter on a
|
|
12
|
+
* field the index does not declare fails the query, and search falls back to
|
|
13
|
+
* the in-memory scan. Atlas index definition:
|
|
12
14
|
* ```json
|
|
13
15
|
* {
|
|
14
|
-
* "
|
|
15
|
-
* "
|
|
16
|
-
* "
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* }
|
|
16
|
+
* "fields": [
|
|
17
|
+
* { "type": "vector", "path": "vector", "numDimensions": 1536, "similarity": "cosine" },
|
|
18
|
+
* { "type": "filter", "path": "workspaceId" },
|
|
19
|
+
* { "type": "filter", "path": "kind" }
|
|
20
|
+
* ]
|
|
20
21
|
* }
|
|
21
22
|
* ```
|
|
22
23
|
*/
|
|
@@ -67,6 +68,8 @@ function col () {
|
|
|
67
68
|
* vector: number[],
|
|
68
69
|
* textSource: string,
|
|
69
70
|
* textLength: number,
|
|
71
|
+
* kind?: string,
|
|
72
|
+
* textHash?: string,
|
|
70
73
|
* }} doc
|
|
71
74
|
*/
|
|
72
75
|
export async function upsertEmbedding (doc) {
|
|
@@ -97,6 +100,43 @@ export async function deleteEmbedding (resourceId) {
|
|
|
97
100
|
await col().deleteOne({ resourceId })
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Filter for `$vectorSearch` and the in-memory fallback.
|
|
105
|
+
* `kind` keeps a request vector from matching other requests.
|
|
106
|
+
*
|
|
107
|
+
* Atlas `$vectorSearch` filters do not support `$exists`, so the Atlas query
|
|
108
|
+
* matches `kind` exactly. A changed document writes `kind` when it is embedded
|
|
109
|
+
* or scored. The in-memory fallback can include legacy rows that predate the field.
|
|
110
|
+
*
|
|
111
|
+
* @param {{ workspaceId?: string, kind?: string, includeMissingKind?: boolean }} [scope]
|
|
112
|
+
*/
|
|
113
|
+
export function embeddingSearchFilter ({ workspaceId, kind, includeMissingKind = false } = {}) {
|
|
114
|
+
const filter = {}
|
|
115
|
+
if (workspaceId) filter.workspaceId = workspaceId
|
|
116
|
+
if (!kind) return filter
|
|
117
|
+
if (!includeMissingKind) {
|
|
118
|
+
filter.kind = kind
|
|
119
|
+
return filter
|
|
120
|
+
}
|
|
121
|
+
filter.$or = [
|
|
122
|
+
{ kind },
|
|
123
|
+
{ kind: { $exists: false } },
|
|
124
|
+
{ kind: null },
|
|
125
|
+
]
|
|
126
|
+
return filter
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Record what a stored vector represents without rewriting the vector.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} resourceId
|
|
133
|
+
* @param {string} kind
|
|
134
|
+
*/
|
|
135
|
+
export async function setEmbeddingKind (resourceId, kind) {
|
|
136
|
+
if (!resourceId || !kind) return
|
|
137
|
+
await col().updateOne({ resourceId }, { $set: { kind, updatedAt: new Date() } })
|
|
138
|
+
}
|
|
139
|
+
|
|
100
140
|
/**
|
|
101
141
|
* Semantic search within a workspace using cosine similarity.
|
|
102
142
|
* Tries Atlas $vectorSearch first; falls back to in-memory similarity.
|
|
@@ -106,11 +146,16 @@ export async function deleteEmbedding (resourceId) {
|
|
|
106
146
|
* queryVector: number[],
|
|
107
147
|
* limit?: number,
|
|
108
148
|
* threshold?: number,
|
|
149
|
+
* kind?: string,
|
|
109
150
|
* }} opts
|
|
110
151
|
* @returns {Promise<Array<{ resourceId: string, score: number }>>}
|
|
111
152
|
*/
|
|
112
|
-
export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20, threshold = 0 }) {
|
|
153
|
+
export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20, threshold = 0, kind } = {}) {
|
|
154
|
+
if (!workspaceId || !Array.isArray(queryVector) || !queryVector.length) return []
|
|
155
|
+
const filter = embeddingSearchFilter({ workspaceId, kind })
|
|
156
|
+
const capped = Math.max(1, limit)
|
|
113
157
|
// Try Atlas Vector Search (requires index on Atlas M10+).
|
|
158
|
+
// An empty result is the index answer. A missing index throws and falls back.
|
|
114
159
|
try {
|
|
115
160
|
const pipeline = [
|
|
116
161
|
{
|
|
@@ -118,9 +163,9 @@ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20,
|
|
|
118
163
|
index: ATLAS_INDEX,
|
|
119
164
|
path: 'vector',
|
|
120
165
|
queryVector,
|
|
121
|
-
numCandidates:
|
|
122
|
-
limit,
|
|
123
|
-
filter
|
|
166
|
+
numCandidates: capped * 10,
|
|
167
|
+
limit: capped,
|
|
168
|
+
filter,
|
|
124
169
|
},
|
|
125
170
|
},
|
|
126
171
|
{
|
|
@@ -132,17 +177,14 @@ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20,
|
|
|
132
177
|
},
|
|
133
178
|
]
|
|
134
179
|
const results = await col().aggregate(pipeline).toArray()
|
|
135
|
-
|
|
136
|
-
return results.filter((r) => r.score >= threshold)
|
|
137
|
-
}
|
|
180
|
+
return results.filter((r) => r.score >= threshold)
|
|
138
181
|
} catch {
|
|
139
182
|
// Atlas Vector Search not available — fall through to cosine similarity.
|
|
140
183
|
}
|
|
141
184
|
|
|
142
|
-
// In-memory cosine similarity fallback.
|
|
143
185
|
log.debug('[VectorStore] Atlas $vectorSearch unavailable — using in-memory cosine similarity')
|
|
144
186
|
const docs = await col()
|
|
145
|
-
.find({ workspaceId })
|
|
187
|
+
.find(embeddingSearchFilter({ workspaceId, kind, includeMissingKind: true }))
|
|
146
188
|
.project({ resourceId: 1, vector: 1 })
|
|
147
189
|
.toArray()
|
|
148
190
|
|
|
@@ -153,7 +195,7 @@ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20,
|
|
|
153
195
|
}))
|
|
154
196
|
.filter((r) => r.score >= threshold)
|
|
155
197
|
.sort((a, b) => b.score - a.score)
|
|
156
|
-
.slice(0,
|
|
198
|
+
.slice(0, capped)
|
|
157
199
|
}
|
|
158
200
|
|
|
159
201
|
/**
|
|
@@ -178,5 +220,6 @@ export async function ensureEmbeddingIndexes () {
|
|
|
178
220
|
await c.createIndex({ resourceId: 1 }, { name: 'resourceId', unique: true, background: true })
|
|
179
221
|
await c.createIndex({ workspaceId: 1 }, { name: 'workspaceId', background: true })
|
|
180
222
|
await c.createIndex({ workspaceId: 1, updatedAt: -1 }, { name: 'workspaceId_updatedAt', background: true })
|
|
223
|
+
await c.createIndex({ workspaceId: 1, kind: 1 }, { name: 'workspaceId_kind', background: true })
|
|
181
224
|
log.info('[VectorStore] resource_embeddings indexes ensured')
|
|
182
225
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
jest.unstable_mockModule('@ossy/observability', () => ({
|
|
4
|
+
createLogger: () => ({ debug () {}, info () {}, warn () {}, error () {} }),
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
jest.unstable_mockModule('@ossy/event-store', () => ({
|
|
8
|
+
Mongo: { db: { collection () { return {} } } },
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
const { embeddingSearchFilter } = await import('./vector-store.js')
|
|
12
|
+
|
|
13
|
+
describe('embeddingSearchFilter', () => {
|
|
14
|
+
it('scopes by workspace and kind for Atlas-compatible filters', () => {
|
|
15
|
+
expect(embeddingSearchFilter({ workspaceId: 'ws-1', kind: 'resume' })).toEqual({
|
|
16
|
+
workspaceId: 'ws-1',
|
|
17
|
+
kind: 'resume',
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('includes legacy rows without kind for the in-memory fallback', () => {
|
|
22
|
+
expect(embeddingSearchFilter({
|
|
23
|
+
workspaceId: 'ws-1',
|
|
24
|
+
kind: 'resume',
|
|
25
|
+
includeMissingKind: true,
|
|
26
|
+
})).toEqual({
|
|
27
|
+
workspaceId: 'ws-1',
|
|
28
|
+
$or: [
|
|
29
|
+
{ kind: 'resume' },
|
|
30
|
+
{ kind: { $exists: false } },
|
|
31
|
+
{ kind: null },
|
|
32
|
+
],
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
})
|