312 lines
13 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|