feat(backend): Phase D.1 smart routine suggestions engine + routes + 6 tests
This commit is contained in:
parent
2e0ddcfe43
commit
3cda171b68
135
backend/src/modules/suggestions/engine.ts
Normal file
135
backend/src/modules/suggestions/engine.ts
Normal 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 };
|
||||||
|
}
|
||||||
53
backend/src/modules/suggestions/routes.ts
Normal file
53
backend/src/modules/suggestions/routes.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
116
backend/src/modules/suggestions/suggestions.test.ts
Normal file
116
backend/src/modules/suggestions/suggestions.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
backend/src/modules/suggestions/types.ts
Normal file
39
backend/src/modules/suggestions/types.ts
Normal 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>;
|
||||||
@ -14,6 +14,7 @@ import { sharedTimerRoutes } from './modules/shared-timers/routes.js';
|
|||||||
import { webhookRoutes } from './modules/webhooks/routes.js';
|
import { webhookRoutes } from './modules/webhooks/routes.js';
|
||||||
import { agentActionRoutes } from './modules/agent-actions/routes.js';
|
import { agentActionRoutes } from './modules/agent-actions/routes.js';
|
||||||
import { plannerRoutes } from './modules/planner/routes.js';
|
import { plannerRoutes } from './modules/planner/routes.js';
|
||||||
|
import { suggestionRoutes } from './modules/suggestions/routes.js';
|
||||||
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { initDatastore } from './lib/datastore.js';
|
import { initDatastore } from './lib/datastore.js';
|
||||||
import { initEncryption } from './lib/field-encrypt.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(webhookRoutes, { prefix: '/api' });
|
||||||
await app.register(agentActionRoutes, { prefix: '/api' });
|
await app.register(agentActionRoutes, { prefix: '/api' });
|
||||||
await app.register(plannerRoutes, { prefix: '/api' });
|
await app.register(plannerRoutes, { prefix: '/api' });
|
||||||
|
await app.register(suggestionRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
// ── Phase A.4: Context-aware AI messages ─────────────────────────
|
// ── Phase A.4: Context-aware AI messages ─────────────────────────
|
||||||
const ContextMessageSchema = z.object({
|
const ContextMessageSchema = z.object({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user