Merge branch 'feat/multilingual-improvements'

# Conflicts:
#	manifest.json
This commit is contained in:
mssoftjp 2025-10-04 18:51:40 +09:00
commit f85e680184
40 changed files with 4417 additions and 161 deletions

1
.gitignore vendored
View file

@ -100,3 +100,4 @@ agents.md
# Personal, not for public release
scripts/
memo/
tests/

View file

@ -7,9 +7,12 @@ High-accuracy voice input for Obsidian. Uses OpenAI GPT-4o Audio Transcriptions
- Oneclick recording: start/stop from a microphone ribbon icon
- Pushtotalk: longpress to record, release to stop
- Model selection: GPT4o Transcribe or GPT4o mini Transcribe
- AI postprocessing: 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 postprocessing: optional dictionary-based cleanup (applied to all languages when enabled)
- Quick controls in view: copy/clear/insert at cursor/append to end
- Autosave 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 plugins settings.
- OpenAI API Key: stored locally (encrypted at rest)
- Transcription Model: `gpt-4o-transcribe` or `gpt-4o-mini-transcribe`
- AI Postprocessing: enable dictionarybased cleanup (Japanese)
- **Transcription Language**: Auto/Japanese/English/Chinese/Korean (auto-detection recommended)
- AI Postprocessing: enable dictionarybased cleanup (applied to all languages when enabled)
- Maximum Recording Duration: slider (default 5 min)
- Plugin Language: English/Japanese (autodetected from Obsidian, adjustable)
- Plugin Language: English/Japanese (controls UI display only, autodetected from Obsidian, adjustable)
## Security & Privacy
- Processing in memory; audio is not written to disk by the plugin
- HTTPS for all network requests (via Obsidians `requestUrl`)
- Audio you record is transmitted to OpenAI for transcription over HTTPS (via Obsidians `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 desktoponly by design.
See also OpenAIs Privacy Policy.
## Troubleshooting
@ -72,6 +78,10 @@ See also OpenAIs 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)
Thirdparty licensing: see `THIRD_PARTY_LICENSES.md`.
@ -85,9 +95,12 @@ Thirdparty licensing: see `THIRD_PARTY_LICENSES.md`.
- ワンクリック録音(リボンのマイクアイコン)
- プッシュトゥトーク(長押しで録音開始、離して停止)
- モデル選択GPT4o Transcribe / GPT4o mini Transcribe
- AI後処理辞書ベースの補正、JA向け
- 言語設定の分離UI言語と音声認識言語を独立設定
- 自動言語検出Obsidianロケールに基づく音声認識言語の自動検出
- AI後処理辞書ベースの補正、日本語のみ適用
- ビュー内のクイック操作(コピー/クリア/カーソル位置へ挿入/末尾へ追記)
- 自動保存(定期保存とフォーカス外れ時)。再オープン時に自動復元
- 多言語サポート(日本語、英語、中国語、韓国語のインターフェース)
## 必要条件
@ -125,16 +138,37 @@ Thirdparty 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): プロンプトは付与しません。クリーニングは言語別+汎用(コロン付き)で安全側に適用。
- 自動: 上記の動作を自動選択。
## セキュリティ / プライバシー
- 処理はメモリ内で行い、音声ファイルはプラグイン側でディスク保存しません
- 通信はHTTPSObsidianの `requestUrl` 経由)
- 録音した音声は文字起こしのため OpenAI に送信され、HTTPSObsidian の `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` を参照してください。
## ライセンス

View file

@ -25,4 +25,4 @@ module.exports = {
moduleNameMapper: {
'^obsidian$': '<rootDir>/tests/mocks/obsidian.ts',
},
};
};

View file

@ -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
View file

@ -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",

View file

@ -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": {

View 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]
}
};

View file

@ -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,
/** 音声設定 */

View file

@ -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;
}
}
}
}

View file

@ -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];
}
}
}

View file

@ -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
});
}

View 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();
}
}

View 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]);
}
}

View 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;
}
}

View 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[];
}

View file

@ -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'
},
}
}
};

View file

@ -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: '文脈キーワード'
},
}
}
};

View file

@ -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: '문맥 키워드'
},
}
}
};

View file

@ -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: '语境关键词'
},
}
}
};

View file

@ -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;

View file

@ -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'
}
};

View file

@ -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,

View file

@ -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') };
}
}
}

View file

@ -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' });

View file

@ -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;
}
}

View file

@ -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 */ }
}
}

View file

@ -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;
}
}

View file

@ -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)
}
/**

View 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
View 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
View 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
View 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

View file

@ -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);
});
});
});

View file

@ -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(/出力形式/);
});
});
});

View file

@ -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();
});
});
});

View 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');
});
});
});

View file

@ -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');
});
});
});

View 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);
});
});
});
});

View 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);
});
});
});
});

View file

@ -1,3 +1,4 @@
{
"0.8.0": "17.0.0"
"0.8.0": "17.0.0",
"0.9.0": "1.7.0"
}