# ガイド: 記録と書き出し

あなたは文字起こしの記録を、議事録として残したり、検索できるようにしたり、別のシステムに取り込んだりできます。

## 2 つの取り方

| 方法 | いつ | 長所 | 短所 |
|---|---|---|---|
| **HTTP で書き出す**（`GET /v1/sessions/:id/transcript`） | セッションが終わった後（途中でも可） | 全文が確実に取れる。整形後（`refined`）のテキストと整形前（`rawText`）の両方を取れる | リアルタイムではない |
| **WebSocket で受け取りながら保存する** | セッション中 | 発話をリアルタイムに保存できる | 切断中の発話は取り戻せない。最後に HTTP で書き出して比べると確実 |

**記録はセッションの終了から保持期間（Discord サーバーのレベルにより 30〜365 日）が過ぎると消えます。** 記録が消えた後は、書き出しが `404 session_not_found` になり、記録は戻せません。残したい記録は保持期間の内に書き出してください。

## HTTP で書き出す

```sh
# JSON で書き出す（既定）
curl -s "$BASE/v1/sessions/$SID/transcript" -H "Authorization: Bearer $TOKEN" -o transcript.json

# テキストで書き出す（タイムゾーンは tz で指定する。既定は Asia/Tokyo）
curl -s "$BASE/v1/sessions/$SID/transcript?format=text&tz=UTC" -H "Authorization: Bearer $TOKEN"

# CSV で書き出す（見出しは「日時,話者,発言」。Excel で開けるように、先頭に BOM が付く）
curl -s "$BASE/v1/sessions/$SID/transcript?format=csv" -H "Authorization: Bearer $TOKEN" -o transcript.csv
```

- `$TOKEN` には、`$SID` のセッションの `transcript:read` のトークン（セッションが終わった後も発行できる）か、`transcripts:read` の API キー（[HTTP API](../http.md#exportTranscript)）を入れます。
- 文字おこしくんは、長いセッションでも受け取れるように、応答を少しずつ流します。文字おこしくんの API 全体で同時に流せる書き出しには上限があります。上限を超えると、文字おこしくんは `429 rate_limited` を返します（`Retry-After` の秒数の後にやり直してください）。
- 資格情報を持たない人（チャットの参加者など）に記録を渡すときは、あなたのサーバー側のコードが、`transcripts:read` の API キーで書き出しのリンクを作れます（`POST /v1/sessions/:id/downloads`。[HTTP API](../http.md#createTranscriptDownloads)）。リンクを開いた人のブラウザは、記録を CSV・JSON・テキストのファイルとして保存します。リンクに `&disposition=inline` を足すと、ブラウザは、記録をファイルとして保存せずに、ページとして表示します。応答の `chatgptUrl` は、記録を ChatGPT に渡して要約を頼むリンクです（[HTTP API](../http.md#openTranscriptInChatGpt)）。文字おこしくんは、リンクを 1 つずつ取り消せないので、リンクの有効期間（`ttlSeconds`。既定 1 時間）は必要な長さより長くしないでください。

JSON の形:

```json
{
  "sessionId": "0f0c3c1e-…",
  "label": "#雑談 VC",
  "speakers": [{ "speakerId": "discord:1", "displayName": "たろう", "avatarUrl": "https://cdn.discordapp.com/…" }],
  "utterances": [
    {
      "utteranceId": "utt_01",
      "trackId": "discord:1",
      "speakerId": "discord:1",
      "speakerName": "たろう",
      "stage": "refined",
      "text": "こんにちは、今日はよろしくお願いします。",
      "rawText": "こんにちは今日はよろしくお願いします",
      "startedAt": 1790350826814
    }
  ]
}
```

- `utterances` は、発話ごとの最新の改訂（`final` か `refined`。認識途中は含まない）を、`startedAt` の順に並べたものです。
- `rawText` は整形前の音声認識の結果で、`text` は整形後の結果です（整形しなかったら、`rawText` と同じです）。
- 話者が分からない発話には、`speakerId` が付かず、`speakerName` はトラック名などの代替名になります。

## WebSocket で受け取りながら保存する

保存は **`utteranceId` をキーにした upsert** にし、**`revision` が大きいときだけ**上書きします。文字おこしくんは、同じ発話を `final` → `refined` と送り、再接続の `welcome.recent` でも同じ発話をもう一度送ります。

```sql
CREATE TABLE utterances (
  session_id   TEXT    NOT NULL,
  utterance_id TEXT    NOT NULL,
  track_id     TEXT    NOT NULL,
  speaker_id   TEXT,               -- unattributed なら NULL
  stage        TEXT    NOT NULL,   -- final / refined（partial は保存しない）
  revision     INTEGER NOT NULL,
  text         TEXT    NOT NULL,
  started_at   INTEGER NOT NULL,   -- エポックミリ秒
  PRIMARY KEY (session_id, utterance_id)
);

-- 新しい改訂のときだけ上書きする（古い・重複した改訂は何もしない）
INSERT INTO utterances VALUES (:session_id, :utterance_id, :track_id, :speaker_id, :stage, :revision, :text, :started_at)
ON CONFLICT (session_id, utterance_id) DO UPDATE SET
  track_id = excluded.track_id, speaker_id = excluded.speaker_id, stage = excluded.stage,
  revision = excluded.revision, text = excluded.text, started_at = excluded.started_at
WHERE excluded.revision > utterances.revision;
```

- `partial` は保存しなくてよい（すぐに置き換わる）。
- 話者の表示名は別の表に `speakerId` → 表示名で持ち、`speaker_upserted` で更新します（名前は途中で変わることがあります）。
- `session_ended` を受け取ったら、HTTP の書き出しで記録を取り直して、保存した発話と**比べ**ます（切断中の取りこぼしを埋める）。書き出しの `utteranceId` は、WebSocket で届いた `utteranceId` と同じです。

## セッションを探す

### 終わったときに書き出す（Webhook） {#on-session-ended}

Discord サーバーの管理者が Webhook の送信先を登録し、送る出来事に「文字起こしの終了」を選ぶと、文字おこしくんはセッションが終わるたびに `session.ended` をあなたのサーバーへ送ります（[Webhook リファレンス](../webhooks.md)）。あなたのサーバーは `session.ended` を受け取ったら、`data.sessionId` のセッションの記録を書き出せます。文字おこしくんは確定した発言を保存し終えてから `session.ended` を送るので、あなたのサーバーが `session.ended` を受け取ってから書き出す記録には終わりまでの発言が入ります。新しく終わったセッションを探して一覧を読み直す必要はありません。

署名を確かめる依存なしの参照実装 [examples/mojiokoshi-webhook.ts](../examples/mojiokoshi-webhook.ts) と依存なしのクライアント [examples/mojiokoshi-http.ts](../examples/mojiokoshi-http.ts) を使う例:

```ts
// archive-webhook.mts — node archive-webhook.mts（Node 22.18 以降。2 つの例のファイルを同じフォルダに置く）
import { writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { createMojiokoshiHttp } from "./mojiokoshi-http.ts";
import { MojiokoshiWebhookError, verifyMojiokoshiWebhook } from "./mojiokoshi-webhook.ts";

const api = createMojiokoshiHttp({ apiKey: process.env.MOJIOKOSHI_API_KEY! }); // transcripts:read
const secret = process.env.MOJIOKOSHI_WEBHOOK_SECRET!; // whsec_…（ソースに書かず、環境変数などから読む）

createServer((request, response) => {
  const chunks: Buffer[] = [];
  request.on("data", (chunk: Buffer) => chunks.push(chunk));
  request.on("end", async () => {
    let event;
    try {
      event = await verifyMojiokoshiWebhook(Buffer.concat(chunks), request.headers, secret); // JSON として読む前の生の本文で確かめる
    } catch (error) {
      response.writeHead(error instanceof MojiokoshiWebhookError ? 400 : 500).end();
      return;
    }
    response.writeHead(204).end(); // 書き出す前に 2xx を返す（文字おこしくんは 15 秒で待つのをやめる）
    if (event.type !== "session.ended") return; // session.started・webhook.test・知らない出来事には何もしない
    try {
      // 同じ出来事が 2 回届いても、同じファイルを書き直すだけで済む
      const transcript = await api.exportTranscript(event.data.sessionId);
      await writeFile(`${event.data.sessionId}.json`, JSON.stringify(transcript, null, 2));
    } catch (error) {
      console.error("書き出せませんでした", event.data.sessionId, error);
    }
  });
}).listen(8787);
```

- Discord サーバーの管理者が発言を保存しない設定にしていると書き出しには何も入らないので、あなたのサーバーは `session.started` を受け取ったらすぐに WebSocket で発言を受け取りながら保存を始めてください（[「保存しない」設定で記録を残す](../webhooks.md#store-transcripts-off)）。
- 文字おこしくんが止まっている間は、文字おこしくんは `session.ended` を送りません。文字おこしくんは再起動すると、送れていない出来事から送ります（[届け方](../webhooks.md#delivery)）。
- Discord サーバーの管理者か文字おこしくんが送信先を止めている間にセッションが終わると、文字おこしくんはそのセッションの `session.ended` を後から送りません（[送らないとき](../webhooks.md#not-sent)）。あなたは送信先が止まっている間に終わったセッションを[決まった時刻に一覧を読む](#by-schedule)方法で探せます。

### 決まった時刻に一覧を読む {#by-schedule}

`transcripts:read` の API キーがあれば、Discord サーバー（テナント）のセッションを一覧できます。

```sh
# 終わったセッションを新しい順に一覧する（続きは、nextCursor を cursor に渡して取る）
curl -s "$BASE/v1/sessions?status=ended&limit=20" -H "Authorization: Bearer $KEY"
```

毎日決まった時刻に、前回の実行の後に終わったセッションを書き出す、という使い方ができます。依存なしのクライアント [examples/mojiokoshi-http.ts](../examples/mojiokoshi-http.ts) を使う例:

```ts
// archive.mts — node archive.mts（Node 22.18 以降。mojiokoshi-http.ts を同じフォルダに置く）
import { writeFile } from "node:fs/promises";
import { createMojiokoshiHttp, MojiokoshiHttpError } from "./mojiokoshi-http.ts";

const api = createMojiokoshiHttp({ apiKey: process.env.MOJIOKOSHI_API_KEY! }); // transcripts:read
const since = Number(process.env.SINCE ?? 0); // 前回の実行時刻（エポックミリ秒）

// セッションは新しい順に届くので、前回の実行の 1 日前より前に作られたセッションまで来たら、ループを止める（nextCursor は allSessions がたどる）。
for await (const session of api.allSessions({ status: "ended", limit: 100 })) {
  if (session.createdAt < since - 86_400_000) break;
  if ((session.endedAt ?? 0) < since) continue;
  try {
    const transcript = await api.exportTranscript(session.sessionId); // 429 なら、api が Retry-After の秒数だけ待って、自動でやり直す
    await writeFile(`${session.sessionId}.json`, JSON.stringify(transcript, null, 2));
  } catch (error) {
    if (error instanceof MojiokoshiHttpError && error.code === "session_not_found") continue; // 保持期間を過ぎて、記録が消えた
    throw error;
  }
}
```

エンドポイントの詳細は、[HTTP API リファレンス](../http.md#listSessions)にあります。

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

```text
文字おこしくんのセッションの文字起こしを、終わった後に書き出して SQLite に保存するスクリプトを書いてください。
仕様: https://moji.kaitoranma.com/docs/guides/archive.md

- 書き出しは GET /v1/sessions/:id/transcript（Authorization: Bearer <transcript:read のトークンか API キー>）。JSON の utterances を保存する。
- 保存は (session_id, utterance_id) を主キーにした upsert で、revision が大きいときだけ上書きする（WebSocket から保存した行と突き合わせても壊れないように）。
- 429 rate_limited なら Retry-After の秒数だけ待ってやり直す。404 session_not_found は保持期間を過ぎたものとして記録して飛ばす。
- 資格情報は環境変数から読み、ログに出さない。
- HTTP は注入した fetch で呼び、偽の応答でテストする。
- 文字おこしくんの Webhook の session.ended を受けて書き出すときは、署名を JSON として読む前の生の本文で確かめ（https://moji.kaitoranma.com/docs/webhooks.md）、書き出す前に 2xx を返す。同じ webhook-id が 2 回届いても記録が壊れないようにする。
```
