diff --git a/.gitignore b/.gitignore index e8c46e8..ea2951a 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,4 @@ agents.md # Personal, not for public release scripts/ memo/ +tests/ diff --git a/README.md b/README.md index 2f38496..210d29c 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,12 @@ High-accuracy voice input for Obsidian. Uses OpenAI GPT-4o Audio Transcriptions - One‑click recording: start/stop from a microphone ribbon icon - Push‑to‑talk: long‑press to record, release to stop - Model selection: GPT‑4o Transcribe or GPT‑4o mini Transcribe -- AI post‑processing: optional dictionary-based cleanup (JA) +- Language separation: independent UI language and voice recognition language settings +- Auto language detection: automatic voice recognition language based on Obsidian locale +- AI post‑processing: optional dictionary-based cleanup (applied to all languages when enabled) - Quick controls in view: copy/clear/insert at cursor/append to end - Auto‑save drafts: periodic and on blur, automatic restore +- Multilingual support: Japanese, English, Chinese, Korean interface languages ## Requirements @@ -47,16 +50,19 @@ Tip: A settings gear in the view header opens the plugin’s settings. - OpenAI API Key: stored locally (encrypted at rest) - Transcription Model: `gpt-4o-transcribe` or `gpt-4o-mini-transcribe` -- AI Post‑processing: enable dictionary‑based cleanup (Japanese) +- **Transcription Language**: Auto/Japanese/English/Chinese/Korean (auto-detection recommended) +- AI Post‑processing: enable dictionary‑based cleanup (applied to all languages when enabled) - Maximum Recording Duration: slider (default 5 min) -- Plugin Language: English/Japanese (auto‑detected from Obsidian, adjustable) +- Plugin Language: English/Japanese (controls UI display only, auto‑detected from Obsidian, adjustable) ## Security & Privacy - Processing in memory; audio is not written to disk by the plugin -- HTTPS for all network requests (via Obsidian’s `requestUrl`) +- Audio you record is transmitted to OpenAI for transcription over HTTPS (via Obsidian’s `requestUrl`). - API key is encrypted for storage +Note: When Electron SafeStorage is unavailable, the plugin falls back to lightweight obfuscation for the stored key; the plugin is desktop‑only by design. + See also OpenAI’s Privacy Policy. ## Troubleshooting @@ -72,6 +78,10 @@ See also OpenAI’s Privacy Policy. - Deploy locally: `npm run deploy-local` (copies to detected vaults) - Analyze unused code (build-time): `npm run analyze:unused` +### Documentation + +- Processing flow visualization: see [`docs/PROCESSING_FLOW.md`](docs/PROCESSING_FLOW.md) + Third‑party licensing: see `THIRD_PARTY_LICENSES.md`. — @@ -85,9 +95,12 @@ Third‑party licensing: see `THIRD_PARTY_LICENSES.md`. - ワンクリック録音(リボンのマイクアイコン) - プッシュトゥトーク(長押しで録音開始、離して停止) - モデル選択(GPT‑4o Transcribe / GPT‑4o mini Transcribe) -- AI後処理(辞書ベースの補正、JA向け) +- 言語設定の分離(UI言語と音声認識言語を独立設定) +- 自動言語検出(Obsidianロケールに基づく音声認識言語の自動検出) +- AI後処理(辞書ベースの補正、日本語のみ適用) - ビュー内のクイック操作(コピー/クリア/カーソル位置へ挿入/末尾へ追記) - 自動保存(定期保存とフォーカス外れ時)。再オープン時に自動復元 +- 多言語サポート(日本語、英語、中国語、韓国語のインターフェース) ## 必要条件 @@ -125,16 +138,37 @@ Third‑party licensing: see `THIRD_PARTY_LICENSES.md`. - OpenAI APIキー: ローカルに暗号化して保存 - 文字起こしモデル: `gpt-4o-transcribe` または `gpt-4o-mini-transcribe` -- AI後処理: 辞書ベースの補正(日本語向け) +- **音声認識言語**: 自動/日本語/英語/中国語/韓国語(自動検出を推奨) +- AI後処理: 辞書ベースの補正(有効時は全言語に適用) - 最大録音時間: スライダー(初期値5分) -- プラグイン言語: 英語/日本語(Obsidian設定から自動検出、変更可) +- **プラグイン言語**: UI表示のみを制御。Obsidianの言語設定から自動検出(ja/zh/ko/en)。 + +### 言語設定 + +- **プラグイン言語**: UI表示のみを制御。Obsidianの言語設定から自動検出(ja/zh/ko/en)。 +- **音声認識言語**: 音声認識/文字起こしの言語。既定は「自動」(Obsidianのロケールから ja/zh/ko/en を自動選択)。 + +#### 自動検出の動作 + +- ja-* → 日本語 +- zh-* → 中国語 +- ko-* → 韓国語 +- その他 → 英語 + +#### 言語別の処理 + +- 日本語 (ja): 精度向上のためプロンプトを付与+辞書補正(有効時) +- 中国語/英語/韓国語 (zh/en/ko): プロンプトは付与しません。クリーニングは言語別+汎用(コロン付き)で安全側に適用。 +- 自動: 上記の動作を自動選択。 ## セキュリティ / プライバシー - 処理はメモリ内で行い、音声ファイルはプラグイン側でディスク保存しません -- 通信はHTTPS(Obsidianの `requestUrl` 経由) +- 録音した音声は文字起こしのため OpenAI に送信され、HTTPS(Obsidian の `requestUrl` 経由)で通信します。 - APIキーは保存時に暗号化 +補足: Electron の SafeStorage が利用できない環境では保存キーを軽度に難読化して保持します(本プラグインはデスクトップ専用の設計です)。 + OpenAIのプライバシーポリシーもご参照ください。 ## トラブルシューティング @@ -149,6 +183,10 @@ OpenAIのプライバシーポリシーもご参照ください。 - ビルド: `npm run build-plugin` - ローカル配布: `npm run deploy-local` +### ドキュメント + +- 処理フローの可視化: [`docs/PROCESSING_FLOW.md`](docs/PROCESSING_FLOW.md) を参照 + サードパーティライセンスは `THIRD_PARTY_LICENSES.md` を参照してください。 ## ライセンス diff --git a/jest.config.js b/jest.config.js index 93691d7..492e94b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,4 +25,4 @@ module.exports = { moduleNameMapper: { '^obsidian$': '/tests/mocks/obsidian.ts', }, -}; \ No newline at end of file +}; diff --git a/manifest.json b/manifest.json index 945ae5f..cd7b064 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "id": "voice-input", "name": "Voice Input", - "version": "0.8.0", + "version": "0.9.0", "minAppVersion": "1.7.0", - "description": "Voice input with real-time transcription using GPT-4o.", + "description": "AI-powered high-accuracy voice input using OpenAI GPT-4o Transcribe API", "author": "Musashino Software", "authorUrl": "https://github.com/mssoftjp", "fundingUrl": "https://buymeacoffee.com/mssoft", diff --git a/package-lock.json b/package-lock.json index a9f9d16..2a2e9a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "voice-input", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "voice-input", - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "@echogarden/fvad-wasm": "^0.2.0", diff --git a/package.json b/package.json index 3a5cc2a..a881bda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "voice-input", - "version": "0.8.0", + "version": "0.9.0", "description": "Voice input plugin for Obsidian with accurate transcription", "main": "main.js", "scripts": { diff --git a/src/config/CleaningConfig.ts b/src/config/CleaningConfig.ts new file mode 100644 index 0000000..4cbc51e --- /dev/null +++ b/src/config/CleaningConfig.ts @@ -0,0 +1,121 @@ +/** + * Configuration for the text cleaning pipeline + * Centralized management of thresholds, patterns, and safety limits + * Language-independent approach with universal patterns + */ + +export interface SafetyThresholds { + /** Maximum reduction allowed by a single cleaner (0.0-1.0) */ + singleCleanerMaxReduction: number; + /** Emergency fallback threshold - rollback if exceeded (0.0-1.0) */ + emergencyFallbackThreshold: number; + /** Warning threshold for logging (0.0-1.0) */ + warningThreshold: number; +} + +export interface RepetitionThresholds { + /** Base threshold for repetition detection */ + baseThreshold: number; + /** Length factor for dynamic threshold calculation */ + lengthFactor: number; + /** Divisor for dynamic threshold based on text length */ + dynamicThresholdDivisor: number; + /** Ratio of repeated items to keep (0.0-1.0) */ + shortCharKeepRatio: number; + /** Threshold for sentence repetition detection */ + sentenceRepetition: number; + /** Similarity threshold for sentence comparison (0.0-1.0) */ + similarityThreshold: number; + /** Minimum sentence length for similarity comparison */ + minimumSentenceLengthForSimilarity: number; + /** Maximum consecutive newlines to allow */ + consecutiveNewlineLimit: number; + /** Enumeration detection configuration */ + enumerationDetection: { + enabled: boolean; + minRepeatCount: number; + }; + /** Paragraph repetition detection configuration */ + paragraphRepeat: { + enabled: boolean; + headChars: number; + }; +} + +export interface ContaminationPatterns { + /** Instruction patterns to remove from text beginning */ + instructionPatterns: string[]; + /** Context patterns for general cleanup */ + contextPatterns: string[]; + /** Prompt snippet lengths for partial matching */ + promptSnippetLengths: number[]; +} + +export interface CleaningConfig { + safety: SafetyThresholds; + repetition: RepetitionThresholds; + contamination: ContaminationPatterns; +} + +/** + * Default cleaning configuration with conservative settings + * Optimized for safety and minimal false positives + */ +export const CLEANING_CONFIG: CleaningConfig = { + safety: { + singleCleanerMaxReduction: 0.3, + emergencyFallbackThreshold: 0.5, + warningThreshold: 0.15 + }, + + repetition: { + baseThreshold: 3, + lengthFactor: 2, + dynamicThresholdDivisor: 100, + shortCharKeepRatio: 0.3, + sentenceRepetition: 3, + similarityThreshold: 0.85, + minimumSentenceLengthForSimilarity: 10, + consecutiveNewlineLimit: 3, + enumerationDetection: { + enabled: true, + minRepeatCount: 3 + }, + paragraphRepeat: { + enabled: true, + headChars: 50 + } + }, + + contamination: { + instructionPatterns: [ + // Universal instruction patterns (language-independent structure) + 'Please transcribe only the following audio content', + 'Do not include this instruction in your output', + 'Record only the speaker\'s statements accurately', + 'PLEASE TRANSCRIBE ONLY THE FOLLOWING AUDIO CONTENT', + 'RECORD ONLY THE SPEAKER\'S STATEMENTS ACCURATELY', + '以下の音声内容のみを文字に起こしてください', + 'この指示文は出力に含めないでください', + '話者の発言内容だけを正確に記録してください', + '请仅转录以下音频内容', + '不要包含此指令在输出中', + '请准确记录说话者的发言内容', + '다음 음성 내용만 전사해주세요', + '이 지시사항을 출력에 포함하지 마세요', + '화자의 발언 내용만 정확히 기록해주세요' + ], + + contextPatterns: [ + // Universal context patterns (structural) + '/^\\s*\\([^)]*Speaker[^)]*only[^)]*\\)\\s*$/gmi', + '/^\\s*([^)]*話者[^)]*のみ[^)]*)\\s*$/gm', + '/^\\s*([^)]*说话者[^)]*内容[^)]*)\\s*$/gm', + '/^\\s*([^)]*화자[^)]*발언만[^)]*)\\s*$/gm', + '/\\b(output|format|instruction)\\s*:?\\s*$/gmi', + '/^\\s*(transcribe|record|speak)\\s+only\\b.*$/gmi' + ], + + promptSnippetLengths: [20, 30, 40, 50] + } +}; diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 264ce92..5d268d3 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -70,9 +70,6 @@ export const DEFAULT_TRANSCRIPTION_SETTINGS = { /** デフォルトの文字起こしモデル */ model: 'gpt-4o-transcribe' as const, - /** 言語設定 */ - language: 'ja', - /** 文字起こし補正の有効化 */ enableTranscriptionCorrection: true } as const; @@ -83,7 +80,6 @@ export const DEFAULT_TRANSCRIPTION_SETTINGS = { export const DEFAULT_USER_SETTINGS = { /** 基本設定 */ openaiApiKey: '', - language: DEFAULT_TRANSCRIPTION_SETTINGS.language, enableTranscriptionCorrection: DEFAULT_TRANSCRIPTION_SETTINGS.enableTranscriptionCorrection, /** 音声設定 */ diff --git a/src/core/audio/AudioRecorder.ts b/src/core/audio/AudioRecorder.ts index 3860e33..156aa3b 100644 --- a/src/core/audio/AudioRecorder.ts +++ b/src/core/audio/AudioRecorder.ts @@ -38,6 +38,7 @@ export class AudioRecorder extends Disposable { private logger: Logger | null = null; private recordingStartTime: number = 0; private maxRecordingTimer: NodeJS.Timeout | null = null; + private isStarting: boolean = false; constructor(options: AudioRecorderOptions) { super(); @@ -71,6 +72,17 @@ export class AudioRecorder extends Disposable { async initialize(): Promise { this.throwIfDisposed(); + // 既にAudioContextが有効なら再初期化はスキップ + if (this.audioContext && this.audioContext.state !== 'closed') { + // まだビジュアライザー未生成ならここで生成 + if (!this.visualizer && this.options.visualizerContainer) { + this.visualizer = this.options.useSimpleVisualizer + ? new SimpleAudioLevelIndicator(this.options.visualizerContainer) + : new AudioVisualizer(this.options.visualizerContainer); + } + return; + } + if (this.options.useVAD && this.vadProcessor) { await this.vadProcessor.initialize(); } @@ -127,11 +139,21 @@ export class AudioRecorder extends Disposable { async startRecording(): Promise { this.throwIfDisposed(); - if (this.isRecording) { + if (this.isRecording || this.isStarting) { return; } + this.isStarting = true; try { + // Ensure AudioContext exists (防御的) + if (!this.audioContext || this.audioContext.state === 'closed') { + await this.initialize(); + } + // Safariなどでのsuspend対策 + if (this.audioContext!.state === 'suspended') { + await this.audioContext!.resume(); + } + // Log recording session start this.logger?.info('Starting recording session', { sampleRate: AUDIO_CONSTANTS.SAMPLE_RATE, @@ -183,7 +205,13 @@ export class AudioRecorder extends Disposable { } // Setup audio processing chain with filters - this.sourceNode = this.audioContext!.createMediaStreamSource(this.stream); + if (!this.audioContext) { + throw new TranscriptionError( + TranscriptionErrorType.AUDIO_INITIALIZATION_FAILED, + 'Audio context is not initialized' + ); + } + this.sourceNode = this.audioContext.createMediaStreamSource(this.stream); this.gainNode = this.audioContext!.createGain(); this.gainNode.gain.value = DEFAULT_AUDIO_SETTINGS.gain; // デフォルトゲイン @@ -249,6 +277,8 @@ export class AudioRecorder extends Disposable { } catch (error) { this.logger?.error('Failed to start recording', error); throw error; + } finally { + this.isStarting = false; } } @@ -585,4 +615,4 @@ export class AudioRecorder extends Disposable { this.options.autoStopSilenceDuration = duration; } } -} \ No newline at end of file +} diff --git a/src/core/transcription/DictionaryCorrector.ts b/src/core/transcription/DictionaryCorrector.ts index 272a6aa..c9a2f32 100644 --- a/src/core/transcription/DictionaryCorrector.ts +++ b/src/core/transcription/DictionaryCorrector.ts @@ -2,7 +2,6 @@ import { CorrectionRule, DictionaryCorrectorOptions, ITextCorrector, SimpleCorrectionDictionary, CorrectionEntry } from '../../interfaces'; import { TranscriptionError, TranscriptionErrorType } from '../../errors'; import { API_CONSTANTS, DICTIONARY_CONSTANTS } from '../../config'; -import { requestUrl } from 'obsidian'; /** * 辞書修正設定 @@ -138,4 +137,4 @@ export class DictionaryCorrector implements ITextCorrector { getDefaultRules(): CorrectionRule[] { return [...this.defaultRules]; } -} \ No newline at end of file +} diff --git a/src/core/transcription/TranscriptionService.ts b/src/core/transcription/TranscriptionService.ts index 68d3587..29caae8 100644 --- a/src/core/transcription/TranscriptionService.ts +++ b/src/core/transcription/TranscriptionService.ts @@ -1,21 +1,64 @@ import { DictionaryCorrector } from './DictionaryCorrector'; import { TranscriptionResult, ITranscriptionProvider, SimpleCorrectionDictionary } from '../../interfaces'; import { TranscriptionError, TranscriptionErrorType } from '../../errors'; -import { SecurityUtils } from '../../security'; import { API_CONSTANTS, DEFAULT_TRANSCRIPTION_SETTINGS, TRANSCRIPTION_MODEL_COSTS } from '../../config'; import { ObsidianHttpClient } from '../../utils/ObsidianHttpClient'; import { createServiceLogger } from '../../services'; import { Logger } from '../../utils'; +import { StandardCleaningPipeline } from './cleaning/StandardCleaningPipeline'; +import { PromptContaminationCleaner } from './cleaning/PromptContaminationCleaner'; +import { UniversalRepetitionCleaner } from './cleaning/UniversalRepetitionCleaner'; + +// Prompt constants for string drift prevention (文字列ドリフト対策) +const PROMPT_CONSTANTS = { + JAPANESE: { + INSTRUCTION_1: '以下の音声内容のみを文字に起こしてください。この指示文は出力に含めないでください。', + INSTRUCTION_2: '話者の発言内容だけを正確に記録してください。', + OUTPUT_FORMAT: '出力形式:', + OUTPUT_FORMAT_FULL_COLON: '出力形式:', + SPEAKER_ONLY: '(話者の発言のみ)' + }, + ENGLISH: { + INSTRUCTION_1: 'Please transcribe only the following audio content. Do not include this instruction in your output.', + INSTRUCTION_1_CAPS: 'PLEASE TRANSCRIBE ONLY THE FOLLOWING AUDIO CONTENT. DO NOT INCLUDE THIS INSTRUCTION IN YOUR OUTPUT.', + INSTRUCTION_2: 'Record only the speaker\'s statements accurately.', + INSTRUCTION_2_CAPS: 'RECORD ONLY THE SPEAKER\'S STATEMENTS ACCURATELY.', + OUTPUT_FORMAT: 'Output format:', + OUTPUT_FORMAT_CAPS: 'OUTPUT FORMAT:', + OUTPUT_FORMAT_FULL_COLON: 'Output format:', + SPEAKER_ONLY: '(Speaker content only)', + SPEAKER_ONLY_CAPS: '(SPEAKER CONTENT ONLY)' + }, + CHINESE: { + INSTRUCTION_1: '请仅转录以下音频内容。不要包含此指令在输出中。', + INSTRUCTION_2: '请准确记录说话者的发言内容。', + OUTPUT_FORMAT: '输出格式:', + OUTPUT_FORMAT_FULL_COLON: '输出格式:', + SPEAKER_ONLY: '(仅说话者内容)' + }, + KOREAN: { + INSTRUCTION_1: '다음 음성 내용만 전사해주세요. 이 지시사항을 출력에 포함하지 마세요.', + INSTRUCTION_2: '화자의 발언 내용만 정확히 기록해주세요.', + OUTPUT_FORMAT: '출력 형식:', + OUTPUT_FORMAT_FULL_COLON: '출력 형식:', + SPEAKER_ONLY: '(화자 발언만)' + }, + GENERIC: { + OUTPUT_FORMAT: 'Output format:', + OUTPUT_FORMAT_FULL_COLON: 'Output format:', + FORMAT: 'Format:' + } +} as const; export class TranscriptionService implements ITranscriptionProvider { private apiKey: string; private corrector: DictionaryCorrector; private logger: Logger; + private cleaningPipeline: StandardCleaningPipeline; // 注: gpt-4o-mini-transcribe と gpt-4o-transcribe の両方が日本語音声認識で高精度 // コスト重視なら mini、わずかな精度向上が必要なら通常版を選択 private model: 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe' = DEFAULT_TRANSCRIPTION_SETTINGS.model; private enableTranscriptionCorrection: boolean = DEFAULT_TRANSCRIPTION_SETTINGS.enableTranscriptionCorrection; - private language: string = 'ja'; constructor(apiKey: string, dictionary?: SimpleCorrectionDictionary) { this.apiKey = apiKey; @@ -23,13 +66,19 @@ export class TranscriptionService implements ITranscriptionProvider { this.corrector = new DictionaryCorrector({ correctionDictionary: dictionary }); + + // Initialize the new cleaning pipeline + this.cleaningPipeline = new StandardCleaningPipeline([ + new PromptContaminationCleaner(), + new UniversalRepetitionCleaner() + ]); } - async transcribe(audioBlob: Blob, language: string = 'ja'): Promise { + async transcribe(audioBlob: Blob, language: string): Promise { return this.transcribeAudio(audioBlob, language); } - async transcribeAudio(audioBlob: Blob, language: string = 'ja'): Promise { + async transcribeAudio(audioBlob: Blob, language: string): Promise { const startTime = Date.now(); const perfStartTime = performance.now(); @@ -49,13 +98,10 @@ export class TranscriptionService implements ITranscriptionProvider { formData.append('response_format', 'json'); formData.append('temperature', String(API_CONSTANTS.PARAMETERS.TRANSCRIPTION_TEMPERATURE)); // Deterministic output - // Language setting - if (language !== 'auto') { - formData.append('language', language); - } + // Language setting (auto 廃止のため常に明示指定) + formData.append('language', language); - // Build prompt for transcription - const prompt = this.buildTranscriptionPrompt(); + const prompt = this.buildTranscriptionPrompt(language); if (prompt) { formData.append('prompt', prompt); } @@ -114,14 +160,11 @@ export class TranscriptionService implements ITranscriptionProvider { } // Clean up GPT-4o specific artifacts - originalText = this.cleanGPT4oResponse(originalText); + originalText = await this.cleanGPT4oResponse(originalText, language); // プロンプトエラーの検出(音声が無音の場合にプロンプトが返される問題) - if (originalText.includes('この指示文は出力に含めないでください') || - originalText.includes('話者の発言内容だけを正確に記録してください') || - originalText === '(話者の発言のみ)' || - originalText.trim() === '話者の発言のみ') { + if (this.isPromptErrorDetected(originalText, language)) { // 音声がない場合は空文字を返す originalText = ''; } @@ -143,8 +186,8 @@ export class TranscriptionService implements ITranscriptionProvider { }; } - // Apply corrections if enabled (only for Japanese) - const correctedText = this.enableTranscriptionCorrection && language === 'ja' + // Apply corrections if enabled + const correctedText = this.enableTranscriptionCorrection ? await this.corrector.correct(originalText) : originalText; @@ -181,51 +224,99 @@ export class TranscriptionService implements ITranscriptionProvider { } /** - * Build prompt for GPT-4o transcription + * Build prompt for GPT-4o transcription (for all languages except auto) */ - private buildTranscriptionPrompt(): string { - return `以下の音声内容のみを文字に起こしてください。この指示文は出力に含めないでください。 -話者の発言内容だけを正確に記録してください。 + private buildTranscriptionPrompt(language: string): string { + // auto は廃止済み + + const normalizedLang = this.normalizeLanguage(language); + + switch (normalizedLang) { + case 'ja': + return `${PROMPT_CONSTANTS.JAPANESE.INSTRUCTION_1} +${PROMPT_CONSTANTS.JAPANESE.INSTRUCTION_2} -出力形式: +${PROMPT_CONSTANTS.JAPANESE.OUTPUT_FORMAT} -(話者の発言のみ) +${PROMPT_CONSTANTS.JAPANESE.SPEAKER_ONLY} `; + + case 'en': + return `${PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_1} +${PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_2} + +${PROMPT_CONSTANTS.ENGLISH.OUTPUT_FORMAT} + +${PROMPT_CONSTANTS.ENGLISH.SPEAKER_ONLY} +`; + + case 'zh': + return `${PROMPT_CONSTANTS.CHINESE.INSTRUCTION_1} +${PROMPT_CONSTANTS.CHINESE.INSTRUCTION_2} + +${PROMPT_CONSTANTS.CHINESE.OUTPUT_FORMAT} + +${PROMPT_CONSTANTS.CHINESE.SPEAKER_ONLY} +`; + + case 'ko': + return `${PROMPT_CONSTANTS.KOREAN.INSTRUCTION_1} +${PROMPT_CONSTANTS.KOREAN.INSTRUCTION_2} + +${PROMPT_CONSTANTS.KOREAN.OUTPUT_FORMAT} + +${PROMPT_CONSTANTS.KOREAN.SPEAKER_ONLY} +`; + + default: + // For any other language, return empty string + return ''; + } } /** - * Clean GPT-4o specific response artifacts + * Clean GPT-4o specific response artifacts using the new cleaning pipeline + * Falls back to legacy cleaning if the pipeline fails */ - private cleanGPT4oResponse(text: string): string { - // First attempt: Extract content from complete TRANSCRIPT tags - let transcriptMatch = text.match(/\s*([\s\S]*?)\s*<\/TRANSCRIPT>/); - if (transcriptMatch) { - text = transcriptMatch[1]; - } else { - // Second attempt: Handle incomplete TRANSCRIPT tags (missing closing tag) - const openingMatch = text.match(/\s*([\s\S]*)/); - if (openingMatch) { - text = openingMatch[1]; - } + private async cleanGPT4oResponse(text: string, language: string): Promise { + const normalizedLang = this.normalizeLanguage(language); + + // 1) 先に機械的にTRANSCRIPTタグ等の構造ラッパーを除去してから + // パイプラインに渡す(安全判定の基準長もラッパー除去後にする) + const preStripped = this.preStripTranscriptWrappers(text); + + try { + // Use the new cleaning pipeline + const result = await this.cleaningPipeline.execute(preStripped, normalizedLang, { + language: normalizedLang, + originalLength: preStripped.length, + enableDetailedLogging: false + }); + + return result.finalText.trim().replace(/\n{3,}/g, '\n\n'); + + } catch (error) { + // Fall back to legacy cleaning if pipeline fails + this.logger.warn('Cleaning pipeline failed, falling back to legacy cleaning', error); + return this.legacyCleanGPT4oResponse(text, language); } + } - // Remove TRANSCRIPT opening tag if still present (for cases where it's not properly extracted) - text = text.replace(/<\/?TRANSCRIPT[^>]*>/g, ''); + /** + * Legacy cleaning method (preserved for fallback) + */ + private legacyCleanGPT4oResponse(text: string, language: string): string { + // Normalize language for processing + const normalizedLang = this.normalizeLanguage(language); + + // 先に構造ラッパー(TRANSCRIPTタグ)を機械的に除去 + text = this.preStripTranscriptWrappers(text); + + // Apply language-specific cleaning + text = this.applyLanguageSpecificCleaning(text, normalizedLang); - // Remove specific meta instruction patterns (both at line start and anywhere in text) - const metaPatterns = [ - /^以下の音声内容.*?$/gm, - /^この指示文.*?$/gm, - /^話者の発言内容だけを正確に記録してください.*?$/gm, - /^話者の発言.*?$/gm, - /^出力形式.*?$/gm, - /(話者の発言のみ)/g, // Remove this specific phrase anywhere in the text - ]; - - // Apply all cleaning patterns - for (const pattern of metaPatterns) { - text = text.replace(pattern, ''); - } + // Apply generic cleaning (only colon-based patterns to prevent over-removal) + text = this.applyGenericCleaning(text); // Clean up extra whitespace and empty lines text = text.trim(); @@ -236,6 +327,300 @@ export class TranscriptionService implements ITranscriptionProvider { return text; } + /** + * TRANSCRIPT系のXMLライクなラッパーを機械的に除去し、中身のテキストを返す + * - 完全な ... を優先的に抽出 + * - 閉じタグ欠落時は開きタグ以降を抽出 + * - 残存する TRANSCRIPT 開閉タグは除去 + */ + private preStripTranscriptWrappers(text: string): string { + let result = text; + // 完全なタグにマッチ + const completeMatch = result.match(/]*>\s*([\s\S]*?)\s*<\/TRANSCRIPT>/); + if (completeMatch) { + result = completeMatch[1]; + } else { + // 開始タグのみ(閉じタグ欠落)に対応 + const openingMatch = result.match(/]*>\s*([\s\S]*)/); + if (openingMatch) { + result = openingMatch[1]; + } + } + // 念のため残りの TRANSCRIPT 開閉タグを除去(属性/大小文字変化にもある程度対応) + result = result.replace(/<\/?TRANSCRIPT[^>]*>/gi, ''); + result = result.replace(/<\/?transcription[^>]*>/gi, ''); + return result; + } + + /** + * Normalize language code for consistent processing + */ + private normalizeLanguage(language: string): string { + // auto は廃止済み + const lang = language.toLowerCase(); + if (lang.startsWith('ja')) return 'ja'; + if (lang.startsWith('zh')) return 'zh'; + if (lang.startsWith('ko')) return 'ko'; + if (lang.startsWith('en')) return 'en'; + return lang; + } + + /** + * Apply language-specific cleaning patterns + */ + private applyLanguageSpecificCleaning(text: string, language: string): string { + switch (language) { + case 'ja': + return this.applyJapaneseCleaning(text); + case 'en': + return this.applyEnglishCleaning(text); + case 'zh': + return this.applyChineseCleaning(text); + case 'ko': + return this.applyKoreanCleaning(text); + default: + return text; + } + } + + /** + * Enhanced position guard check - determines if a line should be cleaned based on position + * Beginning content: first 3 lines or immediately after + * End content: last 2 lines + */ + private shouldCleanLine(index: number, totalLines: number, lines: string[], isEndHallucination: boolean = false): boolean { + // For end hallucination patterns, check last 2 lines + if (isEndHallucination) { + return index >= Math.max(0, totalLines - 2); + } + + // For beginning patterns, check first 3 lines + if (index < 3) { + return true; + } + + // Check if line comes immediately after tag + if (index > 0 && index < totalLines) { + const previousLine = lines[index - 1].trim(); + if (previousLine === '' || previousLine.includes('')) { + return true; + } + } + + return false; + } + + /** + * Apply Japanese-specific cleaning patterns - using exact line matching with enhanced position guard for safety + */ + private applyJapaneseCleaning(text: string): string { + // Split into lines for exact line matching + const lines = text.split('\n'); + const cleanedLines = []; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const trimmedLine = line.trim(); + + // Only remove lines that exactly match prompt instructions AND are in protected position + if (this.shouldCleanLine(index, lines.length, lines) && ( + trimmedLine === PROMPT_CONSTANTS.JAPANESE.INSTRUCTION_1 || + trimmedLine === PROMPT_CONSTANTS.JAPANESE.INSTRUCTION_2 || + trimmedLine === PROMPT_CONSTANTS.JAPANESE.OUTPUT_FORMAT || + trimmedLine === PROMPT_CONSTANTS.JAPANESE.OUTPUT_FORMAT_FULL_COLON || + trimmedLine === PROMPT_CONSTANTS.JAPANESE.SPEAKER_ONLY + )) { + // Skip this line - don't add to cleanedLines + continue; + } + + // Apply phrase removal only to protected positions + let cleanedLine = line; + if (this.shouldCleanLine(index, lines.length, lines)) { + cleanedLine = cleanedLine.replace(new RegExp(PROMPT_CONSTANTS.JAPANESE.SPEAKER_ONLY.replace(/[()()]/g, '\\$&'), 'g'), ''); + } + + cleanedLines.push(cleanedLine); + } + + return cleanedLines.join('\n'); + } + + /** + * Apply English-specific cleaning patterns - using exact line matching with enhanced position guard for safety + */ + private applyEnglishCleaning(text: string): string { + // Split into lines for exact line matching + const lines = text.split('\n'); + const cleanedLines = []; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const trimmedLine = line.trim(); + + // Only remove lines that exactly match prompt instructions AND are in protected position + if (this.shouldCleanLine(index, lines.length, lines) && ( + trimmedLine === PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_1 || + trimmedLine === PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_1_CAPS || + trimmedLine === PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_2 || + trimmedLine === PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_2_CAPS || + trimmedLine === PROMPT_CONSTANTS.ENGLISH.OUTPUT_FORMAT || + trimmedLine === PROMPT_CONSTANTS.ENGLISH.OUTPUT_FORMAT_CAPS || + trimmedLine === PROMPT_CONSTANTS.ENGLISH.OUTPUT_FORMAT_FULL_COLON || + trimmedLine === PROMPT_CONSTANTS.ENGLISH.SPEAKER_ONLY || + trimmedLine === PROMPT_CONSTANTS.ENGLISH.SPEAKER_ONLY_CAPS + )) { + // Skip this line - don't add to cleanedLines + continue; + } + + // Apply phrase removal only to protected positions + let cleanedLine = line; + if (this.shouldCleanLine(index, lines.length, lines)) { + cleanedLine = cleanedLine.replace(/\(Speaker content only\)/g, ''); + cleanedLine = cleanedLine.replace(/\(SPEAKER CONTENT ONLY\)/g, ''); + } + + cleanedLines.push(cleanedLine); + } + + return cleanedLines.join('\n'); + } + + /** + * Apply Chinese-specific cleaning patterns - using exact line matching with enhanced position guard for safety + */ + private applyChineseCleaning(text: string): string { + // Split into lines for exact line matching + const lines = text.split('\n'); + const cleanedLines = []; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const trimmedLine = line.trim(); + + // Only remove lines that exactly match prompt instructions AND are in protected position + if (this.shouldCleanLine(index, lines.length, lines) && ( + trimmedLine === PROMPT_CONSTANTS.CHINESE.INSTRUCTION_1 || + trimmedLine === PROMPT_CONSTANTS.CHINESE.INSTRUCTION_2 || + trimmedLine === PROMPT_CONSTANTS.CHINESE.OUTPUT_FORMAT || + trimmedLine === PROMPT_CONSTANTS.CHINESE.OUTPUT_FORMAT_FULL_COLON || + trimmedLine === PROMPT_CONSTANTS.CHINESE.SPEAKER_ONLY + )) { + // Skip this line - don't add to cleanedLines + continue; + } + + // Apply phrase removal only to protected positions + let cleanedLine = line; + if (this.shouldCleanLine(index, lines.length, lines)) { + cleanedLine = cleanedLine.replace(/(仅说话者内容)/g, ''); + } + + cleanedLines.push(cleanedLine); + } + + return cleanedLines.join('\n'); + } + + /** + * Apply Korean-specific cleaning patterns - using exact line matching with enhanced position guard for safety + */ + private applyKoreanCleaning(text: string): string { + // Split into lines for exact line matching + const lines = text.split('\n'); + const cleanedLines = []; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const trimmedLine = line.trim(); + + // Only remove lines that exactly match prompt instructions AND are in protected position + if (this.shouldCleanLine(index, lines.length, lines) && ( + trimmedLine === PROMPT_CONSTANTS.KOREAN.INSTRUCTION_1 || + trimmedLine === PROMPT_CONSTANTS.KOREAN.INSTRUCTION_2 || + trimmedLine === PROMPT_CONSTANTS.KOREAN.OUTPUT_FORMAT || + trimmedLine === PROMPT_CONSTANTS.KOREAN.OUTPUT_FORMAT_FULL_COLON || + trimmedLine === PROMPT_CONSTANTS.KOREAN.SPEAKER_ONLY + )) { + // Skip this line - don't add to cleanedLines + continue; + } + + // Apply phrase removal only to protected positions + let cleanedLine = line; + if (this.shouldCleanLine(index, lines.length, lines)) { + cleanedLine = cleanedLine.replace(/(화자 발언만)/g, ''); + } + + cleanedLines.push(cleanedLine); + } + + return cleanedLines.join('\n'); + } + + /** + * Apply generic cleaning patterns (conservative approach) - using exact line matching with enhanced position guard for safety + */ + private applyGenericCleaning(text: string): string { + // Split into lines for exact line matching + const lines = text.split('\n'); + const cleanedLines = []; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const trimmedLine = line.trim(); + + // Only remove lines that exactly match generic format instructions AND are in protected position + if (this.shouldCleanLine(index, lines.length, lines) && ( + trimmedLine === PROMPT_CONSTANTS.GENERIC.OUTPUT_FORMAT || + trimmedLine === PROMPT_CONSTANTS.GENERIC.OUTPUT_FORMAT_FULL_COLON || + trimmedLine === PROMPT_CONSTANTS.GENERIC.FORMAT + )) { + // Skip this line - don't add to cleanedLines + continue; + } + + cleanedLines.push(line); + } + + return cleanedLines.join('\n'); + } + + /** + * Detect prompt error patterns by language + */ + private isPromptErrorDetected(text: string, language: string): boolean { + const normalizedLang = this.normalizeLanguage(language); + + switch (normalizedLang) { + case 'ja': + return text.includes('この指示文は出力に含めないでください') || + text.includes(PROMPT_CONSTANTS.JAPANESE.INSTRUCTION_2) || + text === PROMPT_CONSTANTS.JAPANESE.SPEAKER_ONLY || + text.trim() === '話者の発言のみ'; + case 'en': + return text.includes('Please transcribe only the speaker') || + text.includes('Do not include this instruction') || + text.trim() === PROMPT_CONSTANTS.ENGLISH.SPEAKER_ONLY || + text.trim() === PROMPT_CONSTANTS.ENGLISH.SPEAKER_ONLY_CAPS; + case 'zh': + return text.includes('请仅转录说话者') || + text.includes('不要包含此指令') || + text.trim() === PROMPT_CONSTANTS.CHINESE.SPEAKER_ONLY; + case 'ko': + return text.includes('화자의 발언만 전사해주세요') || + text.includes('이 지시사항을 포함하지 마세요') || + text.trim() === PROMPT_CONSTANTS.KOREAN.SPEAKER_ONLY; + default: + // For auto and other languages, use Japanese patterns as fallback + return text.includes('この指示文は出力に含めないでください') || + text.includes(PROMPT_CONSTANTS.JAPANESE.INSTRUCTION_2) || + text === PROMPT_CONSTANTS.JAPANESE.SPEAKER_ONLY || + text.trim() === '話者の発言のみ'; + } + } + /** * Estimate transcription cost */ @@ -276,7 +661,8 @@ export class TranscriptionService implements ITranscriptionProvider { * Set transcription correction enabled/disabled */ setTranscriptionCorrection(enabled: boolean) { - this.enableTranscriptionCorrection = enabled; + // Delegate to a single code path to keep states in sync + this.updateCorrectorSettings({ enabled }); } /** @@ -284,11 +670,11 @@ export class TranscriptionService implements ITranscriptionProvider { */ updateApiKey(apiKey: string) { this.apiKey = apiKey; - // API key is no longer needed for the simplified corrector - // Preserve existing dictionary settings when updating API key - const currentDict = this.corrector.getSettings().correctionDictionary; + // Preserve existing corrector settings when updating API key + const currentSettings = this.corrector.getSettings(); this.corrector = new DictionaryCorrector({ - correctionDictionary: currentDict + enabled: currentSettings.enabled, + correctionDictionary: currentSettings.correctionDictionary }); } diff --git a/src/core/transcription/cleaning/PromptContaminationCleaner.ts b/src/core/transcription/cleaning/PromptContaminationCleaner.ts new file mode 100644 index 0000000..896adf2 --- /dev/null +++ b/src/core/transcription/cleaning/PromptContaminationCleaner.ts @@ -0,0 +1,320 @@ +/** + * Universal prompt contamination cleaner + * Removes instruction prompts, XML tags, and context patterns + * Language-independent approach using structural patterns + */ + +import { CleaningResult, TextCleaner, CleaningContext } from './interfaces'; +import { CLEANING_CONFIG } from '../../../config/CleaningConfig'; + +// Simple logger interface for tests +interface SimpleLogger { + debug: (message: string, metadata?: any) => void; + warn: (message: string, metadata?: any) => void; +} + +// Fallback logger for test environments +const createFallbackLogger = (): SimpleLogger => ({ + debug: () => {/* no-op */}, + warn: () => {/* no-op */} +}); + +export class PromptContaminationCleaner implements TextCleaner { + readonly name = 'PromptContaminationCleaner'; + readonly enabled = true; + private logger: SimpleLogger; + + constructor() { + try { + // Try to use the real logger + const { createServiceLogger } = require('../../../services'); + this.logger = createServiceLogger('PromptContaminationCleaner'); + } catch { + // Fall back to no-op logger for tests + this.logger = createFallbackLogger(); + } + } + + clean(text: string, language: string, context?: CleaningContext): CleaningResult { + const original = text; + const startTime = performance.now(); + let cleaned = text; + const issues: string[] = []; + let patternsMatched = 0; + + const { instructionPatterns, contextPatterns, promptSnippetLengths } = + CLEANING_CONFIG.contamination; + + // Step 1: Handle XML tags (highest priority) + cleaned = this.removeXmlTags(cleaned); + if (cleaned !== text) { + patternsMatched++; + this.logger.debug('XML tags removed'); + } + + // Step 2: Remove leading prompt block (multi-line instructions at the very beginning) + const beforeInstructions = cleaned; + cleaned = this.removeLeadingPromptBlock(cleaned, instructionPatterns); + // Also apply exact instruction matches for residuals + cleaned = this.removeInstructionPatterns(cleaned, instructionPatterns); + if (cleaned !== beforeInstructions) { + patternsMatched++; + this.logger.debug('Instruction patterns removed'); + } + + // Step 3: Remove snippet matches (conservative) + const beforeSnippets = cleaned; + cleaned = this.removeSnippetPatterns(cleaned, instructionPatterns, promptSnippetLengths); + if (cleaned !== beforeSnippets) { + patternsMatched++; + this.logger.debug('Snippet patterns removed'); + } + + // Step 4: Apply context patterns + const beforeContext = cleaned; + cleaned = this.removeContextPatterns(cleaned, contextPatterns); + if (cleaned !== beforeContext) { + patternsMatched++; + this.logger.debug('Context patterns removed'); + } + + // Step 5: Clean up excessive whitespace + cleaned = this.normalizeWhitespace(cleaned); + + // Step 6: Final safeguard — remove any leftover exact instruction phrases anywhere (very conservative list) + // This catches cases where punctuation or line breaks prevented earlier head-only removal. + for (const pattern of instructionPatterns) { + try { + const escaped = pattern.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + const anywhere = new RegExp(`${escaped}(?:[\u3002\.::])?`, 'gi'); + cleaned = cleaned.replace(anywhere, ''); + } catch { + // ignore + } + } + cleaned = this.normalizeWhitespace(cleaned); + + const processingTime = performance.now() - startTime; + const reductionRatio = original.length > 0 ? (original.length - cleaned.length) / original.length : 0; + + // Add warnings for significant changes + if (reductionRatio > CLEANING_CONFIG.safety.warningThreshold) { + issues.push(`High reduction ratio: ${Math.round(reductionRatio * 100)}%`); + } + + if (context?.enableDetailedLogging) { + this.logger.debug('Prompt contamination cleaning completed', { + originalLength: original.length, + cleanedLength: cleaned.length, + reductionRatio: reductionRatio.toFixed(3), + patternsMatched, + processingTime: `${processingTime.toFixed(2)}ms` + }); + } + + return { + cleanedText: cleaned, + issues, + hasSignificantChanges: reductionRatio > 0.05, + metadata: { + reductionRatio, + patternsMatched, + processingTime + } + }; + } + + /** + * Remove XML tags using fixed patterns for TRANSCRIPT tags + */ + private removeXmlTags(text: string): string { + let cleaned = text; + + // First: Extract content from complete TRANSCRIPT tags (highest priority) + const completeTagMatch = cleaned.match(/]*>\s*([\s\S]*?)\s*<\/TRANSCRIPT>/); + if (completeTagMatch) { + cleaned = completeTagMatch[1].trim(); + } else { + // Second: Handle incomplete TRANSCRIPT tags (missing closing tag) + const openingMatch = cleaned.match(/]*>\s*([\s\S]*)/); + if (openingMatch) { + cleaned = openingMatch[1].trim(); + } + } + + // Third: Remove any remaining XML-like tags + cleaned = cleaned.replace(/<\/?TRANSCRIPT[^>]*>/g, ''); + cleaned = cleaned.replace(/<\/?transcript[^>]*>/gi, ''); + cleaned = cleaned.replace(/<\/?TRANSCRIPTION[^>]*>/gi, ''); + + // Remove standalone tags and empty XML tags + cleaned = cleaned.replace(/<[^>]*\/>/g, ''); + cleaned = cleaned.replace(/<\w+[^>]*>\s*<\/\w+>/g, ''); + + return cleaned; + } + + /** + * Remove consecutive instruction/context lines at the very beginning (until a non-instruction line appears) + */ + private removeLeadingPromptBlock(text: string, instructionPatterns: string[]): string { + const lines = text.split(/\r?\n/); + let cutIndex = 0; + + // Build quick regexes for instructions and context labels across languages + const escapedInstructions = instructionPatterns.map(p => p.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')); + // Identify a line as instruction if it STARTS with an instruction phrase (rest of line may include more prompts) + const instructionRegexes = escapedInstructions.map(e => new RegExp(`^\s*${e}`, 'i')); + const contextRegexes: RegExp[] = [ + /^\s*Output\s*format\s*:?\s*$/i, + /^\s*Format\s*:?\s*$/i, + /^\s*出力形式\s*:?\s*$/, + /^\s*输出格式\s*:?\s*$/, + /^\s*출력\s*형식\s*:?\s*$/, + /^\s*\(Speaker\s+content\s+only\)\s*$/i, + /^\s*(話者の発言のみ)\s*$/, + /^\s*(仅说话者内容)\s*$/, + /^\s*(화자\s*발언만)\s*$/ + ]; + + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + const trimmed = ln.trim(); + if (trimmed.length === 0) { cutIndex = i + 1; continue; } + const isInstruction = instructionRegexes.some(r => r.test(trimmed)); + const isContext = contextRegexes.some(r => r.test(trimmed)); + if (isInstruction || isContext) { + cutIndex = i + 1; + continue; + } + // stop when encountering first non-instruction/context line + break; + } + + return lines.slice(cutIndex).join('\n'); + } + + /** + * Remove instruction patterns from the beginning of text + */ + private removeInstructionPatterns(text: string, instructionPatterns: string[]): string { + let cleaned = text; + let changed = true; + // Greedily remove any known instruction that appears at the very beginning (allowing optional punctuation right after) + while (changed) { + changed = false; + for (const pattern of instructionPatterns) { + const escaped = pattern.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + // Match at start of string or start of any line (multiline), forgiving trailing punctuation + const reg = new RegExp(`^\\s*${escaped}(?:[\u3002\.::])?\\s*`, 'im'); + const before = cleaned; + cleaned = cleaned.replace(reg, ''); + if (cleaned !== before) { + changed = true; + } + } + } + return cleaned; + } + + /** + * Remove snippet patterns (partial matches with context) + */ + private removeSnippetPatterns(text: string, instructionPatterns: string[], snippetLengths: number[]): string { + let cleaned = text; + // Restrict snippet search to head region to minimize false positives + const MAX_SNIPPET_SEARCH_CHARS = 300; + const head = cleaned.slice(0, MAX_SNIPPET_SEARCH_CHARS); + let headMut = head; + + // Multilingual suffix lexicon for enhanced snippet detection (added JA) + const suffixLexicon = [ + /\b(please|do\s*not\s*include|only|content|output\s*format)\b/gi, // EN + /(请|請|不要|仅|只|内容|输出格式|輸出格式)/g, // ZH + /(해주세요|하지\s*마세요|포함하지\s*마세요|만|내용|출력\s*형식)/g, // KO + /(この指示文|指示文|出力形式|形式|内容|話者|のみ|含めないでください|含めないで)$/g // JA + ]; + + for (const pattern of instructionPatterns) { + for (const length of snippetLengths) { + if (pattern.length < length) continue; + + const snippet = pattern.slice(0, length); + // Escape special regex characters + const escapedSnippet = snippet.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + + // Enhanced multilingual contextual matching (head region only) + for (const suffixPattern of suffixLexicon) { + const contextRegex = new RegExp( + `${escapedSnippet}[^。.!?!?\\n]{0,50}(${suffixPattern.source})`, + suffixPattern.flags + ); + headMut = headMut.replace(contextRegex, ''); + } + + // Legacy pattern for Japanese (apply once within head) + const legacyJapaneseRegex = new RegExp( + `${escapedSnippet}[^。.!?!?\\n]{0,50}(?:ください|してください|です|ます)(?![\u3041-\u3096\u30A1-\u30FA\u4E00-\u9FFF])`, + 'i' + ); + headMut = headMut.replace(legacyJapaneseRegex, ''); + } + } + // Apply head mutations back to full text + cleaned = headMut + cleaned.slice(head.length); + return cleaned; + } + + /** + * Apply context patterns for general cleanup + */ + private removeContextPatterns(text: string, contextPatterns: string[]): string { + let cleaned = text; + + // 1) Apply patterns from configuration (strings like '/.../flags') + const compiled: RegExp[] = []; + for (const p of contextPatterns) { + try { + const m = p.match(/^\/(.*)\/([gimsuy]*)$/); + if (m) { + const [, body, flags] = m as RegExpMatchArray; + compiled.push(new RegExp(body, flags || 'g')); + } + } catch { + // ignore invalid patterns + } + } + for (const r of compiled) cleaned = cleaned.replace(r, ''); + + // 2) Built-in safe patterns + const speakerPatterns = [ + /\(Speaker content only\)/gi, + /\(SPEAKER CONTENT ONLY\)/gi, + /(話者の発言のみ)/g, + /(仅说话者内容)/g, + /(화자 발언만)/g + ]; + for (const pattern of speakerPatterns) cleaned = cleaned.replace(pattern, ''); + + const formatPatterns = [ + /^Output format:\s*$/gm, + /^Format:\s*$/gm, + /^出力形式:\s*$/gm, + /^输出格式:\s*$/gm, + /^출력 형식:\s*$/gm + ]; + for (const pattern of formatPatterns) cleaned = cleaned.replace(pattern, ''); + + return cleaned; + } + + /** + * Normalize whitespace while preserving structure + */ + private normalizeWhitespace(text: string): string { + return text + .replace(/\n{3,}/g, '\n\n') // Limit consecutive newlines + .replace(/^\s*\n/gm, '') // Remove lines with only whitespace + .trim(); + } +} diff --git a/src/core/transcription/cleaning/StandardCleaningPipeline.ts b/src/core/transcription/cleaning/StandardCleaningPipeline.ts new file mode 100644 index 0000000..307b5f5 --- /dev/null +++ b/src/core/transcription/cleaning/StandardCleaningPipeline.ts @@ -0,0 +1,257 @@ +/** + * Standard implementation of the cleaning pipeline + * Orchestrates multiple cleaners with safety monitoring and metrics collection + */ + +import { CleaningPipeline, CleaningContext, TextCleaner, SafetyCheckResult } from './interfaces'; +import { CLEANING_CONFIG } from '../../../config/CleaningConfig'; + +// Simple logger interface for tests +interface SimpleLogger { + info: (message: string, metadata?: any) => void; + debug: (message: string, metadata?: any) => void; + warn: (message: string, metadata?: any) => void; + error: (message: string, error?: any) => void; +} + +// Fallback logger for test environments +const createFallbackLogger = (): SimpleLogger => ({ + info: () => {/* no-op */}, + debug: () => {/* no-op */}, + warn: () => {/* no-op */}, + error: () => {/* no-op */} +}); + +export class StandardCleaningPipeline implements CleaningPipeline { + readonly name = 'StandardCleaningPipeline'; + private logger: SimpleLogger; + + constructor(private cleaners: TextCleaner[] = []) { + try { + // Try to use the real logger + const { createServiceLogger } = require('../../../services'); + this.logger = createServiceLogger('StandardCleaningPipeline'); + } catch { + // Fall back to no-op logger for tests + this.logger = createFallbackLogger(); + } + } + + async execute(text: string, language: string, context?: CleaningContext) { + const startTime = performance.now(); + const originalLength = text.length; + + const fullContext: CleaningContext = { + language, + originalLength, + startTime, + ...context + }; + + this.logger.info('Starting cleaning pipeline', { + originalLength, + language, + cleanerCount: this.cleaners.length, + enabledCleaners: this.cleaners.filter(c => c.enabled).length + }); + + let currentText = text; + const cleanerResults: Array<{ + cleanerName: string; + reductionRatio: number; + processingTime: number; + issues: string[]; + }> = []; + + // Execute each cleaner in sequence + for (const cleaner of this.cleaners) { + if (!cleaner.enabled) { + this.logger.debug(`Skipping disabled cleaner: ${cleaner.name}`); + continue; + } + + const cleanerStartTime = performance.now(); + const textBeforeCleaner = currentText; + + try { + this.logger.debug(`Executing cleaner: ${cleaner.name}`, { + inputLength: textBeforeCleaner.length + }); + + const result = await Promise.resolve( + cleaner.clean(textBeforeCleaner, language, fullContext) + ); + + // Safety check + const safetyCheck = this.performSafetyCheck( + textBeforeCleaner, + result.cleanedText, + cleaner.name + ); + + if (safetyCheck.action === 'rollback') { + this.logger.warn(`Rolling back cleaner ${cleaner.name}`, { + reason: safetyCheck.reason, + reductionRatio: safetyCheck.reductionRatio + }); + // Keep original text, don't apply this cleaner + } else if (safetyCheck.action === 'skip') { + this.logger.warn(`Skipping cleaner ${cleaner.name}`, { + reason: safetyCheck.reason + }); + // Keep original text, don't apply this cleaner + } else { + // Apply the cleaned text + currentText = result.cleanedText; + } + + const processingTime = performance.now() - cleanerStartTime; + const reductionRatio = textBeforeCleaner.length > 0 + ? (textBeforeCleaner.length - currentText.length) / textBeforeCleaner.length + : 0; + + cleanerResults.push({ + cleanerName: cleaner.name, + reductionRatio, + processingTime, + issues: result.issues + }); + + this.logger.debug(`Completed cleaner: ${cleaner.name}`, { + outputLength: currentText.length, + reductionRatio: reductionRatio.toFixed(3), + processingTime: `${processingTime.toFixed(2)}ms`, + issueCount: result.issues.length + }); + + // Log warnings for significant changes + if (reductionRatio > CLEANING_CONFIG.safety.warningThreshold) { + this.logger.warn(`High reduction ratio in ${cleaner.name}`, { + reductionRatio: reductionRatio.toFixed(3), + threshold: CLEANING_CONFIG.safety.warningThreshold + }); + } + + } catch (error) { + this.logger.error(`Error in cleaner ${cleaner.name}`, error); + cleanerResults.push({ + cleanerName: cleaner.name, + reductionRatio: 0, + processingTime: performance.now() - cleanerStartTime, + issues: [`Error: ${error instanceof Error ? error.message : String(error)}`] + }); + // Continue with the original text if a cleaner fails + } + } + + const totalProcessingTime = performance.now() - startTime; + const totalReductionRatio = originalLength > 0 + ? (originalLength - currentText.length) / originalLength + : 0; + + // Final safety check for the entire pipeline + const finalSafetyCheck = this.performSafetyCheck(text, currentText, 'Pipeline'); + if (finalSafetyCheck.action === 'rollback') { + this.logger.error('Pipeline safety failure - reverting to original text', { + reason: finalSafetyCheck.reason, + totalReductionRatio: finalSafetyCheck.reductionRatio + }); + currentText = text; + } + + this.logger.info('Cleaning pipeline completed', { + originalLength, + finalLength: currentText.length, + totalReductionRatio: totalReductionRatio.toFixed(3), + totalProcessingTime: `${totalProcessingTime.toFixed(2)}ms`, + cleanersExecuted: cleanerResults.length + }); + + return { + finalText: currentText, + metadata: { + totalOriginalLength: originalLength, + totalFinalLength: currentText.length, + totalReductionRatio, + cleanerResults + } + }; + } + + /** + * Perform safety checks on cleaning results + */ + private performSafetyCheck( + originalText: string, + cleanedText: string, + cleanerName: string + ): SafetyCheckResult { + const originalLength = originalText.length; + const cleanedLength = cleanedText.length; + + if (originalLength === 0) { + return { isSafe: true, action: 'proceed', reductionRatio: 0 }; + } + + const reductionRatio = (originalLength - cleanedLength) / originalLength; + + // Relax safety thresholds for structural cleaners that may legitimately + // remove large wrappers (e.g., TRANSCRIPT tags or full prompt lines). + // This prevents false rollbacks like when input is mostly XML wrappers. + const isStructuralCleaner = cleanerName === 'PromptContaminationCleaner' || cleanerName === 'Pipeline'; + const emergencyThreshold = isStructuralCleaner + ? Math.max(0.95, CLEANING_CONFIG.safety.emergencyFallbackThreshold) + : CLEANING_CONFIG.safety.emergencyFallbackThreshold; + const singleCleanerThreshold = isStructuralCleaner + ? Math.max(0.9, CLEANING_CONFIG.safety.singleCleanerMaxReduction) + : CLEANING_CONFIG.safety.singleCleanerMaxReduction; + + // Check against emergency fallback threshold + if (reductionRatio > emergencyThreshold) { + return { + isSafe: false, + reason: `Reduction ratio ${reductionRatio.toFixed(3)} exceeds emergency threshold ${emergencyThreshold}`, + action: 'rollback', + reductionRatio + }; + } + + // Check against single cleaner threshold + if (reductionRatio > singleCleanerThreshold) { + return { + isSafe: false, + reason: `Reduction ratio ${reductionRatio.toFixed(3)} exceeds single cleaner threshold ${singleCleanerThreshold}`, + action: 'skip', + reductionRatio + }; + } + + return { isSafe: true, action: 'proceed', reductionRatio }; + } + + /** + * Add a cleaner to the pipeline + */ + addCleaner(cleaner: TextCleaner): void { + this.cleaners.push(cleaner); + } + + /** + * Remove a cleaner from the pipeline + */ + removeCleaner(cleanerName: string): boolean { + const index = this.cleaners.findIndex(c => c.name === cleanerName); + if (index >= 0) { + this.cleaners.splice(index, 1); + return true; + } + return false; + } + + /** + * Get all cleaners in the pipeline + */ + getCleaners(): readonly TextCleaner[] { + return Object.freeze([...this.cleaners]); + } +} diff --git a/src/core/transcription/cleaning/UniversalRepetitionCleaner.ts b/src/core/transcription/cleaning/UniversalRepetitionCleaner.ts new file mode 100644 index 0000000..d454c8b --- /dev/null +++ b/src/core/transcription/cleaning/UniversalRepetitionCleaner.ts @@ -0,0 +1,459 @@ +/** + * Universal repetition cleaner - language-independent approach + * Handles character/token/phrase/sentence/paragraph/enumeration/tail repetitions + * Uses structural patterns rather than language-specific rules + */ + +import { CleaningResult, TextCleaner, CleaningContext } from './interfaces'; +import { CLEANING_CONFIG } from '../../../config/CleaningConfig'; + +// Simple logger interface for tests +interface SimpleLogger { + debug: (message: string, metadata?: any) => void; +} + +// Fallback logger for test environments +const createFallbackLogger = (): SimpleLogger => ({ + debug: () => {/* no-op */} +}); + +export class UniversalRepetitionCleaner implements TextCleaner { + readonly name = 'UniversalRepetitionCleaner'; + readonly enabled = true; + private logger: SimpleLogger; + + constructor() { + try { + // Try to use the real logger + const { createServiceLogger } = require('../../../services'); + this.logger = createServiceLogger('UniversalRepetitionCleaner'); + } catch { + // Fall back to no-op logger for tests + this.logger = createFallbackLogger(); + } + } + + clean(text: string, language: string, context?: CleaningContext): CleaningResult { + const original = text; + const startTime = performance.now(); + let cleaned = text; + const issues: string[] = []; + let totalReductionSteps = 0; + + // Safety check: don't process very short texts + if (text.length < 10) { + return { + cleanedText: text, + issues: [], + hasSignificantChanges: false, + metadata: { processingTime: performance.now() - startTime } + }; + } + + // Step 1: Character and symbol repetition suppression + const step1Result = this.suppressCharacterRepetitions(cleaned); + cleaned = step1Result.text; + totalReductionSteps += step1Result.changes; + + // Step 2: Token repetition suppression + const step2Result = this.suppressTokenRepetitions(cleaned, original.length, language); + cleaned = step2Result.text; + totalReductionSteps += step2Result.changes; + + // Step 3: Sentence repetition suppression + const step3Result = this.suppressSentenceRepetitions(cleaned); + cleaned = step3Result.text; + totalReductionSteps += step3Result.changes; + + // Step 4: Enumeration compression + if (CLEANING_CONFIG.repetition.enumerationDetection.enabled) { + const step4Result = this.compressEnumerations(cleaned); + cleaned = step4Result.text; + totalReductionSteps += step4Result.changes; + } + + // Step 5: Paragraph repetition suppression + if (CLEANING_CONFIG.repetition.paragraphRepeat.enabled) { + const step5Result = this.suppressParagraphRepetitions(cleaned); + cleaned = step5Result.text; + totalReductionSteps += step5Result.changes; + } + + // Step 6: Tail repetition suppression + const step6Result = this.suppressTailRepetitions(cleaned); + cleaned = step6Result.text; + totalReductionSteps += step6Result.changes; + + // Step 7: Final cleanup + cleaned = this.finalCleanup(cleaned); + + const processingTime = performance.now() - startTime; + const reductionRatio = original.length > 0 ? (original.length - cleaned.length) / original.length : 0; + + // Safety check: if reduction is too high, fall back to original + if (reductionRatio > CLEANING_CONFIG.safety.emergencyFallbackThreshold) { + issues.push(`Emergency fallback: excessive reduction (${Math.round(reductionRatio * 100)}%)`); + cleaned = original; + } + + if (context?.enableDetailedLogging) { + this.logger.debug('Universal repetition cleaning completed', { + originalLength: original.length, + cleanedLength: cleaned.length, + reductionRatio: reductionRatio.toFixed(3), + totalReductionSteps, + processingTime: `${processingTime.toFixed(2)}ms` + }); + } + + return { + cleanedText: cleaned, + issues, + hasSignificantChanges: reductionRatio > 0.05, + metadata: { + reductionRatio, + totalReductionSteps, + processingTime + } + }; + } + + /** + * Suppress character and symbol repetitions (language-independent) + */ + private suppressCharacterRepetitions(text: string): { text: string; changes: number } { + let result = text; + let changes = 0; + + // Define repetition patterns with their limits + const patterns = [ + { pattern: /([.!?])\1{5,}/g, replacement: '$1$1$1', description: 'punctuation' }, + { pattern: /([。!?])\1{3,}/g, replacement: '$1', description: 'cjk-punctuation' }, + { pattern: /[…]{3,}/g, replacement: '…', description: 'ellipsis' }, + { pattern: /[-—–]{6,}/g, replacement: '—', description: 'dashes' }, + { pattern: /[•·・]{6,}/g, replacement: '・', description: 'bullets' }, + { pattern: /[,,]{4,}/g, replacement: ',,', description: 'commas' }, + { pattern: /[\s]{4,}/g, replacement: ' ', description: 'spaces' } + ]; + + for (const { pattern, replacement } of patterns) { + const before = result; + result = result.replace(pattern, replacement); + if (result !== before) changes++; + } + + return { text: result, changes }; + } + + /** + * Suppress token repetitions using language-independent tokenization + */ + private suppressTokenRepetitions(text: string, originalLength: number, language?: string): { text: string; changes: number } { + const config = CLEANING_CONFIG.repetition; + let changes = 0; + + // Language-independent tokenization + const tokens = this.tokenizeText(text); + const tokenCounts = new Map(); + + // Count normalized tokens (case-insensitive, normalized) + for (const token of tokens) { + const normalizedToken = this.normalizeToken(token); + if (this.isSignificantToken(normalizedToken)) { + tokenCounts.set(normalizedToken, (tokenCounts.get(normalizedToken) || 0) + 1); + } + } + + // Calculate dynamic threshold based on text length + const dynamicThreshold = config.baseThreshold + + Math.floor(originalLength / config.dynamicThresholdDivisor) * config.lengthFactor; + + let result = text; + + // Reduce excessive repetitions + for (const [normalizedToken, count] of tokenCounts) { + if (count >= dynamicThreshold) { + const keepCount = Math.max(1, Math.floor(count * config.shortCharKeepRatio)); + const reductionNeeded = count - keepCount; + + // Create escape pattern for regex + const escapedToken = this.escapeRegex(normalizedToken); + // Use Unicode-aware boundaries for CJK languages where \b is ineffective + const isCJK = !!language && /^(ja|zh|ko)/i.test(language); + const pattern = isCJK + ? `(? { + removed++; + return removed <= reductionNeeded ? '' : match; + }); + + if (removed > 0) changes++; + } + } + + return { text: result, changes }; + } + + /** + * Suppress sentence repetitions using structural similarity + */ + private suppressSentenceRepetitions(text: string): { text: string; changes: number } { + const config = CLEANING_CONFIG.repetition; + let changes = 0; + + // Split into sentences using universal punctuation + const sentences = this.splitIntoSentences(text); + const result: string[] = []; + const seenSentences = new Map(); + // For near-duplicate detection using 3-gram Jaccard + const seenKeys: string[] = []; + + for (const sentence of sentences) { + const normalizedSentence = this.normalizeSentence(sentence); + + if (normalizedSentence.length >= config.minimumSentenceLengthForSimilarity) { + // Find an existing key that is identical or highly similar + let key = normalizedSentence; + let similarKey: string | null = null; + for (const k of seenKeys) { + if (k === normalizedSentence) { similarKey = k; break; } + const sim = this.jaccard3GramSimilarity(k, normalizedSentence); + if (sim >= config.similarityThreshold) { similarKey = k; break; } + } + if (similarKey) key = similarKey; else seenKeys.push(key); + + const count = seenSentences.get(key) || 0; + seenSentences.set(key, count + 1); + + // Only keep if under repetition threshold + if (count + 1 <= config.sentenceRepetition) { + result.push(sentence); + } else { + changes++; + } + } else { + // Always keep short sentences + result.push(sentence); + } + } + + return { text: result.join(''), changes }; + } + + /** + * Compute Jaccard similarity over 3-gram sets of two normalized strings + */ + private jaccard3GramSimilarity(a: string, b: string): number { + const aSet = this.toNGramSet(a, 3); + const bSet = this.toNGramSet(b, 3); + if (aSet.size === 0 && bSet.size === 0) return 1; + let inter = 0; + for (const g of aSet) if (bSet.has(g)) inter++; + const union = aSet.size + bSet.size - inter; + return union > 0 ? inter / union : 1; + } + + private toNGramSet(s: string, n: number): Set { + const set = new Set(); + const len = s.length; + if (len < n) { set.add(s); return set; } + for (let i = 0; i <= len - n; i++) set.add(s.slice(i, i + n)); + return set; + } + + /** + * Compress enumeration patterns (A,B,A,B... -> A,B) + */ + private compressEnumerations(text: string): { text: string; changes: number } { + const config = CLEANING_CONFIG.repetition.enumerationDetection; + let changes = 0; + + // Split by sentences and process each + const sentences = this.splitIntoSentences(text); + const result = sentences.map(sentence => { + // Detect common separators + const separators = [',', ';', '、', '·', '\t', /\s{2,}/]; + + for (const sep of separators) { + const sepRegex = sep instanceof RegExp ? sep : new RegExp(`\\s*${this.escapeRegex(sep)}\\s*`); + + // Check if sentence contains this separator + if (sepRegex.test(sentence)) { + const parts = sentence.split(sepRegex); + + if (parts.length >= config.minRepeatCount * 2) { + const compressed = this.detectAndCompressPattern(parts, config.minRepeatCount); + if (compressed.changed) { + changes++; + const sepStr = sep instanceof RegExp ? ' ' : (sep === '、' ? '、' : `${sep} `); + return compressed.parts.join(sepStr) + + (sentence.match(/[。.!?!?]+$/) ? sentence.match(/[。.!?!?]+$/)?.[0] || '' : ''); + } + } + } + } + + return sentence; + }); + + return { text: result.join(''), changes }; + } + + /** + * Suppress paragraph repetitions using fingerprinting + */ + private suppressParagraphRepetitions(text: string): { text: string; changes: number } { + const config = CLEANING_CONFIG.repetition.paragraphRepeat; + let changes = 0; + + const paragraphs = text.split(/\n\s*\n/); + const seenFingerprints = new Set(); + const result: string[] = []; + + for (const paragraph of paragraphs) { + const trimmed = paragraph.trim(); + if (trimmed.length === 0) continue; + + // Create fingerprint from first N characters + const fingerprint = this.normalizeSentence(trimmed.slice(0, config.headChars)); + + if (!seenFingerprints.has(fingerprint)) { + seenFingerprints.add(fingerprint); + result.push(paragraph); + } else { + changes++; + } + } + + return { text: result.join('\n\n'), changes }; + } + + /** + * Suppress tail repetitions (high self-repetition density at end) + */ + private suppressTailRepetitions(text: string): { text: string; changes: number } { + let changes = 0; + + // Analyze last 400 characters for repetition density + const tailLength = Math.min(400, text.length); + if (tailLength < 80) return { text, changes }; + + const tail = text.slice(-tailLength); + const diversity = this.calculateLexicalDiversity(tail); + const repetitionDensity = this.calculateRepetitionDensity(tail); + + // If tail has low diversity or high repetition, cut back to last sentence + if (diversity < 0.3 || repetitionDensity >= 2) { + const cutPoint = text.length - tailLength; + const beforeTail = text.slice(0, cutPoint); + + // Find last sentence boundary + const sentenceEnds = ['.', '。', '!', '!', '?', '?']; + let lastSentenceEnd = -1; + + for (let i = beforeTail.length - 1; i >= 0; i--) { + if (sentenceEnds.includes(beforeTail[i])) { + lastSentenceEnd = i; + break; + } + } + + if (lastSentenceEnd > 0) { + changes++; + return { text: beforeTail.slice(0, lastSentenceEnd + 1), changes }; + } + } + + return { text, changes }; + } + + /** + * Final cleanup of the text + */ + private finalCleanup(text: string): string { + return text + .replace(/\uFFFD+/g, '') // Remove replacement characters + .replace(/\n{3,}/g, '\n\n') // Normalize line breaks + .trim(); + } + + // Helper methods + + private tokenizeText(text: string): string[] { + // Universal tokenization: split on whitespace and punctuation + // Handle CJK characters as individual tokens when needed + return Array.from(text.matchAll(/\p{L}+|\p{N}+|\p{P}+|\s+/gu), m => m[0]); + } + + private normalizeToken(token: string): string { + return token.normalize('NFKC').toLowerCase().trim(); + } + + private isSignificantToken(token: string): boolean { + // Skip pure punctuation, whitespace, and very short tokens + return token.length > 0 && + !/^\p{P}+$|^\s+$|^[、。.!?!?…]+$/u.test(token) && + token.length >= 2; + } + + private splitIntoSentences(text: string): string[] { + // Universal sentence splitting using common punctuation, preserving the punctuation + return text.split(/(?<=[。.!?!?])\s*/); + } + + private normalizeSentence(sentence: string): string { + return sentence + .replace(/[、。,.;:!!??\s]/g, '') + .normalize('NFKC') + .toLowerCase(); + } + + private escapeRegex(str: string): string { + return str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + } + + private detectAndCompressPattern(parts: string[], minRepeat: number): { parts: string[]; changed: boolean } { + // Try different pattern lengths + for (let patternLength = 2; patternLength <= Math.floor(parts.length / minRepeat); patternLength++) { + const pattern = parts.slice(0, patternLength); + let matches = 1; + + // Check how many times this pattern repeats + for (let i = patternLength; i < parts.length; i += patternLength) { + const candidate = parts.slice(i, i + patternLength); + if (this.arraysEqual(pattern, candidate)) { + matches++; + } else { + break; + } + } + + if (matches >= minRepeat) { + return { parts: pattern, changed: true }; + } + } + + return { parts, changed: false }; + } + + private arraysEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((val, i) => val === b[i]); + } + + private calculateLexicalDiversity(text: string): number { + const tokens = this.tokenizeText(text).filter(t => this.isSignificantToken(this.normalizeToken(t))); + const uniqueTokens = new Set(tokens.map(t => this.normalizeToken(t))); + return tokens.length > 0 ? uniqueTokens.size / tokens.length : 1; + } + + private calculateRepetitionDensity(text: string): number { + // Count overlapping repetitive patterns + const repetitions = (text.match(/(.{2,20})\1{2,}/gs) || []).length; + return repetitions; + } +} diff --git a/src/core/transcription/cleaning/interfaces.ts b/src/core/transcription/cleaning/interfaces.ts new file mode 100644 index 0000000..d140fe8 --- /dev/null +++ b/src/core/transcription/cleaning/interfaces.ts @@ -0,0 +1,117 @@ +/** + * Core interfaces for the text cleaning pipeline + * Provides contracts for cleaners, results, and pipeline execution + */ + +/** + * Result of a text cleaning operation + */ +export interface CleaningResult { + /** The cleaned text */ + cleanedText: string; + /** List of issues or warnings encountered during cleaning */ + issues: string[]; + /** Whether significant changes were made */ + hasSignificantChanges: boolean; + /** Additional metadata about the cleaning operation */ + metadata?: Record; +} + +/** + * Context information for cleaning operations + */ +export interface CleaningContext { + /** Language code ('auto' for auto-detection) */ + language: string; + /** Original text length before any cleaning */ + originalLength: number; + /** Enable detailed logging for debugging */ + enableDetailedLogging?: boolean; + /** Original prompt used for transcription (if any) */ + originalPrompt?: string; + /** Processing start time for performance tracking */ + startTime?: number; +} + +/** + * Interface for text cleaning implementations + */ +export interface TextCleaner { + /** Unique name for this cleaner */ + readonly name: string; + /** Whether this cleaner is enabled */ + readonly enabled: boolean; + + /** + * Clean the given text + * @param text Text to clean + * @param language Language code + * @param context Additional context for cleaning + * @returns Cleaning result (can be async) + */ + clean(text: string, language: string, context?: CleaningContext): Promise | CleaningResult; +} + +/** + * Interface for cleaning pipeline implementations + */ +export interface CleaningPipeline { + /** Unique name for this pipeline */ + readonly name: string; + + /** + * Execute the complete cleaning pipeline + * @param text Text to clean + * @param language Language code + * @param context Additional context for cleaning + * @returns Final cleaned text with metadata + */ + execute(text: string, language: string, context?: CleaningContext): Promise<{ + finalText: string; + metadata: { + totalOriginalLength: number; + totalFinalLength: number; + totalReductionRatio: number; + cleanerResults?: Array<{ + cleanerName: string; + reductionRatio: number; + processingTime: number; + issues: string[]; + }>; + }; + }>; +} + +/** + * Safety check result for cleaning operations + */ +export interface SafetyCheckResult { + /** Whether the operation is safe to proceed */ + isSafe: boolean; + /** Reason for safety failure (if any) */ + reason?: string; + /** Suggested action */ + action: 'proceed' | 'skip' | 'rollback'; + /** Calculated reduction ratio */ + reductionRatio: number; +} + +/** + * Performance metrics for cleaning operations + */ +export interface CleaningMetrics { + /** Cleaner name */ + cleanerName: string; + /** Processing time in milliseconds */ + processingTime: number; + /** Original text length */ + originalLength: number; + /** Final text length */ + finalLength: number; + /** Reduction ratio (0.0-1.0) */ + reductionRatio: number; + /** Number of patterns matched */ + patternsMatched: number; + /** Issues encountered */ + issues: string[]; +} \ No newline at end of file diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 9049b18..cc7d80b 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -60,7 +60,8 @@ export const en: TranslationResource = { processing: { transcribing: 'Status: Transcribing...', correcting: 'Status: Correcting...', - completed: 'Status: Completed' + completed: 'Status: Completed', + waiting: 'waiting' }, transcription: { vadAutoStopped: 'Status: Auto-stopped due to silence', @@ -112,6 +113,12 @@ export const en: TranslationResource = { } }, ui: { + help: { + dictionaryFromComma: 'Enter multiple source patterns separated by commas (e.g., "pattern 1, pattern 2").' + }, + commands: { + openView: 'Open view' + }, buttons: { recordStart: 'Start Voice Input', recordStop: 'Stop Voice Input', @@ -140,7 +147,7 @@ export const en: TranslationResource = { }, titles: { main: 'Voice Input', - settings: 'Voice Input Settings', + settings: 'Voice Input Settings' }, settings: { apiKey: 'OpenAI API Key', @@ -153,8 +160,17 @@ export const en: TranslationResource = { transcriptionModelDesc: 'Select the model for voice recognition', maxRecordingDuration: 'Max Recording Duration', maxRecordingDurationDesc: 'Maximum recording time in seconds ({min}s - {max}min)', + language: 'Voice Recognition Language', + languageDesc: 'Language for voice recognition and transcription.', + transcriptionLanguage: 'Transcription Language', + transcriptionLanguageDesc: 'Language for voice recognition and transcription.', pluginLanguage: 'Plugin Language', - pluginLanguageDesc: 'Set language for UI, voice processing, and correction dictionary', + pluginLanguageDesc: 'Set language for UI display', + // Advanced settings + languageLinking: 'Link UI and recognition languages', + languageLinkingDesc: 'When enabled, recognition language follows UI language. When disabled, you can set recognition language independently.', + advancedTranscriptionLanguage: 'Recognition Language (Advanced)', + advancedTranscriptionLanguageDesc: 'Set the language for voice recognition independently.', customDictionary: 'Custom Dictionary', customDictionaryDesc: 'Manage corrections used for post-processing', dictionaryDefinite: 'Definite Corrections (max {max})', @@ -163,7 +179,12 @@ export const en: TranslationResource = { }, options: { modelMini: 'GPT-4o mini Transcribe', - modelFull: 'GPT-4o Transcribe' + modelFull: 'GPT-4o Transcribe', + languageAuto: 'Auto', + languageJa: 'Japanese', + languageEn: 'English', + languageZh: 'Chinese', + languageKo: 'Korean' }, tooltips: { copy: 'Copy to clipboard', @@ -182,6 +203,6 @@ export const en: TranslationResource = { fromMultiple: 'From (comma-separated)', to: 'To', context: 'Context Keywords' - }, + } } }; diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 0ae49c2..8d58f6a 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -60,7 +60,8 @@ export const ja: TranslationResource = { processing: { transcribing: 'ステータス: 文字起こし中...', correcting: 'ステータス: 清書中...', - completed: 'ステータス: 完了' + completed: 'ステータス: 完了', + waiting: '待機中' }, transcription: { vadAutoStopped: 'ステータス: 無音検出により自動停止しました', @@ -112,6 +113,12 @@ export const ja: TranslationResource = { } }, ui: { + help: { + dictionaryFromComma: '入力語はカンマ区切りで複数指定できます(例: パターン1, パターン2)。' + }, + commands: { + openView: 'ビューを開く' + }, buttons: { recordStart: '音声入力開始', recordStop: '音声入力停止', @@ -140,7 +147,7 @@ export const ja: TranslationResource = { }, titles: { main: 'Voice Input', - settings: 'Voice Input 設定', + settings: 'Voice Input 設定' }, settings: { apiKey: 'OpenAI API キー', @@ -153,8 +160,17 @@ export const ja: TranslationResource = { transcriptionModelDesc: '音声認識に使用するモデルを選択', maxRecordingDuration: '最大録音時間', maxRecordingDurationDesc: '最大録音時間(秒)({min}秒〜{max}分)', + language: '音声認識言語', + languageDesc: '音声認識と文字起こしの言語を設定します。', + transcriptionLanguage: '音声認識言語', + transcriptionLanguageDesc: '音声認識と文字起こしの言語を設定します。', pluginLanguage: 'プラグイン言語', pluginLanguageDesc: 'UI表示、音声認識処理、補正辞書の言語を設定', + // 高度設定 + languageLinking: 'UI言語と認識言語を連動する', + languageLinkingDesc: 'オンの場合、認識言語がUI言語に従います。オフの場合、認識言語を個別に設定できます。', + advancedTranscriptionLanguage: '認識言語(高度設定)', + advancedTranscriptionLanguageDesc: '音声認識の言語を個別に設定します。', customDictionary: 'カスタム辞書', customDictionaryDesc: '補正辞書を管理', dictionaryDefinite: '固定補正(最大{max}個)', @@ -163,7 +179,12 @@ export const ja: TranslationResource = { }, options: { modelMini: 'GPT-4o mini Transcribe', - modelFull: 'GPT-4o Transcribe' + modelFull: 'GPT-4o Transcribe', + languageAuto: '自動', + languageJa: '日本語', + languageEn: '英語', + languageZh: '中国語', + languageKo: '韓国語' }, tooltips: { copy: 'クリップボードにコピー', @@ -182,6 +203,6 @@ export const ja: TranslationResource = { fromMultiple: '入力語(カンマ区切り)', to: '修正語', context: '文脈キーワード' - }, + } } }; diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index a52ab9a..8b2aea4 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -60,7 +60,8 @@ export const ko: TranslationResource = { processing: { transcribing: '상태: 음성 텍스트 변환 중...', correcting: '상태: 텍스트 정리 중...', - completed: '상태: 완료' + completed: '상태: 완료', + waiting: '대기 중' }, transcription: { vadAutoStopped: '상태: 무음 감지로 자동 중지됨', @@ -112,6 +113,12 @@ export const ko: TranslationResource = { } }, ui: { + help: { + dictionaryFromComma: '입력어는 쉼표로 여러 패턴을 지정할 수 있습니다(예: 패턴1, 패턴2).' + }, + commands: { + openView: '뷰 열기' + }, buttons: { recordStart: '음성 입력 시작', recordStop: '음성 입력 중지', @@ -140,7 +147,7 @@ export const ko: TranslationResource = { }, titles: { main: 'Voice Input', - settings: 'Voice Input 설정', + settings: 'Voice Input 설정' }, settings: { apiKey: 'OpenAI API 키', @@ -153,8 +160,17 @@ export const ko: TranslationResource = { transcriptionModelDesc: '음성 인식에 사용할 모델 선택', maxRecordingDuration: '최대 녹음 시간', maxRecordingDurationDesc: '최대 녹음 시간(초) ({min}초~{max}분)', + language: '음성 인식 언어', + languageDesc: '음성 인식 및 전사를 위한 언어를 설정합니다.', + transcriptionLanguage: '음성 인식 언어', + transcriptionLanguageDesc: '음성 인식 및 전사를 위한 언어를 설정합니다.', pluginLanguage: '플러그인 언어', pluginLanguageDesc: 'UI 표시, 음성 처리 및 교정 사전의 언어 설정', + // 고급 설정 + languageLinking: 'UI 언어와 인식 언어 연동', + languageLinkingDesc: '활성화하면 인식 언어가 UI 언어를 따릅니다. 비활성화하면 인식 언어를 독립적으로 설정할 수 있습니다.', + advancedTranscriptionLanguage: '인식 언어 (고급 설정)', + advancedTranscriptionLanguageDesc: '음성 인식 언어를 독립적으로 설정합니다.', customDictionary: '사용자 정의 사전', customDictionaryDesc: '후처리에 사용되는 교정 사전 관리', dictionaryDefinite: '고정 교정 (최대 {max}개)', @@ -163,7 +179,12 @@ export const ko: TranslationResource = { }, options: { modelMini: 'GPT-4o mini Transcribe', - modelFull: 'GPT-4o Transcribe' + modelFull: 'GPT-4o Transcribe', + languageAuto: '자동', + languageJa: '일본어', + languageEn: '영어', + languageZh: '중국어', + languageKo: '한국어' }, tooltips: { copy: '클립보드에 복사', @@ -182,6 +203,6 @@ export const ko: TranslationResource = { fromMultiple: '입력어(쉼표구분)', to: '교정어', context: '문맥 키워드' - }, + } } }; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 724ac1a..99f7d77 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -60,7 +60,8 @@ export const zh: TranslationResource = { processing: { transcribing: '状态:语音转文字中...', correcting: '状态:文本处理中...', - completed: '状态:完成' + completed: '状态:完成', + waiting: '等待中' }, transcription: { vadAutoStopped: '状态:检测到静音自动停止', @@ -112,6 +113,12 @@ export const zh: TranslationResource = { } }, ui: { + help: { + dictionaryFromComma: '“来源词”支持用逗号分隔的多个模式(例如:模式1, 模式2)。' + }, + commands: { + openView: '打开视图' + }, buttons: { recordStart: '开始语音输入', recordStop: '停止语音输入', @@ -140,7 +147,7 @@ export const zh: TranslationResource = { }, titles: { main: 'Voice Input', - settings: 'Voice Input 设置', + settings: 'Voice Input 设置' }, settings: { apiKey: 'OpenAI API 密钥', @@ -153,8 +160,17 @@ export const zh: TranslationResource = { transcriptionModelDesc: '选择用于语音识别的模型', maxRecordingDuration: '最大录音时长', maxRecordingDurationDesc: '最大录音时间(秒)({min}秒~{max}分钟)', + language: '语音识别语言', + languageDesc: '设置用于语音识别和转录的语言。', + transcriptionLanguage: '语音识别语言', + transcriptionLanguageDesc: '设置用于语音识别和转录的语言。', pluginLanguage: '插件语言', pluginLanguageDesc: '设置UI显示、语音处理和校正词典的语言', + // 高级设置 + languageLinking: '关联UI语言与识别语言', + languageLinkingDesc: '启用时,识别语言跟随UI语言。禁用时,可独立设置识别语言。', + advancedTranscriptionLanguage: '识别语言(高级设置)', + advancedTranscriptionLanguageDesc: '独立设置语音识别的语言。', customDictionary: '自定义词典', customDictionaryDesc: '管理用于后处理的校正词典', dictionaryDefinite: '固定校正(最多{max}个)', @@ -163,7 +179,12 @@ export const zh: TranslationResource = { }, options: { modelMini: 'GPT-4o mini Transcribe', - modelFull: 'GPT-4o Transcribe' + modelFull: 'GPT-4o Transcribe', + languageAuto: '自动', + languageJa: '日语', + languageEn: '英语', + languageZh: '中文', + languageKo: '韩语' }, tooltips: { copy: '复制到剪贴板', @@ -182,6 +203,6 @@ export const zh: TranslationResource = { fromMultiple: '输入词(逗号分隔)', to: '校正词', context: '语境关键词' - }, + } } }; diff --git a/src/interfaces/i18n.ts b/src/interfaces/i18n.ts index 355a624..e2f18eb 100644 --- a/src/interfaces/i18n.ts +++ b/src/interfaces/i18n.ts @@ -80,6 +80,7 @@ export type TranslationResource = { transcribing: string; correcting: string; completed: string; + waiting: string; }; transcription: { vadAutoStopped: string; @@ -131,6 +132,12 @@ export type TranslationResource = { }; }; ui: { + help: { + dictionaryFromComma: string; + }; + commands: { + openView: string; + }; buttons: { recordStart: string; recordStop: string; @@ -172,8 +179,17 @@ export type TranslationResource = { transcriptionModelDesc: string; maxRecordingDuration: string; maxRecordingDurationDesc: string; + language: string; + languageDesc: string; + transcriptionLanguage: string; + transcriptionLanguageDesc: string; pluginLanguage: string; pluginLanguageDesc: string; + // Advanced settings + languageLinking: string; + languageLinkingDesc: string; + advancedTranscriptionLanguage: string; + advancedTranscriptionLanguageDesc: string; customDictionary: string; customDictionaryDesc: string; dictionaryDefinite: string; @@ -183,6 +199,11 @@ export type TranslationResource = { options: { modelMini: string; modelFull: string; + languageAuto: string; + languageJa: string; + languageEn: string; + languageZh: string; + languageKo: string; }; tooltips: { copy: string; diff --git a/src/interfaces/settings.ts b/src/interfaces/settings.ts index 045221f..082e94f 100644 --- a/src/interfaces/settings.ts +++ b/src/interfaces/settings.ts @@ -8,12 +8,18 @@ export interface VoiceInputSettings { transcriptionModel: 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe'; // 録音設定 maxRecordingSeconds: number; // 最大録音時間(秒) - // プラグイン言語設定 - pluginLanguage: Locale; // プラグイン全体の言語(UI、音声認識、補正辞書) + // 言語設定 + transcriptionLanguage: 'ja' | 'en' | 'zh' | 'ko'; // 音声認識言語 + pluginLanguage: Locale; // プラグインUI表示の言語 customDictionary: SimpleCorrectionDictionary; // デバッグ設定 debugMode: boolean; // デバッグモード logLevel: LogLevel; // ログレベル + // 高度設定 + advanced: { + languageLinkingEnabled: boolean; // UI言語と認識言語を連動する(デフォルト: true) + transcriptionLanguage?: 'ja' | 'en' | 'zh' | 'ko'; // 独立した音声認識言語設定 + }; } export const DEFAULT_SETTINGS: VoiceInputSettings = { @@ -22,10 +28,16 @@ export const DEFAULT_SETTINGS: VoiceInputSettings = { transcriptionModel: 'gpt-4o-transcribe', // 録音設定 maxRecordingSeconds: 300, // 5分(300秒) - // プラグイン言語はObsidianの設定に従う(loadSettingsで設定) + // 言語設定 + transcriptionLanguage: 'en', // 初期値(実際は起動時に環境ロケールへ移行) pluginLanguage: 'en', // 初期値、実際はObsidianの設定に従う customDictionary: { definiteCorrections: [] }, // デバッグ設定 debugMode: false, // 本番環境ではデフォルトでオフ - logLevel: LogLevel.INFO // 通常レベル + logLevel: LogLevel.INFO, // 通常レベル + // 高度設定 + advanced: { + languageLinkingEnabled: true, + transcriptionLanguage: 'en' + } }; diff --git a/src/plugin/VoiceInputPlugin.ts b/src/plugin/VoiceInputPlugin.ts index 7463fee..a58d3ad 100644 --- a/src/plugin/VoiceInputPlugin.ts +++ b/src/plugin/VoiceInputPlugin.ts @@ -110,7 +110,7 @@ export default class VoiceInputPlugin extends Plugin { // Add command this.addCommand({ id: 'open-voice-input', - name: i18n.t('ui.titles.main'), + name: i18n.t('ui.commands.openView'), callback: () => { this.logger.debug('Command executed: open-voice-input'); this.viewManager.activateVoiceInputView(); @@ -221,16 +221,38 @@ export default class VoiceInputPlugin extends Plugin { this.logger?.info('Migrating interfaceLanguage to pluginLanguage'); } - // languageからpluginLanguageへの移行 - if ('language' in data && !('pluginLanguage' in data)) { - // 言語コードを正規化(ja → ja、en → en、その他 → en) - const langCode = data.language as string; - migratedData.pluginLanguage = (langCode === 'ja' || langCode === 'en') ? langCode : 'en'; + // languageからtranscriptionLanguageへの移行(autoは検出ロケールへ変換) + if ('language' in data && !('transcriptionLanguage' in data)) { + // 既存のlanguageフィールドをtranscriptionLanguageに移行 + const langValue = data.language; + if (langValue === 'ja' || langValue === 'en' || langValue === 'zh' || langValue === 'ko') { + migratedData.transcriptionLanguage = langValue; + } else if (langValue === 'auto') { + // auto は廃止: 起動環境のロケールへ固定 + migratedData.transcriptionLanguage = this.detectPluginLanguage(); + } else { + migratedData.transcriptionLanguage = this.detectPluginLanguage(); + } delete migratedData.language; needsSave = true; + this.logger?.info(`Migrating language (${data.language}) to transcriptionLanguage (${migratedData.transcriptionLanguage})`); + } + + // languageからpluginLanguageへの移行(古いバージョンとの互換性のため) + if ('language' in data && !('pluginLanguage' in data)) { + // 言語コードを正規化(ja → ja、en → en、zh → zh、ko → ko、その他 → en) + const langCode = data.language as string; + if (langCode === 'ja' || langCode === 'en' || langCode === 'zh' || langCode === 'ko') { + migratedData.pluginLanguage = langCode; + } else { + migratedData.pluginLanguage = 'en'; + } + needsSave = true; this.logger?.info(`Migrating language (${data.language}) to pluginLanguage (${migratedData.pluginLanguage})`); - } else if ('language' in data) { - // pluginLanguageが既に存在する場合は、languageフィールドを削除 + } + + // 不要になったlanguageフィールドの削除 + if ('language' in data) { delete migratedData.language; needsSave = true; this.logger?.info('Removing redundant language field'); @@ -298,17 +320,44 @@ export default class VoiceInputPlugin extends Plugin { // pluginLanguageが設定されていない場合 if (!hasSettingsKey(data, 'pluginLanguage') && !hasSettingsKey(data, 'interfaceLanguage')) { - const obsidianLocale = this.getObsidianLocale(); - this.settings.pluginLanguage = obsidianLocale.startsWith('ja') ? 'ja' : 'en'; + this.settings.pluginLanguage = this.detectPluginLanguage(); needsSave = true; - this.logger?.info(`Auto-detected language: ${this.settings.pluginLanguage} (from Obsidian: ${obsidianLocale})`); + this.logger?.info(`Auto-detected language: ${this.settings.pluginLanguage} (from Obsidian: ${getObsidianLocale(this.app)})`); + } + + // 高度設定のマイグレーション(auto を廃止) + if (!hasSettingsKey(data, 'advanced')) { + // 既存ユーザーには言語連動をデフォルトで有効化(現行動作維持) + this.settings.advanced = { + languageLinkingEnabled: true, + transcriptionLanguage: this.detectPluginLanguage() + }; + needsSave = true; + this.logger?.info('Initialized advanced settings with language linking enabled for backward compatibility'); + } else if (data.advanced && !hasSettingsKey(data.advanced, 'languageLinkingEnabled')) { + // advancedオブジェクトは存在するが、languageLinkingEnabledが無い場合 + this.settings.advanced.languageLinkingEnabled = true; + needsSave = true; + this.logger?.info('Added languageLinkingEnabled to existing advanced settings'); + } else if (data.advanced) { + // auto からの置換 + const adv = data.advanced as { transcriptionLanguage?: string }; + if (adv.transcriptionLanguage === 'auto') { + this.settings.advanced.transcriptionLanguage = this.detectPluginLanguage(); + needsSave = true; + this.logger?.info('Migrated advanced.transcriptionLanguage from auto to detected locale'); + } } } else { // 保存データが存在しない場合(初回起動) - const obsidianLocale = this.getObsidianLocale(); - this.settings.pluginLanguage = obsidianLocale.startsWith('ja') ? 'ja' : 'en'; + this.settings.pluginLanguage = this.detectPluginLanguage(); + this.settings.transcriptionLanguage = this.detectPluginLanguage(); + this.settings.advanced = { + languageLinkingEnabled: true, + transcriptionLanguage: this.detectPluginLanguage() + }; needsSave = true; - this.logger?.info(`First run - auto-detected language: ${this.settings.pluginLanguage}`); + this.logger?.info(`First run - detected locale: ${this.settings.pluginLanguage}, transcriptionLanguage set to detected locale, advanced settings initialized`); } // 必要に応じて設定を保存 @@ -327,10 +376,34 @@ export default class VoiceInputPlugin extends Plugin { } /** - * Obsidianの言語設定を取得 - */ - private getObsidianLocale(): string { - return getObsidianLocale(this.app); + * Plugin language auto detection (ja/zh/ko/en) + */ + private detectPluginLanguage(): 'ja' | 'zh' | 'ko' | 'en' { + const obsidianLocale = getObsidianLocale(this.app).toLowerCase(); + if (obsidianLocale.startsWith('ja')) { + return 'ja'; + } else if (obsidianLocale.startsWith('zh')) { + return 'zh'; + } else if (obsidianLocale.startsWith('ko')) { + return 'ko'; + } else { + return 'en'; + } + } + + /** + * 解決済み言語を取得(高度設定の連動設定に基づく) + * auto は廃止済みのため、常に具体的な言語コードを返す + */ + getResolvedLanguage(): 'ja' | 'zh' | 'ko' | 'en' { + // 言語連動が無効な場合: advanced.transcriptionLanguage を優先 + if (this.settings.advanced?.languageLinkingEnabled === false) { + const advancedLang = this.settings.advanced?.transcriptionLanguage; + return (advancedLang ?? this.detectPluginLanguage()) as 'ja' | 'zh' | 'ko' | 'en'; + } + // 言語連動が有効: 通常の transcriptionLanguage を使用 + const baseLang = this.settings.transcriptionLanguage; + return (baseLang ?? this.detectPluginLanguage()) as 'ja' | 'zh' | 'ko' | 'en'; } async saveSettings() { @@ -341,6 +414,13 @@ export default class VoiceInputPlugin extends Plugin { }; await this.saveData(dataToSave); + // Broadcast settings-changed event for open views to react immediately (UI + services) + try { + // Custom workspace event (safe to ignore if not observed) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.app.workspace as any).trigger?.('voice-input:settings-changed', { settings: this.settings }); + } catch (_) { /* noop */ } + // Update logger configuration based on new settings Logger.getInstance().updateConfig({ debugMode: this.settings.debugMode, diff --git a/src/security/SecurityUtils.ts b/src/security/SecurityUtils.ts index 03420da..5d2edb1 100644 --- a/src/security/SecurityUtils.ts +++ b/src/security/SecurityUtils.ts @@ -1,4 +1,5 @@ import { ObsidianHttpClient } from '../utils/ObsidianHttpClient'; +import { getI18n } from '../services'; export class SecurityUtils { /** @@ -64,6 +65,8 @@ export class SecurityUtils { * Test OpenAI API key by making a simple API call */ static async testOpenAIAPIKey(apiKey: string): Promise<{ valid: boolean; error?: string }> { + const i18n = getI18n(); + try { const { status } = await ObsidianHttpClient.request({ url: 'https://api.openai.com/v1/models', @@ -74,14 +77,14 @@ export class SecurityUtils { if (status >= 200 && status < 300) { return { valid: true }; } else if (status === 401) { - return { valid: false, error: 'APIキーが無効です' }; + return { valid: false, error: i18n.t('error.api.unauthorized') }; } else if (status === 429) { - return { valid: false, error: 'レート制限に達しています' }; + return { valid: false, error: i18n.t('error.api.rateLimited') }; } else { - return { valid: false, error: `エラー: HTTP ${status}` }; + return { valid: false, error: `${i18n.t('error.general.error')}: HTTP ${status}` }; } } catch (_error) { - return { valid: false, error: 'ネットワークエラー' }; + return { valid: false, error: i18n.t('error.general.network') }; } } } diff --git a/src/settings/VoiceInputSettingTab.ts b/src/settings/VoiceInputSettingTab.ts index 56d338e..12aecb2 100644 --- a/src/settings/VoiceInputSettingTab.ts +++ b/src/settings/VoiceInputSettingTab.ts @@ -56,7 +56,22 @@ export class VoiceInputSettingTab extends PluginSettingTab { )) .setValue(this.plugin.settings.pluginLanguage) .onChange(async (value: Locale) => { + const transcriptionLocale = value as 'ja' | 'en' | 'zh' | 'ko'; this.plugin.settings.pluginLanguage = value; + + // Keep transcription language synchronized while linking remains enabled + if (this.plugin.settings.advanced?.languageLinkingEnabled !== false) { + this.plugin.settings.transcriptionLanguage = transcriptionLocale; + if (!this.plugin.settings.advanced) { + this.plugin.settings.advanced = { + languageLinkingEnabled: true, + transcriptionLanguage: transcriptionLocale + }; + } else { + this.plugin.settings.advanced.transcriptionLanguage = transcriptionLocale; + } + } + await this.plugin.saveSettings(); this.i18n.setLocale(value); @@ -73,6 +88,51 @@ export class VoiceInputSettingTab extends PluginSettingTab { this.display(); })); + // Advanced Language Settings + new Setting(containerEl) + .setName(this.i18n.t('ui.settings.languageLinking')) + .setDesc(this.i18n.t('ui.settings.languageLinkingDesc')) + .addToggle(toggle => toggle + .setValue(this.plugin.settings.advanced?.languageLinkingEnabled !== false) + .onChange(async (value) => { + // Initialize advanced object if it doesn't exist + if (!this.plugin.settings.advanced) { + this.plugin.settings.advanced = { + languageLinkingEnabled: value, + transcriptionLanguage: this.plugin.getResolvedLanguage() + }; + } else { + this.plugin.settings.advanced.languageLinkingEnabled = value; + } + await this.plugin.saveSettings(); + // Refresh the UI to show/hide the advanced transcription language setting + this.display(); + })); + + // Advanced Transcription Language Setting (only shown when linking is disabled) + if (this.plugin.settings.advanced?.languageLinkingEnabled === false) { + new Setting(containerEl) + .setName(this.i18n.t('ui.settings.advancedTranscriptionLanguage')) + .setDesc(this.i18n.t('ui.settings.advancedTranscriptionLanguageDesc')) + .addDropdown(dropdown => dropdown + .addOption('ja', this.i18n.t('ui.options.languageJa')) + .addOption('en', this.i18n.t('ui.options.languageEn')) + .addOption('zh', this.i18n.t('ui.options.languageZh')) + .addOption('ko', this.i18n.t('ui.options.languageKo')) + .setValue(this.plugin.settings.advanced.transcriptionLanguage ?? this.plugin.getResolvedLanguage()) + .onChange(async (value: 'ja' | 'en' | 'zh' | 'ko') => { + if (!this.plugin.settings.advanced) { + this.plugin.settings.advanced = { + languageLinkingEnabled: false, + transcriptionLanguage: value + }; + } else { + this.plugin.settings.advanced.transcriptionLanguage = value; + } + await this.plugin.saveSettings(); + })); + } + // OpenAI API Key const apiKeySetting = new Setting(containerEl) .setName(this.i18n.t('ui.settings.apiKey')) @@ -174,7 +234,7 @@ export class VoiceInputSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - // AI Post-processing Toggle and Model Selection + // AI Post-processing (dictionary-based) new Setting(containerEl) .setName(this.i18n.t('ui.settings.aiPostProcessing')) .setDesc(this.i18n.t('ui.settings.aiPostProcessingDesc')) @@ -215,34 +275,36 @@ export class VoiceInputSettingTab extends PluginSettingTab { this.display(); // Refresh UI })); - // Dictionary (Unified table editor) - Only show for Japanese - if (this.plugin.settings.pluginLanguage === 'ja') { - new Setting(containerEl) - .setName(this.i18n.t('ui.settings.customDictionary')) - .setDesc(this.i18n.t('ui.settings.customDictionaryDesc')); + // Dictionary (Unified table editor) - Show for all languages + new Setting(containerEl) + .setName(this.i18n.t('ui.settings.customDictionary')) + .setDesc(this.i18n.t('ui.settings.customDictionaryDesc')); - // Create table container for dictionary tables - const tableContainer = containerEl.createDiv('dictionary-table-container'); + // Create table container for dictionary tables + const tableContainer = containerEl.createDiv('dictionary-table-container'); - // Definite Corrections Section - tableContainer.createEl('h4', { text: this.i18n.t('ui.settings.dictionaryDefinite', { max: DICTIONARY_CONSTANTS.MAX_DEFINITE_CORRECTIONS }) }); - this.createCorrectionTable( - tableContainer, - this.plugin.settings.customDictionary.definiteCorrections, - false - ); + // Definite Corrections Section + tableContainer.createEl('h4', { text: this.i18n.t('ui.settings.dictionaryDefinite', { max: DICTIONARY_CONSTANTS.MAX_DEFINITE_CORRECTIONS }) }); + tableContainer.createEl('div', { + cls: 'setting-item-description', + text: this.i18n.t('ui.help.dictionaryFromComma') + }); + this.createCorrectionTable( + tableContainer, + this.plugin.settings.customDictionary.definiteCorrections, + false + ); - // Import/Export buttons - new Setting(containerEl) - .setName(this.i18n.t('ui.settings.dictionaryImportExport')) - .setDesc(this.i18n.t('ui.settings.dictionaryImportExportDesc')) - .addButton(button => button - .setButtonText(this.i18n.t('ui.buttons.export')) - .onClick(() => this.exportDictionary())) - .addButton(button => button - .setButtonText(this.i18n.t('ui.buttons.import')) - .onClick(() => this.importDictionary())); - } + // Import/Export buttons + new Setting(containerEl) + .setName(this.i18n.t('ui.settings.dictionaryImportExport')) + .setDesc(this.i18n.t('ui.settings.dictionaryImportExportDesc')) + .addButton(button => button + .setButtonText(this.i18n.t('ui.buttons.export')) + .onClick(() => this.exportDictionary())) + .addButton(button => button + .setButtonText(this.i18n.t('ui.buttons.import')) + .onClick(() => this.importDictionary())); // Debug Settings Section - Removed as per ai-transcriber pattern // containerEl.createEl('h3', { text: this.i18n.t('ui.titles.debugSettings') || 'Debug Settings' }); diff --git a/src/styles/voice-input.css b/src/styles/voice-input.css index 3c44b9c..5d9d55b 100644 --- a/src/styles/voice-input.css +++ b/src/styles/voice-input.css @@ -117,9 +117,9 @@ padding: 54px 12px; font-size: 20px; font-weight: 600; - background: #f9e79f; /* Soft yellow */ - color: var(--text-normal); - border: 1px solid #f4d03f; + background: var(--background-secondary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); border-radius: 4px; cursor: pointer; transition: background-color 0.2s; @@ -130,7 +130,7 @@ } .voice-input-cancel-button-full:hover { - background: #f4d03f; /* Slightly darker yellow on hover */ + background: var(--background-modifier-hover); } @@ -242,7 +242,7 @@ font-size: 14px; } -.setting-item-description { +.voice-input-settings-panel .setting-item-description { padding: 8px 0; font-size: 13px; color: var(--text-muted); @@ -452,4 +452,4 @@ .voice-input-textarea-wide { width: 100% !important; min-height: 100px !important; -} \ No newline at end of file +} diff --git a/src/views/VoiceInputView.ts b/src/views/VoiceInputView.ts index 3962f47..db1bb77 100644 --- a/src/views/VoiceInputView.ts +++ b/src/views/VoiceInputView.ts @@ -67,6 +67,20 @@ export class VoiceInputView extends ItemView { this.setupFocusManagement(); this.setupAutoSaveOnBlur(); this.setupPeriodicSave(); + + // Subscribe to settings change events to keep UI toggles in sync + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ws: any = this.app.workspace as any; + if (ws?.on) { + const ref = ws.on('voice-input:settings-changed', () => { + this.ui?.updateSettingsUI(); + this.actions?.updateTranscriptionService(); + }); + // Ensure cleanup via plugin's registerEvent lifecycle + this.plugin.registerEvent(ref); + } + } catch (_) { /* noop */ } } } diff --git a/src/views/VoiceInputViewActions.ts b/src/views/VoiceInputViewActions.ts index 430f0fa..be70be7 100644 --- a/src/views/VoiceInputViewActions.ts +++ b/src/views/VoiceInputViewActions.ts @@ -28,6 +28,8 @@ export class VoiceInputViewActions { processingQueue: [] }; private isProcessingAudio = false; + // 連打や高速操作による並行実行を防ぐための遷移ロック + private isTransitioning = false; private statusTimer: NodeJS.Timeout | null = null; private clearConfirmTimer: NodeJS.Timeout | null = null; private clearPressCount = 0; @@ -113,10 +115,20 @@ export class VoiceInputViewActions { * Toggle recording on/off */ async toggleRecording() { + if (this.isTransitioning) return; // 二重操作防止 + this.isTransitioning = true; if (this.audioRecorder && this.audioRecorder.isActive()) { - await this.stopRecording(); + try { + await this.stopRecording(); + } finally { + this.isTransitioning = false; + } } else { - await this.startRecording(); + try { + await this.startRecording(); + } finally { + this.isTransitioning = false; + } } } @@ -398,7 +410,7 @@ export class VoiceInputViewActions { // When processing, the first item is being processed, others are waiting const waitingCount = queueLength > 0 ? queueLength - 1 : 0; if (waitingCount > 0) { - statusText += ` (${waitingCount} 待機中)`; + statusText += ` (${waitingCount} ${this.i18n.t('status.processing.waiting')})`; } this.view.ui.statusEl.setText(statusText); this.view.ui.statusEl.addClass('processing'); @@ -455,7 +467,7 @@ export class VoiceInputViewActions { if (!this.transcriptionService) { throw new Error('Transcription service not available'); } - const result = await this.transcriptionService.transcribeAudio(audioBlob, this.plugin.settings.pluginLanguage); + const result = await this.transcriptionService.transcribeAudio(audioBlob, this.plugin.getResolvedLanguage()); // Check if result is empty if (!result.text || result.text.trim() === '') { @@ -603,7 +615,10 @@ export class VoiceInputViewActions { * Cancel recording without processing */ async cancelRecording() { + if (this.isTransitioning) return; // 二重キャンセル防止 + this.isTransitioning = true; if (!this.audioRecorder || !this.audioRecorder.isActive()) { + this.isTransitioning = false; return; } @@ -628,6 +643,8 @@ export class VoiceInputViewActions { this.logger.error('Error cancelling recording', error); this.updateUIAfterError(); this.view.ui.setButtonsEnabled(true); + } finally { + this.isTransitioning = false; } } diff --git a/src/views/VoiceInputViewUI.ts b/src/views/VoiceInputViewUI.ts index 94a316d..9e13380 100644 --- a/src/views/VoiceInputViewUI.ts +++ b/src/views/VoiceInputViewUI.ts @@ -231,7 +231,6 @@ export class VoiceInputViewUI { this.plugin.settings.enableTranscriptionCorrection = value; await this.plugin.saveSettings(); this.view.actions.updateTranscriptionService(); - // Enable/disable the model dropdown }); }); } @@ -378,7 +377,7 @@ export class VoiceInputViewUI { this.transcriptionModelDropdown.setValue(this.plugin.settings.transcriptionModel); } - // Update post-processing model dropdown + // (no additional post-processing UI elements to update) } /** diff --git a/tests/language-detection.test.ts b/tests/language-detection.test.ts new file mode 100644 index 0000000..ae1acd7 --- /dev/null +++ b/tests/language-detection.test.ts @@ -0,0 +1,201 @@ +import { getObsidianLocale } from '../src/types'; + +// Mock to test language detection logic +jest.mock('../src/types', () => ({ + getObsidianLocale: jest.fn() +})); + +// Extract the detection logic for isolated testing +function detectPluginLanguage(getObsidianLocaleFn: () => string): 'ja' | 'zh' | 'ko' | 'en' { + const obsidianLocale = getObsidianLocaleFn().toLowerCase(); + + if (obsidianLocale.startsWith('ja')) { + return 'ja'; + } else if (obsidianLocale.startsWith('zh')) { + return 'zh'; + } else if (obsidianLocale.startsWith('ko')) { + return 'ko'; + } else { + return 'en'; + } +} + +function getResolvedLanguage( + pluginLanguage: string, + detectFn: () => 'ja' | 'zh' | 'ko' | 'en' +): 'ja' | 'zh' | 'ko' | 'en' { + if (!['ja', 'zh', 'ko', 'en'].includes(pluginLanguage)) { + return detectFn(); + } + return pluginLanguage as 'ja' | 'zh' | 'ko' | 'en'; +} + +describe('Language Detection Logic', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('detectPluginLanguage', () => { + test('should detect Japanese locale', () => { + const mockGetObsidianLocale = jest.fn(() => 'ja-JP'); + const result = detectPluginLanguage(mockGetObsidianLocale); + expect(result).toBe('ja'); + }); + + test('should detect Chinese locale', () => { + const mockGetObsidianLocale = jest.fn(() => 'zh-CN'); + const result = detectPluginLanguage(mockGetObsidianLocale); + expect(result).toBe('zh'); + }); + + test('should detect Korean locale', () => { + const mockGetObsidianLocale = jest.fn(() => 'ko-KR'); + const result = detectPluginLanguage(mockGetObsidianLocale); + expect(result).toBe('ko'); + }); + + test('should default to English for unknown locales', () => { + const mockGetObsidianLocale = jest.fn(() => 'fr-FR'); + const result = detectPluginLanguage(mockGetObsidianLocale); + expect(result).toBe('en'); + }); + + test('should handle case insensitive locale matching', () => { + const mockGetObsidianLocale = jest.fn(() => 'ZH-TW'); + const result = detectPluginLanguage(mockGetObsidianLocale); + expect(result).toBe('zh'); + }); + + test('should handle edge cases', () => { + const mockGetObsidianLocale = jest.fn(() => ''); + const result = detectPluginLanguage(mockGetObsidianLocale); + expect(result).toBe('en'); + }); + }); + + describe('getResolvedLanguage', () => { + const mockDetectFn = jest.fn(() => 'ko' as const); + + beforeEach(() => { + mockDetectFn.mockClear(); + }); + + test('should return plugin language when set to valid value', () => { + const result = getResolvedLanguage('ja', mockDetectFn); + expect(result).toBe('ja'); + expect(mockDetectFn).not.toHaveBeenCalled(); + }); + + test('should fall back to auto-detection for invalid plugin language', () => { + const result = getResolvedLanguage('invalid', mockDetectFn); + expect(result).toBe('ko'); + expect(mockDetectFn).toHaveBeenCalledTimes(1); + }); + + // auto は廃止済みのためテストしない + + test('should work with all supported languages', () => { + expect(getResolvedLanguage('ja', mockDetectFn)).toBe('ja'); + expect(getResolvedLanguage('zh', mockDetectFn)).toBe('zh'); + expect(getResolvedLanguage('ko', mockDetectFn)).toBe('ko'); + expect(getResolvedLanguage('en', mockDetectFn)).toBe('en'); + expect(mockDetectFn).not.toHaveBeenCalled(); + }); + }); + + describe('getResolvedLanguageWithAdvancedSettings', () => { + const mockDetectFn = jest.fn(() => 'ko' as const); + + beforeEach(() => { + mockDetectFn.mockClear(); + }); + + // Function to simulate the new advanced language resolution logic + function getResolvedLanguageAdvanced( + transcriptionLanguage: string | undefined, + advanced: { languageLinkingEnabled?: boolean; transcriptionLanguage?: string } | undefined, + detectFn: () => 'ja' | 'zh' | 'ko' | 'en' + ): 'ja' | 'zh' | 'ko' | 'en' { + if (advanced?.languageLinkingEnabled === false) { + const adv = advanced?.transcriptionLanguage; + return (['ja', 'zh', 'ko', 'en'].includes(adv as any) ? adv : detectFn()) as 'ja' | 'zh' | 'ko' | 'en'; + } + return (['ja', 'zh', 'ko', 'en'].includes(transcriptionLanguage as any) ? transcriptionLanguage : detectFn()) as 'ja' | 'zh' | 'ko' | 'en'; + } + + test('should use TL when linking enabled; fallback to detection when missing/invalid', () => { + // Default case: advanced.languageLinkingEnabled is true + const advanced = { languageLinkingEnabled: true }; + + // Should use transcriptionLanguage when it's not 'auto' + expect(getResolvedLanguageAdvanced('ja', advanced, mockDetectFn)).toBe('ja'); + expect(mockDetectFn).not.toHaveBeenCalled(); + + // Missing or invalid TL falls back to detection + expect(getResolvedLanguageAdvanced(undefined, advanced, mockDetectFn)).toBe('ko'); + expect(getResolvedLanguageAdvanced('invalid', advanced, mockDetectFn)).toBe('ko'); + }); + + test('should treat undefined advanced as linking enabled', () => { + // When advanced is undefined, should default to traditional behavior (linking enabled) + expect(getResolvedLanguageAdvanced('ja', undefined, mockDetectFn)).toBe('ja'); + expect(mockDetectFn).not.toHaveBeenCalled(); + + expect(getResolvedLanguageAdvanced(undefined, undefined, mockDetectFn)).toBe('ko'); + expect(mockDetectFn).toHaveBeenCalledTimes(1); + }); + + test('should use advanced.transcriptionLanguage when language linking is disabled', () => { + const advanced = { + languageLinkingEnabled: false, + transcriptionLanguage: 'en' + }; + + // Should ignore the regular transcriptionLanguage and use advanced.transcriptionLanguage + expect(getResolvedLanguageAdvanced('ja', advanced, mockDetectFn)).toBe('en'); + expect(mockDetectFn).not.toHaveBeenCalled(); + }); + + test('should use advanced TL when linking disabled; fallback to detection when missing/invalid', () => { + const advanced = { + languageLinkingEnabled: false, + transcriptionLanguage: undefined as unknown as string + }; + + expect(getResolvedLanguageAdvanced('ja', advanced, mockDetectFn)).toBe('ko'); + expect(mockDetectFn).toHaveBeenCalled(); + }); + + test('should use detection when linking disabled and advanced TL is undefined', () => { + const advanced = { + languageLinkingEnabled: false + // transcriptionLanguage is undefined + }; + + expect(getResolvedLanguageAdvanced('ja', advanced, mockDetectFn)).toBe('ko'); + expect(mockDetectFn).toHaveBeenCalled(); + }); + + test('should work with all supported languages in advanced mode', () => { + const advanced = { + languageLinkingEnabled: false, + transcriptionLanguage: 'zh' + }; + + // Test all supported languages + advanced.transcriptionLanguage = 'ja'; + expect(getResolvedLanguageAdvanced('en', advanced, mockDetectFn)).toBe('ja'); + + advanced.transcriptionLanguage = 'zh'; + expect(getResolvedLanguageAdvanced('en', advanced, mockDetectFn)).toBe('zh'); + + advanced.transcriptionLanguage = 'ko'; + expect(getResolvedLanguageAdvanced('en', advanced, mockDetectFn)).toBe('ko'); + + advanced.transcriptionLanguage = 'en'; + expect(getResolvedLanguageAdvanced('ja', advanced, mockDetectFn)).toBe('en'); + + expect(mockDetectFn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts new file mode 100644 index 0000000..2a63f11 --- /dev/null +++ b/tests/mocks/obsidian.ts @@ -0,0 +1,42 @@ +// Mock Obsidian API for tests +export class TFile { + constructor(public path: string) {} +} + +export class Vault { + async read(): Promise { + return ''; + } + + async create(): Promise { + return new TFile('mock.md'); + } +} + +export class App { + vault = new Vault(); +} + +export class Plugin { + app = new App(); + + async loadData(): Promise { + return null; + } + + async saveData(): Promise { + // Mock implementation + } +} + +export class Component { + // Mock component +} + +export class ItemView { + // Mock item view +} + +export class WorkspaceLeaf { + // Mock workspace leaf +} diff --git a/tests/setup 2.ts b/tests/setup 2.ts new file mode 100644 index 0000000..f61b152 --- /dev/null +++ b/tests/setup 2.ts @@ -0,0 +1,10 @@ +// Jest setup file +import 'jest-environment-jsdom'; + +// Global test configuration +global.console = { + ...console, + // Override console methods if needed for tests +}; + +// Mock implementations can go here \ No newline at end of file diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..10b4222 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,10 @@ +// Jest setup file +import 'jest-environment-jsdom'; + +// Global test configuration +global.console = { + ...console, + // Override console methods if needed for tests +}; + +// Mock implementations can go here diff --git a/tests/unit/core/transcription/multilingual-dictionary-correction.test.ts b/tests/unit/core/transcription/multilingual-dictionary-correction.test.ts new file mode 100644 index 0000000..ad86158 --- /dev/null +++ b/tests/unit/core/transcription/multilingual-dictionary-correction.test.ts @@ -0,0 +1,214 @@ +/** + * Tests for multilingual dictionary correction functionality + * Validates that dictionary correction applies to all languages when enabled + */ + +import { DictionaryCorrector } from '../../../../src/core/transcription/DictionaryCorrector'; +import { SimpleCorrectionDictionary } from '../../../../src/interfaces'; + +describe('Multilingual Dictionary Correction', () => { + let corrector: DictionaryCorrector; + + // Sample dictionary with common corrections + const testDictionary: SimpleCorrectionDictionary = { + definiteCorrections: [ + { from: ['AI'], to: 'artificial intelligence' }, + { from: ['GPT'], to: 'Generative Pre-trained Transformer' }, + { from: ['API'], to: 'Application Programming Interface' }, + { from: ['UI'], to: 'User Interface' }, + { from: ['あなた'], to: 'あなた様' }, // Japanese correction + { from: ['hello'], to: 'Hello' }, // English correction + { from: ['你好'], to: '您好' }, // Chinese correction (informal to formal) + { from: ['안녕'], to: '안녕하세요' }, // Korean correction (informal to formal) + ] + }; + + beforeEach(() => { + corrector = new DictionaryCorrector({ + enabled: true, + correctionDictionary: testDictionary + }); + }); + + describe('Dictionary correction applies to all languages', () => { + it('should apply corrections to English text', async () => { + const input = 'The AI uses GPT technology for API calls with a modern UI.'; + const result = await corrector.correct(input); + + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + expect(result).toContain('Application Programming Interface'); + expect(result).toContain('User Interface'); + expect(result).not.toContain('AI '); + expect(result).not.toContain('GPT '); + expect(result).not.toContain('API '); + expect(result).not.toContain('UI.'); + }); + + it('should apply corrections to Japanese text', async () => { + const input = 'あなたは AI を使って GPT で API を呼び出します。'; + const result = await corrector.correct(input); + + expect(result).toContain('あなた様'); + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + expect(result).toContain('Application Programming Interface'); + expect(result).not.toContain('あなたは'); + }); + + it('should apply corrections to Chinese text', async () => { + const input = '你好,AI 使用 GPT 技术通过 API 调用。'; + const result = await corrector.correct(input); + + expect(result).toContain('您好'); + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + expect(result).toContain('Application Programming Interface'); + expect(result).not.toContain('你好'); + }); + + it('should apply corrections to Korean text', async () => { + const input = '안녕, AI가 GPT 기술로 API를 호출합니다.'; + const result = await corrector.correct(input); + + expect(result).toContain('안녕하세요'); + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + expect(result).toContain('Application Programming Interface'); + expect(result).not.toContain('안녕,'); + }); + + it('should apply corrections to mixed language text', async () => { + const input = 'Hello, あなた! The AI system uses GPT. 你好 API!'; + const result = await corrector.correct(input); + + expect(result).toContain('Hello'); + expect(result).toContain('あなた様'); + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + expect(result).toContain('您好'); + expect(result).toContain('Application Programming Interface'); + }); + }); + + describe('Dictionary correction can be disabled', () => { + it('should not apply corrections when disabled', async () => { + const disabledCorrector = new DictionaryCorrector({ + enabled: false, + correctionDictionary: testDictionary + }); + + const input = 'The AI uses GPT technology for API calls with a modern UI.'; + const result = await disabledCorrector.correct(input); + + // Should return original text unchanged + expect(result).toBe(input); + expect(result).toContain('AI'); + expect(result).toContain('GPT'); + expect(result).toContain('API'); + expect(result).toContain('UI'); + }); + }); + + describe('Empty or missing dictionary handling', () => { + it('should handle empty dictionary gracefully', async () => { + const emptyCorrector = new DictionaryCorrector({ + enabled: true, + correctionDictionary: { definiteCorrections: [] } + }); + + const inputs = [ + 'English text with AI and GPT', + 'あなたは日本語です', + '你好中文', + '안녕하세요 한국어' + ]; + + for (const input of inputs) { + const result = await emptyCorrector.correct(input); + expect(result).toBe(input); // Should return unchanged + } + }); + + it('should handle missing dictionary gracefully', async () => { + const noDictCorrector = new DictionaryCorrector({ + enabled: true + // No correctionDictionary provided + }); + + const inputs = [ + 'English text with AI and GPT', + 'あなたは日本語です', + '你好中文', + '안녕하세요 한국어' + ]; + + for (const input of inputs) { + const result = await noDictCorrector.correct(input); + expect(result).toBe(input); // Should return unchanged + } + }); + }); + + describe('Edge cases', () => { + it('should handle short text correctly', async () => { + const shortTexts = ['AI', 'GPT', 'API', 'UI']; + + for (const text of shortTexts) { + const result = await corrector.correct(text); + expect(result).not.toBe(text); // Should be corrected + expect(result.length).toBeGreaterThan(text.length); + } + }); + + it('should handle empty or very short text', async () => { + const emptyTexts = ['', ' ', 'a']; + + for (const text of emptyTexts) { + const result = await corrector.correct(text); + // Should return as-is for very short text based on implementation + expect(result).toBe(text); + } + }); + + it('should handle text without any corrections needed', async () => { + const cleanTexts = [ + 'This is clean English text.', + 'これは綺麗な日本語です。', + '这是干净的中文文本。', + '이것은 깨끗한 한국어 텍스트입니다.' + ]; + + for (const text of cleanTexts) { + const result = await corrector.correct(text); + expect(result).toBe(text); // Should return unchanged + } + }); + }); + + describe('Case sensitivity and pattern matching', () => { + it('should match corrections case-sensitively', async () => { + const input = 'The ai and AI are different, also gpt vs GPT.'; + const result = await corrector.correct(input); + + // Only 'AI' and 'GPT' (exact case matches) should be corrected + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + expect(result).toContain('ai and'); // lowercase 'ai' should remain + expect(result).toContain('gpt vs'); // lowercase 'gpt' should remain + }); + + it('should handle multiple occurrences of the same pattern', async () => { + const input = 'AI helps with AI development. GPT-3 and GPT-4 are AI models.'; + const result = await corrector.correct(input); + + // All instances of 'AI' and 'GPT' should be corrected + expect(result).not.toContain('AI '); + expect(result).not.toContain('GPT-'); + const aiCount = (result.match(/artificial intelligence/g) || []).length; + const gptCount = (result.match(/Generative Pre-trained Transformer/g) || []).length; + expect(aiCount).toBeGreaterThanOrEqual(2); + expect(gptCount).toBeGreaterThanOrEqual(2); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/core/transcription/multilingual-pipeline-integration.test.ts b/tests/unit/core/transcription/multilingual-pipeline-integration.test.ts new file mode 100644 index 0000000..ed5a7b7 --- /dev/null +++ b/tests/unit/core/transcription/multilingual-pipeline-integration.test.ts @@ -0,0 +1,128 @@ +import { StandardCleaningPipeline } from '../../../../src/core/transcription/cleaning/StandardCleaningPipeline'; +import { PromptContaminationCleaner } from '../../../../src/core/transcription/cleaning/PromptContaminationCleaner'; +import { UniversalRepetitionCleaner } from '../../../../src/core/transcription/cleaning/UniversalRepetitionCleaner'; + +describe('Multilingual Pipeline Integration', () => { + let pipeline: StandardCleaningPipeline; + + beforeEach(() => { + pipeline = new StandardCleaningPipeline([ + new PromptContaminationCleaner(), + new UniversalRepetitionCleaner() + ]); + }); + + // Helper function to simulate pre-stripping like TranscriptionService does + const preStripTranscriptWrappers = (text: string): string => { + let result = text; + // Complete tag match + const completeMatch = result.match(/]*>\s*([\s\S]*?)\s*<\/TRANSCRIPT>/); + if (completeMatch) { + result = completeMatch[1]; + } else { + // Opening tag only (missing closing tag) + const openingMatch = result.match(/]*>\s*([\s\S]*)/); + if (openingMatch) { + result = openingMatch[1]; + } + } + // Remove any remaining TRANSCRIPT tags + result = result.replace(/<\/?TRANSCRIPT[^>]*>/gi, ''); + result = result.replace(/<\/?transcription[^>]*>/gi, ''); + return result; + }; + + describe('Complete flow (pre-strip + pipeline)', () => { + it('EN: complete flow removes prompts/wrappers and keeps content', async () => { + const input = ` +Please transcribe only the following audio content. Do not include this instruction in your output. +Record only the speaker's statements accurately. + +Output format: +(Speaker content only) +Hello world, this is a test message. +`; + + // Step 1: Pre-strip (simulating TranscriptionService behavior) + const preStripped = preStripTranscriptWrappers(input); + + // Step 2: Pipeline cleaning + const { finalText } = await pipeline.execute(preStripped, 'en', { + language: 'en', + originalLength: preStripped.length + }); + + expect(finalText).toContain('Hello world, this is a test message'); + expect(finalText).not.toMatch(/<\/?TRANSCRIPT/i); + expect(finalText).not.toMatch(/Please transcribe/); + expect(finalText).not.toMatch(/Output format/); + expect(finalText).not.toMatch(/Speaker content only/); + }); + + it('ZH: complete flow removes prompts/wrappers and keeps content', async () => { + const input = ` +请仅转录以下音频内容。不要包含此指令在输出中。 +请准确记录说话者的发言内容。 + +输出格式: +(仅说话者内容) +这是一个测试消息。 +`; + + const preStripped = preStripTranscriptWrappers(input); + const { finalText } = await pipeline.execute(preStripped, 'zh', { + language: 'zh', + originalLength: preStripped.length + }); + + expect(finalText).toContain('这是一个测试消息'); + expect(finalText).not.toMatch(/<\/?TRANSCRIPT/i); + expect(finalText).not.toMatch(/请仅转录/); + expect(finalText).not.toMatch(/输出格式/); + }); + + it('KO: complete flow removes prompts/wrappers and keeps content', async () => { + const input = ` +다음 음성 내용만 전사해주세요. 이 지시사항을 출력에 포함하지 마세요. +화자의 발언 내용만 정확히 기록해주세요. + +출력 형식: +(화자 발언만) +안녕하세요, 테스트 메시지입니다. +`; + + const preStripped = preStripTranscriptWrappers(input); + const { finalText } = await pipeline.execute(preStripped, 'ko', { + language: 'ko', + originalLength: preStripped.length + }); + + expect(finalText).toContain('안녕하세요, 테스트 메시지입니다'); + expect(finalText).not.toMatch(/<\/?TRANSCRIPT/i); + expect(finalText).not.toMatch(/다음 음성 내용만/); + expect(finalText).not.toMatch(/출력 형식/); + }); + + it('JA: complete flow removes prompts/wrappers and keeps content (regression test)', async () => { + const input = ` +以下の音声内容のみを文字に起こしてください。この指示文は出力に含めないでください。 +話者の発言内容だけを正確に記録してください。 + +出力形式: +(話者の発言のみ) +こんにちは、テストメッセージです。 +`; + + const preStripped = preStripTranscriptWrappers(input); + const { finalText } = await pipeline.execute(preStripped, 'ja', { + language: 'ja', + originalLength: preStripped.length + }); + + expect(finalText).toContain('こんにちは、テストメッセージです'); + expect(finalText).not.toMatch(/<\/?TRANSCRIPT/i); + expect(finalText).not.toMatch(/以下の音声内容/); + expect(finalText).not.toMatch(/出力形式/); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/core/transcription/multilingual-prompt-support.test.ts b/tests/unit/core/transcription/multilingual-prompt-support.test.ts new file mode 100644 index 0000000..cfcf130 --- /dev/null +++ b/tests/unit/core/transcription/multilingual-prompt-support.test.ts @@ -0,0 +1,277 @@ +/** + * Tests for multilingual prompt support + * Verifies that prompts are correctly generated and applied for all supported languages + */ + +// Prompt constants for string drift prevention (same as in main service) +const PROMPT_CONSTANTS = { + JAPANESE: { + INSTRUCTION_1: '以下の音声内容のみを文字に起こしてください。この指示文は出力に含めないでください。', + INSTRUCTION_2: '話者の発言内容だけを正確に記録してください。', + OUTPUT_FORMAT: '出力形式:', + OUTPUT_FORMAT_FULL_COLON: '出力形式:', + SPEAKER_ONLY: '(話者の発言のみ)' + }, + ENGLISH: { + INSTRUCTION_1: 'Please transcribe only the following audio content. Do not include this instruction in your output.', + INSTRUCTION_2: 'Record only the speaker\'s statements accurately.', + OUTPUT_FORMAT: 'Output format:', + SPEAKER_ONLY: '(Speaker content only)' + }, + CHINESE: { + INSTRUCTION_1: '请仅转录以下音频内容。不要包含此指令在输出中。', + INSTRUCTION_2: '请准确记录说话者的发言内容。', + OUTPUT_FORMAT: '输出格式:', + SPEAKER_ONLY: '(仅说话者内容)' + }, + KOREAN: { + INSTRUCTION_1: '다음 음성 내용만 전사해주세요. 이 지시사항을 출력에 포함하지 마세요.', + INSTRUCTION_2: '화자의 발언 내용만 정확히 기록해주세요.', + OUTPUT_FORMAT: '출력 형식:', + SPEAKER_ONLY: '(화자 발언만)' + } +} as const; + +// Mock TranscriptionService to test multilingual prompt functionality +class MockTranscriptionService { + /** + * Normalize language code for consistent processing + */ + normalizeLanguage(language: string): string { + if (language === 'auto') return 'auto'; + const lang = language.toLowerCase(); + if (lang.startsWith('ja')) return 'ja'; + if (lang.startsWith('zh')) return 'zh'; + if (lang.startsWith('ko')) return 'ko'; + if (lang.startsWith('en')) return 'en'; + return lang; + } + + /** + * Build prompt for GPT-4o transcription (for all languages except auto) + */ + buildTranscriptionPrompt(language: string): string { + // No prompt for auto language mode (as it might interfere with language detection) + if (language === 'auto') { + return ''; + } + + const normalizedLang = this.normalizeLanguage(language); + + switch (normalizedLang) { + case 'ja': + return `${PROMPT_CONSTANTS.JAPANESE.INSTRUCTION_1} +${PROMPT_CONSTANTS.JAPANESE.INSTRUCTION_2} + +${PROMPT_CONSTANTS.JAPANESE.OUTPUT_FORMAT} + +${PROMPT_CONSTANTS.JAPANESE.SPEAKER_ONLY} +`; + + case 'en': + return `${PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_1} +${PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_2} + +${PROMPT_CONSTANTS.ENGLISH.OUTPUT_FORMAT} + +${PROMPT_CONSTANTS.ENGLISH.SPEAKER_ONLY} +`; + + case 'zh': + return `${PROMPT_CONSTANTS.CHINESE.INSTRUCTION_1} +${PROMPT_CONSTANTS.CHINESE.INSTRUCTION_2} + +${PROMPT_CONSTANTS.CHINESE.OUTPUT_FORMAT} + +${PROMPT_CONSTANTS.CHINESE.SPEAKER_ONLY} +`; + + case 'ko': + return `${PROMPT_CONSTANTS.KOREAN.INSTRUCTION_1} +${PROMPT_CONSTANTS.KOREAN.INSTRUCTION_2} + +${PROMPT_CONSTANTS.KOREAN.OUTPUT_FORMAT} + +${PROMPT_CONSTANTS.KOREAN.SPEAKER_ONLY} +`; + + default: + // For any other language, return empty string + return ''; + } + } +} + +describe('Multilingual Prompt Support', () => { + let service: MockTranscriptionService; + + beforeEach(() => { + service = new MockTranscriptionService(); + }); + + describe('Prompt generation for supported languages', () => { + it('should generate appropriate prompts for each supported language', () => { + const languages = ['ja', 'en', 'zh', 'ko']; + + languages.forEach(lang => { + const prompt = service.buildTranscriptionPrompt(lang); + + // All prompts should be non-empty + expect(prompt.length).toBeGreaterThan(0); + + // All prompts should contain TRANSCRIPT tags + expect(prompt).toContain(''); + expect(prompt).toContain(''); + + // Prompts should be in the appropriate language + switch (lang) { + case 'ja': + expect(prompt).toContain('以下の音声内容のみを文字に起こしてください'); + expect(prompt).toContain('(話者の発言のみ)'); + break; + case 'en': + expect(prompt).toContain('Please transcribe only the following audio content'); + expect(prompt).toContain('(Speaker content only)'); + break; + case 'zh': + expect(prompt).toContain('请仅转录以下音频内容'); + expect(prompt).toContain('(仅说话者内容)'); + break; + case 'ko': + expect(prompt).toContain('다음 음성 내용만 전사해주세요'); + expect(prompt).toContain('(화자 발언만)'); + break; + } + }); + }); + + it('should not generate prompts for auto language mode', () => { + const prompt = service.buildTranscriptionPrompt('auto'); + expect(prompt).toBe(''); + }); + + it('should not generate prompts for unsupported languages', () => { + const unsupportedLanguages = ['fr', 'de', 'es', 'it', 'pt', 'ru', 'ar']; + + unsupportedLanguages.forEach(lang => { + const prompt = service.buildTranscriptionPrompt(lang); + expect(prompt).toBe(''); + }); + }); + }); + + describe('Language code normalization', () => { + it('should handle language variants correctly', () => { + const testCases = [ + { input: 'ja', expected: 'ja' }, + { input: 'ja-JP', expected: 'ja' }, + { input: 'JA', expected: 'ja' }, + { input: 'en', expected: 'en' }, + { input: 'en-US', expected: 'en' }, + { input: 'en-GB', expected: 'en' }, + { input: 'EN', expected: 'en' }, + { input: 'zh', expected: 'zh' }, + { input: 'zh-CN', expected: 'zh' }, + { input: 'zh-TW', expected: 'zh' }, + { input: 'ZH', expected: 'zh' }, + { input: 'ko', expected: 'ko' }, + { input: 'ko-KR', expected: 'ko' }, + { input: 'KO', expected: 'ko' }, + { input: 'auto', expected: 'auto' }, + ]; + + testCases.forEach(({ input, expected }) => { + expect(service.normalizeLanguage(input)).toBe(expected); + }); + }); + + it('should generate prompts based on normalized language codes', () => { + // Test that language variants get the same prompt as their base language + const jaPrompt = service.buildTranscriptionPrompt('ja'); + const jaJPPrompt = service.buildTranscriptionPrompt('ja-JP'); + const JAPrompt = service.buildTranscriptionPrompt('JA'); + + expect(jaJPPrompt).toBe(jaPrompt); + expect(JAPrompt).toBe(jaPrompt); + + const enPrompt = service.buildTranscriptionPrompt('en'); + const enUSPrompt = service.buildTranscriptionPrompt('en-US'); + const ENPrompt = service.buildTranscriptionPrompt('EN'); + + expect(enUSPrompt).toBe(enPrompt); + expect(ENPrompt).toBe(enPrompt); + }); + }); + + describe('Prompt structure validation', () => { + it('should ensure all prompts follow consistent structure', () => { + const languages = ['ja', 'en', 'zh', 'ko']; + + languages.forEach(lang => { + const prompt = service.buildTranscriptionPrompt(lang); + + // Should contain instruction not to include the prompt + const lowerPrompt = prompt.toLowerCase(); + expect( + lowerPrompt.includes('not include') || + lowerPrompt.includes('含めない') || + lowerPrompt.includes('不要包含') || + lowerPrompt.includes('포함하지') + ).toBe(true); + + // Should contain output format instructions + expect( + prompt.includes('Output format') || + prompt.includes('出力形式') || + prompt.includes('输出格式') || + prompt.includes('출력 형식') + ).toBe(true); + + // Should have proper TRANSCRIPT tag structure + expect(prompt).toContain(''); + expect(prompt).toContain(''); + + // TRANSCRIPT tags should be in correct order + const transcriptStart = prompt.indexOf(''); + const transcriptEnd = prompt.indexOf(''); + expect(transcriptStart).toBeLessThan(transcriptEnd); + }); + }); + + it('should contain language-appropriate speaker placeholders', () => { + const expectedPlaceholders = { + 'ja': '(話者の発言のみ)', + 'en': '(Speaker content only)', + 'zh': '(仅说话者内容)', + 'ko': '(화자 발언만)' + }; + + Object.entries(expectedPlaceholders).forEach(([lang, placeholder]) => { + const prompt = service.buildTranscriptionPrompt(lang); + expect(prompt).toContain(placeholder); + }); + }); + }); + + describe('Integration with auto mode behavior', () => { + it('should maintain current auto mode behavior (no prompt)', () => { + // Auto mode should continue to not get prompts to avoid interfering with language detection + expect(service.buildTranscriptionPrompt('auto')).toBe(''); + expect(service.buildTranscriptionPrompt('AUTO')).toBe(''); + }); + }); + + describe('Edge cases', () => { + it('should handle empty and null language inputs gracefully', () => { + expect(service.buildTranscriptionPrompt('')).toBe(''); + expect(service.buildTranscriptionPrompt(' ')).toBe(''); + }); + + it('should handle mixed case language codes', () => { + expect(service.buildTranscriptionPrompt('Ja')).toBeTruthy(); + expect(service.buildTranscriptionPrompt('En')).toBeTruthy(); + expect(service.buildTranscriptionPrompt('Zh')).toBeTruthy(); + expect(service.buildTranscriptionPrompt('Ko')).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/core/transcription/transcription-cleaning.test.ts b/tests/unit/core/transcription/transcription-cleaning.test.ts new file mode 100644 index 0000000..d1665a8 --- /dev/null +++ b/tests/unit/core/transcription/transcription-cleaning.test.ts @@ -0,0 +1,703 @@ +/** + * Comprehensive tests for transcription cleaning functionality + * Tests the multilingual approach with language-specific patterns + */ + +// Mock TranscriptionService to test its cleaning methods +class MockTranscriptionService { + /** + * Clean GPT-4o specific response artifacts with language-specific processing + */ + cleanGPT4oResponse(text: string, language: string): string { + // Normalize language for processing + const normalizedLang = this.normalizeLanguage(language); + + // First attempt: Extract content from complete TRANSCRIPT tags + let transcriptMatch = text.match(/\s*([\s\S]*?)\s*<\/TRANSCRIPT>/); + if (transcriptMatch) { + text = transcriptMatch[1]; + } else { + // Second attempt: Handle incomplete TRANSCRIPT tags (missing closing tag) + const openingMatch = text.match(/\s*([\s\S]*)/); + if (openingMatch) { + text = openingMatch[1]; + } + } + + // Remove TRANSCRIPT opening tag if still present (for cases where it's not properly extracted) + text = text.replace(/<\/?TRANSCRIPT[^>]*>/g, ''); + + // Apply language-specific cleaning + text = this.applyLanguageSpecificCleaning(text, normalizedLang); + + // Apply generic cleaning (only colon-based patterns to prevent over-removal) + text = this.applyGenericCleaning(text); + + // Clean up extra whitespace and empty lines + text = text.trim(); + text = text.replace(/\n{3,}/g, '\n\n'); + text = text.replace(/^\s*\n/gm, ''); // Remove lines with only whitespace + text = text.trim(); + + return text; + } + + /** + * Normalize language code for consistent processing + */ + normalizeLanguage(language: string): string { + if (language === 'auto') return 'auto'; + const lang = language.toLowerCase(); + if (lang.startsWith('ja')) return 'ja'; + if (lang.startsWith('zh')) return 'zh'; + if (lang.startsWith('ko')) return 'ko'; + if (lang.startsWith('en')) return 'en'; + return lang; + } + + /** + * Apply language-specific cleaning patterns + */ + applyLanguageSpecificCleaning(text: string, language: string): string { + switch (language) { + case 'ja': + return this.applyJapaneseCleaning(text); + case 'en': + return this.applyEnglishCleaning(text); + case 'zh': + return this.applyChineseCleaning(text); + case 'ko': + return this.applyKoreanCleaning(text); + default: + return text; + } + } + + /** + * Apply Japanese-specific cleaning patterns + */ + applyJapaneseCleaning(text: string): string { + const patterns = [ + /^以下の音声内容.*?$/gm, + /^この指示文.*?$/gm, + /^話者の発言内容だけを正確に記録してください.*?$/gm, + /^話者の発言.*?$/gm, + /^出力形式.*?$/gm, + /(話者の発言のみ)/g, + ]; + + for (const pattern of patterns) { + text = text.replace(pattern, ''); + } + return text; + } + + /** + * Apply English-specific cleaning patterns - using exact line matching for safety + */ + applyEnglishCleaning(text: string): string { + // Split into lines for exact line matching + const lines = text.split('\n'); + const cleanedLines = lines.filter(line => { + const trimmedLine = line.trim(); + // Only remove lines that exactly match prompt instructions + return !( + trimmedLine === 'Please transcribe only the following audio content. Do not include this instruction in your output.' || + trimmedLine === 'Record only the speaker\'s statements accurately.' || + trimmedLine === 'Output format:' || + trimmedLine === '(Speaker content only)' + ); + }); + + // Also remove the phrase anywhere in text (not just full lines) + let result = cleanedLines.join('\n'); + result = result.replace(/\(Speaker content only\)/g, ''); + + return result; + } + + /** + * Apply Chinese-specific cleaning patterns - using exact line matching for safety + */ + applyChineseCleaning(text: string): string { + // Split into lines for exact line matching + const lines = text.split('\n'); + const cleanedLines = lines.filter(line => { + const trimmedLine = line.trim(); + // Only remove lines that exactly match prompt instructions + return !( + trimmedLine === '请仅转录以下音频内容。不要包含此指令在输出中。' || + trimmedLine === '请准确记录说话者的发言内容。' || + trimmedLine === '输出格式:' || + trimmedLine === '(仅说话者内容)' + ); + }); + + // Also remove the phrase anywhere in text (not just full lines) + let result = cleanedLines.join('\n'); + result = result.replace(/(仅说话者内容)/g, ''); + + return result; + } + + /** + * Apply Korean-specific cleaning patterns - using exact line matching for safety + */ + applyKoreanCleaning(text: string): string { + // Split into lines for exact line matching + const lines = text.split('\n'); + const cleanedLines = lines.filter(line => { + const trimmedLine = line.trim(); + // Only remove lines that exactly match prompt instructions + return !( + trimmedLine === '다음 음성 내용만 전사해주세요. 이 지시사항을 출력에 포함하지 마세요.' || + trimmedLine === '화자의 발언 내용만 정확히 기록해주세요.' || + trimmedLine === '출력 형식:' || + trimmedLine === '(화자 발언만)' + ); + }); + + // Also remove the phrase anywhere in text (not just full lines) + let result = cleanedLines.join('\n'); + result = result.replace(/(화자 발언만)/g, ''); + + return result; + } + + /** + * Apply generic cleaning patterns (conservative approach) - using exact line matching for safety + */ + applyGenericCleaning(text: string): string { + // Split into lines for exact line matching + const lines = text.split('\n'); + const cleanedLines = lines.filter(line => { + const trimmedLine = line.trim(); + // Only remove lines that exactly match generic format instructions + return !( + trimmedLine === 'Output format:' || + trimmedLine === 'Format:' + ); + }); + + return cleanedLines.join('\n'); + } + + /** + * Detect prompt error patterns by language + */ + isPromptErrorDetected(text: string, language: string): boolean { + const normalizedLang = this.normalizeLanguage(language); + + switch (normalizedLang) { + case 'ja': + return text.includes('この指示文は出力に含めないでください') || + text.includes('話者の発言内容だけを正確に記録してください') || + text === '(話者の発言のみ)' || + text.trim() === '話者の発言のみ'; + case 'en': + return text.includes('Please transcribe only the speaker') || + text.includes('Do not include this instruction') || + text.trim() === '(Speaker content only)'; + case 'zh': + return text.includes('请仅转录说话者') || + text.includes('不要包含此指令') || + text.trim() === '(仅说话者内容)'; + case 'ko': + return text.includes('화자의 발언만 전사해주세요') || + text.includes('이 지시사항을 포함하지 마세요') || + text.trim() === '(화자 발언만)'; + default: + // For auto and other languages, use Japanese patterns as fallback + return text.includes('この指示文は出力に含めないでください') || + text.includes('話者の発言内容だけを正確に記録してください') || + text === '(話者の発言のみ)' || + text.trim() === '話者の発言のみ'; + } + } + + /** + * Build prompt for GPT-4o transcription + */ + buildTranscriptionPrompt(language: string): string { + // No prompt for auto language mode (as it might interfere with language detection) + if (language === 'auto') { + return ''; + } + + const normalizedLang = this.normalizeLanguage(language); + + switch (normalizedLang) { + case 'ja': + return `以下の音声内容のみを文字に起こしてください。この指示文は出力に含めないでください。 +話者の発言内容だけを正確に記録してください。 + +出力形式: + +(話者の発言のみ) +`; + + case 'en': + return `Please transcribe only the following audio content. Do not include this instruction in your output. +Record only the speaker's statements accurately. + +Output format: + +(Speaker content only) +`; + + case 'zh': + return `请仅转录以下音频内容。不要包含此指令在输出中。 +请准确记录说话者的发言内容。 + +输出格式: + +(仅说话者内容) +`; + + case 'ko': + return `다음 음성 내용만 전사해주세요. 이 지시사항을 출력에 포함하지 마세요. +화자의 발언 내용만 정확히 기록해주세요. + +출력 형식: + +(화자 발언만) +`; + + default: + // For any other language, return empty string + return ''; + } + } +} + +describe('Multilingual Transcription Cleaning', () => { + let service: MockTranscriptionService; + + beforeEach(() => { + service = new MockTranscriptionService(); + }); + + describe('TRANSCRIPT tag extraction', () => { + it('should extract content from complete TRANSCRIPT tags', () => { + const input = `Some prefix text + +実際の発言内容 + +Some suffix text`; + const result = service.cleanGPT4oResponse(input, 'ja'); + expect(result).toContain('実際の発言内容'); + expect(result).not.toContain(''); + expect(result).not.toContain(''); + }); + + it('should handle incomplete TRANSCRIPT tags (missing closing tag)', () => { + const input = `Some prefix text + +実際の発言内容 +Additional content`; + const result = service.cleanGPT4oResponse(input, 'ja'); + expect(result).toContain('実際の発言内容'); + expect(result).toContain('Additional content'); + expect(result).not.toContain(''); + }); + + it('should handle malformed TRANSCRIPT tags', () => { + const input = ` +Content with attributes +`; + const result = service.cleanGPT4oResponse(input, 'en'); + expect(result).toContain('Content with attributes'); + expect(result).not.toContain(''); + }); + }); + + describe('Language-specific cleaning', () => { + describe('Japanese cleaning', () => { + it('should remove Japanese meta instructions', () => { + const patterns = [ + '以下の音声内容のみを文字に起こしてください', + 'この指示文は出力に含めないでください', + '話者の発言内容だけを正確に記録してください', + '出力形式:' + ]; + + patterns.forEach(pattern => { + const input = `${pattern}\n実際の発言\nその他の内容`; + const result = service.cleanGPT4oResponse(input, 'ja'); + expect(result).not.toContain(pattern); + expect(result).toContain('実際の発言'); + expect(result).toContain('その他の内容'); + }); + }); + + it('should remove Japanese meta phrases anywhere in text', () => { + const input = '前の文章 (話者の発言のみ) 後の文章'; + const result = service.cleanGPT4oResponse(input, 'ja'); + expect(result).not.toContain('(話者の発言のみ)'); + expect(result).toContain('前の文章'); + expect(result).toContain('後の文章'); + }); + }); + + describe('English cleaning', () => { + it('should remove English meta instructions', () => { + const exactPatterns = [ + 'Please transcribe only the following audio content. Do not include this instruction in your output.', + 'Record only the speaker\'s statements accurately.', + 'Output format:' + ]; + + exactPatterns.forEach(pattern => { + const input = `${pattern}\nActual speech\nOther content`; + const result = service.cleanGPT4oResponse(input, 'en'); + expect(result).not.toContain(pattern); + expect(result).toContain('Actual speech'); + expect(result).toContain('Other content'); + }); + }); + + it('should NOT remove similar but different phrases to avoid over-removal', () => { + const normalContent = [ + 'Please transcribe this document', // Different from exact prompt + 'Do not include extras', // Different from exact prompt + 'Record everything accurately', // Different from exact prompt + 'Output format is important', // Different from exact prompt + 'Format the data properly' // Different from exact prompt + ]; + + normalContent.forEach(content => { + const input = `${content}\nActual speech\nOther content`; + const result = service.cleanGPT4oResponse(input, 'en'); + expect(result).toContain(content); + expect(result).toContain('Actual speech'); + expect(result).toContain('Other content'); + }); + }); + + it('should remove English meta phrases anywhere in text', () => { + const input = 'Before text (Speaker content only) After text'; + const result = service.cleanGPT4oResponse(input, 'en'); + expect(result).not.toContain('(Speaker content only)'); + expect(result).toContain('Before text'); + expect(result).toContain('After text'); + }); + }); + + describe('Chinese cleaning', () => { + it('should remove Chinese meta instructions', () => { + const exactPatterns = [ + '请仅转录以下音频内容。不要包含此指令在输出中。', + '请准确记录说话者的发言内容。', + '输出格式:' + ]; + + exactPatterns.forEach(pattern => { + const input = `${pattern}\n实际语音\n其他内容`; + const result = service.cleanGPT4oResponse(input, 'zh'); + expect(result).not.toContain(pattern); + expect(result).toContain('实际语音'); + expect(result).toContain('其他内容'); + }); + }); + + it('should NOT remove similar but different phrases to avoid over-removal', () => { + const normalContent = [ + '请转录这个文档', // Different from exact prompt + '不要包含额外内容', // Different from exact prompt + '请准确记录所有内容', // Different from exact prompt + '输出格式很重要', // Different from exact prompt + '格式化数据正确' // Different from exact prompt + ]; + + normalContent.forEach(content => { + const input = `${content}\n实际语音\n其他内容`; + const result = service.cleanGPT4oResponse(input, 'zh'); + expect(result).toContain(content); + expect(result).toContain('实际语音'); + expect(result).toContain('其他内容'); + }); + }); + + it('should remove Chinese meta phrases anywhere in text', () => { + const input = '前面的文字 (仅说话者内容) 后面的文字'; + const result = service.cleanGPT4oResponse(input, 'zh'); + expect(result).not.toContain('(仅说话者内容)'); + expect(result).toContain('前面的文字'); + expect(result).toContain('后面的文字'); + }); + }); + + describe('Korean cleaning', () => { + it('should remove Korean meta instructions', () => { + const exactPatterns = [ + '다음 음성 내용만 전사해주세요. 이 지시사항을 출력에 포함하지 마세요.', + '화자의 발언 내용만 정확히 기록해주세요.', + '출력 형식:' + ]; + + exactPatterns.forEach(pattern => { + const input = `${pattern}\n실제 음성\n기타 내용`; + const result = service.cleanGPT4oResponse(input, 'ko'); + expect(result).not.toContain(pattern); + expect(result).toContain('실제 음성'); + expect(result).toContain('기타 내용'); + }); + }); + + it('should NOT remove similar but different phrases to avoid over-removal', () => { + const normalContent = [ + '다음 음성을 들어보세요', // Different from exact prompt + '이 지시사항이 중요합니다', // Different from exact prompt + '화자의 발언을 기록합니다', // Different from exact prompt + '출력 형식이 중요합니다', // Different from exact prompt + '형식을 올바르게 설정' // Different from exact prompt + ]; + + normalContent.forEach(content => { + const input = `${content}\n실제 음성\n기타 내용`; + const result = service.cleanGPT4oResponse(input, 'ko'); + expect(result).toContain(content); + expect(result).toContain('실제 음성'); + expect(result).toContain('기타 내용'); + }); + }); + + it('should remove Korean meta phrases anywhere in text', () => { + const input = '앞의 문장 (화자 발언만) 뒤의 문장'; + const result = service.cleanGPT4oResponse(input, 'ko'); + expect(result).not.toContain('(화자 발언만)'); + expect(result).toContain('앞의 문장'); + expect(result).toContain('뒤의 문장'); + }); + }); + }); + + describe('Generic cleaning (conservative approach)', () => { + it('should only remove exact format instruction lines', () => { + const input = `Output format: +Normal sentence about output +Format: +Another normal sentence`; + + const result = service.cleanGPT4oResponse(input, 'en'); + expect(result).not.toContain('Output format:'); + expect(result).not.toContain('Format:'); + expect(result).toContain('Normal sentence about output'); + expect(result).toContain('Another normal sentence'); + }); + + it('should NOT remove format-related content with additional text', () => { + const input = `Output format: JSON response +Format: Speaker content only +Output was successful +Format looks good +This is normal content`; + + const result = service.cleanGPT4oResponse(input, 'fr'); // Non-supported language + expect(result).toContain('Output format: JSON response'); + expect(result).toContain('Format: Speaker content only'); + expect(result).toContain('Output was successful'); + expect(result).toContain('Format looks good'); + expect(result).toContain('This is normal content'); + }); + }); + + describe('Prompt error detection by language', () => { + describe('Japanese error detection', () => { + it('should detect Japanese prompt error patterns', () => { + const errorPatterns = [ + 'この指示文は出力に含めないでください', + '話者の発言内容だけを正確に記録してください', + '(話者の発言のみ)', + '話者の発言のみ' + ]; + + errorPatterns.forEach(pattern => { + expect(service.isPromptErrorDetected(pattern, 'ja')).toBe(true); + }); + }); + }); + + describe('English error detection', () => { + it('should detect English prompt error patterns', () => { + const errorPatterns = [ + 'Please transcribe only the speaker', + 'Do not include this instruction', + '(Speaker content only)' + ]; + + errorPatterns.forEach(pattern => { + expect(service.isPromptErrorDetected(pattern, 'en')).toBe(true); + }); + }); + }); + + describe('Chinese error detection', () => { + it('should detect Chinese prompt error patterns', () => { + const errorPatterns = [ + '请仅转录说话者', + '不要包含此指令', + '(仅说话者内容)' + ]; + + errorPatterns.forEach(pattern => { + expect(service.isPromptErrorDetected(pattern, 'zh')).toBe(true); + }); + }); + }); + + describe('Korean error detection', () => { + it('should detect Korean prompt error patterns', () => { + const errorPatterns = [ + '화자의 발언만 전사해주세요', + '이 지시사항을 포함하지 마세요', + '(화자 발언만)' + ]; + + errorPatterns.forEach(pattern => { + expect(service.isPromptErrorDetected(pattern, 'ko')).toBe(true); + }); + }); + }); + + it('should NOT detect normal content as errors', () => { + const normalContent = [ + 'こんにちは、今日はいい天気ですね', + 'Hello, how are you today?', + '你好,今天天气不错', + '안녕하세요, 오늘 날씨가 좋네요' + ]; + + normalContent.forEach(content => { + expect(service.isPromptErrorDetected(content, 'ja')).toBe(false); + expect(service.isPromptErrorDetected(content, 'en')).toBe(false); + expect(service.isPromptErrorDetected(content, 'zh')).toBe(false); + expect(service.isPromptErrorDetected(content, 'ko')).toBe(false); + }); + }); + }); + + describe('buildTranscriptionPrompt language branching', () => { + it('should return Japanese prompt for ja language', () => { + const result = service.buildTranscriptionPrompt('ja'); + expect(result).toContain('以下の音声内容のみを文字に起こしてください'); + expect(result).toContain(''); + expect(result).toContain('(話者の発言のみ)'); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return English prompt for en language', () => { + const result = service.buildTranscriptionPrompt('en'); + expect(result).toContain('Please transcribe only the following audio content'); + expect(result).toContain(''); + expect(result).toContain('(Speaker content only)'); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return Chinese prompt for zh language', () => { + const result = service.buildTranscriptionPrompt('zh'); + expect(result).toContain('请仅转录以下音频内容'); + expect(result).toContain(''); + expect(result).toContain('(仅说话者内容)'); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return Korean prompt for ko language', () => { + const result = service.buildTranscriptionPrompt('ko'); + expect(result).toContain('다음 음성 내용만 전사해주세요'); + expect(result).toContain(''); + expect(result).toContain('(화자 발언만)'); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return empty string for auto language', () => { + expect(service.buildTranscriptionPrompt('auto')).toBe(''); + }); + + it('should return empty string for unsupported languages', () => { + const languages = ['fr', 'de', 'es', 'unknown']; + languages.forEach(lang => { + expect(service.buildTranscriptionPrompt(lang)).toBe(''); + }); + }); + }); + + describe('Language normalization', () => { + it('should normalize language codes correctly', () => { + expect(service.normalizeLanguage('ja')).toBe('ja'); + expect(service.normalizeLanguage('ja-JP')).toBe('ja'); + expect(service.normalizeLanguage('JA')).toBe('ja'); + + expect(service.normalizeLanguage('en')).toBe('en'); + expect(service.normalizeLanguage('en-US')).toBe('en'); + expect(service.normalizeLanguage('EN')).toBe('en'); + + expect(service.normalizeLanguage('zh')).toBe('zh'); + expect(service.normalizeLanguage('zh-CN')).toBe('zh'); + expect(service.normalizeLanguage('ZH')).toBe('zh'); + + expect(service.normalizeLanguage('ko')).toBe('ko'); + expect(service.normalizeLanguage('ko-KR')).toBe('ko'); + expect(service.normalizeLanguage('KO')).toBe('ko'); + + expect(service.normalizeLanguage('auto')).toBe('auto'); + expect(service.normalizeLanguage('fr')).toBe('fr'); + }); + }); + + describe('Whitespace and formatting cleanup', () => { + it('should clean up extra whitespace and empty lines', () => { + const input = ` \n\n\n実際の内容\n\n\n\n別の内容\n\n `; + const result = service.cleanGPT4oResponse(input, 'ja'); + // After trim, replace 3+ newlines with 2, then remove empty lines, the result should be without double newlines + expect(result).toBe('実際の内容\n別の内容'); + }); + + it('should preserve single line breaks between content', () => { + const input = `Line 1\nLine 2\nLine 3`; + const result = service.cleanGPT4oResponse(input, 'en'); + expect(result).toBe('Line 1\nLine 2\nLine 3'); + }); + + it('should convert triple+ line breaks to double then remove empty lines', () => { + const input = `Line 1\n\n\n\n\nLine 2`; + const result = service.cleanGPT4oResponse(input, 'en'); + // The regex /^\s*\n/gm removes lines that contain only whitespace, which includes the double newlines + expect(result).toBe('Line 1\nLine 2'); + }); + }); + + describe('Integration-style tests', () => { + it('should handle complex multilingual transcription response', () => { + const input = ` +以下の音声内容のみを文字に起こしてください +こんにちは、今日は会議です +出力形式: JSON +(話者の発言のみ) +`; + + const result = service.cleanGPT4oResponse(input, 'ja'); + expect(result).toBe('こんにちは、今日は会議です'); + }); + + it('should preserve content when no cleaning is needed', () => { + const input = 'Hello everyone, welcome to the meeting'; + const result = service.cleanGPT4oResponse(input, 'en'); + expect(result).toBe('Hello everyone, welcome to the meeting'); + }); + + it('should handle mixed language content appropriately', () => { + // This test now expects only exact matches to be removed + const input = `Please transcribe the following +Hello world +Format: Clean output`; + + const result = service.cleanGPT4oResponse(input, 'en'); + // Since none of these lines exactly match our prompt patterns, they should all remain + expect(result).toContain('Please transcribe the following'); + expect(result).toContain('Hello world'); + expect(result).toContain('Format: Clean output'); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/core/transcription/transcription-service-multilingual-correction.test.ts b/tests/unit/core/transcription/transcription-service-multilingual-correction.test.ts new file mode 100644 index 0000000..7c05bb4 --- /dev/null +++ b/tests/unit/core/transcription/transcription-service-multilingual-correction.test.ts @@ -0,0 +1,187 @@ +/** + * Integration tests for TranscriptionService multilingual dictionary correction + * Validates that the service applies dictionary correction to all languages when enabled + */ + +import { TranscriptionService } from '../../../../src/core/transcription/TranscriptionService'; +import { SimpleCorrectionDictionary } from '../../../../src/interfaces'; + +// Mock the ObsidianHttpClient since we're not testing actual API calls +jest.mock('../../../../src/utils/ObsidianHttpClient', () => ({ + ObsidianHttpClient: { + postFormData: jest.fn() + } +})); + +// Mock the logger +jest.mock('../../../../src/services', () => ({ + createServiceLogger: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn() + })) +})); + +describe('TranscriptionService Multilingual Dictionary Correction Integration', () => { + let service: TranscriptionService; + + const testDictionary: SimpleCorrectionDictionary = { + definiteCorrections: [ + { from: ['AI'], to: 'artificial intelligence' }, + { from: ['GPT'], to: 'Generative Pre-trained Transformer' }, + { from: ['こんにちは'], to: 'こんにちは(修正済み)' }, + { from: ['你好'], to: '您好' }, + { from: ['안녕'], to: '안녕하세요' } + ] + }; + + beforeEach(() => { + service = new TranscriptionService('test-api-key', testDictionary); + // Enable dictionary correction + service.setTranscriptionCorrection(true); + }); + + // Helper to verify correction is applied via the service's corrector + describe('Direct corrector access verification', () => { + it('should provide access to corrector with multilingual capability', async () => { + const corrector = service.getCorrector(); + + // Test multiple languages + const testCases = [ + { input: 'This AI uses GPT', expected: 'artificial intelligence' }, + { input: 'こんにちは、AIです', expected: 'こんにちは(修正済み)' }, + { input: '你好,这是AI', expected: '您好' }, + { input: '안녕, AI 시스템', expected: '안녕하세요' } + ]; + + for (const testCase of testCases) { + const result = await corrector.correct(testCase.input); + expect(result).toContain(testCase.expected); + } + }); + + it('should respect the enableTranscriptionCorrection setting', async () => { + const corrector = service.getCorrector(); + + // Test with correction enabled + service.setTranscriptionCorrection(true); + let result = await corrector.correct('This AI system uses GPT'); + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + + // Test with correction disabled + service.setTranscriptionCorrection(false); + result = await corrector.correct('This AI system uses GPT'); + expect(result).toBe('This AI system uses GPT'); // Should remain unchanged + }); + }); + + describe('Dictionary update functionality', () => { + it('should allow updating dictionary and apply new corrections to all languages', async () => { + const newDictionary: SimpleCorrectionDictionary = { + definiteCorrections: [ + { from: ['ML'], to: 'Machine Learning' }, + { from: ['さようなら'], to: 'さようなら(丁寧)' }, + { from: ['再见'], to: '再見' }, + { from: ['가다'], to: '가시다' } + ] + }; + + // Update dictionary + service.setCustomDictionary(newDictionary); + const corrector = service.getCorrector(); + + // Test that new corrections work for multiple languages + const testCases = [ + { input: 'ML is powerful', expected: 'Machine Learning' }, + { input: 'さようなら、また明日', expected: 'さようなら(丁寧)' }, + { input: '再见朋友', expected: '再見' }, + { input: '가다 오다', expected: '가시다' } + ]; + + for (const testCase of testCases) { + const result = await corrector.correct(testCase.input); + expect(result).toContain(testCase.expected); + } + + // Test that old corrections are replaced + const oldResult = await corrector.correct('This AI uses GPT'); + expect(oldResult).toBe('This AI uses GPT'); // Old corrections should not apply + }); + }); + + describe('Settings update integration', () => { + it('should respect corrector settings updates for all languages', async () => { + const corrector = service.getCorrector(); + + // Initial test - corrections should work + let result = await corrector.correct('AI and GPT systems こんにちは'); + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + expect(result).toContain('こんにちは(修正済み)'); + + // Update settings to disable corrections + service.updateCorrectorSettings({ enabled: false }); + + // Corrections should no longer apply to any language + result = await corrector.correct('AI and GPT systems こんにちは'); + expect(result).toBe('AI and GPT systems こんにちは'); + + // Re-enable corrections + service.updateCorrectorSettings({ enabled: true }); + + // Corrections should work again for all languages + result = await corrector.correct('AI and GPT systems こんにちは'); + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + expect(result).toContain('こんにちは(修正済み)'); + }); + }); + + describe('API key update preservation', () => { + it('should preserve dictionary settings when API key is updated', async () => { + const corrector = service.getCorrector(); + + // Test initial corrections work + let result = await corrector.correct('AI and GPT systems'); + expect(result).toContain('artificial intelligence'); + + // Update API key + service.updateApiKey('new-test-api-key'); + + // Dictionary corrections should still work + const newCorrector = service.getCorrector(); + result = await newCorrector.correct('AI and GPT systems'); + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + }); + }); + + describe('Model update compatibility', () => { + it('should maintain dictionary correction functionality across model changes', async () => { + const corrector = service.getCorrector(); + + // Test with initial model + let result = await corrector.correct('AI uses GPT technology'); + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + + // Change model + service.setModel('gpt-4o-mini-transcribe'); + + // Dictionary corrections should still work + result = await corrector.correct('AI uses GPT technology'); + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + + // Change to other model + service.setModel('gpt-4o-transcribe'); + + // Dictionary corrections should still work + result = await corrector.correct('AI uses GPT technology'); + expect(result).toContain('artificial intelligence'); + expect(result).toContain('Generative Pre-trained Transformer'); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/core/transcription/transcription-simple.test.ts b/tests/unit/core/transcription/transcription-simple.test.ts new file mode 100644 index 0000000..f2f12a9 --- /dev/null +++ b/tests/unit/core/transcription/transcription-simple.test.ts @@ -0,0 +1,214 @@ +/** + * Simple tests that verify the core transcription cleaning logic + * without complex service dependencies + */ + +describe('Transcription Cleaning Logic', () => { + + // Helper function that replicates the cleaning logic from TranscriptionService + function cleanGPT4oResponse(text: string): string { + // First attempt: Extract content from complete TRANSCRIPT tags + let transcriptMatch = text.match(/\s*([\s\S]*?)\s*<\/TRANSCRIPT>/); + if (transcriptMatch) { + text = transcriptMatch[1]; + } else { + // Second attempt: Handle incomplete TRANSCRIPT tags (missing closing tag) + const openingMatch = text.match(/\s*([\s\S]*)/); + if (openingMatch) { + text = openingMatch[1]; + } + } + + // Remove TRANSCRIPT opening tag if still present + text = text.replace(/<\/?TRANSCRIPT[^>]*>/g, ''); + + // Remove specific meta instruction patterns + const metaPatterns = [ + /^以下の音声内容.*?$/gm, + /^この指示文.*?$/gm, + /^話者の発言内容だけを正確に記録してください.*?$/gm, + /^話者の発言.*?$/gm, + /^出力形式.*?$/gm, + /(話者の発言のみ)/g, + ]; + + // Apply all cleaning patterns + for (const pattern of metaPatterns) { + text = text.replace(pattern, ''); + } + + // Clean up extra whitespace and empty lines + text = text.trim(); + text = text.replace(/\n{3,}/g, '\n\n'); + text = text.replace(/^\s*\n/gm, ''); + text = text.trim(); + + return text; + } + + // Helper function for prompt building logic + function buildTranscriptionPrompt(language: string): string { + // Only provide prompt for Japanese language + if (language !== 'ja') { + return ''; + } + + return `以下の音声内容のみを文字に起こしてください。この指示文は出力に含めないでください。 +話者の発言内容だけを正確に記録してください。 + +出力形式: + +(話者の発言のみ) +`; + } + + // Helper function for locale detection logic + function detectPluginLanguage(obsidianLocale: string): 'ja' | 'en' | 'zh' | 'ko' { + const locale = obsidianLocale.toLowerCase(); + if (locale.startsWith('ja')) return 'ja'; + if (locale.startsWith('zh')) return 'zh'; + if (locale.startsWith('ko')) return 'ko'; + return 'en'; + } + + describe('TRANSCRIPT tag extraction', () => { + it('should extract content from complete TRANSCRIPT tags', () => { + const input = `Some prefix text + +実際の発言内容 + +Some suffix text`; + const result = cleanGPT4oResponse(input); + expect(result).toContain('実際の発言内容'); + expect(result).not.toContain(''); + expect(result).not.toContain(''); + }); + + it('should handle incomplete TRANSCRIPT tags (missing closing tag)', () => { + const input = `Some prefix text + +実際の発言内容 +Additional content`; + const result = cleanGPT4oResponse(input); + expect(result).toContain('実際の発言内容'); + expect(result).toContain('Additional content'); + expect(result).not.toContain(''); + }); + }); + + describe('Meta instruction pattern removal', () => { + it('should remove Japanese meta instructions', () => { + const patterns = [ + '以下の音声内容のみを文字に起こしてください', + 'この指示文は出力に含めないでください', + '話者の発言内容だけを正確に記録してください', + '出力形式:' + ]; + + patterns.forEach(pattern => { + const input = `${pattern}\n実際の発言\nその他の内容`; + const result = cleanGPT4oResponse(input); + expect(result).not.toContain(pattern); + expect(result).toContain('実際の発言'); + expect(result).toContain('その他の内容'); + }); + }); + + it('should remove meta phrases anywhere in text', () => { + const input = '前の文章 (話者の発言のみ) 後の文章'; + const result = cleanGPT4oResponse(input); + expect(result).not.toContain('(話者の発言のみ)'); + expect(result).toContain('前の文章'); + expect(result).toContain('後の文章'); + }); + + it('should preserve normal content that might contain similar words', () => { + const input = '今日は出力について話しました。発言が重要です。'; + const result = cleanGPT4oResponse(input); + expect(result).toContain('今日は出力について話しました'); + expect(result).toContain('発言が重要です'); + }); + }); + + describe('buildTranscriptionPrompt language branching', () => { + it('should return Japanese prompt for ja language', () => { + const result = buildTranscriptionPrompt('ja'); + expect(result).toContain('以下の音声内容のみを文字に起こしてください'); + expect(result).toContain(''); + expect(result).toContain('(話者の発言のみ)'); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return empty string for non-ja languages', () => { + const languages = ['en', 'zh', 'ko', 'auto', 'fr']; + languages.forEach(lang => { + expect(buildTranscriptionPrompt(lang)).toBe(''); + }); + }); + }); + + describe('Locale detection for multilingual support', () => { + it('should detect Japanese locale variants', () => { + const jaLocales = ['ja', 'ja-JP', 'ja_JP', 'JA', 'JA-JP']; + jaLocales.forEach(locale => { + expect(detectPluginLanguage(locale)).toBe('ja'); + }); + }); + + it('should detect Chinese locale variants', () => { + const zhLocales = ['zh', 'zh-CN', 'zh-TW', 'zh_CN', 'ZH', 'zh-Hans', 'zh-Hant']; + zhLocales.forEach(locale => { + expect(detectPluginLanguage(locale)).toBe('zh'); + }); + }); + + it('should detect Korean locale variants', () => { + const koLocales = ['ko', 'ko-KR', 'ko_KR', 'KO', 'KO-KR']; + koLocales.forEach(locale => { + expect(detectPluginLanguage(locale)).toBe('ko'); + }); + }); + + it('should default to English for other locales', () => { + const otherLocales = ['en', 'en-US', 'en-GB', 'fr', 'de', 'es', 'it', 'ru', 'unknown', '']; + otherLocales.forEach(locale => { + expect(detectPluginLanguage(locale)).toBe('en'); + }); + }); + }); + + describe('Error detection patterns', () => { + function isPromptErrorDetected(text: string): boolean { + return text.includes('この指示文は出力に含めないでください') || + text.includes('話者の発言内容だけを正確に記録してください') || + text === '(話者の発言のみ)' || + text.trim() === '話者の発言のみ'; + } + + it('should detect prompt error patterns', () => { + const errorPatterns = [ + 'この指示文は出力に含めないでください', + '話者の発言内容だけを正確に記録してください', + '(話者の発言のみ)', + '話者の発言のみ' + ]; + + errorPatterns.forEach(pattern => { + expect(isPromptErrorDetected(pattern)).toBe(true); + }); + }); + + it('should not detect normal content as errors', () => { + const normalContent = [ + 'こんにちは、今日はいい天気ですね', + 'Meeting started at 2 PM', + '会議の議事録を記録します', + 'The speaker mentioned important points' + ]; + + normalContent.forEach(content => { + expect(isPromptErrorDetected(content)).toBe(false); + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/settings/locale-detection.test.ts b/tests/unit/settings/locale-detection.test.ts new file mode 100644 index 0000000..1ef9763 --- /dev/null +++ b/tests/unit/settings/locale-detection.test.ts @@ -0,0 +1,253 @@ +/** + * Tests for locale detection functionality + * Validates getObsidianLocale() function with different Obsidian app configurations + */ + +import { getObsidianLocale } from '../../../src/types/obsidian-internal'; +import { App } from 'obsidian'; + +// Mock Obsidian's moment export +jest.mock('obsidian', () => ({ + ...jest.requireActual('obsidian'), + moment: { + locale: jest.fn() + } +})); + +// Import moment after mocking +import { moment } from 'obsidian'; + +describe('Locale Detection', () => { + beforeEach(() => { + // Reset mocks + (moment.locale as jest.Mock).mockReset(); + }); + + describe('getObsidianLocale() function', () => { + it('should detect ja-JP locale from vault config', () => { + const mockApp = { + vault: { + config: { + locale: 'ja-JP' + } + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe('ja-jp'); + }); + + it('should detect en-US locale from vault config', () => { + const mockApp = { + vault: { + config: { + locale: 'en-US' + } + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe('en-us'); + }); + + it('should detect zh-CN locale from vault config', () => { + const mockApp = { + vault: { + config: { + locale: 'zh-CN' + } + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe('zh-cn'); + }); + + it('should detect ko-KR locale from vault config', () => { + const mockApp = { + vault: { + config: { + locale: 'ko-KR' + } + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe('ko-kr'); + }); + + it('should fallback to app.locale when vault config is not available', () => { + const mockApp = { + locale: 'ja-JP', + vault: { + config: {} + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe('ja-jp'); + }); + + it('should fallback to moment.locale() when app locale is not available', () => { + (moment.locale as jest.Mock).mockReturnValue('zh-CN'); + + const mockApp = { + vault: { + config: {} + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe('zh-cn'); + expect(moment.locale).toHaveBeenCalled(); + }); + + it('should fallback to "en" when no locale is available', () => { + (moment.locale as jest.Mock).mockReturnValue(undefined); + + const mockApp = { + vault: { + config: {} + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe('en'); + }); + + it('should normalize locale codes to lowercase', () => { + const testCases = [ + { input: 'JA-JP', expected: 'ja-jp' }, + { input: 'EN-US', expected: 'en-us' }, + { input: 'ZH-CN', expected: 'zh-cn' }, + { input: 'KO-KR', expected: 'ko-kr' }, + { input: 'Fr-FR', expected: 'fr-fr' }, + { input: 'DE', expected: 'de' } + ]; + + testCases.forEach(({ input, expected }) => { + const mockApp = { + vault: { + config: { + locale: input + } + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe(expected); + }); + }); + + it('should handle missing vault property gracefully', () => { + (moment.locale as jest.Mock).mockReturnValue('en-US'); + + const mockApp = {} as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe('en-us'); + }); + + it('should handle null/undefined moment locale gracefully', () => { + (moment.locale as jest.Mock).mockReturnValue(null); + + const mockApp = { + vault: { + config: {} + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe('en'); + }); + + it('should prioritize vault config over app locale', () => { + const mockApp = { + locale: 'en-US', // This should be ignored + vault: { + config: { + locale: 'ja-JP' // This should take priority + } + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe('ja-jp'); + }); + + it('should prioritize app locale over moment locale', () => { + (moment.locale as jest.Mock).mockReturnValue('ko-KR'); // This should be ignored + + const mockApp = { + locale: 'zh-CN', // This should take priority + vault: { + config: {} + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe('zh-cn'); + }); + }); + + describe('Locale integration scenarios', () => { + it('should handle complex locale codes correctly', () => { + const complexLocales = [ + { input: 'ja-JP-u-ca-japanese', expected: 'ja-jp-u-ca-japanese' }, + { input: 'en-US-POSIX', expected: 'en-us-posix' }, + { input: 'zh-Hans-CN', expected: 'zh-hans-cn' }, + { input: 'ko-Kore-KR', expected: 'ko-kore-kr' } + ]; + + complexLocales.forEach(({ input, expected }) => { + const mockApp = { + vault: { + config: { + locale: input + } + } + } as App; + + const result = getObsidianLocale(mockApp); + expect(result).toBe(expected); + }); + }); + + it('should handle various fallback chains correctly', () => { + // Test the full fallback chain: vault.config.locale → app.locale → moment.locale() → 'en' + const fallbackChains = [ + { + name: 'vault config available', + mockApp: { vault: { config: { locale: 'ja-JP' } } }, + momentReturn: 'en-US', + expected: 'ja-jp' + }, + { + name: 'app locale available', + mockApp: { locale: 'zh-CN', vault: { config: {} } }, + momentReturn: 'en-US', + expected: 'zh-cn' + }, + { + name: 'moment locale available', + mockApp: { vault: { config: {} } }, + momentReturn: 'ko-KR', + expected: 'ko-kr' + }, + { + name: 'default fallback', + mockApp: { vault: { config: {} } }, + momentReturn: undefined, + expected: 'en' + } + ]; + + fallbackChains.forEach(({ name, mockApp, momentReturn, expected }) => { + (moment.locale as jest.Mock).mockReturnValue(momentReturn); + + const result = getObsidianLocale(mockApp as App); + expect(result).toBe(expected); + }); + }); + }); +}); \ No newline at end of file diff --git a/versions.json b/versions.json index 068ee55..1544cf5 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,4 @@ { - "0.8.0": "17.0.0" + "0.8.0": "17.0.0", + "0.9.0": "1.7.0" } \ No newline at end of file