# 発話の改訂モデル

文字おこしくんが送る発話（`utterance`）は、1 回で届き切るものではありません。文字おこしくんは、同じ発話を**認識途中 → 確定 → 整形**と何度も送ります。受け手は同じ `utteranceId` の発話が届くたびに、持っている前の改訂を**置き換え**ます。受け手が追記すると、画面に同じ文が何度も並びます。

## 3 つの段階

| `stage` | 意味 | いつ届くか | 表示のしかたの例 |
|---|---|---|---|
| `partial` | 認識途中。後から文字が変わる | 話している間、数百 ms ごと | 薄く表示する・字幕なら最終行に出す |
| `final` | 音声認識の確定 | 話し終わり（発話区間の終わり）の直後 | 普通に表示する |
| `refined` | LLM による整形（句読点・表記の修正） | `final` の数百 ms〜数秒後に、非同期で届く。整形しない設定の Discord サーバーでは届かない | `final` をそのまま置き換える |

- どの段階でも、同じ発話は同じ `utteranceId` で届きます。改訂のたびに `revision` が増えます。
- クライアントの受信が、文字おこしくんからの送信に追いつかないとき、文字おこしくんは `partial` を間引くことがあります。**`revision` は連番とは限りません。**
- `refined` は来ないこともあります。整形しない設定の Discord サーバーでは `refined` は来ず、整形に失敗したときも来ません。発話が `final` のまま終わっても、エラーではありません。
- `text` は空文字のこともあります。たとえば、認識の途中で音が無かったときに、`text` が空文字になります。

## 受け手の決まり

1. 発話は **`utteranceId` をキーにした表（Map）** で持つ。
2. 届いた `utterance` の `revision` が、持っているものより**大きいときだけ**置き換える。同じか小さいものは捨てる（受け手は、到着順に頼らずに `revision` を比べるので、再接続の `welcome.recent` と重なっても、受け手の処理は冪等になる）。
3. `welcome` を受け取ったら、状態を**丸ごと置き換える**（再接続したときも）。受け手は切断中の差分を取り戻せない。代わりに、`welcome.recent`（直近の発話の最新の改訂）で状態を作り直す。
4. 文字おこしくんは、話者をまたいだ順序を揃えずに送る。時刻順に並べるときは `startedAt`（文字おこしくんが最初の音声を受け取った時刻、エポックミリ秒）で並べ、同じなら `utteranceId` で並べる。

```js
const utterances = new Map(); // utteranceId → 最新の改訂

function onUtterance(u) {
  const known = utterances.get(u.utteranceId);
  if (known !== undefined && known.revision >= u.revision) return false; // 古い・重複した改訂
  utterances.set(u.utteranceId, u); // 追記ではなく置き換え
  return true;
}

function onWelcome(welcome) {
  utterances.clear(); // スナップショットで丸ごと置き換える
  for (const u of welcome.recent) onUtterance(u);
}

const ordered = () =>
  [...utterances.values()].sort((a, b) => a.startedAt - b.startedAt || (a.utteranceId < b.utteranceId ? -1 : 1));
```

## 届き方の例

1 人の話者の 1 つの発話は、たとえば次のように届きます（表では `trackId` などを省いています）。

| 順 | `stage` | `revision` | `text` | 受け手の状態（`utt_02`） |
|---|---|---|---|---|
| 1 | `partial` | 0 | それでは | それでは |
| 2 | `partial` | 1 | それでは始め | それでは始め |
| 3 | `partial` | 2 | それでは始めましょ | それでは始めましょ |
| 4 | `final` | 3 | それでは始めましょう | それでは始めましょう |
| 5 | `refined` | 4 | それでは始めましょう。 | それでは始めましょう。 |
| 6 | `partial` | 2（遅れて届いた） | それでは始めましょ | 変えない（捨てる） |

同時に別の話者の発話（別の `utteranceId`）も並行して届きます。2 人が同時に話せば、2 つの発話が同時に `partial` のまま伸びていきます。

## 話者

`utterance.speaker` は次のどれかです。

| `kind` | 意味 | 表示 |
|---|---|---|
| `declared` | 入力元が宣言した話者（Discord のユーザーなど） | `speakerId` の話者の表示名 |
| `inferred` | 1 本の音声に複数人の声が入ったトラックで、話者分離が推定した話者 | `speakerId` の話者（「話者 1」など）。**inferred の発話は、後の改訂で別の `speakerId` の話者に付け替わることがある** |
| `unattributed` | 話者が分からない | トラック（`trackId`）単位で表示する |

- 表示名は `welcome.speakers` と `speaker_upserted` で届きます。受け手は `speaker_removed` を受け取った後も、その話者の過去の発話を消さずに残します（表示名は覚えておいたものを使います）。
- 表示名は空文字のことがあります。表示名が空文字のときは、「不明な話者」などの代替名を表示してください。

## よくある間違い

| 起きていること | 原因 | 正しくは |
|---|---|---|
| 同じ文が `partial` の数だけ並ぶ | `utterance` を受け取るたびに行を追加している | `utteranceId` で置き換える |
| 文字が前の状態に戻る | 最後に届いたものを最新とみなしている（遅れて届いた古い改訂で上書きしている） | `revision` を比べる |
| 再接続の後に、重複や消えたはずの状態が残る | 再接続の後に `welcome.recent` を前の状態に足している | 状態を `welcome` で丸ごと置き換える（`revision` の比較で重複は消える） |
| 句読点の無い文が記録に残る | `final` だけを保存して `refined` を無視している | `refined` でも置き換える（保存するときも upsert する） |
| 話者をまたいだ前後が入れ替わる | 到着順に並べている | `startedAt` で並べる |
| 発話の `text` や表示名が HTML として動く | `innerHTML` に `text` や表示名を入れている | `textContent` を使う |

## 保持する数の上限

長いセッションでは発話が増え続けるので、受け手は、ふつう画面に出す分だけの発話を持ちます（公式の表示ページは 500 件を持ちます）。次の順で発話を捨てると、長く話している途中の発話が消えません。

1. 確定した発話（`final` / `refined`）のうち `startedAt` が最も古いものから捨てる。
2. 持っている発話がすべて認識途中のときを除き、認識途中（`partial`）は捨てない。
3. 上限に達しているときに届いた、知らない `utteranceId` の `refined` は無視する（捨てた発話の整形結果なので、入れると古い発話が復活する）。

## コーディングエージェント向けのプロンプト

```text
文字おこしくんの Subscribe WebSocket API の utterance メッセージを扱う状態管理を実装してください。
仕様: https://moji.kaitoranma.com/docs/revisions.md

- 発話は utteranceId をキーにした Map で持ち、revision が大きいときだけ置き換える（同じか小さい revision は無視）。追記しない。
- welcome を受け取ったら状態を丸ごと置き換え、welcome.recent を同じ規則で入れる。
- 表示用の並びは startedAt → utteranceId の昇順。
- speaker.kind が unattributed の発話はトラック（trackId）単位、declared / inferred は speakerId 単位でまとめる。inferred は改訂で speakerId が変わりうる。
- 上限を超えたら確定済み（final / refined）の古いものから捨て、partial は残す。
- テスト: partial r0 → final r3 → 遅れた partial r2 → refined r4 の順に入れて、最後が refined のテキスト 1 件になること。welcome で置き換わること。
```
