| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- 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<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[], // 新規:選択されたグループ
- 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<any[]> {
- 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<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
- );
- 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<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
- }, '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<string | null> {
- 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;
- }
- }
|