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
5 changes: 3 additions & 2 deletions BackendAcademy/src/ai/ai.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AiController } from './ai.controller';
import { AiService, AI_PROVIDER } from './ai.service';
import { PromptTemplateService } from './prompt-template.service';
import { ClaudeProvider } from './providers/claude.provider';
import { OpenaiProvider } from './providers/openai.provider';

Expand All @@ -18,7 +19,7 @@ const aiProviderFactory = {

@Module({
controllers: [AiController],
providers: [AiService, aiProviderFactory],
exports: [AiService],
providers: [AiService, PromptTemplateService, aiProviderFactory],
exports: [AiService, PromptTemplateService],
})
export class AiModule {}
182 changes: 181 additions & 1 deletion BackendAcademy/src/ai/ai.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CreateChatRequestDto } from './dto/create-chat-request.dto';
import { GetHintDto } from './dto/get-hint.dto';
Expand All @@ -17,6 +17,7 @@ import {
} from './interfaces/ai.interface';
import { PreScoreResult } from './interfaces/pre-score.interface';
import { AiProvider } from './interfaces/ai-provider.interface';
import { PromptTemplateService } from './prompt-template.service';
import { v4 as uuidv4 } from 'uuid';
import { AnalyticsService } from '../analytics/analytics.service';
import { RedisService } from '../redis/redis.service';
Expand All @@ -27,20 +28,24 @@ export const AI_PROVIDER = 'AI_PROVIDER';

@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();
private readonly defaultTimeoutMs: number;
private readonly maxChatHistoryLength: number;

constructor(
@Optional() @Inject(AI_PROVIDER) private aiProvider?: AiProvider,
private configService?: ConfigService,
private readonly analyticsService?: AnalyticsService,
private readonly redisService?: RedisService,
private readonly monitoringService?: MonitoringService,
@Optional() private readonly promptTemplateService?: PromptTemplateService,
@Optional() private readonly securityService?: SecurityService,
) {
this.defaultTimeoutMs = this.configService?.get<number>('DEFAULT_REQUEST_TIMEOUT_MS') ?? 30_000;
this.maxChatHistoryLength = this.configService?.get<number>('AI_MAX_CHAT_HISTORY_LENGTH') ?? 50;
this.initializeSampleHints();
}

Expand Down Expand Up @@ -101,6 +106,21 @@ export class AiService {
): Promise<AiChatResponse> {
const { message, userId, context } = createChatRequestDto;

// #374: Use versioned prompt template from configuration
const systemPrompt = this.promptTemplateService
? this.promptTemplateService.getSystemPrompt('chat_tutor', {
version: this.configService?.get<string>('AI_PROMPT_TEMPLATE_VERSION'),
})
: 'You are a helpful Rust programming tutor.';

const response = this.aiProvider
? await this.aiProvider.generateChatCompletion({
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: message },
],
})
: this.fallbackResponse(message);
// Issue #371: sanitise user-supplied prompts before they reach the AI
// provider. When SecurityService is wired in, prompts containing known
// prompt-injection patterns are either wrapped in a hard system-pinned
Expand Down Expand Up @@ -130,13 +150,27 @@ export class AiService {
response,
timestamp: new Date(),
context,
isComplete: true,
};

if (!this.chatHistory.has(userId)) {
this.chatHistory.set(userId, []);
}
this.chatHistory.get(userId)!.push(chatMessage);

// #372: Auto-summarise when history exceeds threshold
await this.autoSummarize(userId);

// Track prompt template version in metrics (#374)
if (this.monitoringService) {
const templateVersion =
this.configService?.get<string>('AI_PROMPT_TEMPLATE_VERSION') ?? '1.0.0';
this.monitoringService.incrementCounter('ai_prompt_template_used', 1, {
version: templateVersion,
template: 'chat_tutor',
});
}

if (this.redisService) {
await this.redisService.refreshUserSnapshot(userId, {
lastInteractionAt: new Date(),
Expand Down Expand Up @@ -253,10 +287,156 @@ export class AiService {
};
}

// ──────────────────────────────────────────────────────────────────
// Chat history management (#372, #373)
// ──────────────────────────────────────────────────────────────────

async getChatHistory(userId: string): Promise<ChatMessage[]> {
return this.chatHistory.get(userId) || [];
}

/**
* #373: Marks a message as incomplete when a chat streaming disconnect
* leaves a partial response in state. Incomplete messages can be cleaned
* up or shown to the user with a warning.
*/
markMessageIncomplete(userId: string, messageId: string): boolean {
const history = this.chatHistory.get(userId);
if (!history) return false;

const msg = history.find((m) => m.id === messageId);
if (!msg) return false;

msg.isComplete = false;

if (this.monitoringService) {
this.monitoringService.incrementCounter('chat_streaming_disconnects', 1, {
userId,
messageId,
});
}

this.logger.warn(
`Message ${messageId} for user ${userId} marked incomplete due to streaming disconnect`,
);
return true;
}

/**
* #373: Removes all incomplete messages from a user's chat history,
* preventing partial responses from persisting in conversation state.
*/
cleanupIncompleteMessages(userId: string): number {
const history = this.chatHistory.get(userId);
if (!history) return 0;

const incompleteCount = history.filter((m) => !m.isComplete).length;
const cleaned = history.filter((m) => m.isComplete);
this.chatHistory.set(userId, cleaned);

if (incompleteCount > 0) {
this.logger.log(
`Cleaned up ${incompleteCount} incomplete messages for user ${userId}`,
);
if (this.monitoringService) {
this.monitoringService.incrementCounter(
'chat_incomplete_messages_cleaned',
incompleteCount,
{ userId },
);
}
}
return incompleteCount;
}

/**
* #372: Automatically generates a conversation summary when chat history
* exceeds the configured maxChatHistoryLength. Older messages are compacted
* into a summary string to keep token usage under control during long
* tutoring sessions.
*/
private async autoSummarize(userId: string): Promise<void> {
const history = this.chatHistory.get(userId);
if (!history || history.length <= this.maxChatHistoryLength) return;

const excess = history.length - this.maxChatHistoryLength;
const olderMessages = history.slice(0, excess);
const recentMessages = history.slice(excess);

// Build a compact summary from older messages
const topicSummary = this.buildConversationSummary(olderMessages);

// Store the summary on the most relevant chat record or create one
const existingRecord = Array.from(this.chatRecords.values()).find(
(r) => r.userId === userId,
);

const summaryText = `[Conversation summary — ${new Date().toISOString()}]: ${topicSummary}`;

if (existingRecord) {
existingRecord.summary = existingRecord.summary
? `${existingRecord.summary}\n${summaryText}`
: summaryText;
existingRecord.lastSummaryAt = new Date();
} else {
const newRecord: AiChatRecord = {
id: uuidv4(),
userId,
sessionId: `session-${Date.now()}`,
messages: recentMessages,
startedAt: olderMessages[0]?.timestamp ?? new Date(),
lastActivityAt: new Date(),
summary: summaryText,
lastSummaryAt: new Date(),
};
this.chatRecords.set(newRecord.id, newRecord);
}

// Keep only the most recent messages in active history
this.chatHistory.set(userId, recentMessages);

if (this.monitoringService) {
this.monitoringService.incrementCounter('chat_summary_generated', 1, {
userId,
compactedCount: String(excess),
});
}

this.logger.log(
`Auto-summarised ${excess} messages for user ${userId} (${recentMessages.length} retained)`,
);
}

/**
* #372: Builds a compact conversation summary from a list of chat messages.
* Extracts key topics and user questions without storing the full text.
*/
private buildConversationSummary(messages: ChatMessage[]): string {
if (messages.length === 0) return 'No prior conversation.';

const userMessages = messages
.filter((m) => m.message && m.message.trim().length > 0)
.map((m) => m.message.slice(0, 120));

if (userMessages.length === 0) return `${messages.length} interactions.`;

const topics = userMessages.slice(0, 10).join('; ');
const topicPreview =
topics.length > 500 ? topics.slice(0, 500) + '...' : topics;

return `${messages.length} messages covering: ${topicPreview}`;
}

/**
* #372: Returns the current conversation summary for a user, if one exists.
*/
getConversationSummary(userId: string): string | null {
const record = Array.from(this.chatRecords.values()).find(
(r) => r.userId === userId,
);
return record?.summary ?? null;
}

getChatRecord(sessionId: string): AiChatRecord | null {
return this.chatRecords.get(sessionId) ?? null;
}
Expand Down
6 changes: 6 additions & 0 deletions BackendAcademy/src/ai/interfaces/ai.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface ChatMessage {
response: string;
timestamp: Date;
context?: Record<string, any>;
/** Indicates whether the chat response was fully delivered (#373) */
isComplete: boolean;
}

export interface Hint {
Expand Down Expand Up @@ -45,6 +47,10 @@ export interface AiChatRecord {
startedAt: Date;
lastActivityAt: Date;
metadata?: Record<string, unknown>;
/** Compact summary of conversation for long tutoring sessions (#372) */
summary?: string;
/** Timestamp of the last summary generation (#372) */
lastSummaryAt?: Date;
}

export interface VoiceInteractionResponse {
Expand Down
Loading
Loading