feat(backend): Phase D.1 smart routine suggestions engine + routes + 6 tests

This commit is contained in:
saravanakumardb1 2026-04-18 18:12:47 -07:00
parent 2e0ddcfe43
commit 3cda171b68
5 changed files with 345 additions and 0 deletions

View File

@ -0,0 +1,135 @@
/**
* Smart Routine Suggestions engine Phase D.1.
*
* Analyzes completed timers to detect patterns and suggest routines.
*/
import type { TimerDoc } from '../timers/types.js';
import type { RoutineDoc } from '../routines/types.js';
import type { RoutineSuggestion, SuggestionType } from './types.js';
import { randomUUID } from 'node:crypto';
interface PatternGroup {
label: string;
category?: string;
count: number;
avgDurationMs: number;
urgency: string;
}
/**
* Generate suggestions from usage patterns.
*/
export function generateSuggestions(
completedTimers: TimerDoc[],
routines: RoutineDoc[],
maxSuggestions: number,
): { suggestions: RoutineSuggestion[]; analyzedTimers: number; analyzedRoutines: number } {
const suggestions: RoutineSuggestion[] = [];
const now = new Date().toISOString();
// ── Pattern 1: Repeated timer labels → suggest routine creation ──
const labelCounts = new Map<string, PatternGroup>();
for (const t of completedTimers) {
const key = t.label.toLowerCase().trim();
const existing = labelCounts.get(key);
if (existing) {
existing.count++;
existing.avgDurationMs = (existing.avgDurationMs * (existing.count - 1) + t.duration) / existing.count;
} else {
labelCounts.set(key, {
label: t.label,
category: t.category,
count: 1,
avgDurationMs: t.duration,
urgency: t.urgency,
});
}
}
const repeatedLabels = [...labelCounts.values()]
.filter(g => g.count >= 3)
.sort((a, b) => b.count - a.count);
for (const group of repeatedLabels.slice(0, 2)) {
const existingRoutine = routines.find(r =>
r.name.toLowerCase().includes(group.label.toLowerCase())
);
if (!existingRoutine) {
suggestions.push(makeSuggestion('routine_creation',
`Create a "${group.label}" routine`,
`You've used "${group.label}" ${group.count} times. Consider making it a routine with a ${Math.round(group.avgDurationMs / 60_000)}m timer.`,
Math.min(0.9, 0.5 + group.count * 0.05),
{ label: group.label, avgDurationMinutes: Math.round(group.avgDurationMs / 60_000), category: group.category },
now,
));
}
}
// ── Pattern 2: Long continuous focus → suggest break reminders ──
const longTimers = completedTimers.filter(t =>
t.type !== 'pomodoro' && t.duration > 90 * 60_000
);
if (longTimers.length >= 2) {
suggestions.push(makeSuggestion('break_reminder',
'Add break reminders for long sessions',
`You completed ${longTimers.length} sessions over 90 minutes. Consider using Pomodoro mode or adding cascade pre-warnings.`,
0.7,
{ longSessionCount: longTimers.length },
now,
));
}
// ── Pattern 3: Similar category timers → suggest merge ──
const byCat = new Map<string, TimerDoc[]>();
for (const t of completedTimers) {
if (t.category) {
const arr = byCat.get(t.category) || [];
arr.push(t);
byCat.set(t.category, arr);
}
}
for (const [cat, timers] of byCat) {
const uniqueLabels = new Set(timers.map(t => t.label.toLowerCase().trim()));
if (uniqueLabels.size >= 3 && timers.length >= 5) {
suggestions.push(makeSuggestion('routine_merge',
`Combine "${cat}" timers into a routine`,
`You have ${uniqueLabels.size} different "${cat}" timers. Merging them into a multi-step routine could save setup time.`,
0.6,
{ category: cat, uniqueLabels: [...uniqueLabels], totalCount: timers.length },
now,
));
}
}
// ── Pattern 4: Aggressive cascade on gentle timers ──
const misconfigured = completedTimers.filter(t =>
t.urgency === 'gentle' && t.cascade?.preset === 'aggressive'
);
if (misconfigured.length >= 2) {
suggestions.push(makeSuggestion('cascade_adjustment',
'Reduce cascade intensity for gentle timers',
`${misconfigured.length} gentle-urgency timers used aggressive cascades. Switch to "minimal" or "standard" for a calmer experience.`,
0.65,
{ count: misconfigured.length, labels: misconfigured.slice(0, 3).map(t => t.label) },
now,
));
}
return {
suggestions: suggestions.slice(0, maxSuggestions),
analyzedTimers: completedTimers.length,
analyzedRoutines: routines.length,
};
}
function makeSuggestion(
type: SuggestionType,
title: string,
description: string,
confidence: number,
data: Record<string, unknown>,
createdAt: string,
): RoutineSuggestion {
return { id: randomUUID(), type, title, description, confidence, data, createdAt };
}

View File

@ -0,0 +1,53 @@
/**
* Smart Routine Suggestions routes Phase D.1.
*
* GET /suggestions analyze usage patterns and return suggestions
*/
import type { FastifyInstance } from 'fastify';
import { BadRequestError } from '@bytelyst/errors';
import { extractAuth } from '../../lib/auth.js';
import { isFeatureEnabled } from '../../lib/feature-flags.js';
import { PRODUCT_ID } from '../../lib/product-config.js';
import { generateSuggestions } from './engine.js';
import { GetSuggestionsSchema } from './types.js';
import * as timerRepo from '../timers/repository.js';
import * as routineRepo from '../routines/repository.js';
export async function suggestionRoutes(app: FastifyInstance) {
app.addHook('onRequest', async (_req, reply) => {
if (!isFeatureEnabled('smart_suggestions.enabled')) {
reply.code(400);
throw new BadRequestError('Smart suggestions are not enabled');
}
});
app.get('/suggestions', async req => {
const auth = await extractAuth(req);
const parsed = GetSuggestionsSchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
// Load completed timers from the lookback window
const since = new Date(Date.now() - parsed.data.lookbackDays * 86_400_000).toISOString();
const { items: allTimers } = await timerRepo.listTimers(auth.sub, PRODUCT_ID, {
sortBy: 'createdAt',
sortOrder: 'desc',
limit: 100,
offset: 0,
});
const completedTimers = allTimers.filter(t =>
['completed', 'dismissed', 'fired'].includes(t.state) && t.createdAt >= since
);
const { items: routines } = await routineRepo.listRoutines(auth.sub, PRODUCT_ID, {
sortBy: 'createdAt',
sortOrder: 'desc',
limit: 50,
offset: 0,
});
return generateSuggestions(completedTimers, routines, parsed.data.maxSuggestions);
});
}

View File

@ -0,0 +1,116 @@
/**
* Tests for Smart Routine Suggestions engine Phase D.1.
*/
import { describe, it, expect } from 'vitest';
import { generateSuggestions } from './engine.js';
import type { TimerDoc } from '../timers/types.js';
import type { RoutineDoc } from '../routines/types.js';
function makeTimer(label: string, overrides: Partial<TimerDoc> = {}): TimerDoc {
return {
id: `t-${label}-${Math.random()}`,
userId: 'u1',
productId: 'chronomind',
label,
type: 'countdown',
state: 'completed',
urgency: 'standard',
duration: 30 * 60_000,
targetTime: '2026-01-15T10:00:00.000Z',
createdAt: '2026-01-15T09:30:00.000Z',
syncVersion: 1,
...overrides,
};
}
function makeRoutine(name: string): RoutineDoc {
return {
id: `r-${name}`,
userId: 'u1',
productId: 'chronomind',
name,
steps: [],
totalDurationMinutes: 30,
status: 'ready',
currentStepIndex: 0,
isTemplate: false,
createdAt: '2026-01-15T00:00:00.000Z',
elapsedBeforePause: 0,
syncVersion: 1,
};
}
describe('generateSuggestions', () => {
it('suggests routine creation for repeated labels', () => {
const timers = [
makeTimer('Morning Standup'),
makeTimer('Morning Standup'),
makeTimer('Morning Standup'),
makeTimer('Morning Standup'),
];
const result = generateSuggestions(timers, [], 5);
expect(result.suggestions.length).toBeGreaterThanOrEqual(1);
const routineSug = result.suggestions.find(s => s.type === 'routine_creation');
expect(routineSug).toBeDefined();
expect(routineSug!.title).toContain('Morning Standup');
expect(routineSug!.confidence).toBeGreaterThan(0.5);
});
it('does not suggest routine if one already exists', () => {
const timers = [
makeTimer('Morning Standup'),
makeTimer('Morning Standup'),
makeTimer('Morning Standup'),
];
const routines = [makeRoutine('Morning Standup Routine')];
const result = generateSuggestions(timers, routines, 5);
const routineSug = result.suggestions.find(s =>
s.type === 'routine_creation' && s.title.toLowerCase().includes('morning standup')
);
expect(routineSug).toBeUndefined();
});
it('suggests break reminders for long sessions', () => {
const timers = [
makeTimer('Deep Work', { duration: 120 * 60_000 }),
makeTimer('Research', { duration: 100 * 60_000 }),
];
const result = generateSuggestions(timers, [], 5);
const breakSug = result.suggestions.find(s => s.type === 'break_reminder');
expect(breakSug).toBeDefined();
});
it('suggests cascade adjustment for misconfigured timers', () => {
const timers = [
makeTimer('Gentle A', { urgency: 'gentle', cascade: { preset: 'aggressive', intervals: [] } }),
makeTimer('Gentle B', { urgency: 'gentle', cascade: { preset: 'aggressive', intervals: [] } }),
];
const result = generateSuggestions(timers, [], 5);
const cascadeSug = result.suggestions.find(s => s.type === 'cascade_adjustment');
expect(cascadeSug).toBeDefined();
});
it('respects maxSuggestions limit', () => {
const timers = Array.from({ length: 20 }, (_, i) =>
makeTimer(`Task ${i % 3}`, { category: 'work' })
);
const result = generateSuggestions(timers, [], 2);
expect(result.suggestions.length).toBeLessThanOrEqual(2);
});
it('returns counts of analyzed items', () => {
const timers = [makeTimer('A'), makeTimer('B')];
const routines = [makeRoutine('R1')];
const result = generateSuggestions(timers, routines, 5);
expect(result.analyzedTimers).toBe(2);
expect(result.analyzedRoutines).toBe(1);
});
});

View File

@ -0,0 +1,39 @@
/**
* Smart Routine Suggestions types Phase D.1.
*
* Analyzes user's completed timers/routines and suggests improvements.
*/
import { z } from 'zod';
export const SuggestionTypes = [
'routine_creation',
'time_optimization',
'break_reminder',
'routine_merge',
'cascade_adjustment',
] as const;
export type SuggestionType = (typeof SuggestionTypes)[number];
export interface RoutineSuggestion {
id: string;
type: SuggestionType;
title: string;
description: string;
confidence: number;
data: Record<string, unknown>;
createdAt: string;
}
export interface SuggestionsResponse {
suggestions: RoutineSuggestion[];
analyzedTimers: number;
analyzedRoutines: number;
}
export const GetSuggestionsSchema = z.object({
lookbackDays: z.coerce.number().int().min(1).max(90).default(14),
maxSuggestions: z.coerce.number().int().min(1).max(10).default(5),
});
export type GetSuggestionsInput = z.infer<typeof GetSuggestionsSchema>;

View File

@ -14,6 +14,7 @@ import { sharedTimerRoutes } from './modules/shared-timers/routes.js';
import { webhookRoutes } from './modules/webhooks/routes.js';
import { agentActionRoutes } from './modules/agent-actions/routes.js';
import { plannerRoutes } from './modules/planner/routes.js';
import { suggestionRoutes } from './modules/suggestions/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { initDatastore } from './lib/datastore.js';
import { initEncryption } from './lib/field-encrypt.js';
@ -61,6 +62,7 @@ await app.register(sharedTimerRoutes, { prefix: '/api' });
await app.register(webhookRoutes, { prefix: '/api' });
await app.register(agentActionRoutes, { prefix: '/api' });
await app.register(plannerRoutes, { prefix: '/api' });
await app.register(suggestionRoutes, { prefix: '/api' });
// ── Phase A.4: Context-aware AI messages ─────────────────────────
const ContextMessageSchema = z.object({