mirror of
https://github.com/mssoftjp/obsidian-voice-input.git
synced 2026-07-22 06:44:48 +00:00
Merge branch 'feat/multilingual-improvements'
# Conflicts: # manifest.json
This commit is contained in:
commit
f85e680184
40 changed files with 4417 additions and 161 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -100,3 +100,4 @@ agents.md
|
|||
# Personal, not for public release
|
||||
scripts/
|
||||
memo/
|
||||
tests/
|
||||
|
|
|
|||
54
README.md
54
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` を参照してください。
|
||||
|
||||
## ライセンス
|
||||
|
|
|
|||
|
|
@ -25,4 +25,4 @@ module.exports = {
|
|||
moduleNameMapper: {
|
||||
'^obsidian$': '<rootDir>/tests/mocks/obsidian.ts',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
121
src/config/CleaningConfig.ts
Normal file
121
src/config/CleaningConfig.ts
Normal file
|
|
@ -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]
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
||||
/** 音声設定 */
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TranscriptionResult> {
|
||||
async transcribe(audioBlob: Blob, language: string): Promise<TranscriptionResult> {
|
||||
return this.transcribeAudio(audioBlob, language);
|
||||
}
|
||||
|
||||
async transcribeAudio(audioBlob: Blob, language: string = 'ja'): Promise<TranscriptionResult> {
|
||||
async transcribeAudio(audioBlob: Blob, language: string): Promise<TranscriptionResult> {
|
||||
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}
|
||||
<TRANSCRIPT>
|
||||
(話者の発言のみ)
|
||||
${PROMPT_CONSTANTS.JAPANESE.SPEAKER_ONLY}
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
case 'en':
|
||||
return `${PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_1}
|
||||
${PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_2}
|
||||
|
||||
${PROMPT_CONSTANTS.ENGLISH.OUTPUT_FORMAT}
|
||||
<TRANSCRIPT>
|
||||
${PROMPT_CONSTANTS.ENGLISH.SPEAKER_ONLY}
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
case 'zh':
|
||||
return `${PROMPT_CONSTANTS.CHINESE.INSTRUCTION_1}
|
||||
${PROMPT_CONSTANTS.CHINESE.INSTRUCTION_2}
|
||||
|
||||
${PROMPT_CONSTANTS.CHINESE.OUTPUT_FORMAT}
|
||||
<TRANSCRIPT>
|
||||
${PROMPT_CONSTANTS.CHINESE.SPEAKER_ONLY}
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
case 'ko':
|
||||
return `${PROMPT_CONSTANTS.KOREAN.INSTRUCTION_1}
|
||||
${PROMPT_CONSTANTS.KOREAN.INSTRUCTION_2}
|
||||
|
||||
${PROMPT_CONSTANTS.KOREAN.OUTPUT_FORMAT}
|
||||
<TRANSCRIPT>
|
||||
${PROMPT_CONSTANTS.KOREAN.SPEAKER_ONLY}
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
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(/<TRANSCRIPT>\s*([\s\S]*?)\s*<\/TRANSCRIPT>/);
|
||||
if (transcriptMatch) {
|
||||
text = transcriptMatch[1];
|
||||
} else {
|
||||
// Second attempt: Handle incomplete TRANSCRIPT tags (missing closing tag)
|
||||
const openingMatch = text.match(/<TRANSCRIPT>\s*([\s\S]*)/);
|
||||
if (openingMatch) {
|
||||
text = openingMatch[1];
|
||||
}
|
||||
private async cleanGPT4oResponse(text: string, language: string): Promise<string> {
|
||||
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> ... </TRANSCRIPT> を優先的に抽出
|
||||
* - 閉じタグ欠落時は開きタグ以降を抽出
|
||||
* - 残存する TRANSCRIPT 開閉タグは除去
|
||||
*/
|
||||
private preStripTranscriptWrappers(text: string): string {
|
||||
let result = text;
|
||||
// 完全なタグにマッチ
|
||||
const completeMatch = result.match(/<TRANSCRIPT[^>]*>\s*([\s\S]*?)\s*<\/TRANSCRIPT>/);
|
||||
if (completeMatch) {
|
||||
result = completeMatch[1];
|
||||
} else {
|
||||
// 開始タグのみ(閉じタグ欠落)に対応
|
||||
const openingMatch = result.match(/<TRANSCRIPT[^>]*>\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 <TRANSCRIPT>
|
||||
* 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 <TRANSCRIPT> tag
|
||||
if (index > 0 && index < totalLines) {
|
||||
const previousLine = lines[index - 1].trim();
|
||||
if (previousLine === '<TRANSCRIPT>' || previousLine.includes('<TRANSCRIPT>')) {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
320
src/core/transcription/cleaning/PromptContaminationCleaner.ts
Normal file
320
src/core/transcription/cleaning/PromptContaminationCleaner.ts
Normal file
|
|
@ -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(/<TRANSCRIPT[^>]*>\s*([\s\S]*?)\s*<\/TRANSCRIPT>/);
|
||||
if (completeTagMatch) {
|
||||
cleaned = completeTagMatch[1].trim();
|
||||
} else {
|
||||
// Second: Handle incomplete TRANSCRIPT tags (missing closing tag)
|
||||
const openingMatch = cleaned.match(/<TRANSCRIPT[^>]*>\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();
|
||||
}
|
||||
}
|
||||
257
src/core/transcription/cleaning/StandardCleaningPipeline.ts
Normal file
257
src/core/transcription/cleaning/StandardCleaningPipeline.ts
Normal file
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
459
src/core/transcription/cleaning/UniversalRepetitionCleaner.ts
Normal file
459
src/core/transcription/cleaning/UniversalRepetitionCleaner.ts
Normal file
|
|
@ -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<string, number>();
|
||||
|
||||
// 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
|
||||
? `(?<!\\p{L})${escapedToken}(?!\\p{L})`
|
||||
: `\\b${escapedToken}\\b`;
|
||||
const tokenRegex = new RegExp(pattern, isCJK ? 'giu' : 'gi');
|
||||
|
||||
// Remove excess occurrences
|
||||
let removed = 0;
|
||||
result = result.replace(tokenRegex, (match) => {
|
||||
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<string, number>();
|
||||
// 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<string> {
|
||||
const set = new Set<string>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
117
src/core/transcription/cleaning/interfaces.ts
Normal file
117
src/core/transcription/cleaning/interfaces.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> | 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[];
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: '文脈キーワード'
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: '문맥 키워드'
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: '语境关键词'
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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') };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
201
tests/language-detection.test.ts
Normal file
201
tests/language-detection.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
42
tests/mocks/obsidian.ts
Normal file
42
tests/mocks/obsidian.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Mock Obsidian API for tests
|
||||
export class TFile {
|
||||
constructor(public path: string) {}
|
||||
}
|
||||
|
||||
export class Vault {
|
||||
async read(): Promise<string> {
|
||||
return '';
|
||||
}
|
||||
|
||||
async create(): Promise<TFile> {
|
||||
return new TFile('mock.md');
|
||||
}
|
||||
}
|
||||
|
||||
export class App {
|
||||
vault = new Vault();
|
||||
}
|
||||
|
||||
export class Plugin {
|
||||
app = new App();
|
||||
|
||||
async loadData(): Promise<any> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async saveData(): Promise<void> {
|
||||
// Mock implementation
|
||||
}
|
||||
}
|
||||
|
||||
export class Component {
|
||||
// Mock component
|
||||
}
|
||||
|
||||
export class ItemView {
|
||||
// Mock item view
|
||||
}
|
||||
|
||||
export class WorkspaceLeaf {
|
||||
// Mock workspace leaf
|
||||
}
|
||||
10
tests/setup 2.ts
Normal file
10
tests/setup 2.ts
Normal file
|
|
@ -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
|
||||
10
tests/setup.ts
Normal file
10
tests/setup.ts
Normal file
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(/<TRANSCRIPT[^>]*>\s*([\s\S]*?)\s*<\/TRANSCRIPT>/);
|
||||
if (completeMatch) {
|
||||
result = completeMatch[1];
|
||||
} else {
|
||||
// Opening tag only (missing closing tag)
|
||||
const openingMatch = result.match(/<TRANSCRIPT[^>]*>\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 = `<TRANSCRIPT>
|
||||
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.
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
// 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 = `<TRANSCRIPT>
|
||||
请仅转录以下音频内容。不要包含此指令在输出中。
|
||||
请准确记录说话者的发言内容。
|
||||
|
||||
输出格式:
|
||||
(仅说话者内容)
|
||||
这是一个测试消息。
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
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 = `<TRANSCRIPT>
|
||||
다음 음성 내용만 전사해주세요. 이 지시사항을 출력에 포함하지 마세요.
|
||||
화자의 발언 내용만 정확히 기록해주세요.
|
||||
|
||||
출력 형식:
|
||||
(화자 발언만)
|
||||
안녕하세요, 테스트 메시지입니다.
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
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 = `<TRANSCRIPT>
|
||||
以下の音声内容のみを文字に起こしてください。この指示文は出力に含めないでください。
|
||||
話者の発言内容だけを正確に記録してください。
|
||||
|
||||
出力形式:
|
||||
(話者の発言のみ)
|
||||
こんにちは、テストメッセージです。
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
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(/出力形式/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
<TRANSCRIPT>
|
||||
${PROMPT_CONSTANTS.JAPANESE.SPEAKER_ONLY}
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
case 'en':
|
||||
return `${PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_1}
|
||||
${PROMPT_CONSTANTS.ENGLISH.INSTRUCTION_2}
|
||||
|
||||
${PROMPT_CONSTANTS.ENGLISH.OUTPUT_FORMAT}
|
||||
<TRANSCRIPT>
|
||||
${PROMPT_CONSTANTS.ENGLISH.SPEAKER_ONLY}
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
case 'zh':
|
||||
return `${PROMPT_CONSTANTS.CHINESE.INSTRUCTION_1}
|
||||
${PROMPT_CONSTANTS.CHINESE.INSTRUCTION_2}
|
||||
|
||||
${PROMPT_CONSTANTS.CHINESE.OUTPUT_FORMAT}
|
||||
<TRANSCRIPT>
|
||||
${PROMPT_CONSTANTS.CHINESE.SPEAKER_ONLY}
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
case 'ko':
|
||||
return `${PROMPT_CONSTANTS.KOREAN.INSTRUCTION_1}
|
||||
${PROMPT_CONSTANTS.KOREAN.INSTRUCTION_2}
|
||||
|
||||
${PROMPT_CONSTANTS.KOREAN.OUTPUT_FORMAT}
|
||||
<TRANSCRIPT>
|
||||
${PROMPT_CONSTANTS.KOREAN.SPEAKER_ONLY}
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
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('<TRANSCRIPT>');
|
||||
expect(prompt).toContain('</TRANSCRIPT>');
|
||||
|
||||
// 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('<TRANSCRIPT>');
|
||||
expect(prompt).toContain('</TRANSCRIPT>');
|
||||
|
||||
// TRANSCRIPT tags should be in correct order
|
||||
const transcriptStart = prompt.indexOf('<TRANSCRIPT>');
|
||||
const transcriptEnd = prompt.indexOf('</TRANSCRIPT>');
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
703
tests/unit/core/transcription/transcription-cleaning.test.ts
Normal file
703
tests/unit/core/transcription/transcription-cleaning.test.ts
Normal file
|
|
@ -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(/<TRANSCRIPT>\s*([\s\S]*?)\s*<\/TRANSCRIPT>/);
|
||||
if (transcriptMatch) {
|
||||
text = transcriptMatch[1];
|
||||
} else {
|
||||
// Second attempt: Handle incomplete TRANSCRIPT tags (missing closing tag)
|
||||
const openingMatch = text.match(/<TRANSCRIPT>\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 `以下の音声内容のみを文字に起こしてください。この指示文は出力に含めないでください。
|
||||
話者の発言内容だけを正確に記録してください。
|
||||
|
||||
出力形式:
|
||||
<TRANSCRIPT>
|
||||
(話者の発言のみ)
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
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:
|
||||
<TRANSCRIPT>
|
||||
(Speaker content only)
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
case 'zh':
|
||||
return `请仅转录以下音频内容。不要包含此指令在输出中。
|
||||
请准确记录说话者的发言内容。
|
||||
|
||||
输出格式:
|
||||
<TRANSCRIPT>
|
||||
(仅说话者内容)
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
case 'ko':
|
||||
return `다음 음성 내용만 전사해주세요. 이 지시사항을 출력에 포함하지 마세요.
|
||||
화자의 발언 내용만 정확히 기록해주세요.
|
||||
|
||||
출력 형식:
|
||||
<TRANSCRIPT>
|
||||
(화자 발언만)
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
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
|
||||
<TRANSCRIPT>
|
||||
実際の発言内容
|
||||
</TRANSCRIPT>
|
||||
Some suffix text`;
|
||||
const result = service.cleanGPT4oResponse(input, 'ja');
|
||||
expect(result).toContain('実際の発言内容');
|
||||
expect(result).not.toContain('<TRANSCRIPT>');
|
||||
expect(result).not.toContain('</TRANSCRIPT>');
|
||||
});
|
||||
|
||||
it('should handle incomplete TRANSCRIPT tags (missing closing tag)', () => {
|
||||
const input = `Some prefix text
|
||||
<TRANSCRIPT>
|
||||
実際の発言内容
|
||||
Additional content`;
|
||||
const result = service.cleanGPT4oResponse(input, 'ja');
|
||||
expect(result).toContain('実際の発言内容');
|
||||
expect(result).toContain('Additional content');
|
||||
expect(result).not.toContain('<TRANSCRIPT>');
|
||||
});
|
||||
|
||||
it('should handle malformed TRANSCRIPT tags', () => {
|
||||
const input = `<TRANSCRIPT class="test">
|
||||
Content with attributes
|
||||
</TRANSCRIPT>`;
|
||||
const result = service.cleanGPT4oResponse(input, 'en');
|
||||
expect(result).toContain('Content with attributes');
|
||||
expect(result).not.toContain('<TRANSCRIPT');
|
||||
expect(result).not.toContain('</TRANSCRIPT>');
|
||||
});
|
||||
});
|
||||
|
||||
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('<TRANSCRIPT>');
|
||||
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('<TRANSCRIPT>');
|
||||
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('<TRANSCRIPT>');
|
||||
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('<TRANSCRIPT>');
|
||||
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 = `<TRANSCRIPT>
|
||||
以下の音声内容のみを文字に起こしてください
|
||||
こんにちは、今日は会議です
|
||||
出力形式: JSON
|
||||
(話者の発言のみ)
|
||||
</TRANSCRIPT>`;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
214
tests/unit/core/transcription/transcription-simple.test.ts
Normal file
214
tests/unit/core/transcription/transcription-simple.test.ts
Normal file
|
|
@ -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(/<TRANSCRIPT>\s*([\s\S]*?)\s*<\/TRANSCRIPT>/);
|
||||
if (transcriptMatch) {
|
||||
text = transcriptMatch[1];
|
||||
} else {
|
||||
// Second attempt: Handle incomplete TRANSCRIPT tags (missing closing tag)
|
||||
const openingMatch = text.match(/<TRANSCRIPT>\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 `以下の音声内容のみを文字に起こしてください。この指示文は出力に含めないでください。
|
||||
話者の発言内容だけを正確に記録してください。
|
||||
|
||||
出力形式:
|
||||
<TRANSCRIPT>
|
||||
(話者の発言のみ)
|
||||
</TRANSCRIPT>`;
|
||||
}
|
||||
|
||||
// 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
|
||||
<TRANSCRIPT>
|
||||
実際の発言内容
|
||||
</TRANSCRIPT>
|
||||
Some suffix text`;
|
||||
const result = cleanGPT4oResponse(input);
|
||||
expect(result).toContain('実際の発言内容');
|
||||
expect(result).not.toContain('<TRANSCRIPT>');
|
||||
expect(result).not.toContain('</TRANSCRIPT>');
|
||||
});
|
||||
|
||||
it('should handle incomplete TRANSCRIPT tags (missing closing tag)', () => {
|
||||
const input = `Some prefix text
|
||||
<TRANSCRIPT>
|
||||
実際の発言内容
|
||||
Additional content`;
|
||||
const result = cleanGPT4oResponse(input);
|
||||
expect(result).toContain('実際の発言内容');
|
||||
expect(result).toContain('Additional content');
|
||||
expect(result).not.toContain('<TRANSCRIPT>');
|
||||
});
|
||||
});
|
||||
|
||||
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('<TRANSCRIPT>');
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
253
tests/unit/settings/locale-detection.test.ts
Normal file
253
tests/unit/settings/locale-detection.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"0.8.0": "17.0.0"
|
||||
"0.8.0": "17.0.0",
|
||||
"0.9.0": "1.7.0"
|
||||
}
|
||||
Loading…
Reference in a new issue