chat.service.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
  2. import { ConfigService } from '@nestjs/config';
  3. import { ChatOpenAI } from '@langchain/openai';
  4. import { PromptTemplate } from '@langchain/core/prompts';
  5. import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
  6. import { EmbeddingService } from '../knowledge-base/embedding.service';
  7. import { ModelConfigService } from '../model-config/model-config.service';
  8. import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
  9. import { SearchHistoryService } from '../search-history/search-history.service';
  10. import { ModelConfig, ModelType } from '../types';
  11. import { RagService } from '../rag/rag.service';
  12. import { DEFAULT_VECTOR_DIMENSIONS, DEFAULT_LANGUAGE } from '../common/constants';
  13. import { I18nService } from '../i18n/i18n.service';
  14. import { UserSettingService } from '../user-setting/user-setting.service';
  15. export interface ChatMessage {
  16. role: 'user' | 'assistant';
  17. content: string;
  18. }
  19. @Injectable()
  20. export class ChatService {
  21. private readonly logger = new Logger(ChatService.name);
  22. private readonly defaultDimensions: number;
  23. constructor(
  24. @Inject(forwardRef(() => ElasticsearchService))
  25. private elasticsearchService: ElasticsearchService,
  26. private embeddingService: EmbeddingService,
  27. private modelConfigService: ModelConfigService,
  28. @Inject(forwardRef(() => KnowledgeGroupService))
  29. private knowledgeGroupService: KnowledgeGroupService,
  30. private searchHistoryService: SearchHistoryService,
  31. private configService: ConfigService,
  32. private ragService: RagService,
  33. private i18nService: I18nService,
  34. private userSettingService: UserSettingService,
  35. ) {
  36. this.defaultDimensions = parseInt(
  37. this.configService.get<string>('DEFAULT_VECTOR_DIMENSIONS', String(DEFAULT_VECTOR_DIMENSIONS)),
  38. );
  39. }
  40. async *streamChat(
  41. message: string,
  42. history: ChatMessage[],
  43. userId: string,
  44. modelConfig: ModelConfig,
  45. userLanguage: string = DEFAULT_LANGUAGE,
  46. selectedEmbeddingId?: string,
  47. selectedGroups?: string[], // 新規:選択されたグループ
  48. selectedFiles?: string[], // 新規:選択されたファイル
  49. historyId?: string, // 新規:対話履歴ID
  50. enableRerank: boolean = false,
  51. selectedRerankId?: string,
  52. temperature?: number, // 新規: temperature パラメータ
  53. maxTokens?: number, // 新規: maxTokens パラメータ
  54. topK?: number, // 新規: topK パラメータ
  55. similarityThreshold?: number, // 新規: similarityThreshold パラメータ
  56. rerankSimilarityThreshold?: number, // 新規: rerankSimilarityThreshold パラメータ
  57. enableQueryExpansion?: boolean, // 新規
  58. enableHyDE?: boolean, // 新規
  59. tenantId?: string // 新規: tenant isolation
  60. ): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
  61. console.log('=== ChatService.streamChat ===');
  62. console.log('ユーザーID:', userId);
  63. console.log('User language:', userLanguage);
  64. console.log('Selected embedding model ID:', selectedEmbeddingId);
  65. console.log('Selected group:', selectedGroups);
  66. console.log('Selected files:', selectedFiles);
  67. console.log('History ID:', historyId);
  68. console.log('Temperature:', temperature);
  69. console.log('Max Tokens:', maxTokens);
  70. console.log('Top K:', topK);
  71. console.log('Similarity threshold:', similarityThreshold);
  72. console.log('Rerank threshold:', rerankSimilarityThreshold);
  73. console.log('Query expansion:', enableQueryExpansion);
  74. console.log('HyDE:', enableHyDE);
  75. console.log('Model configuration:', {
  76. name: modelConfig.name,
  77. modelId: modelConfig.modelId,
  78. baseUrl: modelConfig.baseUrl,
  79. });
  80. console.log('API Key プレフィックス:', modelConfig.apiKey?.substring(0, 10) + '...');
  81. console.log('API Key length:', modelConfig.apiKey?.length);
  82. // 現在の言語設定を取得 (下位互換性のためにLANGUAGE_CONFIGを保持しますが、現在はi18nサービスを使>用)
  83. // ユーザー設定に基づいて実際の言語を使用
  84. const effectiveUserLanguage = userLanguage || DEFAULT_LANGUAGE;
  85. let currentHistoryId = historyId;
  86. let fullResponse = '';
  87. try {
  88. // historyId がない場合は、新しい対話履歴を作成
  89. if (!currentHistoryId) {
  90. const searchHistory = await this.searchHistoryService.create(
  91. userId,
  92. tenantId || 'default', // 新規
  93. message,
  94. selectedGroups,
  95. );
  96. currentHistoryId = searchHistory.id;
  97. console.log(this.i18nService.getMessage('creatingHistory', effectiveUserLanguage) + currentHistoryId);
  98. yield { type: 'historyId', data: currentHistoryId };
  99. }
  100. // ユーザーメッセージを保存
  101. await this.searchHistoryService.addMessage(currentHistoryId, 'user', message);
  102. // 1. ユーザーの埋め込みモデル設定を取得
  103. let embeddingModel: any;
  104. if (selectedEmbeddingId) {
  105. // Find specifically selected model
  106. embeddingModel = await this.modelConfigService.findOne(selectedEmbeddingId, userId, tenantId || 'default');
  107. } else {
  108. // Use organization's default from Index Chat Config (strict)
  109. embeddingModel = await this.modelConfigService.findDefaultByType(tenantId || 'default', ModelType.EMBEDDING);
  110. }
  111. console.log(this.i18nService.getMessage('usingEmbeddingModel', effectiveUserLanguage) + embeddingModel.name + ' ' + embeddingModel.modelId + ' ID:' + embeddingModel.id);
  112. // 2. ユーザーのクエリを直接使用して検索
  113. console.log(this.i18nService.getMessage('startingSearch', effectiveUserLanguage));
  114. yield { type: 'content', data: this.i18nService.getMessage('searching', effectiveUserLanguage) + '\n' };
  115. let searchResults: any[] = [];
  116. let context = '';
  117. try {
  118. // 3. 選択された知識グループがある場合、まずそれらのグループ内のファイルIDを取得
  119. let effectiveFileIds = selectedFiles; // 明示的に指定されたファイルを優先
  120. if (!effectiveFileIds && selectedGroups && selectedGroups.length > 0) {
  121. // ナレッジグループからファイルIDを取得
  122. effectiveFileIds = await this.knowledgeGroupService.getFileIdsByGroups(selectedGroups, userId, tenantId as string);
  123. }
  124. // 3. RagService を使用して検索 (混合検索 + Rerank をサポート)
  125. const ragResults = await this.ragService.searchKnowledge(
  126. message,
  127. userId,
  128. topK,
  129. similarityThreshold,
  130. embeddingModel.id,
  131. true, // enableFullTextSearch (Chat defaults to hybrid)
  132. enableRerank,
  133. selectedRerankId,
  134. undefined, // selectedGroups
  135. effectiveFileIds,
  136. rerankSimilarityThreshold,
  137. tenantId,
  138. enableQueryExpansion,
  139. enableHyDE
  140. );
  141. // RagSearchResult を ChatService が必要とする形式 (any[]) に変換
  142. // HybridSearch は ES の hit 構造を返しますが、RagSearchResult は正規化されています。
  143. // BuildContext は {fileName, content} を期待します。RagSearchResult はこれらを持っています。
  144. searchResults = ragResults;
  145. console.log(this.i18nService.getMessage('searchResultsCount', effectiveUserLanguage) + searchResults.length);
  146. // 4. コンテキストの構築
  147. context = this.buildContext(searchResults, effectiveUserLanguage);
  148. if (searchResults.length === 0) {
  149. if (selectedGroups && selectedGroups.length > 0) {
  150. // ユーザーがナレッジグループを選択したが、一致するものが見つからなかった場合
  151. const noMatchMsg = this.i18nService.getMessage('noMatchInKnowledgeGroup', effectiveUserLanguage);
  152. yield { type: 'content', data: `⚠️ ${noMatchMsg}\n\n` };
  153. } else {
  154. yield { type: 'content', data: this.i18nService.getMessage('noResults', effectiveUserLanguage) + '\n\n' };
  155. }
  156. 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` };
  157. yield { type: 'content', data: `[Debug] ${this.i18nService.getMessage('searchResults', effectiveUserLanguage)}: 0 ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n` };
  158. } else {
  159. yield {
  160. type: 'content',
  161. data: `${searchResults.length} ${this.i18nService.getMessage('relevantInfoFound', effectiveUserLanguage)}。${this.i18nService.getMessage('generatingResponse', effectiveUserLanguage)}...\n\n`,
  162. };
  163. // 一時的なデバッグ情報
  164. const scores = searchResults.map(r => {
  165. if (r.originalScore !== undefined && r.originalScore !== r.score) {
  166. return `${r.originalScore.toFixed(2)} → ${r.score.toFixed(2)}`;
  167. }
  168. return r.score.toFixed(2);
  169. }).join(', ');
  170. const files = [...new Set(searchResults.map(r => r.fileName))].join(', ');
  171. yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('searchHits', effectiveUserLanguage)}: ${searchResults.length} ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n` };
  172. yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('relevance', effectiveUserLanguage)}: ${scores}\n` };
  173. yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('sourceFiles', effectiveUserLanguage)}: ${files}\n\n---\n\n` };
  174. }
  175. } catch (searchError) {
  176. console.error(this.i18nService.getMessage('searchFailedLog', effectiveUserLanguage) + ':', searchError);
  177. yield { type: 'content', data: this.i18nService.getMessage('searchFailed', effectiveUserLanguage) + '\n\n' };
  178. }
  179. // 5. ストリーム回答生成
  180. this.logger.log(this.i18nService.formatMessage('modelCall', {
  181. type: 'LLM',
  182. model: `${modelConfig.name} (${modelConfig.modelId})`,
  183. user: userId
  184. }, effectiveUserLanguage));
  185. const llm = new ChatOpenAI({
  186. apiKey: modelConfig.apiKey || 'ollama',
  187. streaming: true,
  188. temperature: temperature !== undefined ? temperature : 0.3,
  189. maxTokens: maxTokens !== undefined ? maxTokens : undefined,
  190. modelName: modelConfig.modelId,
  191. configuration: {
  192. baseURL: modelConfig.baseUrl || 'http://localhost:11434/v1',
  193. },
  194. });
  195. const promptTemplate =
  196. context.length > 0
  197. ? this.i18nService.getPrompt(
  198. effectiveUserLanguage,
  199. 'withContext',
  200. selectedGroups && selectedGroups.length > 0
  201. )
  202. : this.i18nService.getPrompt(effectiveUserLanguage, 'withoutContext');
  203. const prompt = PromptTemplate.fromTemplate(promptTemplate);
  204. const chain = prompt.pipe(llm);
  205. const stream = await chain.stream({
  206. context,
  207. history: this.formatHistory(history, userLanguage),
  208. question: message,
  209. });
  210. for await (const chunk of stream) {
  211. if (chunk.content) {
  212. fullResponse += chunk.content;
  213. yield { type: 'content', data: chunk.content };
  214. }
  215. }
  216. // AI 回答を保存
  217. await this.searchHistoryService.addMessage(
  218. currentHistoryId,
  219. 'assistant',
  220. fullResponse,
  221. searchResults.map((result) => ({
  222. fileName: result.fileName,
  223. title: result.metadata?.title || result.metadata?.originalName, // ES metadata contains these
  224. content: String(result.content).substring(0, 200) + '...',
  225. score: result.score,
  226. chunkIndex: result.chunkIndex,
  227. fileId: result.fileId,
  228. })),
  229. );
  230. // 7. 自動チャットタイトル生成 (最初のやり取りの後に実行)
  231. const messagesInHistory = await this.searchHistoryService.findOne(currentHistoryId, userId, tenantId);
  232. if (messagesInHistory.messages.length === 2) {
  233. this.generateChatTitle(currentHistoryId, userId, tenantId, effectiveUserLanguage).catch((err) => {
  234. this.logger.error(`Failed to generate chat title for ${currentHistoryId}`, err);
  235. });
  236. }
  237. // 6. 引用元を返却
  238. yield {
  239. type: 'sources',
  240. data: searchResults.map((result) => ({
  241. fileName: result.fileName,
  242. content: String(result.content).substring(0, 200) + '...',
  243. score: result.score,
  244. chunkIndex: result.chunkIndex,
  245. fileId: result.fileId,
  246. })),
  247. };
  248. } catch (error) {
  249. this.logger.error(this.i18nService.getMessage('chatStreamError', effectiveUserLanguage), error);
  250. yield { type: 'content', data: `${this.i18nService.getMessage('error', effectiveUserLanguage)}: ${error.message}` };
  251. }
  252. }
  253. async *streamAssist(
  254. instruction: string,
  255. context: string,
  256. modelConfig: ModelConfig,
  257. ): AsyncGenerator<{ type: 'content'; data: any }> {
  258. try {
  259. this.logger.log(this.i18nService.formatMessage('modelCall', {
  260. type: 'LLM (Assist)',
  261. model: `${modelConfig.name} (${modelConfig.modelId})`,
  262. user: 'N/A'
  263. }, 'ja'));
  264. const llm = new ChatOpenAI({
  265. apiKey: modelConfig.apiKey || 'ollama',
  266. streaming: true,
  267. temperature: 0.7,
  268. modelName: modelConfig.modelId,
  269. configuration: {
  270. baseURL: modelConfig.baseUrl || 'http://localhost:11434/v1',
  271. },
  272. });
  273. const systemPrompt = `${this.i18nService.getMessage('intelligentAssistant', 'ja')}
  274. 提供されたテキスト内容を、ユーザーの指示に基づいて修正または改善してください。
  275. 挨拶や結びの言葉(「わかりました、こちらが...」など)は含めず、修正後の内容のみを直接出力してください。
  276. コンテキスト(現在の内容):
  277. ${context}
  278. ユーザーの指示:
  279. ${instruction}`;
  280. const stream = await llm.stream(systemPrompt);
  281. for await (const chunk of stream) {
  282. if (chunk.content) {
  283. yield { type: 'content', data: chunk.content };
  284. }
  285. }
  286. } catch (error) {
  287. this.logger.error(this.i18nService.getMessage('assistStreamError', 'ja'), error);
  288. yield { type: 'content', data: `${this.i18nService.getMessage('error', 'ja')}: ${error.message}` };
  289. }
  290. }
  291. private async hybridSearch(
  292. keywords: string[],
  293. userId: string,
  294. embeddingModelId?: string,
  295. selectedGroups?: string[], // 新規パラメータ
  296. explicitFileIds?: string[], // 新規パラメータ
  297. tenantId?: string, // Added
  298. ): Promise<any[]> {
  299. try {
  300. // キーワードを検索文字列に結合
  301. const combinedQuery = keywords.join(' ');
  302. console.log(this.i18nService.getMessage('searchString', 'ja') + combinedQuery);
  303. // Embedding model IDが提供されているか確認
  304. if (!embeddingModelId) {
  305. console.log(this.i18nService.getMessage('embeddingModelIdNotProvided', 'ja'));
  306. return [];
  307. }
  308. // 実際の埋め込みベクトルを使用
  309. console.log(this.i18nService.getMessage('generatingEmbeddings', 'ja'));
  310. const queryEmbedding = await this.embeddingService.getEmbeddings(
  311. [combinedQuery],
  312. userId,
  313. embeddingModelId,
  314. );
  315. const queryVector = queryEmbedding[0];
  316. console.log(this.i18nService.getMessage('embeddingsGenerated', 'ja') + this.i18nService.getMessage('dimensions', 'ja') + ':', queryVector.length);
  317. // 混合検索
  318. console.log(this.i18nService.getMessage('performingHybridSearch', 'ja'));
  319. const results = await this.elasticsearchService.hybridSearch(
  320. queryVector,
  321. combinedQuery,
  322. userId,
  323. 10,
  324. 0.6,
  325. selectedGroups, // 選択されたグループを渡す
  326. explicitFileIds, // 明示的なファイルIDを渡す
  327. tenantId, // Added: tenantId
  328. );
  329. console.log(this.i18nService.getMessage('esSearchCompleted', 'ja') + this.i18nService.getMessage('resultsCount', 'ja') + ':', results.length);
  330. return results.slice(0, 10);
  331. } catch (error) {
  332. console.error(this.i18nService.getMessage('hybridSearchFailed', 'ja') + ':', error);
  333. return [];
  334. }
  335. }
  336. private buildContext(results: any[], language: string = 'ja'): string {
  337. return results
  338. .map(
  339. (result, index) =>
  340. `[${index + 1}] ${this.i18nService.getMessage('file', language)}:${result.fileName}\n${this.i18nService.getMessage('content', language)}:${result.content}\n`,
  341. )
  342. .join('\n');
  343. }
  344. private formatHistory(
  345. history: ChatMessage[],
  346. userLanguage: string = 'ja',
  347. ): string {
  348. const userLabel = this.i18nService.getMessage('userLabel', userLanguage);
  349. const assistantLabel = this.i18nService.getMessage('assistantLabel', userLanguage);
  350. return history
  351. .slice(-6)
  352. .map(
  353. (msg) =>
  354. `${msg.role === 'user' ? userLabel : assistantLabel}:${msg.content}`,
  355. )
  356. .join('\n');
  357. }
  358. async getContextForTopic(topic: string, userId: string, tenantId?: string, groupId?: string, fileIds?: string[]): Promise<string> {
  359. try {
  360. // Use organization's default embedding from Index Chat Config (strict)
  361. const embeddingModel = await this.modelConfigService.findDefaultByType(tenantId || 'default', ModelType.EMBEDDING);
  362. const results = await this.hybridSearch(
  363. [topic],
  364. userId,
  365. embeddingModel.id,
  366. groupId ? [groupId] : undefined,
  367. fileIds,
  368. tenantId
  369. );
  370. return this.buildContext(results);
  371. } catch (err) {
  372. this.logger.error(`${this.i18nService.getMessage('getContextForTopicFailed', 'ja')}: ${err.message}`);
  373. return '';
  374. }
  375. }
  376. async generateSimpleChat(
  377. messages: ChatMessage[],
  378. userId: string,
  379. tenantId?: string,
  380. modelConfig?: ModelConfig, // Optional, looks up if not provided
  381. ): Promise<string> {
  382. try {
  383. let config = modelConfig;
  384. if (!config) {
  385. // Use organization's default LLM from Index Chat Config (strict)
  386. const found = await this.modelConfigService.findDefaultByType(tenantId || 'default', ModelType.LLM);
  387. config = found as unknown as ModelConfig;
  388. }
  389. this.logger.log(this.i18nService.formatMessage('modelCall', {
  390. type: 'LLM (Simple)',
  391. model: `${config.name} (${config.modelId})`,
  392. user: userId
  393. }, 'ja'));
  394. const settings = await this.userSettingService.findOrCreate(userId);
  395. const llm = new ChatOpenAI({
  396. apiKey: config.apiKey || 'ollama',
  397. temperature: settings.temperature ?? 0.7, // ユーザー設定またはデフォルトを使用
  398. modelName: config.modelId,
  399. configuration: {
  400. baseURL: config.baseUrl || 'http://localhost:11434/v1',
  401. },
  402. });
  403. const response = await llm.invoke(
  404. messages.map(m => [m.role, m.content])
  405. );
  406. return String(response.content);
  407. } catch (error) {
  408. this.logger.error(this.i18nService.getMessage('simpleChatGenerationError', 'ja'), error);
  409. throw error;
  410. }
  411. }
  412. /**
  413. * 対話内容に基づいてチャットのタイトルを自動生成する
  414. */
  415. async generateChatTitle(historyId: string, userId: string, tenantId?: string, language?: string): Promise<string | null> {
  416. this.logger.log(`Generating automatic title for chat session ${historyId} in language: ${language || 'default'}`);
  417. try {
  418. const history = await this.searchHistoryService.findOne(historyId, userId, tenantId || 'default');
  419. if (!history || history.messages.length < 2) {
  420. return null;
  421. }
  422. const userMessage = history.messages.find(m => m.role === 'user')?.content || '';
  423. const aiResponse = history.messages.find(m => m.role === 'assistant')?.content || '';
  424. if (!userMessage || !aiResponse) {
  425. return null;
  426. }
  427. // 優先順位: 引数の言語 > ユーザー設定 > Japanese(ja)
  428. let targetLanguage = language;
  429. if (!targetLanguage) {
  430. const settings = await this.userSettingService.findOrCreate(userId);
  431. targetLanguage = settings.language || 'ja';
  432. }
  433. // プロンプトを構築
  434. const prompt = this.i18nService.getChatTitlePrompt(targetLanguage, userMessage, aiResponse);
  435. // LLMを呼び出してタイトルを生成
  436. const generatedTitle = await this.generateSimpleChat(
  437. [{ role: 'user', content: prompt }],
  438. userId,
  439. tenantId || 'default'
  440. );
  441. if (generatedTitle && generatedTitle.trim().length > 0) {
  442. // 余分な引用符を除去
  443. const cleanedTitle = generatedTitle.trim().replace(/^["']|["']$/g, '').substring(0, 50);
  444. await this.searchHistoryService.updateTitle(historyId, cleanedTitle);
  445. this.logger.log(`Successfully generated title for chat ${historyId}: ${cleanedTitle}`);
  446. return cleanedTitle;
  447. }
  448. } catch (error) {
  449. this.logger.error(`Failed to generate chat title for ${historyId}`, error);
  450. }
  451. return null;
  452. }
  453. }