@realtimex/folio 0.1.15 → 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 +51 -6
- 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/api/src/services/SDKService.ts +48 -2
- 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 +51 -9
- 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/api/src/services/SDKService.js +37 -2
- package/dist/assets/index-CTn5FcC4.js +113 -0
- package/dist/assets/index-Dq9sxoZK.css +1 -0
- package/dist/index.html +2 -2
- package/docs-dev/ingestion-engine.md +3 -3
- 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-Cj989Mcp.js +0 -113
- package/dist/assets/index-DzN8-j-e.css +0 -1
|
@@ -161,7 +161,7 @@ export class IngestionService {
|
|
|
161
161
|
action: "Queued synthetic VLM embedding",
|
|
162
162
|
...details,
|
|
163
163
|
}, opts.supabase);
|
|
164
|
-
RAGService.chunkAndEmbed(opts.ingestionId, opts.userId, syntheticText, opts.supabase, opts.embedSettings).then(() => {
|
|
164
|
+
RAGService.chunkAndEmbed(opts.ingestionId, opts.userId, syntheticText, opts.supabase, opts.embedSettings, opts.workspaceId).then(() => {
|
|
165
165
|
Actuator.logEvent(opts.ingestionId, opts.userId, "analysis", "RAG Embedding", {
|
|
166
166
|
action: "Completed synthetic VLM embedding",
|
|
167
167
|
...details,
|
|
@@ -257,13 +257,13 @@ export class IngestionService {
|
|
|
257
257
|
* Ingest a document using Hybrid Routing Architecture.
|
|
258
258
|
*/
|
|
259
259
|
static async ingest(opts) {
|
|
260
|
-
const { supabase, userId, filename, mimeType, fileSize, source = "upload", filePath, content, fileHash } = opts;
|
|
260
|
+
const { supabase, userId, workspaceId, filename, mimeType, fileSize, source = "upload", filePath, content, fileHash } = opts;
|
|
261
261
|
// Duplicate detection — check if this exact file content was already ingested
|
|
262
262
|
if (fileHash) {
|
|
263
263
|
const { data: existing } = await supabase
|
|
264
264
|
.from("ingestions")
|
|
265
265
|
.select("id, filename, created_at")
|
|
266
|
-
.eq("
|
|
266
|
+
.eq("workspace_id", workspaceId)
|
|
267
267
|
.eq("file_hash", fileHash)
|
|
268
268
|
.eq("status", "matched")
|
|
269
269
|
.order("created_at", { ascending: true })
|
|
@@ -274,6 +274,7 @@ export class IngestionService {
|
|
|
274
274
|
const { data: dupIngestion } = await supabase
|
|
275
275
|
.from("ingestions")
|
|
276
276
|
.insert({
|
|
277
|
+
workspace_id: workspaceId,
|
|
277
278
|
user_id: userId,
|
|
278
279
|
source,
|
|
279
280
|
filename,
|
|
@@ -293,6 +294,7 @@ export class IngestionService {
|
|
|
293
294
|
const { data: ingestion, error: insertErr } = await supabase
|
|
294
295
|
.from("ingestions")
|
|
295
296
|
.insert({
|
|
297
|
+
workspace_id: workspaceId,
|
|
296
298
|
user_id: userId,
|
|
297
299
|
source,
|
|
298
300
|
filename,
|
|
@@ -397,7 +399,7 @@ export class IngestionService {
|
|
|
397
399
|
try {
|
|
398
400
|
// 3. Fast Path — fetch all dependencies in parallel
|
|
399
401
|
const [userPolicies, processingSettingsRow, baselineConfig] = await Promise.all([
|
|
400
|
-
PolicyLoader.load(false, supabase),
|
|
402
|
+
PolicyLoader.load(false, supabase, workspaceId),
|
|
401
403
|
supabase.from("user_settings").select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model, embedding_provider, embedding_model").eq("user_id", userId).maybeSingle(),
|
|
402
404
|
BaselineConfigService.getActive(supabase, userId),
|
|
403
405
|
]);
|
|
@@ -409,11 +411,11 @@ export class IngestionService {
|
|
|
409
411
|
const resolvedProvider = llmSettings.llm_provider ?? llmProvider;
|
|
410
412
|
const resolvedModel = llmSettings.llm_model ?? llmModel;
|
|
411
413
|
const runFastPathAttempt = async (attemptContent, attemptType) => {
|
|
412
|
-
const doc = { filePath: filePath, text: attemptContent, ingestionId: ingestion.id, userId, supabase };
|
|
414
|
+
const doc = { filePath: filePath, text: attemptContent, ingestionId: ingestion.id, userId, workspaceId, supabase };
|
|
413
415
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
414
416
|
const baselineTrace = [];
|
|
415
417
|
// Fire and forget Semantic Embedding Storage
|
|
416
|
-
RAGService.chunkAndEmbed(ingestion.id, userId, doc.text, supabase, embedSettings).catch(err => {
|
|
418
|
+
RAGService.chunkAndEmbed(ingestion.id, userId, doc.text, supabase, embedSettings, workspaceId).catch(err => {
|
|
417
419
|
logger.error(`RAG embedding failed for ${ingestion.id}`, err);
|
|
418
420
|
});
|
|
419
421
|
// 4. Stage 1: Baseline extraction (always runs, LLM call 1 of max 2)
|
|
@@ -482,6 +484,7 @@ export class IngestionService {
|
|
|
482
484
|
const embeddingMeta = this.queueVlmSemanticEmbedding({
|
|
483
485
|
ingestionId: ingestion.id,
|
|
484
486
|
userId,
|
|
487
|
+
workspaceId,
|
|
485
488
|
filename,
|
|
486
489
|
finalStatus,
|
|
487
490
|
policyName,
|
|
@@ -646,12 +649,12 @@ export class IngestionService {
|
|
|
646
649
|
/**
|
|
647
650
|
* Re-run an existing ingestion
|
|
648
651
|
*/
|
|
649
|
-
static async rerun(ingestionId, supabase, userId, opts = {}) {
|
|
652
|
+
static async rerun(ingestionId, supabase, userId, workspaceId, opts = {}) {
|
|
650
653
|
const { data: ingestion, error } = await supabase
|
|
651
654
|
.from("ingestions")
|
|
652
655
|
.select("*")
|
|
653
656
|
.eq("id", ingestionId)
|
|
654
|
-
.eq("
|
|
657
|
+
.eq("workspace_id", workspaceId)
|
|
655
658
|
.single();
|
|
656
659
|
if (error || !ingestion)
|
|
657
660
|
throw new Error("Ingestion not found");
|
|
@@ -747,7 +750,7 @@ export class IngestionService {
|
|
|
747
750
|
}
|
|
748
751
|
if (isFastPath) {
|
|
749
752
|
const [userPolicies, processingSettingsRow, baselineConfig] = await Promise.all([
|
|
750
|
-
PolicyLoader.load(false, supabase),
|
|
753
|
+
PolicyLoader.load(false, supabase, workspaceId),
|
|
751
754
|
supabase.from("user_settings").select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model, embedding_provider, embedding_model").eq("user_id", userId).maybeSingle(),
|
|
752
755
|
BaselineConfigService.getActive(supabase, userId),
|
|
753
756
|
]);
|
|
@@ -759,11 +762,11 @@ export class IngestionService {
|
|
|
759
762
|
const resolvedProvider = llmSettings.llm_provider ?? llmProvider;
|
|
760
763
|
const resolvedModel = llmSettings.llm_model ?? llmModel;
|
|
761
764
|
const runFastPathAttempt = async (attemptContent, attemptType) => {
|
|
762
|
-
const doc = { filePath, text: attemptContent, ingestionId, userId, supabase };
|
|
765
|
+
const doc = { filePath, text: attemptContent, ingestionId, userId, workspaceId, supabase };
|
|
763
766
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
764
767
|
const baselineTrace = [];
|
|
765
768
|
// Fire and forget Semantic Embedding Storage for re-runs
|
|
766
|
-
RAGService.chunkAndEmbed(ingestionId, userId, doc.text, supabase, embedSettings).catch(err => {
|
|
769
|
+
RAGService.chunkAndEmbed(ingestionId, userId, doc.text, supabase, embedSettings, workspaceId).catch(err => {
|
|
767
770
|
logger.error(`RAG embedding failed during rerun for ${ingestionId}`, err);
|
|
768
771
|
});
|
|
769
772
|
baselineTrace.push({
|
|
@@ -842,6 +845,7 @@ export class IngestionService {
|
|
|
842
845
|
const embeddingMeta = this.queueVlmSemanticEmbedding({
|
|
843
846
|
ingestionId,
|
|
844
847
|
userId,
|
|
848
|
+
workspaceId,
|
|
845
849
|
filename,
|
|
846
850
|
finalStatus,
|
|
847
851
|
policyName,
|
|
@@ -982,7 +986,7 @@ export class IngestionService {
|
|
|
982
986
|
* Manually assign an ingestion to a policy and optionally persist it as
|
|
983
987
|
* learning feedback for future automatic matching.
|
|
984
988
|
*/
|
|
985
|
-
static async matchToPolicy(ingestionId, policyId, supabase, userId, opts = {}) {
|
|
989
|
+
static async matchToPolicy(ingestionId, policyId, supabase, userId, workspaceId, opts = {}) {
|
|
986
990
|
const learn = opts.learn !== false;
|
|
987
991
|
const rerun = opts.rerun !== false;
|
|
988
992
|
const allowSideEffects = opts.allowSideEffects === true;
|
|
@@ -994,7 +998,7 @@ export class IngestionService {
|
|
|
994
998
|
.from("ingestions")
|
|
995
999
|
.select("*")
|
|
996
1000
|
.eq("id", ingestionId)
|
|
997
|
-
.eq("
|
|
1001
|
+
.eq("workspace_id", workspaceId)
|
|
998
1002
|
.single();
|
|
999
1003
|
if (ingestionError || !ingestion) {
|
|
1000
1004
|
throw new Error("Ingestion not found");
|
|
@@ -1002,7 +1006,7 @@ export class IngestionService {
|
|
|
1002
1006
|
if (ingestion.status === "processing" || ingestion.status === "pending") {
|
|
1003
1007
|
throw new Error("Cannot manually match while ingestion is still processing");
|
|
1004
1008
|
}
|
|
1005
|
-
const policies = await PolicyLoader.load(false, supabase);
|
|
1009
|
+
const policies = await PolicyLoader.load(false, supabase, workspaceId);
|
|
1006
1010
|
const policy = policies.find((item) => item.metadata.id === normalizedPolicyId);
|
|
1007
1011
|
if (!policy) {
|
|
1008
1012
|
throw new Error(`Policy "${normalizedPolicyId}" was not found or is disabled.`);
|
|
@@ -1021,8 +1025,8 @@ export class IngestionService {
|
|
|
1021
1025
|
learn,
|
|
1022
1026
|
risky_actions: riskyActions,
|
|
1023
1027
|
}, supabase);
|
|
1024
|
-
await this.rerun(ingestionId, supabase, userId, { forcedPolicyId: policy.metadata.id });
|
|
1025
|
-
const refreshed = await this.get(ingestionId, supabase,
|
|
1028
|
+
await this.rerun(ingestionId, supabase, userId, workspaceId, { forcedPolicyId: policy.metadata.id });
|
|
1029
|
+
const refreshed = await this.get(ingestionId, supabase, workspaceId);
|
|
1026
1030
|
if (!refreshed) {
|
|
1027
1031
|
throw new Error("Ingestion not found after rerun.");
|
|
1028
1032
|
}
|
|
@@ -1052,7 +1056,7 @@ export class IngestionService {
|
|
|
1052
1056
|
trace: nextTrace,
|
|
1053
1057
|
})
|
|
1054
1058
|
.eq("id", ingestionId)
|
|
1055
|
-
.eq("
|
|
1059
|
+
.eq("workspace_id", workspaceId)
|
|
1056
1060
|
.select("*")
|
|
1057
1061
|
.single();
|
|
1058
1062
|
if (updateError || !updatedIngestion) {
|
|
@@ -1071,6 +1075,7 @@ export class IngestionService {
|
|
|
1071
1075
|
await PolicyLearningService.recordManualMatch({
|
|
1072
1076
|
supabase,
|
|
1073
1077
|
userId,
|
|
1078
|
+
workspaceId,
|
|
1074
1079
|
ingestion: effectiveIngestion,
|
|
1075
1080
|
policyId: policy.metadata.id,
|
|
1076
1081
|
policyName: policy.metadata.name,
|
|
@@ -1082,7 +1087,7 @@ export class IngestionService {
|
|
|
1082
1087
|
* Generate a user-reviewable refinement draft for an existing policy
|
|
1083
1088
|
* using evidence from a specific ingestion.
|
|
1084
1089
|
*/
|
|
1085
|
-
static async suggestPolicyRefinement(ingestionId, policyId, supabase, userId, opts = {}) {
|
|
1090
|
+
static async suggestPolicyRefinement(ingestionId, policyId, supabase, userId, workspaceId, opts = {}) {
|
|
1086
1091
|
const normalizedPolicyId = policyId.trim();
|
|
1087
1092
|
if (!normalizedPolicyId) {
|
|
1088
1093
|
throw new Error("policy_id is required");
|
|
@@ -1091,12 +1096,12 @@ export class IngestionService {
|
|
|
1091
1096
|
.from("ingestions")
|
|
1092
1097
|
.select("id,filename,mime_type,status,tags,summary,extracted,trace")
|
|
1093
1098
|
.eq("id", ingestionId)
|
|
1094
|
-
.eq("
|
|
1099
|
+
.eq("workspace_id", workspaceId)
|
|
1095
1100
|
.single();
|
|
1096
1101
|
if (ingestionError || !ingestion) {
|
|
1097
1102
|
throw new Error("Ingestion not found");
|
|
1098
1103
|
}
|
|
1099
|
-
const policies = await PolicyLoader.load(false, supabase);
|
|
1104
|
+
const policies = await PolicyLoader.load(false, supabase, workspaceId);
|
|
1100
1105
|
const targetPolicy = policies.find((policy) => policy.metadata.id === normalizedPolicyId);
|
|
1101
1106
|
if (!targetPolicy) {
|
|
1102
1107
|
throw new Error(`Policy "${normalizedPolicyId}" was not found or is disabled.`);
|
|
@@ -1125,19 +1130,19 @@ export class IngestionService {
|
|
|
1125
1130
|
};
|
|
1126
1131
|
}
|
|
1127
1132
|
/**
|
|
1128
|
-
* List ingestions for
|
|
1133
|
+
* List ingestions for an active workspace, newest first.
|
|
1129
1134
|
* Supports server-side pagination and ILIKE search across native text columns
|
|
1130
1135
|
* (filename, policy_name, summary). Tags are handled client-side via the
|
|
1131
1136
|
* filter bar; extracted JSONB search requires a tsvector migration (deferred).
|
|
1132
1137
|
*/
|
|
1133
|
-
static async list(supabase,
|
|
1138
|
+
static async list(supabase, workspaceId, opts = {}) {
|
|
1134
1139
|
const { page = 1, pageSize = 20, query } = opts;
|
|
1135
1140
|
const from = (page - 1) * pageSize;
|
|
1136
1141
|
const to = from + pageSize - 1;
|
|
1137
1142
|
let q = supabase
|
|
1138
1143
|
.from("ingestions")
|
|
1139
1144
|
.select("*", { count: "exact" })
|
|
1140
|
-
.eq("
|
|
1145
|
+
.eq("workspace_id", workspaceId)
|
|
1141
1146
|
.order("created_at", { ascending: false });
|
|
1142
1147
|
if (query?.trim()) {
|
|
1143
1148
|
const term = `%${query.trim()}%`;
|
|
@@ -1156,24 +1161,24 @@ export class IngestionService {
|
|
|
1156
1161
|
/**
|
|
1157
1162
|
* Get a single ingestion by ID.
|
|
1158
1163
|
*/
|
|
1159
|
-
static async get(id, supabase,
|
|
1164
|
+
static async get(id, supabase, workspaceId) {
|
|
1160
1165
|
const { data } = await supabase
|
|
1161
1166
|
.from("ingestions")
|
|
1162
1167
|
.select("*")
|
|
1163
1168
|
.eq("id", id)
|
|
1164
|
-
.eq("
|
|
1169
|
+
.eq("workspace_id", workspaceId)
|
|
1165
1170
|
.single();
|
|
1166
1171
|
return data;
|
|
1167
1172
|
}
|
|
1168
1173
|
/**
|
|
1169
1174
|
* Delete an ingestion record.
|
|
1170
1175
|
*/
|
|
1171
|
-
static async delete(id, supabase,
|
|
1176
|
+
static async delete(id, supabase, workspaceId) {
|
|
1172
1177
|
const { count, error } = await supabase
|
|
1173
1178
|
.from("ingestions")
|
|
1174
1179
|
.delete({ count: "exact" })
|
|
1175
1180
|
.eq("id", id)
|
|
1176
|
-
.eq("
|
|
1181
|
+
.eq("workspace_id", workspaceId);
|
|
1177
1182
|
if (error)
|
|
1178
1183
|
throw new Error(`Failed to delete ingestion: ${error.message}`);
|
|
1179
1184
|
return (count ?? 0) > 0;
|
|
@@ -1183,12 +1188,12 @@ export class IngestionService {
|
|
|
1183
1188
|
* Builds the prompt from already-extracted entities — no file I/O needed.
|
|
1184
1189
|
* The result is saved back to ingestion.summary so subsequent calls are instant.
|
|
1185
1190
|
*/
|
|
1186
|
-
static async summarize(id, supabase, userId, llmSettings = {}) {
|
|
1191
|
+
static async summarize(id, supabase, userId, workspaceId, llmSettings = {}) {
|
|
1187
1192
|
const { data: ing } = await supabase
|
|
1188
1193
|
.from("ingestions")
|
|
1189
1194
|
.select("id, filename, extracted, summary, status")
|
|
1190
1195
|
.eq("id", id)
|
|
1191
|
-
.eq("
|
|
1196
|
+
.eq("workspace_id", workspaceId)
|
|
1192
1197
|
.single();
|
|
1193
1198
|
if (!ing)
|
|
1194
1199
|
throw new Error("Ingestion not found");
|
|
@@ -1244,7 +1249,7 @@ export class IngestionService {
|
|
|
1244
1249
|
.from("ingestions")
|
|
1245
1250
|
.update({ summary })
|
|
1246
1251
|
.eq("id", id)
|
|
1247
|
-
.eq("
|
|
1252
|
+
.eq("workspace_id", workspaceId);
|
|
1248
1253
|
logger.info(`Summary generated and cached for ingestion ${id}`);
|
|
1249
1254
|
return summary;
|
|
1250
1255
|
}
|
|
@@ -767,7 +767,7 @@ export class PolicyEngine {
|
|
|
767
767
|
*/
|
|
768
768
|
static async process(doc, settings = {}, baselineEntities = {}) {
|
|
769
769
|
logger.info(`Processing document: ${doc.filePath}`);
|
|
770
|
-
const policies = await PolicyLoader.load();
|
|
770
|
+
const policies = await PolicyLoader.load(false, doc.supabase, doc.workspaceId);
|
|
771
771
|
const globalTrace = [{ timestamp: new Date().toISOString(), step: "Loaded policies", details: { count: policies.length } }];
|
|
772
772
|
Actuator.logEvent(doc.ingestionId, doc.userId, "info", "Triage", { action: "Loaded policies", count: policies.length }, doc.supabase);
|
|
773
773
|
for (const policy of policies) {
|
|
@@ -841,6 +841,7 @@ export class PolicyEngine {
|
|
|
841
841
|
const learned = await PolicyLearningService.resolveLearnedCandidate({
|
|
842
842
|
supabase: doc.supabase,
|
|
843
843
|
userId: doc.userId,
|
|
844
|
+
workspaceId: doc.workspaceId,
|
|
844
845
|
policyIds: policies.map((policy) => policy.metadata.id),
|
|
845
846
|
filePath: doc.filePath,
|
|
846
847
|
baselineEntities,
|
|
@@ -182,7 +182,14 @@ function buildFromIngestionRow(ingestion) {
|
|
|
182
182
|
}
|
|
183
183
|
export class PolicyLearningService {
|
|
184
184
|
static async recordManualMatch(opts) {
|
|
185
|
-
const { supabase, userId, ingestion, policyId, policyName } = opts;
|
|
185
|
+
const { supabase, userId, workspaceId, ingestion, policyId, policyName } = opts;
|
|
186
|
+
if (!workspaceId) {
|
|
187
|
+
logger.warn("Skipping policy learning feedback: missing workspace context", {
|
|
188
|
+
ingestionId: ingestion.id,
|
|
189
|
+
policyId,
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
186
193
|
const features = buildFromIngestionRow(ingestion);
|
|
187
194
|
if (features.tokens.length === 0) {
|
|
188
195
|
logger.warn("Skipping policy learning feedback: no usable tokens", {
|
|
@@ -192,6 +199,7 @@ export class PolicyLearningService {
|
|
|
192
199
|
return;
|
|
193
200
|
}
|
|
194
201
|
const row = {
|
|
202
|
+
workspace_id: workspaceId,
|
|
195
203
|
user_id: userId,
|
|
196
204
|
ingestion_id: ingestion.id,
|
|
197
205
|
policy_id: policyId,
|
|
@@ -201,7 +209,7 @@ export class PolicyLearningService {
|
|
|
201
209
|
};
|
|
202
210
|
const { error } = await supabase
|
|
203
211
|
.from("policy_match_feedback")
|
|
204
|
-
.upsert(row, { onConflict: "
|
|
212
|
+
.upsert(row, { onConflict: "workspace_id,ingestion_id,policy_id" });
|
|
205
213
|
if (error) {
|
|
206
214
|
logger.error("Failed to save policy match feedback", {
|
|
207
215
|
ingestionId: ingestion.id,
|
|
@@ -217,12 +225,15 @@ export class PolicyLearningService {
|
|
|
217
225
|
});
|
|
218
226
|
}
|
|
219
227
|
static async getPolicyLearningStats(opts) {
|
|
220
|
-
const { supabase, userId } = opts;
|
|
228
|
+
const { supabase, userId, workspaceId } = opts;
|
|
229
|
+
if (!workspaceId) {
|
|
230
|
+
return {};
|
|
231
|
+
}
|
|
221
232
|
const normalizedPolicyIds = (opts.policyIds ?? []).map((id) => id.trim()).filter(Boolean);
|
|
222
233
|
let query = supabase
|
|
223
234
|
.from("policy_match_feedback")
|
|
224
235
|
.select("policy_id,created_at")
|
|
225
|
-
.eq("
|
|
236
|
+
.eq("workspace_id", workspaceId)
|
|
226
237
|
.order("created_at", { ascending: false })
|
|
227
238
|
.limit(5000);
|
|
228
239
|
if (normalizedPolicyIds.length > 0) {
|
|
@@ -251,7 +262,7 @@ export class PolicyLearningService {
|
|
|
251
262
|
return stats;
|
|
252
263
|
}
|
|
253
264
|
static async resolveLearnedCandidate(opts) {
|
|
254
|
-
const { supabase, userId, policyIds, filePath, baselineEntities, documentText } = opts;
|
|
265
|
+
const { supabase, userId, workspaceId, policyIds, filePath, baselineEntities, documentText } = opts;
|
|
255
266
|
if (policyIds.length === 0) {
|
|
256
267
|
return {
|
|
257
268
|
candidate: null,
|
|
@@ -263,6 +274,17 @@ export class PolicyLearningService {
|
|
|
263
274
|
},
|
|
264
275
|
};
|
|
265
276
|
}
|
|
277
|
+
if (!workspaceId) {
|
|
278
|
+
return {
|
|
279
|
+
candidate: null,
|
|
280
|
+
diagnostics: {
|
|
281
|
+
reason: "no_feedback_samples",
|
|
282
|
+
evaluatedPolicies: policyIds.length,
|
|
283
|
+
evaluatedSamples: 0,
|
|
284
|
+
topCandidates: [],
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
266
288
|
const docFeatures = buildFromDocInput({ filePath, baselineEntities, documentText });
|
|
267
289
|
if (docFeatures.tokens.length === 0) {
|
|
268
290
|
return {
|
|
@@ -278,7 +300,7 @@ export class PolicyLearningService {
|
|
|
278
300
|
const { data, error } = await supabase
|
|
279
301
|
.from("policy_match_feedback")
|
|
280
302
|
.select("policy_id,policy_name,features")
|
|
281
|
-
.eq("
|
|
303
|
+
.eq("workspace_id", workspaceId)
|
|
282
304
|
.in("policy_id", policyIds)
|
|
283
305
|
.order("created_at", { ascending: false })
|
|
284
306
|
.limit(400);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createLogger } from "../utils/logger.js";
|
|
2
2
|
const logger = createLogger("PolicyLoader");
|
|
3
3
|
// ─── Cache ───────────────────────────────────────────────────────────────────
|
|
4
|
-
// Keyed by
|
|
4
|
+
// Keyed by workspace_id so one workspace's policies never bleed into another's.
|
|
5
5
|
const _cache = new Map();
|
|
6
6
|
const CACHE_TTL_MS = 30_000;
|
|
7
7
|
// ─── Row → Policy ────────────────────────────────────────────────────────────
|
|
@@ -22,19 +22,21 @@ function rowToPolicy(row) {
|
|
|
22
22
|
// ─── PolicyLoader ────────────────────────────────────────────────────────────
|
|
23
23
|
export class PolicyLoader {
|
|
24
24
|
/**
|
|
25
|
-
* Load all policies for the
|
|
25
|
+
* Load all policies for the active workspace from Supabase.
|
|
26
26
|
* Returns [] if no Supabase client is provided (unauthenticated state).
|
|
27
27
|
*/
|
|
28
|
-
static async load(forceRefresh = false, supabase) {
|
|
28
|
+
static async load(forceRefresh = false, supabase, workspaceId) {
|
|
29
29
|
if (!supabase) {
|
|
30
30
|
logger.info("No Supabase client — policies require authentication");
|
|
31
31
|
return [];
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
const resolvedWorkspaceId = (workspaceId ?? "").trim();
|
|
34
|
+
if (!resolvedWorkspaceId) {
|
|
35
|
+
logger.warn("No workspace context — returning empty policy set");
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
36
38
|
const now = Date.now();
|
|
37
|
-
const cached = _cache.get(
|
|
39
|
+
const cached = _cache.get(resolvedWorkspaceId);
|
|
38
40
|
if (!forceRefresh && cached && now - cached.loadedAt < CACHE_TTL_MS) {
|
|
39
41
|
return cached.policies;
|
|
40
42
|
}
|
|
@@ -42,13 +44,14 @@ export class PolicyLoader {
|
|
|
42
44
|
const { data, error } = await supabase
|
|
43
45
|
.from("policies")
|
|
44
46
|
.select("*")
|
|
47
|
+
.eq("workspace_id", resolvedWorkspaceId)
|
|
45
48
|
.eq("enabled", true)
|
|
46
49
|
.order("priority", { ascending: false });
|
|
47
50
|
if (error)
|
|
48
51
|
throw error;
|
|
49
52
|
const policies = (data ?? []).map(rowToPolicy);
|
|
50
|
-
_cache.set(
|
|
51
|
-
logger.info(`Loaded ${policies.length} policies from DB for
|
|
53
|
+
_cache.set(resolvedWorkspaceId, { policies, loadedAt: Date.now() });
|
|
54
|
+
logger.info(`Loaded ${policies.length} policies from DB for workspace ${resolvedWorkspaceId}`);
|
|
52
55
|
return policies;
|
|
53
56
|
}
|
|
54
57
|
catch (err) {
|
|
@@ -56,9 +59,9 @@ export class PolicyLoader {
|
|
|
56
59
|
return [];
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
|
-
static invalidateCache(
|
|
60
|
-
if (
|
|
61
|
-
_cache.delete(
|
|
62
|
+
static invalidateCache(workspaceId) {
|
|
63
|
+
if (workspaceId) {
|
|
64
|
+
_cache.delete(workspaceId);
|
|
62
65
|
}
|
|
63
66
|
else {
|
|
64
67
|
_cache.clear();
|
|
@@ -78,11 +81,12 @@ export class PolicyLoader {
|
|
|
78
81
|
* Save (upsert) a policy to Supabase.
|
|
79
82
|
* Throws if no Supabase client is available.
|
|
80
83
|
*/
|
|
81
|
-
static async save(policy, supabase, userId) {
|
|
82
|
-
if (!supabase || !userId) {
|
|
84
|
+
static async save(policy, supabase, userId, workspaceId) {
|
|
85
|
+
if (!supabase || !userId || !workspaceId) {
|
|
83
86
|
throw new Error("Authentication required to save policies");
|
|
84
87
|
}
|
|
85
88
|
const row = {
|
|
89
|
+
workspace_id: workspaceId,
|
|
86
90
|
user_id: userId,
|
|
87
91
|
policy_id: policy.metadata.id,
|
|
88
92
|
api_version: policy.apiVersion,
|
|
@@ -94,25 +98,25 @@ export class PolicyLoader {
|
|
|
94
98
|
};
|
|
95
99
|
const { error } = await supabase
|
|
96
100
|
.from("policies")
|
|
97
|
-
.upsert(row, { onConflict: "
|
|
101
|
+
.upsert(row, { onConflict: "workspace_id,policy_id" });
|
|
98
102
|
if (error)
|
|
99
103
|
throw new Error(`Failed to save policy: ${error.message}`);
|
|
100
|
-
this.invalidateCache();
|
|
104
|
+
this.invalidateCache(workspaceId);
|
|
101
105
|
logger.info(`Saved policy to DB: ${policy.metadata.id}`);
|
|
102
106
|
return `db:policies/${policy.metadata.id}`;
|
|
103
107
|
}
|
|
104
108
|
/**
|
|
105
109
|
* Partially update a policy (enabled toggle, name, description, tags, priority).
|
|
106
110
|
*/
|
|
107
|
-
static async patch(policyId, patch, supabase, userId) {
|
|
108
|
-
if (!supabase || !userId) {
|
|
111
|
+
static async patch(policyId, patch, supabase, userId, workspaceId) {
|
|
112
|
+
if (!supabase || !userId || !workspaceId) {
|
|
109
113
|
throw new Error("Authentication required to update policies");
|
|
110
114
|
}
|
|
111
115
|
const { data: existing, error: fetchErr } = await supabase
|
|
112
116
|
.from("policies")
|
|
113
117
|
.select("metadata, priority, enabled")
|
|
114
118
|
.eq("policy_id", policyId)
|
|
115
|
-
.eq("
|
|
119
|
+
.eq("workspace_id", workspaceId)
|
|
116
120
|
.single();
|
|
117
121
|
if (fetchErr || !existing)
|
|
118
122
|
throw new Error("Policy not found");
|
|
@@ -131,10 +135,10 @@ export class PolicyLoader {
|
|
|
131
135
|
priority: patch.priority ?? existing.priority,
|
|
132
136
|
})
|
|
133
137
|
.eq("policy_id", policyId)
|
|
134
|
-
.eq("
|
|
138
|
+
.eq("workspace_id", workspaceId);
|
|
135
139
|
if (error)
|
|
136
140
|
throw new Error(`Failed to patch policy: ${error.message}`);
|
|
137
|
-
this.invalidateCache();
|
|
141
|
+
this.invalidateCache(workspaceId);
|
|
138
142
|
logger.info(`Patched policy: ${policyId}`);
|
|
139
143
|
return true;
|
|
140
144
|
}
|
|
@@ -142,18 +146,18 @@ export class PolicyLoader {
|
|
|
142
146
|
* Delete a policy by ID from Supabase.
|
|
143
147
|
* Throws if no Supabase client is available.
|
|
144
148
|
*/
|
|
145
|
-
static async delete(policyId, supabase, userId) {
|
|
146
|
-
if (!supabase || !userId) {
|
|
149
|
+
static async delete(policyId, supabase, userId, workspaceId) {
|
|
150
|
+
if (!supabase || !userId || !workspaceId) {
|
|
147
151
|
throw new Error("Authentication required to delete policies");
|
|
148
152
|
}
|
|
149
153
|
const { error, count } = await supabase
|
|
150
154
|
.from("policies")
|
|
151
155
|
.delete({ count: "exact" })
|
|
152
156
|
.eq("policy_id", policyId)
|
|
153
|
-
.eq("
|
|
157
|
+
.eq("workspace_id", workspaceId);
|
|
154
158
|
if (error)
|
|
155
159
|
throw new Error(`Failed to delete policy: ${error.message}`);
|
|
156
|
-
this.invalidateCache();
|
|
160
|
+
this.invalidateCache(workspaceId);
|
|
157
161
|
return (count ?? 0) > 0;
|
|
158
162
|
}
|
|
159
163
|
}
|
|
@@ -89,7 +89,7 @@ export class RAGService {
|
|
|
89
89
|
/**
|
|
90
90
|
* Process an ingested document's raw text: chunk it, embed it, and store in DB.
|
|
91
91
|
*/
|
|
92
|
-
static async chunkAndEmbed(ingestionId, userId, rawText, supabase, settings) {
|
|
92
|
+
static async chunkAndEmbed(ingestionId, userId, rawText, supabase, settings, workspaceId) {
|
|
93
93
|
if (/^\[VLM_(IMAGE|PDF)_DATA:/.test(rawText)) {
|
|
94
94
|
logger.info(`Skipping chunking and embedding for VLM base64 multimodal data (Ingestion: ${ingestionId})`);
|
|
95
95
|
return;
|
|
@@ -100,6 +100,21 @@ export class RAGService {
|
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
102
|
const resolvedModel = await this.resolveEmbeddingModel(settings || {});
|
|
103
|
+
let resolvedWorkspaceId = (workspaceId ?? "").trim();
|
|
104
|
+
if (!resolvedWorkspaceId) {
|
|
105
|
+
const { data: ingestionRow, error: ingestionLookupError } = await supabase
|
|
106
|
+
.from("ingestions")
|
|
107
|
+
.select("workspace_id")
|
|
108
|
+
.eq("id", ingestionId)
|
|
109
|
+
.maybeSingle();
|
|
110
|
+
if (ingestionLookupError) {
|
|
111
|
+
throw new Error(`Failed to resolve workspace for ingestion ${ingestionId}: ${ingestionLookupError.message}`);
|
|
112
|
+
}
|
|
113
|
+
resolvedWorkspaceId = String(ingestionRow?.workspace_id ?? "").trim();
|
|
114
|
+
if (!resolvedWorkspaceId) {
|
|
115
|
+
throw new Error(`Workspace context is required to index chunks for ingestion ${ingestionId}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
103
118
|
logger.info(`Extracted ${chunks.length} chunks for ingestion ${ingestionId}. Embedding with ${resolvedModel.provider}/${resolvedModel.model}...`);
|
|
104
119
|
// Global gate: background fire-and-forget jobs are bounded process-wide.
|
|
105
120
|
await this.acquireEmbedJobSlot();
|
|
@@ -113,6 +128,7 @@ export class RAGService {
|
|
|
113
128
|
const { data: existing } = await supabase
|
|
114
129
|
.from("document_chunks")
|
|
115
130
|
.select("id")
|
|
131
|
+
.eq("workspace_id", resolvedWorkspaceId)
|
|
116
132
|
.eq("ingestion_id", ingestionId)
|
|
117
133
|
.eq("content_hash", hash)
|
|
118
134
|
.eq("embedding_provider", resolvedModel.provider)
|
|
@@ -126,6 +142,7 @@ export class RAGService {
|
|
|
126
142
|
const embedding = await this.embedTextWithResolvedModel(content, resolvedModel);
|
|
127
143
|
const vector_dim = embedding.length;
|
|
128
144
|
const { error } = await supabase.from("document_chunks").insert({
|
|
145
|
+
workspace_id: resolvedWorkspaceId,
|
|
129
146
|
user_id: userId,
|
|
130
147
|
ingestion_id: ingestionId,
|
|
131
148
|
content,
|
|
@@ -155,30 +172,44 @@ export class RAGService {
|
|
|
155
172
|
* Semantically search the document chunks using dynamic pgvector partial indexing.
|
|
156
173
|
*/
|
|
157
174
|
static async runSearchForModel(args) {
|
|
158
|
-
const { userId, supabase, modelScope, queryEmbedding, queryDim, similarityThreshold, topK } = args;
|
|
159
|
-
const
|
|
160
|
-
p_user_id: userId,
|
|
175
|
+
const { userId, workspaceId, supabase, modelScope, queryEmbedding, queryDim, similarityThreshold, topK } = args;
|
|
176
|
+
const basePayload = {
|
|
161
177
|
p_embedding_provider: modelScope.provider,
|
|
162
178
|
p_embedding_model: modelScope.model,
|
|
163
179
|
query_embedding: queryEmbedding,
|
|
164
180
|
match_threshold: similarityThreshold,
|
|
165
181
|
match_count: topK,
|
|
166
182
|
query_dim: queryDim
|
|
167
|
-
}
|
|
183
|
+
};
|
|
184
|
+
const { data, error } = workspaceId
|
|
185
|
+
? await supabase.rpc("search_workspace_documents", {
|
|
186
|
+
p_workspace_id: workspaceId,
|
|
187
|
+
...basePayload
|
|
188
|
+
})
|
|
189
|
+
: await supabase.rpc("search_documents", {
|
|
190
|
+
p_user_id: userId,
|
|
191
|
+
...basePayload
|
|
192
|
+
});
|
|
168
193
|
if (error) {
|
|
169
194
|
throw new Error(`Knowledge base search failed for ${modelScope.provider}/${modelScope.model}: ${error.message}`);
|
|
170
195
|
}
|
|
171
196
|
return (data || []);
|
|
172
197
|
}
|
|
173
|
-
static async
|
|
174
|
-
|
|
198
|
+
static async listModelScopes(userId, supabase, workspaceId) {
|
|
199
|
+
let query = supabase
|
|
175
200
|
.from("document_chunks")
|
|
176
201
|
.select("embedding_provider, embedding_model, vector_dim, created_at")
|
|
177
|
-
.eq("user_id", userId)
|
|
178
202
|
.order("created_at", { ascending: false })
|
|
179
203
|
.limit(2000);
|
|
204
|
+
if (workspaceId) {
|
|
205
|
+
query = query.eq("workspace_id", workspaceId);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
query = query.eq("user_id", userId);
|
|
209
|
+
}
|
|
210
|
+
const { data, error } = await query;
|
|
180
211
|
if (error) {
|
|
181
|
-
logger.warn("Failed to list
|
|
212
|
+
logger.warn("Failed to list embedding scopes for RAG fallback", { userId, workspaceId, error });
|
|
182
213
|
return [];
|
|
183
214
|
}
|
|
184
215
|
const scopes = new Map();
|
|
@@ -203,7 +234,7 @@ export class RAGService {
|
|
|
203
234
|
return Array.from(scopes.values());
|
|
204
235
|
}
|
|
205
236
|
static async searchDocuments(query, userId, supabase, options = {}) {
|
|
206
|
-
const { topK = 5, similarityThreshold = 0.7, settings } = options;
|
|
237
|
+
const { topK = 5, similarityThreshold = 0.7, settings, workspaceId } = options;
|
|
207
238
|
const minThreshold = Math.max(0.1, Math.min(similarityThreshold, 0.4));
|
|
208
239
|
const thresholdLevels = Array.from(new Set([similarityThreshold, minThreshold]));
|
|
209
240
|
const preferred = await this.resolveEmbeddingModel(settings || {});
|
|
@@ -232,6 +263,7 @@ export class RAGService {
|
|
|
232
263
|
logger.info(`Searching knowledge base (${scope.provider}/${scope.model}, dim=${queryDim}, topK=${topK}, threshold=${threshold})`);
|
|
233
264
|
const hits = await this.runSearchForModel({
|
|
234
265
|
userId,
|
|
266
|
+
workspaceId,
|
|
235
267
|
supabase,
|
|
236
268
|
modelScope: scope,
|
|
237
269
|
queryEmbedding,
|
|
@@ -268,7 +300,7 @@ export class RAGService {
|
|
|
268
300
|
});
|
|
269
301
|
}
|
|
270
302
|
if (collected.size === 0) {
|
|
271
|
-
const scopes = await this.
|
|
303
|
+
const scopes = await this.listModelScopes(userId, supabase, workspaceId);
|
|
272
304
|
const fallbackScopes = scopes.filter((scope) => !(scope.provider === preferredScope.provider && scope.model === preferredScope.model));
|
|
273
305
|
for (const scope of fallbackScopes) {
|
|
274
306
|
try {
|