@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
|
@@ -10,14 +10,24 @@ const router = Router();
|
|
|
10
10
|
router.use(optionalAuth);
|
|
11
11
|
// GET /api/policies — list all loaded policies
|
|
12
12
|
router.get("/", asyncHandler(async (req, res) => {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
if (!req.supabase || !req.user) {
|
|
14
|
+
const policies = await PolicyLoader.load(false, req.supabase, req.workspaceId);
|
|
15
|
+
res.json({ success: true, policies });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (!req.workspaceId) {
|
|
19
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const policies = await PolicyLoader.load(false, req.supabase, req.workspaceId);
|
|
23
|
+
if (policies.length === 0) {
|
|
15
24
|
res.json({ success: true, policies });
|
|
16
25
|
return;
|
|
17
26
|
}
|
|
18
27
|
const stats = await PolicyLearningService.getPolicyLearningStats({
|
|
19
28
|
supabase: req.supabase,
|
|
20
29
|
userId: req.user.id,
|
|
30
|
+
workspaceId: req.workspaceId,
|
|
21
31
|
policyIds: policies.map((policy) => policy.metadata.id),
|
|
22
32
|
});
|
|
23
33
|
const enrichedPolicies = policies.map((policy) => {
|
|
@@ -35,17 +45,33 @@ router.get("/", asyncHandler(async (req, res) => {
|
|
|
35
45
|
}));
|
|
36
46
|
// POST /api/policies — save a new policy
|
|
37
47
|
router.post("/", asyncHandler(async (req, res) => {
|
|
48
|
+
if (!req.supabase || !req.user) {
|
|
49
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!req.workspaceId) {
|
|
53
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
38
56
|
const policy = req.body;
|
|
39
57
|
if (!PolicyLoader.validate(policy)) {
|
|
40
58
|
res.status(400).json({ success: false, error: "Invalid policy schema" });
|
|
41
59
|
return;
|
|
42
60
|
}
|
|
43
|
-
const filePath = await PolicyLoader.save(policy, req.supabase, req.user
|
|
61
|
+
const filePath = await PolicyLoader.save(policy, req.supabase, req.user.id, req.workspaceId);
|
|
44
62
|
res.status(201).json({ success: true, filePath });
|
|
45
63
|
}));
|
|
46
64
|
// DELETE /api/policies/:id — delete a policy by ID
|
|
47
65
|
router.delete("/:id", asyncHandler(async (req, res) => {
|
|
48
|
-
|
|
66
|
+
if (!req.supabase || !req.user) {
|
|
67
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!req.workspaceId) {
|
|
71
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const deleted = await PolicyLoader.delete(req.params["id"], req.supabase, req.user.id, req.workspaceId);
|
|
49
75
|
if (!deleted) {
|
|
50
76
|
res.status(404).json({ success: false, error: "Policy not found" });
|
|
51
77
|
return;
|
|
@@ -54,14 +80,30 @@ router.delete("/:id", asyncHandler(async (req, res) => {
|
|
|
54
80
|
}));
|
|
55
81
|
// PATCH /api/policies/:id — partial update (enabled toggle, metadata fields)
|
|
56
82
|
router.patch("/:id", asyncHandler(async (req, res) => {
|
|
83
|
+
if (!req.supabase || !req.user) {
|
|
84
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!req.workspaceId) {
|
|
88
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
57
91
|
const { enabled, name, description, tags, priority } = req.body;
|
|
58
|
-
await PolicyLoader.patch(req.params["id"], { enabled, name, description, tags, priority }, req.supabase, req.user
|
|
92
|
+
await PolicyLoader.patch(req.params["id"], { enabled, name, description, tags, priority }, req.supabase, req.user.id, req.workspaceId);
|
|
59
93
|
res.json({ success: true });
|
|
60
94
|
}));
|
|
61
95
|
// POST /api/policies/reload — force cache invalidation
|
|
62
96
|
router.post("/reload", asyncHandler(async (req, res) => {
|
|
63
|
-
|
|
64
|
-
|
|
97
|
+
if (!req.supabase || !req.user) {
|
|
98
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!req.workspaceId) {
|
|
102
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
PolicyLoader.invalidateCache(req.workspaceId);
|
|
106
|
+
const policies = await PolicyLoader.load(true, req.supabase, req.workspaceId);
|
|
65
107
|
res.json({ success: true, count: policies.length });
|
|
66
108
|
}));
|
|
67
109
|
// POST /api/policies/synthesize — NL → Policy via LLM
|
|
@@ -9,29 +9,33 @@ router.get("/", async (req, res) => {
|
|
|
9
9
|
return;
|
|
10
10
|
}
|
|
11
11
|
try {
|
|
12
|
-
const
|
|
12
|
+
const workspaceId = req.workspaceId;
|
|
13
|
+
if (!workspaceId) {
|
|
14
|
+
res.status(403).json({ success: false, error: "Workspace membership required" });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
13
17
|
const s = req.supabase; // the scoped service client
|
|
14
18
|
// 1. Total Documents Ingested
|
|
15
19
|
const { count: totalDocumentsCount, error: err1 } = await s
|
|
16
20
|
.from("ingestions")
|
|
17
21
|
.select("*", { count: 'exact', head: true })
|
|
18
|
-
.eq("
|
|
22
|
+
.eq("workspace_id", workspaceId);
|
|
19
23
|
// 2. Active Policies
|
|
20
24
|
const { count: activePoliciesCount, error: err2 } = await s
|
|
21
25
|
.from("policies")
|
|
22
26
|
.select("*", { count: 'exact', head: true })
|
|
23
|
-
.eq("
|
|
27
|
+
.eq("workspace_id", workspaceId)
|
|
24
28
|
.eq("enabled", true);
|
|
25
29
|
// 3. RAG Knowledge Base (Chunks)
|
|
26
30
|
const { count: ragChunksCount, error: err3 } = await s
|
|
27
31
|
.from("document_chunks")
|
|
28
32
|
.select("*", { count: 'exact', head: true })
|
|
29
|
-
.eq("
|
|
33
|
+
.eq("workspace_id", workspaceId);
|
|
30
34
|
// 4. Automation Runs (Sum of actions taken across ingestions)
|
|
31
35
|
const { data: ingestionsWithActions, error: err4 } = await s
|
|
32
36
|
.from("ingestions")
|
|
33
37
|
.select("actions_taken")
|
|
34
|
-
.eq("
|
|
38
|
+
.eq("workspace_id", workspaceId)
|
|
35
39
|
.not("actions_taken", "is", null);
|
|
36
40
|
let automationRunsCount = 0;
|
|
37
41
|
if (ingestionsWithActions) {
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { config } from "../config/index.js";
|
|
3
|
+
import { optionalAuth } from "../middleware/auth.js";
|
|
4
|
+
import { asyncHandler } from "../middleware/errorHandler.js";
|
|
5
|
+
import { getSupabaseConfigFromHeaders } from "../services/supabase.js";
|
|
6
|
+
function mapRpcStatus(errorCode) {
|
|
7
|
+
if (errorCode === "42501")
|
|
8
|
+
return 403;
|
|
9
|
+
if (errorCode === "22023")
|
|
10
|
+
return 400;
|
|
11
|
+
return 500;
|
|
12
|
+
}
|
|
13
|
+
const router = Router();
|
|
14
|
+
router.use(optionalAuth);
|
|
15
|
+
router.get("/", asyncHandler(async (req, res) => {
|
|
16
|
+
if (!req.user || !req.supabase) {
|
|
17
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const { data: membershipsData, error: membershipsError } = await req.supabase
|
|
21
|
+
.from("workspace_members")
|
|
22
|
+
.select("workspace_id,role,status,created_at,updated_at")
|
|
23
|
+
.eq("user_id", req.user.id)
|
|
24
|
+
.eq("status", "active")
|
|
25
|
+
.order("created_at", { ascending: true });
|
|
26
|
+
if (membershipsError) {
|
|
27
|
+
const code = membershipsError.code;
|
|
28
|
+
// Backward compatibility for projects before workspace migration.
|
|
29
|
+
if (code === "42P01") {
|
|
30
|
+
res.json({
|
|
31
|
+
success: true,
|
|
32
|
+
workspaces: [],
|
|
33
|
+
activeWorkspaceId: null,
|
|
34
|
+
activeWorkspaceRole: null,
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
res.status(500).json({ success: false, error: membershipsError.message });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const memberships = (membershipsData ?? []);
|
|
42
|
+
if (memberships.length === 0) {
|
|
43
|
+
res.json({
|
|
44
|
+
success: true,
|
|
45
|
+
workspaces: [],
|
|
46
|
+
activeWorkspaceId: null,
|
|
47
|
+
activeWorkspaceRole: null,
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const workspaceIds = memberships.map((membership) => membership.workspace_id);
|
|
52
|
+
const { data: workspaceData, error: workspaceError } = await req.supabase
|
|
53
|
+
.from("workspaces")
|
|
54
|
+
.select("id,name,owner_user_id,created_at,updated_at")
|
|
55
|
+
.in("id", workspaceIds);
|
|
56
|
+
if (workspaceError) {
|
|
57
|
+
res.status(500).json({ success: false, error: workspaceError.message });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const workspaceMap = new Map();
|
|
61
|
+
for (const workspace of (workspaceData ?? [])) {
|
|
62
|
+
workspaceMap.set(workspace.id, workspace);
|
|
63
|
+
}
|
|
64
|
+
const workspaces = memberships
|
|
65
|
+
.map((membership) => {
|
|
66
|
+
const workspace = workspaceMap.get(membership.workspace_id);
|
|
67
|
+
if (!workspace)
|
|
68
|
+
return null;
|
|
69
|
+
return {
|
|
70
|
+
id: workspace.id,
|
|
71
|
+
name: workspace.name,
|
|
72
|
+
owner_user_id: workspace.owner_user_id,
|
|
73
|
+
created_at: workspace.created_at,
|
|
74
|
+
updated_at: workspace.updated_at,
|
|
75
|
+
role: membership.role,
|
|
76
|
+
membership_status: membership.status,
|
|
77
|
+
membership_created_at: membership.created_at,
|
|
78
|
+
};
|
|
79
|
+
})
|
|
80
|
+
.filter((workspace) => Boolean(workspace));
|
|
81
|
+
const activeWorkspaceId = req.workspaceId ?? workspaces[0]?.id ?? null;
|
|
82
|
+
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeWorkspaceId) ?? null;
|
|
83
|
+
res.json({
|
|
84
|
+
success: true,
|
|
85
|
+
workspaces,
|
|
86
|
+
activeWorkspaceId,
|
|
87
|
+
activeWorkspaceRole: activeWorkspace?.role ?? null,
|
|
88
|
+
});
|
|
89
|
+
}));
|
|
90
|
+
router.get("/:workspaceId/members", asyncHandler(async (req, res) => {
|
|
91
|
+
if (!req.user || !req.supabase) {
|
|
92
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const workspaceId = req.params["workspaceId"];
|
|
96
|
+
if (!workspaceId) {
|
|
97
|
+
res.status(400).json({ success: false, error: "workspaceId is required" });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const { data, error } = await req.supabase.rpc("workspace_list_members", {
|
|
101
|
+
p_workspace_id: workspaceId,
|
|
102
|
+
});
|
|
103
|
+
if (error) {
|
|
104
|
+
const status = mapRpcStatus(error.code);
|
|
105
|
+
res.status(status).json({ success: false, error: error.message });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
res.json({
|
|
109
|
+
success: true,
|
|
110
|
+
members: (data ?? []),
|
|
111
|
+
});
|
|
112
|
+
}));
|
|
113
|
+
router.post("/:workspaceId/members/invite", asyncHandler(async (req, res) => {
|
|
114
|
+
if (!req.user || !req.supabase) {
|
|
115
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const workspaceId = req.params["workspaceId"];
|
|
119
|
+
const email = typeof req.body?.email === "string" ? req.body.email.trim() : "";
|
|
120
|
+
const role = req.body?.role === "admin" ? "admin" : "member";
|
|
121
|
+
if (!workspaceId) {
|
|
122
|
+
res.status(400).json({ success: false, error: "workspaceId is required" });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!email) {
|
|
126
|
+
res.status(400).json({ success: false, error: "email is required" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const headerConfig = getSupabaseConfigFromHeaders(req.headers);
|
|
130
|
+
const envUrl = config.supabase.url;
|
|
131
|
+
const envKey = config.supabase.anonKey;
|
|
132
|
+
const envIsValid = envUrl.startsWith("http://") || envUrl.startsWith("https://");
|
|
133
|
+
const supabaseUrl = envIsValid && envKey ? envUrl : headerConfig?.url;
|
|
134
|
+
const anonKey = envIsValid && envKey ? envKey : headerConfig?.anonKey;
|
|
135
|
+
if (!supabaseUrl || !anonKey) {
|
|
136
|
+
res.status(500).json({ success: false, error: "Supabase config unavailable for invite workflow." });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const authHeader = req.headers.authorization;
|
|
140
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
141
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const response = await fetch(`${supabaseUrl}/functions/v1/workspace-invite`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: {
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
Authorization: authHeader,
|
|
149
|
+
apikey: anonKey,
|
|
150
|
+
},
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
workspace_id: workspaceId,
|
|
153
|
+
email,
|
|
154
|
+
role,
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
const payload = await response.json().catch(() => ({}));
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
res.status(response.status).json({
|
|
160
|
+
success: false,
|
|
161
|
+
error: payload?.error?.message || payload?.error || `Invite workflow failed (${response.status})`,
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
res.json(payload);
|
|
166
|
+
}));
|
|
167
|
+
router.patch("/:workspaceId/members/:userId", asyncHandler(async (req, res) => {
|
|
168
|
+
if (!req.user || !req.supabase) {
|
|
169
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const workspaceId = req.params["workspaceId"];
|
|
173
|
+
const userId = req.params["userId"];
|
|
174
|
+
const role = req.body?.role === "admin" ? "admin" : req.body?.role === "member" ? "member" : "";
|
|
175
|
+
if (!workspaceId || !userId) {
|
|
176
|
+
res.status(400).json({ success: false, error: "workspaceId and userId are required" });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!role) {
|
|
180
|
+
res.status(400).json({ success: false, error: "role must be admin or member" });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const { data, error } = await req.supabase.rpc("workspace_update_member_role", {
|
|
184
|
+
p_workspace_id: workspaceId,
|
|
185
|
+
p_target_user_id: userId,
|
|
186
|
+
p_role: role,
|
|
187
|
+
});
|
|
188
|
+
if (error) {
|
|
189
|
+
const status = mapRpcStatus(error.code);
|
|
190
|
+
res.status(status).json({ success: false, error: error.message });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
res.json({
|
|
194
|
+
success: true,
|
|
195
|
+
member: Array.isArray(data) ? data[0] ?? null : null,
|
|
196
|
+
});
|
|
197
|
+
}));
|
|
198
|
+
router.delete("/:workspaceId/members/:userId", asyncHandler(async (req, res) => {
|
|
199
|
+
if (!req.user || !req.supabase) {
|
|
200
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const workspaceId = req.params["workspaceId"];
|
|
204
|
+
const userId = req.params["userId"];
|
|
205
|
+
if (!workspaceId || !userId) {
|
|
206
|
+
res.status(400).json({ success: false, error: "workspaceId and userId are required" });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const { data, error } = await req.supabase.rpc("workspace_remove_member", {
|
|
210
|
+
p_workspace_id: workspaceId,
|
|
211
|
+
p_target_user_id: userId,
|
|
212
|
+
});
|
|
213
|
+
if (error) {
|
|
214
|
+
const status = mapRpcStatus(error.code);
|
|
215
|
+
res.status(status).json({ success: false, error: error.message });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
res.json({ success: true, removed: data === true });
|
|
219
|
+
}));
|
|
220
|
+
export default router;
|
|
@@ -9,7 +9,7 @@ export class ChatService {
|
|
|
9
9
|
* Send a message to an existing session, augment with RAG context if needed,
|
|
10
10
|
* and stream the AI response back into the database.
|
|
11
11
|
*/
|
|
12
|
-
static async handleMessage(sessionId, userId, content, supabase) {
|
|
12
|
+
static async handleMessage(sessionId, userId, content, supabase, workspaceId) {
|
|
13
13
|
// 1. Get User/Session Settings (Models to use)
|
|
14
14
|
const { data: settings } = await supabase
|
|
15
15
|
.from("user_settings")
|
|
@@ -49,7 +49,12 @@ export class ChatService {
|
|
|
49
49
|
embedding_model: embedSettings.embedding_model ?? "auto",
|
|
50
50
|
query_preview: content.slice(0, 180),
|
|
51
51
|
}, supabase);
|
|
52
|
-
contextSources = await RAGService.searchDocuments(content, userId, supabase, {
|
|
52
|
+
contextSources = await RAGService.searchDocuments(content, userId, supabase, {
|
|
53
|
+
topK: 5,
|
|
54
|
+
similarityThreshold: 0.65,
|
|
55
|
+
settings: embedSettings,
|
|
56
|
+
workspaceId
|
|
57
|
+
});
|
|
53
58
|
Actuator.logEvent(null, userId, "analysis", "RAG Retrieval", {
|
|
54
59
|
action: "RAG query response",
|
|
55
60
|
session_id: sessionId,
|
|
@@ -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,
|