Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion BackendAcademy/src/ai/ai.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,6 +21,11 @@ export class AiController {
return this.aiService.getHint(getHintDto);
}

@Get('hints/analytics')
async getHintUsageAnalytics(): Promise<HintUsageAnalytics> {
return this.aiService.getHintUsageAnalytics();
}

@Post('pre-score')
async preScore(@Body() dto: PreScoreDto) {
return this.aiService.preScore(dto);
Expand Down
40 changes: 40 additions & 0 deletions BackendAcademy/src/ai/ai.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
124 changes: 123 additions & 1 deletion BackendAcademy/src/ai/ai.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
AiRecommendationResponse,
ChatMessage,
Hint,
HintUsageAnalytics,
HintUsageRecord,
VoiceInteractionResponse,
TtsResponse,
} from './interfaces/ai.interface';
Expand All @@ -30,13 +32,23 @@ 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);

private chatHistory: Map<string, ChatMessage[]> = new Map();
private chatRecords: Map<string, AiChatRecord> = new Map();
private hints: Map<string, Hint[]> = new Map();
/** BA-081: Durable hint usage records keyed by `userId:hintId`. */
private hintUsage: Map<string, HintUsageRecord> = new Map();
private readonly defaultTimeoutMs: number;
private readonly maxChatHistoryLength: number;

Expand Down Expand Up @@ -249,7 +261,7 @@ export class AiService {
}

async getHint(getHintDto: GetHintDto): Promise<AiHintResponse> {
const { challengeId, difficulty = 1 } = getHintDto;
const { challengeId, difficulty = 1, userId } = getHintDto;

const challengeHints = this.hints.get(challengeId) || [];

Expand All @@ -267,13 +279,123 @@ 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,
difficulty: hint.difficulty,
};
}

// ──────────────────────────────────────────────────────────────────
// 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<void> {
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<HintUsageAnalytics> {
const records = await this.collectHintUsageRecords();

const usesByHint: Record<string, number> = {};
const usesByDifficulty: Record<number, number> = {};
const uniqueUserIds = new Set<string>();
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<HintUsageRecord[]> {
const merged = new Map<string, HintUsageRecord>(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<PreScoreResult> {
const { taskId, code } = dto;

Expand Down
34 changes: 34 additions & 0 deletions BackendAcademy/src/ai/interfaces/ai.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
/** `difficulty -> total uses across all users`. */
usesByDifficulty: Record<number, number>;
}

export interface AiChatRecord {
id: string;
userId: string;
Expand Down
Loading