# 受話API仕様書

**Version: v1.0.9**

## 変更履歴
- **v1.0.9**: シナリオ分類 / 対面受付 / 引き継ぎステータス（2026-05-12）
  - **シナリオ分類**: `scenario_types.category` を追加（`incoming` / `outgoing` / `tablet`）。シナリオ管理画面でカテゴリ別タブ表示。発信用は枠のみで scenario_types 未登録。対面受付用に5種（`vendor_reception` / `general_counter` / `after_hours_counter` / `event_reception` / `lodging_counter`）を追加。
  - **対面受付セッション**: `POST /api/{store_id}/{shop_id}/tablet/session` を新設。店頭タブレット等から保存済み `category='tablet'` シナリオを起動し、Twilio を介さずに Deepgram Voice Agent と会話できる。WebSocket は既存 `/test/ws/test/{session_id}` を流用。
  - **通話履歴に `tablet` 方向を追加**: `calls.direction` に `tablet` を追加。対面受付セッション終了時に会話ログ・要約を `calls` テーブルに保存し、通話詳細・引き継ぎUIに着信/発信と同列で表示する。
  - **引き継ぎステータス**: `calls.handoff_status` を追加（`pending` / `in_progress` / `done`）。`PATCH /api/{store_id}/{shop_id}/calls/{call_id}/handoff-status` で更新。担当者の対応状況を UI 上で管理する。
  - **自動要約**: 着信・対面受付セッション終了時に OpenAI による構造化要約をバックグラウンドで自動実行（旧: 手動「AI要約生成」のみ）。`POST /api/calls/summarize` は手動再生成用に残置。
  - DB マイグレーション:
    - `db/migrations/2026-05-11_add_scenario_category.sql` — `scenario_types.category` 追加 + 対面受付5種挿入
    - `db/migrations/2026-05-12_add_handoff_status.sql` — `calls.handoff_status` 追加
- **v1.0.8**: シナリオ単位の通話制御 / 挨拶文オプション化（2026-05-01）
  - シナリオに `max_call_duration_sec`（最大通話秒数。null=サーバー既定 / 0=無制限）と `multi_agent_enabled`（マルチエージェント切替の有効/無効）を追加。一覧・詳細・保存 API すべてで返却・受付。
  - §6 設定保存：`max_call_duration_sec` / `multi_agent_enabled` を任意フィールドとして追加。`greeting_message` を**必須→任意（空欄可）**に緩和。
  - §5 通話テスト（PC / 電話）：`greeting_message` の必須バリデーションを撤廃（空欄なら AI は冒頭発話せずお客様の第一声を待つ）。
  - 無音タイムアウトを 2 段階化：**10 秒**で「お電話が遠いようです。もう一度お願いいたします」、**20 秒**で「失礼いたします」→ 切断。
  - 通話上限の **残 30 秒**で「お時間の都合で、間もなくお電話を終了させていただきます」を発話、上限到達で「失礼いたします」→ 切断。
  - 各タイムアウト/上限は環境変数（`SILENCE_WARN_SEC` / `SILENCE_HANGUP_SEC` / `MAX_CALL_DURATION_SEC`）で全店共通の既定値を上書き可能。シナリオ単位の上書きは `max_call_duration_sec` のみ。
  - DB マイグレーション: `db/migrations/2026-05-01_add_scenario_call_controls.sql`
- **v1.0.7**: マルチエージェント（§11）と通話制御の動作仕様を実装に合わせて整理（2026-04-29）
  - §11 マルチエージェント: 切替判定の **3 経路** を明記（user 発話 direct match / assistant 発話 predicate match / LLM Function Call `transfer_to_specialist`）
  - 切替シーケンスを更新: 旧声で「○○にお繋ぎします」アナウンス → 2.5 秒待機 → `UpdateSpeak` で声切替 → specialist greeting → `UpdatePrompt` で人格上書き
  - `agent_state.current_id` による二重切替抑止（同一エージェントへの再切替リクエストは `already_active=true` のレスポンスを返してスキップ）
  - `trigger_hint` の処理仕様: カンマ・読点・空白・スラッシュ・パイプで分割。1 文字キーワードは誤マッチ防止のため除外
  - 空 `trigger_hint` のエージェントはプロンプト内のヒント行から除外（LLM ノイズ削減）
  - `UpdatePrompt` の append 仕様に対する打ち消し対策（override_prompt の冒頭で「営業電話お断り等は完全に無効化」を明示）
  - 通話制御に「無音タイムアウト」「最大通話時間」を追加（着信・ブラウザテスト両方で共通）
  - ブラウザテスト（§5.1 `/test/browser`）でもマルチエージェント・無音タイムアウトが有効になることを明記
- **v1.0.6**: シナリオ種別「取引業者」(`business_partner`) と `block_type='partner'` を追加
  - `scenario_types` に `business_partner`（sort_order=2）を追加。既存 `spam_call` 以降を1つずつシフト（合計8種類）
  - `blocked_phone_numbers.block_type` に `partner` を追加（spam/sales/partner の3値）。着信時に `partner` 一致 → 「取引業者」シナリオ即時適用
- **v1.0.5**: 画面側共有向けに整理・実装反映
  - ドキュメントのセクション番号を Swagger UI のタグ番号（1〜10）と一致させた
  - §5 通話テスト（電話）レスポンスに `session_id` を明記（ポーリング/キャンセルAPIで使用）
  - §8 迷惑電話 `block_type` の扱いを明記（値はバックエンド固定の `'spam'` / `'sales'`。画面側では2値のラジオ/セレクトで送る）
  - §8 ブロック番号追加のHTTPステータスを `200` に修正（実装は201を返していない）
  - §5 通話テストは「設定保存前のプレビュー内容」で実行する旨を冒頭にも明記
  - §5 と §6 設定保存のパラメータ対応表を追加（共通3項目＋保存専用3項目＋電話テスト専用2項目を整理）
  - 共通仕様に「列挙値リファレンス」を追加（`block_type` / `scenario_type_id` / `source` / `test_type` / 通話テスト `status` / `day_of_week` / `direction` / 通話 `status` / `role` / `urgency` / `request_type` の値域を一覧化）
  - エンドポイント一覧を新しい番号体系に合わせて刷新
- **v1.0.4**: 実装に合わせて反映（発信・AI要約を追記、シナリオ詳細の返却項目を補完）
  - 通話履歴 AIサマリー生成 `POST /api/calls/summarize` を追加
  - 発信（AIアウトバウンド）を追加: `POST /api/calls/outgoing` / `POST /api/calls/{call_id}/retry` / `POST /api/calls/{call_id}/update-status`
  - シナリオ詳細レスポンスに `scenario_type_id` / `ring_count_threshold` を明記
  - プロンプトAI整形: エラー時 `UPSTREAM_ERROR` (HTTP 502) / `INTERNAL_ERROR` (HTTP 500) を返す旨を追記
- **v1.0.3**: 画面設計(2026-04-16版)反映
  - 営業時間取得 `GET /business-hours` 追加（共通設定画面・各シナリオ画面の時刻表示用）
  - 通話テスト: `GET /test/{session_id}/status`（待機/通話中/完了のポーリング）と `DELETE /test/{session_id}`（終了/キャンセル）を追加
  - シナリオ判定: `scenarios.is_active=false` のシナリオが該当した場合は AI 不介入（切断）にする仕様を明文化。DBスキーマに `test_sessions.status` カラムを追加
  - 通話履歴SSE `/api/calls/stream` を削除（現行実装では未提供）
- **v1.0.2**: 実装反映
  - シナリオ一覧に `greeting_message` / `system_prompt` を追加（実装が一覧時にも返却）
  - プロンプトAI整形: リクエストに `current_greeting`、レスポンスに `original_greeting` / `generated_greeting` を追加（挨拶文・プロンプトを同時生成）
  - 通話履歴保存は JSON ファイルではなく MySQL `calls` テーブルに直接 INSERT
- **v1.0.1**: バージョン削除・テストプレビュー・スケジュール分離

## 共通仕様

### 店舗特定
全APIはパスの `{store_id}`（販売会社ID／ストア）と `{shop_id}`（店舗ID／ショップ）で対象店舗を特定する。
`shop_id` は販売会社をまたぐと重複しうるが、`store_id + shop_id` の組は一意。

```
/api/{store_id}/{shop_id}/...
```

### 認証
```
Authorization: Bearer {token}
```

### エラーレスポンス
```json
{
  "error": {
    "code": "NOT_FOUND",
    "message": "指定されたリソースが見つかりません"
  }
}
```

| HTTP | コード | 説明 |
|------|--------|------|
| 400 | `INVALID_REQUEST` | リクエスト不正 |
| 400 | `PROMPT_TOO_LONG` | プロンプト文字数上限超過 |
| 404 | `SCENARIO_NOT_FOUND` | シナリオが存在しない |
| 404 | `VERSION_NOT_FOUND` | バージョンが存在しない |
| 404 | `STORE_NOT_FOUND` | 店舗が存在しない |
| 404 | `NOT_FOUND` | リソースが見つからない（テストセッション・通話履歴等） |
| 404 | `AGENT_NOT_FOUND` | 専門エージェントが存在しない（§11） |
| 404 | `FUNCTION_NOT_FOUND` | Function Calling 定義が存在しない（§11） |
| 409 | `ALREADY_ACTIVE` | 既に運用中 |
| 409 | `ALREADY_EXISTS` | 同一IDの専門エージェント/関数定義が既に存在（§11） |
| 409 | `CANNOT_DELETE_CURRENT` | 運用中バージョンは削除不可 |
| 500 | `INTERNAL_ERROR` | サーバ内部エラー（OPENAI_API_KEY 未設定等） |
| 502 | `UPSTREAM_ERROR` | 外部API（OpenAI等）連携エラー |

### 日時
ISO 8601形式。タイムゾーンは JST (`Asia/Tokyo`)。

### 通話制御（無音タイムアウト・最大通話時間）

着信通話とブラウザテスト通話の両方で、課金スパイク対策として下記の自動制御が動く（API 経由ではなく内部実装）。

| 条件 | 動作 | デフォルト値 |
|------|------|-------------|
| 通話開始から N 秒経過 | 強制切断（「通話時間が上限となりました。失礼いたします。」を発話して切断） | 300 秒（5 分） |
| 最終活動から N 秒無音 | 強制切断（「失礼いたします。」を発話して切断） | 40 秒 |
| 最終活動から N 秒無音 | 警告メッセージ（「お電話が遠いようです。もう一度お願いいたします。」、1 通話で最大 2 回） | 20 秒 |

最終活動時刻は `UserStartedSpeaking` / `ConversationText (role=user)` / `AgentAudioDone`（AI 発話終了）で更新される。AI が長く喋っている間はタイマーが進まない。
具体的な実装と定数は `server/incoming.py`（`_silence_watchdog`, `make_silence_state`, `SILENCE_*` / `MAX_CALL_DURATION_SEC` / `HANGUP_GRACE_SEC`）を参照。

### Swagger UI のタグとの対応
本ドキュメントのセクション番号は Swagger UI のタグ番号（`1. マスタ` ～ `10. 発信`）と一致している。同一タグ配下の複数エンドポイントは枝番（例: `5.1`, `5.2`）で表す。

### 列挙値リファレンス

APIで扱う列挙型フィールドの値域一覧。リクエスト送信時・レスポンス解釈時の参照用。記載外の値を送ると `400 INVALID_REQUEST` または仕様外挙動となる。

#### `block_type`（ブロック番号の種別）§8
| 値 | 意味 | 画面表示 | 着信時の挙動 |
|----|-----|---------|-------------|
| `spam` | 迷惑電話 | 「迷惑電話」バッジ | 「迷惑電話」シナリオ即時適用（時間帯無関係）|
| `sales` | 営業電話 | 「営業電話」バッジ | 「営業電話」シナリオ即時適用（時間帯無関係）|
| `partner` | 取引業者 | 「取引業者」バッジ | 「取引業者」シナリオ即時適用（時間帯無関係）|

#### `scenario_type_id` / `scenario.id`（シナリオ種別）§1 / §2
| 値 | 画面表示 | sort_order |
|----|---------|:----------:|
| `sales_call` | 営業電話 | 1 |
| `business_partner` | 取引業者 | 2 |
| `spam_call` | 迷惑電話 | 3 |
| `year_end_holiday` | 年末年始 | 4 |
| `golden_week` | ゴールデンウィーク | 5 |
| `obon_holiday` | お盆休み | 6 |
| `store_holiday` | 店休日 | 5 |
| `business_hours_closed` | 営業時間外 | 6 |
| `store_busy` | 営業時間 | 7 |
| `lunch_break` | 休憩時間 | 8 |

> 1店舗につき上記8種類を上限に持つ。シナリオ詳細取得時に自動プロビジョニング（不足分を `is_active=false` で挿入）する。

#### `source`（バージョン作成元）§4
| 値 | 意味 |
|----|-----|
| `manual` | 画面から「設定保存」で手動作成 |
| `ai_generate` | プロンプトAI整形結果を保存 |
| `restore` | 過去バージョンを復元して作成 |

#### `test_type`（通話テスト種別）§5
| 値 | 意味 |
|----|-----|
| `browser` | PC画面テスト（§5.1 /test/browser） |
| `phone` | 電話テスト（§5.2 /test/phone） |

#### 通話テストセッション `status` §5.3
| 値 | 意味 |
|----|-----|
| `waiting` | 登録済。着信/WebSocket接続待ち |
| `active` | 通話中（WebSocket接続中）|
| `completed` | 通話終了 |
| `cancelled` | 画面から終了ボタンで停止 |
| `expired` | `expires_at` 超過 |

#### `day_of_week`（曜日）§7
| 値 | 曜日 |
|:--:|-----|
| 0 | 月 |
| 1 | 火 |
| 2 | 水 |
| 3 | 木 |
| 4 | 金 |
| 5 | 土 |
| 6 | 日 |

#### `direction`（通話方向）§9
| 値 | 意味 |
|----|-----|
| `incoming` | 着信（お客様 → 店舗）|
| `outgoing` | 発信（AI → お客様。入庫予定リマインド等）|
| `tablet` | 対面受付（店頭タブレット等から起動、Twilio非経由 `/tablet/session`）|

#### `scenario_types.category`（シナリオ分類）§1
| 値 | 意味 |
|----|-----|
| `incoming` | 着信時シナリオ（既定）|
| `outgoing` | システムからの発信シナリオ（v1.0.9 時点では枠のみ。scenario_types は未登録）|
| `tablet` | 対面受付シナリオ（店頭タブレット等から会話開始）|

> `category` 値の文字列 `tablet` は内部識別子としてそのまま使用（DBマイグレーション無し方針）。画面表示では「対面受付」と統一。

#### `handoff_status`（引き継ぎ対応状況）§9
| 値 | 意味 |
|----|-----|
| `pending` | 未対応（既定値。AI対応のみで担当者は未着手）|
| `in_progress` | 対応中（担当者が対応開始）|
| `done` | 完了 |

#### 通話履歴 `status` §9
| 値 | 適用 | 意味 |
|----|:---:|-----|
| `calling` | 発信 | Twilio発信リクエスト投入直後 |
| `ringing` | 発信 | 呼出中（コール音鳴動中）|
| `active` | 着信/発信 | 通話中 |
| `completed` | 着信/発信 | 正常終了 |
| `no-answer` | 発信 | 不応答（リトライ対象）|
| `busy` | 発信 | 話中（リトライ対象）|
| `failed` | 発信 | 発信失敗（リトライ対象）|
| `canceled` | 発信 | Twilio側でキャンセル |
| `error` | 発信 | 内部エラー |

> `no-answer` / `busy` / `failed` になると、残リトライ回数があれば自動で再発信される（§10.1 の `retry_interval` 秒後）。

#### `role`（会話ログの話者）§9.2
| 値 | 意味 |
|----|-----|
| `user` | お客様（発信者）|
| `assistant` | AIオペレーター |

#### AI要約 `urgency` §9.3
| 値 | 意味 |
|----|-----|
| `通常` | 通常優先度 |
| `急ぎ` | 早めに折返し推奨 |
| `緊急` | 即対応（走行不能・事故・発煙等）|

#### AI要約 `request_type` §9.3
| 値 |
|----|
| `新規予約` |
| `予約変更` |
| `キャンセル` |
| `問い合わせ` |
| `不具合` |
| `事故` |
| `その他` |

> OpenAI生成結果。モデルの判定によっては未定義の文字列が返ることがあり得るため、画面側は unknown ケースの fallback を用意することを推奨。

---

## 1. マスタ

### 1.1 GET `/api/scenario-types`

シナリオ種別マスタ取得（画面プルダウン用）。全店舗共通。

**Response 200:**

`category` 別に並び順は以下のとおり（`ORDER BY category, sort_order`）。

| category | id | name | sort_order |
|----------|----|------|-----------:|
| `incoming` | `sales_call` | 営業電話 | 1 |
| `incoming` | `business_partner` | 取引業者 | 2 |
| `incoming` | `spam_call` | 迷惑電話 | 3 |
| `incoming` | `excluded_call` | 除外電話 | 4 |
| `incoming` | `year_end_holiday` | 年末年始 | 5 |
| `incoming` | `golden_week` | ゴールデンウィーク | 6 |
| `incoming` | `obon_holiday` | お盆休み | 7 |
| `incoming` | `store_holiday` | 店休日 | 8 |
| `incoming` | `business_hours_closed` | 営業時間外 | 9 |
| `incoming` | `store_busy` | 営業時間 | 10 |
| `incoming` | `lunch_break` | 休憩時間 | 11 |
| `tablet` | `vendor_reception` | 業者受付 | 21 |
| `tablet` | `general_counter` | 総合カウンター | 22 |
| `tablet` | `after_hours_counter` | 営業時間外受付 | 23 |
| `tablet` | `event_reception` | イベント受付 | 24 |
| `tablet` | `lodging_counter` | 宿泊カウンター | 25 |

`outgoing` カテゴリは枠のみで scenario_types 未登録（v1.0.9 時点）。

```json
{
  "scenario_types": [
    {
      "id": "sales_call",
      "name": "営業電話",
      "description": "営業電話と判定された着信の対応",
      "category": "incoming",
      "sort_order": 1,
      "is_system": true,
      "default_greeting_message": "...",
      "default_system_prompt": "..."
    },
    {
      "id": "vendor_reception",
      "name": "業者受付",
      "description": "店頭タブレットからの業者来訪受付",
      "category": "tablet",
      "sort_order": 21,
      "is_system": true,
      "default_greeting_message": "...",
      "default_system_prompt": "..."
    }
    // ... (上表参照)
  ]
}
```

**レスポンスフィールド（`scenario_types[]` 要素）:**

| フィールド | 型 | 取りうる値 | 説明 |
|-----------|----|-----------|------|
| `id` | string | 上表参照 | シナリオ種別ID。店舗別シナリオの `scenario_type_id` と一致 |
| `name` | string | 上表参照 | 画面表示名 |
| `description` | string \| null | 任意 | 説明文 |
| `category` | string | `incoming` / `outgoing` / `tablet` | シナリオ分類。画面のサブタブ分割に使用 |
| `sort_order` | int | 1〜25 | 表示順 |
| `is_system` | bool | `true` / `false` | システム標準シナリオは `true` |
| `default_greeting_message` | string \| null | 任意 | 新規登録ダイアログの初期値（画面側で使用） |
| `default_system_prompt` | string \| null | 任意 | 新規登録ダイアログの初期値（画面側で使用） |

---

## 2. シナリオ

店舗に紐づくシナリオ一覧・詳細。シナリオは `scenario_types` マスタに対応。

### 2.1 GET `/api/{store_id}/{shop_id}/scenarios`

シナリオ一覧を取得する。編集画面の初期化で使うため、一覧時点で `greeting_message` / `system_prompt` / `version_number` まで返却する（詳細APIを個別に叩かなくても編集可能）。

**Response 200:**
```json
{
  "scenarios": [
    {
      "id": "business_hours_closed",
      "scenario_type_id": "business_hours_closed",
      "name": "営業時間外",
      "is_active": true,
      "ring_count_threshold": null,
      "max_call_duration_sec": null,
      "multi_agent_enabled": true,
      "greeting_message": "いつもお世話になっております。...",
      "system_prompt": "あなたはトヨタカローラ名古屋のAI電話オペレーターです。...",
      "version_number": 3,
      "sort_order": 5,
      "current_version_id": "ver_store001_bus_003"
    },
    {
      "id": "store_busy",
      "scenario_type_id": "store_busy",
      "name": "営業時間",
      "is_active": true,
      "ring_count_threshold": 5,
      "max_call_duration_sec": 600,
      "multi_agent_enabled": true,
      "greeting_message": "...",
      "system_prompt": "...",
      "version_number": 2,
      "sort_order": 6,
      "current_version_id": "ver_store001_sto_002"
    }
  ]
}
```

**レスポンスフィールド（`scenarios[]` 要素）:**

| フィールド | 型 | 取りうる値 | 説明 |
|-----------|----|-----------|------|
| `id` | string | §1 `scenario_types.id` と同値 | 店舗別シナリオのID（= シナリオ種別ID）|
| `scenario_type_id` | string | §1 `scenario_types.id` と同値 | シナリオ種別。§1参照 |
| `name` | string | 種別名（例「営業時間外」）| 表示名 |
| `is_active` | bool | `true` / `false` | `false` の場合 AI 不介入（切断）|
| `ring_count_threshold` | int \| null | 1以上 / `null` | `store_busy` のみ値あり。それ以外は `null` |
| `max_call_duration_sec` | int \| null | `null` / `0` / 正数 | 最大通話秒数。`null`=サーバー既定（`MAX_CALL_DURATION_SEC`、現状 300）/ `0`=無制限 / 正数=その秒数 |
| `multi_agent_enabled` | bool | `true` / `false` | `false` で specialist 切替（direct match / LLM Function Call）を全てスキップ |
| `greeting_message` | string \| null | 任意（空文字可）| 現在バージョンの挨拶文。空のとき AI は冒頭発話せずお客様の第一声を待つ |
| `system_prompt` | string \| null | 任意 | 現在バージョンのシステムプロンプト |
| `version_number` | int \| null | 1以上 | 現在バージョンの番号 |
| `sort_order` | int | 1〜8 | 種別マスタ由来の並び順 |
| `current_version_id` | string \| null | バージョンID | 現在運用中のバージョンID |

### 2.2 GET `/api/{store_id}/{shop_id}/scenarios/{scenario_id}`

選択したシナリオの通話設定・現在バージョンを取得。

**Response 200:**
```json
{
  "id": "business_hours_closed",
  "scenario_type_id": "business_hours_closed",
  "name": "営業時間外",
  "is_active": true,
  "ring_count_threshold": null,
  "max_call_duration_sec": null,
  "multi_agent_enabled": true,
  "prompt_max_length": 5000,
  "greeting_message": "いつもお世話になっております。トヨタカローラ名古屋、AIオペレーターです。ただいまの時間は営業時間外となっております。",
  "system_prompt": "あなたはトヨタカローラ名古屋のAI電話オペレーターです。...",
  "current_version": {
    "id": "ver_001",
    "version_number": 3,
    "change_summary": "営業時間案内を修正",
    "created_at": "2026-04-06T10:00:00+09:00"
  }
}
```

**レスポンスフィールド:**

| フィールド | 型 | 取りうる値 | 説明 |
|-----------|----|-----------|------|
| `id` | string | §1 `scenario_types.id` と同値 | 店舗別シナリオのID |
| `scenario_type_id` | string | §1 `scenario_types.id` と同値 | シナリオ種別 |
| `name` | string | 種別名 | 表示名 |
| `is_active` | bool | `true` / `false` | `false` の場合 AI 不介入（切断）|
| `ring_count_threshold` | int \| null | 1以上 / `null` | `store_busy` のみ値あり |
| `max_call_duration_sec` | int \| null | `null` / `0` / 正数 | 最大通話秒数。`null`=サーバー既定 / `0`=無制限 / 正数=その秒数 |
| `multi_agent_enabled` | bool | `true` / `false` | マルチエージェント切替の有効/無効 |
| `prompt_max_length` | int | 既定 5000 | `system_prompt` の最大文字数 |
| `greeting_message` | string | 任意（空文字可）| 現在バージョンの挨拶文 |
| `system_prompt` | string | 任意 | 現在バージョンのシステムプロンプト |
| `current_version` | object \| null | 下記 | 現在運用中のバージョン情報 |
| `current_version.id` | string | バージョンID | |
| `current_version.version_number` | int | 1以上 | |
| `current_version.change_summary` | string \| null | 任意 | 変更概要 |
| `current_version.created_at` | string | ISO 8601 / JST | 作成日時 |

> 営業日・営業時間・休憩時間はケプラー連携データで自動判定される。シナリオ自体には稼働曜日・時間の設定を持たない。

---

## 3. プロンプトAI整形

GPT等を使って、変更指示から**挨拶文とシステムプロンプトを同時に生成**する。確定前のプレビュー。

### 3.1 POST `/api/{store_id}/{shop_id}/scenarios/{scenario_id}/prompt/generate`

**Request:**
```json
{
  "current_greeting": "いつもお世話になっております。...",
  "current_prompt": "あなたはトヨタカローラ名古屋のAI電話オペレーターです。...",
  "change_instructions": "挨拶文をもっと親しみやすく。予約受付時に車種と希望日時を必ず確認するようにしてほしい"
}
```

| フィールド | 必須 | 説明 |
|-----------|------|------|
| `current_greeting` | No | 現在の挨拶文（未入力時は空文字） |
| `current_prompt` | Yes | 現在のシステムプロンプト |
| `change_instructions` | Yes | 変更指示。挨拶文・プロンプトの片方だけの指示でもよい |

**Response 200:**
```json
{
  "original_greeting": "（変更前の挨拶文）",
  "original_prompt": "（変更前のプロンプト全文）",
  "generated_greeting": "（AIが生成した改善版挨拶文）",
  "generated_prompt": "（AIが生成した改善版プロンプト全文）",
  "changes_description": "以下の変更を加えました:\n- 挨拶を親しみやすいトーンに調整\n- 予約受付フローに車種確認ステップを追加"
}
```

> 挨拶文 (`greeting`) とシステムプロンプトをセットで生成する。
> 画面側では2つの独立したテキストエリアで表示し、それぞれ「挨拶文に反映」「プロンプトに反映」ボタンで個別に適用する。
> 変更指示が挨拶文のみ・プロンプトのみの場合でもレスポンスには両方のフィールドが返る（変更不要な方は現在の内容がそのまま返る）。
>
> プレビュー状態。確定する場合は §6 シナリオ設定保存で保存する。

**エラー:**

| HTTP | code | 条件 |
|------|------|------|
| 500 | `INTERNAL_ERROR` | `OPENAI_API_KEY` が未設定 |
| 502 | `UPSTREAM_ERROR` | OpenAI API の接続失敗 / 非JSONレスポンス / エラー応答 |

> 実装は OpenAI Responses API (`gpt-5-mini`) + `web_search_preview` ツールを使用。生成結果は音声読み上げ用に Markdown/URL 等を除去してから返す。

---

## 4. バージョン管理

`greeting_message` + `system_prompt` をセットでバージョン管理する。

### 4.1 GET `/api/{store_id}/{shop_id}/scenarios/{scenario_id}/versions`

**Query:** `limit` (default: 20), `offset` (default: 0)

**Response 200:**
```json
{
  "current_version_id": "ver_003",
  "versions": [
    {
      "id": "ver_003",
      "version_number": 5,
      "change_summary": "予約受付フローを追加",
      "source": "manual",
      "created_at": "2026-04-07T12:30:00+09:00",
      "is_current": true
    },
    {
      "id": "ver_002",
      "version_number": 4,
      "change_summary": "挨拶文を簡潔に変更",
      "source": "manual",
      "created_at": "2026-04-07T12:00:00+09:00",
      "is_current": false
    }
  ],
  "total": 5
}
```

**レスポンスフィールド（`versions[]` 要素）:**

| フィールド | 型 | 取りうる値 | 説明 |
|-----------|----|-----------|------|
| `id` | string | バージョンID | |
| `version_number` | int | 1以上 | バージョン番号（1から連番）|
| `change_summary` | string \| null | 任意 | 変更概要 |
| `source` | string | `manual` / `ai_generate` / `restore` | 作成元。`manual`=画面保存、`ai_generate`=AI整形保存、`restore`=復元 |
| `created_at` | string | ISO 8601 / JST | 作成日時 |
| `is_current` | bool | `true` / `false` | 現在運用中なら `true` |

### 4.2 GET `/api/{store_id}/{shop_id}/scenarios/{scenario_id}/versions/{version_id}`

**Response 200:**
```json
{
  "id": "ver_002",
  "version_number": 4,
  "greeting_message": "いつもお世話になっております。...",
  "system_prompt": "あなたはトヨタカローラ名古屋の...",
  "change_summary": "挨拶文を簡潔に変更",
  "source": "manual",
  "created_at": "2026-04-07T12:00:00+09:00"
}
```

**レスポンスフィールド:**

| フィールド | 型 | 取りうる値 | 説明 |
|-----------|----|-----------|------|
| `id` | string | バージョンID | |
| `version_number` | int | 1以上 | バージョン番号 |
| `greeting_message` | string \| null | 任意 | 当時の挨拶文 |
| `system_prompt` | string \| null | 任意 | 当時のシステムプロンプト |
| `change_summary` | string \| null | 任意 | 変更概要 |
| `source` | string | `manual` / `ai_generate` / `restore` | 作成元 |
| `created_at` | string | ISO 8601 / JST | 作成日時 |

### 4.3 POST `/api/{store_id}/{shop_id}/scenarios/{scenario_id}/versions/{version_id}/restore`

過去バージョンを復元（新バージョンとして保存）。

**Response 200:**
```json
{
  "message": "バージョン4を復元しました",
  "version": {
    "id": "ver_004",
    "version_number": 6,
    "change_summary": "バージョン4から復元",
    "source": "restore",
    "created_at": "2026-04-07T13:00:00+09:00"
  }
}
```

### 4.4 DELETE `/api/{store_id}/{shop_id}/scenarios/{scenario_id}/versions/{version_id}`

バージョンを削除する。現在運用中のバージョン（`is_current=true`）は削除不可。

**Response 200:**
```json
{
  "message": "バージョン4を削除しました"
}
```

**Response 409:**
```json
{
  "error": {
    "code": "CANNOT_DELETE_CURRENT",
    "message": "運用中のバージョンは削除できません"
  }
}
```

---

## 5. 通話テスト

**設定保存前のプレビュー内容**で通話テストを行う。画面のテキストエリアで編集中の `greeting_message` / `system_prompt` をそのまま送ると、その内容で AI が応対する（DB保存値にフォールバックしない）。

### §6 設定保存とのパラメータ対応

通話テストと設定保存の両方で **AI の応対内容を決める3項目（`scenario_id` / `greeting_message` / `system_prompt`）は共通**。画面側はこの3項目を同じ変数から両APIに渡す構成で問題ない。

| フィールド | §6 設定保存 | §5.1 PCテスト | §5.2 電話テスト | 備考 |
|-----------|:----------:|:-------------:|:---------------:|------|
| `scenario_id` | ✓ | ✓ | ✓ | |
| `greeting_message` | △ | △ | △ | **空欄可**。画面の編集中テキスト（空のとき AI は冒頭発話せずお客様の第一声を待つ）|
| `system_prompt` | ✓ | ✓ | ✓ | 画面の編集中テキスト（保存前のプレビュー値でOK） |
| `ring_count_threshold` | ✓ | ― | ― | テストは直接接続するためコール閾値は適用されない |
| `max_call_duration_sec` | △ | ― | ― | 任意。`null`=既定 / `0`=無制限 / 正数=秒数。テストにも適用される（テストセッションがシナリオ設定を流用） |
| `multi_agent_enabled` | △ | ― | ― | 任意。`null`=現状維持。テストにも適用される |
| `is_active` | ✓ | ― | ― | テスト中は常に有効扱い（ON/OFFトグルの影響を受けない）|
| `change_summary` | ✓ | ― | ― | バージョン履歴用。テストでは不要 |
| `caller_phone_number` | ― | ― | ✓ | 電話テストの発信元番号 |
| `expires_minutes` | ― | ― | ✓ | 電話テストの有効時間（分） |

### 5.1 POST `/api/{store_id}/{shop_id}/test/browser`

Deepgram経由でブラウザ上の通話テストを開始する。

**Request:**
```json
{
  "scenario_id": "business_hours_closed",
  "greeting_message": "いつもお世話になっております。トヨタカローラ名古屋、AIオペレーターです。",
  "system_prompt": "あなたはトヨタカローラ名古屋のAI電話オペレーターです。..."
}
```

| フィールド | 必須 | 説明 |
|-----------|------|------|
| `scenario_id` | Yes | テスト対象のシナリオID |
| `greeting_message` | No | 画面上の受話開始コメント（未保存可・空欄可）|
| `system_prompt` | Yes | 画面上のシステムプロンプト（未保存可） |

**Response 200:**
```json
{
  "session_id": "test_abc123",
  "websocket_url": "wss://example.com/ws/test/test_abc123",
  "greeting_message": "いつもお世話になっております。...",
  "expires_at": "2026-04-22T12:30:00+09:00"
}
```

> 返却された `websocket_url` にブラウザから WebSocket 接続すると通話が開始する。

### 5.2 POST `/api/{store_id}/{shop_id}/test/phone`

電話番号によるテスト設定を登録する。指定した発信元番号から店舗のAI電話番号に着信した際に、本APIで登録した内容で応答する。本APIは発信を行わず、登録のみ。

**Request:**
```json
{
  "scenario_id": "business_hours_closed",
  "greeting_message": "いつもお世話になっております。トヨタカローラ名古屋、AIオペレーターです。",
  "system_prompt": "あなたはトヨタカローラ名古屋のAI電話オペレーターです。...",
  "caller_phone_number": "09012345678",
  "expires_minutes": 30
}
```

| フィールド | 必須 | 説明 |
|-----------|------|------|
| `scenario_id` | Yes | テスト対象のシナリオID |
| `greeting_message` | No | 画面上の受話開始コメント（未保存可・空欄可）|
| `system_prompt` | Yes | 画面上のシステムプロンプト（未保存可） |
| `caller_phone_number` | Yes | テスト発信元の電話番号。`090...` のような国内表記で送れば `+81...` に正規化してDBに保存する |
| `expires_minutes` | No | 有効時間（分）。デフォルト30分。経過後は通常判定に戻る |

**Response 200:**
```json
{
  "message": "テスト設定を登録しました。指定の電話番号から以下のAI番号にお電話ください。",
  "session_id": "test_abc123",
  "ai_phone_number": "+815012345678",
  "caller_phone_number": "+819012345678",
  "scenario_id": "business_hours_closed",
  "expires_at": "2026-04-22T12:30:00+09:00"
}
```

| フィールド | 説明 |
|-----------|------|
| `session_id` | §5.3 ステータス取得・§5.4 キャンセルで使用 |
| `ai_phone_number` | `shops.ai_phone_number` を優先。未設定時は `TWILIO_PHONE_NUMBER` 環境変数にフォールバック |
| `caller_phone_number` | E.164 正規化後の値 |

> 登録後、`caller_phone_number` から `ai_phone_number` に電話をかけるとテスト応答される。

### 5.3 GET `/api/{store_id}/{shop_id}/test/{session_id}/status`

通話テストセッションの現在ステータスを取得する。画面の通話テストモーダルが待機中（最長10分）をポーリングする用途。

**Response 200:**
```json
{
  "session_id": "test_abc123",
  "test_type": "phone",
  "scenario_id": "business_hours_closed",
  "status": "waiting",
  "caller_phone_number": "+819012345678",
  "expires_at": "2026-04-22T12:10:00+09:00",
  "started_at": null,
  "ended_at": null
}
```

`status` の遷移:

| 値 | 状態 |
|----|------|
| `waiting` | 登録済。着信/WebSocket接続待ち |
| `active` | 通話中（WebSocket接続中） |
| `completed` | 通話終了 |
| `cancelled` | 画面から終了ボタンで停止 |
| `expired` | `expires_at` 超過（10分無応答等）|

### 5.4 DELETE `/api/{store_id}/{shop_id}/test/{session_id}`

通話テストを終了/キャンセルする。モーダルの「終了」ボタンで呼ぶ。

**Response 200:**
```json
{
  "message": "テストセッションを終了しました (cancelled)"
}
```

- `active`（通話中）の場合は `completed` に、それ以外は `cancelled` に遷移する。
- 削除後、該当 `session_id` の WebSocket 再接続は拒否される。

### 5.5 POST `/api/{store_id}/{shop_id}/tablet/session`

**対面受付セッション** を作成する。店頭タブレット等から保存済みの `category='tablet'` シナリオを起動し、Twilio を介さずに AI と会話を開始するためのエンドポイント。返却された `websocket_url` に接続することで Deepgram Voice Agent と会話できる。

通話テスト（§5.1〜5.4）とは違い、これは **本番扱いの会話セッション**: 終了時に会話ログ・要約が `calls` テーブルに `direction='tablet'` で保存され、通話詳細・引き継ぎUI（§9）に着信/発信と同列で表示される。

> 内部実装上は §5.1 と同じ `/test/ws/test/{session_id}` の WebSocket ハンドラを再利用しているため、ブラウザテストと同等のオーディオ配管（linear16 16kHz）が使える。

**Request:**
```json
{
  "scenario_id": "vendor_reception"
}
```

| フィールド | 必須 | 説明 |
|-----------|------|------|
| `scenario_id` | Yes | 起動するシナリオID。`category='tablet'` のシナリオのみ受付 |

**Response 200:**
```json
{
  "session_id": "tab_abc123def456",
  "websocket_url": "wss://example.com/test/ws/test/tab_abc123def456",
  "scenario_id": "vendor_reception",
  "scenario_name": "業者受付",
  "greeting_message": "いらっしゃいませ。...",
  "expires_at": "2026-05-12T14:30:00+09:00"
}
```

**エラー:**

| HTTP | code | 条件 |
|------|------|------|
| 400 | `INVALID_CATEGORY` | 指定シナリオの `category` が `tablet` 以外 |
| 400 | `SCENARIO_INACTIVE` | 指定シナリオが `is_active=false` |
| 400 | `SCENARIO_PROMPT_EMPTY` | システムプロンプトが未保存 |
| 404 | `SCENARIO_NOT_FOUND` | 指定シナリオが存在しない |

---

## 6. 設定保存

プルダウンで選択中のシナリオの設定内容を保存する。保存した内容が着信時にそのまま参照される。

### 6.1 POST `/api/{store_id}/{shop_id}/scenarios/save`

**Request:**
```json
{
  "scenario_id": "business_hours_closed",
  "greeting_message": "いつもお世話になっております。トヨタカローラ名古屋、AIオペレーターです。",
  "system_prompt": "あなたはトヨタカローラ名古屋のAI電話オペレーターです。...",
  "ring_count_threshold": 5,
  "max_call_duration_sec": 600,
  "multi_agent_enabled": true,
  "is_active": true,
  "change_summary": "営業時間案内を修正"
}
```

| フィールド | 必須 | 説明 |
|-----------|------|------|
| `scenario_id` | Yes | 保存対象のシナリオID |
| `greeting_message` | No | 受話開始コメント。**空文字可**。空のときは AI が冒頭発話せず、お客様の第一声を待ってから応答する（プロンプトに「冒頭はお客様発話を待つ」指示を自動注入）|
| `system_prompt` | Yes | 通話内容のシステムプロンプト |
| `ring_count_threshold` | No | `store_busy` 用コール閾値 |
| `max_call_duration_sec` | No | 最大通話秒数。`null`/未指定=既存値維持、`0`=無制限、正数=その秒数。上限到達の **30 秒前**に終話予告、上限到達で「失礼いたします」→ 切断 |
| `multi_agent_enabled` | No | マルチエージェント切替の有効/無効。`null`/未指定=既存値維持、`false` で specialist 切替（direct match / LLM Function Call）を全てスキップ |
| `is_active` | No | このシナリオを有効にするか（省略時 `true`） |
| `change_summary` | No | 変更概要（バージョン履歴に記録） |

**Response 200:**
```json
{
  "message": "設定を保存しました",
  "scenario_id": "business_hours_closed",
  "version": {
    "id": "ver_005",
    "version_number": 6,
    "change_summary": "営業時間案内を修正",
    "created_at": "2026-04-07T12:00:00+09:00"
  },
  "saved_at": "2026-04-07T12:00:00+09:00"
}
```

> `greeting_message` + `system_prompt` を保存し、新バージョンが自動作成される。

**エラー:**

| HTTP | code | 条件 |
|------|------|------|
| 400 | `PROMPT_TOO_LONG` | `system_prompt` が `prompt_max_length`（既定5000）を超過 |
| 404 | `SCENARIO_NOT_FOUND` | 指定 `scenario_id` が存在しない |

---

## 7. 営業日・営業時間連携（ケプラー ↔ xVoice）

営業日・営業時間・休憩時間は**ケプラー側DBが正**。着信時のシナリオ自動判定に使用。

> **🔧 モック運用中（2026-04-22時点）**
>
> - **本来の方針**: xVoice が**ケプラーAPI経由**でケプラーDBの値（店休日/営業時間/休憩時間）を取得する
> - **現状**: ケプラーAPI未接続のため、xVoice 側ローカルDB（`shop_business_days` / `shop_business_hours` / `shop_breaks`）と `db/seed.sql` の初期データをモックとして返却
> - 下記の `sync` 系エンドポイントは、ケプラー接続までの暫定としてローカルDB に書き込み、`GET /business-hours` は同ローカルDB から読み出す
> - ケプラーAPI接続時は `GET /business-hours` をケプラー呼び出しに差し替える想定

### 7.1 POST `/api/{store_id}/{shop_id}/business-days/sync`

営業日（店休日）データを一括同期する。

**Request:**
```json
{
  "days": [
    { "date": "2026-04-13", "is_holiday": true,  "holiday_name": "定休日" },
    { "date": "2026-04-14", "is_holiday": false, "holiday_name": null },
    { "date": "2026-04-29", "is_holiday": true,  "holiday_name": "昭和の日" },
    { "date": "2026-04-30", "is_holiday": true,  "holiday_name": "GW" },
    { "date": "2026-05-01", "is_holiday": true,  "holiday_name": "GW" },
    { "date": "2026-05-02", "is_holiday": true,  "holiday_name": "GW" }
  ]
}
```

**Response 200:**
```json
{
  "message": "営業日データを同期しました",
  "synced_count": 6,
  "synced_at": "2026-04-10T09:00:00+09:00"
}
```

> 店休日は `is_holiday=true` で「店休日」シナリオが適用される。年末年始/GW/お盆等の長期休業は専用シナリオを管理画面から有効化する。

### 7.2 POST `/api/{store_id}/{shop_id}/business-hours/sync`

営業時間・休憩時間データを一括同期する（全量洗い替え）。

**Request:**
```json
{
  "hours": [
    {
      "day_of_week": 0,
      "open_time": "09:00",
      "close_time": "18:00",
      "breaks": [
        { "start_time": "12:00", "end_time": "13:00" }
      ]
    },
    {
      "day_of_week": 5,
      "open_time": "10:00",
      "close_time": "17:00",
      "breaks": []
    }
  ]
}
```

| フィールド | 説明 |
|-----------|------|
| `day_of_week` | 0=月, 1=火, 2=水, 3=木, 4=金, 5=土, 6=日 |
| `open_time` | 営業開始（HH:MM） |
| `close_time` | 営業終了（HH:MM）。この時刻以降は「営業時間外」判定 |
| `breaks[]` | 休憩枠。この時間帯は「休憩時間」判定。複数設定可 |

**Response 200:**
```json
{
  "message": "営業時間データを同期しました",
  "synced_at": "2026-04-10T09:00:00+09:00"
}
```

### 7.3 GET `/api/{store_id}/{shop_id}/business-hours`

曜日ごとの営業時間・休憩時間を取得する。共通設定画面（P1）と各シナリオ画面（P2）の時刻表示に使用。

**Response 200:**
```json
{
  "hours": [
    {
      "day_of_week": 0,
      "open_time": "09:30",
      "close_time": "18:30",
      "breaks": [
        { "start_time": "12:00", "end_time": "13:00" }
      ]
    },
    {
      "day_of_week": 5,
      "open_time": "10:00",
      "close_time": "17:00",
      "breaks": []
    }
  ]
}
```

> `day_of_week` は 0=月 … 6=日。登録されていない曜日は配列に含まれない（画面側で「休業」扱い）。

### 着信時のシナリオ自動判定（参考）

```
着信 (caller_number 取得)
 │
 ├─ blocked_phone_numbers で照合
 │   spam 一致 → 「迷惑電話」（時間帯に関係なく即適用）
 │   sales 一致 → 「営業電話」（時間帯に関係なく即適用）
 │
 ├─ shop_business_days で当日を検索
 │   is_holiday=true → 「店休日」
 │
 ├─ shop_business_hours で当日の曜日を検索
 │   現在時刻 < open_time or >= close_time → 「営業時間外」
 │
 ├─ shop_breaks で休憩枠を検索
 │   start_time <= 現在時刻 < end_time → 「休憩時間」
 │
 ├─ ring_count_threshold 回以上応答なし → 「営業中（○コール以上）」
 │
 └─ いずれも該当せず → 店舗スタッフが直接応答（AI不介入）
```

### シナリオOFF（`scenarios.is_active = 0`）の扱い

画面の共通設定で各シナリオのON/OFFトグルを切り替えると `scenarios.is_active` が更新される。

- 該当シナリオが判定された時点で `is_active = 0` の場合、**AI不介入**（切断＝店舗スタッフが直接応答する想定）。
- 着信処理側 (`server/incoming.py`) は Deepgram 接続前にチェックし、WebSocket を閉じる。
- 判定レスポンス（`resolve_incoming` の戻り値）には `ai_active: bool` が含まれる。

---

## 8. 迷惑電話（ブロック番号管理）

電話番号ごとのブロックフラグを **xVoice 側DB（`blocked_phone_numbers` テーブル）** で管理する。
着信時のシナリオ判定で最優先で参照され、時間帯に関係なく即座にシナリオが適用される。

> **保管場所**: xVoice 側DB（ケプラー連携ではなく xVoice 自身で保持・CRUD可能）
> `customer_code` フィールドはケプラー側の顧客コードを任意で紐付けるためのもの（無くてもよい）
>
> ケプラー顧客データとの連携方針（参考、未確定）:
> - 案A: 既存顧客データ側にフラグを追加（日次バッチ、タイムラグあり）
> - 案B: 本API で xVoice 側が独自にリアルタイム管理 ← 現行実装
> - 案C: ケプラー顧客DBを本APIから参照

### `block_type` の定義

| 値 | 意味 | 画面表示例 |
|----|-----|-----------|
| `"spam"` | 迷惑電話 | バッジ「迷惑電話」 |
| `"sales"` | 営業電話 | バッジ「営業電話」 |
| `"partner"` | 取引業者 | バッジ「取引業者」 |

- 値は**バックエンド固定**。画面側で自由に決める文字列ではなく、上記の3択のみを受け付ける（他の値は `400 INVALID_REQUEST`）。
- 画面側の実装は、3値ラジオ/セレクトでユーザーに選ばせ、送信時は `"spam"` / `"sales"` / `"partner"` に変換して渡す構成を想定。
- 着信時の挙動: `spam` 一致 → 「迷惑電話」シナリオ適用、`sales` 一致 → 「営業電話」シナリオ適用、`partner` 一致 → 「取引業者」シナリオ適用（いずれも時間帯に関係なく即時）。

### 8.1 GET `/api/{store_id}/{shop_id}/blocked-numbers`

ブロック番号一覧を取得する。

**Query:**

| パラメータ | 必須 | 型 | 取りうる値 | 説明 |
|-----------|------|----|-----------|------|
| `block_type` | No | string | `spam` / `sales` / `partner` | ブロック種別でフィルタ。`spam`＝迷惑電話、`sales`＝営業電話、`partner`＝取引業者。未指定で全件 |
| `limit` | No | int | 1以上の整数 | 取得件数上限。デフォルト 100 |
| `offset` | No | int | 0以上の整数 | スキップ件数。デフォルト 0 |

**Response 200:**
```json
{
  "numbers": [
    {
      "id": 1,
      "phone_number": "+819099999999",
      "block_type": "spam",
      "customer_code": null,
      "note": "自動音声の営業電話",
      "created_at": "2026-04-10T10:00:00+09:00"
    },
    {
      "id": 2,
      "phone_number": "+819088888888",
      "block_type": "sales",
      "customer_code": "C001234",
      "note": "コピー機の営業",
      "created_at": "2026-04-09T15:00:00+09:00"
    }
  ],
  "total": 2
}
```

**レスポンスフィールド（`numbers[]` 要素）:**

| フィールド | 型 | 取りうる値 | 説明 |
|-----------|----|-----------|------|
| `id` | int | 1以上 | ブロック番号レコードID。§8.3 解除APIで使用 |
| `phone_number` | string | E.164形式 (`+81...`) | 着信判定対象の電話番号 |
| `block_type` | string | `spam` / `sales` / `partner` | `spam`＝迷惑電話、`sales`＝営業電話、`partner`＝取引業者 |
| `customer_code` | string \| null | 任意 | ケプラー顧客コード。未紐付けは null |
| `note` | string \| null | 任意 | メモ |
| `created_at` | string \| null | ISO 8601 / JST | 登録日時 |

### 8.2 POST `/api/{store_id}/{shop_id}/blocked-numbers`

ブロック番号を追加する。追加後、その番号からの着信は即座に該当シナリオが適用される。同じ `(phone_number, block_type)` の組が既にある場合は `note` のみ更新（ON DUPLICATE KEY UPDATE）。

**Request:**
```json
{
  "phone_number": "+819099999999",
  "block_type": "spam",
  "note": "自動音声の営業電話"
}
```

| フィールド | 必須 | 型 | 取りうる値 | 説明 |
|-----------|------|----|-----------|------|
| `phone_number` | Yes | string | E.164形式推奨 (`+81...`) | 対象の電話番号 |
| `block_type` | Yes | string | **`spam` / `sales` / `partner` のみ** | `spam`＝迷惑電話、`sales`＝営業電話、`partner`＝取引業者。他の値は `400 INVALID_REQUEST` |
| `note` | No | string | 任意 | メモ |

**Response 200:**
```json
{
  "message": "ブロック番号を追加しました",
  "id": 3,
  "phone_number": "+819099999999",
  "block_type": "spam"
}
```

**レスポンスフィールド:**

| フィールド | 型 | 取りうる値 | 説明 |
|-----------|----|-----------|------|
| `message` | string | 固定文言 | 「ブロック番号を追加しました」 |
| `id` | int \| null | 1以上 | 追加/更新されたレコードID |
| `phone_number` | string | リクエスト値の echo | |
| `block_type` | string | `spam` / `sales` / `partner` | リクエスト値の echo |

**エラー:**

| HTTP | code | 条件 |
|------|------|------|
| 400 | `INVALID_REQUEST` | `block_type` が `spam` / `sales` / `partner` 以外 |

### 8.3 DELETE `/api/{store_id}/{shop_id}/blocked-numbers/{blocked_id}`

ブロック番号を解除する。解除後は通常のシナリオ判定（時間帯ベース）に戻る。

**Response 200:**
```json
{
  "message": "ブロック番号を解除しました"
}
```

**エラー:**

| HTTP | code | 条件 |
|------|------|------|
| 404 | `NOT_FOUND` | 指定 `blocked_id` が該当店舗に存在しない |

---

## 9. 通話履歴

通話結果の取得・AI要約・引き継ぎステータス管理。各通話は店舗 (store_id + shop_id) に紐づく。AI要約API (§9.3) のみ店舗スコープ外（`call_id` で一意に特定できるため）。

**収録対象**:
- `direction='incoming'` … Twilio 着信終了時に自動保存
- `direction='tablet'` … 対面受付セッション（§5.5）終了時に自動保存
- `direction='outgoing'` … 発信終了時に保存（旧API `/api/calls/outgoing` 経由は現状インメモリのみ。多店舗化対応は別途）

**自動要約**: `direction='incoming'` / `'tablet'` のセッション終了時に、バックグラウンドで OpenAI による構造化要約が走り、`summary` テキストと `metadata` JSON が更新される（手動 §9.3 の自動版）。

### 9.1 GET `/api/{store_id}/{shop_id}/calls`

通話履歴一覧を取得する。新しい順にソート。

**Query:** `limit` (default: 50), `offset` (default: 0), `direction` (任意: `incoming` | `outgoing` | `tablet`), `status` (任意)

**Response 200:**
```json
{
  "calls": [
    {
      "id": 42,
      "direction": "incoming",
      "caller_number": "+819012345678",
      "scenario_id": "business_hours_closed",
      "status": "completed",
      "duration_sec": 180,
      "summary": "車検予約希望。タテイシ様。4月15日13時希望。代車希望あり。",
      "handoff_status": "pending",
      "started_at": "2026-04-10T18:30:00+09:00",
      "ended_at": "2026-04-10T18:33:00+09:00",
      "created_at": "2026-04-10T18:30:00+09:00"
    },
    {
      "id": 43,
      "direction": "tablet",
      "caller_number": null,
      "scenario_id": "vendor_reception",
      "status": "completed",
      "duration_sec": 95,
      "summary": "○○配送、整備受付に荷物搬入予定の事前連絡。",
      "handoff_status": "done",
      "started_at": "2026-05-12T10:15:00+09:00",
      "ended_at": "2026-05-12T10:16:35+09:00",
      "created_at": "2026-05-12T10:15:00+09:00"
    }
  ],
  "total": 156
}
```

> `direction='tablet'` は対面受付（店頭タブレット等から起動）。`caller_number` は `null`。

### 9.2 GET `/api/{store_id}/{shop_id}/calls/{call_id}`

通話詳細を取得する。会話ログ全文（transcript）を含む。

**Response 200:**
```json
{
  "id": 42,
  "direction": "incoming",
  "caller_number": "+819012345678",
  "scenario_id": "business_hours_closed",
  "status": "completed",
  "duration_sec": 180,
  "summary": "車検予約希望。タテイシ様。4月15日13時希望。代車希望あり。",
  "handoff_status": "pending",
  "transcript": [
    { "role": "assistant", "content": "いつもお世話になっております。トヨタカローラ名古屋、AIオペレーターです。..." },
    { "role": "user",      "content": "車検の予約をしたいんですが" },
    { "role": "assistant", "content": "車検のご予約ですね。お名前をお伺いしてもよろしいでしょうか。" },
    { "role": "user",      "content": "タテイシです" }
  ],
  "metadata": {
    "customer_name": "タテイシ",
    "request_type": "新規予約",
    "service": "車検",
    "preferred_date": "4月15日 13時",
    "loaner_needed": true,
    "action_needed": "車検予約の空き確認 → 折り返し連絡"
  },
  "started_at": "2026-04-10T18:30:00+09:00",
  "ended_at": "2026-04-10T18:33:00+09:00",
  "created_at": "2026-04-10T18:30:00+09:00"
}
```

> 通話履歴のリアルタイム購読（SSE）は現行実装では提供していない（§履歴のポーリングで更新）。

### 9.3 POST `/api/calls/summarize`

通話の `transcript`（会話ログ）から構造化サマリー（`customer_name` / `request_type` / `action_needed` 等）を OpenAI で **再生成** する。`call_id` を指定した場合は、生成結果を該当レコードの `summary` / `metadata` カラムに保存する。

通話終了時に自動要約が走るため通常は呼ぶ必要はない。担当者が要約を作り直したいときの **手動再生成用**。

> 店舗スコープ外（`store_id` / `shop_id` をパスに持たない）。

**Request:**
```json
{
  "call_id": 42,
  "transcript": [
    { "role": "assistant", "content": "いつもお世話になっております。..." },
    { "role": "user",      "content": "車検の予約をしたいんですが" }
  ]
}
```

| フィールド | 必須 | 説明 |
|-----------|------|------|
| `call_id` | No | DB から `transcript` を読み、生成結果を同レコードに書き戻す |
| `transcript` | No | 直接指定する場合の会話ログ配列。`call_id` と併用も可（指定優先） |

> `call_id` と `transcript` のいずれも無い・または会話データが取得できない場合は HTTP 400。

**Response 200:**
```json
{
  "summary": {
    "customer_name": "タテイシ",
    "request_type": "新規予約",
    "summary": "車検予約希望。4月15日13時希望。",
    "details": "お客様: 車検の予約をしたい...",
    "action_needed": "車検予約の空き確認 → 折り返し連絡",
    "urgency": "通常",
    "vehicle_number": null,
    "preferred_date": "4月15日 13時",
    "callback_needed": true,
    "notes": null
  }
}
```

> `OPENAI_API_KEY` 未設定時、または OpenAI 呼び出しが失敗した場合はフォールバック（簡易要約）を返す。

### 9.4 PATCH `/api/{store_id}/{shop_id}/calls/{call_id}/handoff-status`

担当者の引き継ぎ対応状況を更新する。通話詳細画面の「未対応 / 対応中 / 完了」ボタンから呼ばれる。

**Request:**
```json
{
  "handoff_status": "in_progress"
}
```

| フィールド | 必須 | 取りうる値 | 説明 |
|-----------|------|-----------|------|
| `handoff_status` | Yes | `pending` / `in_progress` / `done` | 新しいステータス |

**Response 200:**
```json
{
  "id": 42,
  "handoff_status": "in_progress"
}
```

**エラー:**

| HTTP | code | 条件 |
|------|------|------|
| 404 | `NOT_FOUND` | 指定 `call_id` が該当店舗に存在しない |

---

## 10. 発信（AIアウトバウンド）

トヨタ側発信（入庫予定リマインド等）。Twilio REST API で発信し、Deepgram Voice Agent で会話する。店舗スコープ外。

### 10.1 POST `/api/calls/outgoing`

発信を開始する。不応答/話中/失敗の場合はバックグラウンドで自動リトライ（`max_retries` 回まで、`retry_interval` 秒間隔）。

**Request:**
```json
{
  "phone": "+819012345678",
  "site_name": "トヨタカローラ名古屋",
  "max_retries": 3,
  "retry_interval": 600,
  "customer_name": "タテイシ",
  "customer_date": "4月21日",
  "customer_time": "13時",
  "customer_service": "車検",
  "customer_is_shaken": true
}
```

| フィールド | 必須 | 説明 |
|-----------|------|------|
| `phone` | Yes | 発信先電話番号（E.164推奨） |
| `site_name` | No | 表示名。履歴表示用 |
| `max_retries` | No | リトライ最大回数。デフォルトは環境変数 `OUTGOING_MAX_RETRIES` |
| `retry_interval` | No | リトライ間隔（秒）。デフォルトは環境変数 `OUTGOING_RETRY_INTERVAL` |
| `customer_name` | No | 対象顧客名（カタカナ想定） |
| `customer_date` | No | 入庫予定日（例: "4月21日"）。未指定時は翌日が自動セット |
| `customer_time` | No | 入庫予定時刻（例: "13時"） |
| `customer_service` | No | 入庫内容（整備/車検 等） |
| `customer_is_shaken` | No | 車検フラグ。`true` の場合、荷物搬出案内を挿入 |

**Response 200:**
```json
{
  "success": true,
  "call_id": 101,
  "twilio_sid": "CA1234567890abcdef"
}
```

失敗時:
```json
{ "success": false, "call_id": 101, "error": "Twilioエラーメッセージ" }
```

### 10.2 POST `/api/calls/{call_id}/retry`

手動リトライ。履歴の `direction` が `outgoing` である必要がある。

**Response 200:**
```json
{ "success": true, "retry_count": 2, "twilio_sid": "CA..." }
```

失敗時:
```json
{ "success": false, "error": "通話履歴が見つかりません" }
```

### 10.3 POST `/api/calls/{call_id}/update-status`

通話ステータスを手動で更新する。`no-answer` / `busy` / `failed` に更新した場合は、残リトライ回数があれば自動リトライを予約する。

**Query:** `status` (任意: `completed` など。デフォルト `completed`)

**Response 200:**
```json
{ "success": true }
```

---

## 11. マルチエージェント

会話中に専門AI（保険専門・整備専門 等）へ動的に引き継ぐための設定。
着信時とブラウザテスト時に DB 定義が読み込まれ、Deepgram Voice Agent の `transfer_to_specialist` function と Function Calling 定義へ自動変換される。実行ロジックは `server/incoming.py` 参照。

- **specialist_agents**: 切替先エージェントの定義（プロンプト・声・LLM）。`trigger_hint` が切替判断のキーワード
- **agent_functions**: Function Calling 定義（顧客情報照会・ナレッジ検索等）。`handler_type` は `mock` / `api` / `rag`

### 切替判定の 3 経路

通話中、以下の 3 経路のいずれかで切替が発火する。最初に発火したものが処理され、`agent_state.current_id` で同一エージェントへの再切替は抑止される。

| 経路 | トリガー | アナウンス（旧声） |
|------|---------|--------------------|
| **user 発話 direct match** | お客様の発話に specialist の `trigger_hint` のいずれかが含まれる | あり：「○○にお繋ぎします。少々お待ちください。」 |
| **assistant 発話 predicate match** | AI の発話に「お繋ぎ／代わります／引き継ぎ／専門担当」等のシグナル語＋`trigger_hint` が両方含まれる | なし（既に AI が予告発話済みのため） |
| **LLM Function Call** | LLM が `transfer_to_specialist(agent_id, reason)` を呼び出す | あり |

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

```
1. （アナウンス対象なら）旧声で「○○にお繋ぎします」を InjectAgentMessage
2. 2.5 秒待機（旧声 TTS の完了を待つ）
3. UpdateSpeak で specialist の TTS モデルへ切替
4. InjectAgentMessage で specialist greeting を新声で発話
5. UpdatePrompt で人格を上書き
6. （Function Call 経路のみ）FunctionCallResponse を返す
```

### `trigger_hint` の処理仕様

- カンマ `,` ／読点 `、 ，` ／空白 ／スラッシュ `/ ／` ／パイプ `| ｜` で分割
- **1 文字キーワードは誤マッチ防止のため除外**（例: 「車」のみだとあらゆる文脈でマッチしてしまう）
- 空 `trigger_hint` のエージェントは LLM 用プロンプトのヒント行に含めない（プロンプトのノイズ削減）

### `UpdatePrompt` の append 仕様への対策

Deepgram Voice Agent の `UpdatePrompt` は **既存プロンプトに追記**される（置換ではない）。
元シナリオの「お断り」指示が残るとLLMが断り応答を続けるため、`override_prompt` の冒頭で打ち消し文＋「正当なお客様」明示でエージェント人格を上書きする。

### 共通エラー

| HTTP | コード | 説明 |
|------|--------|------|
| 404 | `AGENT_NOT_FOUND` | 専門エージェントが存在しない |
| 404 | `FUNCTION_NOT_FOUND` | 関数定義が存在しない |
| 409 | `ALREADY_EXISTS` | 同じIDのエージェント/関数が既に存在 |
| 400 | `INVALID_REQUEST` | 更新対象フィールドなし等 |

### 11.1 GET `/api/{store_id}/{shop_id}/specialist-agents`

専門エージェント一覧（`sort_order`, `id` 昇順）。

**Response 200:**
```json
{
  "agents": [
    {
      "id": "insurance_expert",
      "name": "保険専門オペレーター",
      "description": "自動車保険・特約に関する専門的な問い合わせ対応",
      "trigger_hint": "保険,特約,車両入替,等級,免責",
      "prompt": "あなたは自動車保険の専門AIオペレーターです。...",
      "greeting": "保険に詳しい専門オペレーターに代わります。少々お待ちください。",
      "tts_model": "aura-2-fujin-ja",
      "llm_provider": "open_ai",
      "llm_model": "gpt-4o-mini",
      "sort_order": 1,
      "is_active": true,
      "created_at": "2026-04-24T10:00:00+09:00",
      "updated_at": "2026-04-24T10:00:00+09:00"
    }
  ],
  "total": 1
}
```

### 11.2 GET `/api/{store_id}/{shop_id}/specialist-agents/{agent_id}`

詳細取得。レスポンスは一覧の1要素と同形式。

### 11.3 POST `/api/{store_id}/{shop_id}/specialist-agents`

新規作成。`id` は店舗内で一意。

**Request:**
```json
{
  "id": "insurance_expert",
  "name": "保険専門オペレーター",
  "description": "自動車保険に関する専門問い合わせ",
  "trigger_hint": "保険,特約,車両入替",
  "prompt": "あなたは...",
  "greeting": "保険専門に代わります",
  "tts_model": "aura-2-fujin-ja",
  "llm_provider": "open_ai",
  "llm_model": "gpt-4o-mini",
  "sort_order": 1,
  "is_active": true
}
```

**Response 200:**
```json
{ "message": "専門エージェントを作成しました", "agent": { /* 11.1 と同形式 */ } }
```

### 11.4 PATCH `/api/{store_id}/{shop_id}/specialist-agents/{agent_id}`

部分更新。送信されたフィールドのみ更新。`id` は変更不可。

**Response 200:**
```json
{ "message": "専門エージェントを更新しました", "agent": { /* 11.1 と同形式 */ } }
```

### 11.5 DELETE `/api/{store_id}/{shop_id}/specialist-agents/{agent_id}`

**Response 200:**
```json
{ "message": "専門エージェントを削除しました" }
```

### 11.6 GET `/api/{store_id}/{shop_id}/agent-functions`

Function Calling 定義一覧。

**Response 200:**
```json
{
  "functions": [
    {
      "id": "lookup_customer",
      "name": "lookup_customer",
      "description": "着信番号からお客様情報を検索します。",
      "parameters_json": {
        "type": "object",
        "properties": {
          "phone_number": { "type": "string", "description": "お客様の電話番号" }
        },
        "required": ["phone_number"]
      },
      "handler_type": "mock",
      "handler_config": {
        "delay_sec": 10,
        "mock_response": { "customer_name": "平川様", "vehicle": "カローラクロス" }
      },
      "is_active": true,
      "created_at": "2026-04-24T10:00:00+09:00",
      "updated_at": "2026-04-24T10:00:00+09:00"
    }
  ],
  "total": 1
}
```

- `handler_type`: `mock` = モック応答（`delay_sec` 秒後に `mock_response` を返す）、`api` = 外部API連携（未実装）、`rag` = ナレッジ検索（未実装）
- `parameters_json` は JSON Schema 形式（Deepgramにそのまま渡される）

### 11.7 GET `/api/{store_id}/{shop_id}/agent-functions/{function_id}`

詳細取得。

### 11.8 POST `/api/{store_id}/{shop_id}/agent-functions`

新規作成。リクエスト形式は 11.6 の1要素から `created_at` / `updated_at` を除いたもの。

### 11.9 PATCH `/api/{store_id}/{shop_id}/agent-functions/{function_id}`

部分更新。送信されたフィールドのみ更新。

### 11.10 DELETE `/api/{store_id}/{shop_id}/agent-functions/{function_id}`

**Response 200:**
```json
{ "message": "Function Calling 定義を削除しました" }
```

---

## エンドポイント一覧

| タグ | 番号 | メソッド | パス | 概要 |
|------|-----|---------|------|------|
| 1. マスタ | 1.1 | GET | `/api/scenario-types` | シナリオ種別マスタ（プルダウン用） |
| 2. シナリオ | 2.1 | GET | `/api/{store_id}/{shop_id}/scenarios` | シナリオ一覧 |
| 2. シナリオ | 2.2 | GET | `/api/{store_id}/{shop_id}/scenarios/{scenario_id}` | シナリオ詳細 |
| 3. プロンプトAI整形 | 3.1 | POST | `/api/{store_id}/{shop_id}/scenarios/{scenario_id}/prompt/generate` | プロンプトAI整形 |
| 4. バージョン管理 | 4.1 | GET | `/api/{store_id}/{shop_id}/scenarios/{scenario_id}/versions` | バージョン一覧 |
| 4. バージョン管理 | 4.2 | GET | `/api/{store_id}/{shop_id}/scenarios/{scenario_id}/versions/{version_id}` | バージョン詳細 |
| 4. バージョン管理 | 4.3 | POST | `/api/{store_id}/{shop_id}/scenarios/{scenario_id}/versions/{version_id}/restore` | バージョン復元 |
| 4. バージョン管理 | 4.4 | DELETE | `/api/{store_id}/{shop_id}/scenarios/{scenario_id}/versions/{version_id}` | バージョン削除 |
| 5. 通話テスト | 5.1 | POST | `/api/{store_id}/{shop_id}/test/browser` | 通話テスト（PC画面） |
| 5. 通話テスト | 5.2 | POST | `/api/{store_id}/{shop_id}/test/phone` | 通話テスト（電話） |
| 5. 通話テスト | 5.3 | GET | `/api/{store_id}/{shop_id}/test/{session_id}/status` | 通話テスト ステータス取得（ポーリング） |
| 5. 通話テスト | 5.4 | DELETE | `/api/{store_id}/{shop_id}/test/{session_id}` | 通話テスト終了/キャンセル |
| 5. 通話テスト | 5.5 | POST | `/api/{store_id}/{shop_id}/tablet/session` | 対面受付セッション作成 |
| 6. 設定保存 | 6.1 | POST | `/api/{store_id}/{shop_id}/scenarios/save` | シナリオ設定保存 |
| 7. 営業日・営業時間連携 | 7.1 | POST | `/api/{store_id}/{shop_id}/business-days/sync` | 営業日同期（ケプラー連携） |
| 7. 営業日・営業時間連携 | 7.2 | POST | `/api/{store_id}/{shop_id}/business-hours/sync` | 営業時間同期（ケプラー連携） |
| 7. 営業日・営業時間連携 | 7.3 | GET | `/api/{store_id}/{shop_id}/business-hours` | 営業時間取得（画面表示用） |
| 8. 迷惑電話 | 8.1 | GET | `/api/{store_id}/{shop_id}/blocked-numbers` | ブロック番号一覧 |
| 8. 迷惑電話 | 8.2 | POST | `/api/{store_id}/{shop_id}/blocked-numbers` | ブロック番号追加 |
| 8. 迷惑電話 | 8.3 | DELETE | `/api/{store_id}/{shop_id}/blocked-numbers/{blocked_id}` | ブロック番号解除 |
| 9. 通話履歴 | 9.1 | GET | `/api/{store_id}/{shop_id}/calls` | 通話履歴一覧 |
| 9. 通話履歴 | 9.2 | GET | `/api/{store_id}/{shop_id}/calls/{call_id}` | 通話詳細（会話ログ含む） |
| 9. 通話履歴 | 9.3 | POST | `/api/calls/summarize` | 通話内容からAI要約生成（手動再生成用） |
| 9. 通話履歴 | 9.4 | PATCH | `/api/{store_id}/{shop_id}/calls/{call_id}/handoff-status` | 引き継ぎステータス更新 |
| 10. 発信 | 10.1 | POST | `/api/calls/outgoing` | AI発信開始（リトライ予約あり） |
| 10. 発信 | 10.2 | POST | `/api/calls/{call_id}/retry` | 発信手動リトライ |
| 10. 発信 | 10.3 | POST | `/api/calls/{call_id}/update-status` | 通話ステータス手動更新 |
| 11. マルチエージェント | 11.1 | GET | `/api/{store_id}/{shop_id}/specialist-agents` | 専門エージェント一覧 |
| 11. マルチエージェント | 11.2 | GET | `/api/{store_id}/{shop_id}/specialist-agents/{agent_id}` | 専門エージェント詳細 |
| 11. マルチエージェント | 11.3 | POST | `/api/{store_id}/{shop_id}/specialist-agents` | 専門エージェント新規作成 |
| 11. マルチエージェント | 11.4 | PATCH | `/api/{store_id}/{shop_id}/specialist-agents/{agent_id}` | 専門エージェント更新 |
| 11. マルチエージェント | 11.5 | DELETE | `/api/{store_id}/{shop_id}/specialist-agents/{agent_id}` | 専門エージェント削除 |
| 11. マルチエージェント | 11.6 | GET | `/api/{store_id}/{shop_id}/agent-functions` | Function Calling 定義一覧 |
| 11. マルチエージェント | 11.7 | GET | `/api/{store_id}/{shop_id}/agent-functions/{function_id}` | Function Calling 定義詳細 |
| 11. マルチエージェント | 11.8 | POST | `/api/{store_id}/{shop_id}/agent-functions` | Function Calling 定義新規作成 |
| 11. マルチエージェント | 11.9 | PATCH | `/api/{store_id}/{shop_id}/agent-functions/{function_id}` | Function Calling 定義更新 |
| 11. マルチエージェント | 11.10 | DELETE | `/api/{store_id}/{shop_id}/agent-functions/{function_id}` | Function Calling 定義削除 |
