@realtimex/folio 0.1.16 → 0.1.17
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/api/src/middleware/auth.ts +77 -0
- package/api/src/routes/chat.ts +7 -1
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/ingestions.ts +45 -5
- package/api/src/routes/policies.ts +50 -7
- package/api/src/routes/stats.ts +9 -5
- package/api/src/routes/workspaces.ts +290 -0
- package/api/src/services/ChatService.ts +8 -2
- package/api/src/services/IngestionService.ts +38 -26
- package/api/src/services/PolicyEngine.ts +4 -1
- package/api/src/services/PolicyLearningService.ts +31 -6
- package/api/src/services/PolicyLoader.ts +44 -25
- package/api/src/services/RAGService.ts +52 -12
- package/dist/api/src/middleware/auth.js +59 -0
- package/dist/api/src/routes/chat.js +1 -1
- package/dist/api/src/routes/index.js +2 -0
- package/dist/api/src/routes/ingestions.js +45 -8
- package/dist/api/src/routes/policies.js +49 -7
- package/dist/api/src/routes/stats.js +9 -5
- package/dist/api/src/routes/workspaces.js +220 -0
- package/dist/api/src/services/ChatService.js +7 -2
- package/dist/api/src/services/IngestionService.js +35 -30
- package/dist/api/src/services/PolicyEngine.js +2 -1
- package/dist/api/src/services/PolicyLearningService.js +28 -6
- package/dist/api/src/services/PolicyLoader.js +29 -25
- package/dist/api/src/services/RAGService.js +43 -11
- package/dist/assets/index-CTn5FcC4.js +113 -0
- package/dist/assets/index-Dq9sxoZK.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/supabase/functions/workspace-invite/index.ts +110 -0
- package/supabase/migrations/20260223000000_initial_foundation.sql +5 -0
- package/supabase/migrations/20260224000004_add_avatars_storage.sql +4 -0
- package/supabase/migrations/20260224000006_add_policies_table.sql +5 -0
- package/supabase/migrations/20260224000008_add_ingestions_table.sql +2 -0
- package/supabase/migrations/20260225000000_setup_compatible_mode.sql +17 -4
- package/supabase/migrations/20260225000003_add_baseline_configs.sql +4 -3
- package/supabase/migrations/20260226000000_add_processing_events.sql +1 -0
- package/supabase/migrations/20260226000002_add_dynamic_rag.sql +1 -0
- package/supabase/migrations/20260226000005_add_chat_tables.sql +3 -0
- package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +4 -0
- package/supabase/migrations/20260302064608_add_ingestion_llm_settings_compat.sql +15 -0
- package/supabase/migrations/20260303000000_add_workspaces_phase1.sql +459 -0
- package/supabase/migrations/20260303010000_add_workspace_management_rpc.sql +310 -0
- package/supabase/migrations/20260303020000_workspace_scope_document_chunks.sql +139 -0
- package/dist/assets/index-DzN8-j-e.css +0 -1
- package/dist/assets/index-dnBz6SWG.js +0 -113
|
@@ -276,11 +276,19 @@ export class PolicyLearningService {
|
|
|
276
276
|
static async recordManualMatch(opts: {
|
|
277
277
|
supabase: SupabaseClient;
|
|
278
278
|
userId: string;
|
|
279
|
+
workspaceId?: string;
|
|
279
280
|
ingestion: IngestionLike;
|
|
280
281
|
policyId: string;
|
|
281
282
|
policyName?: string;
|
|
282
283
|
}): Promise<void> {
|
|
283
|
-
const { supabase, userId, ingestion, policyId, policyName } = opts;
|
|
284
|
+
const { supabase, userId, workspaceId, ingestion, policyId, policyName } = opts;
|
|
285
|
+
if (!workspaceId) {
|
|
286
|
+
logger.warn("Skipping policy learning feedback: missing workspace context", {
|
|
287
|
+
ingestionId: ingestion.id,
|
|
288
|
+
policyId,
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
284
292
|
const features = buildFromIngestionRow(ingestion);
|
|
285
293
|
|
|
286
294
|
if (features.tokens.length === 0) {
|
|
@@ -292,6 +300,7 @@ export class PolicyLearningService {
|
|
|
292
300
|
}
|
|
293
301
|
|
|
294
302
|
const row = {
|
|
303
|
+
workspace_id: workspaceId,
|
|
295
304
|
user_id: userId,
|
|
296
305
|
ingestion_id: ingestion.id,
|
|
297
306
|
policy_id: policyId,
|
|
@@ -302,7 +311,7 @@ export class PolicyLearningService {
|
|
|
302
311
|
|
|
303
312
|
const { error } = await supabase
|
|
304
313
|
.from("policy_match_feedback")
|
|
305
|
-
.upsert(row, { onConflict: "
|
|
314
|
+
.upsert(row, { onConflict: "workspace_id,ingestion_id,policy_id" });
|
|
306
315
|
|
|
307
316
|
if (error) {
|
|
308
317
|
logger.error("Failed to save policy match feedback", {
|
|
@@ -323,15 +332,19 @@ export class PolicyLearningService {
|
|
|
323
332
|
static async getPolicyLearningStats(opts: {
|
|
324
333
|
supabase: SupabaseClient;
|
|
325
334
|
userId: string;
|
|
335
|
+
workspaceId?: string;
|
|
326
336
|
policyIds?: string[];
|
|
327
337
|
}): Promise<PolicyLearningStats> {
|
|
328
|
-
const { supabase, userId } = opts;
|
|
338
|
+
const { supabase, userId, workspaceId } = opts;
|
|
339
|
+
if (!workspaceId) {
|
|
340
|
+
return {};
|
|
341
|
+
}
|
|
329
342
|
const normalizedPolicyIds = (opts.policyIds ?? []).map((id) => id.trim()).filter(Boolean);
|
|
330
343
|
|
|
331
344
|
let query = supabase
|
|
332
345
|
.from("policy_match_feedback")
|
|
333
346
|
.select("policy_id,created_at")
|
|
334
|
-
.eq("
|
|
347
|
+
.eq("workspace_id", workspaceId)
|
|
335
348
|
.order("created_at", { ascending: false })
|
|
336
349
|
.limit(5000);
|
|
337
350
|
|
|
@@ -366,12 +379,13 @@ export class PolicyLearningService {
|
|
|
366
379
|
static async resolveLearnedCandidate(opts: {
|
|
367
380
|
supabase: SupabaseClient;
|
|
368
381
|
userId: string;
|
|
382
|
+
workspaceId?: string;
|
|
369
383
|
policyIds: string[];
|
|
370
384
|
filePath: string;
|
|
371
385
|
baselineEntities: Record<string, unknown>;
|
|
372
386
|
documentText?: string;
|
|
373
387
|
}): Promise<LearnedCandidateResolution> {
|
|
374
|
-
const { supabase, userId, policyIds, filePath, baselineEntities, documentText } = opts;
|
|
388
|
+
const { supabase, userId, workspaceId, policyIds, filePath, baselineEntities, documentText } = opts;
|
|
375
389
|
if (policyIds.length === 0) {
|
|
376
390
|
return {
|
|
377
391
|
candidate: null,
|
|
@@ -383,6 +397,17 @@ export class PolicyLearningService {
|
|
|
383
397
|
},
|
|
384
398
|
};
|
|
385
399
|
}
|
|
400
|
+
if (!workspaceId) {
|
|
401
|
+
return {
|
|
402
|
+
candidate: null,
|
|
403
|
+
diagnostics: {
|
|
404
|
+
reason: "no_feedback_samples",
|
|
405
|
+
evaluatedPolicies: policyIds.length,
|
|
406
|
+
evaluatedSamples: 0,
|
|
407
|
+
topCandidates: [],
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
386
411
|
|
|
387
412
|
const docFeatures = buildFromDocInput({ filePath, baselineEntities, documentText });
|
|
388
413
|
if (docFeatures.tokens.length === 0) {
|
|
@@ -400,7 +425,7 @@ export class PolicyLearningService {
|
|
|
400
425
|
const { data, error } = await supabase
|
|
401
426
|
.from("policy_match_feedback")
|
|
402
427
|
.select("policy_id,policy_name,features")
|
|
403
|
-
.eq("
|
|
428
|
+
.eq("workspace_id", workspaceId)
|
|
404
429
|
.in("policy_id", policyIds)
|
|
405
430
|
.order("created_at", { ascending: false })
|
|
406
431
|
.limit(400);
|
|
@@ -63,7 +63,7 @@ export interface FolioPolicy {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
// ─── Cache ───────────────────────────────────────────────────────────────────
|
|
66
|
-
// Keyed by
|
|
66
|
+
// Keyed by workspace_id so one workspace's policies never bleed into another's.
|
|
67
67
|
|
|
68
68
|
const _cache = new Map<string, { policies: FolioPolicy[]; loadedAt: number }>();
|
|
69
69
|
const CACHE_TTL_MS = 30_000;
|
|
@@ -90,21 +90,27 @@ function rowToPolicy(row: any): FolioPolicy {
|
|
|
90
90
|
export class PolicyLoader {
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Load all policies for the
|
|
93
|
+
* Load all policies for the active workspace from Supabase.
|
|
94
94
|
* Returns [] if no Supabase client is provided (unauthenticated state).
|
|
95
95
|
*/
|
|
96
|
-
static async load(
|
|
96
|
+
static async load(
|
|
97
|
+
forceRefresh = false,
|
|
98
|
+
supabase?: SupabaseClient | null,
|
|
99
|
+
workspaceId?: string
|
|
100
|
+
): Promise<FolioPolicy[]> {
|
|
97
101
|
if (!supabase) {
|
|
98
102
|
logger.info("No Supabase client — policies require authentication");
|
|
99
103
|
return [];
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
const resolvedWorkspaceId = (workspaceId ?? "").trim();
|
|
107
|
+
if (!resolvedWorkspaceId) {
|
|
108
|
+
logger.warn("No workspace context — returning empty policy set");
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
105
111
|
|
|
106
112
|
const now = Date.now();
|
|
107
|
-
const cached = _cache.get(
|
|
113
|
+
const cached = _cache.get(resolvedWorkspaceId);
|
|
108
114
|
if (!forceRefresh && cached && now - cached.loadedAt < CACHE_TTL_MS) {
|
|
109
115
|
return cached.policies;
|
|
110
116
|
}
|
|
@@ -113,14 +119,15 @@ export class PolicyLoader {
|
|
|
113
119
|
const { data, error } = await supabase
|
|
114
120
|
.from("policies")
|
|
115
121
|
.select("*")
|
|
122
|
+
.eq("workspace_id", resolvedWorkspaceId)
|
|
116
123
|
.eq("enabled", true)
|
|
117
124
|
.order("priority", { ascending: false });
|
|
118
125
|
|
|
119
126
|
if (error) throw error;
|
|
120
127
|
|
|
121
128
|
const policies = (data ?? []).map(rowToPolicy);
|
|
122
|
-
_cache.set(
|
|
123
|
-
logger.info(`Loaded ${policies.length} policies from DB for
|
|
129
|
+
_cache.set(resolvedWorkspaceId, { policies, loadedAt: Date.now() });
|
|
130
|
+
logger.info(`Loaded ${policies.length} policies from DB for workspace ${resolvedWorkspaceId}`);
|
|
124
131
|
return policies;
|
|
125
132
|
} catch (err) {
|
|
126
133
|
logger.error("Failed to load policies from DB", { err });
|
|
@@ -128,9 +135,9 @@ export class PolicyLoader {
|
|
|
128
135
|
}
|
|
129
136
|
}
|
|
130
137
|
|
|
131
|
-
static invalidateCache(
|
|
132
|
-
if (
|
|
133
|
-
_cache.delete(
|
|
138
|
+
static invalidateCache(workspaceId?: string) {
|
|
139
|
+
if (workspaceId) {
|
|
140
|
+
_cache.delete(workspaceId);
|
|
134
141
|
} else {
|
|
135
142
|
_cache.clear();
|
|
136
143
|
}
|
|
@@ -152,12 +159,18 @@ export class PolicyLoader {
|
|
|
152
159
|
* Save (upsert) a policy to Supabase.
|
|
153
160
|
* Throws if no Supabase client is available.
|
|
154
161
|
*/
|
|
155
|
-
static async save(
|
|
156
|
-
|
|
162
|
+
static async save(
|
|
163
|
+
policy: FolioPolicy,
|
|
164
|
+
supabase?: SupabaseClient | null,
|
|
165
|
+
userId?: string,
|
|
166
|
+
workspaceId?: string
|
|
167
|
+
): Promise<string> {
|
|
168
|
+
if (!supabase || !userId || !workspaceId) {
|
|
157
169
|
throw new Error("Authentication required to save policies");
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
const row = {
|
|
173
|
+
workspace_id: workspaceId,
|
|
161
174
|
user_id: userId,
|
|
162
175
|
policy_id: policy.metadata.id,
|
|
163
176
|
api_version: policy.apiVersion,
|
|
@@ -170,11 +183,11 @@ export class PolicyLoader {
|
|
|
170
183
|
|
|
171
184
|
const { error } = await supabase
|
|
172
185
|
.from("policies")
|
|
173
|
-
.upsert(row, { onConflict: "
|
|
186
|
+
.upsert(row, { onConflict: "workspace_id,policy_id" });
|
|
174
187
|
|
|
175
188
|
if (error) throw new Error(`Failed to save policy: ${error.message}`);
|
|
176
189
|
|
|
177
|
-
this.invalidateCache();
|
|
190
|
+
this.invalidateCache(workspaceId);
|
|
178
191
|
logger.info(`Saved policy to DB: ${policy.metadata.id}`);
|
|
179
192
|
return `db:policies/${policy.metadata.id}`;
|
|
180
193
|
}
|
|
@@ -186,9 +199,10 @@ export class PolicyLoader {
|
|
|
186
199
|
policyId: string,
|
|
187
200
|
patch: { enabled?: boolean; name?: string; description?: string; tags?: string[]; priority?: number },
|
|
188
201
|
supabase?: SupabaseClient | null,
|
|
189
|
-
userId?: string
|
|
202
|
+
userId?: string,
|
|
203
|
+
workspaceId?: string
|
|
190
204
|
): Promise<boolean> {
|
|
191
|
-
if (!supabase || !userId) {
|
|
205
|
+
if (!supabase || !userId || !workspaceId) {
|
|
192
206
|
throw new Error("Authentication required to update policies");
|
|
193
207
|
}
|
|
194
208
|
|
|
@@ -196,7 +210,7 @@ export class PolicyLoader {
|
|
|
196
210
|
.from("policies")
|
|
197
211
|
.select("metadata, priority, enabled")
|
|
198
212
|
.eq("policy_id", policyId)
|
|
199
|
-
.eq("
|
|
213
|
+
.eq("workspace_id", workspaceId)
|
|
200
214
|
.single();
|
|
201
215
|
|
|
202
216
|
if (fetchErr || !existing) throw new Error("Policy not found");
|
|
@@ -217,11 +231,11 @@ export class PolicyLoader {
|
|
|
217
231
|
priority: patch.priority ?? existing.priority,
|
|
218
232
|
})
|
|
219
233
|
.eq("policy_id", policyId)
|
|
220
|
-
.eq("
|
|
234
|
+
.eq("workspace_id", workspaceId);
|
|
221
235
|
|
|
222
236
|
if (error) throw new Error(`Failed to patch policy: ${error.message}`);
|
|
223
237
|
|
|
224
|
-
this.invalidateCache();
|
|
238
|
+
this.invalidateCache(workspaceId);
|
|
225
239
|
logger.info(`Patched policy: ${policyId}`);
|
|
226
240
|
return true;
|
|
227
241
|
}
|
|
@@ -230,8 +244,13 @@ export class PolicyLoader {
|
|
|
230
244
|
* Delete a policy by ID from Supabase.
|
|
231
245
|
* Throws if no Supabase client is available.
|
|
232
246
|
*/
|
|
233
|
-
static async delete(
|
|
234
|
-
|
|
247
|
+
static async delete(
|
|
248
|
+
policyId: string,
|
|
249
|
+
supabase?: SupabaseClient | null,
|
|
250
|
+
userId?: string,
|
|
251
|
+
workspaceId?: string
|
|
252
|
+
): Promise<boolean> {
|
|
253
|
+
if (!supabase || !userId || !workspaceId) {
|
|
235
254
|
throw new Error("Authentication required to delete policies");
|
|
236
255
|
}
|
|
237
256
|
|
|
@@ -239,11 +258,11 @@ export class PolicyLoader {
|
|
|
239
258
|
.from("policies")
|
|
240
259
|
.delete({ count: "exact" })
|
|
241
260
|
.eq("policy_id", policyId)
|
|
242
|
-
.eq("
|
|
261
|
+
.eq("workspace_id", workspaceId);
|
|
243
262
|
|
|
244
263
|
if (error) throw new Error(`Failed to delete policy: ${error.message}`);
|
|
245
264
|
|
|
246
|
-
this.invalidateCache();
|
|
265
|
+
this.invalidateCache(workspaceId);
|
|
247
266
|
return (count ?? 0) > 0;
|
|
248
267
|
}
|
|
249
268
|
}
|
|
@@ -133,7 +133,8 @@ export class RAGService {
|
|
|
133
133
|
userId: string,
|
|
134
134
|
rawText: string,
|
|
135
135
|
supabase: SupabaseClient,
|
|
136
|
-
settings?: EmbeddingSettings
|
|
136
|
+
settings?: EmbeddingSettings,
|
|
137
|
+
workspaceId?: string
|
|
137
138
|
): Promise<void> {
|
|
138
139
|
if (/^\[VLM_(IMAGE|PDF)_DATA:/.test(rawText)) {
|
|
139
140
|
logger.info(`Skipping chunking and embedding for VLM base64 multimodal data (Ingestion: ${ingestionId})`);
|
|
@@ -147,6 +148,22 @@ export class RAGService {
|
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
const resolvedModel = await this.resolveEmbeddingModel(settings || {});
|
|
151
|
+
let resolvedWorkspaceId = (workspaceId ?? "").trim();
|
|
152
|
+
if (!resolvedWorkspaceId) {
|
|
153
|
+
const { data: ingestionRow, error: ingestionLookupError } = await supabase
|
|
154
|
+
.from("ingestions")
|
|
155
|
+
.select("workspace_id")
|
|
156
|
+
.eq("id", ingestionId)
|
|
157
|
+
.maybeSingle();
|
|
158
|
+
if (ingestionLookupError) {
|
|
159
|
+
throw new Error(`Failed to resolve workspace for ingestion ${ingestionId}: ${ingestionLookupError.message}`);
|
|
160
|
+
}
|
|
161
|
+
resolvedWorkspaceId = String(ingestionRow?.workspace_id ?? "").trim();
|
|
162
|
+
if (!resolvedWorkspaceId) {
|
|
163
|
+
throw new Error(`Workspace context is required to index chunks for ingestion ${ingestionId}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
150
167
|
logger.info(
|
|
151
168
|
`Extracted ${chunks.length} chunks for ingestion ${ingestionId}. Embedding with ${resolvedModel.provider}/${resolvedModel.model}...`
|
|
152
169
|
);
|
|
@@ -164,6 +181,7 @@ export class RAGService {
|
|
|
164
181
|
const { data: existing } = await supabase
|
|
165
182
|
.from("document_chunks")
|
|
166
183
|
.select("id")
|
|
184
|
+
.eq("workspace_id", resolvedWorkspaceId)
|
|
167
185
|
.eq("ingestion_id", ingestionId)
|
|
168
186
|
.eq("content_hash", hash)
|
|
169
187
|
.eq("embedding_provider", resolvedModel.provider)
|
|
@@ -181,6 +199,7 @@ export class RAGService {
|
|
|
181
199
|
const vector_dim = embedding.length;
|
|
182
200
|
|
|
183
201
|
const { error } = await supabase.from("document_chunks").insert({
|
|
202
|
+
workspace_id: resolvedWorkspaceId,
|
|
184
203
|
user_id: userId,
|
|
185
204
|
ingestion_id: ingestionId,
|
|
186
205
|
content,
|
|
@@ -212,6 +231,7 @@ export class RAGService {
|
|
|
212
231
|
*/
|
|
213
232
|
private static async runSearchForModel(args: {
|
|
214
233
|
userId: string;
|
|
234
|
+
workspaceId?: string;
|
|
215
235
|
supabase: SupabaseClient;
|
|
216
236
|
modelScope: ModelScope;
|
|
217
237
|
queryEmbedding: number[];
|
|
@@ -219,17 +239,26 @@ export class RAGService {
|
|
|
219
239
|
similarityThreshold: number;
|
|
220
240
|
topK: number;
|
|
221
241
|
}): Promise<RetrievedChunk[]> {
|
|
222
|
-
const { userId, supabase, modelScope, queryEmbedding, queryDim, similarityThreshold, topK } = args;
|
|
242
|
+
const { userId, workspaceId, supabase, modelScope, queryEmbedding, queryDim, similarityThreshold, topK } = args;
|
|
223
243
|
|
|
224
|
-
const
|
|
225
|
-
p_user_id: userId,
|
|
244
|
+
const basePayload = {
|
|
226
245
|
p_embedding_provider: modelScope.provider,
|
|
227
246
|
p_embedding_model: modelScope.model,
|
|
228
247
|
query_embedding: queryEmbedding,
|
|
229
248
|
match_threshold: similarityThreshold,
|
|
230
249
|
match_count: topK,
|
|
231
250
|
query_dim: queryDim
|
|
232
|
-
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const { data, error } = workspaceId
|
|
254
|
+
? await supabase.rpc("search_workspace_documents", {
|
|
255
|
+
p_workspace_id: workspaceId,
|
|
256
|
+
...basePayload
|
|
257
|
+
})
|
|
258
|
+
: await supabase.rpc("search_documents", {
|
|
259
|
+
p_user_id: userId,
|
|
260
|
+
...basePayload
|
|
261
|
+
});
|
|
233
262
|
|
|
234
263
|
if (error) {
|
|
235
264
|
throw new Error(`Knowledge base search failed for ${modelScope.provider}/${modelScope.model}: ${error.message}`);
|
|
@@ -238,19 +267,27 @@ export class RAGService {
|
|
|
238
267
|
return (data || []) as RetrievedChunk[];
|
|
239
268
|
}
|
|
240
269
|
|
|
241
|
-
private static async
|
|
270
|
+
private static async listModelScopes(
|
|
242
271
|
userId: string,
|
|
243
|
-
supabase: SupabaseClient
|
|
272
|
+
supabase: SupabaseClient,
|
|
273
|
+
workspaceId?: string
|
|
244
274
|
): Promise<ModelScope[]> {
|
|
245
|
-
|
|
275
|
+
let query = supabase
|
|
246
276
|
.from("document_chunks")
|
|
247
277
|
.select("embedding_provider, embedding_model, vector_dim, created_at")
|
|
248
|
-
.eq("user_id", userId)
|
|
249
278
|
.order("created_at", { ascending: false })
|
|
250
279
|
.limit(2000);
|
|
251
280
|
|
|
281
|
+
if (workspaceId) {
|
|
282
|
+
query = query.eq("workspace_id", workspaceId);
|
|
283
|
+
} else {
|
|
284
|
+
query = query.eq("user_id", userId);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const { data, error } = await query;
|
|
288
|
+
|
|
252
289
|
if (error) {
|
|
253
|
-
logger.warn("Failed to list
|
|
290
|
+
logger.warn("Failed to list embedding scopes for RAG fallback", { userId, workspaceId, error });
|
|
254
291
|
return [];
|
|
255
292
|
}
|
|
256
293
|
|
|
@@ -284,12 +321,14 @@ export class RAGService {
|
|
|
284
321
|
topK?: number;
|
|
285
322
|
similarityThreshold?: number;
|
|
286
323
|
settings?: EmbeddingSettings;
|
|
324
|
+
workspaceId?: string;
|
|
287
325
|
} = {}
|
|
288
326
|
): Promise<RetrievedChunk[]> {
|
|
289
327
|
const {
|
|
290
328
|
topK = 5,
|
|
291
329
|
similarityThreshold = 0.7,
|
|
292
|
-
settings
|
|
330
|
+
settings,
|
|
331
|
+
workspaceId
|
|
293
332
|
} = options;
|
|
294
333
|
|
|
295
334
|
const minThreshold = Math.max(0.1, Math.min(similarityThreshold, 0.4));
|
|
@@ -325,6 +364,7 @@ export class RAGService {
|
|
|
325
364
|
|
|
326
365
|
const hits = await this.runSearchForModel({
|
|
327
366
|
userId,
|
|
367
|
+
workspaceId,
|
|
328
368
|
supabase,
|
|
329
369
|
modelScope: scope,
|
|
330
370
|
queryEmbedding,
|
|
@@ -363,7 +403,7 @@ export class RAGService {
|
|
|
363
403
|
}
|
|
364
404
|
|
|
365
405
|
if (collected.size === 0) {
|
|
366
|
-
const scopes = await this.
|
|
406
|
+
const scopes = await this.listModelScopes(userId, supabase, workspaceId);
|
|
367
407
|
const fallbackScopes = scopes.filter(
|
|
368
408
|
(scope) => !(scope.provider === preferredScope.provider && scope.model === preferredScope.model)
|
|
369
409
|
);
|
|
@@ -14,6 +14,55 @@ function resolveSupabaseConfig(req) {
|
|
|
14
14
|
}
|
|
15
15
|
return headerConfig;
|
|
16
16
|
}
|
|
17
|
+
function resolvePreferredWorkspaceId(req) {
|
|
18
|
+
const raw = req.headers["x-workspace-id"];
|
|
19
|
+
if (typeof raw === "string") {
|
|
20
|
+
const trimmed = raw.trim();
|
|
21
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(raw) && typeof raw[0] === "string") {
|
|
24
|
+
const trimmed = raw[0].trim();
|
|
25
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
async function resolveWorkspaceContext(req, supabase, user) {
|
|
30
|
+
const preferredWorkspaceId = resolvePreferredWorkspaceId(req);
|
|
31
|
+
const { data, error } = await supabase
|
|
32
|
+
.from("workspace_members")
|
|
33
|
+
.select("workspace_id,role,status,created_at")
|
|
34
|
+
.eq("user_id", user.id)
|
|
35
|
+
.eq("status", "active")
|
|
36
|
+
.order("created_at", { ascending: true });
|
|
37
|
+
if (error) {
|
|
38
|
+
const errorCode = error.code;
|
|
39
|
+
// Backward compatibility: allow projects that haven't migrated yet.
|
|
40
|
+
if (errorCode === "42P01") {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
throw new AuthorizationError(`Failed to resolve workspace membership: ${error.message}`);
|
|
44
|
+
}
|
|
45
|
+
const memberships = (data ?? []);
|
|
46
|
+
if (memberships.length === 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (preferredWorkspaceId) {
|
|
50
|
+
const preferred = memberships.find((membership) => membership.workspace_id === preferredWorkspaceId);
|
|
51
|
+
if (preferred) {
|
|
52
|
+
return {
|
|
53
|
+
workspaceId: preferred.workspace_id,
|
|
54
|
+
workspaceRole: preferred.role,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const active = memberships[0];
|
|
59
|
+
if (!active)
|
|
60
|
+
return null;
|
|
61
|
+
return {
|
|
62
|
+
workspaceId: active.workspace_id,
|
|
63
|
+
workspaceRole: active.role,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
17
66
|
export async function authMiddleware(req, _res, next) {
|
|
18
67
|
try {
|
|
19
68
|
const supabaseConfig = resolveSupabaseConfig(req);
|
|
@@ -38,6 +87,11 @@ export async function authMiddleware(req, _res, next) {
|
|
|
38
87
|
});
|
|
39
88
|
req.user = user;
|
|
40
89
|
req.supabase = supabase;
|
|
90
|
+
const workspace = await resolveWorkspaceContext(req, supabase, user);
|
|
91
|
+
if (workspace) {
|
|
92
|
+
req.workspaceId = workspace.workspaceId;
|
|
93
|
+
req.workspaceRole = workspace.workspaceRole;
|
|
94
|
+
}
|
|
41
95
|
Logger.setPersistence(supabase, user.id);
|
|
42
96
|
return next();
|
|
43
97
|
}
|
|
@@ -59,6 +113,11 @@ export async function authMiddleware(req, _res, next) {
|
|
|
59
113
|
}
|
|
60
114
|
req.user = user;
|
|
61
115
|
req.supabase = supabase;
|
|
116
|
+
const workspace = await resolveWorkspaceContext(req, supabase, user);
|
|
117
|
+
if (workspace) {
|
|
118
|
+
req.workspaceId = workspace.workspaceId;
|
|
119
|
+
req.workspaceRole = workspace.workspaceRole;
|
|
120
|
+
}
|
|
62
121
|
Logger.setPersistence(supabase, user.id);
|
|
63
122
|
next();
|
|
64
123
|
}
|
|
@@ -103,7 +103,7 @@ router.post("/message", asyncHandler(async (req, res) => {
|
|
|
103
103
|
await req.supabase.from("chat_sessions").update({ title }).eq("id", normalizedSessionId);
|
|
104
104
|
}
|
|
105
105
|
try {
|
|
106
|
-
const aiMessage = await ChatService.handleMessage(normalizedSessionId, req.user.id, trimmedContent, req.supabase);
|
|
106
|
+
const aiMessage = await ChatService.handleMessage(normalizedSessionId, req.user.id, trimmedContent, req.supabase, req.workspaceId);
|
|
107
107
|
res.json({ success: true, message: aiMessage });
|
|
108
108
|
}
|
|
109
109
|
catch (error) {
|
|
@@ -13,6 +13,7 @@ import settingsRoutes from "./settings.js";
|
|
|
13
13
|
import rulesRoutes from "./rules.js";
|
|
14
14
|
import chatRoutes from "./chat.js";
|
|
15
15
|
import statsRoutes from "./stats.js";
|
|
16
|
+
import workspaceRoutes from "./workspaces.js";
|
|
16
17
|
const router = Router();
|
|
17
18
|
router.use("/health", healthRoutes);
|
|
18
19
|
router.use("/migrate", migrateRoutes);
|
|
@@ -28,4 +29,5 @@ router.use("/settings", settingsRoutes);
|
|
|
28
29
|
router.use("/rules", rulesRoutes);
|
|
29
30
|
router.use("/chat", chatRoutes);
|
|
30
31
|
router.use("/stats", statsRoutes);
|
|
32
|
+
router.use("/workspaces", workspaceRoutes);
|
|
31
33
|
export default router;
|
|
@@ -17,10 +17,14 @@ router.get("/", asyncHandler(async (req, res) => {
|
|
|
17
17
|
res.status(401).json({ success: false, error: "Authentication required" });
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
|
+
if (!req.workspaceId) {
|
|
21
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
20
24
|
const page = Math.max(1, parseInt(req.query["page"]) || 1);
|
|
21
25
|
const pageSize = Math.min(100, Math.max(1, parseInt(req.query["pageSize"]) || 20));
|
|
22
26
|
const query = req.query["q"]?.trim() || undefined;
|
|
23
|
-
const { ingestions, total } = await IngestionService.list(req.supabase, req.
|
|
27
|
+
const { ingestions, total } = await IngestionService.list(req.supabase, req.workspaceId, { page, pageSize, query });
|
|
24
28
|
res.json({ success: true, ingestions, total, page, pageSize });
|
|
25
29
|
}));
|
|
26
30
|
// GET /api/ingestions/:id — get single ingestion
|
|
@@ -29,7 +33,11 @@ router.get("/:id", asyncHandler(async (req, res) => {
|
|
|
29
33
|
res.status(401).json({ success: false, error: "Authentication required" });
|
|
30
34
|
return;
|
|
31
35
|
}
|
|
32
|
-
|
|
36
|
+
if (!req.workspaceId) {
|
|
37
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const ingestion = await IngestionService.get(req.params["id"], req.supabase, req.workspaceId);
|
|
33
41
|
if (!ingestion) {
|
|
34
42
|
res.status(404).json({ success: false, error: "Not found" });
|
|
35
43
|
return;
|
|
@@ -42,6 +50,10 @@ router.post("/upload", upload.single("file"), asyncHandler(async (req, res) => {
|
|
|
42
50
|
res.status(401).json({ success: false, error: "Authentication required" });
|
|
43
51
|
return;
|
|
44
52
|
}
|
|
53
|
+
if (!req.workspaceId) {
|
|
54
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
45
57
|
const file = req.file;
|
|
46
58
|
if (!file) {
|
|
47
59
|
res.status(400).json({ success: false, error: "No file uploaded" });
|
|
@@ -70,6 +82,7 @@ router.post("/upload", upload.single("file"), asyncHandler(async (req, res) => {
|
|
|
70
82
|
const ingestion = await IngestionService.ingest({
|
|
71
83
|
supabase: req.supabase,
|
|
72
84
|
userId: req.user.id,
|
|
85
|
+
workspaceId: req.workspaceId,
|
|
73
86
|
filename: file.originalname,
|
|
74
87
|
mimeType: file.mimetype,
|
|
75
88
|
fileSize: file.size,
|
|
@@ -86,7 +99,11 @@ router.post("/:id/rerun", asyncHandler(async (req, res) => {
|
|
|
86
99
|
res.status(401).json({ success: false, error: "Authentication required" });
|
|
87
100
|
return;
|
|
88
101
|
}
|
|
89
|
-
|
|
102
|
+
if (!req.workspaceId) {
|
|
103
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const matched = await IngestionService.rerun(req.params["id"], req.supabase, req.user.id, req.workspaceId);
|
|
90
107
|
res.json({ success: true, matched });
|
|
91
108
|
}));
|
|
92
109
|
// POST /api/ingestions/:id/match — manually assign a policy and optionally learn from it
|
|
@@ -95,6 +112,10 @@ router.post("/:id/match", asyncHandler(async (req, res) => {
|
|
|
95
112
|
res.status(401).json({ success: false, error: "Authentication required" });
|
|
96
113
|
return;
|
|
97
114
|
}
|
|
115
|
+
if (!req.workspaceId) {
|
|
116
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
98
119
|
const policyId = typeof req.body?.policy_id === "string" ? req.body.policy_id.trim() : "";
|
|
99
120
|
if (!policyId) {
|
|
100
121
|
res.status(400).json({ success: false, error: "policy_id is required" });
|
|
@@ -104,7 +125,7 @@ router.post("/:id/match", asyncHandler(async (req, res) => {
|
|
|
104
125
|
const rerun = req.body?.rerun !== false;
|
|
105
126
|
const allowSideEffects = req.body?.allow_side_effects === true;
|
|
106
127
|
try {
|
|
107
|
-
const ingestion = await IngestionService.matchToPolicy(req.params["id"], policyId, req.supabase, req.user.id, {
|
|
128
|
+
const ingestion = await IngestionService.matchToPolicy(req.params["id"], policyId, req.supabase, req.user.id, req.workspaceId, {
|
|
108
129
|
learn,
|
|
109
130
|
rerun,
|
|
110
131
|
allowSideEffects,
|
|
@@ -130,13 +151,17 @@ router.post("/:id/refine-policy", asyncHandler(async (req, res) => {
|
|
|
130
151
|
res.status(401).json({ success: false, error: "Authentication required" });
|
|
131
152
|
return;
|
|
132
153
|
}
|
|
154
|
+
if (!req.workspaceId) {
|
|
155
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
133
158
|
const policyId = typeof req.body?.policy_id === "string" ? req.body.policy_id.trim() : "";
|
|
134
159
|
if (!policyId) {
|
|
135
160
|
res.status(400).json({ success: false, error: "policy_id is required" });
|
|
136
161
|
return;
|
|
137
162
|
}
|
|
138
163
|
try {
|
|
139
|
-
const suggestion = await IngestionService.suggestPolicyRefinement(req.params["id"], policyId, req.supabase, req.user.id, {
|
|
164
|
+
const suggestion = await IngestionService.suggestPolicyRefinement(req.params["id"], policyId, req.supabase, req.user.id, req.workspaceId, {
|
|
140
165
|
provider: typeof req.body?.provider === "string" ? req.body.provider : undefined,
|
|
141
166
|
model: typeof req.body?.model === "string" ? req.body.model : undefined,
|
|
142
167
|
});
|
|
@@ -161,13 +186,17 @@ router.post("/:id/summarize", asyncHandler(async (req, res) => {
|
|
|
161
186
|
res.status(401).json({ success: false, error: "Authentication required" });
|
|
162
187
|
return;
|
|
163
188
|
}
|
|
189
|
+
if (!req.workspaceId) {
|
|
190
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
164
193
|
const { data: settingsRow } = await req.supabase
|
|
165
194
|
.from("user_settings")
|
|
166
195
|
.select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model")
|
|
167
196
|
.eq("user_id", req.user.id)
|
|
168
197
|
.maybeSingle();
|
|
169
198
|
const llmSettings = IngestionService.resolveIngestionLlmSettings(settingsRow);
|
|
170
|
-
const summary = await IngestionService.summarize(req.params["id"], req.supabase, req.user.id, llmSettings);
|
|
199
|
+
const summary = await IngestionService.summarize(req.params["id"], req.supabase, req.user.id, req.workspaceId, llmSettings);
|
|
171
200
|
res.json({ success: true, summary });
|
|
172
201
|
}));
|
|
173
202
|
// PATCH /api/ingestions/:id/tags — replace tags array (human edits)
|
|
@@ -176,6 +205,10 @@ router.patch("/:id/tags", asyncHandler(async (req, res) => {
|
|
|
176
205
|
res.status(401).json({ success: false, error: "Authentication required" });
|
|
177
206
|
return;
|
|
178
207
|
}
|
|
208
|
+
if (!req.workspaceId) {
|
|
209
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
179
212
|
const tags = req.body?.tags;
|
|
180
213
|
if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string")) {
|
|
181
214
|
res.status(400).json({ success: false, error: "tags must be an array of strings" });
|
|
@@ -186,7 +219,7 @@ router.patch("/:id/tags", asyncHandler(async (req, res) => {
|
|
|
186
219
|
.from("ingestions")
|
|
187
220
|
.update({ tags: normalized })
|
|
188
221
|
.eq("id", req.params["id"])
|
|
189
|
-
.eq("
|
|
222
|
+
.eq("workspace_id", req.workspaceId);
|
|
190
223
|
if (error) {
|
|
191
224
|
res.status(500).json({ success: false, error: error.message });
|
|
192
225
|
return;
|
|
@@ -199,7 +232,11 @@ router.delete("/:id", asyncHandler(async (req, res) => {
|
|
|
199
232
|
res.status(401).json({ success: false, error: "Authentication required" });
|
|
200
233
|
return;
|
|
201
234
|
}
|
|
202
|
-
|
|
235
|
+
if (!req.workspaceId) {
|
|
236
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const deleted = await IngestionService.delete(req.params["id"], req.supabase, req.workspaceId);
|
|
203
240
|
if (!deleted) {
|
|
204
241
|
res.status(404).json({ success: false, error: "Not found" });
|
|
205
242
|
return;
|