diff --git a/BackendAcademy/src/ai/ai.controller.ts b/BackendAcademy/src/ai/ai.controller.ts index 91be3a0e6..eae3969d5 100644 --- a/BackendAcademy/src/ai/ai.controller.ts +++ b/BackendAcademy/src/ai/ai.controller.ts @@ -5,7 +5,7 @@ import { GetHintDto } from './dto/get-hint.dto'; import { PreScoreDto } from './dto/pre-score.dto'; import { VoiceInteractionDto } from './dto/voice-interaction.dto'; import { TtsRequestDto } from './dto/tts-request.dto'; -import { ChatMessage } from './interfaces/ai.interface'; +import { ChatMessage, HintUsageAnalytics } from './interfaces/ai.interface'; @Controller('ai') export class AiController { @@ -21,6 +21,11 @@ export class AiController { return this.aiService.getHint(getHintDto); } + @Get('hints/analytics') + async getHintUsageAnalytics(): Promise { + return this.aiService.getHintUsageAnalytics(); + } + @Post('pre-score') async preScore(@Body() dto: PreScoreDto) { return this.aiService.preScore(dto); diff --git a/BackendAcademy/src/ai/ai.service.spec.ts b/BackendAcademy/src/ai/ai.service.spec.ts index 51f7483df..a1457b925 100644 --- a/BackendAcademy/src/ai/ai.service.spec.ts +++ b/BackendAcademy/src/ai/ai.service.spec.ts @@ -93,6 +93,46 @@ describe('AiService', () => { const hint = await service.getHint({ userId: 'user-1', challengeId: 'nope', difficulty: 1 }); expect(hint.hint).toContain('No hints available'); }); + + it('records user-scoped, deduplicated hint usage (BA-081)', async () => { + const service = new AiService(undefined, configService({})); + + await service.getHint({ userId: 'user-1', challengeId: 'sample-challenge-001', difficulty: 1 }); + await service.getHint({ userId: 'user-1', challengeId: 'sample-challenge-001', difficulty: 1 }); + await service.getHint({ userId: 'user-2', challengeId: 'sample-challenge-001', difficulty: 1 }); + await service.getHint({ userId: 'user-2', challengeId: 'sample-challenge-001', difficulty: 2 }); + + const analytics = await service.getHintUsageAnalytics(); + + // Same user requesting the same hint twice is one deduplicated record. + expect(analytics.uniqueUsers).toBe(2); + expect(analytics.totalUses).toBe(4); + expect(analytics.records).toHaveLength(3); + + const firstHint = analytics.records.find( + (r) => r.userId === 'user-1' && r.difficulty === 1, + ); + expect(firstHint?.usedCount).toBe(2); + + // Difficulty distribution is captured for calibration. + expect(analytics.usesByDifficulty[1]).toBe(3); + expect(analytics.usesByDifficulty[2]).toBe(1); + }); + + it('survives a restart by persisting through RedisService (BA-081)', async () => { + const redis = new (require('../redis/redis.service').RedisService)(); + const service = new AiService(undefined, configService({}), undefined, redis); + + await service.getHint({ userId: 'user-1', challengeId: 'sample-challenge-001', difficulty: 1 }); + + // A fresh service instance (simulating a restart) reads from Redis. + const restarted = new AiService(undefined, configService({}), undefined, redis); + const analytics = await restarted.getHintUsageAnalytics(); + + expect(analytics.totalUses).toBe(1); + expect(analytics.uniqueUsers).toBe(1); + expect(analytics.records[0].userId).toBe('user-1'); + }); }); describe('preScore', () => { diff --git a/BackendAcademy/src/ai/ai.service.ts b/BackendAcademy/src/ai/ai.service.ts index 3f5ed6c57..5ae88a1b3 100644 --- a/BackendAcademy/src/ai/ai.service.ts +++ b/BackendAcademy/src/ai/ai.service.ts @@ -12,6 +12,8 @@ import { AiRecommendationResponse, ChatMessage, Hint, + HintUsageAnalytics, + HintUsageRecord, VoiceInteractionResponse, TtsResponse, } from './interfaces/ai.interface'; @@ -30,6 +32,14 @@ const MAX_CHAT_HISTORY_PER_USER = 200; // bound in-memory growth per user const MAX_TRACKED_USERS = 5_000; // bound total map size across users const MAX_PRE_SCORE_CODE_LENGTH = 20_000; // guard against oversized submissions +/// BA-081: Redis key prefix for durable hint usage records. +const HINT_USAGE_KEY_PREFIX = 'hint:usage:'; +/// BA-081: Redis key prefix for the per-hint user set (for unique-user counts). +const HINT_USERS_KEY_PREFIX = 'hint:users:'; +/// BA-081: Hint usage must survive restarts for calibration analytics; keep +/// records for 90 days instead of relying on the cache default TTL. +const HINT_USAGE_TTL_MS = 90 * 24 * 60 * 60 * 1000; + @Injectable() export class AiService { private readonly logger = new Logger(AiService.name); @@ -37,6 +47,8 @@ export class AiService { private chatHistory: Map = new Map(); private chatRecords: Map = new Map(); private hints: Map = new Map(); + /** BA-081: Durable hint usage records keyed by `userId:hintId`. */ + private hintUsage: Map = new Map(); private readonly defaultTimeoutMs: number; private readonly maxChatHistoryLength: number; @@ -249,7 +261,7 @@ export class AiService { } async getHint(getHintDto: GetHintDto): Promise { - const { challengeId, difficulty = 1 } = getHintDto; + const { challengeId, difficulty = 1, userId } = getHintDto; const challengeHints = this.hints.get(challengeId) || []; @@ -267,6 +279,11 @@ export class AiService { hint.usedCount++; + // BA-081: Persist user-scoped hint usage so counts are durable and can + // support difficulty calibration across instances. Falls back to a + // process-local map when RedisService isn't injected. + await this.recordHintUsage(userId, challengeId, hint); + return { hint: hint.hint, hintId: hint.id, @@ -274,6 +291,111 @@ export class AiService { }; } + // ────────────────────────────────────────────────────────────────── + // BA-081: Hint usage analytics + // ────────────────────────────────────────────────────────────────── + + /** + * Persist a hint request, deduplicated per `userId:hintId`. + * + * The in-memory map is the source of truth when no RedisService is + * injected (unit tests). When it is, every record is mirrored to Redis + * under `hint:usage:{userId}:{hintId}` and every hint keeps a set of the + * users who used it (`hint:users:{hintId}`) so unique-user counts survive + * restarts and are correct across instances. + */ + private async recordHintUsage( + userId: string, + challengeId: string, + hint: Hint, + ): Promise { + const recordKey = `${userId}:${hint.id}`; + const existing = this.hintUsage.get(recordKey); + const now = new Date(); + + const record: HintUsageRecord = existing + ? { ...existing, usedCount: existing.usedCount + 1, lastUsedAt: now } + : { + hintId: hint.id, + challengeId, + difficulty: hint.difficulty, + userId, + usedCount: 1, + firstUsedAt: now, + lastUsedAt: now, + }; + this.hintUsage.set(recordKey, record); + + if (this.redisService) { + await Promise.all([ + this.redisService.set( + `${HINT_USAGE_KEY_PREFIX}${recordKey}`, + record, + HINT_USAGE_TTL_MS, + ), + this.redisService.sadd(`${HINT_USERS_KEY_PREFIX}${hint.id}`, userId), + ]); + } + } + + /** + * BA-081: Aggregate hint usage for analytics. Combines the process-local + * map with any records persisted in Redis so results are correct even + * after a restart or across replicas. + */ + async getHintUsageAnalytics(): Promise { + const records = await this.collectHintUsageRecords(); + + const usesByHint: Record = {}; + const usesByDifficulty: Record = {}; + const uniqueUserIds = new Set(); + let totalUses = 0; + + for (const record of records) { + usesByHint[record.hintId] = (usesByHint[record.hintId] ?? 0) + record.usedCount; + usesByDifficulty[record.difficulty] = + (usesByDifficulty[record.difficulty] ?? 0) + record.usedCount; + uniqueUserIds.add(record.userId); + totalUses += record.usedCount; + } + + return { + totalUses, + uniqueUsers: uniqueUserIds.size, + records, + usesByHint, + usesByDifficulty, + }; + } + + /** + * BA-081: Fetch every persisted hint usage record, merging the local map + * with Redis state (Redis wins on key collision since it may contain data + * from another instance). + */ + private async collectHintUsageRecords(): Promise { + const merged = new Map(this.hintUsage); + + if (this.redisService) { + const persistedKeys = await this.redisService.getKeys( + `${HINT_USAGE_KEY_PREFIX}*`, + ); + for (const key of persistedKeys) { + const stored = (await this.redisService.get(key)) as + | HintUsageRecord + | null + | undefined; + if (stored && stored.hintId) { + merged.set(key.replace(HINT_USAGE_KEY_PREFIX, ''), stored); + } + } + } + + return Array.from(merged.values()).sort( + (a, b) => b.lastUsedAt.getTime() - a.lastUsedAt.getTime(), + ); + } + async preScore(dto: PreScoreDto): Promise { const { taskId, code } = dto; diff --git a/BackendAcademy/src/ai/interfaces/ai.interface.ts b/BackendAcademy/src/ai/interfaces/ai.interface.ts index c285625a6..ce11740bb 100644 --- a/BackendAcademy/src/ai/interfaces/ai.interface.ts +++ b/BackendAcademy/src/ai/interfaces/ai.interface.ts @@ -39,6 +39,40 @@ export interface AiHintResponse { difficulty: number; } +/** + * BA-081: Durable, user-scoped record of a hint request. + * + * Keyed by `userId:hintId`, so a user requesting the same hint twice is + * tracked as a single record with an incremented `usedCount` rather than two + * separate rows. This is what lets analytics answer "how often is this hint + * used, by how many distinct users, and at what difficulty" without counting + * the same learner repeatedly. + */ +export interface HintUsageRecord { + hintId: string; + challengeId: string; + difficulty: number; + userId: string; + /** Number of times this user requested this hint. */ + usedCount: number; + firstUsedAt: Date; + lastUsedAt: Date; +} + +/** + * BA-081: Aggregated hint usage, queryable for calibration analytics. + */ +export interface HintUsageAnalytics { + totalUses: number; + uniqueUsers: number; + /** One entry per `userId:hintId`, deduplicated per user. */ + records: HintUsageRecord[]; + /** `hintId -> total uses across all users`. */ + usesByHint: Record; + /** `difficulty -> total uses across all users`. */ + usesByDifficulty: Record; +} + export interface AiChatRecord { id: string; userId: string;