Files
classroomio/apps/api/src/services/agent/student-tools.ts
T

292 lines
9.9 KiB
TypeScript

import { and, asc, eq, ilike, or } from 'drizzle-orm';
import { tool } from 'ai';
import { z } from 'zod';
import { db } from '@cio/db';
import * as schema from '@cio/db/schema';
import type { AiTutorSettings } from '@cio/ai-assistant';
import { getLesson } from '@api/services/lesson/lesson';
import { getExercise } from '@api/services/exercise/exercise';
import { listCourseSections } from '@api/services/course/section';
import { AppError } from '@api/utils/errors';
import { AgentEvent, trackAgentEvent } from '@api/utils/tinybird';
import { verifyExerciseBelongsToCourse, verifyLessonBelongsToCourse } from './chat-context';
/**
* Student agent tools — read-only, course-scoped.
*
* Every tool re-verifies the resource belongs to `courseId` so a poisoned model
* cannot reach into other courses or other learners' data. Exercise reads strip
* answer keys and per-question scoring data before returning.
*/
const SEARCH_SNIPPET_RADIUS = 80;
function logToolEvent(
phase: 'start' | 'success' | 'error',
toolName: string,
details: { orgId: string; userId: string; courseId: string; args?: unknown; error?: unknown }
) {
const base = `[student-tool:${phase}] ${toolName}`;
if (phase === 'error') {
console.error(base, {
courseId: details.courseId,
userId: details.userId,
args: details.args,
error: details.error instanceof Error ? details.error.message : details.error
});
return;
}
console.info(base, {
courseId: details.courseId,
userId: details.userId
});
}
async function executeStudentTool<T>(
toolName: string,
ctx: { orgId: string; userId: string; courseId: string; args?: unknown },
execute: () => Promise<T>
): Promise<T> {
trackAgentEvent(AgentEvent.TOOL_CALLED, {
orgId: ctx.orgId,
userId: ctx.userId,
courseId: ctx.courseId,
toolName
});
logToolEvent('start', toolName, ctx);
try {
const result = await execute();
trackAgentEvent(AgentEvent.TOOL_COMPLETED, {
orgId: ctx.orgId,
userId: ctx.userId,
courseId: ctx.courseId,
toolName,
success: true
});
logToolEvent('success', toolName, ctx);
return result;
} catch (error) {
logToolEvent('error', toolName, { ...ctx, error });
if (error instanceof AppError) throw error;
throw error instanceof Error ? error : new Error(String(error));
}
}
function stripAnswerKeysFromQuestion(question: {
id: number | string;
title: string;
questionTypeId: number;
order: number | null;
points: number | null;
options?: { label: string | null }[];
}): {
id: number | string;
question: string;
questionTypeId: number;
order: number | null;
options: { label: string }[];
} {
return {
id: question.id,
question: question.title,
questionTypeId: question.questionTypeId,
order: question.order,
options: (question.options ?? []).filter((o) => o.label != null).map((o) => ({ label: o.label as string }))
};
}
function makeSnippet(text: string, query: string, radius: number = SEARCH_SNIPPET_RADIUS): string {
if (!text) return '';
const normalized = text
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const lower = normalized.toLowerCase();
const idx = lower.indexOf(query.toLowerCase());
if (idx < 0) return normalized.slice(0, radius * 2) + (normalized.length > radius * 2 ? '…' : '');
const start = Math.max(0, idx - radius);
const end = Math.min(normalized.length, idx + query.length + radius);
const prefix = start > 0 ? '…' : '';
const suffix = end < normalized.length ? '…' : '';
return `${prefix}${normalized.slice(start, end)}${suffix}`;
}
const listCourseOutlineParam = z.object({});
const readLessonParam = z.object({ lessonId: z.string() });
const readExerciseParam = z.object({ exerciseId: z.string() });
const searchCourseParam = z.object({
query: z.string().min(1).max(200),
limit: z.number().int().min(1).max(20).default(8)
});
export function buildStudentAgentTools(orgId: string, userId: string, courseId: string, _settings: AiTutorSettings) {
return {
list_course_outline: tool({
description:
'List the course outline (sections with their lessons and exercises). The courseId is automatically scoped — do not pass it. Returns titles and ids only.',
inputSchema: listCourseOutlineParam,
execute: async () => {
return executeStudentTool('list_course_outline', { orgId, userId, courseId }, async () => {
const [sections, lessons, exercises] = await Promise.all([
listCourseSections(courseId),
db
.select({
id: schema.lesson.id,
title: schema.lesson.title,
sectionId: schema.lesson.sectionId,
order: schema.lesson.order
})
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseId))
.orderBy(asc(schema.lesson.order)),
db
.select({
id: schema.exercise.id,
title: schema.exercise.title,
lessonId: schema.exercise.lessonId,
sectionId: schema.exercise.sectionId,
order: schema.exercise.order
})
.from(schema.exercise)
.where(eq(schema.exercise.courseId, courseId))
.orderBy(asc(schema.exercise.order))
]);
return { sections, lessons, exercises };
});
}
}),
read_lesson: tool({
description:
'Read the title and body of a specific lesson in the current course. Use this when the learner refers to a lesson that is not already loaded in your context.',
inputSchema: readLessonParam,
execute: async (args) => {
return executeStudentTool('read_lesson', { orgId, userId, courseId, args }, async () => {
await verifyLessonBelongsToCourse(args.lessonId, courseId);
const lesson = await getLesson(args.lessonId);
const lessonWithLangs = lesson as {
id: string;
title: string;
note?: string | null;
lessonLanguages?: Array<{ locale: string; content: string | null }>;
};
const content = lessonWithLangs.lessonLanguages?.find((ll) => ll.locale === 'en')?.content ?? null;
return {
id: lesson.id,
title: lesson.title,
content,
note: lessonWithLangs.note ?? null
};
});
}
}),
read_exercise: tool({
description:
'Read an exercise prompt — the question text and options visible to a student. Answer keys, correct flags, and marking schemes are stripped before returning.',
inputSchema: readExerciseParam,
execute: async (args) => {
return executeStudentTool('read_exercise', { orgId, userId, courseId, args }, async () => {
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
const exercise = await getExercise(args.exerciseId);
const questions = (exercise.questions ?? []).map((q) =>
stripAnswerKeysFromQuestion({
id: q.id,
title: q.title,
questionTypeId: q.questionTypeId,
order: q.order,
points: q.points ?? null,
options: q.options ?? []
})
);
return {
id: exercise.id,
title: exercise.title,
description: exercise.description,
questions
};
});
}
}),
search_course: tool({
description:
'Search this course for lessons and exercises whose title or body contains the query. Returns up to `limit` ranked snippets with ids you can pass to read_lesson / read_exercise.',
inputSchema: searchCourseParam,
execute: async (args) => {
return executeStudentTool('search_course', { orgId, userId, courseId, args }, async () => {
const pattern = `%${args.query}%`;
const limit = args.limit ?? 8;
const [lessonMatches, exerciseMatches] = await Promise.all([
db
.select({
id: schema.lesson.id,
title: schema.lesson.title,
content: schema.lessonLanguage.content
})
.from(schema.lesson)
.leftJoin(
schema.lessonLanguage,
and(eq(schema.lessonLanguage.lessonId, schema.lesson.id), eq(schema.lessonLanguage.locale, 'en'))
)
.where(
and(
eq(schema.lesson.courseId, courseId),
or(ilike(schema.lesson.title, pattern), ilike(schema.lessonLanguage.content, pattern))
)
)
.limit(limit),
db
.select({
id: schema.exercise.id,
title: schema.exercise.title,
description: schema.exercise.description
})
.from(schema.exercise)
.where(
and(
eq(schema.exercise.courseId, courseId),
or(ilike(schema.exercise.title, pattern), ilike(schema.exercise.description, pattern))
)
)
.limit(limit)
]);
const lessonsResult = lessonMatches.map((lm) => ({
type: 'lesson' as const,
id: lm.id,
title: lm.title,
snippet: makeSnippet(lm.content ?? lm.title ?? '', args.query)
}));
const exercisesResult = exerciseMatches.map((em) => ({
type: 'exercise' as const,
id: em.id,
title: em.title,
snippet: makeSnippet(em.description ?? em.title ?? '', args.query)
}));
const merged = [...lessonsResult, ...exercisesResult].slice(0, limit);
return { query: args.query, results: merged };
});
}
})
};
}
export type StudentAgentTools = ReturnType<typeof buildStudentAgentTools>;