feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user