# xVoice — システムフロー

## 1. システム全体像

```
┌─────────────┐       ┌──────────┐       ┌─────────────────┐
│  ケプラー    │──API──▶│          │       │  Deepgram       │
│ (営業日/顧客)│  連携  │          │◀─WS──▶│  Voice Agent    │
└─────────────┘       │  xVoice  │       │  (STT/LLM/TTS)  │
                      │  Server  │       └─────────────────┘
┌─────────────┐       │ (FastAPI)│       ┌─────────────────┐
│  管理画面    │◀─HTTP─▶│          │◀─WS──▶│  Twilio         │
│ (ブラウザ)   │       │          │       │  Media Streams  │
└─────────────┘       └──────────┘       └────────┬────────┘
                                                  │
                                           公衆電話網(PSTN)
                                                  │
                                          ┌───────┴───────┐
                                          │   お客様の電話  │
                                          └───────────────┘
```

### 主要コンポーネント

| コンポーネント | 役割 |
|--------------|------|
| **xVoice Server** | FastAPI。API・WebSocket・管理画面を提供 |
| **Twilio** | 電話回線。PSTN ↔ WebSocket（Media Streams）の変換 |
| **Deepgram Voice Agent** | STT（Nova-3）→ LLM（GPT-4o-mini）→ TTS（Aura-2）を一体処理。通話中に UpdatePrompt / UpdateThink / UpdateSpeak / Function Calling で動的拡張 |
| **ケプラー** | トヨタ販社の基幹システム。営業日/営業時間/顧客情報の源泉 |
| **管理画面** | シナリオ編集・通話テスト・通話履歴閲覧 |

---

## 2. 受話（着信）フロー

お客様 → 店舗AI電話番号に電話をかける場合。

```
お客様 ──電話──▶ Twilio番号
                    │
                    ▼
           Twilio が Webhook 呼出
           GET /twilio/twiml
                    │
                    ▼
           TwiML で Media Streams 指定
           <Stream url="wss://.../ws/twilio" />
                    │
                    ▼
     ┌──────────────────────────────────────┐
     │  xVoice: /ws/twilio                  │
     │                                      │
     │  1. シナリオ判定                      │
     │     - 営業日/営業時間 確認            │
     │     - 迷惑/営業/取引業者番号照合       │
     │     - コール数閾値チェック            │
     │     → 適用シナリオ決定                │
     │                                      │
     │  2. DB から拡張データ取得              │
     │     - specialist_agents（専門エージェント定義）│
     │     - agent_functions（Function Calling 定義）│
     │                                      │
     │  3. Deepgram Voice Agent 接続         │
     │     - prompt + greeting + functions   │
     │       を Settings メッセージで送信     │
     │                                      │
     │  4. 顧客情報の非同期注入（バックグラウンド）│
     │     - 着信番号 → 顧客情報取得（モック10秒）│
     │     - UpdatePrompt で追記             │
     │                                      │
     │  5. 音声ブリッジ + イベント処理        │
     │     Twilio ←→ xVoice ←→ DG          │
     │     (mulaw 8kHz 双方向転送)           │
     │                                      │
     │     - UserStartedSpeaking → バージイン│
     │     - ConversationText → 会話ログ蓄積 │
     │     - FunctionCallRequest             │
     │       → 非同期で処理:                 │
     │         a. lookup_customer → モック応答│
     │         b. search_knowledge → モック応答│
     │         c. transfer_to_specialist     │
     │            → UpdateThink(プロンプト置換)│
     │            → UpdateSpeak(声切替)       │
     └──────────────────────────────────────┘
                    │
                    ▼ (通話終了)
     ┌──────────────────────────────┐
     │  通話履歴保存                 │
     │  - direction: incoming       │
     │  - caller_number             │
     │  - transcript (会話ログ全文) │
     │  - summary (要件サマリー)    │
     └──────────────────────────────┘
```

### シナリオ判定ロジック（着信時）

着信があると、以下のフローチャートを**上から順に**評価し、最初に条件を満たしたシナリオを適用する。
どのシナリオにも該当しない場合は、店舗スタッフの電話がそのまま鳴る（AI不介入）。

```
着信（caller_number 取得）
 │
 │  ┌──────────────────────────────────────────────────┐
 ├──│ ① blocked_phone_numbers で caller_number を照合   │
 │  │    block_type = 'spam' に一致？                    │
 │  └────────────────────────Yes──▶ 「迷惑電話」シナリオ │
 │                                                      │
 │  ┌──────────────────────────────────────────────────┐
 ├──│ ② blocked_phone_numbers で caller_number を照合   │
 │  │    block_type = 'sales' に一致？                   │
 │  └────────────────────────Yes──▶ 「営業電話」シナリオ │
 │                                                      │
 │  ┌──────────────────────────────────────────────────┐
 ├──│ ②-2 blocked_phone_numbers で caller_number を照合 │
 │  │    block_type = 'partner' に一致？                 │
 │  └────────────────────────Yes──▶ 「取引業者」シナリオ │
 │                                                      │
 │  ┌──────────────────────────────────────────────────┐
 ├──│ ③ shop_business_days で今日の日付を検索           │
 │  │    is_holiday = true？                             │
 │  │    is_holiday = true → 「店休日」シナリオ          │
 │  └──Yes──────────────────────▶ 「店休日」シナリオ     │
 │                                                      │
 │  ┌──────────────────────────────────────────────────┐
 ├──│ ④ shop_business_hours で今日の曜日を検索          │
 │  │    現在時刻 < open_time or 現在時刻 > close_time？ │
 │  └────────────────────────Yes──▶ 「営業時間外」シナリオ│
 │                                                      │
 │  ┌──────────────────────────────────────────────────┐
 ├──│ ⑤ shop_breaks で現在時刻が休憩枠内か判定          │
 │  │    break.start_time <= now < break.end_time？      │
 │  └────────────────────────Yes──▶ 「休憩時間」シナリオ │
 │                                                      │
 │  ┌──────────────────────────────────────────────────┐
 ├──│ ⑥ Twilio のコール転送設定 or PBX連携              │
 │  │    店舗電話が ring_count_threshold 回以上鳴っても   │
 │  │    スタッフが応答しなかった？                       │
 │  └────────────────Yes──▶ 「営業時間（○コール以上）」  │
 │                                                      │
 └── 上記すべて No ──▶ 店舗スタッフが直接応答（AI不介入）
```

---

### シナリオ別 詳細仕様

#### ① 迷惑電話 (`spam_call`)

| 項目 | 内容 |
|------|------|
| **判定条件** | `blocked_phone_numbers` テーブルで `caller_number` が `block_type = 'spam'` に一致 |
| **参照データ** | `blocked_phone_numbers` (phone_number, block_type) |
| **判定タイミング** | 着信直後（Twilio Start イベントで caller_number 取得後） |
| **greeting** | 「お電話ありがとうございます。トヨタカローラ名古屋でございます。」 |
| **prompt方針** | 丁寧かつ毅然とお断り。個人情報・社内情報は一切回答しない。速やかに通話を終了する |
| **備考** | ケプラーからフラグ連携。連携タイミングによっては新規登録後にラグが発生する可能性あり |

#### ② 営業電話 (`sales_call`)

| 項目 | 内容 |
|------|------|
| **判定条件** | `blocked_phone_numbers` テーブルで `caller_number` が `block_type = 'sales'` に一致 |
| **参照データ** | `blocked_phone_numbers` (phone_number, block_type) |
| **判定タイミング** | 着信直後（①と同時に検索） |
| **greeting** | 「お電話ありがとうございます。トヨタカローラ名古屋でございます。」 |
| **prompt方針** | 丁寧にお断り。「担当者は不在」「折り返し連絡は難しい」旨を伝える。しつこい場合は「書面でお送りください」と案内 |
| **備考** | 迷惑電話と同じテーブルだが `block_type` で区別。対応トーンが異なる（営業電話は丁寧断り、迷惑電話は毅然と即終了） |

#### ②-2 取引業者 (`business_partner`)

| 項目 | 内容 |
|------|------|
| **判定条件** | `blocked_phone_numbers` テーブルで `caller_number` が `block_type = 'partner'` に一致 |
| **参照データ** | `blocked_phone_numbers` (phone_number, block_type) |
| **判定タイミング** | 着信直後（①②と同テーブルを参照） |
| **greeting** | 「いつもお世話になっております。…お取引先様、ご用件をお伺いいたします。」 |
| **prompt方針** | 社名・担当者名・用件を伺い、納期確認や連絡事項はメモを取る。担当者から折り返す旨を伝え、緊急時は優先取り次ぎを案内 |
| **備考** | 取引のある業者（部品商社・整備工具メーカー等）からの定期的な連絡を想定。`blocked_phone_numbers` テーブル名は歴史的経緯による（実態はホワイト+ブラックリスト） |

#### ③ 店休日 (`store_holiday`) / 年末年始・GW・お盆 (`year_end_holiday` / `golden_week` / `obon_holiday`)

| 項目 | 店休日 | 年末年始 / GW / お盆 |
|------|---------|---------------------|
| **判定条件** | `shop_business_days` で当日 `is_holiday = true` | 各長期休業シナリオは管理画面でON/OFF切替（自動判定なし） |
| **参照データ** | `shop_business_days` (date, is_holiday, holiday_name) | scenarios.is_active |
| **判定タイミング** | 着信時に当日の日付で検索 | シナリオ取得時 |
| **greeting** | 「…本日は定休日となっております。」 | 「…ただいま年末年始休業中となっております。」など |
| **prompt方針** | 用件と連絡先を伺い、**次の営業日**に折り返す旨を案内。緊急時はJAF案内 | 用件と連絡先を伺い、**営業再開後**に折り返す旨を案内。緊急時はJAF案内 |
| **is_active** | 常時 `true`（ケプラーの営業日データで自動判定） | デフォルト `false`。管理画面から手動で有効化 |
| **備考** | `holiday_name`（例: "定休日"）があれば greeting に含められる | 3シナリオ独立。長期休業の自動判定は廃止 |

#### ④ 営業時間外 (`business_hours_closed`)

| 項目 | 内容 |
|------|------|
| **判定条件** | `shop_business_hours` で当日の曜日レコードを取得し、`現在時刻 < open_time` または `現在時刻 >= close_time` |
| **参照データ** | `shop_business_hours` (day_of_week, open_time, close_time) |
| **判定タイミング** | ③の店休日判定が No の場合に評価 |
| **greeting** | 「…ただいまの時間は営業時間外となっております。」 |
| **prompt方針** | 用件と連絡先を伺い、**翌営業日**に折り返す旨を案内。緊急時（事故・故障等）はJAFへの連絡を案内 |
| **具体例** | 営業時間 09:00〜18:00 の店舗に 19:30 に着信 → 営業時間外シナリオ適用 |

#### ⑤ 休憩時間 (`lunch_break`)

| 項目 | 内容 |
|------|------|
| **判定条件** | `shop_breaks` で当日の休憩枠を取得し、`break.start_time <= 現在時刻 < break.end_time` |
| **参照データ** | `shop_business_hours` → `shop_breaks` (start_time, end_time) |
| **判定タイミング** | ④の営業時間外判定が No（= 営業時間内）の場合に評価 |
| **greeting** | 「…ただいまお昼休みのお時間をいただいております。」 |
| **prompt方針** | 用件と連絡先を伺い、**午後の営業再開後**に折り返す旨を案内。急ぎの場合はその旨を記録し優先対応する旨を伝える |
| **具体例** | 休憩枠 12:00〜13:00 の店舗に 12:30 に着信 → 休憩時間シナリオ適用 |
| **備考** | 1日に複数の休憩枠を設定可能（`shop_breaks` は `shop_business_hours` に複数紐づく） |

#### ⑥ 営業時間・Nコール以上 (`store_busy`)

| 項目 | 内容 |
|------|------|
| **判定条件** | 営業時間内 かつ 休憩時間外 かつ 店舗電話が `ring_count_threshold` 回（デフォルト5回）以上鳴ってもスタッフが応答しなかった |
| **参照データ** | `scenarios.ring_count_threshold` + Twilio転送設定 or PBX連携 |
| **判定タイミング** | Twilio側の設定で一定コール後にAI転送される（店舗PBX → Twilio転送 → xVoice） |
| **greeting** | 「…ただいまスタッフが対応中のため、AIオペレーターが承ります。」 |
| **prompt方針** | 用件を**詳しく**伺い、担当スタッフから折り返す旨を案内。予約・点検の希望日時など詳細を聞き取る |
| **実現方式** | 店舗のPBXまたはTwilioの転送設定で「N秒（≒Nコール）応答なし → AI番号に転送」を設定。xVoice側はこの着信を受けた時点で store_busy シナリオを適用 |
| **備考** | `ring_count_threshold` は管理画面から変更可能 |

---

### シナリオ判定と Deepgram への設定反映

```
シナリオ決定
 │
 ▼
scenarios テーブルから current_version_id を取得
 │
 ▼
scenario_versions テーブルから以下を取得:
 ├─ greeting_message  → Deepgram Agent の greeting に設定
 └─ system_prompt     → Deepgram Agent の think.prompt に設定
 │
 ▼
Deepgram Voice Agent API に Settings を送信:
{
  "type": "Settings",
  "audio": {
    "input":  { "encoding": "mulaw", "sample_rate": 8000 },
    "output": { "encoding": "mulaw", "sample_rate": 8000, "container": "none" }
  },
  "agent": {
    "language": "ja",
    "listen":  { "provider": { "type": "deepgram", "model": "nova-3" } },
    "think":   {
      "provider": { "type": "open_ai",  "model": "gpt-4o-mini" },
      "prompt": "<system_prompt + 専門エージェント切替ヒント>",
      "functions": [                          ← agent_functions テーブルから生成
        { "name": "lookup_customer", "description": "...", "parameters": {...} },
        { "name": "search_knowledge", "description": "...", "parameters": {...} },
        { "name": "transfer_to_specialist", "description": "...", "parameters": {...} }
      ]
    },
    "speak":   { "provider": { "type": "deepgram", "model": "aura-2-izanami-ja" } },
    "greeting": "<scenario_versions.greeting_message>"
  }
}
```

> - **greeting_message**: Deepgram が通話開始直後に自動的にTTS再生するテキスト。お客様が最初に聞く音声。
>   - **空欄保存可**。空のときは Settings の `greeting` キー自体を送らず、AI から先に発話させない。
>     代わりに `system_prompt` 末尾に「お客様の第一声を待ってから自然に応対せよ」という指示（`NO_GREETING_DIRECTIVE`）を自動注入する。
>     営業電話・迷惑電話シナリオなど「お客様の用件次第で応対を分岐したい」場面で使用。
> - **system_prompt**: LLM（GPT-4o-mini）に渡すシステムプロンプト。AIオペレーターの振る舞い・応対方針・禁止事項等を定義。
> - **functions**: LLM が必要に応じて呼び出す関数定義。`endpoint` なし = クライアントサイド関数（xVoice が処理して応答を返す）。
>   - シナリオの `multi_agent_enabled=false` のときは `transfer_to_specialist` 関数も登録されず、specialists 一覧自体が空で渡るためマルチエージェント切替は完全に抑止される。
> - シナリオごとに greeting と prompt の両方がバージョン管理されているため、管理画面で編集・保存した内容がそのまま着信時に使われる。

### 通話中の動的拡張（Deepgram WebSocket メッセージ）

着信後、通話中に以下の3つの仕組みで動的にAIの振る舞いを変更する。

#### ① UpdatePrompt — 顧客情報・過去通話履歴の追記

着信と**同時に**バックグラウンドで顧客情報取得を開始し、取得完了後に `UpdatePrompt` でプロンプトに追記する。
通話は待たずに開始されるため、お客様に遅延は感じさせない。

```
着信開始 ─────────────────────────────────────────▶ 通話中
 │                                                   ↑
 └─ [バックグラウンド] 顧客情報取得                    │
       └─ calls テーブルから caller_number で         │
          過去通話を直近3件取得し、metadata から       │
          customer_name / vehicle / 直近予約等を集約  │
                       │                             │
                       ▼                             │
                  UpdatePrompt ──────────────────────┘
                  {
                    "type": "UpdatePrompt",
                    "prompt": "【お客様情報（着信番号: ...）】\n
                               ・お名前: <metadata.customer_name>\n
                               ・お車: <metadata.vehicle>\n
                               【過去のやり取り（直近）】\n
                               ・YYYY-MM-DD HH:MM (着信) <summary>"
                  }

※ UpdatePrompt は既存プロンプトに「追記」される（置換ではない）
※ 追記後、AIは過去のやり取りを踏まえて自然に応対する（一度に並べ立てない）
※ 過去通話が無い番号なら UpdatePrompt は送らない（AI に嘘の情報を与えない）
※ ケプラー本連携が来たら _build_customer_context の中だけ差し替える設計
```

#### ② エージェント切替 — 専門オペレーターへの切替

会話の流れから「専門的な話題になった」と判断されたら、xVoice はエージェント切替シーケンスを実行する。
判断は以下の **3 経路** で行われ、最初に発火したものが切替を起動する（同じエージェントへの再切替は抑止される）。

> **前提**: シナリオの `multi_agent_enabled=true` のときのみ動作する。`false` のシナリオでは specialists 一覧が空で渡るため、direct match / LLM Function Call の両経路ともスキップされる。営業電話・迷惑電話など「専門エージェントに繋ぐ必要がない」シナリオは OFF で運用する。

```
1. user 発話の direct match
   お客様発話の ConversationText に specialist の trigger_hint が含まれる
   → 即時切替（旧声で「○○にお繋ぎします」を予告してから新声に切替）

2. assistant 発話の predicate match
   AI 発話に「お繋ぎ／代わります／引き継ぎ／専門担当」等のシグナル語＋trigger_hint が両方含まれる
   → 即時切替（既に AI が予告発話済みなので予告アナウンスは省略）

3. LLM の Function Calling（transfer_to_specialist）
   LLM が文脈判断で transfer_to_specialist(agent_id, reason) を呼び出す
   → 切替（旧声で予告 → 新声へ切替）
```

**3 経路をなぜ用意するか:**

- ① の direct match は最速（user 発話直後）。Deepgram VAD のタイミングで `ConversationText` 確定が遅れた場合のフェイルセーフが必要
- ② は ① が間に合わずに AI が「○○にお繋ぎします」と先に発話してしまったケースを救済する
- ③ は文脈判断が必要な微妙なケースで LLM が判断する正攻法

**切替シーケンス（共通）:**

```
切替発火（経路①〜③のいずれか）
 │
 ▼
agent_state["current_id"] を比較
  既に同じID → スキップ（FunctionCallResponse の場合は already_active=true で返答）
  未切替/別ID → 続行
 │
 ▼
[announce_transfer=True のとき]    ← user direct match / Function Call で True
  InjectAgentMessage（旧声）
   "○○にお繋ぎします。少々お待ちください。"
  await asyncio.sleep(2.5)         ← 旧声 TTS 完了待ち
 │
 ▼
UpdateSpeak                        ← 声を切替（VALID_TTS_MODELS_JA でガード）
  { "speak": { "provider": {"type":"deepgram","model":"aura-2-fujin-ja"} } }
 │
 ▼
InjectAgentMessage（新声）         ← specialist greeting
  "保険に詳しい専門オペレーターに代わります。少々お待ちください。"
 │
 ▼
UpdatePrompt                       ← プロンプトを強烈に上書き
  「【最重要・上書き指示 / OVERRIDE】これより前に与えられた…
    （営業電話お断り、迷惑電話拒否、店休日案内 等）はすべて完全に無効化…
    お客様は専門相談に来た正当なお客様です…
    ##############################################
    <specialist_agents.prompt>
    ##############################################」
 │
 ▼
[Function Call 経路のみ]
  FunctionCallResponse              ← LLM に切替完了通知
   { "success": true, "switched_to": "...", "already_active": false }
 │
 ▼
専門エージェントとして会話を継続（会話履歴は引き継がれる）
```

**仕様メモ:**

- `UpdatePrompt` は **append 仕様**（既存プロンプトに追記される）。元シナリオの「お断り」指示が残ると LLM が断り応答に引きずられるため、override_prompt の冒頭で打ち消し文と「正当なお客様」明示で人格を上書きする
- `LLM プロバイダ/モデルはセッション中に変更不可`（Deepgram Voice Agent 仕様）
- `tts_model` に未知のモデル名を送ると `FAILED_TO_SPEAK` で通話が切れるため、サーバ側で公式サポート 5 モデル（izanami / uzume / ama / fujin / ebisu）でガード
- `trigger_hint` の処理:
  - カンマ・読点・空白・スラッシュ・パイプで分割
  - **1 文字キーワードは誤マッチ防止のため除外**（例: 「車」のみだとあらゆる文脈でマッチしてしまう）
  - 空 `trigger_hint` のエージェントは `_build_agent_settings` の hint 行に含めない（LLM のプロンプトノイズ削減）
- 二重切替抑止: `agent_state` という可変 dict を closure 共有し、`current_id` を持つ。direct match と Function Call の両方が同じ ID を更新・参照するため、二重発火が防がれる
- assistant 発話による predicate match は誤検知防止のため **シグナル語＋trigger_hint の両方を含むときのみ** マッチ
- ブラウザテスト（`/test/ws` / `/test/ws/test/{session_id}`）でも電話と同じ切替ロジックが動く

**現在登録済みの専門エージェント（例）:**

| ID | 名称 | trigger_hint | 声 |
|----|------|-------------------|-----|
| `insurance_expert` | 保険専門オペレーター | 保険,特約,車両入替,等級,免責... | aura-2-fujin-ja |
| `maintenance_expert` | 整備専門オペレーター | 車検,整備,部品,オイル交換,警告灯... | aura-2-fujin-ja |

#### ③ Function Calling — RAG / 外部データ取得

LLM が回答に必要な情報を持っていない場合、定義済み関数を呼び出す。
xVoice がリクエストを受け取り、処理結果を返す。

```
LLM: 「保険の等級について調べます」
           │
           ▼
   FunctionCallRequest (Deepgram → xVoice)
   {
     "type": "FunctionCallRequest",
     "functions": [{
       "id": "fc_abc123...",
       "name": "search_knowledge",
       "arguments": "{\"query\": \"自動車保険 等級 仕組み\"}"
     }]
   }
           │
           ▼ xVoice が処理
           │
    ┌──────┴──────────────────────────────────────┐
    │ 1. InjectAgentMessage                        │
    │    「少々お待ちください、お調べいたします。」  │
    │                                              │
    │ 2. handler_type に応じて処理                  │
    │    - mock: delay_sec 後にモック応答            │
    │    - api:  外部API呼び出し（将来）             │
    │    - rag:  ベクトルDB検索（将来）              │
    │                                              │
    │ 3. FunctionCallResponse                      │
    │    { "type": "FunctionCallResponse",         │
    │      "id": "fc_abc123...",                   │
    │      "name": "search_knowledge",             │
    │      "content": "{\"found\":true,...}" }     │
    └──────────────────────────────────────────────┘
           │
           ▼
    LLM が検索結果を踏まえてお客様に回答

※ FunctionCallRequest の arguments は JSON文字列で届く
※ FunctionCallResponse の content も JSON文字列で返す
※ 処理は非同期（音声転送はブロックしない）
```

**現在登録済みの関数:**

| ID | handler_type | 遅延 | 用途 |
|----|-------------|------|------|
| `lookup_customer` | mock | 10秒 | 着信番号から顧客情報取得 |
| `search_knowledge` | mock | 5秒 | ナレッジベース検索 |

#### ④ 無音タイムアウト・最大通話時間 — 課金スパイク対策

無言放置・長電話による Twilio / Deepgram の継続課金を防ぐため、`_silence_watchdog` が 1 秒ごとに状態を監視する。
着信（`/ws/twilio`）・ブラウザテスト（`/test/ws/test/{session_id}` / `/test/ws`）の **3 経路すべて** で同じ実装を共有する。

```
通話開始
 │
 │  call_start_at = time.monotonic()  ← 通話開始時刻
 │  last_activity_at = call_start_at  ← 最終活動時刻
 │
 ├─ ユーザー活動でリセット:
 │   - UserStartedSpeaking
 │   - ConversationText (role=user)
 │   - AgentAudioDone（AI発話終了 = AI発話中はカウントしない）
 │
 ▼
1 秒ごとに監視:
 │
 ├─ 通話開始から 5 分（MAX_CALL_DURATION_SEC、シナリオで上書き可）超過？
 │   → InjectAgentMessage「失礼いたします。」
 │   → 5 秒待機（HANGUP_GRACE_SEC）
 │   → websocket.close()
 │   ※ シナリオの `max_call_duration_sec=0` なら無制限（チェックスキップ）
 │
 ├─ 通話上限まで残り 30 秒（MAX_CALL_PRE_HANGUP_NOTICE_SEC）切った？（1回のみ）
 │   → InjectAgentMessage「お時間の都合で、間もなくお電話を終了させていただきます。」
 │
 ├─ 最終活動から 20 秒（SILENCE_HANGUP_SEC）無音？
 │   → InjectAgentMessage「失礼いたします。」
 │   → 5 秒待機
 │   → websocket.close()
 │
 └─ 最終活動から 10 秒（SILENCE_WARN_SEC）無音？
     → InjectAgentMessage「お電話が遠いようです。もう一度お願いいたします。」
     → 同区間 1 回 + 1 通話で 1 回まで（SILENCE_WARN_MAX）
```

**設定値（既定）:**

既定値は環境変数（`.env`）で全店共通の上書きが可能。シナリオ単位の上書きは `max_call_duration_sec` のみ（DB の `scenarios` テーブル）。

| 定数 | 既定値 | 環境変数 | シナリオ別上書き | 用途 |
|------|-------|---------|---------------|------|
| `SILENCE_WARN_SEC` | 10.0 | `SILENCE_WARN_SEC` | ― | 無音で「お電話が遠いようです」を発話する閾値 |
| `SILENCE_HANGUP_SEC` | 20.0 | `SILENCE_HANGUP_SEC` | ― | 無音で強制切断する閾値（「失礼いたします」発話後） |
| `SILENCE_WARN_MAX` | 1 | ― | ― | 1 通話あたり警告メッセージを出す最大回数 |
| `MAX_CALL_DURATION_SEC` | 300.0 | `MAX_CALL_DURATION_SEC` | `scenarios.max_call_duration_sec` | 通話開始からの絶対上限。`0` で無制限 |
| `MAX_CALL_PRE_HANGUP_NOTICE_SEC` | 30.0 | ― | ― | 上限の N 秒前に終話予告を入れる |
| `HANGUP_GRACE_SEC` | 5.0 | ― | ― | 終話メッセージ発話用の猶予 |

**仕様メモ:**

- AI が長く喋っている間はタイマーが進まない（`AgentAudioDone` で `last_activity_at` 更新 → 無音計測は AI 発話終了から）
- 警告は同じ無音区間で 1 回だけ。ユーザー活動があれば `warning_sent` フラグがリセットされ、次の無音で再度警告可能
- 1 通話で警告を出せる総回数は `SILENCE_WARN_MAX` 回まで（10 秒で再促し→さらに 10 秒無音なら切断、合計 20 秒）
- 切断時の終話メッセージは旧声/新声どちらでも自然に聞こえるよう短文
- `silence_state` は dict で closure 共有。各イベントハンドラから直接更新する
- シナリオで `max_call_duration_sec` を `null`（既定）/ `0`（無制限）/ 正数（その秒数）で個別設定可能。長時間の重要相談シナリオは `0`、迷惑電話は短く設定する運用想定

---

## 3. 発話（発信）フロー

整備入庫リマインダーなど、店舗 → お客様への発信。

```
管理画面 or API
 │
 ▼
POST /api/calls/outgoing
 │  phone, customer_name, date, time, service, is_shaken
 │
 ▼
┌─────────────────────────────────┐
│  1. 通話レコード作成 (status:    │
│     calling)                    │
│                                 │
│  2. Twilio REST API で発信      │
│     - to: お客様電話番号         │
│     - from: Twilio番号          │
│     - url: /twilio/outgoing-twiml│
│     - status_callback 登録      │
│                                 │
│  3. 相手が応答                   │
│     → TwiML で Media Streams    │
│     → /ws/twilio-outgoing 接続  │
└─────────────────────────────────┘
          │
          ▼
┌─────────────────────────────────┐
│  /ws/twilio-outgoing            │
│                                 │
│  1. 顧客情報からプロンプト生成   │
│     - 氏名・入庫日時・作業内容  │
│     - 車検フラグ → 荷物案内追加 │
│                                 │
│  2. Deepgram Voice Agent 接続   │
│     - 発信用 prompt + greeting  │
│                                 │
│  3. 音声ブリッジ（受話と同様）   │
│  4. 会話テキスト蓄積             │
└─────────────────────────────────┘
          │
          ▼ (通話終了 or 不応答)
┌─────────────────────────────────┐
│  Twilio Status Callback         │
│  POST /twilio/status-callback   │
│                                 │
│  completed (通話時間>0) → 完了  │
│  completed (通話時間=0) → 不応答│
│  no-answer / busy / failed      │
│    → リトライ判定               │
│                                 │
│  リトライ:                       │
│    retry_count < max_retries ?  │
│    → Yes: N秒後に自動再発信     │
│    → No:  gave_up ステータス    │
└─────────────────────────────────┘
```

### リトライ仕様

| 設定 | デフォルト |
|------|----------|
| リトライ間隔 | 180秒（3分） |
| 最大リトライ回数 | 3回 |
| 呼出タイムアウト | 90秒 |

- 不応答・話中・失敗の場合に自動リトライ
- iPhoneブロック等で通話時間0秒のcompleted → 不応答扱い
- 手動リトライ API (`POST /api/calls/{call_id}/retry`) あり

---

## 4. シナリオ管理フロー

管理画面でシナリオ（AIの応答設定）を編集・保存する流れ。

```
管理画面
 │
 ├─ シナリオ一覧取得
 │  GET /api/{store_id}/{shop_id}/scenarios
 │  → プルダウンに表示
 │
 ├─ シナリオ詳細取得
 │  GET /api/{store_id}/{shop_id}/scenarios/{scenario_id}
 │  → greeting_message, system_prompt, current_version 表示
 │
 ├─ AI整形（任意）
 │  POST /api/.../prompt/generate
 │  → 変更指示を入力すると AIがプロンプトを改善
 │  → プレビュー状態（まだ未保存）
 │
 ├─ 通話テスト（任意）
 │  POST /api/.../test/browser  → ブラウザ上で音声テスト
 │  POST /api/.../test/phone    → 実電話でテスト
 │  → 未保存のプレビュー内容でテスト可能
 │
 └─ 保存
    POST /api/{store_id}/{shop_id}/scenarios/save
    → greeting_message + system_prompt を保存
    → 新バージョンが自動作成
    → 以降の着信はこの設定で応答
```

### バージョン管理

```
保存するたびに新バージョンが作成される

  ver_001 ← ver_002 ← ver_003 (current) ← ver_004
                         ↑
                    現在運用中

操作:
  - 一覧取得:  GET  .../versions
  - 詳細取得:  GET  .../versions/{version_id}
  - 復元:      POST .../versions/{version_id}/restore
               → 過去バージョンの内容で新バージョンを作成
  - 削除:      DELETE .../versions/{version_id}
               → 運用中バージョンは削除不可
```

---

## 5. 通話結果の取得・要約・スタッフ引き継ぎ

通話中〜通話終了後のデータ収集・加工・保存の流れ。

### 5.1 通話中のデータ収集

通話中、Deepgram Voice Agent API から WebSocket 経由で以下のイベントがリアルタイムに届く。

```
Deepgram Voice Agent API
 │
 ├── [バイナリ]  TTS音声データ (mulaw 8kHz)
 │    → Twilio へ転送（お客様に聞こえる音声）
 │
 ├── ConversationText イベント
 │    {
 │      "type": "ConversationText",
 │      "role": "user",           ← お客様の発言（STT結果）
 │      "content": "車検の予約をしたいんですが"
 │    }
 │    {
 │      "type": "ConversationText",
 │      "role": "assistant",      ← AIオペレーターの発言（LLM生成テキスト）
 │      "content": "車検のご予約ですね。お名前をお伺いしてもよろしいでしょうか。"
 │    }
 │    → xVoice 側の conversation_texts リストに蓄積
 │
 └── UserStartedSpeaking イベント
      → バージイン処理（AIの音声再生を中断）
```

**xVoice がメモリ上に保持するデータ（通話中）:**

```python
conversation_texts: list[dict] = [
    {"role": "user",      "content": "車検の予約をしたいんですが"},
    {"role": "assistant", "content": "車検のご予約ですね。お名前をお伺いしてもよろしいでしょうか。"},
    {"role": "user",      "content": "タテイシです"},
    {"role": "assistant", "content": "タテイシ様、ありがとうございます。お車のナンバー下4桁を..."},
    ...
]
```

### 5.2 通話終了時の保存処理

WebSocket 切断（通話終了）をトリガーに、以下の処理が実行される。

```
通話終了（WebSocket 切断）
 │
 ▼
┌──────────────────────────────────────────────────────┐
│  1. 会話ログ整形 (full_text)                          │
│     conversation_texts を読みやすいテキストに変換      │
│                                                      │
│     お客様: 車検の予約をしたいんですが                  │
│     オペレーター: 車検のご予約ですね。お名前を...       │
│     お客様: タテイシです                               │
│     オペレーター: タテイシ様、ありがとうございます...    │
│                                                      │
│  2. 要件サマリー生成 (summary)                         │
│     INSERT 直後の summary は簡易版（お客様発言の        │
│     先頭200文字）。続いて asyncio.create_task で       │
│     summarize_and_update_call が走り、GPT-4o-mini     │
│     で構造化要約を生成して summary / metadata を更新。  │
│                                                      │
│  3. calls テーブルに保存                               │
│     {                                                │
│       direction:    "incoming" / "outgoing" / "tablet",│
│       caller_number: "+819012345678",                 │
│       scenario_id:  "business_hours_closed",          │
│       status:       "completed",                      │
│       summary:      "車検予約希望。タテイシ様...",      │
│       transcript:   [{role, content}, ...],  ← JSON  │
│       metadata:     {customer_name, urgency, ...},   │
│       handoff_status: "pending",                      │
│       duration_sec: 180,                              │
│       started_at:   "2026-04-10T18:30:00+09:00",     │
│       ended_at:     "2026-04-10T18:33:00+09:00"      │
│     }                                                │
│                                                      │
│  4. 管理画面はポーリングで一覧再取得                    │
│     （SSE /api/calls/stream は廃止済み）              │
└──────────────────────────────────────────────────────┘
```

### 5.3 要約と引き継ぎ情報の生成

通話結果から「スタッフに伝えるべき内容」を構造化して引き継ぐ。

#### 自動要約フロー（着信 / 対面受付）

通話終了時の `INSERT INTO calls` 直後、`asyncio.create_task(summarize_and_update_call(call_id, transcript))` で **バックグラウンドで自動要約** が走る（着信本流: `server/incoming.py`、対面受付: `server/test_call.py._run_browser_test` の `test_type='tablet'` 経路）。

```python
# server/summarize.py（抜粋）
async def summarize_and_update_call(call_id, transcript):
    summary = await summarize_transcript(transcript)  # OpenAI gpt-4o-mini
    UPDATE calls SET summary = :summary, metadata = :metadata WHERE id = :id
```

- 要約失敗時 / `OPENAI_API_KEY` 未設定時はフォールバック（お客様発言の先頭200文字）に切り替え、呼び出し元は落とさない
- 手動再生成は `POST /api/calls/summarize`（API §9.3）

#### 引き継ぎステータス管理

`calls.handoff_status` で担当者の対応状況を 3 値管理:

| 値 | 意味 |
|----|------|
| `pending` | 未対応（既定値）|
| `in_progress` | 対応中 |
| `done` | 完了 |

管理画面の通話詳細から `PATCH /api/{store_id}/{shop_id}/calls/{call_id}/handoff-status`（API §9.4）でステータスを更新する。

#### 要約用プロンプトの抽出項目（構造化 JSON）

通話終了時に LLM（GPT-4o-mini）へ会話ログ全文を渡し、構造化された要約を生成する。

```
会話ログ全文
 │
 ▼
GPT-4o-mini（要約用プロンプト）
 │
 ▼
構造化 JSON で返却:
{
  "summary":       "車検予約希望",
  "customer": {
    "name":        "タテイシ",
    "phone":       "09012345678",
    "plate_last4": "1234",
    "is_existing":  true
  },
  "request": {
    "type":           "新規予約",
    "service":        "車検",
    "preferred_date": "4月15日 13時",
    "second_date":    "4月16日 午前",
    "loaner_needed":  true,
    "symptoms":       null
  },
  "urgency":       "通常",
  "staff_notes":   "代車希望あり。車検のため荷物を降ろして来店いただくよう案内済み。",
  "action_needed": "車検予約の空き確認 → 折り返し連絡"
}
```

**要約用プロンプトで抽出する項目（スタッフ引き継ぎ用）:**

| カテゴリ | 抽出項目 | 説明 |
|---------|---------|------|
| **顧客情報** | 氏名（カタカナ） | AIが漢字変換せずカタカナのまま記録 |
| | 電話番号 | 折り返し先 |
| | ナンバー下4桁 | 車両特定用（1桁ずつ記録） |
| | 既存客/新規 | |
| **用件** | 用件種別 | 新規予約/変更/キャンセル/不具合/事故/試乗/その他 |
| | 対象サービス | 点検/車検/オイル交換/保険/etc. |
| | 希望日時 | 第一希望 + 第二希望 |
| | 代車希望 | あり/なし |
| | 症状・詳細 | 不具合の場合: いつから/走行可否/警告灯等 |
| **緊急度** | 緊急/急ぎ/通常 | 事故・自走不可・ブレーキ異常等は「緊急」 |
| **担当者指名** | 指名スタッフ名 | あれば |
| **AI案内済み事項** | AIが伝えた内容 | 「JAF案内済み」「荷物案内済み」等 |
| **必要アクション** | スタッフがすべきこと | 「予約空き確認→折り返し」「至急連絡」等 |

#### シナリオ別の引き継ぎポイント

| シナリオ | 引き継ぎで特に重要な項目 |
|---------|----------------------|
| **営業時間外** | 用件 + 連絡先 + 緊急度。翌営業日に折り返す前提 |
| **店休日** | 同上。次営業日がいつかの情報もあると良い |
| **休憩時間** | 用件 + 連絡先。午後に折り返す前提。急ぎフラグ |
| **年末年始/GW/お盆** | 用件 + 連絡先 + 緊急度。営業再開日の情報 |
| **営業中Nコール以上** | 用件の詳細（予約希望日時等）。すぐ折り返せる前提なので詳しく聞き取り |
| **営業電話** | 相手の会社名・名前・用件。基本はお断り済みの旨 |
| **迷惑電話** | 電話番号のみ記録。通話内容の詳細は不要 |
| **発信（リマインダー）** | 予定変更/キャンセル希望の有無 + 追加依頼の内容 |

### 5.4 通話結果の取得API

保存された通話結果は以下のAPIで取得する。

```
┌─────────────────────────────────────────────────┐
│  通話履歴一覧                                     │
│  GET /api/{store_id}/{shop_id}/calls             │
│  → 新しい順にソート、direction/status でフィルタ可  │
│  → 各レコードに summary, handoff_status, ...      │
│                                                  │
│  通話詳細                                          │
│  GET /api/{store_id}/{shop_id}/calls/{call_id}   │
│  → transcript / metadata を含めて全件返却         │
│                                                  │
│  引き継ぎステータス更新                            │
│  PATCH .../calls/{call_id}/handoff-status        │
│  → pending / in_progress / done を切替           │
│                                                  │
│  ※ SSE /api/calls/stream は廃止。画面側はポーリング│
└─────────────────────────────────────────────────┘
```

#### calls テーブルの主要カラムと用途

| カラム | 型 | 用途 |
|-------|-----|------|
| `id` | BIGINT | 通話ID |
| `store_id` | VARCHAR(100) | 販売会社ID |
| `shop_id` | VARCHAR(100) | 店舗ID |
| `direction` | VARCHAR(10) | `incoming`（着信）/ `outgoing`（発信）/ `tablet`（対面受付） |
| `caller_number` | VARCHAR(20) | 発信元電話番号（`tablet` は null） |
| `scenario_id` | VARCHAR(100) | 適用されたシナリオ |
| `status` | VARCHAR(20) | `ringing` / `active` / `completed` / `failed` / `no-answer` / `busy` / `canceled` / `error` |
| `duration_sec` | INT | 通話時間（秒） |
| `summary` | TEXT | 要件サマリー（スタッフが一覧で確認） |
| `transcript` | JSON | 会話ログ全文 `[{role, content}, ...]` |
| `metadata` | JSON | 構造化要約（`customer_name` / `request_type` / `action_needed` / `urgency` 等） |
| `handoff_status` | VARCHAR(20) | 引き継ぎ対応状況: `pending` / `in_progress` / `done` |
| `started_at` | DATETIME | 通話開始日時 |
| `ended_at` | DATETIME | 通話終了日時 |
| `created_at` | DATETIME | レコード作成日時 |

#### データの流れまとめ

```
                     通話中                          通話終了後
                 ┌────────────┐                  ┌──────────────┐
Deepgram ──WS──▶│ xVoice     │                  │              │
                │            │                  │  calls DB    │
  ConversationText イベント   │──保存──▶          │              │
  ・role: user/assistant     │                  │  transcript  │──▶ 管理画面
  ・content: テキスト         │                  │  summary     │    (一覧表示)
                │            │                  │  metadata    │
                │ メモリ上に蓄積│                  │              │
                └────────────┘                  └──────┬───────┘
                                                       │
                                                       ▼
                                                ┌──────────────┐
                                                │  SSE 配信     │
                                                │  /api/calls/  │
                                                │   stream      │
                                                └──────┬───────┘
                                                       │
                                                       ▼
                                                 管理画面が
                                                 リアルタイム更新
```

### 5.5 通話音声の録音（Twilio Recording）

会話のテキストログとは別に、通話音声そのものを録音・保存する場合は Twilio の Recording 機能を利用する。

```
方法A: TwiML に <Record> を追加
──────────────────────────────
<Response>
  <Connect>
    <Stream url="wss://.../ws/twilio" />
  </Connect>
  <Record recordingStatusCallback="/twilio/recording-callback" />
</Response>

→ Twilio が音声を自動録音し、完了後にコールバックで URL を通知
→ calls テーブルの metadata に recording_url を保存


方法B: Twilio REST API で録音開始
──────────────────────────────
client.calls(sid).recordings.create()

→ 通話中に動的に録音を開始/停止できる
```

| 項目 | 内容 |
|------|------|
| **録音フォーマット** | WAV or MP3（Twilio設定） |
| **保存先** | Twilio クラウド上（デフォルト30日保持） or S3等に転送 |
| **取得API** | `GET /api/calls/{call_id}/recording` → Twilio Recording URL を返す |
| **用途** | トラブル時の確認、応対品質チェック、AI精度の評価 |
| **注意** | 通話録音には法的な告知義務あり。greeting で「品質向上のため録音させていただきます」等の案内が必要 |

> 現時点では Twilio Recording は未実装。テキストログ（transcript）のみで運用中。

---

## 6. ケプラー連携フロー

ケプラー（トヨタ販社基幹システム）からのデータ連携。

```
ケプラー
 │
 ├─ ① 営業日（店休日）
 │    日付 + 休日フラグ
 │    → shop_business_days テーブル
 │
 ├─ ② 営業時間
 │    曜日 + 開店/閉店時刻
 │    → shop_business_hours テーブル
 │
 ├─ ③ 休憩時間
 │    営業時間内の休憩枠
 │    → shop_breaks テーブル
 │
 └─ ④ 迷惑/営業/取引業者電話フラグ
      電話番号 + spam/sales/partner 区分
      → blocked_phone_numbers テーブル
```

> **連携方式は検討中（2026-04-10時点）**
>
> - **案A**: 専用API で xVoice 側に連携（シナリオ保存APIとは別タイミング）
> - **案B**: ケプラーDB を xVoice から直接参照
>
> 迷惑/営業/取引業者電話フラグについては追加で：
> - **案C**: 既存顧客データ（渡部さん側）にフラグ追加（日次バッチ、タイムラグあり）
> - **案D**: 専用リストAPI で追加/削除（リアルタイム）

---

## 7. データモデル概要

```
scenario_types                          ← 全店舗共通マスタ（プルダウン用）
 │
shops
 └── stores  ※ shop_id 単体は重複可、(store_id, shop_id) で一意
      │
      ├── scenarios ──FK──▶ scenario_types
      │    └── scenario_versions        (greeting + prompt のバージョン履歴)
      │
      ├── shop_business_days           (ケプラー: 営業日/店休日)
      ├── shop_business_hours          (ケプラー: 営業時間)
      │    └── shop_breaks             (ケプラー: 休憩時間)
      │
      ├── blocked_phone_numbers         (ケプラー: 迷惑/営業/取引業者電話リスト)
      │
      ├── test_sessions                 (通話テスト: ブラウザ/電話)
      └── calls ──FK──▶ stores          (通話履歴 ※店舗に必ず紐づく)
```

### キー設計

| テーブル | PK | 親テーブルFK |
|---------|-----|-------------|
| shops | `(id)` | — |
| stores | `(store_id, id)` | shops |
| scenario_types | `(id)` | — （共通マスタ） |
| scenarios | `(store_id, shop_id, id)` | stores + scenario_types |
| scenario_versions | `(store_id, shop_id, scenario_id, id)` | scenarios |
| shop_business_days | `(id)` + UNIQUE `(store_id, shop_id, date)` | stores |
| shop_business_hours | `(id)` + UNIQUE `(store_id, shop_id, day_of_week)` | stores |
| shop_breaks | `(id)` | shop_business_hours |
| blocked_phone_numbers | `(id)` + UNIQUE `(store_id, shop_id, phone_number, block_type)` | stores |
| test_sessions | `(id)` | scenarios |
| calls | `(id)` | stores |

---

## 8. 通信プロトコル

### 着信時の音声パイプライン

```
お客様 ←PSTN→ Twilio ←WS(mulaw 8kHz)→ xVoice ←WS(mulaw 8kHz)→ Deepgram
```

| 区間 | プロトコル | フォーマット |
|------|----------|-------------|
| お客様 ↔ Twilio | PSTN | - |
| Twilio ↔ xVoice | WebSocket (Media Streams) | mulaw, 8kHz, base64エンコード |
| xVoice ↔ Deepgram | WebSocket (Voice Agent API) | mulaw, 8kHz, バイナリ |

### 管理画面 ↔ サーバー

| 用途 | プロトコル |
|------|----------|
| API呼出 | HTTP (REST) |
| 通話履歴リアルタイム更新 | SSE (`/api/calls/stream`) |
| ブラウザ通話テスト | WebSocket |

---

## 9. デプロイ構成

| 環境 | 用途 | 備考 |
|------|------|------|
| **Vercel** | 受話API (Serverless Functions) | `api/index.py` |
| **Fly.io** | 通話サーバー (WebSocket常駐) | `server/` — Twilio/Deepgram連携 |
| **ローカル** | 開発 | ngrok 等でトンネル |
