@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.
Files changed (163) hide show
  1. package/.env.example +20 -0
  2. package/README.md +63 -0
  3. package/api/server.ts +130 -0
  4. package/api/src/config/index.ts +96 -0
  5. package/api/src/middleware/auth.ts +128 -0
  6. package/api/src/middleware/errorHandler.ts +88 -0
  7. package/api/src/middleware/index.ts +4 -0
  8. package/api/src/middleware/rateLimit.ts +71 -0
  9. package/api/src/middleware/validation.ts +58 -0
  10. package/api/src/routes/accounts.ts +142 -0
  11. package/api/src/routes/baseline-config.ts +124 -0
  12. package/api/src/routes/chat.ts +154 -0
  13. package/api/src/routes/health.ts +61 -0
  14. package/api/src/routes/index.ts +35 -0
  15. package/api/src/routes/ingestions.ts +275 -0
  16. package/api/src/routes/migrate.ts +112 -0
  17. package/api/src/routes/policies.ts +121 -0
  18. package/api/src/routes/processing.ts +90 -0
  19. package/api/src/routes/rules.ts +11 -0
  20. package/api/src/routes/sdk.ts +100 -0
  21. package/api/src/routes/settings.ts +80 -0
  22. package/api/src/routes/setup.ts +389 -0
  23. package/api/src/routes/stats.ts +81 -0
  24. package/api/src/routes/tts.ts +190 -0
  25. package/api/src/services/BaselineConfigService.ts +208 -0
  26. package/api/src/services/ChatService.ts +204 -0
  27. package/api/src/services/GoogleDriveService.ts +331 -0
  28. package/api/src/services/GoogleSheetsService.ts +1107 -0
  29. package/api/src/services/IngestionService.ts +1187 -0
  30. package/api/src/services/ModelCapabilityService.ts +248 -0
  31. package/api/src/services/PolicyEngine.ts +1625 -0
  32. package/api/src/services/PolicyLearningService.ts +527 -0
  33. package/api/src/services/PolicyLoader.ts +249 -0
  34. package/api/src/services/RAGService.ts +391 -0
  35. package/api/src/services/SDKService.ts +249 -0
  36. package/api/src/services/supabase.ts +113 -0
  37. package/api/src/utils/Actuator.ts +284 -0
  38. package/api/src/utils/actions/ActionHandler.ts +34 -0
  39. package/api/src/utils/actions/AppendToGSheetAction.ts +260 -0
  40. package/api/src/utils/actions/AutoRenameAction.ts +58 -0
  41. package/api/src/utils/actions/CopyAction.ts +120 -0
  42. package/api/src/utils/actions/CopyToGDriveAction.ts +64 -0
  43. package/api/src/utils/actions/LogCsvAction.ts +48 -0
  44. package/api/src/utils/actions/NotifyAction.ts +39 -0
  45. package/api/src/utils/actions/RenameAction.ts +57 -0
  46. package/api/src/utils/actions/WebhookAction.ts +58 -0
  47. package/api/src/utils/actions/utils.ts +293 -0
  48. package/api/src/utils/llmResponse.ts +61 -0
  49. package/api/src/utils/logger.ts +67 -0
  50. package/bin/folio-deploy.js +12 -0
  51. package/bin/folio-setup.js +45 -0
  52. package/bin/folio.js +65 -0
  53. package/dist/api/server.js +106 -0
  54. package/dist/api/src/config/index.js +81 -0
  55. package/dist/api/src/middleware/auth.js +93 -0
  56. package/dist/api/src/middleware/errorHandler.js +73 -0
  57. package/dist/api/src/middleware/index.js +4 -0
  58. package/dist/api/src/middleware/rateLimit.js +43 -0
  59. package/dist/api/src/middleware/validation.js +54 -0
  60. package/dist/api/src/routes/accounts.js +110 -0
  61. package/dist/api/src/routes/baseline-config.js +91 -0
  62. package/dist/api/src/routes/chat.js +114 -0
  63. package/dist/api/src/routes/health.js +52 -0
  64. package/dist/api/src/routes/index.js +31 -0
  65. package/dist/api/src/routes/ingestions.js +207 -0
  66. package/dist/api/src/routes/migrate.js +91 -0
  67. package/dist/api/src/routes/policies.js +86 -0
  68. package/dist/api/src/routes/processing.js +75 -0
  69. package/dist/api/src/routes/rules.js +8 -0
  70. package/dist/api/src/routes/sdk.js +80 -0
  71. package/dist/api/src/routes/settings.js +68 -0
  72. package/dist/api/src/routes/setup.js +315 -0
  73. package/dist/api/src/routes/stats.js +62 -0
  74. package/dist/api/src/routes/tts.js +178 -0
  75. package/dist/api/src/services/BaselineConfigService.js +168 -0
  76. package/dist/api/src/services/ChatService.js +166 -0
  77. package/dist/api/src/services/GoogleDriveService.js +280 -0
  78. package/dist/api/src/services/GoogleSheetsService.js +795 -0
  79. package/dist/api/src/services/IngestionService.js +990 -0
  80. package/dist/api/src/services/ModelCapabilityService.js +179 -0
  81. package/dist/api/src/services/PolicyEngine.js +1353 -0
  82. package/dist/api/src/services/PolicyLearningService.js +397 -0
  83. package/dist/api/src/services/PolicyLoader.js +159 -0
  84. package/dist/api/src/services/RAGService.js +295 -0
  85. package/dist/api/src/services/SDKService.js +212 -0
  86. package/dist/api/src/services/supabase.js +72 -0
  87. package/dist/api/src/utils/Actuator.js +225 -0
  88. package/dist/api/src/utils/actions/ActionHandler.js +1 -0
  89. package/dist/api/src/utils/actions/AppendToGSheetAction.js +191 -0
  90. package/dist/api/src/utils/actions/AutoRenameAction.js +49 -0
  91. package/dist/api/src/utils/actions/CopyAction.js +112 -0
  92. package/dist/api/src/utils/actions/CopyToGDriveAction.js +55 -0
  93. package/dist/api/src/utils/actions/LogCsvAction.js +42 -0
  94. package/dist/api/src/utils/actions/NotifyAction.js +32 -0
  95. package/dist/api/src/utils/actions/RenameAction.js +51 -0
  96. package/dist/api/src/utils/actions/WebhookAction.js +51 -0
  97. package/dist/api/src/utils/actions/utils.js +237 -0
  98. package/dist/api/src/utils/llmResponse.js +63 -0
  99. package/dist/api/src/utils/logger.js +51 -0
  100. package/dist/assets/index-DzN8-j-e.css +1 -0
  101. package/dist/assets/index-Uy-ai3Dh.js +113 -0
  102. package/dist/favicon.svg +31 -0
  103. package/dist/folio-logo.svg +46 -0
  104. package/dist/index.html +14 -0
  105. package/docs-dev/FPE-spec.md +196 -0
  106. package/docs-dev/folio-prd.md +47 -0
  107. package/docs-dev/foundation-checklist.md +30 -0
  108. package/docs-dev/hybrid-routing-architecture.md +205 -0
  109. package/docs-dev/ingestion-engine.md +69 -0
  110. package/docs-dev/port-from-email-automator.md +32 -0
  111. package/docs-dev/tech-spec.md +98 -0
  112. package/index.html +13 -0
  113. package/package.json +101 -0
  114. package/public/favicon.svg +31 -0
  115. package/public/folio-logo.svg +46 -0
  116. package/scripts/dev-task.mjs +51 -0
  117. package/scripts/get-latest-migration-timestamp.mjs +34 -0
  118. package/scripts/migrate.sh +91 -0
  119. package/supabase/.temp/cli-latest +1 -0
  120. package/supabase/.temp/gotrue-version +1 -0
  121. package/supabase/.temp/pooler-url +1 -0
  122. package/supabase/.temp/postgres-version +1 -0
  123. package/supabase/.temp/project-ref +1 -0
  124. package/supabase/.temp/rest-version +1 -0
  125. package/supabase/.temp/storage-migration +1 -0
  126. package/supabase/.temp/storage-version +1 -0
  127. package/supabase/config.toml +64 -0
  128. package/supabase/functions/_shared/auth.ts +35 -0
  129. package/supabase/functions/_shared/cors.ts +12 -0
  130. package/supabase/functions/_shared/supabaseAdmin.ts +17 -0
  131. package/supabase/functions/api-v1-settings/index.ts +66 -0
  132. package/supabase/functions/setup/index.ts +91 -0
  133. package/supabase/migrations/20260223000000_initial_foundation.sql +136 -0
  134. package/supabase/migrations/20260223000001_add_migration_rpc.sql +10 -0
  135. package/supabase/migrations/20260224000002_add_init_state_view.sql +20 -0
  136. package/supabase/migrations/20260224000003_port_user_creation_parity.sql +139 -0
  137. package/supabase/migrations/20260224000004_add_avatars_storage.sql +26 -0
  138. package/supabase/migrations/20260224000005_add_tts_and_embed_settings.sql +24 -0
  139. package/supabase/migrations/20260224000006_add_policies_table.sql +48 -0
  140. package/supabase/migrations/20260224000007_fix_migration_rpc.sql +9 -0
  141. package/supabase/migrations/20260224000008_add_ingestions_table.sql +42 -0
  142. package/supabase/migrations/20260225000000_setup_compatible_mode.sql +119 -0
  143. package/supabase/migrations/20260225000001_restore_ingestions.sql +49 -0
  144. package/supabase/migrations/20260225000002_add_ingestion_trace.sql +2 -0
  145. package/supabase/migrations/20260225000003_add_baseline_configs.sql +35 -0
  146. package/supabase/migrations/20260226000000_add_processing_events.sql +26 -0
  147. package/supabase/migrations/20260226000001_add_ingestion_file_hash.sql +10 -0
  148. package/supabase/migrations/20260226000002_add_dynamic_rag.sql +150 -0
  149. package/supabase/migrations/20260226000003_add_ingestion_summary.sql +4 -0
  150. package/supabase/migrations/20260226000004_add_ingestion_tags.sql +7 -0
  151. package/supabase/migrations/20260226000005_add_chat_tables.sql +60 -0
  152. package/supabase/migrations/20260227000000_harden_chat_messages_rls.sql +25 -0
  153. package/supabase/migrations/20260228000000_add_vision_model_capabilities.sql +8 -0
  154. package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +51 -0
  155. package/supabase/migrations/29991231235959_test_migration.sql +0 -0
  156. package/supabase/templates/confirmation.html +76 -0
  157. package/supabase/templates/email-change.html +76 -0
  158. package/supabase/templates/invite.html +72 -0
  159. package/supabase/templates/magic-link.html +68 -0
  160. package/supabase/templates/recovery.html +82 -0
  161. package/tsconfig.api.json +16 -0
  162. package/tsconfig.json +25 -0
  163. 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
+ }