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 { UserSettingService } from '../user-setting/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 userSettingService: UserSettingService, ) { this.defaultDimensions = parseInt( this.configService.get('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[], // 新規:選択されたグループ selectedFiles?: string[], // 新規:選択されたファイル historyId?: string, // 新規:対話履歴ID enableRerank: boolean = false, selectedRerankId?: string, temperature?: number, // 新規: temperature パラメータ maxTokens?: number, // 新規: maxTokens パラメータ topK?: number, // 新規: topK パラメータ similarityThreshold?: number, // 新規: similarityThreshold パラメータ rerankSimilarityThreshold?: number, // 新規: rerankSimilarityThreshold パラメータ enableQueryExpansion?: boolean, // 新規 enableHyDE?: boolean, // 新規 tenantId?: string // 新規: tenant isolation ): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> { console.log('=== ChatService.streamChat ==='); console.log('ユーザーID:', userId); console.log('User language:', userLanguage); console.log('Selected embedding model ID:', selectedEmbeddingId); console.log('Selected group:', 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 プレフィックス:', modelConfig.apiKey?.substring(0, 10) + '...'); console.log('API Key length:', modelConfig.apiKey?.length); // 現在の言語設定を取得 (下位互換性のためにLANGUAGE_CONFIGを保持しますが、現在はi18nサービスを使>用) // ユーザー設定に基づいて実際の言語を使用 const effectiveUserLanguage = userLanguage || DEFAULT_LANGUAGE; let currentHistoryId = historyId; let fullResponse = ''; try { // historyId がない場合は、新しい対話履歴を作成 if (!currentHistoryId) { const searchHistory = await this.searchHistoryService.create( userId, tenantId || 'default', // 新規 message, selectedGroups, ); currentHistoryId = searchHistory.id; console.log(this.i18nService.getMessage('creatingHistory', effectiveUserLanguage) + currentHistoryId); yield { type: 'historyId', data: currentHistoryId }; } // ユーザーメッセージを保存 await this.searchHistoryService.addMessage(currentHistoryId, 'user', message); // 1. ユーザーの埋め込みモデル設定を取得 let embeddingModel: any; if (selectedEmbeddingId) { // Find specifically selected model embeddingModel = await this.modelConfigService.findOne(selectedEmbeddingId, userId, tenantId || 'default'); } 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. ユーザーのクエリを直接使用して検索 console.log(this.i18nService.getMessage('startingSearch', effectiveUserLanguage)); yield { type: 'content', data: this.i18nService.getMessage('searching', effectiveUserLanguage) + '\n' }; let searchResults: any[] = []; let context = ''; try { // 3. 選択された知識グループがある場合、まずそれらのグループ内のファイルIDを取得 let effectiveFileIds = selectedFiles; // 明示的に指定されたファイルを優先 if (!effectiveFileIds && selectedGroups && selectedGroups.length > 0) { // ナレッジグループからファイルIDを取得 effectiveFileIds = await this.knowledgeGroupService.getFileIdsByGroups(selectedGroups, userId, tenantId as string); } // 3. RagService を使用して検索 (混合検索 + 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 ); // RagSearchResult を ChatService が必要とする形式 (any[]) に変換 // HybridSearch は ES の hit 構造を返しますが、RagSearchResult は正規化されています。 // BuildContext は {fileName, content} を期待します。RagSearchResult はこれらを持っています。 searchResults = ragResults; console.log(this.i18nService.getMessage('searchResultsCount', effectiveUserLanguage) + searchResults.length); // 4. コンテキストの構築 context = this.buildContext(searchResults, effectiveUserLanguage); if (searchResults.length === 0) { if (selectedGroups && selectedGroups.length > 0) { // ユーザーがナレッジグループを選択したが、一致するものが見つからなかった場合 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`, }; // 一時的なデバッグ情報 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. ストリーム回答生成 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 }; } } // AI 回答を保存 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. 自動チャットタイトル生成 (最初のやり取りの後に実行) const messagesInHistory = await this.searchHistoryService.findOne(currentHistoryId, userId, tenantId); if (messagesInHistory.messages.length === 2) { this.generateChatTitle(currentHistoryId, userId, tenantId, effectiveUserLanguage).catch((err) => { this.logger.error(`Failed to generate chat title for ${currentHistoryId}`, err); }); } // 6. 引用元を返却 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, ): AsyncGenerator<{ type: 'content'; data: any }> { try { this.logger.log(this.i18nService.formatMessage('modelCall', { type: 'LLM (Assist)', model: `${modelConfig.name} (${modelConfig.modelId})`, user: 'N/A' }, 'ja')); 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', 'ja')} 提供されたテキスト内容を、ユーザーの指示に基づいて修正または改善してください。 挨拶や結びの言葉(「わかりました、こちらが...」など)は含めず、修正後の内容のみを直接出力してください。 コンテキスト(現在の内容): ${context} ユーザーの指示: ${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', 'ja'), error); yield { type: 'content', data: `${this.i18nService.getMessage('error', 'ja')}: ${error.message}` }; } } private async hybridSearch( keywords: string[], userId: string, embeddingModelId?: string, selectedGroups?: string[], // 新規パラメータ explicitFileIds?: string[], // 新規パラメータ tenantId?: string, // Added ): Promise { try { // キーワードを検索文字列に結合 const combinedQuery = keywords.join(' '); console.log(this.i18nService.getMessage('searchString', 'ja') + combinedQuery); // Embedding model IDが提供されているか確認 if (!embeddingModelId) { console.log(this.i18nService.getMessage('embeddingModelIdNotProvided', 'ja')); return []; } // 実際の埋め込みベクトルを使用 console.log(this.i18nService.getMessage('generatingEmbeddings', 'ja')); const queryEmbedding = await this.embeddingService.getEmbeddings( [combinedQuery], userId, embeddingModelId, ); const queryVector = queryEmbedding[0]; console.log(this.i18nService.getMessage('embeddingsGenerated', 'ja') + this.i18nService.getMessage('dimensions', 'ja') + ':', queryVector.length); // 混合検索 console.log(this.i18nService.getMessage('performingHybridSearch', 'ja')); const results = await this.elasticsearchService.hybridSearch( queryVector, combinedQuery, userId, 10, 0.6, selectedGroups, // 選択されたグループを渡す explicitFileIds, // 明示的なファイルIDを渡す tenantId, // Added: tenantId ); console.log(this.i18nService.getMessage('esSearchCompleted', 'ja') + this.i18nService.getMessage('resultsCount', 'ja') + ':', results.length); return results.slice(0, 10); } catch (error) { console.error(this.i18nService.getMessage('hybridSearchFailed', 'ja') + ':', error); return []; } } private buildContext(results: any[], language: string = 'ja'): 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 = 'ja', ): 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[]): Promise { 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 ); return this.buildContext(results); } catch (err) { this.logger.error(`${this.i18nService.getMessage('getContextForTopicFailed', 'ja')}: ${err.message}`); return ''; } } async generateSimpleChat( messages: ChatMessage[], userId: string, tenantId?: string, modelConfig?: ModelConfig, // Optional, looks up if not provided ): Promise { 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 }, 'ja')); const settings = await this.userSettingService.findOrCreate(userId); 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', 'ja'), error); throw error; } } /** * 対話内容に基づいてチャットのタイトルを自動生成する */ async generateChatTitle(historyId: string, userId: string, tenantId?: string, language?: string): Promise { this.logger.log(`Generating automatic title for chat session ${historyId} in language: ${language || 'default'}`); 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; } // 優先順位: 引数の言語 > ユーザー設定 > Japanese(ja) let targetLanguage = language; if (!targetLanguage) { const settings = await this.userSettingService.findOrCreate(userId); targetLanguage = settings.language || 'ja'; } // プロンプトを構築 const prompt = this.i18nService.getChatTitlePrompt(targetLanguage, userMessage, aiResponse); // LLMを呼び出してタイトルを生成 const generatedTitle = await this.generateSimpleChat( [{ role: 'user', content: prompt }], userId, tenantId || 'default' ); if (generatedTitle && generatedTitle.trim().length > 0) { // 余分な引用符を除去 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; } }