feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import { CoursePlanSchema } from '@cio/ai-assistant';
|
||||
import {
|
||||
LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT,
|
||||
LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT,
|
||||
LANDING_PAGE_SECTION_HTML_AGENT_HINT
|
||||
} from '@cio/ai-assistant/tools';
|
||||
import { QUESTION_TYPE_REGISTRY } from '@cio/question-types';
|
||||
import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
|
||||
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
@@ -56,7 +62,14 @@ export const updateContentParam = z.object({
|
||||
|
||||
export const questionSchema = z.object({
|
||||
question: z.string().min(1),
|
||||
questionTypeId: z.number().int().min(1).max(14).default(1),
|
||||
questionTypeId: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(QUESTION_TYPE_REGISTRY.length)
|
||||
.describe(
|
||||
'Required. Use the numeric question type IDs from the teacher system prompt (Question Types). Omitting this field is invalid — set an explicit type on every question and vary types within each exercise.'
|
||||
),
|
||||
points: z.number().min(0).default(1),
|
||||
order: z.number().int().min(0),
|
||||
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
|
||||
@@ -150,30 +163,62 @@ export const addQuestionsParam = z.object({
|
||||
questions: z.array(questionSchema)
|
||||
});
|
||||
|
||||
export const updateQuestionSettingsSchema = z
|
||||
.object({
|
||||
correctValue: z.union([z.number(), z.string(), z.boolean()]).optional(),
|
||||
tolerance: z.number().min(0).optional(),
|
||||
correctAnswers: z.array(z.string().min(1)).optional(),
|
||||
template: z.string().min(1).optional(),
|
||||
maxStars: z.number().int().min(1).optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
distractors: z.array(z.string().min(1)).optional()
|
||||
})
|
||||
.passthrough()
|
||||
.refine((settings) => Object.keys(settings).length > 0, {
|
||||
message: 'Provide at least one settings field'
|
||||
})
|
||||
.describe(
|
||||
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
|
||||
);
|
||||
|
||||
export const updateOptionSchema = z.object({
|
||||
id: z.number().int().optional(),
|
||||
label: z.string().min(1).optional(),
|
||||
isCorrect: z.boolean().optional(),
|
||||
settings: z.record(z.string(), z.unknown()).optional()
|
||||
settings: updateQuestionSettingsSchema.optional()
|
||||
});
|
||||
|
||||
export const updateQuestionPatchSchema = z.object({
|
||||
id: z.number().int(),
|
||||
question: z.string().min(1).optional(),
|
||||
questionTypeId: z.number().int().min(1).max(14).optional(),
|
||||
points: z.number().min(0).optional(),
|
||||
order: z.number().int().min(0).optional(),
|
||||
exerciseSectionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.'
|
||||
),
|
||||
settings: z.record(z.string(), z.unknown()).optional(),
|
||||
options: z.array(updateOptionSchema).optional()
|
||||
});
|
||||
export const updateQuestionPatchSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
question: z.string().min(1).optional(),
|
||||
questionTypeId: z.number().int().min(1).max(QUESTION_TYPE_REGISTRY.length).optional(),
|
||||
points: z.number().min(0).optional(),
|
||||
order: z.number().int().min(0).optional(),
|
||||
exerciseSectionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.'
|
||||
),
|
||||
settings: updateQuestionSettingsSchema.optional(),
|
||||
options: z.array(updateOptionSchema).optional()
|
||||
})
|
||||
.refine(
|
||||
(patch) =>
|
||||
patch.question !== undefined ||
|
||||
patch.questionTypeId !== undefined ||
|
||||
patch.points !== undefined ||
|
||||
patch.order !== undefined ||
|
||||
patch.exerciseSectionId !== undefined ||
|
||||
patch.settings !== undefined ||
|
||||
patch.options !== undefined,
|
||||
{
|
||||
message: 'Provide at least one field to update besides id'
|
||||
}
|
||||
);
|
||||
|
||||
export const updateQuestionsParam = z.object({
|
||||
exerciseId: z.string(),
|
||||
@@ -181,6 +226,37 @@ export const updateQuestionsParam = z.object({
|
||||
});
|
||||
export const coursePlanParam = z.object({ plan: CoursePlanSchema });
|
||||
|
||||
const courseTemplateIdParam = z.enum(['product_101', 'product_onboarding', 'expert_on_x']);
|
||||
|
||||
const agentTemplateFormFieldBase = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
placeholder: z.string().optional()
|
||||
});
|
||||
|
||||
const agentTemplateFormFieldParam = z.discriminatedUnion('type', [
|
||||
agentTemplateFormFieldBase.extend({ type: z.literal('text') }),
|
||||
agentTemplateFormFieldBase.extend({ type: z.literal('textarea') }),
|
||||
agentTemplateFormFieldBase.extend({ type: z.literal('url') }),
|
||||
agentTemplateFormFieldBase.extend({
|
||||
type: z.literal('select'),
|
||||
options: z.array(z.object({ value: z.string().min(1), label: z.string().min(1) })).min(1)
|
||||
})
|
||||
]);
|
||||
|
||||
export const askTemplateQuestionsParam = z.object({
|
||||
templateId: courseTemplateIdParam,
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
fields: z.array(agentTemplateFormFieldParam).min(1)
|
||||
});
|
||||
|
||||
export const fetchDocumentationUrlParam = z.object({
|
||||
url: z.string().url()
|
||||
});
|
||||
|
||||
// Gemini's tool-schema validator only accepts string enums, so numeric/boolean
|
||||
// `z.literal` values must be relaxed to plain types in the schemas the model sees.
|
||||
export const agentLessonTabsOrder = z.array(
|
||||
@@ -190,9 +266,36 @@ export const agentLessonTabsOrder = z.array(
|
||||
})
|
||||
);
|
||||
export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({
|
||||
lessonTabsOrder: agentLessonTabsOrder.optional()
|
||||
lessonTabsOrder: agentLessonTabsOrder.optional(),
|
||||
requirements: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||
description: z.string().optional().describe(LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT),
|
||||
goals: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||
instructor: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
coursesNo: z.number().optional(),
|
||||
description: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||
imgUrl: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
export const updateCourseLandingPageParam = ZCourseLandingPageUpdate.extend({
|
||||
title: z.string().min(1).optional().describe('Plain-text public course title (no HTML).'),
|
||||
description: z.string().min(1).optional().describe(LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT),
|
||||
overview: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||
generateImage: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set true to auto-resolve a banner image from Unsplash. The server searches Unsplash using imageQuery if provided, otherwise the course title. Use this to fix a missing-banner blocker — do NOT ask the teacher to describe an image.'
|
||||
),
|
||||
imageQuery: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.optional()
|
||||
.describe('Optional Unsplash search query (1–120 chars). Omit to let the server use the course title.'),
|
||||
metadata: agentLandingPageMetadataUpdate.optional()
|
||||
}).refine(
|
||||
(data) =>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getDocumentText } from '@api/services/agent/document';
|
||||
import { redis } from '@api/utils/redis/redis';
|
||||
import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent';
|
||||
import { z } from 'zod';
|
||||
import { CoursePlanSchema } from '@cio/ai-assistant';
|
||||
import { CoursePlanFieldsSchema, type CourseTemplateId } from '@cio/ai-assistant';
|
||||
|
||||
type ResourceOwnershipRow = {
|
||||
courseId: string | null;
|
||||
@@ -137,7 +137,7 @@ type PlanMetadataMessage = {
|
||||
};
|
||||
};
|
||||
|
||||
export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof CoursePlanSchema> | undefined {
|
||||
export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof CoursePlanFieldsSchema> | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index] as PlanMetadataMessage;
|
||||
|
||||
@@ -149,7 +149,7 @@ export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedPlan = CoursePlanSchema.safeParse(message.metadata.plan.payload);
|
||||
const parsedPlan = CoursePlanFieldsSchema.safeParse(message.metadata.plan.payload);
|
||||
|
||||
if (parsedPlan.success) {
|
||||
return parsedPlan.data;
|
||||
@@ -158,3 +158,26 @@ export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const COURSE_TEMPLATE_ID_SET = new Set<CourseTemplateId>(['product_101', 'product_onboarding', 'expert_on_x']);
|
||||
|
||||
export function getActiveCourseTemplateId(messages: unknown[]): CourseTemplateId | undefined {
|
||||
for (const message of messages) {
|
||||
const candidate = message as {
|
||||
role?: string;
|
||||
metadata?: { template?: { id?: string } };
|
||||
};
|
||||
|
||||
if (candidate.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = candidate.metadata?.template?.id;
|
||||
|
||||
if (id && COURSE_TEMPLATE_ID_SET.has(id as CourseTemplateId)) {
|
||||
return id as CourseTemplateId;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -2,23 +2,153 @@ import {
|
||||
listChatConversations,
|
||||
getChatConversation,
|
||||
createChatConversation,
|
||||
saveChatMessages,
|
||||
saveChatMessages as saveDbChatMessages,
|
||||
deleteChatConversation,
|
||||
updateConversationTitle,
|
||||
getChatHistory,
|
||||
saveChatHistory,
|
||||
saveChatHistory as saveDbChatHistory,
|
||||
deleteChatHistory
|
||||
} from '@cio/db/queries/agent';
|
||||
|
||||
const MAX_PERSISTED_TOOL_OUTPUT_CHARS = 2000;
|
||||
const DROPPED_PART_TYPES = new Set(['step-start', 'reasoning']);
|
||||
const TOOLS_WITH_FULL_OUTPUT = new Set(['generate_course_plan', 'ask_template_questions', 'fetch_documentation_url']);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function serializeValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getToolName(part: Record<string, unknown>): string | null {
|
||||
const type = part.type;
|
||||
|
||||
if (type === 'tool-invocation') {
|
||||
const toolInvocation = part.toolInvocation;
|
||||
|
||||
return isRecord(toolInvocation) && typeof toolInvocation.toolName === 'string' ? toolInvocation.toolName : null;
|
||||
}
|
||||
|
||||
if (typeof type === 'string' && type.startsWith('tool-')) {
|
||||
return type.slice(5);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildTruncatedOutput(output: unknown): unknown {
|
||||
const serialized = serializeValue(output);
|
||||
|
||||
if (serialized.length <= MAX_PERSISTED_TOOL_OUTPUT_CHARS) {
|
||||
return output;
|
||||
}
|
||||
|
||||
const preview = serialized.slice(0, MAX_PERSISTED_TOOL_OUTPUT_CHARS) + '...[truncated]';
|
||||
|
||||
if (typeof output === 'string') {
|
||||
return preview;
|
||||
}
|
||||
|
||||
if (!isRecord(output)) {
|
||||
return preview;
|
||||
}
|
||||
|
||||
const preservedScalars = Object.fromEntries(
|
||||
Object.entries(output).filter(
|
||||
([, value]) => value == null || ['string', 'number', 'boolean'].includes(typeof value)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
...preservedScalars,
|
||||
truncated: true,
|
||||
preview
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeMessagePart(part: unknown): unknown | null {
|
||||
if (!isRecord(part)) {
|
||||
return part;
|
||||
}
|
||||
|
||||
const type = typeof part.type === 'string' ? part.type : '';
|
||||
|
||||
if (DROPPED_PART_TYPES.has(type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolName = getToolName(part);
|
||||
|
||||
if (!toolName || TOOLS_WITH_FULL_OUTPUT.has(toolName)) {
|
||||
return part;
|
||||
}
|
||||
|
||||
if (part.type === 'tool-invocation' && isRecord(part.toolInvocation)) {
|
||||
return {
|
||||
...part,
|
||||
toolInvocation: {
|
||||
...part.toolInvocation,
|
||||
result: buildTruncatedOutput(part.toolInvocation.result)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (part.state !== 'output-available' || part.output == null) {
|
||||
return part;
|
||||
}
|
||||
|
||||
return {
|
||||
...part,
|
||||
output: buildTruncatedOutput(part.output)
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeChatMessages(messages: unknown[]): unknown[] {
|
||||
return messages.map((message) => {
|
||||
if (!isRecord(message) || !Array.isArray(message.parts)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const sanitizedParts = message.parts.map(sanitizeMessagePart).filter((part) => part !== null);
|
||||
|
||||
return {
|
||||
...message,
|
||||
parts: sanitizedParts
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveChatMessages(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
messages: unknown[],
|
||||
title?: string
|
||||
): Promise<void> {
|
||||
await saveDbChatMessages(conversationId, userId, sanitizeChatMessages(messages), title);
|
||||
}
|
||||
|
||||
async function persistChatHistory(courseId: string, userId: string, messages: unknown[]): Promise<void> {
|
||||
await saveDbChatHistory(courseId, userId, sanitizeChatMessages(messages));
|
||||
}
|
||||
|
||||
export {
|
||||
listChatConversations,
|
||||
getChatConversation,
|
||||
createChatConversation,
|
||||
saveChatMessages,
|
||||
deleteChatConversation,
|
||||
updateConversationTitle,
|
||||
// Legacy compat
|
||||
getChatHistory as fetchChatHistory,
|
||||
saveChatHistory as persistChatHistory,
|
||||
persistChatHistory,
|
||||
deleteChatHistory as clearChatHistory
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@api/services/agent/chat-context';
|
||||
import {
|
||||
addQuestionsParam,
|
||||
askTemplateQuestionsParam,
|
||||
coursePlanParam,
|
||||
createExerciseParam,
|
||||
createExerciseSectionParam,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
createSectionParam,
|
||||
emptyParam,
|
||||
exerciseReadParam,
|
||||
fetchDocumentationUrlParam,
|
||||
goLiveParam,
|
||||
lessonReadParam,
|
||||
reorderContentParam,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
updateQuestionsParam,
|
||||
updateSectionParam
|
||||
} from '@api/services/agent/agent-tool-schemas';
|
||||
import { fetchDocumentationUrl } from '@api/services/agent/fetch-url';
|
||||
|
||||
function summarizeAgentDebugValue(value: unknown, depth = 0): unknown {
|
||||
if (value == null) return value;
|
||||
@@ -184,7 +187,7 @@ async function executeAgentTool<TArgs, TResult>(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentTools(orgId: string, userId: string, courseId: string) {
|
||||
export function buildAgentTools(orgId: string, userId: string, courseId: string, priorMessages: unknown[]) {
|
||||
return {
|
||||
get_course_structure: tool({
|
||||
description:
|
||||
@@ -316,6 +319,7 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
|
||||
});
|
||||
return {
|
||||
lessonId: args.lessonId,
|
||||
lessonTitle: lesson.title,
|
||||
locale: args.locale,
|
||||
contentLength: normalizedContent.length,
|
||||
updated: true
|
||||
@@ -488,7 +492,12 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
|
||||
];
|
||||
|
||||
await updateExerciseService(args.exerciseId, { questions: allQuestions });
|
||||
return { exerciseId: args.exerciseId, addedCount: args.questions.length, totalCount: allQuestions.length };
|
||||
return {
|
||||
exerciseId: args.exerciseId,
|
||||
exerciseTitle: existingExercise.title,
|
||||
addedCount: args.questions.length,
|
||||
totalCount: allQuestions.length
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
@@ -545,7 +554,11 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
|
||||
|
||||
await updateExerciseService(args.exerciseId, { questions: merged });
|
||||
|
||||
return { exerciseId: args.exerciseId, updatedCount: merged.length };
|
||||
return {
|
||||
exerciseId: args.exerciseId,
|
||||
exerciseTitle: existing.title,
|
||||
updatedCount: merged.length
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
@@ -586,7 +599,7 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
|
||||
|
||||
update_course_landing_page: tool({
|
||||
description:
|
||||
'Update course-level landing page fields for this course, including public title, description, overview, goals, requirements, instructor metadata, pricing, and banner image. The courseId is automatically set — do not pass it.',
|
||||
'Update course-level landing page fields for this course (public title, course description, overview, goals, requirements, the Description section after Requirements, instructor bio, pricing, banner image). The top-level course description field is plain text only—no HTML. All other narrative sections (overview, metadata goals/requirements, metadata description for the block after Requirements, instructor description, etc.) are HTML: paragraphs, lists, line breaks, bold, and italic only—never heading tags (h1–h6) because the UI shows section titles. Title is plain text. The courseId is automatically set — do not pass it.',
|
||||
inputSchema: updateCourseLandingPageParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_course_landing_page', { orgId, userId, courseId, args }, async () => {
|
||||
@@ -650,6 +663,42 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
ask_template_questions: tool({
|
||||
description:
|
||||
'Render a structured questionnaire card to the teacher. Pause until the teacher submits via metadata.template.action submit_template_answers or skips via skip_template_form.',
|
||||
inputSchema: askTemplateQuestionsParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool(
|
||||
'ask_template_questions',
|
||||
{ orgId, userId, courseId, args },
|
||||
async () =>
|
||||
({
|
||||
awaiting_user: true as const,
|
||||
templateId: args.templateId,
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
fields: args.fields
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
fetch_documentation_url: tool({
|
||||
description:
|
||||
'Fetch a public documentation URL via Jina Reader. Returns markdown wrapped as untrusted external content plus same-origin links for follow-up fetches.',
|
||||
inputSchema: fetchDocumentationUrlParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('fetch_documentation_url', { orgId, userId, courseId, args }, async () => {
|
||||
return fetchDocumentationUrl({
|
||||
url: args.url,
|
||||
orgId,
|
||||
courseId,
|
||||
priorMessages
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { ErrorCodes } from '@cio/utils/constants';
|
||||
import { getChatConversation, saveChatMessages } from '@api/services/agent/chat-history';
|
||||
import { summarizeConversation, type SummarizeOptions } from '@api/services/agent/summarize';
|
||||
|
||||
export async function compactConversation(conversationId: string, userId: string): Promise<{ messages: unknown[] }> {
|
||||
const conversation = await getChatConversation(conversationId, userId);
|
||||
|
||||
if (!conversation) {
|
||||
throw new AppError('Conversation not found', 'CONVERSATION_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
const storedMessages = (conversation.messages ?? []) as unknown[];
|
||||
|
||||
if (storedMessages.length === 0) {
|
||||
throw new AppError('No messages to compact', ErrorCodes.NO_MESSAGES_TO_COMPACT, 400);
|
||||
}
|
||||
|
||||
const summary = await summarizeConversation({ messages: storedMessages as SummarizeOptions['messages'] });
|
||||
|
||||
const seed = {
|
||||
id: randomUUID(),
|
||||
role: 'user' as const,
|
||||
parts: [{ type: 'text' as const, text: summary }],
|
||||
metadata: {
|
||||
compaction: {
|
||||
compactedAt: new Date().toISOString(),
|
||||
originalMessageCount: storedMessages.length
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await saveChatMessages(conversationId, userId, [seed]);
|
||||
|
||||
return { messages: [seed] };
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isIPv4, isIPv6 } from 'node:net';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { env } from '@api/config/env';
|
||||
import { redis, logRedisUnavailableOnce } from '@api/utils/redis/redis';
|
||||
import { isOrgOnPaidPlan } from '@api/services/agent/usage';
|
||||
import { ErrorCodes } from '@cio/utils/constants/error-codes';
|
||||
|
||||
const JINA_READER_PREFIX = 'https://r.jina.ai/';
|
||||
const MAX_MARKDOWN_BYTES = 150 * 1024;
|
||||
const CACHE_TTL_SEC = 7 * 24 * 3600;
|
||||
const QUOTA_TTL_SEC = 25 * 3600;
|
||||
|
||||
export type FetchDocumentationUrlResult = {
|
||||
url: string;
|
||||
pageTitle: string;
|
||||
content: string;
|
||||
links: string[];
|
||||
contentTokens: number;
|
||||
fetchedAt: string;
|
||||
cacheHit: boolean;
|
||||
};
|
||||
|
||||
type CachedFetchPayload = {
|
||||
url: string;
|
||||
pageTitle: string;
|
||||
rawMarkdown: string;
|
||||
links: string[];
|
||||
fetchedAt: string;
|
||||
};
|
||||
|
||||
function isForbiddenHostname(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase();
|
||||
|
||||
if (host === 'localhost' || host.endsWith('.localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isIPv4(host) || isIPv6(host)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function assertFetchableDocumentationUrl(rawUrl: string): URL {
|
||||
let parsed: URL;
|
||||
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new AppError('Invalid documentation URL', 'INVALID_DOCUMENTATION_URL', 400);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new AppError('Documentation URL must use http or https', 'INVALID_DOCUMENTATION_URL', 400);
|
||||
}
|
||||
|
||||
if (isForbiddenHostname(parsed.hostname)) {
|
||||
throw new AppError('Documentation URL hostname is not allowed', 'INVALID_DOCUMENTATION_URL', 400);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function extractSameOriginLinks(markdown: string, origin: URL): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
const re = /\[[^\]]*\]\((https?:[^)\s]+)\)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = re.exec(markdown)) !== null) {
|
||||
try {
|
||||
const linkUrl = new URL(match[1]);
|
||||
if (linkUrl.protocol !== 'http:' && linkUrl.protocol !== 'https:') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (linkUrl.hostname !== origin.hostname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = linkUrl.href;
|
||||
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
out.push(normalized);
|
||||
}
|
||||
} catch {
|
||||
// skip invalid URL in markdown
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function guessPageTitle(markdown: string, fallbackUrl: string): string {
|
||||
const trimmed = markdown.trim();
|
||||
const firstLine = trimmed.split('\n')[0] ?? '';
|
||||
|
||||
if (firstLine.startsWith('# ')) {
|
||||
return firstLine.slice(2).trim();
|
||||
}
|
||||
|
||||
if (firstLine.startsWith('#')) {
|
||||
return firstLine.replace(/^#+\s*/, '').trim();
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(fallbackUrl).hostname;
|
||||
} catch {
|
||||
return 'Documentation';
|
||||
}
|
||||
}
|
||||
|
||||
function truncateMarkdown(body: string): { text: string; truncated: boolean } {
|
||||
if (body.length <= MAX_MARKDOWN_BYTES) {
|
||||
return { text: body, truncated: false };
|
||||
}
|
||||
|
||||
const suffix = '\n\n[Truncated: content exceeded 150 KB]';
|
||||
|
||||
return {
|
||||
text: body.slice(0, MAX_MARKDOWN_BYTES - suffix.length) + suffix,
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
function wrapUntrustedMarkdown(url: string, markdown: string): string {
|
||||
const safeSrc = url.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
|
||||
return `<external_untrusted_document src="${safeSrc}">\n${markdown}\n</external_untrusted_document>`;
|
||||
}
|
||||
|
||||
export function countCompletedFetchDocumentationCalls(messages: unknown[]): number {
|
||||
let count = 0;
|
||||
|
||||
for (const msg of messages as { parts?: unknown[] }[]) {
|
||||
const parts = msg.parts;
|
||||
|
||||
if (!parts) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = part as Record<string, unknown>;
|
||||
const type = typeof record.type === 'string' ? record.type : '';
|
||||
let toolName: string | null = null;
|
||||
|
||||
if (type === 'tool-invocation') {
|
||||
toolName = (record.toolInvocation as { toolName?: string } | undefined)?.toolName ?? null;
|
||||
} else if (type.startsWith('tool-')) {
|
||||
toolName = type.slice(5);
|
||||
}
|
||||
|
||||
if (toolName !== 'fetch_documentation_url') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = record.state;
|
||||
|
||||
if (state === 'result' || state === 'output-available' || state === 'output-error') {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
async function redisSafeGet(key: string): Promise<string | null> {
|
||||
try {
|
||||
if (!redis.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await redis.get(key);
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('fetch-url cache get failed', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function redisSafeSet(key: string, value: string, ttlSec: number): Promise<void> {
|
||||
try {
|
||||
if (!redis.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
await redis.set(key, value, { EX: ttlSec });
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('fetch-url cache set failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function incrementOrgDailyFetchQuota(orgId: string, maxPerDay: number): Promise<void> {
|
||||
const dayKey = new Date().toISOString().slice(0, 10);
|
||||
const key = `agent:fetch_url:quota:${orgId}:${dayKey}`;
|
||||
|
||||
try {
|
||||
if (!redis.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await redis.incr(key);
|
||||
|
||||
if (count === 1) {
|
||||
await redis.expire(key, QUOTA_TTL_SEC);
|
||||
}
|
||||
|
||||
if (count > maxPerDay) {
|
||||
throw new AppError(
|
||||
'Daily documentation fetch quota exceeded for this organization',
|
||||
'AGENT_FETCH_QUOTA_EXCEEDED',
|
||||
429
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logRedisUnavailableOnce('fetch-url quota redis failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMarkdownFromJina(url: string): Promise<{ markdown: string; status: number }> {
|
||||
const readerHref = `${JINA_READER_PREFIX}${url}`;
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (env.JINA_API_KEY) {
|
||||
headers.Authorization = `Bearer ${env.JINA_API_KEY}`;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(readerHref, {
|
||||
headers,
|
||||
redirect: 'follow'
|
||||
});
|
||||
} catch {
|
||||
throw new AppError('Failed to reach documentation reader', 'DOCUMENTATION_FETCH_FAILED', 502);
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
const bytesHint = response.headers.get('content-length');
|
||||
|
||||
if (!response.ok) {
|
||||
console.info('[fetch_documentation_url]', { url, status, bytes: bytesHint });
|
||||
|
||||
throw new AppError(`Documentation fetch failed with status ${status}`, 'DOCUMENTATION_FETCH_FAILED', 502);
|
||||
}
|
||||
|
||||
const markdown = await response.text();
|
||||
|
||||
console.info('[fetch_documentation_url]', { url, status, bytes: String(markdown.length) });
|
||||
|
||||
return { markdown, status };
|
||||
}
|
||||
|
||||
export async function fetchDocumentationUrl(params: {
|
||||
url: string;
|
||||
orgId: string;
|
||||
courseId: string;
|
||||
priorMessages: unknown[];
|
||||
}): Promise<FetchDocumentationUrlResult> {
|
||||
const { url, orgId, courseId, priorMessages } = params;
|
||||
|
||||
const normalizedUrl = assertFetchableDocumentationUrl(url);
|
||||
const normalizedHref = normalizedUrl.href;
|
||||
|
||||
const priorFetches = countCompletedFetchDocumentationCalls(priorMessages);
|
||||
|
||||
if (priorFetches >= env.AGENT_MAX_FETCHES_PER_CONVERSATION) {
|
||||
throw new AppError('Maximum documentation fetches per conversation reached', 'AGENT_FETCH_CONVERSATION_LIMIT', 429);
|
||||
}
|
||||
|
||||
const paid = await isOrgOnPaidPlan(orgId);
|
||||
|
||||
if (!paid) {
|
||||
throw new AppError('Documentation fetching requires a paid plan', ErrorCodes.UPGRADE_REQUIRED, 403);
|
||||
}
|
||||
|
||||
const cacheKeyRaw = `${orgId}:${normalizedHref}`;
|
||||
const cacheKey = `agent:fetch_url:${createHash('sha256').update(cacheKeyRaw).digest('hex')}`;
|
||||
|
||||
const cachedRaw = await redisSafeGet(cacheKey);
|
||||
|
||||
if (cachedRaw) {
|
||||
try {
|
||||
const cached = JSON.parse(cachedRaw) as CachedFetchPayload;
|
||||
const truncated = truncateMarkdown(cached.rawMarkdown);
|
||||
|
||||
return {
|
||||
url: cached.url,
|
||||
pageTitle: cached.pageTitle,
|
||||
content: wrapUntrustedMarkdown(cached.url, truncated.text),
|
||||
links: cached.links,
|
||||
contentTokens: Math.ceil(truncated.text.length / 4),
|
||||
fetchedAt: cached.fetchedAt,
|
||||
cacheHit: true
|
||||
};
|
||||
} catch {
|
||||
// fall through to refetch
|
||||
}
|
||||
}
|
||||
|
||||
await incrementOrgDailyFetchQuota(orgId, env.AGENT_MAX_FETCHES_PER_ORG_PER_DAY);
|
||||
|
||||
const { markdown } = await fetchMarkdownFromJina(normalizedHref);
|
||||
const truncated = truncateMarkdown(markdown);
|
||||
const pageTitle = guessPageTitle(truncated.text, normalizedHref);
|
||||
const links = extractSameOriginLinks(truncated.text, normalizedUrl);
|
||||
const fetchedAt = new Date().toISOString();
|
||||
|
||||
const cachePayload: CachedFetchPayload = {
|
||||
url: normalizedHref,
|
||||
pageTitle,
|
||||
rawMarkdown: truncated.text,
|
||||
links,
|
||||
fetchedAt
|
||||
};
|
||||
|
||||
await redisSafeSet(cacheKey, JSON.stringify(cachePayload), CACHE_TTL_SEC);
|
||||
|
||||
void courseId;
|
||||
|
||||
return {
|
||||
url: normalizedHref,
|
||||
pageTitle,
|
||||
content: wrapUntrustedMarkdown(normalizedHref, truncated.text),
|
||||
links,
|
||||
contentTokens: Math.ceil(truncated.text.length / 4),
|
||||
fetchedAt,
|
||||
cacheHit: false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Anthropic strictly requires every `tool_use` block to be followed in the very
|
||||
* next message by a `tool_result` block with a matching id. OpenAI tolerates
|
||||
* mismatches; Anthropic 400s.
|
||||
*
|
||||
* The break-shapes this catches:
|
||||
* (a) `convertToModelMessages` splits an assistant UIMessage with parts
|
||||
* `[tool-call, text, text]` into TWO assistant ModelMessages plus a tool
|
||||
* message — the wedged second assistant message separates tool_use from
|
||||
* tool_result.
|
||||
* (b) `trimMessageHistory` slices the array between an assistant tool-call
|
||||
* turn and its tool-result message.
|
||||
* (c) A tool call was aborted mid-stream and persisted without a result.
|
||||
*
|
||||
* Strategy: walk the ModelMessage[] once. For every assistant message that has
|
||||
* tool-call parts, **immediately** emit a tool message containing matching
|
||||
* tool-result parts — pulled from anywhere later in the array, or synthesized
|
||||
* as an error result if missing entirely. Tool messages whose results have
|
||||
* already been claimed by an earlier assistant turn are dropped (or trimmed)
|
||||
* to avoid duplicates.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
type ToolCallPart = { type: 'tool-call' | 'dynamic-tool-call'; toolCallId: string; toolName: string };
|
||||
type ToolResultPart = {
|
||||
type: 'tool-result' | 'dynamic-tool-result';
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
output?: unknown;
|
||||
};
|
||||
|
||||
function isToolCallPart(part: unknown): part is ToolCallPart {
|
||||
const type = (part as { type?: unknown })?.type;
|
||||
return type === 'tool-call' || type === 'dynamic-tool-call';
|
||||
}
|
||||
|
||||
function isToolResultPart(part: unknown): part is ToolResultPart {
|
||||
const type = (part as { type?: unknown })?.type;
|
||||
return type === 'tool-result' || type === 'dynamic-tool-result';
|
||||
}
|
||||
|
||||
function syntheticErrorResult(tc: ToolCallPart): ToolResultPart {
|
||||
return {
|
||||
type: 'tool-result',
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
output: { type: 'error-text', value: 'Tool call was interrupted; no result captured.' }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic accepts assistant content in the canonical shape `[text*, tool_use*]`
|
||||
* (text/reasoning first, then tool_uses at the end). When the assistant content
|
||||
* has text AFTER tool_use blocks (`[tool_use, text, text]`), Anthropic's strict
|
||||
* validator can reject it with the same "tool_result missing immediately after"
|
||||
* error even though the next message IS a tool message — because it doesn't
|
||||
* recognise the assistant turn as ending on tool_use. Reorder so tool-calls
|
||||
* always come last within each assistant message.
|
||||
*/
|
||||
function reorderAssistantContent(content: any[]): any[] {
|
||||
const toolCalls: any[] = [];
|
||||
const nonToolCalls: any[] = [];
|
||||
|
||||
for (const part of content) {
|
||||
if (isToolCallPart(part)) {
|
||||
toolCalls.push(part);
|
||||
} else {
|
||||
nonToolCalls.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
// Only return a new array if we actually moved something.
|
||||
if (toolCalls.length === 0 || nonToolCalls.length === 0) return content;
|
||||
|
||||
return [...nonToolCalls, ...toolCalls];
|
||||
}
|
||||
|
||||
export function sanitizeDanglingToolCalls(messages: any[]): any[] {
|
||||
// Pass 1 — index every tool-result we can find anywhere in the conversation,
|
||||
// keyed by toolCallId. If duplicates exist, last one wins (most recent).
|
||||
const toolResultsByCallId = new Map<string, ToolResultPart>();
|
||||
for (const m of messages) {
|
||||
if (m?.role !== 'tool' || !Array.isArray(m.content)) continue;
|
||||
|
||||
for (const part of m.content) {
|
||||
if (isToolResultPart(part)) {
|
||||
toolResultsByCallId.set(part.toolCallId, part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — rebuild. After each assistant message with tool-calls, inject a
|
||||
// tool message whose results match those calls in the same order. Skip the
|
||||
// original tool messages whose parts we've already claimed. Also reorder
|
||||
// assistant content so tool-calls always sit at the END.
|
||||
const claimedResultIds = new Set<string>();
|
||||
const result: any[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i += 1) {
|
||||
const message = messages[i];
|
||||
|
||||
// Filter or drop tool messages whose results were already pulled forward.
|
||||
if (message?.role === 'tool' && Array.isArray(message.content)) {
|
||||
const remaining = message.content.filter(
|
||||
(part: unknown) => !isToolResultPart(part) || !claimedResultIds.has(part.toolCallId)
|
||||
);
|
||||
if (remaining.length > 0) {
|
||||
result.push({ ...message, content: remaining });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message?.role !== 'assistant' || !Array.isArray(message.content)) {
|
||||
result.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reorderedContent = reorderAssistantContent(message.content);
|
||||
const reorderedMessage = reorderedContent === message.content ? message : { ...message, content: reorderedContent };
|
||||
|
||||
result.push(reorderedMessage);
|
||||
|
||||
const toolCalls = reorderedContent.filter(isToolCallPart);
|
||||
if (toolCalls.length === 0) continue;
|
||||
|
||||
const resultParts: ToolResultPart[] = toolCalls.map((tc: ToolCallPart) => {
|
||||
claimedResultIds.add(tc.toolCallId);
|
||||
const existing = toolResultsByCallId.get(tc.toolCallId);
|
||||
|
||||
return existing ?? syntheticErrorResult(tc);
|
||||
});
|
||||
|
||||
result.push({ role: 'tool', content: resultParts });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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>;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { generateText } from 'ai';
|
||||
import { createModel, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
|
||||
const SUMMARIZE_SYSTEM_PROMPT = `You are summarizing a multi-turn ClassroomIO course-creation chat so the assistant can pick up exactly where it left off in a new conversation.
|
||||
|
||||
Write the summary as a first-person user message to the assistant. It must be DETAILED — assume the receiving assistant has zero prior context.
|
||||
|
||||
Required structure (use these exact section headings, written in plain text without markdown):
|
||||
|
||||
1. Course context: course title, intended audience, primary outcomes, and the active template flow (Product 101, Product Onboarding, Expert on X, or other) if any.
|
||||
2. Template form answers: every answered field by key and value, including the chosen depth tier (light / balanced / deep_doc) and any documentation URL provided.
|
||||
3. Documentation fetched: list every URL that was fetched via fetch_documentation_url and a one-line note on what was learned from each (key product surface area, vocabulary, workflows). Do NOT skip this section if any docs were fetched.
|
||||
4. Plan status: whether a course plan has been generated, the plan title and section count, and whether it was approved, edited, or still pending changes.
|
||||
5. Implementation progress: which sections / lessons / exercises were already created (by title), and any tool errors hit.
|
||||
6. Open decisions: anything the teacher said they still wanted to do, change, or get an answer on.
|
||||
7. Continue by: an explicit next action for the assistant (e.g. "Fetch the remaining linked docs and then call generate_course_plan", or "Resume implementing from section 4").
|
||||
|
||||
Length target: at least 250 words; expand as needed to capture detail. Do not write a high-level paragraph; write the structured handoff above. Never abbreviate or omit sections — write "(none)" if a section is empty.`;
|
||||
|
||||
interface InboundMessagePart {
|
||||
type: string;
|
||||
text?: string;
|
||||
toolName?: string;
|
||||
state?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
|
||||
interface InboundMessage {
|
||||
role: string;
|
||||
content?: string;
|
||||
parts?: InboundMessagePart[];
|
||||
metadata?: Record<string, unknown> | null | undefined;
|
||||
}
|
||||
|
||||
export interface SummarizeOptions {
|
||||
messages: InboundMessage[];
|
||||
}
|
||||
|
||||
function truncate(input: string, max: number): string {
|
||||
if (input.length <= max) return input;
|
||||
|
||||
return `${input.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
function stringifySafely(value: unknown): string {
|
||||
try {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||
} catch {
|
||||
return '[unserializable]';
|
||||
}
|
||||
}
|
||||
|
||||
function partsToText(message: InboundMessage): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (message.content && message.content.trim().length > 0) {
|
||||
lines.push(message.content.trim());
|
||||
}
|
||||
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === 'text' && part.text) {
|
||||
lines.push(part.text.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.type === 'tool-invocation' || part.type?.startsWith('tool-')) {
|
||||
const toolName = part.toolName ?? (part.type.startsWith('tool-') ? part.type.slice(5) : 'unknown_tool');
|
||||
|
||||
const inputText = part.input !== undefined ? truncate(stringifySafely(part.input), 600) : '';
|
||||
const outputText = part.output !== undefined ? truncate(stringifySafely(part.output), 1500) : '';
|
||||
|
||||
const segments: string[] = [`[tool:${toolName}]`];
|
||||
|
||||
if (inputText) {
|
||||
segments.push(`input=${inputText}`);
|
||||
}
|
||||
|
||||
if (outputText) {
|
||||
segments.push(`output=${outputText}`);
|
||||
}
|
||||
|
||||
lines.push(segments.join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
const metadataTemplate = message.metadata?.template;
|
||||
|
||||
if (metadataTemplate && typeof metadataTemplate === 'object') {
|
||||
lines.push(`[template-action] ${truncate(stringifySafely(metadataTemplate), 600)}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const MAX_CONVERSATION_CHARS = 60_000;
|
||||
const MAX_OUTPUT_TOKENS = 2_000;
|
||||
|
||||
export async function summarizeConversation(options: SummarizeOptions): Promise<string> {
|
||||
const providerConfig = pickAnyConfiguredProvider();
|
||||
|
||||
if (!providerConfig) {
|
||||
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
|
||||
}
|
||||
|
||||
const model = createModel(providerConfig);
|
||||
|
||||
const conversationLines: string[] = [];
|
||||
|
||||
for (const message of options.messages ?? []) {
|
||||
const role = message.role === 'user' ? 'User' : 'Assistant';
|
||||
const body = partsToText(message);
|
||||
|
||||
if (!body.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
conversationLines.push(`${role}: ${body}`);
|
||||
}
|
||||
|
||||
const conversationText = conversationLines.join('\n\n');
|
||||
|
||||
// Prefer keeping the END of the transcript when truncating — recent context is most
|
||||
// valuable for "continue from here" continuations.
|
||||
const truncatedText =
|
||||
conversationText.length > MAX_CONVERSATION_CHARS
|
||||
? conversationText.slice(conversationText.length - MAX_CONVERSATION_CHARS)
|
||||
: conversationText;
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||
prompt: `Here is the conversation to summarize. Tool calls appear as [tool:name] input=… output=… lines.\n\n${truncatedText}\n\nReturn the structured handoff message described in the system prompt.`,
|
||||
maxOutputTokens: MAX_OUTPUT_TOKENS,
|
||||
maxRetries: 0
|
||||
});
|
||||
|
||||
return result.text.trim();
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { generateText } from 'ai';
|
||||
import { AIProvider, type AIProviderConfig } from '@cio/ai-assistant';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
|
||||
const TEXT_GEN_MODELS: Record<AIProvider, string> = {
|
||||
[AIProvider.OPENAI]: 'gpt-4o-mini',
|
||||
[AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001',
|
||||
[AIProvider.GOOGLE]: 'gemini-3.1-flash-lite',
|
||||
[AIProvider.MOONSHOT]: 'kimi-k2.6'
|
||||
};
|
||||
|
||||
function createTextGenModel(config: AIProviderConfig) {
|
||||
const modelName = TEXT_GEN_MODELS[config.provider];
|
||||
|
||||
switch (config.provider) {
|
||||
case AIProvider.OPENAI: {
|
||||
const openai = createOpenAI({ apiKey: config.apiKey });
|
||||
return openai(modelName);
|
||||
}
|
||||
case AIProvider.ANTHROPIC: {
|
||||
const anthropic = createAnthropic({ apiKey: config.apiKey });
|
||||
return anthropic(modelName);
|
||||
}
|
||||
case AIProvider.GOOGLE: {
|
||||
const google = createGoogleGenerativeAI({ apiKey: config.apiKey });
|
||||
return google(modelName);
|
||||
}
|
||||
case AIProvider.MOONSHOT: {
|
||||
const moonshot = createOpenAI({ apiKey: config.apiKey, baseURL: 'https://api.moonshot.cn/v1' });
|
||||
return moonshot(modelName);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported AI provider: ${config.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenerateFieldTextResult {
|
||||
text: string;
|
||||
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
export async function generateFieldText(
|
||||
prompt: string,
|
||||
tone: string,
|
||||
format: 'plain' | 'html',
|
||||
context: string | undefined,
|
||||
providerConfig: AIProviderConfig
|
||||
): Promise<GenerateFieldTextResult> {
|
||||
const model = createTextGenModel(providerConfig);
|
||||
const modelName = TEXT_GEN_MODELS[providerConfig.provider];
|
||||
|
||||
const contextLine = context ? `Context: ${context}.` : '';
|
||||
const formatLine =
|
||||
format === 'html'
|
||||
? 'Return valid HTML (use <p>, <ul>, <li>, <strong> tags as appropriate). Never use heading tags (h1, h2, h3, h4, h5, h6). No surrounding code blocks or markdown.'
|
||||
: 'Return plain text only — no HTML tags, no markdown, no quotes, no labels.';
|
||||
const systemPrompt = [
|
||||
'You are a content writer helping build a landing page for an online course platform.',
|
||||
`Write text that matches the following description. Use a ${tone} tone.`,
|
||||
contextLine,
|
||||
formatLine
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const { text, usage } = await generateText(
|
||||
providerConfig.provider === AIProvider.GOOGLE
|
||||
? {
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: prompt.slice(0, 1000),
|
||||
maxOutputTokens: 512,
|
||||
maxRetries: 0,
|
||||
providerOptions: {
|
||||
google: { thinkingConfig: { thinkingBudget: 0 } }
|
||||
}
|
||||
}
|
||||
: {
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: prompt.slice(0, 1000),
|
||||
maxOutputTokens: 512,
|
||||
maxRetries: 0
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
text: text.trim(),
|
||||
usage: {
|
||||
promptTokens: usage.inputTokens ?? 0,
|
||||
completionTokens: usage.outputTokens ?? 0,
|
||||
totalTokens: usage.totalTokens ?? 0
|
||||
},
|
||||
modelName
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
const TITLE_MODELS: Record<AIProvider, string> = {
|
||||
[AIProvider.OPENAI]: 'gpt-4o-mini',
|
||||
[AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001',
|
||||
[AIProvider.GOOGLE]: 'gemini-2.5-flash',
|
||||
[AIProvider.GOOGLE]: 'gemini-3.1-flash-lite',
|
||||
[AIProvider.MOONSHOT]: 'kimi-k2.6'
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function generateConversationTitle(
|
||||
|
||||
const truncatedMessage = firstMessageText.slice(0, 1000);
|
||||
|
||||
// Gemini 2.5 counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text.
|
||||
// Gemini 3.x counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text.
|
||||
const { text } = await generateText(
|
||||
providerConfig.provider === AIProvider.GOOGLE
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { defaultAiTutorSettings, mergeAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
||||
import {
|
||||
getCourseAiTutorOverride,
|
||||
getOrgAiTutorSettings,
|
||||
updateCourseAiTutorOverride,
|
||||
updateOrgAiTutorSettings
|
||||
} from '@cio/db/queries/agent';
|
||||
|
||||
import { AppError } from '@api/utils/errors';
|
||||
|
||||
/**
|
||||
* Resolved AI tutor settings for a given (org, course) pair.
|
||||
*
|
||||
* Merge order (later overrides earlier):
|
||||
* defaultAiTutorSettings → org.aiTutorSettings → course.metadata.aiTutor
|
||||
*
|
||||
* If the course override has `inheritFromOrg !== false`, the org defaults apply
|
||||
* for every field the course did not explicitly set. The course can still flip
|
||||
* individual fields (including `enabled`).
|
||||
*/
|
||||
export async function getEffectiveAiTutorSettings(orgId: string, courseId: string): Promise<AiTutorSettings> {
|
||||
const [orgSettings, courseOverride] = await Promise.all([
|
||||
getOrgAiTutorSettings(orgId),
|
||||
getCourseAiTutorOverride(courseId)
|
||||
]);
|
||||
|
||||
const orgResolved = mergeAiTutorSettings(defaultAiTutorSettings, orgSettings as Partial<AiTutorSettings> | null);
|
||||
|
||||
if (!courseOverride) return orgResolved;
|
||||
if (courseOverride.inheritFromOrg === true) return orgResolved;
|
||||
|
||||
const { inheritFromOrg: _drop, ...override } = courseOverride;
|
||||
return mergeAiTutorSettings(orgResolved, override as Partial<AiTutorSettings>);
|
||||
}
|
||||
|
||||
export async function getResolvedOrgAiTutorSettings(orgId: string): Promise<AiTutorSettings> {
|
||||
const orgSettings = await getOrgAiTutorSettings(orgId);
|
||||
return mergeAiTutorSettings(defaultAiTutorSettings, orgSettings as Partial<AiTutorSettings> | null);
|
||||
}
|
||||
|
||||
export async function updateOrgAiTutorSettingsService(
|
||||
orgId: string,
|
||||
patch: Partial<AiTutorSettings>
|
||||
): Promise<AiTutorSettings> {
|
||||
const updated = await updateOrgAiTutorSettings(orgId, patch);
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Organization not found', 'ORGANIZATION_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
return mergeAiTutorSettings(defaultAiTutorSettings, updated as Partial<AiTutorSettings>);
|
||||
}
|
||||
|
||||
export interface CourseAiTutorSettingsView {
|
||||
org: AiTutorSettings;
|
||||
override: (Partial<AiTutorSettings> & { inheritFromOrg?: boolean }) | null;
|
||||
effective: AiTutorSettings;
|
||||
}
|
||||
|
||||
export async function getCourseAiTutorSettingsView(
|
||||
orgId: string,
|
||||
courseId: string
|
||||
): Promise<CourseAiTutorSettingsView> {
|
||||
const [org, override, effective] = await Promise.all([
|
||||
getResolvedOrgAiTutorSettings(orgId),
|
||||
getCourseAiTutorOverride(courseId),
|
||||
getEffectiveAiTutorSettings(orgId, courseId)
|
||||
]);
|
||||
|
||||
return {
|
||||
org,
|
||||
override: (override as (Partial<AiTutorSettings> & { inheritFromOrg?: boolean }) | null) ?? null,
|
||||
effective
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateCourseAiTutorOverrideService(
|
||||
courseId: string,
|
||||
patch: Partial<AiTutorSettings> & { inheritFromOrg?: boolean }
|
||||
): Promise<Partial<AiTutorSettings> & { inheritFromOrg?: boolean }> {
|
||||
const updated = await updateCourseAiTutorOverride(courseId, patch);
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
return updated as Partial<AiTutorSettings> & { inheritFromOrg?: boolean };
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
STUDENT_TUTOR_APPROACHING_THRESHOLD,
|
||||
STUDENT_TUTOR_MONTHLY_CAP,
|
||||
type AiTutorSettings
|
||||
} from '@cio/ai-assistant/tutor-config';
|
||||
import {
|
||||
getCapStatusSummary,
|
||||
getLearnerCapEvents,
|
||||
getLearnerCourseBreakdown,
|
||||
getLearnerDailyUsage,
|
||||
getLearnerLeaderboard,
|
||||
getMonthlyTutorCount,
|
||||
incrementMonthlyTutorCount,
|
||||
recordCapEvent
|
||||
} from '@cio/db/queries/agent';
|
||||
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { getEffectiveAiTutorSettings } from './tutor-config';
|
||||
import { enforceTokenBalance } from './usage';
|
||||
|
||||
export const STUDENT_TUTOR_CAP = STUDENT_TUTOR_MONTHLY_CAP;
|
||||
|
||||
/**
|
||||
* Whether per-learner cap enforcement is on for this environment.
|
||||
* Phase 1 ships with this off (instrumentation only); Phase 3 flips it on.
|
||||
*/
|
||||
export function isCapEnforced(): boolean {
|
||||
const raw = process.env.AI_TUTOR_CAP_ENFORCED;
|
||||
if (raw == null) return false;
|
||||
return raw === '1' || raw.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function startOfCurrentMonthUtc(): Date {
|
||||
const now = new Date();
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
function startOfPreviousMonthUtc(): Date {
|
||||
const now = new Date();
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
function startOfLast90DaysUtc(): Date {
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export function resolveTutorPeriod(period: 'current' | 'previous' | 'last90'): { start: Date; end?: Date } {
|
||||
if (period === 'previous') {
|
||||
const start = startOfPreviousMonthUtc();
|
||||
const end = startOfCurrentMonthUtc();
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
if (period === 'last90') {
|
||||
return { start: startOfLast90DaysUtc() };
|
||||
}
|
||||
|
||||
return { start: startOfCurrentMonthUtc() };
|
||||
}
|
||||
|
||||
export interface TutorPolicyResult {
|
||||
settings: AiTutorSettings;
|
||||
messageCount: number;
|
||||
capRemaining: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order of checks (matches PRD §"Enforcement"):
|
||||
* 1. Workspace AI tutor toggle (settings.enabled)
|
||||
* 2. Org pooled quota (enforceTokenBalance)
|
||||
* 3. Per-learner monthly cap (STUDENT_TUTOR_MONTHLY_CAP)
|
||||
*
|
||||
* Each failure logs a cap event so admins can see why a learner was blocked.
|
||||
* Cap enforcement is gated behind `AI_TUTOR_CAP_ENFORCED` for Phase 1 rollout.
|
||||
*/
|
||||
export async function enforceStudentTutorPolicy(
|
||||
orgId: string,
|
||||
courseId: string,
|
||||
userId: string
|
||||
): Promise<TutorPolicyResult> {
|
||||
const settings = await getEffectiveAiTutorSettings(orgId, courseId);
|
||||
|
||||
if (!settings.enabled) {
|
||||
await recordCapEvent({ orgId, userId, courseId, eventType: 'tutor_disabled' });
|
||||
throw new AppError('AI tutor is disabled for this workspace', 'AI_TUTOR_DISABLED', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
await enforceTokenBalance(orgId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.code === 'TOKEN_LIMIT_REACHED') {
|
||||
await recordCapEvent({ orgId, userId, courseId, eventType: 'pool_exhausted' });
|
||||
throw new AppError('AI tutor is taking a break', 'POOL_EXHAUSTED', 429);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const monthly = await getMonthlyTutorCount(orgId, userId, startOfCurrentMonthUtc());
|
||||
|
||||
if (isCapEnforced() && monthly.messageCount >= STUDENT_TUTOR_CAP) {
|
||||
if (!monthly.capHitAt) {
|
||||
await recordCapEvent({ orgId, userId, courseId, eventType: 'cap_reached' });
|
||||
}
|
||||
throw new AppError('AI tutor is taking a break', 'LEARNER_CAP_REACHED', 429);
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
messageCount: monthly.messageCount,
|
||||
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - monthly.messageCount)
|
||||
};
|
||||
}
|
||||
|
||||
export async function incrementStudentTutorCount(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
courseId: string
|
||||
): Promise<{ messageCount: number; capHit: boolean }> {
|
||||
const result = await incrementMonthlyTutorCount({
|
||||
orgId,
|
||||
userId,
|
||||
periodStart: startOfCurrentMonthUtc(),
|
||||
cap: STUDENT_TUTOR_CAP
|
||||
});
|
||||
|
||||
if (result.capHit && isCapEnforced()) {
|
||||
await recordCapEvent({ orgId, userId, courseId, eventType: 'cap_reached' });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getStudentTutorStatus(
|
||||
orgId: string,
|
||||
courseId: string,
|
||||
userId: string
|
||||
): Promise<{ enabled: boolean; cap: number; capRemaining: number; enforced: boolean }> {
|
||||
const settings = await getEffectiveAiTutorSettings(orgId, courseId);
|
||||
const monthly = await getMonthlyTutorCount(orgId, userId, startOfCurrentMonthUtc());
|
||||
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
cap: STUDENT_TUTOR_CAP,
|
||||
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - monthly.messageCount),
|
||||
enforced: isCapEnforced()
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Admin reads (Fair-Use leaderboard + summary + per-learner detail) ───────
|
||||
|
||||
export interface AdminLeaderboardEntry {
|
||||
userId: string;
|
||||
fullname: string | null;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
messageCount: number;
|
||||
tokens: number;
|
||||
capRemaining: number;
|
||||
capPct: number;
|
||||
status: 'under' | 'approaching' | 'at_cap';
|
||||
capHitAt: string | null;
|
||||
lastMessageAt: string | null;
|
||||
}
|
||||
|
||||
export async function getTutorLearnerLeaderboard(
|
||||
orgId: string,
|
||||
params: {
|
||||
period: 'current' | 'previous' | 'last90';
|
||||
search?: string;
|
||||
sort?: 'messages' | 'tokens' | 'capPct';
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<{ entries: AdminLeaderboardEntry[]; total: number; cap: number; periodStart: string }> {
|
||||
const { start, end } = resolveTutorPeriod(params.period);
|
||||
const page = params.page ?? 1;
|
||||
const limit = params.limit ?? 20;
|
||||
|
||||
const { rows, total } = await getLearnerLeaderboard(orgId, {
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
search: params.search,
|
||||
sort: params.sort,
|
||||
limit,
|
||||
offset: (page - 1) * limit
|
||||
});
|
||||
|
||||
const entries: AdminLeaderboardEntry[] = rows.map((row) => {
|
||||
const messageCount = row.messageCount;
|
||||
const capPct = messageCount / STUDENT_TUTOR_CAP;
|
||||
const status: AdminLeaderboardEntry['status'] =
|
||||
messageCount >= STUDENT_TUTOR_CAP
|
||||
? 'at_cap'
|
||||
: capPct >= STUDENT_TUTOR_APPROACHING_THRESHOLD
|
||||
? 'approaching'
|
||||
: 'under';
|
||||
|
||||
return {
|
||||
userId: row.userId,
|
||||
fullname: row.fullname,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatarUrl,
|
||||
messageCount,
|
||||
tokens: row.tokens,
|
||||
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - messageCount),
|
||||
capPct,
|
||||
status,
|
||||
capHitAt: row.capHitAt,
|
||||
lastMessageAt: row.lastMessageAt
|
||||
};
|
||||
});
|
||||
|
||||
return { entries, total, cap: STUDENT_TUTOR_CAP, periodStart: start.toISOString() };
|
||||
}
|
||||
|
||||
export async function getTutorCapStatusSummary(
|
||||
orgId: string,
|
||||
period: 'current' | 'previous' | 'last90' = 'current'
|
||||
): Promise<{ atCap: number; approaching: number; totalActive: number; cap: number; periodStart: string }> {
|
||||
const { start } = resolveTutorPeriod(period);
|
||||
const summary = await getCapStatusSummary(orgId, start, STUDENT_TUTOR_CAP, STUDENT_TUTOR_APPROACHING_THRESHOLD);
|
||||
|
||||
return { ...summary, cap: STUDENT_TUTOR_CAP, periodStart: start.toISOString() };
|
||||
}
|
||||
|
||||
export interface AdminLearnerDetail {
|
||||
userId: string;
|
||||
cap: number;
|
||||
messageCount: number;
|
||||
capRemaining: number;
|
||||
status: AdminLeaderboardEntry['status'];
|
||||
dailyUsage: { date: string; messages: number; tokens: number }[];
|
||||
courseBreakdown: { courseId: string; courseTitle: string | null; tokens: number; messages: number }[];
|
||||
capEvents: { id: number; eventType: string; occurredAt: string; courseId: string | null }[];
|
||||
periodStart: string;
|
||||
}
|
||||
|
||||
export async function getTutorLearnerDetail(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
period: 'current' | 'previous' | 'last90' = 'current'
|
||||
): Promise<AdminLearnerDetail> {
|
||||
const { start } = resolveTutorPeriod(period);
|
||||
const monthly = await getMonthlyTutorCount(orgId, userId, start);
|
||||
const [dailyUsage, courseBreakdown, capEvents] = await Promise.all([
|
||||
getLearnerDailyUsage(orgId, userId, start),
|
||||
getLearnerCourseBreakdown(orgId, userId, start),
|
||||
getLearnerCapEvents(orgId, userId, start)
|
||||
]);
|
||||
|
||||
const capPct = monthly.messageCount / STUDENT_TUTOR_CAP;
|
||||
const status: AdminLeaderboardEntry['status'] =
|
||||
monthly.messageCount >= STUDENT_TUTOR_CAP
|
||||
? 'at_cap'
|
||||
: capPct >= STUDENT_TUTOR_APPROACHING_THRESHOLD
|
||||
? 'approaching'
|
||||
: 'under';
|
||||
|
||||
return {
|
||||
userId,
|
||||
cap: STUDENT_TUTOR_CAP,
|
||||
messageCount: monthly.messageCount,
|
||||
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - monthly.messageCount),
|
||||
status,
|
||||
dailyUsage,
|
||||
courseBreakdown,
|
||||
capEvents,
|
||||
periodStart: start.toISOString()
|
||||
};
|
||||
}
|
||||
@@ -32,13 +32,15 @@ const PLAN_TOKEN_ALLOWANCES: Record<string, number> = {
|
||||
|
||||
// ─── Model Cost Multipliers ───────────────────────────────────────────────────
|
||||
|
||||
// Blended multiplier vs Gemini 2.5 Flash baseline ($0.15/1M); ~80/20 input/output mix; unknown → 1×.
|
||||
// Blended multiplier vs Gemini 3.1 Flash Lite baseline ($0.50/1M blended at 80/20 input/output mix); unknown → 1×.
|
||||
// gemini-2.5-flash kept for backward compat with historical usage records.
|
||||
const MODEL_COST_MULTIPLIER: Record<string, number> = {
|
||||
'gemini-2.5-flash': 1,
|
||||
'gpt-5.4-mini': 3,
|
||||
'gemini-3.1-flash-lite': 1,
|
||||
'gemini-2.5-flash': 1.5,
|
||||
'gpt-5.4-mini': 4,
|
||||
'claude-sonnet-4-6': 11,
|
||||
'claude-haiku-4-5-20251001': 1,
|
||||
'kimi-k2.6': 3
|
||||
'claude-haiku-4-5-20251001': 1.5,
|
||||
'kimi-k2.6': 4
|
||||
};
|
||||
|
||||
function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number {
|
||||
|
||||
Reference in New Issue
Block a user