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
+123 -20
View File
@@ -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) =>
+26 -3
View File
@@ -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;
}
+134 -4
View File
@@ -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
};
+53 -4
View File
@@ -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
});
});
}
})
};
}
+38
View File
@@ -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] };
}
+343
View File
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
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>;
+140
View File
@@ -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 };
}
+271
View File
@@ -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()
};
}
+7 -5
View File
@@ -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 {