iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
AI Engineering

從行程到應援:30 天做出追星實用工具箱系列 第 17 篇

Day 17:上下文感知翻譯,避免口語化斷章取義

  • 分享至 

  • xImage
  •  

一、前言

昨天,我們已將逐字稿透過LLM翻譯,還加入了追星人常用的飯圈用語,但可能還會遇到一個問題點就是會斷章取義,導致翻譯出來的句子會讓人誤會。

今天我們要實作上下文感知滑動視窗翻譯機制!

  • 設計對話歷史記憶庫 (Sliding Context Window):維護近 3~5 句的對話上下文紀錄。
  • 升級 TranslationService:將「對話歷史 + 當前句子」一起打包丟給 LLM 進行上下文感知翻譯。
  • 保持語法自然銜接:確保前文與後文的稱謂、時態與語氣保持一致。

二、今日任務

Step 1: 建立上下文對話模型與記憶庫

在 lib/features/live_translate/data/models/ 下建立 chat_context_data.dart

import 'dart:collection';

class ChatContextMessage {
  final String rawText;        // 原始 STT 逐字稿
  final String translatedText; // 翻譯後的文字

  ChatContextMessage({
    required this.rawText,
    required this.translatedText,
  });
}

class ChatContextManager {
  final int maxHistory; // 最多保留幾句上下文
  final Queue<ChatContextMessage> _history = Queue<ChatContextMessage>();

  ChatContextManager({this.maxHistory = 3});

  /// 新增對話紀錄
  void addMessage(String raw, String translated) {
    if (_history.length >= maxHistory) {
      _history.removeFirst(); // 滿了就丟棄最舊的一句
    }
    _history.add(ChatContextMessage(rawText: raw, translatedText: translated));
  }

  /// 格式化為 LLM 看得懂的歷史 Prompt
  String getFormattedHistory() {
    if (_history.isEmpty) return '(無前文紀錄)';

    final StringBuffer buffer = StringBuffer();
    int index = 1;
    for (var msg in _history) {
      buffer.writeln('第 $index 句 [原文]: ${msg.rawText} | [譯文]: ${msg.translatedText}');
      index++;
    }
    return buffer.toString().trim();
  }

  /// 清空歷史紀錄(切換話題或重置時使用)
  void clear() {
    _history.clear();
  }
}

Step 2: 升級 TranslationService 支援 Context-Aware

更新 lib/features/live_translate/data/services/translation_service.dart,加入 contextHistory 參數

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';

class TranslationService {
  final String _openAiUrl = 'https://api.openai.com/v1/chat/completions';

  /// 支援上下文感知的翻譯請求
  Future<String?> translateWithContext({
    required String rawText,
    required String targetLanguage,
    required String contextHistory, // 傳入上一步驟生成的上下文歷史 Prompt
  }) async {
    if (rawText.trim().isEmpty) return '';

    final apiKey = dotenv.env['OPENAI_API_KEY'];
    if (apiKey == null || apiKey.isEmpty) return null;

    try {
      final response = await http.post(
        Uri.parse(_openAiUrl),
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
          'Authorization': 'Bearer $apiKey',
        },
        body: jsonEncode({
          'model': 'gpt-4o-mini',
          'temperature': 0.3,
          'messages': [
            {
              'role': 'system',
              'content': '''你是一位同聲傳譯專家。請參考【前文對話歷史】,將【當前輸入】翻譯為 $targetLanguage。

【翻譯原則】:
1. 利用【前文對話歷史】推斷當前句子的主詞、代名詞(如:他/他們/這件事)以及主題背景。
2. 避免斷章取義,確保譯文前後語氣、時態與專有名詞統一樣式。
3. 剔除語音贅字(如:呃、那個),補充標點符號。
4. 僅回傳【當前輸入】的最終翻譯結果,嚴禁輸出任何解釋或標籤。'''
            },
            {
              'role': 'user',
              'content': '''
【前文對話歷史】:
$contextHistory

【當前輸入】:
$rawText
'''
            }
          ],
        }),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(utf8.decode(response.bodyBytes));
        return data['choices'][0]['message']['content']?.toString().trim();
      } else {
        print('翻譯 API 錯誤: ${response.body}');
        return null;
      }
    } catch (e) {
      print('請求異常: $e');
      return null;
    }
  }
}

Step 3: 在 multi_lang_page.dart 中串接對話記憶庫

更新 UI 控制邏輯,讓每次翻譯前都會先帶入上下文,翻譯完畢後自動記錄

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import '../../models/audio_config.dart';
import '../../../../services/audio_recorder_service.dart';
import '../../../../services/whisper_service.dart';
import '../../data/models/chat_context_data.dart';
import '../../data/services/translation_service.dart';

class MultiLangAudioPage extends StatefulWidget {
  const MultiLangAudioPage({super.key});

  @override
  State<MultiLangAudioPage> createState() => _MultiLangAudioPageState();
}

class _MultiLangAudioPageState extends State<MultiLangAudioPage> {
  final AudioRecorderService _recorder = AudioRecorderService();
  final WhisperService _whisper = WhisperService();
  final TranslationService _translationService = TranslationService();
  final ChatContextManager _contextManager = ChatContextManager(maxHistory: 3);

  SupportedLanguage _selectedLang = SupportedLanguage.ja; // 預設測試日語
  bool _isRecording = false;
  bool _isLoading = false;
  String _rawTranscript = ''; // 原始 STT 逐字稿
  String _translatedText = ''; // LLM 修飾後的最終翻譯
  String? _audioPath;

  // 依語言決定要載入哪一份飯圈字典(其他語言不加字典)
  String? get _fandomCategory {
    switch (_selectedLang.code) {
      case 'ko':
        return 'kpop';
      case 'ja':
        return 'jpop';
      default:
        return null;
    }
  }

  void _toggleRecording() async {
    if (_isRecording) {
      await _recorder.stopRecording();
      setState(() {
        _isRecording = false;
      });

      if (_audioPath != null) {
        await _processAudioAndTranslate(File(_audioPath!));
      }
    } else {
      final tempDir = await getTemporaryDirectory();
      _audioPath = '${tempDir.path}/test_${_selectedLang.code}.m4a';

      await _recorder.startFileRecording(_audioPath!);
      setState(() {
        _isRecording = true;
        _rawTranscript = '';
        _translatedText = '';
      });
    }
  }

  // 處理語音流程:先 STT 取得逐字稿,再送 LLM 翻譯修飾
  Future<void> _processAudioAndTranslate(File audioFile) async {
    setState(() => _isLoading = true);

    // 1. 先用 Whisper 取得原始逐字稿 (STT)
    final rawResult = await _whisper.transcribeAudio(
      audioFile,
      language: _selectedLang,
      category: _fandomCategory,
    );

    if (rawResult != null && rawResult.trim().isNotEmpty) {
      // 2. 取得最近 3 句的歷史紀錄 Prompt
      final historyPrompt = _contextManager.getFormattedHistory();

      // 3. 上下文感知翻譯
      final translated = await _translationService.translateWithContext(
        rawText: rawResult,
        targetLanguage: '繁體中文', // 設定你想翻譯成的目標語言
        contextHistory: historyPrompt,
      );

      // 4. 翻譯成功才存入記憶庫,供下一句使用
      if (translated != null && translated.isNotEmpty) {
        _contextManager.addMessage(rawResult, translated);
      }

      setState(() {
        _rawTranscript = rawResult;
        _translatedText = translated ?? '翻譯失敗';
        _isLoading = false;
      });
    } else {
      setState(() {
        _rawTranscript = '未辨識到聲音';
        _translatedText = '';
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    final currentConfig = LanguageAudioConfig.forLanguage(_selectedLang);

    return Scaffold(
      appBar: AppBar(title: const Text('Day 12 - 多國語音聲學調校')),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          children: [
            // 語言切換選單
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('選擇目標語言:', style: TextStyle(fontSize: 16)),
                DropdownButton<SupportedLanguage>(
                  value: _selectedLang,
                  items: SupportedLanguage.values.map((lang) {
                    return DropdownMenuItem(
                      value: lang,
                      child: Text(lang.label),
                    );
                  }).toList(),
                  onChanged: (val) {
                    if (val != null) {
                      _contextManager.clear(); // 換語言就清掉前文,避免混入
                      setState(() => _selectedLang = val);
                    }
                  },
                ),
              ],
            ),
            const SizedBox(height: 10),

            // 顯示該語言的聲學調校參數
            Card(
              color: Colors.blueGrey[50],
              child: Padding(
                padding: const EdgeInsets.all(12.0),
                child: Column(
                  children: [
                    Text('聲學配置: SampleRate ${currentConfig.sampleRate}Hz | Chunk: ${currentConfig.chunkIntervalMs}ms'),
                    const SizedBox(height: 5),
                    Text('Prompt 補全: "${currentConfig.language.initialPrompt}"', style: const TextStyle(fontSize: 12, color: Colors.grey)),
                    if (_fandomCategory != null) ...[
                      const SizedBox(height: 5),
                      Text('飯圈字典: ${_fandomCategory!}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
                    ],
                  ],
                ),
              ),
            ),
            const SizedBox(height: 30),

            // 錄音按鈕
            ElevatedButton.icon(
              onPressed: _isLoading ? null : _toggleRecording,
              icon: Icon(_isRecording ? Icons.stop : Icons.mic),
              label: Text(_isRecording ? '停止並分析' : '開始測試發音 (${_selectedLang.code.toUpperCase()})'),
              style: ElevatedButton.styleFrom(
                backgroundColor: _isRecording ? Colors.red : Colors.indigo,
                padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
              ),
            ),
            const SizedBox(height: 30),

            if (_isLoading) const CircularProgressIndicator(),

            if (_rawTranscript.isNotEmpty) ...[
              const Text('原始逐字稿:', style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 10),
              Container(
                width: double.infinity,
                padding: const EdgeInsets.all(16),
                decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(8)),
                child: Text(_rawTranscript, style: const TextStyle(fontSize: 16)),
              ),
              const SizedBox(height: 20),
            ],

            if (_translatedText.isNotEmpty) ...[
              const Text('翻譯結果:', style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 10),
              Container(
                width: double.infinity,
                padding: const EdgeInsets.all(16),
                decoration: BoxDecoration(color: Colors.indigo[50], borderRadius: BorderRadius.circular(8)),
                child: Text(_translatedText, style: const TextStyle(fontSize: 18)),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

檢查清單

嘗試說兩句話(不帶代名詞):

  • 第 1 句:
    • 輸入:「昨日、SEVENTEENのコンサートに行きました。」 (昨天去了 SEVENTEEN 的演唱會),此時記憶庫記錄了第 1 句
  • 第 2 句:
    • 輸入:「本当にカッコよかった!」 (真的超級帥!)
    • 沒有 Context 時:直譯為「真的太帥了!」(不知道誰很帥)。
    • 有 Context 感知 時:LLM 讀取歷史後理解「帥」是指前面的 SEVENTEEN 團員,自動翻譯為:「(他們)真的超級帥!」,讓語意變得順暢自然!

上一篇
Day 16:解決 AI 聽不懂飯圈梗!設計「偶像暱稱與專有名詞」Prompt 字典
系列文
從行程到應援:30 天做出追星實用工具箱 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言