Jelajahi Sumber

自动生成标题功能

shang-chunyu 3 minggu lalu
induk
melakukan
e94f076017

+ 55 - 0
server/src/chat/chat.service.ts

@@ -260,6 +260,7 @@ export class ChatService {
         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,
@@ -267,6 +268,14 @@ export class ChatService {
         })),
       );
 
+      // 7. 自動チャットタイトル生成 (最初のやり取りの後に実行)
+      const messagesInHistory = await this.searchHistoryService.findOne(currentHistoryId, userId);
+      if (messagesInHistory.messages.length === 2) {
+        this.generateChatTitle(currentHistoryId, userId).catch((err) => {
+          this.logger.error(`Failed to generate chat title for ${currentHistoryId}`, err);
+        });
+      }
+
       // 6. 引用元を返却
       yield {
         type: 'sources',
@@ -462,4 +471,50 @@ ${instruction}`;
       throw error;
     }
   }
+
+  /**
+   * 対話内容に基づいてチャットのタイトルを自動生成する
+   */
+  async generateChatTitle(historyId: string, userId: string): Promise<string | null> {
+    this.logger.log(`Generating automatic title for chat session ${historyId}`);
+
+    try {
+      const history = await this.searchHistoryService.findOne(historyId, userId);
+      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;
+      }
+
+      // ユーザー設定から言語を取得
+      const settings = await this.userSettingService.findOrCreate(userId);
+      const language = settings.language || 'ja';
+
+      // プロンプトを構築
+      const prompt = this.i18nService.getChatTitlePrompt(language, userMessage, aiResponse);
+
+      // LLMを呼び出してタイトルを生成
+      const generatedTitle = await this.generateSimpleChat(
+        [{ role: 'user', content: prompt }],
+        userId
+      );
+
+      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;
+  }
 }

+ 21 - 0
server/src/elasticsearch/elasticsearch.service.ts

@@ -100,6 +100,7 @@ export class ElasticsearchService implements OnModuleInit {
       vector,
       fileId: metadata.fileId,
       fileName: metadata.originalName,
+      title: metadata.title || metadata.originalName,
       fileMimeType: metadata.mimetype,
       chunkIndex: metadata.chunkIndex,
       startPosition: metadata.startPosition,
@@ -129,6 +130,20 @@ export class ElasticsearchService implements OnModuleInit {
     });
   }
 
+  async updateTitleByFileId(fileId: string, title: string) {
+    await this.client.updateByQuery({
+      index: this.indexName,
+      query: {
+        term: { fileId },
+      },
+      script: {
+        source: 'ctx._source.title = params.title',
+        params: { title },
+      },
+      refresh: true, // 即座に検索に反映させる
+    });
+  }
+
   async deleteByUserId(userId: string) {
     // Note: This method should likely only be used by admin functionality
     // since it deletes all data for a user
@@ -171,6 +186,7 @@ export class ElasticsearchService implements OnModuleInit {
         content: hit._source?.content,
         fileId: hit._source?.fileId,
         fileName: hit._source?.fileName,
+        title: hit._source?.title,
         chunkIndex: hit._source?.chunkIndex,
         startPosition: hit._source?.startPosition,
         endPosition: hit._source?.endPosition,
@@ -219,6 +235,7 @@ export class ElasticsearchService implements OnModuleInit {
         content: hit._source?.content,
         fileId: hit._source?.fileId,
         fileName: hit._source?.fileName,
+        title: hit._source?.title,
         chunkIndex: hit._source?.chunkIndex,
         startPosition: hit._source?.startPosition,
         endPosition: hit._source?.endPosition,
@@ -327,6 +344,7 @@ export class ElasticsearchService implements OnModuleInit {
           content: result.content,
           fileId: result.fileId,
           fileName: result.fileName,
+          title: result.title,
           chunkIndex: result.chunkIndex,
           startPosition: result.startPosition,
           endPosition: result.endPosition,
@@ -352,6 +370,7 @@ export class ElasticsearchService implements OnModuleInit {
         // ファイル関連情報
         fileId: { type: 'keyword' },
         fileName: { type: 'keyword' },
+        title: { type: 'text' },
         fileMimeType: { type: 'keyword' },
 
         // チャンク情報
@@ -448,6 +467,7 @@ export class ElasticsearchService implements OnModuleInit {
         content: hit._source?.content,
         fileId: hit._source?.fileId,
         fileName: hit._source?.fileName,
+        title: hit._source?.title,
         chunkIndex: hit._source?.chunkIndex,
         startPosition: hit._source?.startPosition,
         endPosition: hit._source?.endPosition,
@@ -526,6 +546,7 @@ export class ElasticsearchService implements OnModuleInit {
         content: hit._source?.content,
         fileId: hit._source?.fileId,
         fileName: hit._source?.fileName,
+        title: hit._source?.title,
         chunkIndex: hit._source?.chunkIndex,
         startPosition: hit._source?.startPosition,
         endPosition: hit._source?.endPosition,

+ 51 - 2
server/src/i18n/i18n.service.ts

@@ -229,9 +229,58 @@ ${hasKnowledgeGroup ? `
 {history}
 
 ユーザーの質問:{question}
-
-日本語で回答してください。
+      日本語で回答してください。
 `;
     }
   }
+
+  // タイトル生成用のプロンプトを取得
+  getDocumentTitlePrompt(lang: string = this.defaultLanguage, contentSample: string): string {
+    const language = this.getLanguage(lang);
+    if (language === 'zh') {
+      return `你是一个文档分析师。请阅读以下文本(文档开头部分),并生成一个简炼、专业的标题(不超过50个字符)。
+只返回标题文本。不要包含任何解释性文字或前导词(如“标题是:”)。
+语言:中文
+文本内容:
+${contentSample}`;
+    } else if (language === 'en') {
+      return `You are a document analyzer. Read the following text (start of a document) and generate a concise, professional title (max 50 chars).
+Return ONLY the title text. No preamble like "The title is...".
+Language: English
+Text:
+${contentSample}`;
+    } else {
+      return `あなたはドキュメントアナライザーです。以下のテキスト(ドキュメントの冒頭部分)を読み、簡潔でプロフェッショナルなタイトル(最大50文字)を生成してください。
+タイトルテキストのみを返してください。説明文や前置き(例:「タイトルは:」)は含めないでください。
+言語:日本語
+テキスト:
+${contentSample}`;
+    }
+  }
+
+  getChatTitlePrompt(lang: string = this.defaultLanguage, userMessage: string, aiResponse: string): string {
+    const language = this.getLanguage(lang);
+    if (language === 'zh') {
+      return `根据以下对话片段,生成一个简短、描述性的标题(不超过50个字符),总结讨论的主题。
+只返回标题文本。不要包含任何前导词。
+语言:中文
+片段:
+用户: ${userMessage}
+助手: ${aiResponse}`;
+    } else if (language === 'en') {
+      return `Based on the following conversation snippet, generate a short, descriptive title (max 50 chars) that summarizes the topic.
+Return ONLY the title. No preamble.
+Language: English
+Snippet:
+User: ${userMessage}
+Assistant: ${aiResponse}`;
+    } else {
+      return `以下の会話スニペットに基づいて、トピックを要約する短く説明的なタイトル(最大50文字)を生成してください。
+タイトルのみを返してください。前置きは不要です。
+言語:日本語
+スニペット:
+ユーザー: ${userMessage}
+アシスタント: ${aiResponse}`;
+    }
+  }
 }

+ 3 - 0
server/src/knowledge-base/knowledge-base.entity.ts

@@ -29,6 +29,9 @@ export class KnowledgeBase {
   @Column({ name: 'original_name' })
   originalName: string;
 
+  @Column({ nullable: true })
+  title: string;
+
   @Column({ name: 'storage_path' })
   storagePath: string;
 

+ 2 - 0
server/src/knowledge-base/knowledge-base.module.ts

@@ -17,6 +17,7 @@ import { LibreOfficeModule } from '../libreoffice/libreoffice.module';
 import { Pdf2ImageModule } from '../pdf2image/pdf2image.module';
 import { VisionPipelineModule } from '../vision-pipeline/vision-pipeline.module';
 import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module';
+import { ChatModule } from '../chat/chat.module';
 
 @Module({
   imports: [
@@ -31,6 +32,7 @@ import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module'
     Pdf2ImageModule,
     VisionPipelineModule,
     forwardRef(() => KnowledgeGroupModule),
+    forwardRef(() => ChatModule),
   ],
   controllers: [KnowledgeBaseController],
   providers: [

+ 66 - 0
server/src/knowledge-base/knowledge-base.service.ts

@@ -20,6 +20,7 @@ import { VisionPipelineService } from '../vision-pipeline/vision-pipeline.servic
 import { LibreOfficeService } from '../libreoffice/libreoffice.service';
 import { Pdf2ImageService } from '../pdf2image/pdf2image.service';
 import { DOC_EXTENSIONS, IMAGE_EXTENSIONS } from '../common/file-support.constants';
+import { ChatService } from '../chat/chat.service';
 
 @Injectable()
 export class KnowledgeBaseService {
@@ -44,6 +45,8 @@ export class KnowledgeBaseService {
     private pdf2ImageService: Pdf2ImageService,
     private configService: ConfigService,
     private i18nService: I18nService,
+    @Inject(forwardRef(() => ChatService))
+    private chatService: ChatService,
   ) { }
 
   async createAndIndex(
@@ -356,6 +359,11 @@ export class KnowledgeBaseService {
       this.logger.error(`Error vectorizing file ${kb.id}`, err);
     });
 
+    // 自動タイトル生成 (非同期的に実行)
+    this.generateTitle(kb.id).catch((err) => {
+      this.logger.error(`Error generating title for file ${kb.id}`, err);
+    });
+
     // 非同期的に PDF 変換をトリガー(ドキュメントファイルの場合)
     this.ensurePDFExists(kb.id, userId).catch((err) => {
       this.logger.warn(this.i18nService.formatMessage('pdfConversionFailedDetail', { id: kb.id }), err);
@@ -451,6 +459,11 @@ export class KnowledgeBaseService {
         this.logger.warn(`Initial PDF conversion failed for ${kb.id}`, err);
       });
 
+      // 自動タイトル生成 (非同期的に実行)
+      this.generateTitle(kb.id).catch((err) => {
+        this.logger.error(`Error generating title for file ${kb.id}`, err);
+      });
+
     } catch (error) {
       this.logger.error(`Vision pipeline error: ${error.message}, falling back to fast mode`);
       return this.processFastMode(kb, userId, config);
@@ -1326,4 +1339,57 @@ export class KnowledgeBaseService {
 
     return defaultDimensions;
   }
+
+  /**
+   * AIを使用して文書のタイトルを自動生成する
+   */
+  async generateTitle(kbId: string): Promise<string | null> {
+    this.logger.log(`Generating automatic title for file ${kbId}`);
+
+    try {
+      const kb = await this.kbRepository.findOne({ where: { id: kbId } });
+      if (!kb || !kb.content || kb.content.trim().length === 0) {
+        return null;
+      }
+
+      // すでにタイトルがある場合はスキップ
+      if (kb.title) {
+        return kb.title;
+      }
+
+      // コンテンツの冒頭サンプルを取得(最大2500文字)
+      const contentSample = kb.content.substring(0, 2500);
+
+      // ユーザー設定から言語を取得、またはデフォルトを使用
+      const settings = await this.userSettingService.findOrCreate(kb.userId);
+      const language = settings.language || 'ja';
+
+      // プロンプトを構築
+      const prompt = this.i18nService.getDocumentTitlePrompt(language, contentSample);
+
+      // LLMを呼び出してタイトルを生成
+      const generatedTitle = await this.chatService.generateSimpleChat(
+        [{ role: 'user', content: prompt }],
+        kb.userId
+      );
+
+      if (generatedTitle && generatedTitle.trim().length > 0) {
+        // 余分な引用符や改行を除去
+        const cleanedTitle = generatedTitle.trim().replace(/^["']|["']$/g, '').substring(0, 100);
+        await this.kbRepository.update(kbId, { title: cleanedTitle });
+
+        // Elasticsearch のチャンクも更新
+        await this.elasticsearchService.updateTitleByFileId(kbId, cleanedTitle).catch((err) => {
+          this.logger.error(`Failed to update title in Elasticsearch for ${kbId}`, err);
+        });
+
+        this.logger.log(`Successfully generated title for ${kbId}: ${cleanedTitle}`);
+        return cleanedTitle;
+      }
+    } catch (error) {
+      this.logger.error(`Failed to generate title for ${kbId}`, error);
+    }
+
+    return null;
+  }
 }

+ 2 - 0
server/src/rag/rag.service.ts

@@ -16,6 +16,7 @@ export interface RagSearchResult {
   chunkIndex: number;
   fileId?: string;
   originalScore?: number; // Rerank前のスコア(デバッグ用)
+  metadata?: any;
 }
 
 
@@ -189,6 +190,7 @@ export class RagService {
         originalScore: result.originalScore !== undefined ? result.originalScore : result.score,
         chunkIndex: result.chunkIndex,
         fileId: result.fileId,
+        metadata: result.metadata,
       }));
 
       return ragResults;

+ 4 - 0
server/src/search-history/search-history.service.ts

@@ -159,4 +159,8 @@ export class SearchHistoryService {
 
     await this.searchHistoryRepository.remove(history);
   }
+
+  async updateTitle(id: string, title: string): Promise<void> {
+    await this.searchHistoryRepository.update(id, { title });
+  }
 }

+ 1 - 1
web/components/ChatInterface.tsx

@@ -349,7 +349,7 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
 
               {files.filter(f => selectedFiles?.includes(f.id)).map(file => (
                 <div key={file.id} className="flex items-center gap-1 bg-blue-100 text-blue-700 px-2 py-1 rounded-full text-xs font-medium border border-blue-200">
-                  <span className="truncate max-w-[150px]">{file.name}</span>
+                  <span className="truncate max-w-[150px]">{file.title || file.name}</span>
                   <button
                     onClick={onClearFileSelection}
                     className="hover:bg-blue-200 rounded-full p-0.5 transition-colors"

+ 1 - 1
web/components/views/NotebookDetailView.tsx

@@ -535,7 +535,7 @@ export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToke
                                     <FileIcon size={24} />
                                 </div>
                                 <div className="flex-1 min-w-0">
-                                    <h3 className="font-medium text-slate-800 truncate" title={file.name}>{file.name}</h3>
+                                    <h3 className="font-medium text-slate-800 truncate" title={file.title || file.name}>{file.title || file.name}</h3>
                                     <p className="text-xs text-slate-400 mt-1">{formatBytes(file.size)}</p>
                                 </div>
                                 <div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">

+ 1 - 0
web/services/chatService.ts

@@ -5,6 +5,7 @@ export interface ChatMessage {
 
 export interface ChatSource {
   fileName: string;
+  title?: string;
   content: string;
   score: number;
   chunkIndex: number;

+ 2 - 0
web/types.ts

@@ -101,6 +101,7 @@ export interface PDFStatus {
 export interface KnowledgeFile {
   id: string;
   name: string;
+  title?: string;
   type: string;
   size: number;
   content: string; // Base64 string
@@ -139,6 +140,7 @@ export interface Message {
 
 export interface ChatSource {
   fileName: string;
+  title?: string;
   content: string;
   score: number;
   chunkIndex: number;