Amazon Connect + KVS + OpenAIで通話の怒りをリアルタイム検知する — 45日で完走した個人開発の全記録

Amazon Connect + KVS + OpenAIで通話の怒りをリアルタイム検知する — 45日で完走した個人開発の全記録 AI

crossbar_telepath のポストモーテムである。プロジェクトは2026-08-24にアーカイブした。畳んだ経緯(電話番号クォータの180日ルール)は前回書いたので、今回は45日間で何ができたかを総括する。死因の解剖が前回なら、今回は生涯の記録である。

作ったもの

TerraformでコールセンターをフルIaC構築し、通話の両話者をリアルタイムに文字起こし・感情分析して、「相手が怒り始めている」を応対者より先に監視卓へ知らせるシステム。2026年10月施行のカスハラ対策義務化(改正労働施策総合推進法)を見据えた検証プロジェクトとして、7月11日の深夜に構想した。

crossbar_telepathのアーキテクチャ: PSTN→Amazon Connect→KVS/Lambda→FastAPI→OpenAI/gpt-audio→React SPA

設計の背骨は電話網のIN(Intelligent Network)である。Amazon Connectを交換機、FastAPIの消費サービスをSCP(サービス制御ポイント)に見立て、通話路(KVS)とシグナリング(Lambda→SQS)を分離した。呼の存在をストリーム一覧から推測するのではなく、コールフローが「いま呼が張られた。音声はこのストリームのここから」を明示的に知らせる。この分離が複数呼の同時処理を支えた

数字で見る45日

項目
期間 2026-07-12 〜 08-24(構想は07-11深夜)
マージしたPR 16本
コミット 71
コード バックエンド約2,900行 + フロント約1,600行(テスト別)
テスト バックエンド53本 + フロント22本(計約1,500行)、CIで強制
実架電E2E 3ラウンド(7/12・7/26・8/1)
ブログ 開発記・技術発見あわせて10本
月額AWSコスト(常設時) 約$3(番号$0.06/日 + KMS $1/月)

技術ハイライト

1. KVSの音声はCodecIDが嘘をつく

最難関と目していたKVSからの音声取り出しは、初日に一番の罠を踏んだ。MKVコンテナのCodecID“A_AAC”を名乗っているのに、中身は生のL16 PCM 8kHzなのだ。ffmpegは律儀にAACとしてデコードを試みて全フレーム失敗する。EBMLを自前で歩くしかない。

def parse_simple_block(data: bytes):
    """SimpleBlockのペイロードから (track_number, pcm_bytes) を返す"""
    first = data[0]
    # トラック番号はEBML vint。トラック数が少ないので1バイト(0x80|n)前提
    if not (first & 0x80):
        raise ValueError("multi-byte track number not supported")
    track = first & 0x7F
    # 続く2バイト=相対タイムコード、1バイト=フラグ、以降がフレームデータ
    return track, data[4:]

SimpleBlockの先頭4バイトを剥がせば生PCMが出てくる。そしてトラック1=相手側、トラック2=自分側——電話の音声が最初から話者別に分かれて届く。話者分離(ダイアライゼーション)という機械学習の難問が、プロトコルの構造で消えるのがこのアーキテクチャの一番の贅沢である。

2. gpt-audioへの指示は「どこに書くか」で0/5が5/5になる

声のトーン判定にはgpt-audio(Chat Completionsの音声入力)を使った。当初、音声を添えているのに「音声をお聞かせください」と返される事象に悩まされた。変数を分離して各5回実測した結果が、コードのコメントとして焼き込んである。

{
    "role": "user",
    # **出力形式の指示をユーザーターンに書く。** systemに書くだけだと
    # モデルが「音声をお聞かせください」と、音声を添えているのに未受領扱いの
    # 応答を返す。2026-08-03に変数を分離して実測(gpt-audio、各5回):
    #   テキスト先・指示なし 0/5 / 音声先・指示なし 0/5
    #   テキスト先・指示あり 4/5 / 音声先・指示あり 5/5
    # **順序は効かない。効くのは形式指示の位置。** 音声先は誤差程度の上積み
    "content": [
        {"type": "input_audio", "input_audio": {"data": b64_wav, "format": "wav"}},
        {"type": "text",
         "text": "上の音声の話者の怒りを判定し、JSONだけを返してください。"
                 '{"anger": 0から100の整数, "tone": "声の様子を一行で"}'},
    ],
}

音声とテキストの順序は効かない。効くのは形式指示がuserターンにあるかだ。詳細は単独記事に書いた。ついでに言うとgpt-audio-miniは静かな声と怒鳴り声を区別できなかった(実測)。トーン判定はフルモデルが要る。

3. 声は言葉より早い

このプロジェクト最大の発見である。同じ通話を「文字起こしテキストの判定」と「音声そのものの判定」の両方に流すと、声のほうが2発話早く怒りを検知した

  • 合成怒り通話: テキストが警報閾値75に達する2発話前に、声が72に到達
  • 実呼(本人が演技): テキスト最大75に対し、声は35→82と先行して追い抜いた

人は言葉を選べるが、声量・話速・トーンの制御は先に破綻する。テキスト判定は怒りが語彙まで到達した後の観測であり、韻律はその上流にある。カスハラ対策の文脈では、この時間差が「エスカレート前に介入できる猶予」そのものだ。判定窓をリアルタイムの固定窓ではなく発話区間(VADのaudio_start_ms/end_ms)で切り直したことで差が見えるようになった。実測の詳細はこちら

4. 怒鳴らずに怒りをテストする

「怒った通話」の検証素材を毎回自分で演じるのは辛い(隣人にも心配される)。そこでTTSで台本から通話を合成し、電話帯域(8kHz)に落とし、KVS互換のMKVとしてパイプラインに流すフィクスチャを作った。実架電と同一の経路を通るので、シグナリング以外の全部が検証できる。作り方は単独記事にした

副産物として限界も分かった。TTSは音量を正規化するため「静かに抑えた怒り」を演じ分けられない(calmとcoldのRMS差1dB)。cold系の素材は人間の録音が要る——合成データの守備範囲を知ることも検証のうちである。

5. 認可は「ブラウザで捨てる」ではなく「そもそも送らない」

最終フェーズでSPA化(React+TypeScript+Vite)とCognito認証・ロール別認可を入れた。通話内容という機微データを扱う以上、WebSocketのbroadcastを全員に投げてクライアントで間引くのは境界ではない。サーバー側で宛先を絞る

def _allowed(self, who: dict, msg: dict) -> bool:
    """この席にこのイベントを送ってよいか(宛先化)。

    ブラウザ側で捨てるのは境界ではない——**そもそも送らない**。
    - SV/認証無効: 全部
    - 応対者: 自分の呼の内容だけ。未割り当てのライブ呼はメタのみ
      (「取る」ための存在通知。文字起こし等の中身は担当になってから)
    - call_updated(担当の変化)は全員へ
    """
    if not who or who.get("role") != "operator":
        return True
    mtype = msg.get("type")
    if mtype == "call_updated":
        return True
    rec = self.get_record(msg.get("contact_id", ""))
    owner = rec.owner_email if rec else None
    if owner == who.get("email"):
        return True
    if mtype in ("call_started", "call_ended"):
        return owner is None  # 未割り当てはメタだけ見せる
    return False

同じ絞り込みをREST側にも掛け(他人の呼の詳細・録音・カードは403)、実Cognitoトークンで境界E2Eを通した。UIも役割で分けた——応対者の画面にはアラート音も感情の数値も出さない。通話の中にいる本人は相手の怒りを一番よく知っており、割り込みは集中を奪うだけだ。気づくべきは監視卓(SV)である。この「誰に何を見せないか」の設計は、機能を足すより時間を使った。

プロセスの学び

  • PR単位+緑のCI+実測をPR本文に、を45日間崩さなかった。書き直しの多い検証プロジェクトほど、テストが仕様書として効く(SPA化のとき、jsdomテスト4本が移植の正解を機械で保証した)
  • 合成フィクスチャは開発速度そのものだった。実架電が必要な検証は3ラウンドで済み、残りは全部リプレイと合成で回した。インフラをdestroyしても開発が止まらない体制が、結果的に(番号クォータで)命綱になった
  • 検証は変数を分離して数える。「gpt-audioの0/5→5/5」も「声の2発話先行」も、印象ではなく回数を数えたから記事になった
  • 一方で、電話番号を使い捨てのリソース扱いした設計判断が命取りになった。この顛末と教訓は前回の記事に詳しい

供養と転生

リポジトリは全履歴を監査のうえ公開した: yoshiharu-ishii/crossbar_telepath。READMEの墓碑銘から各技術記事へ辿れる。

心理分析のコア——話者別ストリーム→文字起こし→テキスト+声のトーン融合判定→リアルタイムUI——はPSTNに依存していない。マイク入力や会議音声に載せ替えれば電話番号なしで生き返る。「声は言葉より早い」は、電話の外でも成り立つはずである。

シリーズ全記事

  1. Terraformで電話網に「盗聴されない盗聴器」を建てる
  2. 通話路を覗くな、シグナリングを引け
  3. 4回電話したのに履歴が2件しかない — KVSから消えた呼を救出する
  4. 知能は網に埋めるな — 電話網のIN設計がAIの時代に蘇る
  5. create_allは黙って列を無視する — 後からAlembicを導入する
  6. 怒った通話をTTSで合成する — KVS互換MKVで架電せずに検証
  7. gpt-audioに「音声をお聞かせください」と言われる — 直したのはJSONの指示を書く場所
  8. 声は言葉より早い — 怒り判定がテキストより2発話先に警報を出すまで
  9. レート制限が永遠に回復しなくなった話 — 自動再接続とカウンタの共鳴
  10. Terraformで作って壊すたびに電話番号の枠が減る — 180日ルールでプロジェクトを畳んだ話

コメント

タイトルとURLをコピーしました