Files
classroomio/apps/api/src/services/agent/tutor-usage.ts
T

272 lines
8.2 KiB
TypeScript

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()
};
}