feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+263 -31
View File
@@ -11,7 +11,12 @@ import {
ZAgentCreditPurchase,
ZAgentCreditsBody,
ZAgentGenerateCourseTitleBody,
ZAgentStatusQuery
ZAgentGenerateTextBody,
ZAgentStatusQuery,
ZAgentSummarizeBody,
ZTutorUsageQuery,
ZTutorUsagePeriod,
ZTutorUsageUserParam
} from '@cio/utils/validation/agent';
import {
addCredits,
@@ -23,15 +28,32 @@ import {
isOrgOnPaidPlan,
recordTokenUsage
} from '@api/services/agent/usage';
import {
enforceStudentTutorPolicy,
getStudentTutorStatus,
getTutorCapStatusSummary,
getTutorLearnerDetail,
getTutorLearnerLeaderboard,
incrementStudentTutorCount
} from '@api/services/agent/tutor-usage';
import { buildStudentAgentTools } from '@api/services/agent/student-tools';
import { parseAndStoreDocument } from '@api/services/agent/document';
import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
import { generateCourseMeta } from '@api/services/agent/title-generation';
import { generateFieldText } from '@api/services/agent/text-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 {
AgentRole,
AIProvider,
MAX_STEPS_PER_ROUND,
getCourseTemplate,
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 { buildSystemPrompt, buildContextMessage } from '@cio/ai-assistant/prompt';
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import { redis } from '@api/utils/redis/redis';
import { db } from '@cio/db';
@@ -41,14 +63,17 @@ 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 { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls';
import {
collectDocumentIds,
getActiveCourseTemplateId,
getLatestImplementationPlan,
loadDocumentsText,
verifyExerciseBelongsToCourse,
verifyLessonBelongsToCourse
} from '@api/services/agent/chat-context';
import { buildAgentTools } from '@api/services/agent/chat-tools';
import { summarizeConversation } from '@api/services/agent/summarize';
import { agentHistoryRouter } from './history';
const agentCoreRouter = new Hono()
@@ -63,7 +88,8 @@ const agentCoreRouter = new Hono()
const status: AgentStatus = {
enabled: false,
role: AgentRole.STUDENT,
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 }
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 },
tutor: { enabled: false, capRemaining: null, cap: null, enforced: false }
};
return c.json({ success: true, data: status });
@@ -74,10 +100,16 @@ const agentCoreRouter = new Hono()
const usage = await getTokenBalance(orgId);
const tutor =
role === AgentRole.STUDENT
? await getStudentTutorStatus(orgId, courseId, user.id)
: { enabled: true, cap: null, capRemaining: null, enforced: false };
const status: AgentStatus = {
enabled: true,
role,
usage
usage,
tutor
};
return c.json({ success: true, data: status });
@@ -187,6 +219,67 @@ const agentCoreRouter = new Hono()
return handleError(c, error, 'Failed to fetch usage stats');
}
})
.get(
'/tutor-usage/leaderboard',
authMiddleware,
orgMemberMiddleware,
zValidator('query', ZTutorUsageQuery),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { period, search, sort, page, limit } = c.req.valid('query');
const data = await getTutorLearnerLeaderboard(orgId, { period, search, sort, page, limit });
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch learner leaderboard');
}
}
)
.get(
'/tutor-usage/summary',
authMiddleware,
orgMemberMiddleware,
zValidator(
'query',
ZTutorUsagePeriod.optional()
.transform((v) => v ?? 'current')
.pipe(ZTutorUsagePeriod)
.or(ZTutorUsagePeriod)
.transform((v) => ({ period: v }))
),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const period = c.req.query('period');
const parsed = ZTutorUsagePeriod.safeParse(period);
const data = await getTutorCapStatusSummary(orgId, parsed.success ? parsed.data : 'current');
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch tutor cap summary');
}
}
)
.get(
'/tutor-usage/:userId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZTutorUsageUserParam),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { userId } = c.req.valid('param');
const period = c.req.query('period');
const parsed = ZTutorUsagePeriod.safeParse(period);
const data = await getTutorLearnerDetail(orgId, userId, parsed.success ? parsed.data : 'current');
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch learner detail');
}
}
)
.post('/credits', authMiddleware, orgAdminMiddleware, zValidator('json', ZAgentCreditsBody), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
@@ -233,6 +326,59 @@ const agentCoreRouter = new Hono()
}
}
)
.post(
'/generate-text',
authMiddleware,
orgMemberMiddleware,
zValidator('json', ZAgentGenerateTextBody),
async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const { prompt, tone, format, context, courseId } = c.req.valid('json');
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
}
await enforceTokenBalance(orgId);
const { text, usage, modelName } = await generateFieldText(prompt, tone, format, context, providerConfig);
if (courseId) {
await recordTokenUsage(orgId, user.id, courseId, usage, modelName);
}
return c.json({ success: true as const, data: { text } });
} catch (error) {
return handleError(c, error, 'Failed to generate text');
}
}
)
.post('/summarize', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentSummarizeBody), async (c) => {
try {
const { messages, courseId } = c.req.valid('json');
const user = c.get('user')!;
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
if (!isTeamMember) {
throw new AppError(
'You must be a course team member to summarize conversations',
'NOT_COURSE_TEAM_MEMBER',
403
);
}
const summary = await summarizeConversation({ messages });
return c.json({ success: true as const, data: { summary } });
} catch (error) {
return handleError(c, error, 'Failed to summarize conversation');
}
})
.post('/chat', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentChatBody), async (c) => {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
@@ -240,7 +386,14 @@ const agentCoreRouter = new Hono()
try {
const { courseId, messages, context, model: requestedModel } = c.req.valid('json');
const modelId = requestedModel ?? DEFAULT_PICKER_MODEL_ID;
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
// Students always use the platform default; admins can pick. Forcing the
// default avoids exposing premium models to learners and keeps per-message
// cost predictable for fair-use accounting.
const modelId =
role === AgentRole.STUDENT ? DEFAULT_PICKER_MODEL_ID : (requestedModel ?? DEFAULT_PICKER_MODEL_ID);
const modelDescriptor = AGENT_MODELS[modelId];
const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider);
@@ -250,13 +403,6 @@ const agentCoreRouter = new Hono()
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,
@@ -276,7 +422,7 @@ const agentCoreRouter = new Hono()
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
}
if (!modelDescriptor.isFree) {
if (role === AgentRole.TEACHER && !modelDescriptor.isFree) {
const isPaid = await isOrgOnPaidPlan(orgId);
if (!isPaid) {
@@ -284,7 +430,14 @@ const agentCoreRouter = new Hono()
}
}
await enforceTokenBalance(orgId);
// Students go through tutor policy (workspace toggle, pool, per-learner cap).
// Teachers continue to use the existing pool-only check.
const studentPolicy =
role === AgentRole.STUDENT ? await enforceStudentTutorPolicy(orgId, courseId, user.id) : null;
if (role === AgentRole.TEACHER) {
await enforceTokenBalance(orgId);
}
const documentIds = collectDocumentIds(messages, context?.documentId);
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined;
@@ -348,38 +501,98 @@ const agentCoreRouter = new Hono()
const startTime = Date.now();
const model = createModel(providerConfig);
const approvedPlan = getLatestImplementationPlan(messages);
const systemPrompt = [
buildSystemPrompt(agentContext),
const approvedPlan = role === AgentRole.TEACHER ? getLatestImplementationPlan(messages) : undefined;
const activeTemplateId = role === AgentRole.TEACHER ? getActiveCourseTemplateId(messages) : undefined;
const activeTemplate = activeTemplateId ? getCourseTemplate(activeTemplateId) : undefined;
// Stable across requests — safe to cache as a long-lived Anthropic prefix.
// Volatile per-request context (lesson/exercise/document/section count/
// approved plan/active template) is sent as a user-turn message instead so
// it doesn't invalidate the tools+system cache.
const systemPrompt = buildSystemPrompt(agentContext, {
tutorSettings: studentPolicy?.settings
});
const contextMessageText = buildContextMessage(agentContext, {
template: activeTemplate,
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 agentTools =
role === AgentRole.STUDENT
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
: buildAgentTools(orgId, user.id, courseId, messages);
const trimmedMessages = trimMessageHistory(messages);
const modelMessages = await convertToModelMessages(trimmedMessages);
const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(trimmedMessages));
let completedStepCount = 0;
let finishReason: string | undefined;
const isAnthropic = providerConfig.provider === AIProvider.ANTHROPIC;
// 1h TTL keeps the prefix warm across tool-execution gaps in long agent
// runs. Break-even is 3 requests within the hour; well under most plan-
// execution loops.
const systemContent = isAnthropic
? {
role: 'system' as const,
content: systemPrompt,
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
providerOptions: {
anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } }
}
}
: systemPrompt;
// Prepend volatile context as a user-turn message so the stable system +
// tools prefix stays cacheable even when the teacher navigates to a
// different lesson or the agent creates sections mid-run.
const modelMessages =
contextMessageText.length > 0
? [
{ role: 'user' as const, content: [{ type: 'text' as const, text: contextMessageText }] },
...convertedMessages
]
: convertedMessages;
console.log(
'[agent.chat] modelMessages →',
JSON.stringify(
modelMessages.map((m, i) => ({
i,
role: m.role,
parts: Array.isArray(m.content)
? m.content.map((p: any) => ({
type: p.type,
toolCallId: p.toolCallId,
toolName: p.toolName,
textPreview: typeof p.text === 'string' ? p.text.slice(0, 60) : undefined
}))
: typeof m.content === 'string'
? m.content.slice(0, 80)
: m.content
})),
null,
2
)
);
// Cache the growing conversation prefix: each turn reads the prior
// transcript at ~0.1x cost instead of reprocessing it at full price.
if (isAnthropic && modelMessages.length > 0) {
const lastMessage = modelMessages[modelMessages.length - 1];
const existingAnthropic = (lastMessage.providerOptions?.anthropic as Record<string, unknown> | undefined) ?? {};
lastMessage.providerOptions = {
...(lastMessage.providerOptions ?? {}),
anthropic: {
...existingAnthropic,
cacheControl: { type: 'ephemeral', ttl: '1h' }
}
};
}
const result = streamText({
model,
maxRetries: 0,
maxRetries: 2,
system: systemContent,
messages: modelMessages,
tools: agentTools,
@@ -394,6 +607,21 @@ const agentCoreRouter = new Hono()
const inputTokens = totalUsage?.inputTokens ?? 0;
const outputTokens = totalUsage?.outputTokens ?? 0;
// Cache hit/miss visibility. If cacheRead stays 0 across repeated
// turns of the same conversation, a silent invalidator is leaking
// into the cached prefix — audit system prompt and tool definitions.
if (isAnthropic) {
const details = totalUsage?.inputTokenDetails;
const cacheRead = details?.cacheReadTokens ?? 0;
const cacheWrite = details?.cacheWriteTokens ?? 0;
const uncached = details?.noCacheTokens ?? inputTokens;
const total = uncached + cacheRead + cacheWrite;
const hitRate = total > 0 ? Math.round((cacheRead / total) * 100) : 0;
console.log(
`[agent.chat] cache hit=${hitRate}% read=${cacheRead} write=${cacheWrite} uncached=${uncached} output=${outputTokens}`
);
}
if (totalUsage) {
await recordTokenUsage(
orgId,
@@ -408,6 +636,10 @@ const agentCoreRouter = new Hono()
);
}
if (role === AgentRole.STUDENT) {
await incrementStudentTutorCount(orgId, user.id, courseId);
}
trackAgentEvent(AgentEvent.CHAT_COMPLETED, {
orgId,
userId: user.id,
+55
View File
@@ -7,6 +7,7 @@ import {
ZAgentConversationParam,
ZAgentConversationCreateBody,
ZAgentHistorySaveBody,
ZAgentHistoryRenameBody,
ZAgentHistoryDeleteParam,
ZAgentGenerateTitleParam,
ZAgentGenerateTitleBody
@@ -20,6 +21,7 @@ import {
deleteChatConversation,
updateConversationTitle
} from '@api/services/agent/chat-history';
import { compactConversation } from '@api/services/agent/compact';
import { generateConversationTitle } from '@api/services/agent/title-generation';
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
@@ -110,6 +112,37 @@ export const agentHistoryRouter = new Hono()
}
)
/**
* PATCH /agent/history/:conversationId
* Rename a conversation without re-uploading messages.
*/
.patch(
'/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
zValidator('json', ZAgentHistoryRenameBody),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const { title } = c.req.valid('json');
const conversation = await getChatConversation(conversationId, user.id);
if (!conversation) {
return c.json({ success: false as const, error: 'Conversation not found' }, 404);
}
await updateConversationTitle(conversationId, user.id, title);
return c.json({ success: true as const, data: { id: conversationId, title } });
} catch (error) {
return handleError(c, error, 'Failed to rename conversation');
}
}
)
/**
* DELETE /agent/history/:conversationId
* Delete a conversation.
@@ -133,6 +166,28 @@ export const agentHistoryRouter = new Hono()
}
)
/**
* POST /agent/history/:conversationId/compact
* Summarize and replace persisted messages with a single seed message.
*/
.post(
'/:conversationId/compact',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const data = await compactConversation(conversationId, user.id);
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to compact conversation');
}
}
)
/**
* POST /agent/history/:conversationId/generate-title
* Generate a short title from the first user message using a cheap model.