life-manager/codebase/features/learning/backend/learning-tools.provider.ts
Claude Code 44deba3852 feat(learning): Implement comprehensive learning path with income tracking integration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-17 17:51:05 -07:00

312 lines
13 KiB
TypeScript

import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { LearningPathStatus, MasteryState, TrainingDifficulty } from '@life-platform/shared';
import { ToolRegistryService } from '@features/assistant/backend/skills/tool-registry.service';
import { LearningService } from './learning.service';
@Injectable()
export class LearningToolsProvider implements OnApplicationBootstrap {
constructor(
private readonly toolRegistry: ToolRegistryService,
private readonly learningService: LearningService,
) {}
onApplicationBootstrap(): void {
this.toolRegistry.register({
name: 'get_learning_due',
description: 'Check what learning reviews are due and whether we are in a learning window',
parameters: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Max reviews to return (default 5)' },
},
},
handler: async (params) => {
try {
const due = await this.learningService.getDue((params.limit as number) ?? 5);
if (due.totalDue === 0 && !due.nextNew) {
const stats = await this.learningService.getAllPathStats();
const totalMastered = stats.reduce((sum, s) => sum + s.mastered, 0);
const totalLessons = stats.reduce((sum, s) => sum + s.totalLessons, 0);
return {
ok: true,
output: `No reviews due. ${stats.length} active paths, ${totalMastered}/${totalLessons} mastered. Window: ${due.inLearningWindow ? 'active' : 'inactive'}`,
};
}
const lines: string[] = [];
lines.push(`Window: ${due.inLearningWindow ? 'ACTIVE' : 'inactive'} | ${due.totalDue} reviews due`);
for (const item of due.reviewsDue) {
lines.push(` - [${item.progress.masteryState}] "${item.lesson.title}" (${item.path.title}) — EF: ${Number(item.progress.easeFactor).toFixed(2)}, interval: ${item.progress.intervalDays}d`);
}
if (due.nextNew) {
lines.push(`Next new: "${due.nextNew.lesson.title}" (${due.nextNew.path.title})`);
}
return { ok: true, output: lines.join('\n') };
} catch (error) {
return { ok: false, error: `Failed to get due reviews: ${error instanceof Error ? error.message : error}` };
}
},
always: true,
});
this.toolRegistry.register({
name: 'record_review',
description: 'Record a review result for a lesson after teaching or quizzing. Uses SM-2 spaced repetition.',
parameters: {
type: 'object',
properties: {
lessonId: { type: 'string', description: 'UUID of the lesson being reviewed' },
qualityRating: {
type: 'number',
description: 'Rating 0-5: 0=complete blank, 1=wrong but recognized, 2=wrong but easy to recall, 3=correct with difficulty, 4=correct with hesitation, 5=perfect',
},
notes: { type: 'string', description: 'Optional notes about the review' },
},
required: ['lessonId', 'qualityRating'],
},
handler: async (params) => {
try {
const progress = await this.learningService.recordReview(
params.lessonId as string,
{
qualityRating: params.qualityRating as number,
notes: params.notes as string | undefined,
},
);
return {
ok: true,
output: `Review recorded: ${progress.masteryState} (EF: ${Number(progress.easeFactor).toFixed(2)}, next review in ${progress.intervalDays}d, rep #${progress.repetitionCount})`,
};
} catch (error) {
return { ok: false, error: `Failed to record review: ${error instanceof Error ? error.message : error}` };
}
},
always: true,
});
this.toolRegistry.register({
name: 'create_learning_path',
description: 'Create a new learning curriculum / path',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: 'Path title' },
projectId: { type: 'string', description: 'UUID of the project' },
difficulty: {
type: 'string',
description: 'Difficulty level',
enum: Object.values(TrainingDifficulty),
},
description: { type: 'string', description: 'Optional description' },
goalId: { type: 'string', description: 'Optional goal UUID to link to' },
estimatedHours: { type: 'number', description: 'Estimated hours to complete' },
tags: { type: 'array', items: { type: 'string' }, description: 'Tags for categorization' },
},
required: ['title', 'projectId', 'difficulty'],
},
handler: async (params) => {
try {
const path = await this.learningService.createPath({
title: params.title as string,
projectId: params.projectId as string,
difficulty: params.difficulty as TrainingDifficulty,
description: params.description as string | undefined,
goalId: params.goalId as string | undefined,
estimatedHours: params.estimatedHours as number | undefined,
tags: params.tags as string[] | undefined,
});
return { ok: true, output: `Created learning path "${path.title}" (id: ${path.id})` };
} catch (error) {
return { ok: false, error: `Failed to create path: ${error instanceof Error ? error.message : error}` };
}
},
always: false,
});
this.toolRegistry.register({
name: 'create_lesson',
description: 'Add a lesson to a learning path',
parameters: {
type: 'object',
properties: {
learningPathId: { type: 'string', description: 'UUID of the learning path' },
title: { type: 'string', description: 'Lesson title' },
keyConcepts: { type: 'array', items: { type: 'string' }, description: 'Key concepts covered' },
content: { type: 'string', description: 'Optional markdown content (empty = Claude generates dynamically)' },
sortOrder: { type: 'number', description: 'Display order within path' },
estimatedMinutes: { type: 'number', description: 'Estimated time to complete' },
},
required: ['learningPathId', 'title'],
},
handler: async (params) => {
try {
const lesson = await this.learningService.createLesson({
learningPathId: params.learningPathId as string,
title: params.title as string,
keyConcepts: params.keyConcepts as string[] | undefined,
content: params.content as string | undefined,
sortOrder: params.sortOrder as number | undefined,
estimatedMinutes: params.estimatedMinutes as number | undefined,
});
return { ok: true, output: `Created lesson "${lesson.title}" (id: ${lesson.id})` };
} catch (error) {
return { ok: false, error: `Failed to create lesson: ${error instanceof Error ? error.message : error}` };
}
},
always: false,
});
this.toolRegistry.register({
name: 'search_lessons',
description: 'Search lessons by title or key concepts across all learning paths',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query to match against lesson titles and key concepts' },
pathId: { type: 'string', description: 'Optional learning path UUID to scope the search' },
},
required: ['query'],
},
handler: async (params) => {
try {
const lessons = await this.learningService.searchLessons(
params.query as string,
params.pathId as string | undefined,
);
if (lessons.length === 0) {
return { ok: true, output: `No lessons found matching "${params.query}".` };
}
const lines = lessons.map((l) => {
const mastery = l.progress?.masteryState ?? 'new';
const pathTitle = l.learningPath?.title ?? 'unknown path';
const time = l.estimatedMinutes ? ` (~${l.estimatedMinutes}min)` : '';
return `- [${mastery}] "${l.title}" (${pathTitle})${time} — id: ${l.id}`;
});
return { ok: true, output: `Found ${lessons.length} lesson(s):\n${lines.join('\n')}` };
} catch (error) {
return { ok: false, error: `Failed to search lessons: ${error instanceof Error ? error.message : error}` };
}
},
always: true,
});
this.toolRegistry.register({
name: 'get_lesson_content',
description: 'Fetch full content for a lesson including key concepts, assignments, and sources',
parameters: {
type: 'object',
properties: {
lessonId: { type: 'string', description: 'UUID of the lesson to fetch' },
},
required: ['lessonId'],
},
handler: async (params) => {
try {
const lesson = await this.learningService.findOneLesson(params.lessonId as string);
const mastery = lesson.progress?.masteryState ?? 'new';
const lines: string[] = [
`"${lesson.title}"`,
`Path: ${lesson.learningPath?.title ?? 'unknown'}`,
`Mastery: ${mastery}`,
];
if (lesson.estimatedMinutes) {
lines.push(`Time: ~${lesson.estimatedMinutes}min`);
}
if (lesson.keyConcepts.length > 0) {
lines.push(`Concepts: ${lesson.keyConcepts.join(', ')}`);
}
if (lesson.content) {
const SMS_CONTENT_LIMIT = 1500;
const truncated = lesson.content.length > SMS_CONTENT_LIMIT
? lesson.content.slice(0, SMS_CONTENT_LIMIT) + '\n...continued'
: lesson.content;
lines.push(`\nContent:\n${truncated}`);
}
if (lesson.assignments?.length) {
lines.push(`\nAssignments: ${lesson.assignments.length}`);
for (const a of lesson.assignments) {
lines.push(` - ${a.description} (${a.tool})`);
}
}
if (lesson.sources.length > 0) {
lines.push(`Sources: ${lesson.sources.join(', ')}`);
}
return { ok: true, output: lines.join('\n') };
} catch (error) {
return { ok: false, error: `Failed to get lesson: ${error instanceof Error ? error.message : error}` };
}
},
always: true,
});
this.toolRegistry.register({
name: 'list_learning_paths',
description: 'List learning paths with progress summary',
parameters: {
type: 'object',
properties: {
status: {
type: 'string',
description: 'Filter by status',
enum: Object.values(LearningPathStatus),
},
},
},
handler: async (params) => {
try {
const stats = await this.learningService.getAllPathStats();
if (stats.length === 0) {
return { ok: true, output: 'No learning paths found.' };
}
const lines = stats.map((s) =>
`- "${s.title}" (${s.completionPct}% complete) — ${s.mastered}/${s.totalLessons} mastered, ${s.learning + s.reviewing} in progress${s.nextReviewAt ? `, next review: ${new Date(s.nextReviewAt).toLocaleDateString()}` : ''}`,
);
return { ok: true, output: `Learning paths:\n${lines.join('\n')}` };
} catch (error) {
return { ok: false, error: `Failed to list paths: ${error instanceof Error ? error.message : error}` };
}
},
always: false,
});
this.toolRegistry.register({
name: 'get_path_stats',
description: 'Get detailed stats for a learning path',
parameters: {
type: 'object',
properties: {
pathId: { type: 'string', description: 'UUID of the learning path' },
},
required: ['pathId'],
},
handler: async (params) => {
try {
const stats = await this.learningService.getPathStats(params.pathId as string);
const lines = [
`"${stats.title}" — ${stats.completionPct}% complete`,
`Total: ${stats.totalLessons} | Mastered: ${stats.mastered} | Reviewing: ${stats.reviewing} | Learning: ${stats.learning} | New: ${stats.new}`,
];
if (stats.nextReviewAt) {
lines.push(`Next review: ${new Date(stats.nextReviewAt).toLocaleString()}`);
}
return { ok: true, output: lines.join('\n') };
} catch (error) {
return { ok: false, error: `Failed to get stats: ${error instanceof Error ? error.message : error}` };
}
},
always: false,
});
}
}