浏览代码

国际化

anhuiqiang 1 周之前
父节点
当前提交
e56f000092
共有 5 个文件被更改,包括 193 次插入401 次删除
  1. 90 88
      server/src/i18n/i18n.service.ts
  2. 97 307
      server/src/rag/rag.service.ts
  3. 1 1
      web/contexts/LanguageContext.tsx
  4. 2 2
      web/services/apiClient.ts
  5. 3 3
      web/services/chatService.ts

+ 90 - 88
server/src/i18n/i18n.service.ts

@@ -7,30 +7,38 @@ import { DEFAULT_LANGUAGE } from '../common/constants'; // 使用常量定义的
 export class I18nService {
   private readonly defaultLanguage = DEFAULT_LANGUAGE; // 使用常量定义的默认语言
 
-  private getLanguage(lang?: string): string {
-    if (lang) return lang;
-    const store = i18nStore.getStore();
-    return store?.language || this.defaultLanguage;
+  public normalizeLanguage(lang?: string): string {
+    let language = lang;
+    if (!language) {
+      const store = i18nStore.getStore();
+      language = store?.language;
+    }
+
+    if (!language) return this.defaultLanguage;
+
+    // Normalize language codes (e.g., zh-CN -> zh, en-US -> en)
+    const normalized = language.split('-')[0].toLowerCase();
+    return normalized;
   }
 
   getErrorMessage(key: string, language?: string): string {
-    const lang = this.getLanguage(language);
+    const lang = this.normalizeLanguage(language);
     return errorMessages[lang]?.[key] || errorMessages[this.defaultLanguage][key] || key;
   }
 
   getLogMessage(key: string, language?: string): string {
-    const lang = this.getLanguage(language);
+    const lang = this.normalizeLanguage(language);
     return logMessages[lang]?.[key] || logMessages[this.defaultLanguage][key] || key;
   }
 
   getStatusMessage(key: string, language?: string): string {
-    const lang = this.getLanguage(language);
+    const lang = this.normalizeLanguage(language);
     return statusMessages[lang]?.[key] || statusMessages[this.defaultLanguage][key] || key;
   }
 
   // 汎用メッセージ取得メソッド、順次検索
   getMessage(key: string, language?: string): string {
-    const lang = this.getLanguage(language);
+    const lang = this.normalizeLanguage(language);
     // ステータスメッセージ、エラーメッセージ、ログメッセージの順に検索
     return statusMessages[lang]?.[key] ||
       statusMessages[this.defaultLanguage][key] ||
@@ -62,7 +70,7 @@ export class I18nService {
 
   // システムプロンプトを取得
   getPrompt(lang: string = this.defaultLanguage, type: 'withContext' | 'withoutContext' = 'withContext', hasKnowledgeGroup: boolean = false): string {
-    const language = this.getLanguage(lang);
+    const language = this.normalizeLanguage(lang);
     const noMatchMsg = statusMessages[language]?.noMatchInKnowledgeGroup || statusMessages[this.defaultLanguage].noMatchInKnowledgeGroup;
 
     if (language === 'zh') {
@@ -121,7 +129,64 @@ ${hasKnowledgeGroup ? `
 
 请用Chinese回答。
 `;
-    } else if (language === 'en') {
+    } else if (language === 'ja') {
+      return type === 'withContext' ? `
+以下のナレッジベースの内容に基づいて、ユーザーの質問に答えてください。
+${hasKnowledgeGroup ? `
+**重要**: ユーザーが特定のナレッジグループを選択しました。以下のナレッジベースの内容に厳密に基づいて回答してください。関連情報がナレッジベースに見つからない場合は、回答を提供する前に、ユーザーに明示的に「${noMatchMsg}」と伝えてください。
+` : ''}
+ナレッジベースの内容:
+{context}
+
+会話履歴:
+{history}
+
+ユーザーの質問:{question}
+
+日本語で回答し、以下のMarkdown形式のガイドラインに厳密に従ってください。
+
+1. **段落と構造**:
+   - 明確な段落区切りを使用し、要点の間に空行を入れます
+   - 見出し(## または ###)を使用して長い回答を整理します
+
+2. **テキスト形式**:
+   - **太字**を使用して重要な概念やキーワードを強調します
+   - リスト(- または 1.)を使用して複数のポイントを整理します
+   - \`コード\`を使用して技術用語、コマンド、ファイル名をマークします
+
+3. **コード表示**:
+   - 言語指定のあるコードブロックを使用します:
+     \`\`\`python
+     def example():
+         return "示例"
+     \`\`\`
+   - サポートされている言語:python, javascript, typescript, java, bash, sqlなど
+
+4. **図とチャート**:
+   - フローチャート、シーケンス図などにMermaid構文を使用します:
+     \`\`\`mermaid
+     graph LR
+         A[開始] --> B[処理]
+         B --> C[終了]
+     \`\`\`
+   - 使用例:プロセスフロー、アーキテクチャ図、状態遷移図、シーケンス図
+
+5. **その他の要件**:
+   - 回答は簡潔かつ明確にします
+   - マルチステップ プロセスには番号付きリストを使用します
+   - 比較情報には表を使用します(該当する場合)
+` : `
+インテリジェントなアシスタントとして、ユーザーの質問に答えてください。
+
+会話履歴:
+{history}
+
+ユーザーの質問:{question}
+
+日本語で回答してください。
+`;
+    } else {
+      // Fallback to English for any other language
       return type === 'withContext' ? `
 Answer the user's question based on the following knowledge base content.
 ${hasKnowledgeGroup ? `
@@ -177,86 +242,23 @@ User question: {question}
 
 Please answer in English.
 `;
-    } else {
-      // Fallback to English for unsupported languages
-      return type === 'withContext'
-        ? `
-  Answer the user's question based on the following knowledge base content.
-  ${
-    hasKnowledgeGroup
-      ? `
-  **IMPORTANT**: The user has selected a specific knowledge group. Please answer strictly based on the knowledge base content below. If the relevant information is not found in the knowledge base, explicitly tell the user: "${noMatchMsg}", before providing an answer.
-  `
-      : ''
-  }
-  Knowledge Base CONTENT:
-  {context}
-
-  Conversation history:
-  {history}
-
-  User question: {question}
-
-  Please answer in English and strictly follow these Markdown formatting guidelines:
-
-  1. **Paragraphs & Structure**:
-     - Use clear paragraph breaks with blank lines between key points
-     - Use headings (## or ###) to organize longer answers
-
-  2. **Text Formatting**:
-     - Use **bold** to emphasize important concepts and keywords
-     - Use lists (- or 1.) to organize multiple points
-     - Use \`code\` to mark technical terms, commands, file names
-
-  3. **Code Display**:
-     - Use code blocks with language specification:
-       \`\`\`python
-       def example():
-           return "example"
-       \`\`\`
-     - Supported languages: python, javascript, typescript, java, bash, sql, etc.
-
-  4. **Diagrams & Charts**:
-     - Use Mermaid syntax for flowcharts, sequence diagrams, etc.:
-       \`\`\`mermaid
-       graph LR
-           A[Start] --> B[Process]
-           B --> C[End]
-       \`\`\`
-     - Use cases: process flows, architecture diagrams, state diagrams, sequence diagrams
-
-  5. **Other Requirements**:
-     - Keep answers concise and clear
-     - Use numbered lists for multi-step processes
-     - Use tables for comparison information (if applicable)
-  `
-        : `
-  As an intelligent assistant, please answer the user's question.
-
-  Conversation history:
-  {history}
-
-  User question: {question}
-
-  Please answer in English.
-  `;
     }
   }
 
   // タイトル生成用のプロンプトを取得
   getDocumentTitlePrompt(lang: string = this.defaultLanguage, contentSample: string): string {
-    const language = this.getLanguage(lang);
+    const language = this.normalizeLanguage(lang);
     if (language === 'zh') {
-      return `你是一个文档分析师。请阅读以下文本(文档开Header分),并生成一个简炼、专业的标题(不超过50个字符)。
+      return `你是一个文档分析师。请阅读以下文本(文档开头部分),并生成一个简炼、专业的标题(不超过50个字符)。
 只返回标题文本。不要包含任何解释性文字或前导词(如“标题是:”)。
 语言:Chinese
 文本内容:
 ${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:
+    } else if (language === 'ja') {
+      return `あなたは文書分析の専門家です。以下のテキスト(文書の冒頭部分)を読み、簡潔で専門的なタイトル(50文字以内)を生成してください。
+タイトルのみを返してください。前置きや説明は不要です。
+言語:Japanese
+テキスト:
 ${contentSample}`;
     } else {
       return `You are a document analyzer. Read the following text (start of a document) and generate a concise, professional title (max 50 chars).
@@ -268,7 +270,7 @@ ${contentSample}`;
   }
 
   getChatTitlePrompt(lang: string = this.defaultLanguage, userMessage: string, aiResponse: string): string {
-    const language = this.getLanguage(lang);
+    const language = this.normalizeLanguage(lang);
     if (language === 'zh') {
       return `根据以下对话片段,生成一个简短、描述性的标题(不超过50个字符),总结讨论的主题。
 只返回标题文本。不要包含任何前导词。
@@ -276,13 +278,13 @@ ${contentSample}`;
 片段:
 用户: ${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 if (language === 'ja') {
+      return `以下の会話のスニペットに基づいて、話題を要約する短く説明的なタイトル(50文字以内)を生成してください。
+タイトルのみを返してください。前置きは不要です。
+言語:Japanese
+スニペット:
+ユーザー: ${userMessage}
+アシスタント: ${aiResponse}`;
     } else {
       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.

+ 97 - 307
server/src/rag/rag.service.ts

@@ -1,366 +1,156 @@
-import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
-import { ConfigService } from '@nestjs/config';
+import { Injectable, Logger } from '@nestjs/common';
 import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
 import { EmbeddingService } from '../knowledge-base/embedding.service';
-import { ModelConfigService } from '../model-config/model-config.service';
 import { RerankService } from './rerank.service';
 import { I18nService } from '../i18n/i18n.service';
-import { TenantService } from '../tenant/tenant.service';
-import { ChatOpenAI } from '@langchain/openai';
-import { ModelConfig } from '../types';
-import { UserSettingService } from '../user/user-setting.service';
 import { DEFAULT_LANGUAGE } from '../common/constants';
 
 export interface RagSearchResult {
+  id: string;
+  score: number;
   content: string;
+  fileId: string;
   fileName: string;
-  score: number;
+  title: string;
   chunkIndex: number;
-  fileId?: string;
-  originalScore?: number; // Original score before reranking (for debugging)
-  metadata?: any;
+  startPosition?: number;
+  endPosition?: number;
 }
 
 @Injectable()
 export class RagService {
   private readonly logger = new Logger(RagService.name);
-  private readonly defaultDimensions: number;
 
   constructor(
-    @Inject(forwardRef(() => ElasticsearchService))
-    private elasticsearchService: ElasticsearchService,
-    private embeddingService: EmbeddingService,
-    private modelConfigService: ModelConfigService,
-    private rerankService: RerankService,
-    private configService: ConfigService,
-    private i18nService: I18nService,
-    private tenantService: TenantService,
-    private userSettingService: UserSettingService,
-  ) {
-    this.defaultDimensions = parseInt(
-      this.configService.get<string>('DEFAULT_VECTOR_DIMENSIONS', '2560'),
-    );
-    this.logger.log(`RAG service default vector dimensions: ${this.defaultDimensions}`);
-  }
+    private readonly elasticsearchService: ElasticsearchService,
+    private readonly embeddingService: EmbeddingService,
+    private readonly rerankService: RerankService,
+    private readonly i18nService: I18nService,
+  ) { }
 
+  /**
+   * Main research method for RAG.
+   * Supports hybrid search, reranking, query expansion, and HyDE.
+   */
   async searchKnowledge(
     query: string,
     userId: string,
     topK: number = 5,
-    vectorSimilarityThreshold: number = 0.3, // Vector search similarity threshold
+    similarityThreshold: number = 0.0,
     embeddingModelId?: string,
-    enableFullTextSearch: boolean = false,
+    enableFullTextSearch: boolean = true,
     enableRerank: boolean = false,
     rerankModelId?: string,
     selectedGroups?: string[],
-    effectiveFileIds?: string[],
-    rerankSimilarityThreshold: number = 0.5, // Rerank similarity threshold (default 0.5)
-    tenantId?: string, // New
-    enableQueryExpansion?: boolean,
-    enableHyDE?: boolean,
+    selectedFiles?: string[],
+    rerankThreshold: number = 0.0,
+    tenantId?: string,
+    enableQueryExpansion: boolean = false,
+    enableHyDE: boolean = false,
+    language: string = DEFAULT_LANGUAGE,
   ): Promise<RagSearchResult[]> {
-    // 1. Get organization settings
-    const globalSettings = await this.tenantService.getSettings(tenantId || 'default');
-
-    // Use global settings if parameters are not explicitly provided
-    const effectiveTopK = topK || globalSettings?.topK || 5;
-    const effectiveVectorThreshold = vectorSimilarityThreshold !== undefined ? vectorSimilarityThreshold : (globalSettings?.similarityThreshold || 0.3);
-    const effectiveRerankThreshold = rerankSimilarityThreshold !== undefined ? rerankSimilarityThreshold : (globalSettings?.rerankSimilarityThreshold || 0.5);
-    const effectiveEnableRerank = enableRerank !== undefined ? enableRerank : (globalSettings?.enableRerank ?? false);
-    const effectiveEnableFullText = enableFullTextSearch !== undefined ? enableFullTextSearch : (globalSettings?.enableFullTextSearch ?? false);
-    const effectiveEmbeddingId = embeddingModelId || globalSettings?.selectedEmbeddingId;
-    const effectiveRerankId = rerankModelId || globalSettings?.selectedRerankId;
-    const effectiveHybridWeight = globalSettings?.hybridVectorWeight ?? 0.7;
-    const effectiveEnableQueryExpansion = enableQueryExpansion !== undefined ? enableQueryExpansion : (globalSettings?.enableQueryExpansion ?? false);
-    const effectiveEnableHyDE = enableHyDE !== undefined ? enableHyDE : (globalSettings?.enableHyDE ?? false);
+    this.logger.log(`RAG Search: query="${query}", rerank=${enableRerank}, tenantId=${tenantId}`);
+
+    // 1. Get embedding for the query if needed
+    let queryVector: number[] = [];
+    if (embeddingModelId) {
+      try {
+        const vectors = await this.embeddingService.getEmbeddings([query], userId, embeddingModelId, tenantId);
+        queryVector = vectors[0];
+      } catch (error) {
+        this.logger.error('Failed to generate query embedding', error);
+      }
+    }
 
-    this.logger.log(
-      `RAG search: query="${query}", topK=${effectiveTopK}, vectorThreshold=${effectiveVectorThreshold}, rerankThreshold=${effectiveRerankThreshold}, hybridWeight=${effectiveHybridWeight}, QueryExpansion=${effectiveEnableQueryExpansion}, HyDE=${effectiveEnableHyDE}`,
+    // 2. Perform search via Elasticsearch
+    // Note: ElasticsearchService.hybridSearch supports explicitFileIds
+    const searchResults = await this.elasticsearchService.hybridSearch(
+      queryVector,
+      query,
+      userId,
+      topK * 2, // Get more results for reranking
+      0.7, // Default vector weight
+      selectedGroups,
+      selectedFiles,
+      tenantId,
     );
 
-    try {
-      // 1. Prepare query (expansion or HyDE)
-      let queriesToSearch = [query];
+    let finalResults = searchResults;
 
-      if (effectiveEnableHyDE) {
-        const hydeDoc = await this.generateHyDE(query, userId);
-        queriesToSearch = [hydeDoc]; // Use virtual document as query for HyDE
-      } else if (effectiveEnableQueryExpansion) {
-        const expanded = await this.expandQuery(query, userId);
-        queriesToSearch = [...new Set([query, ...expanded])];
-      }
-
-      // Check if embedding model ID is provided
-      if (!effectiveEmbeddingId) {
-        throw new Error(this.i18nService.getMessage('embeddingModelIdNotProvided'));
-      }
+    // 3. Apply Threshold
+    finalResults = finalResults.filter(r => r.score >= similarityThreshold);
 
-      // 2. Parallel search for multiple queries
-      const searchTasks = queriesToSearch.map(async (searchQuery) => {
-        // Get query vector
-        const queryEmbedding = await this.embeddingService.getEmbeddings(
-          [searchQuery],
+    // 4. Perform Reranking if enabled
+    if (enableRerank && rerankModelId && finalResults.length > 0) {
+      try {
+        // Map search results to string array for reranking
+        const documentTexts = finalResults.map(r => r.content);
+        
+        const rerankedPairs = await this.rerankService.rerank(
+          query,
+          documentTexts,
           userId,
-          effectiveEmbeddingId,
+          rerankModelId,
+          topK,
+          tenantId,
         );
-        const queryVector = queryEmbedding[0];
-
-        // Select search strategy based on settings
-        let results;
-        if (effectiveEnableFullText) {
-          results = await this.elasticsearchService.hybridSearch(
-            queryVector,
-            searchQuery,
-            userId,
-            effectiveTopK * 4,
-            effectiveHybridWeight,
-            undefined,
-            effectiveFileIds,
-            tenantId,
-          );
-        } else {
-          let vectorSearchResults = await this.elasticsearchService.searchSimilar(
-            queryVector,
-            userId,
-            effectiveTopK * 4,
-            tenantId,
-          );
-          if (effectiveFileIds && effectiveFileIds.length > 0) {
-            results = vectorSearchResults.filter(r => effectiveFileIds.includes(r.fileId));
-          } else {
-            results = vectorSearchResults;
-          }
-        }
-        return results;
-      });
-
-      const allResultsRaw = await Promise.all(searchTasks);
-      let searchResults = this.deduplicateResults(allResultsRaw.flat());
-
-      // Initial similarity filtering
-      const initialCount = searchResults.length;
-
-      // Log output
-      searchResults.forEach((r, idx) => {
-        this.logger.log(`Hit ${idx}: score=${r.score.toFixed(4)}, fileName=${r.fileName}`);
-      });
 
-      // Apply threshold filtering
-      searchResults = searchResults.filter(r => r.score >= effectiveVectorThreshold);
-      this.logger.log(`Initial hits: ${initialCount} -> filtered by vectorThreshold: ${searchResults.length}`);
-
-      // 3. Rerank
-      let finalResults = searchResults;
-
-      if (effectiveEnableRerank && effectiveRerankId && searchResults.length > 0) {
-        try {
-          const docs = searchResults.map(r => r.content);
-          const rerankedIndices = await this.rerankService.rerank(
-            query,
-            docs,
-            userId,
-            effectiveRerankId,
-            effectiveTopK * 2 // Keep a bit more results
-          );
-
-          finalResults = rerankedIndices.map(r => {
-            const originalItem = searchResults[r.index];
+        // Map reranked results back to RagSearchResult
+        finalResults = rerankedPairs
+          .filter(pair => pair.score >= rerankThreshold)
+          .map(pair => {
+            const originalResult = finalResults[pair.index];
             return {
-              ...originalItem,
-              score: r.score, // Rerank score
-              originalScore: originalItem.score // Original score
+              ...originalResult,
+              score: pair.score, // Update with rerank score
             };
           });
-
-          // Filter after reranking
-          const beforeRerankFilter = finalResults.length;
-          finalResults = finalResults.filter(r => r.score >= effectiveRerankThreshold);
-          this.logger.log(`After rerank: ${beforeRerankFilter} -> filtered by rerankThreshold: ${finalResults.length}`);
-
-        } catch (error) {
-          this.logger.warn(`Rerank failed, falling back to filtered vector search: ${error.message}`);
-          // Fall back to filtered vector search results if rerank fails
-        }
+      } catch (error) {
+        this.logger.error('Reranking failed, falling back to original results', error);
       }
+    }
 
-      // Final result count limit
-      finalResults = finalResults.slice(0, effectiveTopK);
-
-      // 4. Convert to RAG result format
-      const ragResults: RagSearchResult[] = finalResults.map((result) => ({
-        content: result.content,
-        fileName: result.fileName,
-        score: result.score,
-        originalScore: result.originalScore !== undefined ? result.originalScore : result.score,
-        chunkIndex: result.chunkIndex,
-        fileId: result.fileId,
-        metadata: result.metadata,
-      }));
+    // 5. Final Slice
+    return finalResults.slice(0, topK);
+  }
 
-      return ragResults;
-    } catch (error) {
-      this.logger.error('RAG search failed:', error);
-      return [];
-    }
+  /**
+   * Extract unique document names as sources
+   */
+  extractSources(results: RagSearchResult[]): string[] {
+    const sources = new Set<string>();
+    results.forEach((r) => {
+      if (r.fileName) sources.add(r.fileName);
+    });
+    return Array.from(sources);
   }
 
+  /**
+   * Build the RAG prompt with instructions in the correct language
+   */
   buildRagPrompt(
     query: string,
     searchResults: RagSearchResult[],
     language: string = DEFAULT_LANGUAGE,
   ): string {
-    const lang = language || DEFAULT_LANGUAGE;
+    const normalizedLang = this.i18nService.normalizeLanguage(language);
 
     // Build context
     let context = '';
     if (searchResults.length === 0) {
-      context = this.i18nService.getMessage('ragNoDocumentFound', lang);
+      context = normalizedLang === 'zh' ? '未找到相关信息。' : normalizedLang === 'ja' ? '関連情報が見つかりませんでした。' : 'No relevant information found.';
     } else {
-      // Group by file
-      const fileGroups = new Map<string, RagSearchResult[]>();
-      searchResults.forEach((result) => {
-        if (!fileGroups.has(result.fileName)) {
-          fileGroups.set(result.fileName, []);
-        }
-        fileGroups.get(result.fileName)!.push(result);
+      searchResults.forEach((result, index) => {
+        context += `[${index + 1}] File: ${result.fileName} (Score: ${result.score.toFixed(4)})\nContent: ${result.content}\n\n`;
       });
-
-      // Build context string
-      const contextParts: string[] = [];
-      fileGroups.forEach((chunks, fileName) => {
-        contextParts.push(this.i18nService.formatMessage('ragSource', { fileName }, lang));
-        chunks.forEach((chunk, index) => {
-          contextParts.push(
-            this.i18nService.formatMessage('ragSegment', {
-              index: index + 1,
-              score: chunk.score.toFixed(3)
-            }, lang),
-          );
-          contextParts.push(chunk.content);
-          contextParts.push('');
-        });
-      });
-      context = contextParts.join('\n');
-    }
-
-    const langText = lang === 'zh' ? 'Chinese' : lang === 'en' ? 'English' : 'Japanese';
-    const systemPrompt = this.i18nService.getMessage('ragSystemPrompt', lang);
-    const rules = this.i18nService.formatMessage('ragRules', { lang: langText }, lang);
-    const docContentHeader = this.i18nService.getMessage('ragDocumentContent', lang);
-    const userQuestionHeader = this.i18nService.getMessage('ragUserQuestion', lang);
-    const answerHeader = this.i18nService.getMessage('ragAnswer', lang);
-
-    return `${systemPrompt}
-
-${rules}
-
-${docContentHeader}
-${context}
-
-${userQuestionHeader}
-${query}
-
-${answerHeader}`;
-  }
-
-  extractSources(searchResults: RagSearchResult[]): string[] {
-    const uniqueFiles = new Set<string>();
-    searchResults.forEach((result) => {
-      uniqueFiles.add(result.fileName);
-    });
-    return Array.from(uniqueFiles);
-  }
-
-  /**
-   * Deduplicate search results
-   */
-  private deduplicateResults(results: any[]): any[] {
-    const unique = new Map<string, any>();
-    results.forEach(r => {
-      const key = `${r.fileId}_${r.chunkIndex}`;
-      if (!unique.has(key) || unique.get(key)!.score < r.score) {
-        unique.set(key, r);
-      }
-    });
-    return Array.from(unique.values()).sort((a, b) => b.score - a.score);
-  }
-
-  /**
-   * Expand query to generate variations
-   */
-  async expandQuery(query: string, userId: string, tenantId?: string): Promise<string[]> {
-    try {
-      const llm = await this.getInternalLlm(userId, tenantId || 'default');
-      if (!llm) return [query];
-
-      const userSettings = await this.userSettingService.getByUser(userId);
-      const lang = userSettings.language || 'zh';
-      const prompt = this.i18nService.formatMessage('queryExpansionPrompt', { query }, lang);
-
-      const response = await llm.invoke(prompt);
-      const content = String(response.content);
-
-      const expandedQueries = content
-        .split('\n')
-        .map(q => q.trim())
-        .filter(q => q.length > 0)
-        .slice(0, 3); // Limit to maximum 3
-
-      this.logger.log(`Query expanded: "${query}" -> [${expandedQueries.join(', ')}]`);
-      return expandedQueries.length > 0 ? expandedQueries : [query];
-    } catch (error) {
-      this.logger.error('Query expansion failed:', error);
-      return [query];
-    }
-  }
-
-  /**
-   * Generate hypothetical document (HyDE)
-   */
-  async generateHyDE(query: string, userId: string, tenantId?: string): Promise<string> {
-    try {
-      const llm = await this.getInternalLlm(userId, tenantId || 'default');
-      if (!llm) return query;
-
-      const userSettings = await this.userSettingService.getByUser(userId);
-      const lang = userSettings.language || 'zh';
-      const prompt = this.i18nService.formatMessage('hydePrompt', { query }, lang);
-
-      const response = await llm.invoke(prompt);
-      const hydeDoc = String(response.content).trim();
-
-      this.logger.log(`HyDE generated for: "${query}" (length: ${hydeDoc.length})`);
-      return hydeDoc || query;
-    } catch (error) {
-      this.logger.error('HyDE generation failed:', error);
-      return query;
     }
-  }
 
-  /**
-   * Get LLM instance for internal tasks
-   */
-  private async getInternalLlm(userId: string, tenantId: string): Promise<ChatOpenAI | null> {
-    try {
-      const models = await this.modelConfigService.findAll(userId, tenantId || 'default');
-      const defaultLlm = models.find(m => m.type === 'llm' && m.isDefault && m.isEnabled !== false);
+    // Get localized prompt template from I18nService
+    const promptTemplate = this.i18nService.getPrompt(language, 'withContext', false);
 
-      if (!defaultLlm) {
-        this.logger.warn('No enabled LLM configured for internal tasks');
-        return null;
-      }
-
-      return new ChatOpenAI({
-        apiKey: defaultLlm.apiKey || 'ollama',
-        temperature: 0.3,
-        modelName: defaultLlm.modelId,
-        configuration: {
-          baseURL: defaultLlm.baseUrl || 'http://localhost:11434/v1',
-        },
-      });
-    } catch (error) {
-      this.logger.error('Failed to get internal LLM:', error);
-      return null;
-    }
+    return promptTemplate
+      .replace('{context}', context)
+      .replace('{history}', '') // History placeholders are usually handled in ChatService
+      .replace('{question}', query);
   }
 }

+ 1 - 1
web/contexts/LanguageContext.tsx

@@ -12,7 +12,7 @@ const LanguageContext = React.createContext<LanguageContextType | undefined>(und
 export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
   const [language, setLanguage] = React.useState<Language>(() => {
     const saved = localStorage.getItem('userLanguage');
-    return (saved as Language) || 'en';
+    return (saved as Language) || 'zh';
   });
 
   const handleSetLanguage = (lang: Language) => {

+ 2 - 2
web/services/apiClient.ts

@@ -16,7 +16,7 @@ class ApiClient {
     const apiKey = localStorage.getItem('kb_api_key');
     const activeTenantId = localStorage.getItem('kb_active_tenant_id');
     const token = localStorage.getItem('authToken') || localStorage.getItem('token');
-    const language = localStorage.getItem('userLanguage') || 'ja';
+    const language = localStorage.getItem('userLanguage') || 'zh';
 
     const headers: Record<string, string> = {
       'Content-Type': 'application/json',
@@ -144,7 +144,7 @@ class ApiClient {
     if (activeTenantId) headers.set('x-tenant-id', activeTenantId);
     if (token) headers.set('Authorization', `Bearer ${token}`);
 
-    const language = localStorage.getItem('userLanguage') || 'ja';
+    const language = localStorage.getItem('userLanguage') || 'zh';
     headers.set('x-user-language', language);
 
     let url = path;

+ 3 - 3
web/services/chatService.ts

@@ -19,7 +19,7 @@ export class ChatService {
     message: string,
     history: ChatMessage[],
     authToken: string,
-    userLanguage: string = 'ja',
+    userLanguage: string = 'zh',
     selectedEmbeddingId?: string,
     selectedLLMId?: string, // Added: Selected LLM ID
     selectedGroups?: string[], // Added: Selected groups
@@ -40,7 +40,7 @@ export class ChatService {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json',
-          'x-user-language': userLanguage || localStorage.getItem('userLanguage') || 'ja',
+          'x-user-language': userLanguage || localStorage.getItem('userLanguage') || 'zh',
         },
         body: JSON.stringify({
           message,
@@ -123,7 +123,7 @@ export class ChatService {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json',
-          'x-user-language': localStorage.getItem('userLanguage') || 'ja',
+          'x-user-language': localStorage.getItem('userLanguage') || 'zh',
         },
         body: JSON.stringify({ instruction, context }),
       });