@realtimex/folio 0.1.2
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/.env.example +20 -0
- package/README.md +63 -0
- package/api/server.ts +130 -0
- package/api/src/config/index.ts +96 -0
- package/api/src/middleware/auth.ts +128 -0
- package/api/src/middleware/errorHandler.ts +88 -0
- package/api/src/middleware/index.ts +4 -0
- package/api/src/middleware/rateLimit.ts +71 -0
- package/api/src/middleware/validation.ts +58 -0
- package/api/src/routes/accounts.ts +142 -0
- package/api/src/routes/baseline-config.ts +124 -0
- package/api/src/routes/chat.ts +154 -0
- package/api/src/routes/health.ts +61 -0
- package/api/src/routes/index.ts +35 -0
- package/api/src/routes/ingestions.ts +275 -0
- package/api/src/routes/migrate.ts +112 -0
- package/api/src/routes/policies.ts +121 -0
- package/api/src/routes/processing.ts +90 -0
- package/api/src/routes/rules.ts +11 -0
- package/api/src/routes/sdk.ts +100 -0
- package/api/src/routes/settings.ts +80 -0
- package/api/src/routes/setup.ts +389 -0
- package/api/src/routes/stats.ts +81 -0
- package/api/src/routes/tts.ts +190 -0
- package/api/src/services/BaselineConfigService.ts +208 -0
- package/api/src/services/ChatService.ts +204 -0
- package/api/src/services/GoogleDriveService.ts +331 -0
- package/api/src/services/GoogleSheetsService.ts +1107 -0
- package/api/src/services/IngestionService.ts +1187 -0
- package/api/src/services/ModelCapabilityService.ts +248 -0
- package/api/src/services/PolicyEngine.ts +1625 -0
- package/api/src/services/PolicyLearningService.ts +527 -0
- package/api/src/services/PolicyLoader.ts +249 -0
- package/api/src/services/RAGService.ts +391 -0
- package/api/src/services/SDKService.ts +249 -0
- package/api/src/services/supabase.ts +113 -0
- package/api/src/utils/Actuator.ts +284 -0
- package/api/src/utils/actions/ActionHandler.ts +34 -0
- package/api/src/utils/actions/AppendToGSheetAction.ts +260 -0
- package/api/src/utils/actions/AutoRenameAction.ts +58 -0
- package/api/src/utils/actions/CopyAction.ts +120 -0
- package/api/src/utils/actions/CopyToGDriveAction.ts +64 -0
- package/api/src/utils/actions/LogCsvAction.ts +48 -0
- package/api/src/utils/actions/NotifyAction.ts +39 -0
- package/api/src/utils/actions/RenameAction.ts +57 -0
- package/api/src/utils/actions/WebhookAction.ts +58 -0
- package/api/src/utils/actions/utils.ts +293 -0
- package/api/src/utils/llmResponse.ts +61 -0
- package/api/src/utils/logger.ts +67 -0
- package/bin/folio-deploy.js +12 -0
- package/bin/folio-setup.js +45 -0
- package/bin/folio.js +65 -0
- package/dist/api/server.js +106 -0
- package/dist/api/src/config/index.js +81 -0
- package/dist/api/src/middleware/auth.js +93 -0
- package/dist/api/src/middleware/errorHandler.js +73 -0
- package/dist/api/src/middleware/index.js +4 -0
- package/dist/api/src/middleware/rateLimit.js +43 -0
- package/dist/api/src/middleware/validation.js +54 -0
- package/dist/api/src/routes/accounts.js +110 -0
- package/dist/api/src/routes/baseline-config.js +91 -0
- package/dist/api/src/routes/chat.js +114 -0
- package/dist/api/src/routes/health.js +52 -0
- package/dist/api/src/routes/index.js +31 -0
- package/dist/api/src/routes/ingestions.js +207 -0
- package/dist/api/src/routes/migrate.js +91 -0
- package/dist/api/src/routes/policies.js +86 -0
- package/dist/api/src/routes/processing.js +75 -0
- package/dist/api/src/routes/rules.js +8 -0
- package/dist/api/src/routes/sdk.js +80 -0
- package/dist/api/src/routes/settings.js +68 -0
- package/dist/api/src/routes/setup.js +315 -0
- package/dist/api/src/routes/stats.js +62 -0
- package/dist/api/src/routes/tts.js +178 -0
- package/dist/api/src/services/BaselineConfigService.js +168 -0
- package/dist/api/src/services/ChatService.js +166 -0
- package/dist/api/src/services/GoogleDriveService.js +280 -0
- package/dist/api/src/services/GoogleSheetsService.js +795 -0
- package/dist/api/src/services/IngestionService.js +990 -0
- package/dist/api/src/services/ModelCapabilityService.js +179 -0
- package/dist/api/src/services/PolicyEngine.js +1353 -0
- package/dist/api/src/services/PolicyLearningService.js +397 -0
- package/dist/api/src/services/PolicyLoader.js +159 -0
- package/dist/api/src/services/RAGService.js +295 -0
- package/dist/api/src/services/SDKService.js +212 -0
- package/dist/api/src/services/supabase.js +72 -0
- package/dist/api/src/utils/Actuator.js +225 -0
- package/dist/api/src/utils/actions/ActionHandler.js +1 -0
- package/dist/api/src/utils/actions/AppendToGSheetAction.js +191 -0
- package/dist/api/src/utils/actions/AutoRenameAction.js +49 -0
- package/dist/api/src/utils/actions/CopyAction.js +112 -0
- package/dist/api/src/utils/actions/CopyToGDriveAction.js +55 -0
- package/dist/api/src/utils/actions/LogCsvAction.js +42 -0
- package/dist/api/src/utils/actions/NotifyAction.js +32 -0
- package/dist/api/src/utils/actions/RenameAction.js +51 -0
- package/dist/api/src/utils/actions/WebhookAction.js +51 -0
- package/dist/api/src/utils/actions/utils.js +237 -0
- package/dist/api/src/utils/llmResponse.js +63 -0
- package/dist/api/src/utils/logger.js +51 -0
- package/dist/assets/index-DzN8-j-e.css +1 -0
- package/dist/assets/index-Uy-ai3Dh.js +113 -0
- package/dist/favicon.svg +31 -0
- package/dist/folio-logo.svg +46 -0
- package/dist/index.html +14 -0
- package/docs-dev/FPE-spec.md +196 -0
- package/docs-dev/folio-prd.md +47 -0
- package/docs-dev/foundation-checklist.md +30 -0
- package/docs-dev/hybrid-routing-architecture.md +205 -0
- package/docs-dev/ingestion-engine.md +69 -0
- package/docs-dev/port-from-email-automator.md +32 -0
- package/docs-dev/tech-spec.md +98 -0
- package/index.html +13 -0
- package/package.json +101 -0
- package/public/favicon.svg +31 -0
- package/public/folio-logo.svg +46 -0
- package/scripts/dev-task.mjs +51 -0
- package/scripts/get-latest-migration-timestamp.mjs +34 -0
- package/scripts/migrate.sh +91 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/.temp/gotrue-version +1 -0
- package/supabase/.temp/pooler-url +1 -0
- package/supabase/.temp/postgres-version +1 -0
- package/supabase/.temp/project-ref +1 -0
- package/supabase/.temp/rest-version +1 -0
- package/supabase/.temp/storage-migration +1 -0
- package/supabase/.temp/storage-version +1 -0
- package/supabase/config.toml +64 -0
- package/supabase/functions/_shared/auth.ts +35 -0
- package/supabase/functions/_shared/cors.ts +12 -0
- package/supabase/functions/_shared/supabaseAdmin.ts +17 -0
- package/supabase/functions/api-v1-settings/index.ts +66 -0
- package/supabase/functions/setup/index.ts +91 -0
- package/supabase/migrations/20260223000000_initial_foundation.sql +136 -0
- package/supabase/migrations/20260223000001_add_migration_rpc.sql +10 -0
- package/supabase/migrations/20260224000002_add_init_state_view.sql +20 -0
- package/supabase/migrations/20260224000003_port_user_creation_parity.sql +139 -0
- package/supabase/migrations/20260224000004_add_avatars_storage.sql +26 -0
- package/supabase/migrations/20260224000005_add_tts_and_embed_settings.sql +24 -0
- package/supabase/migrations/20260224000006_add_policies_table.sql +48 -0
- package/supabase/migrations/20260224000007_fix_migration_rpc.sql +9 -0
- package/supabase/migrations/20260224000008_add_ingestions_table.sql +42 -0
- package/supabase/migrations/20260225000000_setup_compatible_mode.sql +119 -0
- package/supabase/migrations/20260225000001_restore_ingestions.sql +49 -0
- package/supabase/migrations/20260225000002_add_ingestion_trace.sql +2 -0
- package/supabase/migrations/20260225000003_add_baseline_configs.sql +35 -0
- package/supabase/migrations/20260226000000_add_processing_events.sql +26 -0
- package/supabase/migrations/20260226000001_add_ingestion_file_hash.sql +10 -0
- package/supabase/migrations/20260226000002_add_dynamic_rag.sql +150 -0
- package/supabase/migrations/20260226000003_add_ingestion_summary.sql +4 -0
- package/supabase/migrations/20260226000004_add_ingestion_tags.sql +7 -0
- package/supabase/migrations/20260226000005_add_chat_tables.sql +60 -0
- package/supabase/migrations/20260227000000_harden_chat_messages_rls.sql +25 -0
- package/supabase/migrations/20260228000000_add_vision_model_capabilities.sql +8 -0
- package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +51 -0
- package/supabase/migrations/29991231235959_test_migration.sql +0 -0
- package/supabase/templates/confirmation.html +76 -0
- package/supabase/templates/email-change.html +76 -0
- package/supabase/templates/invite.html +72 -0
- package/supabase/templates/magic-link.html +68 -0
- package/supabase/templates/recovery.html +82 -0
- package/tsconfig.api.json +16 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +146 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import { createLogger } from "../utils/logger.js";
|
|
3
|
+
|
|
4
|
+
const logger = createLogger("PolicyLoader");
|
|
5
|
+
|
|
6
|
+
// ─── Types ─────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export type MatchStrategy = "ALL" | "ANY";
|
|
9
|
+
export type ConditionType = "keyword" | "llm_verify" | "semantic" | "filename" | "file_type" | "mime_type";
|
|
10
|
+
|
|
11
|
+
export interface MatchCondition {
|
|
12
|
+
type: ConditionType;
|
|
13
|
+
value?: string | string[];
|
|
14
|
+
prompt?: string;
|
|
15
|
+
confidence_threshold?: number;
|
|
16
|
+
case_sensitive?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ExtractField {
|
|
20
|
+
key: string;
|
|
21
|
+
type: "string" | "currency" | "date" | "number";
|
|
22
|
+
description: string;
|
|
23
|
+
required?: boolean;
|
|
24
|
+
format?: string;
|
|
25
|
+
transformers?: { name: string; as: string }[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ActionType = "rename" | "auto_rename" | "copy" | "copy_to_gdrive" | "append_to_google_sheet" | "log_csv" | "notify" | "webhook";
|
|
29
|
+
|
|
30
|
+
export interface PolicyAction {
|
|
31
|
+
type: ActionType;
|
|
32
|
+
pattern?: string;
|
|
33
|
+
destination?: string;
|
|
34
|
+
filename?: string;
|
|
35
|
+
path?: string;
|
|
36
|
+
columns?: string[];
|
|
37
|
+
spreadsheet_id?: string;
|
|
38
|
+
spreadsheet_url?: string;
|
|
39
|
+
range?: string;
|
|
40
|
+
message?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FolioPolicy {
|
|
44
|
+
apiVersion: "folio/v1";
|
|
45
|
+
kind: "Policy" | "Splitter";
|
|
46
|
+
metadata: {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
version: string;
|
|
50
|
+
description: string;
|
|
51
|
+
priority: number;
|
|
52
|
+
tags?: string[];
|
|
53
|
+
enabled?: boolean;
|
|
54
|
+
};
|
|
55
|
+
spec: {
|
|
56
|
+
match: {
|
|
57
|
+
strategy: MatchStrategy;
|
|
58
|
+
conditions: MatchCondition[];
|
|
59
|
+
};
|
|
60
|
+
extract?: ExtractField[];
|
|
61
|
+
actions?: PolicyAction[];
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Cache ───────────────────────────────────────────────────────────────────
|
|
66
|
+
// Keyed by user_id so one user's policies never bleed into another's.
|
|
67
|
+
|
|
68
|
+
const _cache = new Map<string, { policies: FolioPolicy[]; loadedAt: number }>();
|
|
69
|
+
const CACHE_TTL_MS = 30_000;
|
|
70
|
+
|
|
71
|
+
// ─── Row → Policy ────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
74
|
+
function rowToPolicy(row: any): FolioPolicy {
|
|
75
|
+
return {
|
|
76
|
+
apiVersion: row.api_version ?? "folio/v1",
|
|
77
|
+
kind: row.kind ?? "Policy",
|
|
78
|
+
metadata: {
|
|
79
|
+
...row.metadata,
|
|
80
|
+
id: row.policy_id,
|
|
81
|
+
priority: row.priority,
|
|
82
|
+
enabled: row.enabled,
|
|
83
|
+
},
|
|
84
|
+
spec: row.spec,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── PolicyLoader ────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export class PolicyLoader {
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Load all policies for the authenticated user from Supabase.
|
|
94
|
+
* Returns [] if no Supabase client is provided (unauthenticated state).
|
|
95
|
+
*/
|
|
96
|
+
static async load(forceRefresh = false, supabase?: SupabaseClient | null): Promise<FolioPolicy[]> {
|
|
97
|
+
if (!supabase) {
|
|
98
|
+
logger.info("No Supabase client — policies require authentication");
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Resolve the user ID to scope the cache correctly
|
|
103
|
+
const { data: { user } } = await supabase.auth.getUser();
|
|
104
|
+
const userId = user?.id ?? "anonymous";
|
|
105
|
+
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const cached = _cache.get(userId);
|
|
108
|
+
if (!forceRefresh && cached && now - cached.loadedAt < CACHE_TTL_MS) {
|
|
109
|
+
return cached.policies;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const { data, error } = await supabase
|
|
114
|
+
.from("policies")
|
|
115
|
+
.select("*")
|
|
116
|
+
.eq("enabled", true)
|
|
117
|
+
.order("priority", { ascending: false });
|
|
118
|
+
|
|
119
|
+
if (error) throw error;
|
|
120
|
+
|
|
121
|
+
const policies = (data ?? []).map(rowToPolicy);
|
|
122
|
+
_cache.set(userId, { policies, loadedAt: Date.now() });
|
|
123
|
+
logger.info(`Loaded ${policies.length} policies from DB for user ${userId}`);
|
|
124
|
+
return policies;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
logger.error("Failed to load policies from DB", { err });
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
static invalidateCache(userId?: string) {
|
|
132
|
+
if (userId) {
|
|
133
|
+
_cache.delete(userId);
|
|
134
|
+
} else {
|
|
135
|
+
_cache.clear();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
static validate(policy: unknown): policy is FolioPolicy {
|
|
140
|
+
if (!policy || typeof policy !== "object") return false;
|
|
141
|
+
const p = policy as Partial<FolioPolicy>;
|
|
142
|
+
return (
|
|
143
|
+
p.apiVersion === "folio/v1" &&
|
|
144
|
+
typeof p.metadata?.id === "string" &&
|
|
145
|
+
typeof p.metadata?.priority === "number" &&
|
|
146
|
+
typeof p.spec?.match?.strategy === "string" &&
|
|
147
|
+
Array.isArray(p.spec?.match?.conditions)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Save (upsert) a policy to Supabase.
|
|
153
|
+
* Throws if no Supabase client is available.
|
|
154
|
+
*/
|
|
155
|
+
static async save(policy: FolioPolicy, supabase?: SupabaseClient | null, userId?: string): Promise<string> {
|
|
156
|
+
if (!supabase || !userId) {
|
|
157
|
+
throw new Error("Authentication required to save policies");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const row = {
|
|
161
|
+
user_id: userId,
|
|
162
|
+
policy_id: policy.metadata.id,
|
|
163
|
+
api_version: policy.apiVersion,
|
|
164
|
+
kind: policy.kind,
|
|
165
|
+
metadata: policy.metadata,
|
|
166
|
+
spec: policy.spec,
|
|
167
|
+
enabled: policy.metadata.enabled ?? true,
|
|
168
|
+
priority: policy.metadata.priority,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const { error } = await supabase
|
|
172
|
+
.from("policies")
|
|
173
|
+
.upsert(row, { onConflict: "user_id,policy_id" });
|
|
174
|
+
|
|
175
|
+
if (error) throw new Error(`Failed to save policy: ${error.message}`);
|
|
176
|
+
|
|
177
|
+
this.invalidateCache();
|
|
178
|
+
logger.info(`Saved policy to DB: ${policy.metadata.id}`);
|
|
179
|
+
return `db:policies/${policy.metadata.id}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Partially update a policy (enabled toggle, name, description, tags, priority).
|
|
184
|
+
*/
|
|
185
|
+
static async patch(
|
|
186
|
+
policyId: string,
|
|
187
|
+
patch: { enabled?: boolean; name?: string; description?: string; tags?: string[]; priority?: number },
|
|
188
|
+
supabase?: SupabaseClient | null,
|
|
189
|
+
userId?: string
|
|
190
|
+
): Promise<boolean> {
|
|
191
|
+
if (!supabase || !userId) {
|
|
192
|
+
throw new Error("Authentication required to update policies");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { data: existing, error: fetchErr } = await supabase
|
|
196
|
+
.from("policies")
|
|
197
|
+
.select("metadata, priority, enabled")
|
|
198
|
+
.eq("policy_id", policyId)
|
|
199
|
+
.eq("user_id", userId)
|
|
200
|
+
.single();
|
|
201
|
+
|
|
202
|
+
if (fetchErr || !existing) throw new Error("Policy not found");
|
|
203
|
+
|
|
204
|
+
const updatedMetadata = {
|
|
205
|
+
...existing.metadata,
|
|
206
|
+
...(patch.name !== undefined && { name: patch.name }),
|
|
207
|
+
...(patch.description !== undefined && { description: patch.description }),
|
|
208
|
+
...(patch.tags !== undefined && { tags: patch.tags }),
|
|
209
|
+
...(patch.priority !== undefined && { priority: patch.priority }),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const { error } = await supabase
|
|
213
|
+
.from("policies")
|
|
214
|
+
.update({
|
|
215
|
+
metadata: updatedMetadata,
|
|
216
|
+
enabled: patch.enabled ?? existing.enabled,
|
|
217
|
+
priority: patch.priority ?? existing.priority,
|
|
218
|
+
})
|
|
219
|
+
.eq("policy_id", policyId)
|
|
220
|
+
.eq("user_id", userId);
|
|
221
|
+
|
|
222
|
+
if (error) throw new Error(`Failed to patch policy: ${error.message}`);
|
|
223
|
+
|
|
224
|
+
this.invalidateCache();
|
|
225
|
+
logger.info(`Patched policy: ${policyId}`);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Delete a policy by ID from Supabase.
|
|
231
|
+
* Throws if no Supabase client is available.
|
|
232
|
+
*/
|
|
233
|
+
static async delete(policyId: string, supabase?: SupabaseClient | null, userId?: string): Promise<boolean> {
|
|
234
|
+
if (!supabase || !userId) {
|
|
235
|
+
throw new Error("Authentication required to delete policies");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { error, count } = await supabase
|
|
239
|
+
.from("policies")
|
|
240
|
+
.delete({ count: "exact" })
|
|
241
|
+
.eq("policy_id", policyId)
|
|
242
|
+
.eq("user_id", userId);
|
|
243
|
+
|
|
244
|
+
if (error) throw new Error(`Failed to delete policy: ${error.message}`);
|
|
245
|
+
|
|
246
|
+
this.invalidateCache();
|
|
247
|
+
return (count ?? 0) > 0;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { SDKService } from "./SDKService.js";
|
|
4
|
+
import { createLogger } from "../utils/logger.js";
|
|
5
|
+
|
|
6
|
+
const logger = createLogger("RAGService");
|
|
7
|
+
|
|
8
|
+
export interface RetrievedChunk {
|
|
9
|
+
id: string;
|
|
10
|
+
ingestion_id: string;
|
|
11
|
+
content: string;
|
|
12
|
+
similarity: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ModelScope {
|
|
16
|
+
provider: string;
|
|
17
|
+
model: string;
|
|
18
|
+
vector_dim?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type EmbeddingSettings = { embedding_provider?: string; embedding_model?: string };
|
|
22
|
+
|
|
23
|
+
interface ResolvedEmbeddingModel {
|
|
24
|
+
provider: string;
|
|
25
|
+
model: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class RAGService {
|
|
29
|
+
private static readonly MAX_CONCURRENT_EMBED_JOBS = (() => {
|
|
30
|
+
const parsed = Number.parseInt(process.env.RAG_MAX_CONCURRENT_EMBED_JOBS ?? "2", 10);
|
|
31
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
|
|
32
|
+
})();
|
|
33
|
+
private static activeEmbedJobs = 0;
|
|
34
|
+
private static embedJobWaiters: Array<() => void> = [];
|
|
35
|
+
|
|
36
|
+
private static async acquireEmbedJobSlot(): Promise<void> {
|
|
37
|
+
while (this.activeEmbedJobs >= this.MAX_CONCURRENT_EMBED_JOBS) {
|
|
38
|
+
await new Promise<void>((resolve) => this.embedJobWaiters.push(resolve));
|
|
39
|
+
}
|
|
40
|
+
this.activeEmbedJobs += 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private static releaseEmbedJobSlot(): void {
|
|
44
|
+
this.activeEmbedJobs = Math.max(0, this.activeEmbedJobs - 1);
|
|
45
|
+
const waiter = this.embedJobWaiters.shift();
|
|
46
|
+
if (waiter) waiter();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private static async resolveEmbeddingModel(settings: EmbeddingSettings = {}): Promise<ResolvedEmbeddingModel> {
|
|
50
|
+
const { provider, model } = await SDKService.resolveEmbedProvider(settings);
|
|
51
|
+
return { provider, model };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private static async embedTextWithResolvedModel(text: string, resolvedModel: ResolvedEmbeddingModel): Promise<number[]> {
|
|
55
|
+
const sdk = SDKService.getSDK();
|
|
56
|
+
if (!sdk) {
|
|
57
|
+
throw new Error("RealTimeX SDK not available for embedding");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const response = await sdk.llm.embed(text, {
|
|
61
|
+
provider: resolvedModel.provider,
|
|
62
|
+
model: resolvedModel.model
|
|
63
|
+
});
|
|
64
|
+
const embedding = response.embeddings?.[0];
|
|
65
|
+
if (!embedding) {
|
|
66
|
+
throw new Error("No embedding returned from SDK");
|
|
67
|
+
}
|
|
68
|
+
return embedding;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Splits a large text into smaller semantic chunks (roughly by paragraphs).
|
|
73
|
+
*/
|
|
74
|
+
static chunkText(text: string, maxChunkLength: number = 1000): string[] {
|
|
75
|
+
if (!text || text.trim().length === 0) return [];
|
|
76
|
+
|
|
77
|
+
const paragraphs = text.split(/\n\s*\n/);
|
|
78
|
+
const chunks: string[] = [];
|
|
79
|
+
let currentChunk = "";
|
|
80
|
+
|
|
81
|
+
for (const paragraph of paragraphs) {
|
|
82
|
+
const p = paragraph.trim();
|
|
83
|
+
if (!p) continue;
|
|
84
|
+
|
|
85
|
+
if (p.length > maxChunkLength) {
|
|
86
|
+
// Flush pending chunk before splitting an oversized paragraph
|
|
87
|
+
if (currentChunk.length > 0) {
|
|
88
|
+
chunks.push(currentChunk.trim());
|
|
89
|
+
currentChunk = "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Hard split oversized paragraphs to prevent SDK payload rejection
|
|
93
|
+
let i = 0;
|
|
94
|
+
while (i < p.length) {
|
|
95
|
+
chunks.push(p.slice(i, i + maxChunkLength));
|
|
96
|
+
i += maxChunkLength;
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (currentChunk.length + p.length > maxChunkLength && currentChunk.length > 0) {
|
|
102
|
+
chunks.push(currentChunk.trim());
|
|
103
|
+
currentChunk = "";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
currentChunk += (currentChunk.length > 0 ? "\n\n" : "") + p;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (currentChunk.trim().length > 0) {
|
|
110
|
+
chunks.push(currentChunk.trim());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return chunks;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Generate an embedding for a text string using RealTimeX SDK
|
|
118
|
+
*/
|
|
119
|
+
static async embedText(
|
|
120
|
+
text: string,
|
|
121
|
+
settings?: EmbeddingSettings
|
|
122
|
+
): Promise<number[]> {
|
|
123
|
+
const resolvedModel = await this.resolveEmbeddingModel(settings || {});
|
|
124
|
+
logger.debug(`Generating embedding using ${resolvedModel.provider}/${resolvedModel.model}`);
|
|
125
|
+
return this.embedTextWithResolvedModel(text, resolvedModel);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Process an ingested document's raw text: chunk it, embed it, and store in DB.
|
|
130
|
+
*/
|
|
131
|
+
static async chunkAndEmbed(
|
|
132
|
+
ingestionId: string,
|
|
133
|
+
userId: string,
|
|
134
|
+
rawText: string,
|
|
135
|
+
supabase: SupabaseClient,
|
|
136
|
+
settings?: EmbeddingSettings
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
if (rawText.startsWith("[VLM_IMAGE_DATA:")) {
|
|
139
|
+
logger.info(`Skipping chunking and embedding for VLM base64 image data (Ingestion: ${ingestionId})`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const chunks = this.chunkText(rawText);
|
|
144
|
+
if (chunks.length === 0) {
|
|
145
|
+
logger.info(`No text to chunk for ingestion ${ingestionId}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const resolvedModel = await this.resolveEmbeddingModel(settings || {});
|
|
150
|
+
logger.info(
|
|
151
|
+
`Extracted ${chunks.length} chunks for ingestion ${ingestionId}. Embedding with ${resolvedModel.provider}/${resolvedModel.model}...`
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Global gate: background fire-and-forget jobs are bounded process-wide.
|
|
155
|
+
await this.acquireEmbedJobSlot();
|
|
156
|
+
try {
|
|
157
|
+
// To avoid provider throttling, process chunks sequentially inside each job.
|
|
158
|
+
for (const [index, content] of chunks.entries()) {
|
|
159
|
+
try {
|
|
160
|
+
// Content hash is model-agnostic. Model identity is tracked in dedicated columns.
|
|
161
|
+
const hash = crypto.createHash("sha256").update(content).digest("hex");
|
|
162
|
+
|
|
163
|
+
// Check if this chunk already exists for this ingestion and this model.
|
|
164
|
+
const { data: existing } = await supabase
|
|
165
|
+
.from("document_chunks")
|
|
166
|
+
.select("id")
|
|
167
|
+
.eq("ingestion_id", ingestionId)
|
|
168
|
+
.eq("content_hash", hash)
|
|
169
|
+
.eq("embedding_provider", resolvedModel.provider)
|
|
170
|
+
.eq("embedding_model", resolvedModel.model)
|
|
171
|
+
.maybeSingle();
|
|
172
|
+
|
|
173
|
+
if (existing) {
|
|
174
|
+
continue; // Skip duplicate chunk
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Spread requests slightly to reduce burstiness against embedding APIs.
|
|
178
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
179
|
+
|
|
180
|
+
const embedding = await this.embedTextWithResolvedModel(content, resolvedModel);
|
|
181
|
+
const vector_dim = embedding.length;
|
|
182
|
+
|
|
183
|
+
const { error } = await supabase.from("document_chunks").insert({
|
|
184
|
+
user_id: userId,
|
|
185
|
+
ingestion_id: ingestionId,
|
|
186
|
+
content,
|
|
187
|
+
content_hash: hash,
|
|
188
|
+
embedding_provider: resolvedModel.provider,
|
|
189
|
+
embedding_model: resolvedModel.model,
|
|
190
|
+
embedding,
|
|
191
|
+
vector_dim
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (error) {
|
|
195
|
+
logger.error(`Failed to insert chunk ${index + 1}/${chunks.length} for ${ingestionId}`, { error });
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
logger.error(`Failed to process chunk ${index + 1}/${chunks.length} for ${ingestionId}`, {
|
|
199
|
+
error: err instanceof Error ? err.message : String(err)
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
this.releaseEmbedJobSlot();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
logger.info(`Successfully stored semantic chunks for ingestion ${ingestionId}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Semantically search the document chunks using dynamic pgvector partial indexing.
|
|
212
|
+
*/
|
|
213
|
+
private static async runSearchForModel(args: {
|
|
214
|
+
userId: string;
|
|
215
|
+
supabase: SupabaseClient;
|
|
216
|
+
modelScope: ModelScope;
|
|
217
|
+
queryEmbedding: number[];
|
|
218
|
+
queryDim: number;
|
|
219
|
+
similarityThreshold: number;
|
|
220
|
+
topK: number;
|
|
221
|
+
}): Promise<RetrievedChunk[]> {
|
|
222
|
+
const { userId, supabase, modelScope, queryEmbedding, queryDim, similarityThreshold, topK } = args;
|
|
223
|
+
|
|
224
|
+
const { data, error } = await supabase.rpc("search_documents", {
|
|
225
|
+
p_user_id: userId,
|
|
226
|
+
p_embedding_provider: modelScope.provider,
|
|
227
|
+
p_embedding_model: modelScope.model,
|
|
228
|
+
query_embedding: queryEmbedding,
|
|
229
|
+
match_threshold: similarityThreshold,
|
|
230
|
+
match_count: topK,
|
|
231
|
+
query_dim: queryDim
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (error) {
|
|
235
|
+
throw new Error(`Knowledge base search failed for ${modelScope.provider}/${modelScope.model}: ${error.message}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return (data || []) as RetrievedChunk[];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private static async listUserModelScopes(
|
|
242
|
+
userId: string,
|
|
243
|
+
supabase: SupabaseClient
|
|
244
|
+
): Promise<ModelScope[]> {
|
|
245
|
+
const { data, error } = await supabase
|
|
246
|
+
.from("document_chunks")
|
|
247
|
+
.select("embedding_provider, embedding_model, vector_dim, created_at")
|
|
248
|
+
.eq("user_id", userId)
|
|
249
|
+
.order("created_at", { ascending: false })
|
|
250
|
+
.limit(2000);
|
|
251
|
+
|
|
252
|
+
if (error) {
|
|
253
|
+
logger.warn("Failed to list user embedding scopes for RAG fallback", { error });
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const scopes = new Map<string, ModelScope>();
|
|
258
|
+
for (const row of data || []) {
|
|
259
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
260
|
+
const provider = String((row as any).embedding_provider || "").trim();
|
|
261
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
262
|
+
const model = String((row as any).embedding_model || "").trim();
|
|
263
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
264
|
+
const vector_dim = Number((row as any).vector_dim);
|
|
265
|
+
if (!provider || !model) continue;
|
|
266
|
+
const key = `${provider}::${model}`;
|
|
267
|
+
if (!scopes.has(key)) {
|
|
268
|
+
scopes.set(key, {
|
|
269
|
+
provider,
|
|
270
|
+
model,
|
|
271
|
+
vector_dim: Number.isFinite(vector_dim) && vector_dim > 0 ? vector_dim : undefined
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return Array.from(scopes.values());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
static async searchDocuments(
|
|
280
|
+
query: string,
|
|
281
|
+
userId: string,
|
|
282
|
+
supabase: SupabaseClient,
|
|
283
|
+
options: {
|
|
284
|
+
topK?: number;
|
|
285
|
+
similarityThreshold?: number;
|
|
286
|
+
settings?: EmbeddingSettings;
|
|
287
|
+
} = {}
|
|
288
|
+
): Promise<RetrievedChunk[]> {
|
|
289
|
+
const {
|
|
290
|
+
topK = 5,
|
|
291
|
+
similarityThreshold = 0.7,
|
|
292
|
+
settings
|
|
293
|
+
} = options;
|
|
294
|
+
|
|
295
|
+
const minThreshold = Math.max(0.1, Math.min(similarityThreshold, 0.4));
|
|
296
|
+
const thresholdLevels = Array.from(new Set([similarityThreshold, minThreshold]));
|
|
297
|
+
const preferred = await this.resolveEmbeddingModel(settings || {});
|
|
298
|
+
const preferredScope: ModelScope = { provider: preferred.provider, model: preferred.model };
|
|
299
|
+
const embeddingCache = new Map<string, { queryEmbedding: number[]; queryDim: number }>();
|
|
300
|
+
|
|
301
|
+
const collected = new Map<string, RetrievedChunk>();
|
|
302
|
+
const trySearch = async (scope: ModelScope, threshold: number): Promise<number> => {
|
|
303
|
+
const cacheKey = `${scope.provider}::${scope.model}`;
|
|
304
|
+
let cached = embeddingCache.get(cacheKey);
|
|
305
|
+
if (!cached) {
|
|
306
|
+
const queryEmbedding = await this.embedTextWithResolvedModel(query, {
|
|
307
|
+
provider: scope.provider,
|
|
308
|
+
model: scope.model,
|
|
309
|
+
});
|
|
310
|
+
cached = { queryEmbedding, queryDim: queryEmbedding.length };
|
|
311
|
+
embeddingCache.set(cacheKey, cached);
|
|
312
|
+
}
|
|
313
|
+
const { queryEmbedding, queryDim } = cached;
|
|
314
|
+
if (scope.vector_dim && scope.vector_dim !== queryDim) {
|
|
315
|
+
logger.warn("Skipping model scope due to vector dimension mismatch", {
|
|
316
|
+
scope,
|
|
317
|
+
queryDim
|
|
318
|
+
});
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
logger.info(
|
|
323
|
+
`Searching knowledge base (${scope.provider}/${scope.model}, dim=${queryDim}, topK=${topK}, threshold=${threshold})`
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const hits = await this.runSearchForModel({
|
|
327
|
+
userId,
|
|
328
|
+
supabase,
|
|
329
|
+
modelScope: scope,
|
|
330
|
+
queryEmbedding,
|
|
331
|
+
queryDim,
|
|
332
|
+
similarityThreshold: threshold,
|
|
333
|
+
topK
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
for (const hit of hits) {
|
|
337
|
+
if (!collected.has(hit.id)) {
|
|
338
|
+
collected.set(hit.id, hit);
|
|
339
|
+
} else {
|
|
340
|
+
const existing = collected.get(hit.id)!;
|
|
341
|
+
if (hit.similarity > existing.similarity) {
|
|
342
|
+
collected.set(hit.id, hit);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return hits.length;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
for (const threshold of thresholdLevels) {
|
|
352
|
+
const hits = await trySearch(preferredScope, threshold);
|
|
353
|
+
if (hits > 0) {
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
logger.error("Semantic search failed for preferred embedding scope", {
|
|
359
|
+
provider: preferredScope.provider,
|
|
360
|
+
model: preferredScope.model,
|
|
361
|
+
error
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (collected.size === 0) {
|
|
366
|
+
const scopes = await this.listUserModelScopes(userId, supabase);
|
|
367
|
+
const fallbackScopes = scopes.filter(
|
|
368
|
+
(scope) => !(scope.provider === preferredScope.provider && scope.model === preferredScope.model)
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
for (const scope of fallbackScopes) {
|
|
372
|
+
try {
|
|
373
|
+
for (const threshold of thresholdLevels) {
|
|
374
|
+
await trySearch(scope, threshold);
|
|
375
|
+
if (collected.size >= topK) break;
|
|
376
|
+
}
|
|
377
|
+
} catch (error) {
|
|
378
|
+
logger.warn("Semantic search failed for fallback embedding scope", {
|
|
379
|
+
scope,
|
|
380
|
+
error
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
if (collected.size >= topK) break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return Array.from(collected.values())
|
|
388
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
389
|
+
.slice(0, topK);
|
|
390
|
+
}
|
|
391
|
+
}
|