@littlebearapps/create-platform 1.0.0 → 1.1.0

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 (69) hide show
  1. package/README.md +98 -0
  2. package/dist/index.d.ts +6 -1
  3. package/dist/index.js +36 -6
  4. package/dist/prompts.d.ts +14 -2
  5. package/dist/prompts.js +29 -7
  6. package/dist/templates.js +78 -0
  7. package/package.json +3 -2
  8. package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
  9. package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
  10. package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
  11. package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
  12. package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
  13. package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
  14. package/templates/full/workers/pattern-discovery.ts +661 -0
  15. package/templates/full/workers/platform-alert-router.ts +1809 -0
  16. package/templates/full/workers/platform-notifications.ts +424 -0
  17. package/templates/full/workers/platform-search.ts +480 -0
  18. package/templates/full/workers/platform-settings.ts +436 -0
  19. package/templates/shared/workers/lib/analytics-engine.ts +357 -0
  20. package/templates/shared/workers/lib/billing.ts +293 -0
  21. package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
  22. package/templates/shared/workers/lib/control.ts +292 -0
  23. package/templates/shared/workers/lib/economics.ts +368 -0
  24. package/templates/shared/workers/lib/metrics.ts +103 -0
  25. package/templates/shared/workers/lib/platform-settings.ts +407 -0
  26. package/templates/shared/workers/lib/shared/allowances.ts +333 -0
  27. package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
  28. package/templates/shared/workers/lib/shared/types.ts +58 -0
  29. package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
  30. package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
  31. package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
  32. package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
  33. package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
  34. package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
  35. package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
  36. package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
  37. package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
  38. package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
  39. package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
  40. package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
  41. package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
  42. package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
  43. package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
  44. package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
  45. package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
  46. package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
  47. package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
  48. package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
  49. package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
  50. package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
  51. package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
  52. package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
  53. package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
  54. package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
  55. package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
  56. package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
  57. package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
  58. package/templates/shared/workers/platform-usage.ts +1915 -0
  59. package/templates/standard/workers/error-collector.ts +2670 -0
  60. package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
  61. package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
  62. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
  63. package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
  64. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
  65. package/templates/standard/workers/lib/error-collector/github.ts +329 -0
  66. package/templates/standard/workers/lib/error-collector/types.ts +262 -0
  67. package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
  68. package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
  69. package/templates/standard/workers/platform-sentinel.ts +1744 -0
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Shadow Evaluation for Self-Tuning Pattern System
3
+ *
4
+ * Evaluates patterns in shadow mode for auto-promotion to approved,
5
+ * and approved patterns for auto-demotion to stale.
6
+ *
7
+ * @module workers/lib/pattern-discovery/shadow-evaluation
8
+ */
9
+
10
+ import type { D1Database, KVNamespace } from '@cloudflare/workers-types';
11
+ import type {
12
+ PatternSuggestion,
13
+ ShadowEvaluationResult,
14
+ ShadowEvaluationConfig,
15
+ PatternRule,
16
+ } from './types';
17
+ import { logAuditEvent, refreshDynamicPatternsCache, aggregatePatternEvidence } from './storage';
18
+ import { generateReviewContext } from './ai-prompt';
19
+ import { compilePattern, backtestPattern, storeBacktestResult } from './validation';
20
+ import type { Logger } from '@littlebearapps/platform-sdk';
21
+
22
+ /** Default evaluation configuration */
23
+ export const DEFAULT_EVALUATION_CONFIG: ShadowEvaluationConfig = {
24
+ minMatchesForPromotion: 5,
25
+ minSpreadDaysForPromotion: 3,
26
+ maxMatchRateForPromotion: 0.8,
27
+ shadowPeriodDays: 7,
28
+ staleDaysThreshold: 30,
29
+ };
30
+
31
+ /** Confidence-based shadow period multipliers */
32
+ const CONFIDENCE_SHADOW_PERIODS: Record<string, number> = {
33
+ high: 3, // >=90% confidence: 3 days
34
+ medium: 7, // 70-89%: 7 days
35
+ low: 14, // 50-69%: 14 days
36
+ };
37
+
38
+ /**
39
+ * Get shadow period based on confidence score
40
+ */
41
+ export function getShadowPeriodDays(confidenceScore: number): number {
42
+ if (confidenceScore >= 0.9) return CONFIDENCE_SHADOW_PERIODS.high;
43
+ if (confidenceScore >= 0.7) return CONFIDENCE_SHADOW_PERIODS.medium;
44
+ return CONFIDENCE_SHADOW_PERIODS.low;
45
+ }
46
+
47
+ /**
48
+ * Move a pending pattern into shadow mode
49
+ */
50
+ export async function enterShadowMode(
51
+ db: D1Database,
52
+ patternId: string,
53
+ log: Logger
54
+ ): Promise<boolean> {
55
+ const now = Math.floor(Date.now() / 1000);
56
+
57
+ try {
58
+ // Get the pattern to determine shadow period
59
+ const pattern = await db
60
+ .prepare('SELECT confidence_score FROM transient_pattern_suggestions WHERE id = ?')
61
+ .bind(patternId)
62
+ .first<{ confidence_score: number }>();
63
+
64
+ if (!pattern) {
65
+ log.warn('Pattern not found for shadow mode', { patternId });
66
+ return false;
67
+ }
68
+
69
+ const shadowDays = getShadowPeriodDays(pattern.confidence_score || 0.5);
70
+ const shadowEnd = now + shadowDays * 24 * 60 * 60;
71
+
72
+ await db
73
+ .prepare(
74
+ `
75
+ UPDATE transient_pattern_suggestions
76
+ SET status = 'shadow',
77
+ shadow_mode_start = ?,
78
+ shadow_mode_end = ?,
79
+ shadow_mode_matches = 0,
80
+ shadow_match_days = '[]',
81
+ updated_at = unixepoch()
82
+ WHERE id = ? AND status = 'pending'
83
+ `
84
+ )
85
+ .bind(now, shadowEnd, patternId)
86
+ .run();
87
+
88
+ await logAuditEvent(db, patternId, 'shadow-started', 'system:shadow-evaluator',
89
+ `Entered shadow mode for ${shadowDays} days`, {
90
+ shadowDays,
91
+ confidenceScore: pattern.confidence_score,
92
+ });
93
+
94
+ log.info('Pattern entered shadow mode', { patternId, shadowDays });
95
+ return true;
96
+ } catch (error) {
97
+ log.error('Failed to enter shadow mode', error, { patternId });
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Record a shadow match for a pattern
104
+ */
105
+ export async function recordShadowMatch(
106
+ db: D1Database,
107
+ patternId: string
108
+ ): Promise<void> {
109
+ const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
110
+
111
+ // Get current shadow_match_days
112
+ const current = await db
113
+ .prepare('SELECT shadow_match_days FROM transient_pattern_suggestions WHERE id = ?')
114
+ .bind(patternId)
115
+ .first<{ shadow_match_days: string | null }>();
116
+
117
+ const existingDays: string[] = current?.shadow_match_days
118
+ ? JSON.parse(current.shadow_match_days)
119
+ : [];
120
+
121
+ // Add today if not already present
122
+ if (!existingDays.includes(today)) {
123
+ existingDays.push(today);
124
+ }
125
+
126
+ await db
127
+ .prepare(
128
+ `
129
+ UPDATE transient_pattern_suggestions
130
+ SET shadow_mode_matches = shadow_mode_matches + 1,
131
+ shadow_match_days = ?,
132
+ last_matched_at = unixepoch(),
133
+ updated_at = unixepoch()
134
+ WHERE id = ?
135
+ `
136
+ )
137
+ .bind(JSON.stringify(existingDays), patternId)
138
+ .run();
139
+ }
140
+
141
+ /**
142
+ * Evaluate a single shadow pattern for promotion
143
+ */
144
+ export async function evaluateShadowPattern(
145
+ db: D1Database,
146
+ pattern: PatternSuggestion,
147
+ config: ShadowEvaluationConfig,
148
+ log: Logger
149
+ ): Promise<ShadowEvaluationResult> {
150
+ const now = Math.floor(Date.now() / 1000);
151
+
152
+ // Check if shadow period has ended
153
+ const shadowEnded = pattern.shadowModeEnd && now >= pattern.shadowModeEnd;
154
+ const shadowDays = pattern.shadowModeStart
155
+ ? Math.floor((now - pattern.shadowModeStart) / (24 * 60 * 60))
156
+ : 0;
157
+
158
+ const matchSpreadDays = pattern.shadowMatchDays?.length || 0;
159
+ const shadowMatches = pattern.shadowModeMatches || 0;
160
+
161
+ // Calculate current match rate via backtest
162
+ const rule: PatternRule = {
163
+ type: pattern.patternType,
164
+ value: pattern.patternValue,
165
+ category: pattern.category,
166
+ scope: pattern.scope as PatternRule['scope'],
167
+ };
168
+
169
+ const backtestResult = await backtestPattern(pattern.id, rule, db, log, 7);
170
+
171
+ // Decision logic
172
+ let recommendation: 'promote' | 'demote' | 'continue' = 'continue';
173
+ let reasoning = '';
174
+
175
+ if (!shadowEnded) {
176
+ reasoning = `Shadow period ongoing (${shadowDays}/${config.shadowPeriodDays} days)`;
177
+ } else if (backtestResult.overMatching) {
178
+ recommendation = 'demote';
179
+ reasoning = `Over-matching: ${(backtestResult.matchRate * 100).toFixed(1)}% > ${config.maxMatchRateForPromotion * 100}%`;
180
+ } else if (shadowMatches < config.minMatchesForPromotion) {
181
+ recommendation = 'demote';
182
+ reasoning = `Insufficient matches: ${shadowMatches} < ${config.minMatchesForPromotion}`;
183
+ } else if (matchSpreadDays < config.minSpreadDaysForPromotion) {
184
+ recommendation = 'demote';
185
+ reasoning = `Insufficient spread: ${matchSpreadDays} days < ${config.minSpreadDaysForPromotion}`;
186
+ } else {
187
+ recommendation = 'promote';
188
+ reasoning = `Met criteria: ${shadowMatches} matches across ${matchSpreadDays} days, ${(backtestResult.matchRate * 100).toFixed(1)}% match rate`;
189
+ }
190
+
191
+ log.info('Shadow evaluation result', {
192
+ patternId: pattern.id,
193
+ recommendation,
194
+ shadowMatches,
195
+ matchSpreadDays,
196
+ matchRate: backtestResult.matchRate,
197
+ reasoning,
198
+ });
199
+
200
+ return {
201
+ patternId: pattern.id,
202
+ shadowMatchCount: shadowMatches,
203
+ shadowDays,
204
+ matchSpreadDays,
205
+ currentMatchRate: backtestResult.matchRate,
206
+ recommendation,
207
+ reasoning,
208
+ };
209
+ }
210
+
211
+ /** Environment needed for AI context generation */
212
+ export interface AIContextEnv {
213
+ CLOUDFLARE_ACCOUNT_ID: string;
214
+ PLATFORM_AI_GATEWAY_KEY: string;
215
+ }
216
+
217
+ /**
218
+ * Mark a shadow pattern as ready for human review (no auto-promotion)
219
+ *
220
+ * Instead of auto-promoting patterns, we aggregate evidence and store it
221
+ * as review_context for human decision-making. Optionally generates AI explainer.
222
+ */
223
+ export async function markReadyForReview(
224
+ db: D1Database,
225
+ patternId: string,
226
+ evaluation: ShadowEvaluationResult,
227
+ log: Logger,
228
+ env?: AIContextEnv
229
+ ): Promise<boolean> {
230
+ try {
231
+ // Get pattern details for AI context
232
+ const patternDetails = await db
233
+ .prepare(`
234
+ SELECT pattern_type, pattern_value, category, confidence_score, ai_reasoning
235
+ FROM transient_pattern_suggestions WHERE id = ?
236
+ `)
237
+ .bind(patternId)
238
+ .first<{
239
+ pattern_type: string;
240
+ pattern_value: string;
241
+ category: string;
242
+ confidence_score: number;
243
+ ai_reasoning: string | null;
244
+ }>();
245
+
246
+ if (!patternDetails) {
247
+ log.warn('Pattern not found for review context', { patternId });
248
+ return false;
249
+ }
250
+
251
+ // Aggregate evidence from pattern_match_evidence table
252
+ const evidence = await aggregatePatternEvidence(db, patternId);
253
+
254
+ // Try to generate AI explainer if env is provided
255
+ let aiExplainer: Awaited<ReturnType<typeof generateReviewContext>> = null;
256
+ if (env) {
257
+ aiExplainer = await generateReviewContext(
258
+ {
259
+ patternType: patternDetails.pattern_type,
260
+ patternValue: patternDetails.pattern_value,
261
+ category: patternDetails.category,
262
+ confidenceScore: patternDetails.confidence_score,
263
+ aiReasoning: patternDetails.ai_reasoning ?? undefined,
264
+ },
265
+ evidence,
266
+ env,
267
+ log
268
+ );
269
+ }
270
+
271
+ // Build review context JSON for human review
272
+ const reviewContext = {
273
+ ...evidence,
274
+ evaluatedAt: Math.floor(Date.now() / 1000),
275
+ recommendation: aiExplainer?.recommendation ?? ('likely-approve' as const),
276
+ reasoning: evaluation.reasoning,
277
+ shadowMatchCount: evaluation.shadowMatchCount,
278
+ matchSpreadDays: evaluation.matchSpreadDays,
279
+ matchRate: evaluation.currentMatchRate,
280
+ // AI-generated explainer fields
281
+ aiExplainer: aiExplainer?.summary ?? null,
282
+ whatItCatches: aiExplainer?.whatItCatches ?? null,
283
+ whyTransient: aiExplainer?.whyTransient ?? null,
284
+ affectedAreas: aiExplainer?.affectedAreas ?? null,
285
+ concerns: aiExplainer?.concerns ?? [],
286
+ };
287
+
288
+ // Keep status as 'shadow' but add review_context
289
+ // This signals the pattern is ready for human review
290
+ await db
291
+ .prepare(
292
+ `
293
+ UPDATE transient_pattern_suggestions
294
+ SET review_context = ?,
295
+ updated_at = unixepoch()
296
+ WHERE id = ? AND status = 'shadow'
297
+ `
298
+ )
299
+ .bind(JSON.stringify(reviewContext), patternId)
300
+ .run();
301
+
302
+ await logAuditEvent(db, patternId, 'ready-for-review', 'system:shadow-evaluator',
303
+ 'Evidence collected, awaiting human review', {
304
+ totalMatches: evidence.totalMatches,
305
+ distinctDays: evidence.distinctDays,
306
+ hasAiExplainer: !!aiExplainer,
307
+ });
308
+
309
+ log.info('Pattern marked ready for review', {
310
+ patternId,
311
+ totalMatches: evidence.totalMatches,
312
+ distinctDays: evidence.distinctDays,
313
+ projects: Object.keys(evidence.matchesByProject).length,
314
+ hasAiExplainer: !!aiExplainer,
315
+ });
316
+
317
+ return true;
318
+ } catch (error) {
319
+ log.error('Failed to mark pattern ready for review', error, { patternId });
320
+ return false;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * @deprecated Use markReadyForReview instead - patterns should not auto-promote
326
+ * Kept for reference during migration
327
+ */
328
+ export async function autoPromotePattern(
329
+ db: D1Database,
330
+ kv: KVNamespace,
331
+ patternId: string,
332
+ evaluation: ShadowEvaluationResult,
333
+ log: Logger
334
+ ): Promise<boolean> {
335
+ log.warn('autoPromotePattern is deprecated - use markReadyForReview instead', { patternId });
336
+ // Redirect to markReadyForReview to preserve existing API
337
+ return markReadyForReview(db, patternId, evaluation, log);
338
+ }
339
+
340
+ /**
341
+ * Auto-demote a shadow pattern (failed evaluation)
342
+ */
343
+ export async function autoDemoteShadowPattern(
344
+ db: D1Database,
345
+ patternId: string,
346
+ evaluation: ShadowEvaluationResult,
347
+ log: Logger
348
+ ): Promise<boolean> {
349
+ const now = Math.floor(Date.now() / 1000);
350
+
351
+ try {
352
+ await db
353
+ .prepare(
354
+ `
355
+ UPDATE transient_pattern_suggestions
356
+ SET status = 'rejected',
357
+ shadow_mode_end = ?,
358
+ rejection_reason = ?,
359
+ reviewed_by = 'system:auto-demote',
360
+ reviewed_at = ?,
361
+ updated_at = unixepoch()
362
+ WHERE id = ? AND status = 'shadow'
363
+ `
364
+ )
365
+ .bind(now, evaluation.reasoning, now, patternId)
366
+ .run();
367
+
368
+ await logAuditEvent(db, patternId, 'auto-demoted', 'system:shadow-evaluator',
369
+ evaluation.reasoning, {
370
+ shadowMatchCount: evaluation.shadowMatchCount,
371
+ matchSpreadDays: evaluation.matchSpreadDays,
372
+ matchRate: evaluation.currentMatchRate,
373
+ });
374
+
375
+ log.info('Shadow pattern auto-demoted', { patternId, reasoning: evaluation.reasoning });
376
+ return true;
377
+ } catch (error) {
378
+ log.error('Failed to auto-demote shadow pattern', error, { patternId });
379
+ return false;
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Check approved patterns for staleness
385
+ */
386
+ export async function checkForStalePatterns(
387
+ db: D1Database,
388
+ kv: KVNamespace,
389
+ config: ShadowEvaluationConfig,
390
+ log: Logger
391
+ ): Promise<{ demoted: number; checked: number }> {
392
+ const staleCutoff = Math.floor(Date.now() / 1000) - config.staleDaysThreshold * 24 * 60 * 60;
393
+
394
+ // Find approved, non-protected patterns with no recent matches
395
+ const result = await db
396
+ .prepare(
397
+ `
398
+ SELECT id, pattern_value, category, last_matched_at, match_count
399
+ FROM transient_pattern_suggestions
400
+ WHERE status = 'approved'
401
+ AND is_protected = 0
402
+ AND (last_matched_at IS NULL OR last_matched_at < ?)
403
+ AND match_count > 0
404
+ `
405
+ )
406
+ .bind(staleCutoff)
407
+ .all<{ id: string; pattern_value: string; category: string; last_matched_at: number | null; match_count: number }>();
408
+
409
+ let demoted = 0;
410
+
411
+ for (const pattern of result.results) {
412
+ const now = Math.floor(Date.now() / 1000);
413
+
414
+ await db
415
+ .prepare(
416
+ `
417
+ UPDATE transient_pattern_suggestions
418
+ SET status = 'stale',
419
+ disabled_at = ?,
420
+ updated_at = unixepoch()
421
+ WHERE id = ?
422
+ `
423
+ )
424
+ .bind(now, pattern.id)
425
+ .run();
426
+
427
+ const daysSinceMatch = pattern.last_matched_at
428
+ ? Math.floor((now - pattern.last_matched_at) / (24 * 60 * 60))
429
+ : 'never';
430
+
431
+ await logAuditEvent(db, pattern.id, 'auto-demoted', 'system:stale-detector',
432
+ `No matches in ${config.staleDaysThreshold} days`, {
433
+ lastMatchedAt: pattern.last_matched_at,
434
+ daysSinceMatch,
435
+ totalMatches: pattern.match_count,
436
+ });
437
+
438
+ log.info('Pattern marked stale', {
439
+ patternId: pattern.id,
440
+ category: pattern.category,
441
+ daysSinceMatch,
442
+ });
443
+
444
+ demoted++;
445
+ }
446
+
447
+ // Refresh KV if any patterns were demoted
448
+ if (demoted > 0) {
449
+ await refreshDynamicPatternsCache(db, kv, log);
450
+ }
451
+
452
+ return { demoted, checked: result.results.length };
453
+ }
454
+
455
+ /**
456
+ * Get patterns in shadow mode that need evaluation
457
+ */
458
+ export async function getShadowPatternsForEvaluation(
459
+ db: D1Database
460
+ ): Promise<PatternSuggestion[]> {
461
+ const now = Math.floor(Date.now() / 1000);
462
+
463
+ const result = await db
464
+ .prepare(
465
+ `
466
+ SELECT
467
+ id, pattern_type as patternType, pattern_value as patternValue,
468
+ category, scope, confidence_score as confidenceScore,
469
+ sample_messages as sampleMessages, ai_reasoning as aiReasoning,
470
+ cluster_id as clusterId, status,
471
+ reviewed_by as reviewedBy, reviewed_at as reviewedAt,
472
+ rejection_reason as rejectionReason,
473
+ backtest_match_count as backtestMatchCount,
474
+ backtest_total_errors as backtestTotalErrors,
475
+ backtest_match_rate as backtestMatchRate,
476
+ shadow_mode_start as shadowModeStart,
477
+ shadow_mode_end as shadowModeEnd,
478
+ shadow_mode_matches as shadowModeMatches,
479
+ shadow_match_days as shadowMatchDays,
480
+ enabled_at as enabledAt, disabled_at as disabledAt,
481
+ last_matched_at as lastMatchedAt, match_count as matchCount,
482
+ is_protected as isProtected, source, original_regex as originalRegex,
483
+ created_at as createdAt, updated_at as updatedAt
484
+ FROM transient_pattern_suggestions
485
+ WHERE status = 'shadow'
486
+ AND shadow_mode_end <= ?
487
+ ORDER BY created_at ASC
488
+ `
489
+ )
490
+ .bind(now)
491
+ .all<PatternSuggestion & { sampleMessages: string; shadowMatchDays: string }>();
492
+
493
+ return result.results.map((r) => ({
494
+ ...r,
495
+ sampleMessages: JSON.parse(r.sampleMessages || '[]'),
496
+ shadowMatchDays: r.shadowMatchDays ? JSON.parse(r.shadowMatchDays) : [],
497
+ isProtected: Boolean(r.isProtected),
498
+ }));
499
+ }
500
+
501
+ /**
502
+ * Auto-enter shadow mode for pending patterns older than 24 hours
503
+ */
504
+ export async function autoEnterShadowForOldPending(
505
+ db: D1Database,
506
+ log: Logger
507
+ ): Promise<number> {
508
+ const cutoff = Math.floor(Date.now() / 1000) - 24 * 60 * 60; // 24 hours ago
509
+
510
+ const result = await db
511
+ .prepare(
512
+ `
513
+ SELECT id FROM transient_pattern_suggestions
514
+ WHERE status = 'pending'
515
+ AND created_at < ?
516
+ AND confidence_score >= 0.5
517
+ `
518
+ )
519
+ .bind(cutoff)
520
+ .all<{ id: string }>();
521
+
522
+ let entered = 0;
523
+
524
+ for (const pattern of result.results) {
525
+ const success = await enterShadowMode(db, pattern.id, log);
526
+ if (success) entered++;
527
+ }
528
+
529
+ if (entered > 0) {
530
+ log.info('Auto-entered shadow mode for pending patterns', { count: entered });
531
+ }
532
+
533
+ return entered;
534
+ }
535
+
536
+ /**
537
+ * Run full shadow evaluation cycle
538
+ *
539
+ * Note: Patterns that meet promotion criteria are marked as "ready for review"
540
+ * rather than auto-promoted. Human approval is required.
541
+ *
542
+ * @param env - Optional env for AI context generation. If provided, generates AI explainers.
543
+ */
544
+ export async function runShadowEvaluationCycle(
545
+ db: D1Database,
546
+ kv: KVNamespace,
547
+ log: Logger,
548
+ config: ShadowEvaluationConfig = DEFAULT_EVALUATION_CONFIG,
549
+ env?: AIContextEnv
550
+ ): Promise<{
551
+ evaluated: number;
552
+ readyForReview: number;
553
+ demoted: number;
554
+ enteredShadow: number;
555
+ staleDetected: number;
556
+ /** @deprecated Use readyForReview instead */
557
+ promoted?: number;
558
+ }> {
559
+ log.info('Starting shadow evaluation cycle', { hasAIEnv: !!env });
560
+
561
+ // Step 1: Auto-enter shadow for old pending patterns
562
+ const enteredShadow = await autoEnterShadowForOldPending(db, log);
563
+
564
+ // Step 2: Evaluate shadow patterns ready for decision
565
+ const shadowPatterns = await getShadowPatternsForEvaluation(db);
566
+ let readyForReview = 0;
567
+ let demoted = 0;
568
+
569
+ for (const pattern of shadowPatterns) {
570
+ const evaluation = await evaluateShadowPattern(db, pattern, config, log);
571
+
572
+ if (evaluation.recommendation === 'promote') {
573
+ // Mark as ready for human review instead of auto-promoting
574
+ // Pass env to enable AI explainer generation
575
+ const success = await markReadyForReview(db, pattern.id, evaluation, log, env);
576
+ if (success) readyForReview++;
577
+ } else if (evaluation.recommendation === 'demote') {
578
+ const success = await autoDemoteShadowPattern(db, pattern.id, evaluation, log);
579
+ if (success) demoted++;
580
+ }
581
+ }
582
+
583
+ // Step 3: Check for stale patterns (weekly - only run on Sundays)
584
+ let staleDetected = 0;
585
+ const dayOfWeek = new Date().getDay();
586
+ if (dayOfWeek === 0) { // Sunday
587
+ const staleResult = await checkForStalePatterns(db, kv, config, log);
588
+ staleDetected = staleResult.demoted;
589
+ }
590
+
591
+ const result = {
592
+ evaluated: shadowPatterns.length,
593
+ readyForReview,
594
+ demoted,
595
+ enteredShadow,
596
+ staleDetected,
597
+ // Backwards compatibility
598
+ promoted: readyForReview,
599
+ };
600
+
601
+ log.info('Shadow evaluation cycle complete', result);
602
+ return result;
603
+ }