# 语音助手回拨朗读与挂断提交及提示词调整实现方案

## 一、需求来源

| 需求编号 | 描述 |
|---------|------|
| 1 | 回拨接通后，字幕页面最后一条 AI 回答气泡的内容没有被朗读，需要补上 |
| 2 | 用户正在说话录入中突然点击挂断按钮，应将已转成文字的内容发送给 AI，然后正常走后续流程（与说话中点静音的行为一致） |
| 3 | 将系统提示词中的固定字数上限调整为“回答尽量简短” |

## 二、改动前现状分析

### 2.1 回拨接通后无朗读

**已有流程**：

```
AI 回答完成(handleAiReplyFinished)
  → 窗口不可见 && m_callbackPending?
    → YES → setState(Idle) + beginCallbackAttempts() + return
    → NO  → setState(Reading) + speak(textToRead)（正常朗读）
```

回拨触发路径中，`handleAiReplyFinished` 在 `m_callbackPending && !isVisible()` 时直接 return，跳过了 TTS 朗读。接听时 `onAnswerClicked()` 仅调用 `maybeStartRecognitionForCurrentState()` 启动 ASR，未朗读已完成的 AI 回答。

**问题**：用户接听回拨后看到字幕页面的 AI 回答气泡，但听不到朗读。

### 2.2 说话中挂断丢弃输入

**已有流程**：

```
cutoffClicked:
  if m_callbackMode → cancelCallbackFlow + hide
  if Processing/Answering → stopAudioForHide + m_callbackPending=true + hide
  else → stopAudioForHide + hide
```

`stopAudioForHide()` 内部调用 `cancelPendingFinalSubmit()` 并清空 `m_lastPartialText`，所有用户正在说的语音输入被丢弃。

**对比静音按钮**：静音按钮在用户说话时会将 `m_pendingFinalText + m_lastPartialText` 作为 final 发送给 AI，确保用户输入不丢失。

**问题**：用户说话中点挂断，已转成文字的内容被丢弃，而非发送给 AI。

### 2.3 提示词简短回答规则

`kDefaultSystemPrompt` 曾使用固定字数上限，实际使用中不够灵活。当前规则改为仅要求“回答尽量简短”，不再约束具体字数。

## 三、设计方案

### 3.1 回拨接通后朗读最后一条 AI 回答

在 `onAnswerClicked()` 中，用 TTS 朗读逻辑替换 `maybeStartRecognitionForCurrentState()`：

```
onAnswerClicked()
  → 恢复 UI（setCallbackMode(false)、字幕页等）
  → m_currentAiReplyText 非空?
    → YES → setState(Reading)
           → pauseRecognitionForPlayback()
           → speak(textToRead)
           → 朗读完成 → resumeRecognitionAfterPlayback() → 启动 ASR
    → NO  → maybeStartRecognitionForCurrentState()（保持原有行为）
```

**关键点**：

- `m_currentAiReplyText` 在回拨接听时仍有效：`handleAiReplyFinished` 并未清空该变量，它只在下一轮 `beginAiReply()` 时才清空。
- TTS 朗读失败时兜底：`speak()` 返回 false → `resumeRecognitionAfterPlayback()` → 启动 ASR。
- 与 `handleAiReplyFinished` 中可见窗口的 TTS 路径逻辑一致。

### 3.2 说话中挂断提交已转文字

在 `cutoffClicked` 信号处理中，在 `callbackMode` 判断之后、`Processing/Answering` 判断之前，新增 `m_asrListeningRoundActive` 分支：

```
cutoffClicked:
  if m_callbackMode → cancelCallbackFlow + hide
  if m_asrListeningRoundActive:
    pendingText = pendingRecognitionTextWith(m_lastPartialText)
    if pendingText 非空:
      → m_callbackPending = true
      → hide()
      → submitPendingRecognitionText(m_lastPartialText)
        └─ 未实际提交时回滚 m_callbackPending=false
      → return
  if Processing/Answering → stopAudioForHide + m_callbackPending=true + hide
  else → stopAudioForHide + hide
```

**关键点**：

- `submitPendingRecognitionText()` 内部会：`cancelPendingFinalSubmit()` + `m_asrListeningRoundActive=false` + `m_lastPartialText.clear()` + `m_speechController->stop()` + `m_discardLateAsrResults=true` + `submitFinalText(text)`。
- `submitFinalText()` 将状态变为 `Processing`，网关请求在途，因此需 `m_callbackPending=true` 让 `handleAiReplyFinished` 在窗口隐藏时触发回拨。
- `m_callbackPending` 在提交前置位，避免 `sendMessage()` 同步失败时错误回调看不到隐藏回拨上下文；若未实际提交则回滚。
- 未调用 `m_gatewayClient->cancelResponse()`，Gateway 请求在后台继续处理。
- 用户未说话时：`m_asrListeningRoundActive=false`，直接走原逻辑。
- 用户说话中但内容为空（如只有呼吸声）：`pendingText.trimmed().isEmpty()` 为 true，走原 `stopAudioForHide()` 路径。

### 3.3 提示词简短回答规则调整

将 `kDefaultSystemPrompt` 中的固定字数上限改为“回答尽量简短”。

## 四、文件改动详情

### 4.1 voice-assistant/mainwindow.cpp

**改动点1：`onAnswerClicked()`（行 1093）**

原代码：

```cpp
void MainWindow::onAnswerClicked()
{
    m_callbackPending = false;
    m_callbackAttemptCount = 0;
    m_callbackRingTimer->stop();
    m_callbackRetryTimer->stop();
    setCallbackMode(false);
    ui->bottomWidget->setSubtitleChecked(true);
    m_showChatPage = true;
    updateMiddlePage();
    maybeStartRecognitionForCurrentState();
}
```

新代码：

```cpp
void MainWindow::onAnswerClicked()
{
    m_callbackPending = false;
    m_callbackAttemptCount = 0;
    m_callbackRingTimer->stop();
    m_callbackRetryTimer->stop();
    setCallbackMode(false);
    ui->bottomWidget->setSubtitleChecked(true);
    m_showChatPage = true;
    updateMiddlePage();

    const QString textToRead = m_currentAiReplyText.trimmed();
    if (!textToRead.isEmpty()) {
        setState(Reading);
        pauseRecognitionForPlayback();
        if (!m_synthesisController->speak(textToRead)) {
            resumeRecognitionAfterPlayback();
        }
    } else {
        maybeStartRecognitionForCurrentState();
    }
}
```

**改动点2：`cutoffClicked` 信号处理（行 167）**

原代码：

```cpp
connect(ui->bottomWidget, &MiniPanel::cutoffClicked, this, [this]() {
    if (m_callbackMode) {
        cancelCallbackFlow();
        hide();
        return;
    }
    if (m_state == Processing || m_state == Answering) {
        stopAudioForHide();
        m_callbackPending = true;
        hide();
    } else {
        stopAudioForHide();
        hide();
    }
});
```

新代码：

```cpp
connect(ui->bottomWidget, &MiniPanel::cutoffClicked, this, [this]() {
    if (m_callbackMode) {
        cancelCallbackFlow();
        hide();
        return;
    }
    if (m_asrListeningRoundActive) {
        const QString pendingText = pendingRecognitionTextWith(m_lastPartialText);
        if (!pendingText.trimmed().isEmpty()) {
            m_callbackPending = true;
            hide();
            if (!submitPendingRecognitionText(m_lastPartialText)) {
                m_callbackPending = false;
            }
            return;
        }
    }
    if (m_state == Processing || m_state == Answering) {
        stopAudioForHide();
        m_callbackPending = true;
        hide();
    } else {
        stopAudioForHide();
        hide();
    }
});
```

**改动点3：`kDefaultSystemPrompt`（行 31）**

```
固定字数上限提示 → "回答尽量简短"
```

### 4.2 voice-assistant/Doc/语音助手系统提示词可配置与简短回答实现方案.md

将文档中的固定字数上限描述更新为“回答尽量简短”。

## 五、关键流程图

### 5.1 回拨接通后朗读

```
[回拨界面] --接听--> onAnswerClicked()
                         |
                   setCallbackMode(false)
                   字幕页显示
                         |
                   m_currentAiReplyText 非空?
                    ├─ YES → setState(Reading)
                    |         pauseRecognitionForPlayback()
                    |         speak(textToRead)
                    |           ├─ 成功 → [TTS 朗读中]
                    |           |           |
                    |           |     readingFinished
                    |           |           |
                    |           |     resumeRecognitionAfterPlayback()
                    |           |           |
                    |           |     [Idle → 2100ms → Listening]
                    |           |
                    |           └─ 失败 → resumeRecognitionAfterPlayback()
                    |                           |
                    |                     [Idle → 2100ms → Listening]
                    └─ NO  → maybeStartRecognitionForCurrentState()
                                        |
                                  [Idle → Listening]
```

### 5.2 说话中挂断提交

```
[用户正在说话] --挂断--> cutoffClicked
                             |
                       m_callbackMode? → NO
                             |
                       m_asrListeningRoundActive? → YES
                             |
                       pendingText 非空? → YES
                             |
                       submitPendingRecognitionText(m_lastPartialText)
                         ├─ cancelPendingFinalSubmit()
                         ├─ m_asrListeningRoundActive = false
                         ├─ m_lastPartialText.clear()
                         ├─ m_speechController->stop()
                         ├─ m_discardLateAsrResults = true
                         └─ submitFinalText(text)
                              ├─ setState(Processing)
                              └─ m_gatewayClient->sendMessage(payload)
                             |
                       m_callbackPending = true
                       hide()
                             |
                    [窗口隐藏，Gateway 后台处理]
                             |
                    handleAiReplyFinished()
                             |
                    m_callbackPending && !isVisible() → YES
                             |
                    beginCallbackAttempts()
                             |
                    [回拨界面/振铃]
```

### 5.3 未说话时挂断（原逻辑不变）

```
[用户未说话] --挂断--> cutoffClicked
                             |
                       m_callbackMode? → NO
                       m_asrListeningRoundActive? → NO
                       m_state == Processing/Answering? → NO
                             |
                       stopAudioForHide() + hide()
```

## 六、测试用例覆盖

| 测试场景 | 预期结果 | 覆盖情况 |
|----------|----------|----------|
| 回拨接听后 AI 有回答 | TTS 朗读回答，朗读完成后自动启动 ASR | `onAnswerClicked` 中 `speak(textToRead)` |
| 回拨接听后 AI 无回答 | 直接启动 ASR | `textToRead.isEmpty()` → `maybeStartRecognitionForCurrentState()` |
| 回拨接听后 TTS 启动失败 | 仍能启动 ASR | `speak()` 返回 false → `resumeRecognitionAfterPlayback()` |
| 说话中点挂断（有 partial） | 已转文字发送给 AI，窗口隐藏，AI 完成后回拨 | `m_asrListeningRoundActive` + `submitPendingRecognitionText` |
| 说话中点挂断（有 final + partial） | pending final + partial 一起发送 | `pendingRecognitionTextWith(m_lastPartialText)` |
| 说话中点挂断（内容为空） | 走原 `stopAudioForHide()` 路径 | `pendingText.trimmed().isEmpty()` |
| 未说话时点挂断 | 走原 `stopAudioForHide()` 路径 | `m_asrListeningRoundActive == false` |
| 回拨模式中点挂断 | 走原 `cancelCallbackFlow()` 路径 | `m_callbackMode` 分支优先 |
| AI 处理中挂断（非说话态） | 走原 `m_callbackPending` 路径 | `Processing/Answering` 分支 |
| 提示词简短回答规则 | AI 回答尽量简短 | `kDefaultSystemPrompt` 中“回答尽量简短” |

## 七、风险与缓解

| 风险 | 缓解措施 |
|------|----------|
| `m_currentAiReplyText` 在回拨接听时已被清空 | `handleAiReplyFinished` 不清空该变量，只在 `beginAiReply()` 清空；回拨场景下不会触发 `beginAiReply()` |
| 说话中挂断后 `submitPendingRecognitionText` 异步 stop 期间网关已回复 | `submitPendingRecognitionText` 内部同步设 `m_discardLateAsrResults=true`，迟到回调被丢弃；网关回复走正常 `handleAiReplyFinished` 流程 |
| 说话中挂断后 `m_callbackPending` 与 AI 处理中挂断的 `m_callbackPending` 冲突 | 两者不冲突：说话中挂断先置 `m_callbackPending=true` 进入隐藏回拨上下文，再提交文本；若没有实际发起提交则回滚该标志 |
| 用户已部署的 `voice-assistant.ini` 仍保留旧字数限制 | 配置文件优先于内置默认值，用户需自行修改配置文件或删除后重启以使用新默认值 |
