feat: implement QuestionBank CRUD with pagination and template query

- Add pagination support to findAll (page, limit query params)
- Add findByTemplateId method to service
- Add GET /by-template/:templateId endpoint to controller
- Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
+714
View File
@@ -0,0 +1,714 @@
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 }> {
console.log('=== ChatService.streamChat ===');
console.log('User ID:', userId);
console.log('User language:', userLanguage);
console.log('Selected embedding model ID:', selectedEmbeddingId);
console.log('Selected groups:', selectedGroups);
console.log('Selected files:', selectedFiles);
console.log('History ID:', historyId);
console.log('Temperature:', temperature);
console.log('Max Tokens:', maxTokens);
console.log('Top K:', topK);
console.log('Similarity threshold:', similarityThreshold);
console.log('Rerank threshold:', rerankSimilarityThreshold);
console.log('Query expansion:', enableQueryExpansion);
console.log('HyDE:', enableHyDE);
console.log('Model configuration:', {
name: modelConfig.name,
modelId: modelConfig.modelId,
baseUrl: modelConfig.baseUrl,
});
console.log(
'API Key prefix:',
modelConfig.apiKey?.substring(0, 10) + '...',
);
console.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;
console.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,
);
}
console.log(
this.i18nService.getMessage(
'usingEmbeddingModel',
effectiveUserLanguage,
) +
embeddingModel.name +
' ' +
embeddingModel.modelId +
' ID:' +
embeddingModel.id,
);
// 2. Search using user's query directly
console.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;
console.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) {
console.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(' ');
console.log(
this.i18nService.getMessage('searchString', userLanguage) +
combinedQuery,
);
// Check if embedding model ID is provided
if (!embeddingModelId) {
console.log(
this.i18nService.getMessage(
'embeddingModelIdNotProvided',
userLanguage,
),
);
return [];
}
// Use actual embedding vector
console.log(
this.i18nService.getMessage('generatingEmbeddings', userLanguage),
);
const queryEmbedding = await this.embeddingService.getEmbeddings(
[combinedQuery],
embeddingModelId,
);
const queryVector = queryEmbedding[0];
console.log(
this.i18nService.getMessage('embeddingsGenerated', userLanguage) +
this.i18nService.getMessage('dimensions', userLanguage) +
':',
queryVector.length,
);
// Hybrid search
console.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
);
console.log(
this.i18nService.getMessage('esSearchCompleted', userLanguage) +
this.i18nService.getMessage('resultsCount', userLanguage) +
':',
results.length,
);
return results.slice(0, 10);
} catch (error) {
console.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;
}
}