@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,397 @@
1
+ import { createLogger } from "../utils/logger.js";
2
+ const logger = createLogger("PolicyLearningService");
3
+ function normalizeText(value) {
4
+ if (value == null)
5
+ return "";
6
+ return String(value).toLowerCase().trim();
7
+ }
8
+ function tokenize(value) {
9
+ const normalized = normalizeText(value)
10
+ .replace(/[^a-z0-9]+/g, " ")
11
+ .trim();
12
+ if (!normalized)
13
+ return [];
14
+ return normalized
15
+ .split(/\s+/)
16
+ .map((token) => token.trim())
17
+ .filter((token) => token.length >= 2);
18
+ }
19
+ function dedupeTokens(tokens, limit = 100) {
20
+ const seen = new Set();
21
+ const out = [];
22
+ for (const token of tokens) {
23
+ if (seen.has(token))
24
+ continue;
25
+ seen.add(token);
26
+ out.push(token);
27
+ if (out.length >= limit)
28
+ break;
29
+ }
30
+ return out;
31
+ }
32
+ function toRecord(value) {
33
+ if (!value || typeof value !== "object" || Array.isArray(value))
34
+ return null;
35
+ return value;
36
+ }
37
+ function extractExtension(filename) {
38
+ const name = normalizeText(filename);
39
+ if (!name)
40
+ return undefined;
41
+ const dot = name.lastIndexOf(".");
42
+ if (dot < 0 || dot === name.length - 1)
43
+ return undefined;
44
+ const ext = name.slice(dot + 1).replace(/[^a-z0-9]/g, "");
45
+ return ext || undefined;
46
+ }
47
+ function flattenValues(value, depth = 0) {
48
+ if (value == null || depth > 2)
49
+ return [];
50
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
51
+ return [String(value)];
52
+ }
53
+ if (Array.isArray(value)) {
54
+ return value.flatMap((item) => flattenValues(item, depth + 1));
55
+ }
56
+ const record = toRecord(value);
57
+ if (!record)
58
+ return [];
59
+ return Object.values(record).flatMap((item) => flattenValues(item, depth + 1));
60
+ }
61
+ function normalizeFeatures(value) {
62
+ const record = toRecord(value);
63
+ if (!record)
64
+ return null;
65
+ const rawTokens = Array.isArray(record.tokens) ? record.tokens.map((t) => normalizeText(t)).filter(Boolean) : [];
66
+ const tokens = dedupeTokens(rawTokens, 120);
67
+ if (tokens.length === 0)
68
+ return null;
69
+ const extension = normalizeText(record.extension) || undefined;
70
+ const mime_type = normalizeText(record.mime_type) || undefined;
71
+ const document_type = normalizeText(record.document_type) || undefined;
72
+ const issuer = normalizeText(record.issuer) || undefined;
73
+ return { tokens, extension, mime_type, document_type, issuer };
74
+ }
75
+ function jaccard(tokensA, tokensB) {
76
+ if (tokensA.length === 0 || tokensB.length === 0)
77
+ return 0;
78
+ const setA = new Set(tokensA);
79
+ const setB = new Set(tokensB);
80
+ let intersection = 0;
81
+ for (const token of setA) {
82
+ if (setB.has(token))
83
+ intersection += 1;
84
+ }
85
+ const union = setA.size + setB.size - intersection;
86
+ if (union === 0)
87
+ return 0;
88
+ return intersection / union;
89
+ }
90
+ function softTextMatch(a, b) {
91
+ if (!a || !b)
92
+ return false;
93
+ if (a === b)
94
+ return true;
95
+ return a.includes(b) || b.includes(a);
96
+ }
97
+ function clamp01(value) {
98
+ if (value < 0)
99
+ return 0;
100
+ if (value > 1)
101
+ return 1;
102
+ return value;
103
+ }
104
+ function requiredScoreForSupport(support) {
105
+ return support >= 2 ? 0.72 : 0.82;
106
+ }
107
+ function scorePair(doc, sample) {
108
+ let score = jaccard(doc.tokens, sample.tokens) * 0.72;
109
+ if (doc.extension && sample.extension) {
110
+ score += doc.extension === sample.extension ? 0.16 : -0.04;
111
+ }
112
+ if (doc.mime_type && sample.mime_type) {
113
+ score += doc.mime_type === sample.mime_type ? 0.08 : -0.02;
114
+ }
115
+ if (doc.document_type && sample.document_type) {
116
+ score += softTextMatch(doc.document_type, sample.document_type) ? 0.17 : -0.03;
117
+ }
118
+ if (doc.issuer && sample.issuer) {
119
+ score += softTextMatch(doc.issuer, sample.issuer) ? 0.14 : -0.02;
120
+ }
121
+ return clamp01(score);
122
+ }
123
+ function buildFromDocInput(opts) {
124
+ const extension = extractExtension(opts.filePath);
125
+ const baseline = opts.baselineEntities ?? {};
126
+ const docType = normalizeText(baseline.document_type ??
127
+ baseline.doc_type ??
128
+ baseline.type ??
129
+ baseline.category) || undefined;
130
+ const issuer = normalizeText(baseline.issuer ??
131
+ baseline.vendor ??
132
+ baseline.merchant ??
133
+ baseline.store_name ??
134
+ baseline.sender) || undefined;
135
+ const extractedTokens = flattenValues(baseline).flatMap((value) => tokenize(value));
136
+ const fileTokens = tokenize(opts.filePath.split("/").pop() ?? opts.filePath);
137
+ const textTokens = tokenize((opts.documentText ?? "").slice(0, 1200));
138
+ const tokens = dedupeTokens([
139
+ ...fileTokens,
140
+ ...extractedTokens,
141
+ ...textTokens,
142
+ ...(docType ? tokenize(docType) : []),
143
+ ...(issuer ? tokenize(issuer) : []),
144
+ ], 120);
145
+ return {
146
+ tokens,
147
+ extension,
148
+ document_type: docType,
149
+ issuer,
150
+ };
151
+ }
152
+ function buildFromIngestionRow(ingestion) {
153
+ const extracted = toRecord(ingestion.extracted) ?? {};
154
+ const tags = Array.isArray(ingestion.tags) ? ingestion.tags.map((t) => String(t)) : [];
155
+ const extension = extractExtension(ingestion.filename);
156
+ const mime_type = normalizeText(ingestion.mime_type) || undefined;
157
+ const docType = normalizeText(extracted.document_type ??
158
+ extracted.doc_type ??
159
+ extracted.type ??
160
+ extracted.category) || undefined;
161
+ const issuer = normalizeText(extracted.issuer ??
162
+ extracted.vendor ??
163
+ extracted.merchant ??
164
+ extracted.store_name ??
165
+ extracted.sender) || undefined;
166
+ const extractedWithoutEnrichment = { ...extracted };
167
+ delete extractedWithoutEnrichment["_enrichment"];
168
+ const tokens = dedupeTokens([
169
+ ...tokenize(ingestion.filename),
170
+ ...tags.flatMap((tag) => tokenize(tag)),
171
+ ...flattenValues(extractedWithoutEnrichment).flatMap((value) => tokenize(value)),
172
+ ...(docType ? tokenize(docType) : []),
173
+ ...(issuer ? tokenize(issuer) : []),
174
+ ], 120);
175
+ return {
176
+ tokens,
177
+ extension,
178
+ mime_type,
179
+ document_type: docType,
180
+ issuer,
181
+ };
182
+ }
183
+ export class PolicyLearningService {
184
+ static async recordManualMatch(opts) {
185
+ const { supabase, userId, ingestion, policyId, policyName } = opts;
186
+ const features = buildFromIngestionRow(ingestion);
187
+ if (features.tokens.length === 0) {
188
+ logger.warn("Skipping policy learning feedback: no usable tokens", {
189
+ ingestionId: ingestion.id,
190
+ policyId,
191
+ });
192
+ return;
193
+ }
194
+ const row = {
195
+ user_id: userId,
196
+ ingestion_id: ingestion.id,
197
+ policy_id: policyId,
198
+ policy_name: policyName ?? null,
199
+ feedback_type: "manual_match",
200
+ features,
201
+ };
202
+ const { error } = await supabase
203
+ .from("policy_match_feedback")
204
+ .upsert(row, { onConflict: "user_id,ingestion_id,policy_id" });
205
+ if (error) {
206
+ logger.error("Failed to save policy match feedback", {
207
+ ingestionId: ingestion.id,
208
+ policyId,
209
+ error,
210
+ });
211
+ return;
212
+ }
213
+ logger.info("Saved policy learning feedback", {
214
+ ingestionId: ingestion.id,
215
+ policyId,
216
+ tokens: features.tokens.length,
217
+ });
218
+ }
219
+ static async getPolicyLearningStats(opts) {
220
+ const { supabase, userId } = opts;
221
+ const normalizedPolicyIds = (opts.policyIds ?? []).map((id) => id.trim()).filter(Boolean);
222
+ let query = supabase
223
+ .from("policy_match_feedback")
224
+ .select("policy_id,created_at")
225
+ .eq("user_id", userId)
226
+ .order("created_at", { ascending: false })
227
+ .limit(5000);
228
+ if (normalizedPolicyIds.length > 0) {
229
+ query = query.in("policy_id", normalizedPolicyIds);
230
+ }
231
+ const { data, error } = await query;
232
+ if (error) {
233
+ logger.warn("Failed to read policy learning stats", { userId, error });
234
+ return {};
235
+ }
236
+ const stats = {};
237
+ for (const row of data ?? []) {
238
+ const policyId = typeof row.policy_id === "string" ? row.policy_id : "";
239
+ if (!policyId)
240
+ continue;
241
+ const createdAt = typeof row.created_at === "string" ? row.created_at : null;
242
+ if (!stats[policyId]) {
243
+ stats[policyId] = { samples: 1, lastSampleAt: createdAt };
244
+ continue;
245
+ }
246
+ stats[policyId].samples += 1;
247
+ if (!stats[policyId].lastSampleAt && createdAt) {
248
+ stats[policyId].lastSampleAt = createdAt;
249
+ }
250
+ }
251
+ return stats;
252
+ }
253
+ static async resolveLearnedCandidate(opts) {
254
+ const { supabase, userId, policyIds, filePath, baselineEntities, documentText } = opts;
255
+ if (policyIds.length === 0) {
256
+ return {
257
+ candidate: null,
258
+ diagnostics: {
259
+ reason: "no_policy_ids",
260
+ evaluatedPolicies: 0,
261
+ evaluatedSamples: 0,
262
+ topCandidates: [],
263
+ },
264
+ };
265
+ }
266
+ const docFeatures = buildFromDocInput({ filePath, baselineEntities, documentText });
267
+ if (docFeatures.tokens.length === 0) {
268
+ return {
269
+ candidate: null,
270
+ diagnostics: {
271
+ reason: "no_document_features",
272
+ evaluatedPolicies: policyIds.length,
273
+ evaluatedSamples: 0,
274
+ topCandidates: [],
275
+ },
276
+ };
277
+ }
278
+ const { data, error } = await supabase
279
+ .from("policy_match_feedback")
280
+ .select("policy_id,policy_name,features")
281
+ .eq("user_id", userId)
282
+ .in("policy_id", policyIds)
283
+ .order("created_at", { ascending: false })
284
+ .limit(400);
285
+ if (error) {
286
+ logger.warn("Failed to read policy learning feedback", { userId, error });
287
+ return {
288
+ candidate: null,
289
+ diagnostics: {
290
+ reason: "read_error",
291
+ evaluatedPolicies: policyIds.length,
292
+ evaluatedSamples: 0,
293
+ topCandidates: [],
294
+ },
295
+ };
296
+ }
297
+ const rows = (data ?? []);
298
+ if (rows.length === 0) {
299
+ return {
300
+ candidate: null,
301
+ diagnostics: {
302
+ reason: "no_feedback_samples",
303
+ evaluatedPolicies: policyIds.length,
304
+ evaluatedSamples: 0,
305
+ topCandidates: [],
306
+ },
307
+ };
308
+ }
309
+ const byPolicy = new Map();
310
+ let validSamples = 0;
311
+ for (const row of rows) {
312
+ const sample = normalizeFeatures(row.features);
313
+ if (!sample)
314
+ continue;
315
+ const score = scorePair(docFeatures, sample);
316
+ const existing = byPolicy.get(row.policy_id) ?? [];
317
+ existing.push(score);
318
+ byPolicy.set(row.policy_id, existing);
319
+ validSamples += 1;
320
+ }
321
+ if (byPolicy.size === 0) {
322
+ return {
323
+ candidate: null,
324
+ diagnostics: {
325
+ reason: "no_valid_samples",
326
+ evaluatedPolicies: policyIds.length,
327
+ evaluatedSamples: validSamples,
328
+ topCandidates: [],
329
+ },
330
+ };
331
+ }
332
+ const candidates = [];
333
+ for (const [policyId, scores] of byPolicy.entries()) {
334
+ if (scores.length === 0)
335
+ continue;
336
+ scores.sort((a, b) => b - a);
337
+ const topScores = scores.slice(0, 3);
338
+ const averageTop = topScores.reduce((sum, value) => sum + value, 0) / topScores.length;
339
+ const supportBoost = Math.min(0.08, (scores.length - 1) * 0.02);
340
+ const score = clamp01(averageTop + supportBoost);
341
+ const support = scores.length;
342
+ const requiredScore = requiredScoreForSupport(support);
343
+ candidates.push({
344
+ policyId,
345
+ score,
346
+ support,
347
+ requiredScore,
348
+ accepted: score >= requiredScore,
349
+ });
350
+ }
351
+ candidates.sort((a, b) => b.score - a.score);
352
+ const best = candidates[0];
353
+ const topCandidates = candidates.slice(0, 3);
354
+ if (!best) {
355
+ return {
356
+ candidate: null,
357
+ diagnostics: {
358
+ reason: "no_valid_samples",
359
+ evaluatedPolicies: byPolicy.size,
360
+ evaluatedSamples: validSamples,
361
+ topCandidates: [],
362
+ },
363
+ };
364
+ }
365
+ if (!best.accepted) {
366
+ return {
367
+ candidate: null,
368
+ diagnostics: {
369
+ reason: "score_below_threshold",
370
+ evaluatedPolicies: byPolicy.size,
371
+ evaluatedSamples: validSamples,
372
+ bestCandidate: best,
373
+ topCandidates,
374
+ },
375
+ };
376
+ }
377
+ logger.info("Resolved learned policy candidate", {
378
+ policyId: best.policyId,
379
+ score: best.score,
380
+ support: best.support,
381
+ });
382
+ return {
383
+ candidate: {
384
+ policyId: best.policyId,
385
+ score: best.score,
386
+ support: best.support,
387
+ },
388
+ diagnostics: {
389
+ reason: "accepted",
390
+ evaluatedPolicies: byPolicy.size,
391
+ evaluatedSamples: validSamples,
392
+ bestCandidate: best,
393
+ topCandidates,
394
+ },
395
+ };
396
+ }
397
+ }
@@ -0,0 +1,159 @@
1
+ import { createLogger } from "../utils/logger.js";
2
+ const logger = createLogger("PolicyLoader");
3
+ // ─── Cache ───────────────────────────────────────────────────────────────────
4
+ // Keyed by user_id so one user's policies never bleed into another's.
5
+ const _cache = new Map();
6
+ const CACHE_TTL_MS = 30_000;
7
+ // ─── Row → Policy ────────────────────────────────────────────────────────────
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ function rowToPolicy(row) {
10
+ return {
11
+ apiVersion: row.api_version ?? "folio/v1",
12
+ kind: row.kind ?? "Policy",
13
+ metadata: {
14
+ ...row.metadata,
15
+ id: row.policy_id,
16
+ priority: row.priority,
17
+ enabled: row.enabled,
18
+ },
19
+ spec: row.spec,
20
+ };
21
+ }
22
+ // ─── PolicyLoader ────────────────────────────────────────────────────────────
23
+ export class PolicyLoader {
24
+ /**
25
+ * Load all policies for the authenticated user from Supabase.
26
+ * Returns [] if no Supabase client is provided (unauthenticated state).
27
+ */
28
+ static async load(forceRefresh = false, supabase) {
29
+ if (!supabase) {
30
+ logger.info("No Supabase client — policies require authentication");
31
+ return [];
32
+ }
33
+ // Resolve the user ID to scope the cache correctly
34
+ const { data: { user } } = await supabase.auth.getUser();
35
+ const userId = user?.id ?? "anonymous";
36
+ const now = Date.now();
37
+ const cached = _cache.get(userId);
38
+ if (!forceRefresh && cached && now - cached.loadedAt < CACHE_TTL_MS) {
39
+ return cached.policies;
40
+ }
41
+ try {
42
+ const { data, error } = await supabase
43
+ .from("policies")
44
+ .select("*")
45
+ .eq("enabled", true)
46
+ .order("priority", { ascending: false });
47
+ if (error)
48
+ throw error;
49
+ const policies = (data ?? []).map(rowToPolicy);
50
+ _cache.set(userId, { policies, loadedAt: Date.now() });
51
+ logger.info(`Loaded ${policies.length} policies from DB for user ${userId}`);
52
+ return policies;
53
+ }
54
+ catch (err) {
55
+ logger.error("Failed to load policies from DB", { err });
56
+ return [];
57
+ }
58
+ }
59
+ static invalidateCache(userId) {
60
+ if (userId) {
61
+ _cache.delete(userId);
62
+ }
63
+ else {
64
+ _cache.clear();
65
+ }
66
+ }
67
+ static validate(policy) {
68
+ if (!policy || typeof policy !== "object")
69
+ return false;
70
+ const p = policy;
71
+ return (p.apiVersion === "folio/v1" &&
72
+ typeof p.metadata?.id === "string" &&
73
+ typeof p.metadata?.priority === "number" &&
74
+ typeof p.spec?.match?.strategy === "string" &&
75
+ Array.isArray(p.spec?.match?.conditions));
76
+ }
77
+ /**
78
+ * Save (upsert) a policy to Supabase.
79
+ * Throws if no Supabase client is available.
80
+ */
81
+ static async save(policy, supabase, userId) {
82
+ if (!supabase || !userId) {
83
+ throw new Error("Authentication required to save policies");
84
+ }
85
+ const row = {
86
+ user_id: userId,
87
+ policy_id: policy.metadata.id,
88
+ api_version: policy.apiVersion,
89
+ kind: policy.kind,
90
+ metadata: policy.metadata,
91
+ spec: policy.spec,
92
+ enabled: policy.metadata.enabled ?? true,
93
+ priority: policy.metadata.priority,
94
+ };
95
+ const { error } = await supabase
96
+ .from("policies")
97
+ .upsert(row, { onConflict: "user_id,policy_id" });
98
+ if (error)
99
+ throw new Error(`Failed to save policy: ${error.message}`);
100
+ this.invalidateCache();
101
+ logger.info(`Saved policy to DB: ${policy.metadata.id}`);
102
+ return `db:policies/${policy.metadata.id}`;
103
+ }
104
+ /**
105
+ * Partially update a policy (enabled toggle, name, description, tags, priority).
106
+ */
107
+ static async patch(policyId, patch, supabase, userId) {
108
+ if (!supabase || !userId) {
109
+ throw new Error("Authentication required to update policies");
110
+ }
111
+ const { data: existing, error: fetchErr } = await supabase
112
+ .from("policies")
113
+ .select("metadata, priority, enabled")
114
+ .eq("policy_id", policyId)
115
+ .eq("user_id", userId)
116
+ .single();
117
+ if (fetchErr || !existing)
118
+ throw new Error("Policy not found");
119
+ const updatedMetadata = {
120
+ ...existing.metadata,
121
+ ...(patch.name !== undefined && { name: patch.name }),
122
+ ...(patch.description !== undefined && { description: patch.description }),
123
+ ...(patch.tags !== undefined && { tags: patch.tags }),
124
+ ...(patch.priority !== undefined && { priority: patch.priority }),
125
+ };
126
+ const { error } = await supabase
127
+ .from("policies")
128
+ .update({
129
+ metadata: updatedMetadata,
130
+ enabled: patch.enabled ?? existing.enabled,
131
+ priority: patch.priority ?? existing.priority,
132
+ })
133
+ .eq("policy_id", policyId)
134
+ .eq("user_id", userId);
135
+ if (error)
136
+ throw new Error(`Failed to patch policy: ${error.message}`);
137
+ this.invalidateCache();
138
+ logger.info(`Patched policy: ${policyId}`);
139
+ return true;
140
+ }
141
+ /**
142
+ * Delete a policy by ID from Supabase.
143
+ * Throws if no Supabase client is available.
144
+ */
145
+ static async delete(policyId, supabase, userId) {
146
+ if (!supabase || !userId) {
147
+ throw new Error("Authentication required to delete policies");
148
+ }
149
+ const { error, count } = await supabase
150
+ .from("policies")
151
+ .delete({ count: "exact" })
152
+ .eq("policy_id", policyId)
153
+ .eq("user_id", userId);
154
+ if (error)
155
+ throw new Error(`Failed to delete policy: ${error.message}`);
156
+ this.invalidateCache();
157
+ return (count ?? 0) > 0;
158
+ }
159
+ }