跳轉到

實作完整的對話引擎

我們已經逐一實作了 ASR、NLU、NLG、TTS 引擎以及對話流程,現在,就可以根據前章〈語音助理的基本組成〉所述,將各個元件串接起來,形成一個完整的對話引擎。

初始化

在可以使用這個對話引擎之前,需要先初始化 ASR 引擎。我們於是在對話引擎中加入了一個 init method,另外,寫了一個 _emit method,用來發送對話引擎的狀態。

class DialogEngine implements VuiFlowDelegate {
  void _emit(DialogEngineState state) {
    _state = state;
    _stateStream.add(state);
  }

  Future<bool> init() async => await asrEngine.init();
}

建立意圖與對話流程的對應表格

對話引擎內部會維護一份叫做 _vuiFlowMap 的表格,之後,當對話引擎收到某個意圖時,就可以去這個表格中尋找是否有對應的對話流程。而我們也設計了一個叫做 registerFlows 的 method,可以將新的對話流程加到這個表格中。

class DialogEngine implements VuiFlowDelegate {
...

  Map<String, VuiFlow> _vuiFlowMap = {};
  VuiFlow? _currentVuiFlow;
...

  /// Resets the list of [VuiFlow].
  void resetFlows() {
    _vuiFlowMap = {};
    _updateIntents();
  }

  /// Register a list of [VuiFlow].
  void registerFlows(List<VuiFlow> flows) {
    for (final flow in flows) {
      _vuiFlowMap[flow.intent] = flow;
      flow.delegate = this;
    }
    _updateIntents();
  }

  void _updateIntents() {
    var intents = <String>{
      'Confirm',
      'Acknowledge',
      'Agree',
      'Cancel',
      'Reject',
      'Disagree',
      'Deny',
    };
    var slots = <String>{};

    for (final key in _vuiFlowMap.keys) {
      intents.add(key);
      final flow = _vuiFlowMap[key];
      if (flow == null) {
        continue;
      }
      slots.addAll(flow.slots);
    }
    nluEngine.availableIntents = intents;
    nluEngine.availableSlots = slots;
  }

...
}

當有新的對話流程被加入到對話引擎時,我們會呼叫 _updateIntents method,這個 method 會將所有的意圖與 Slot 都更新到 NLU 引擎中,這樣,NLU 引擎就可以知道目前有哪些意圖與 Slot 可以被辨識。

實作 VUI flow Delegate

每個 VUI flow 的 delegate,其實就是我們的對話引擎。所以我們需要實作 VuiFlowDelegate 這個 interface。

class DialogEngine implements VuiFlowDelegate {
...
  @override
  Future<void> onEndingConversation() async {
    await stop();
  }

  @override
  Future<String?> onGeneratingResponse(
    String utterance, {
    bool useDefaultPrompt = true,
  }) async {
    return await nlgEngine.generateResponse(
      utterance,
      useDefaultPrompt: useDefaultPrompt,
    );
  }

  @override
  Future<void> onPlayingPrompt(String prompt) async {
    await ttsEngine.stopPlaying();
    _emit(DialogEnginePlayingTts(prompt: prompt));
    await ttsEngine.playPrompt(prompt);
  }

  @override
  Future<void> onSettingCurrentVuiFlow(VuiFlow? vuiFlow) async {
    vuiFlow?.delegate = this;
    _currentVuiFlow = vuiFlow;
  }

  @override
  Future<void> onStartingAsr() async {
    await start(clearCurrentVuiFlow: false);
  }
}

然後 startstop 的實作如下。大概就是開始以及停止 ASR/NLU 引擎的相關工作。可以想像如果 NLU 或 NLG 引擎正在運作中,也應該停止,不過這邊就先偷懶不寫。

  Future<bool> start({
    clearCurrentVuiFlow = true,
  }) async {
    if (!asrEngine.isInitialized) {
      return false;
    }

    if (clearCurrentVuiFlow) {
      _currentVuiFlow?.cancel();
      _currentVuiFlow = null;
    }
    await ttsEngine.stopPlaying();
    await asrEngine.stopRecognition();
    await asrEngine.startRecognition();
    _emit(DialogEngineListening(asrResult: ''));
    return true;
  }

  Future<bool> stop() async {
    if (!asrEngine.isInitialized) {
      return false;
    }

    _currentVuiFlow?.cancel();
    _currentVuiFlow = null;
    await ttsEngine.stopPlaying();
    await asrEngine.stopRecognition();
    _emit(DialogEngineIdling());
    return true;
  }

連接 ASR 與 NLU 引擎

在開始建立這個對話引擎的時候,我們就開始監聽 ASR 引擎的狀態。如果 ASR 引擎還在識別當中,我們就透過更新狀態,反應目前的辨識結果,如果辨識完成,就開始讓 NLU 引擎分析意圖(也就是 handleInput 這段)。這邊有一小段邏輯,在於處理使用者完全不說話的狀態—如果完全沒有識別結果,而且不在對話流程中,就會直接進入閒置狀態。

  DialogEngine({
    required this.asrEngine,
    required this.ttsEngine,
    required this.nluEngine,
    required this.nlgEngine,
  }) {
    asrEngine.onResult = (result, isFinal) async {
      if (!isFinal) {
        _emit(DialogEngineListening(asrResult: result));
      } else {
        await handleInput(result);
      }
    };
    asrEngine.onError = (error) async {
      await ttsEngine.stopPlaying();
      await asrEngine.stopRecognition();
      _emit(DialogEngineIdling());
    };
    asrEngine.onStatusChange = (state) async {
      if (state == AsrEngineState.listening) {
        return;
      }
      final current = this.state;
      if (current is DialogEngineCompleteListening) {
        return;
      }
      if (current is DialogEngineListening) {
        if (current.asrResult != '') {
          return;
        }
      }

      if (_currentVuiFlow != null) {
        final intent = NluIntent(intent: '', slots: {});
        await _currentVuiFlow
            ?.handle(intent)
            .timeout(const Duration(seconds: 60));
        return;
      }

      if (current is DialogEngineListening) {
        _emit(DialogEngineIdling());
      }
    };
  }

至於 handleInput 的內容如下。我們平常會暴露 handleInput,因此,外部可以不用真的透過 ASR 語音錄音,而是直接對這個 method 傳入一段文字,就可以啟動 NLU 引擎以及對話流程,進行整合測試。

  String fallbackErrorMessage = 'Sorry, I do not understand for now.';

  Future handleInput(String input) async {
    _emit(DialogEngineCompleteListening(asrResult: input));
    await ttsEngine.stopPlaying();
    await asrEngine.stopRecognition();

    try {
      final additionalPrompt = _collectionAdditionalNluPrompt();
      final intent = await nluEngine.extractIntent(
        input,
        currentIntent: _currentVuiFlow?.intent,
        additionalRequirement: additionalPrompt,
      );
      if (_currentVuiFlow != null) {
        await _currentVuiFlow
            ?.handle(intent)
            .timeout(const Duration(seconds: 60));
        return;
      }
      final flow = _vuiFlowMap[intent.intent];
      if (flow != null) {
        await flow.handle(intent).timeout(const Duration(seconds: 60));
        return;
      }
      final prompt =
          await nlgEngine.generateResponse(input) ?? fallbackErrorMessage;
      await onPlayingPrompt(prompt);
      await stop();
    } catch (e) {
      _currentVuiFlow = null;
      await onPlayingPrompt(fallbackErrorMessage);
      await stop();
    }

到這裡,我們已經完成了我們的對話引擎。

整合測試

在我們急著把這個對話引擎整合到我們的應用程式之前,我們可以先寫一些整合測試,來確保這個對話引擎的功能是正確的。這邊我們可以使用 flutter_test 這個套件,來寫一些測試。測試的內容是,我們先用「我想請假」,啟動 LeaveApplication 意圖,但由於欠缺日期與事由,所以會進入多輪對話,然後我們再輸入「明天下午我想出去玩」,這樣就可以完成這個對話流程,最後驗證是否正確到了 System Call。為了避免因為網路斷線等問題,造成測試卡住,我們預期二十秒之內要完成測試。

  test('Test Engine with Leave Application', () async {
    final engine = DialogEngine(
      asrEngine: MockAsrEngine(),
      ttsEngine: MockTtsEngine(),
      nluEngine: GeminiNluEngine(apiKey: key),
      nlgEngine: GeminiNlgEngine(apiKey: key),
    );

    final completer = Completer();
    var systemCallCalled = false;
    engine.registerFlows([
      LeaveApplicationVuiFlow(
          onMakingLeaveApplication: (reason, date, text) async {
        expect(date, '明天下午');
        expect(reason, '出去玩');
        systemCallCalled = true;
        completer.complete();
        return true;
      })
    ]);
    await engine.init();
    await engine.handleInput('我想請假');
    await Future.delayed(const Duration(seconds: 5));
    await engine.handleInput('明天下午我想出去玩');
    await completer.future.timeout(const Duration(seconds: 20));
    expect(systemCallCalled, isTrue);
  });

在這個整合測試當中,我們也示範了怎樣從外部建立我們的對話引擎的作法。

  • 首先建立對話引擎的 instance,當中選擇了我們要使用的 ASR、NLU、NLG、TTS 引擎
  • 對引擎註冊我們要使用的 VUI flow。
  • 初始化對話引擎

之後,我們透過 handleInput 測試,在實際的 app 中,我們則用 start method 來啟動對話引擎。