chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
+462
View File
@@ -0,0 +1,462 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { handleError, AppError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { streamText, stepCountIs, convertToModelMessages } from 'ai';
import {
ZAgentChatBody,
ZAgentCreditPurchase,
ZAgentCreditsBody,
ZAgentGenerateCourseTitleBody,
ZAgentStatusQuery
} from '@cio/utils/validation/agent';
import {
addCredits,
enforceTokenBalance,
getDetailedUsage,
getPurchasedSummary,
getTeamLeaderboard,
getTokenBalance,
isOrgOnPaidPlan,
recordTokenUsage
} from '@api/services/agent/usage';
import { parseAndStoreDocument } from '@api/services/agent/document';
import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
import { generateCourseMeta } from '@api/services/agent/title-generation';
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { getChatConversation } from '@cio/db/queries/agent';
import { AgentRole, AIProvider, MAX_STEPS_PER_ROUND, type AgentContext, type AgentStatus } from '@cio/ai-assistant';
import { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID } from '@cio/utils/agent-models';
import { buildSystemPrompt } from '@cio/ai-assistant/prompt';
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import { redis } from '@api/utils/redis/redis';
import { db } from '@cio/db';
import * as schema from '@cio/db/schema';
import { eq } from 'drizzle-orm';
import { listCourseSections } from '@api/services/course/section';
import { getLesson } from '@api/services/lesson/lesson';
import { getExercise } from '@api/services/exercise/exercise';
import { trimMessageHistory } from '@api/services/agent/context-window';
import {
collectDocumentIds,
getLatestImplementationPlan,
loadDocumentsText,
verifyExerciseBelongsToCourse,
verifyLessonBelongsToCourse
} from '@api/services/agent/chat-context';
import { buildAgentTools } from '@api/services/agent/chat-tools';
import { agentHistoryRouter } from './history';
const agentCoreRouter = new Hono()
.get('/status', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentStatusQuery), async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const { courseId } = c.req.valid('query');
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
const status: AgentStatus = {
enabled: false,
role: AgentRole.STUDENT,
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 }
};
return c.json({ success: true, data: status });
}
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
const usage = await getTokenBalance(orgId);
const status: AgentStatus = {
enabled: true,
role,
usage
};
return c.json({ success: true, data: status });
} catch (error) {
return handleError(c, error, 'Failed to get agent status');
}
})
.post('/upload', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const courseId = c.req.query('courseId');
if (!courseId) {
throw new AppError('Course ID is required', 'COURSE_ID_REQUIRED', 400);
}
const conversationId = c.req.query('conversationId');
if (!conversationId) {
throw new AppError('Conversation ID is required', 'CONVERSATION_ID_REQUIRED', 400);
}
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
if (!isTeamMember) {
throw new AppError('You must be a course team member to upload documents', 'NOT_COURSE_TEAM_MEMBER', 403);
}
const conversation = await getChatConversation(conversationId, user.id);
if (!conversation || conversation.courseId !== courseId) {
throw new AppError('Conversation not found', 'CONVERSATION_NOT_FOUND', 404);
}
const isPaid = await isOrgOnPaidPlan(orgId);
if (!isPaid) {
return c.json(
{
success: false,
error: 'document_upload_requires_upgrade',
upgradeRequired: true
},
403
);
}
const body = await c.req.parseBody();
const file = body.file;
if (!(file instanceof File)) {
throw new AppError('File is required', 'FILE_REQUIRED', 400);
}
const result = await parseAndStoreDocument(file, orgId, user.id, courseId, conversationId, redis);
return c.json({ success: true, data: result });
} catch (error) {
if (error instanceof AppError) {
if (error.statusCode === 413) {
return c.json({ success: false, error: 'file_too_large', maxSize: 5242880 }, 413);
}
if (error.statusCode === 415) {
return c.json(
{
success: false,
error: 'unsupported_file_type',
allowed: [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
]
},
415
);
}
}
return handleError(c, error, 'Failed to upload document');
}
})
.get('/usage/purchased', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const data = await getPurchasedSummary(orgId);
return c.json({ success: true, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch purchased summary');
}
})
.get('/usage/leaderboard', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const data = await getTeamLeaderboard(orgId);
return c.json({ success: true, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch leaderboard');
}
})
.get('/usage', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const usage = await getDetailedUsage(orgId);
return c.json({ success: true, data: usage });
} catch (error) {
return handleError(c, error, 'Failed to fetch usage stats');
}
})
.post('/credits', authMiddleware, orgAdminMiddleware, zValidator('json', ZAgentCreditsBody), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { amount } = c.req.valid('json');
await addCredits(orgId, amount);
const balance = await getTokenBalance(orgId);
return c.json({ success: true, data: balance });
} catch (error) {
return handleError(c, error, 'Failed to purchase credits');
}
})
.post('/credits/purchase', authOrApiKeyMiddleware, zValidator('json', ZAgentCreditPurchase), async (c) => {
try {
const body = c.req.valid('json');
const purchase = await recordCreditPurchase(body);
return c.json({ success: true, data: purchase });
} catch (error) {
return handleError(c, error, 'Failed to record credit purchase');
}
})
.post(
'/generate-course-title',
authMiddleware,
orgMemberMiddleware,
zValidator('json', ZAgentGenerateCourseTitleBody),
async (c) => {
try {
const { prompt } = c.req.valid('json');
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
}
const meta = await generateCourseMeta(prompt, providerConfig);
return c.json({ success: true as const, data: meta });
} catch (error) {
return handleError(c, error, 'Failed to generate course title');
}
}
)
.post('/chat', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentChatBody), async (c) => {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
try {
const { courseId, messages, context, model: requestedModel } = c.req.valid('json');
const modelId = requestedModel ?? DEFAULT_PICKER_MODEL_ID;
const modelDescriptor = AGENT_MODELS[modelId];
const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider);
if (!baseProviderConfig) {
throw new AppError(`AI assistant is not configured for model "${modelId}"`, 'AI_NOT_CONFIGURED', 503);
}
const providerConfig = { ...baseProviderConfig, model: modelDescriptor.backendModelId };
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
if (role === AgentRole.STUDENT) {
throw new AppError('AI assistant is not available for students yet', 'STUDENT_NOT_SUPPORTED', 403);
}
const [courseRow] = await db
.select({
title: schema.course.title,
description: schema.course.description,
organizationId: schema.group.organizationId
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(eq(schema.course.id, courseId))
.limit(1);
if (!courseRow) {
throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404);
}
if (courseRow.organizationId !== orgId) {
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
}
if (!modelDescriptor.isFree) {
const isPaid = await isOrgOnPaidPlan(orgId);
if (!isPaid) {
throw new AppError('Upgrade required to use this AI model', 'UPGRADE_REQUIRED', 403);
}
}
await enforceTokenBalance(orgId);
const documentIds = collectDocumentIds(messages, context?.documentId);
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined;
const existingSections = await listCourseSections(courseId);
let lessonTitle: string | undefined;
let lessonContent: string | undefined;
if (context?.lessonId) {
try {
await verifyLessonBelongsToCourse(context.lessonId, courseId);
const lesson = await getLesson(context.lessonId);
lessonTitle = lesson.title;
const lessonWithLangs = lesson as {
lessonLanguages?: Array<{ locale: string; content: string | null }>;
};
const langContent = lessonWithLangs.lessonLanguages?.find((ll) => ll.locale === 'en');
lessonContent = langContent?.content || undefined;
} catch {
// Lesson not found or doesn't belong — continue without lesson context
}
}
let exerciseTitle: string | undefined;
if (context?.exerciseId) {
try {
await verifyExerciseBelongsToCourse(context.exerciseId, courseId);
const exercise = await getExercise(context.exerciseId);
exerciseTitle = exercise.title;
} catch {
// Exercise not found or doesn't belong — continue without exercise context
}
}
const agentContext: AgentContext = {
orgId,
courseId,
courseTitle: courseRow.title,
courseDescription: courseRow.description || undefined,
userId: user.id,
role,
locale: 'en',
lessonId: context?.lessonId,
lessonTitle,
lessonContent,
exerciseId: context?.exerciseId,
exerciseTitle,
documentId: context?.documentId,
documentText,
existingSectionCount: existingSections.length
};
trackAgentEvent(AgentEvent.CHAT_STARTED, {
orgId,
userId: user.id,
courseId,
role,
hasDocument: !!documentText,
messageCount: messages.length
});
const startTime = Date.now();
const model = createModel(providerConfig);
const approvedPlan = getLatestImplementationPlan(messages);
const systemPrompt = [
buildSystemPrompt(agentContext),
approvedPlan
? [
'',
'The latest user message approved a final course plan for immediate execution.',
'Implement that exact plan directly without asking the user to restate it.',
'Treat this approved plan as the canonical source if it differs from any earlier draft.',
`Approved plan JSON:\n${JSON.stringify(approvedPlan, null, 2)}`
].join('\n')
: ''
].join('\n');
const agentTools = buildAgentTools(orgId, user.id, courseId);
const trimmedMessages = trimMessageHistory(messages);
const modelMessages = await convertToModelMessages(trimmedMessages);
let completedStepCount = 0;
let finishReason: string | undefined;
const isAnthropic = providerConfig.provider === AIProvider.ANTHROPIC;
const systemContent = isAnthropic
? {
role: 'system' as const,
content: systemPrompt,
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
}
: systemPrompt;
const result = streamText({
model,
maxRetries: 0,
system: systemContent,
messages: modelMessages,
tools: agentTools,
stopWhen: stepCountIs(MAX_STEPS_PER_ROUND),
onStepFinish: () => {
completedStepCount += 1;
},
onFinish: async ({ totalUsage, finishReason: resultFinishReason, steps }) => {
completedStepCount = steps.length;
finishReason = resultFinishReason;
const durationMs = Date.now() - startTime;
const inputTokens = totalUsage?.inputTokens ?? 0;
const outputTokens = totalUsage?.outputTokens ?? 0;
if (totalUsage) {
await recordTokenUsage(
orgId,
user.id,
courseId,
{
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens
},
providerConfig.model || providerConfig.provider
);
}
trackAgentEvent(AgentEvent.CHAT_COMPLETED, {
orgId,
userId: user.id,
courseId,
inputTokens,
outputTokens,
model: providerConfig.model || providerConfig.provider,
durationMs
});
}
});
return result.toUIMessageStreamResponse({
messageMetadata: ({ part }) => {
if (part.type !== 'finish') {
return undefined;
}
return {
tokenUsage: {
promptTokens: part.totalUsage.inputTokens,
completionTokens: part.totalUsage.outputTokens,
totalTokens: part.totalUsage.totalTokens
},
continuation:
completedStepCount >= MAX_STEPS_PER_ROUND
? {
reason: 'step_limit' as const,
maxSteps: MAX_STEPS_PER_ROUND,
finishReason
}
: undefined
};
}
});
} catch (error) {
trackAgentEvent(AgentEvent.CHAT_ERROR, {
orgId,
userId: user?.id,
errorMessage: error instanceof Error ? error.message : 'Unknown error'
});
if (error instanceof AppError) {
return c.json({ success: false, error: error.message, code: error.code }, error.statusCode);
}
console.error('Agent chat error:', error);
return c.json({ success: false, error: 'Failed to process chat message', code: 'INTERNAL_ERROR' }, 500);
}
});
export const agentRouter = new Hono().route('/', agentCoreRouter).route('/history', agentHistoryRouter);