Files
aurak/server/src/chat/chat.service.ts
T

715 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ChatOpenAI } from '@langchain/openai';
import { PromptTemplate } from '@langchain/core/prompts';
import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
import { EmbeddingService } from '../knowledge-base/embedding.service';
import { ModelConfigService } from '../model-config/model-config.service';
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
import { SearchHistoryService } from '../search-history/search-history.service';
import { ModelConfig, ModelType } from '../types';
import { RagService } from '../rag/rag.service';
import {
DEFAULT_VECTOR_DIMENSIONS,
DEFAULT_LANGUAGE,
} from '../common/constants';
import { I18nService } from '../i18n/i18n.service';
import { TenantService } from '../tenant/tenant.service';
import { UserSettingService } from '../user/user-setting.service';
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
@Injectable()
export class ChatService {
private readonly logger = new Logger(ChatService.name);
private readonly defaultDimensions: number;
constructor(
@Inject(forwardRef(() => ElasticsearchService))
private elasticsearchService: ElasticsearchService,
private embeddingService: EmbeddingService,
private modelConfigService: ModelConfigService,
@Inject(forwardRef(() => KnowledgeGroupService))
private knowledgeGroupService: KnowledgeGroupService,
private searchHistoryService: SearchHistoryService,
private configService: ConfigService,
private ragService: RagService,
private i18nService: I18nService,
private tenantService: TenantService,
private userSettingService: UserSettingService,
) {
this.defaultDimensions = parseInt(
this.configService.get<string>(
'DEFAULT_VECTOR_DIMENSIONS',
String(DEFAULT_VECTOR_DIMENSIONS),
),
);
}
async *streamChat(
message: string,
history: ChatMessage[],
userId: string,
modelConfig: ModelConfig,
userLanguage: string = DEFAULT_LANGUAGE,
selectedEmbeddingId?: string,
selectedGroups?: string[], // New: Selected groups
selectedFiles?: string[], // New: Selected files
historyId?: string, // New: Chat history ID
enableRerank: boolean = false,
selectedRerankId?: string,
temperature?: number, // New: temperature parameter
maxTokens?: number, // New: maxTokens parameter
topK?: number, // New: topK parameter
similarityThreshold?: number, // New: similarityThreshold parameter
rerankSimilarityThreshold?: number, // New: rerankSimilarityThreshold parameter
enableQueryExpansion?: boolean, // New
enableHyDE?: boolean, // New
tenantId?: string, // New: tenant isolation
): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
this.logger.log('=== ChatService.streamChat ===');
this.logger.log('User ID:', userId);
this.logger.log('User language:', userLanguage);
this.logger.log('Selected embedding model ID:', selectedEmbeddingId);
this.logger.log('Selected groups:', selectedGroups);
this.logger.log('Selected files:', selectedFiles);
this.logger.log('History ID:', historyId);
this.logger.log('Temperature:', temperature);
this.logger.log('Max Tokens:', maxTokens);
this.logger.log('Top K:', topK);
this.logger.log('Similarity threshold:', similarityThreshold);
this.logger.log('Rerank threshold:', rerankSimilarityThreshold);
this.logger.log('Query expansion:', enableQueryExpansion);
this.logger.log('HyDE:', enableHyDE);
this.logger.log('Model configuration:', {
name: modelConfig.name,
modelId: modelConfig.modelId,
baseUrl: modelConfig.baseUrl,
});
this.logger.log(
'API Key prefix:',
modelConfig.apiKey?.substring(0, 10) + '...',
);
this.logger.log('API Key length:', modelConfig.apiKey?.length);
// Get current language setting (keeping LANGUAGE_CONFIG for backward compatibility, now uses i18n service)
// Use actual language based on user settings
const effectiveUserLanguage = userLanguage || DEFAULT_LANGUAGE;
let currentHistoryId = historyId;
let fullResponse = '';
try {
// Create new chat history if no historyId
if (!currentHistoryId) {
const searchHistory = await this.searchHistoryService.create(
userId,
tenantId || 'default', // New
message,
selectedGroups,
);
currentHistoryId = searchHistory.id;
this.logger.log(
this.i18nService.getMessage(
'creatingHistory',
effectiveUserLanguage,
) + currentHistoryId,
);
yield { type: 'historyId', data: currentHistoryId };
}
// Save user message
await this.searchHistoryService.addMessage(
currentHistoryId,
'user',
message,
);
// 1. Get user's embedding model settings
let embeddingModel: any;
if (selectedEmbeddingId) {
// Find specifically selected model
embeddingModel =
await this.modelConfigService.findOne(selectedEmbeddingId);
} else {
// Use organization's default from Index Chat Config (strict)
embeddingModel = await this.modelConfigService.findDefaultByType(
tenantId || 'default',
ModelType.EMBEDDING,
);
}
this.logger.log(
this.i18nService.getMessage(
'usingEmbeddingModel',
effectiveUserLanguage,
) +
embeddingModel.name +
' ' +
embeddingModel.modelId +
' ID:' +
embeddingModel.id,
);
// 2. Search using user's query directly
this.logger.log(
this.i18nService.getMessage('startingSearch', effectiveUserLanguage),
);
yield {
type: 'content',
data:
this.i18nService.getMessage('searching', effectiveUserLanguage) +
'\n',
};
let searchResults: any[] = [];
let context = '';
try {
// 3. If knowledge groups are selected, get file IDs from those groups first
let effectiveFileIds = selectedFiles; // Prioritize explicitly specified files
if (!effectiveFileIds && selectedGroups && selectedGroups.length > 0) {
// Get file IDs from knowledge groups
effectiveFileIds =
await this.knowledgeGroupService.getFileIdsByGroups(
selectedGroups,
userId,
tenantId as string,
);
}
// 3. Use RagService for search (supports hybrid search + Rerank)
const ragResults = await this.ragService.searchKnowledge(
message,
userId,
topK,
similarityThreshold,
embeddingModel.id,
true, // enableFullTextSearch (Chat defaults to hybrid)
enableRerank,
selectedRerankId,
undefined, // selectedGroups
effectiveFileIds,
rerankSimilarityThreshold,
tenantId,
enableQueryExpansion,
enableHyDE,
);
// Convert RagSearchResult to format needed by ChatService (any[])
// HybridSearch returns ES hit structure, but RagSearchResult is normalized
// BuildContext expects {fileName, content}. RagSearchResult has these
searchResults = ragResults;
this.logger.log(
this.i18nService.getMessage(
'searchResultsCount',
effectiveUserLanguage,
) + searchResults.length,
);
// 4. Build context
context = this.buildContext(searchResults, effectiveUserLanguage);
if (searchResults.length === 0) {
if (selectedGroups && selectedGroups.length > 0) {
// User selected knowledge groups but no matches found
const noMatchMsg = this.i18nService.getMessage(
'noMatchInKnowledgeGroup',
effectiveUserLanguage,
);
yield { type: 'content', data: `⚠️ ${noMatchMsg}\n\n` };
} else {
yield {
type: 'content',
data:
this.i18nService.getMessage(
'noResults',
effectiveUserLanguage,
) + '\n\n',
};
}
yield {
type: 'content',
data: `[Debug] ${this.i18nService.getMessage('searchScope', effectiveUserLanguage)}: ${selectedFiles ? selectedFiles.length + ' ' + this.i18nService.getMessage('files', effectiveUserLanguage) : selectedGroups ? selectedGroups.length + ' ' + this.i18nService.getMessage('notebooks', effectiveUserLanguage) : this.i18nService.getMessage('all', effectiveUserLanguage)}\n`,
};
yield {
type: 'content',
data: `[Debug] ${this.i18nService.getMessage('searchResults', effectiveUserLanguage)}: 0 ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n`,
};
} else {
yield {
type: 'content',
data: `${searchResults.length} ${this.i18nService.getMessage('relevantInfoFound', effectiveUserLanguage)}${this.i18nService.getMessage('generatingResponse', effectiveUserLanguage)}...\n\n`,
};
// Debug info
const scores = searchResults
.map((r) => {
if (
r.originalScore !== undefined &&
r.originalScore !== r.score
) {
return `${r.originalScore.toFixed(2)}${r.score.toFixed(2)}`;
}
return r.score.toFixed(2);
})
.join(', ');
const files = [...new Set(searchResults.map((r) => r.fileName))].join(
', ',
);
yield {
type: 'content',
data: `> [Debug] ${this.i18nService.getMessage('searchHits', effectiveUserLanguage)}: ${searchResults.length} ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n`,
};
yield {
type: 'content',
data: `> [Debug] ${this.i18nService.getMessage('relevance', effectiveUserLanguage)}: ${scores}\n`,
};
yield {
type: 'content',
data: `> [Debug] ${this.i18nService.getMessage('sourceFiles', effectiveUserLanguage)}: ${files}\n\n---\n\n`,
};
}
} catch (searchError) {
this.logger.error(
this.i18nService.getMessage(
'searchFailedLog',
effectiveUserLanguage,
) + ':',
searchError,
);
yield {
type: 'content',
data:
this.i18nService.getMessage('searchFailed', effectiveUserLanguage) +
'\n\n',
};
}
// 5. Stream response generation
this.logger.log(
this.i18nService.formatMessage(
'modelCall',
{
type: 'LLM',
model: `${modelConfig.name} (${modelConfig.modelId})`,
user: userId,
},
effectiveUserLanguage,
),
);
const llm = new ChatOpenAI({
apiKey: modelConfig.apiKey || 'ollama',
streaming: true,
temperature: temperature !== undefined ? temperature : 0.3,
maxTokens: maxTokens !== undefined ? maxTokens : undefined,
modelName: modelConfig.modelId,
configuration: {
baseURL: modelConfig.baseUrl || 'http://localhost:11434/v1',
},
});
const promptTemplate =
context.length > 0
? this.i18nService.getPrompt(
effectiveUserLanguage,
'withContext',
selectedGroups && selectedGroups.length > 0,
)
: this.i18nService.getPrompt(effectiveUserLanguage, 'withoutContext');
const prompt = PromptTemplate.fromTemplate(promptTemplate);
const chain = prompt.pipe(llm);
const stream = await chain.stream({
context,
history: this.formatHistory(history, userLanguage),
question: message,
});
for await (const chunk of stream) {
if (chunk.content) {
fullResponse += chunk.content;
yield { type: 'content', data: chunk.content };
}
}
// Save AI response
await this.searchHistoryService.addMessage(
currentHistoryId,
'assistant',
fullResponse,
searchResults.map((result) => ({
fileName: result.fileName,
title: result.metadata?.title || result.metadata?.originalName, // ES metadata contains these
content: String(result.content).substring(0, 200) + '...',
score: result.score,
chunkIndex: result.chunkIndex,
fileId: result.fileId,
})),
);
// 7. Auto-generate chat title (executed after first exchange)
const messagesInHistory = await this.searchHistoryService.findOne(
currentHistoryId,
userId,
tenantId,
);
if (messagesInHistory.messages.length === 2) {
this.generateChatTitle(currentHistoryId, userId, tenantId).catch(
(err) => {
this.logger.error(
`Failed to generate chat title for ${currentHistoryId}`,
err,
);
},
);
}
// 6. Return sources
yield {
type: 'sources',
data: searchResults.map((result) => ({
fileName: result.fileName,
content: String(result.content).substring(0, 200) + '...',
score: result.score,
chunkIndex: result.chunkIndex,
fileId: result.fileId,
})),
};
} catch (error) {
this.logger.error(
this.i18nService.getMessage('chatStreamError', effectiveUserLanguage),
error,
);
yield {
type: 'content',
data: `${this.i18nService.getMessage('error', effectiveUserLanguage)}: ${error.message}`,
};
}
}
async *streamAssist(
instruction: string,
context: string,
modelConfig: ModelConfig,
userLanguage: string = DEFAULT_LANGUAGE,
): AsyncGenerator<{ type: 'content'; data: any }> {
try {
this.logger.log(
this.i18nService.formatMessage(
'modelCall',
{
type: 'LLM (Assist)',
model: `${modelConfig.name} (${modelConfig.modelId})`,
user: 'N/A',
},
userLanguage,
),
);
const llm = new ChatOpenAI({
apiKey: modelConfig.apiKey || 'ollama',
streaming: true,
temperature: 0.7,
modelName: modelConfig.modelId,
configuration: {
baseURL: modelConfig.baseUrl || 'http://localhost:11434/v1',
},
});
const systemPrompt = `${this.i18nService.getMessage('intelligentAssistant', userLanguage)}
${this.i18nService.getMessage('assistSystemPrompt', userLanguage)}
${this.i18nService.getMessage('contextLabel', userLanguage)}:
${context}
${this.i18nService.getMessage('userInstructionLabel', userLanguage)}:
${instruction}`;
const stream = await llm.stream(systemPrompt);
for await (const chunk of stream) {
if (chunk.content) {
yield { type: 'content', data: chunk.content };
}
}
} catch (error) {
this.logger.error(
this.i18nService.getMessage('assistStreamError', userLanguage),
error,
);
yield {
type: 'content',
data: `${this.i18nService.getMessage('error', userLanguage)}: ${error.message}`,
};
}
}
private async hybridSearch(
keywords: string[],
userId: string,
embeddingModelId?: string,
selectedGroups?: string[], // New parameter
explicitFileIds?: string[], // New parameter
tenantId?: string, // Added
userLanguage: string = DEFAULT_LANGUAGE,
): Promise<any[]> {
try {
// Join keywords into search string
const combinedQuery = keywords.join(' ');
this.logger.log(
this.i18nService.getMessage('searchString', userLanguage) +
combinedQuery,
);
// Check if embedding model ID is provided
if (!embeddingModelId) {
this.logger.log(
this.i18nService.getMessage(
'embeddingModelIdNotProvided',
userLanguage,
),
);
return [];
}
// Use actual embedding vector
this.logger.log(
this.i18nService.getMessage('generatingEmbeddings', userLanguage),
);
const queryEmbedding = await this.embeddingService.getEmbeddings(
[combinedQuery],
embeddingModelId,
);
const queryVector = queryEmbedding[0];
this.logger.log(
this.i18nService.getMessage('embeddingsGenerated', userLanguage) +
this.i18nService.getMessage('dimensions', userLanguage) +
':',
queryVector.length,
);
// Hybrid search
this.logger.log(
this.i18nService.getMessage('performingHybridSearch', userLanguage),
);
const results = await this.elasticsearchService.hybridSearch(
queryVector,
combinedQuery,
userId,
10,
0.6,
selectedGroups, // Pass selected groups
explicitFileIds, // Pass explicit file IDs
tenantId, // Pass tenant ID
);
this.logger.log(
this.i18nService.getMessage('esSearchCompleted', userLanguage) +
this.i18nService.getMessage('resultsCount', userLanguage) +
':',
results.length,
);
return results.slice(0, 10);
} catch (error) {
this.logger.error(
this.i18nService.getMessage('hybridSearchFailed', userLanguage) + ':',
error,
);
return [];
}
}
private buildContext(
results: any[],
language: string = DEFAULT_LANGUAGE,
): string {
return results
.map(
(result, index) =>
`[${index + 1}] ${this.i18nService.getMessage('file', language)}${result.fileName}\n${this.i18nService.getMessage('content', language)}${result.content}\n`,
)
.join('\n');
}
private formatHistory(
history: ChatMessage[],
userLanguage: string = DEFAULT_LANGUAGE,
): string {
const userLabel = this.i18nService.getMessage('userLabel', userLanguage);
const assistantLabel = this.i18nService.getMessage(
'assistantLabel',
userLanguage,
);
return history
.slice(-6)
.map(
(msg) =>
`${msg.role === 'user' ? userLabel : assistantLabel}${msg.content}`,
)
.join('\n');
}
async getContextForTopic(
topic: string,
userId: string,
tenantId?: string,
groupId?: string,
fileIds?: string[],
userLanguage: string = DEFAULT_LANGUAGE,
): Promise<string> {
try {
// Use organization's default embedding from Index Chat Config (strict)
const embeddingModel = await this.modelConfigService.findDefaultByType(
tenantId || 'default',
ModelType.EMBEDDING,
);
const results = await this.hybridSearch(
[topic],
userId,
embeddingModel.id,
groupId ? [groupId] : undefined,
fileIds,
tenantId,
userLanguage,
);
return this.buildContext(results);
} catch (err) {
this.logger.error(
`${this.i18nService.getMessage('getContextForTopicFailed', userLanguage)}: ${err.message}`,
);
return '';
}
}
async generateSimpleChat(
messages: ChatMessage[],
userId: string,
tenantId?: string,
modelConfig?: ModelConfig, // Optional, looks up if not provided
userLanguage: string = DEFAULT_LANGUAGE,
): Promise<string> {
try {
let config = modelConfig;
if (!config) {
// Use organization's default LLM from Index Chat Config (strict)
const found = await this.modelConfigService.findDefaultByType(
tenantId || 'default',
ModelType.LLM,
);
config = found as unknown as ModelConfig;
}
this.logger.log(
this.i18nService.formatMessage(
'modelCall',
{
type: 'LLM (Simple)',
model: `${config.name} (${config.modelId})`,
user: userId,
},
DEFAULT_LANGUAGE,
),
);
const settings = await this.tenantService.getSettings(
tenantId || 'default',
);
const llm = new ChatOpenAI({
apiKey: config.apiKey || 'ollama',
temperature: settings?.temperature ?? 0.7,
modelName: config.modelId,
configuration: {
baseURL: config.baseUrl || 'http://localhost:11434/v1',
},
});
const response = await llm.invoke(
messages.map((m) => [m.role, m.content]),
);
return String(response.content);
} catch (error) {
this.logger.error(
this.i18nService.getMessage('simpleChatGenerationError', userLanguage),
error,
);
throw error;
}
}
/**
* Automatically generate chat title based on conversation content
*/
async generateChatTitle(
historyId: string,
userId: string,
tenantId?: string,
): Promise<string | null> {
this.logger.log(`Generating automatic title for chat session ${historyId}`);
try {
const history = await this.searchHistoryService.findOne(
historyId,
userId,
tenantId || 'default',
);
if (!history || history.messages.length < 2) {
return null;
}
const userMessage =
history.messages.find((m) => m.role === 'user')?.content || '';
const aiResponse =
history.messages.find((m) => m.role === 'assistant')?.content || '';
if (!userMessage || !aiResponse) {
return null;
}
// Get language from user settings
const userSettings = await this.userSettingService.getByUser(userId);
const language = userSettings?.language || DEFAULT_LANGUAGE;
// Build prompt
const prompt = this.i18nService.getChatTitlePrompt(
language,
userMessage,
aiResponse,
);
// Call LLM to generate title
const generatedTitle = await this.generateSimpleChat(
[{ role: 'user', content: prompt }],
userId,
tenantId || 'default',
);
if (generatedTitle && generatedTitle.trim().length > 0) {
// Remove extra quotes
const cleanedTitle = generatedTitle
.trim()
.replace(/^["']|["']$/g, '')
.substring(0, 50);
await this.searchHistoryService.updateTitle(historyId, cleanedTitle);
this.logger.log(
`Successfully generated title for chat ${historyId}: ${cleanedTitle}`,
);
return cleanedTitle;
}
} catch (error) {
this.logger.error(
`Failed to generate chat title for ${historyId}`,
error,
);
}
return null;
}
}