> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# OpenRouter 仪表盘反查 Runbook

> **last-verified: 2026-07-14**（对照 infra PR #1459）

**Service**: shared AI invoke (`lambda/shared/utils/ai/invoke.ts`) + OpenRouter dashboard
**Last Updated**: 2026-07-14
**Owner**: Platform Team
**Related Docs**: [AI Analysis Runbook](/operations/runbooks/ai-analysis.md) | [Pipeline Monitoring](/operations/runbooks/pipeline-monitoring.md)
**前置 PR**: [callytics-infrastructure #966](https://github.com/retaintive/callytics-infrastructure/pull/966)（response-healing + usage telemetry）+ [#967](https://github.com/retaintive/callytics-infrastructure/pull/967)（generation ID + provider in log）

***

## 这个 runbook 解决什么问题

**主场景**：客户在 Studio (retaintive dashboard) 上看到一通**有问题**的 call — task 抓错、staff name 错、call\_state 离谱、cost 异常。客户给你的信息通常只有**电话号码 + 时间**，没有 telephonySessionId、更没有 OpenRouter Generation ID。

需要从**电话号码**反向走 4 步追到 OpenRouter 那一次具体调用的完整 payload。

**反例**：如果只是想看**整体**成本 / 模型使用 → 直接打开 [openrouter.ai/activity](https://openrouter.ai/activity) 看聚合就行，不用走这套反查。

***

## 4-Step 反查路径（\~2 分钟）

```
┌──────────────────────────────────────────────────────────────┐
│ Step 1: Studio (retaintive dashboard)                        │
│ ─────────────────────────────────────────────────────────── │
│ 客户报告"这通分析不对" → 拿到 phone number + 大概时间          │
│ 例: +19142654371, 2026-05-20 13:15                          │
└────────────────────────────┬─────────────────────────────────┘
                             │
                             ▼
┌──────────────────────────────────────────────────────────────┐
│ Step 2: Neon `calls` 表查 telephony_session_id                │
│ ─────────────────────────────────────────────────────────── │
│ 用 from_phone_number / to_phone_number / contact_phone 查    │
│ → 拿到 telephony_session_id + start_time                     │
└────────────────────────────┬─────────────────────────────────┘
                             │
                             ▼
┌──────────────────────────────────────────────────────────────┐
│ Step 3: CloudWatch Logs Insights 拿 openrouterId             │
│ ─────────────────────────────────────────────────────────── │
│ filter @message like /ai_usage/                              │
│ filter telephonySessionId = "abc123"                         │
│ → 拿到 openrouterId = "gen-1779299823-8nd5Ci0U..."           │
│ (3-stage pipeline = 3 行 = 3 个 openrouterId)               │
└────────────────────────────┬─────────────────────────────────┘
                             │
                             ▼
┌──────────────────────────────────────────────────────────────┐
│ Step 4: OpenRouter Activity Page                             │
│ ─────────────────────────────────────────────────────────── │
│ openrouter.ai/activity/{openrouterId}                        │
│ → 完整 payload + provider routing + cache status + 计费     │
└──────────────────────────────────────────────────────────────┘
```

***

## Step 1: Studio dashboard 拿电话号码

客户报告："5 月 20 号下午 1 点多有通 +1 (914) 265-4371 的电话，AI 分析说是 voicemail 但其实是真人对话。"

你需要从客户报告里提取：

| 信息                | 用在哪                                          | 必须吗  |
| ----------------- | -------------------------------------------- | ---- |
| **Phone number**  | Neon `calls` 表 phone 列查询                     | 必须   |
| **大概时间**          | 时间窗口缩小到 ±2h，避免历史撞号                           | 强烈推荐 |
| Store / Franchise | 进一步过滤                                        | 可选   |
| 通话方向（接 / 打）       | 决定查 `from_phone_number` 还是 `to_phone_number` | 可选   |

**Phone 格式规范化**：Neon 里所有电话都是 **E.164** 格式（`+19142654371`，前面带 `+1`）。客户给的 `(914) 265-4371` / `914-265-4371` / `19142654371` 都要先转 E.164 再查。

```bash
# 简单 normalize（去掉所有非数字 + 前置 +1）
echo "(914) 265-4371" | tr -cd '0-9' | sed 's/^/+1/'
# → +19142654371
```

***

## Step 2: Neon `calls` 表查 telephony\_session\_id

### Neon 表结构（相关字段）

| 字段                     | 类型           | 含义                                           |
| ---------------------- | ------------ | -------------------------------------------- |
| `telephony_session_id` | text         | 反查需要的 ID（一通电话唯一，主键）                          |
| `from_phone_number`    | text (E.164) | 主叫号码                                         |
| `to_phone_number`      | text (E.164) | 被叫号码                                         |
| `contact_phone`        | text (E.164) | 客户号码（接 vs 打无关）                               |
| `store_id`             | uuid         | 店铺隔离键（Phase 3 前可能为 NULL）                     |
| `franchise_id`         | text         | 品牌                                           |
| `start_time`           | timestamptz  | 通话开始时间（RC Call Log 的 startTime，`.notNull()`） |
| `s3_analysis_path`     | text         | AI 分析结果 S3 路径（已分析非 null）                     |

来源：`@retaintive/common` 的 `packages/common/src/db/schema/calls.ts`。

> ⚠️ **没有 `created_at` 列**——本表用 RC 通话时间 `start_time` 做时间窗过滤。系统级时间戳是 `synced_at`（首次同步）/ `updated_at`（最后更新），不是通话时间，别拿来卡时间窗。

### Query：按客户电话 + 时间窗

```sql
SELECT
  telephony_session_id,
  store_id,
  franchise_id,
  from_phone_number,
  to_phone_number,
  contact_phone,
  start_time,
  s3_analysis_path
FROM calls
WHERE contact_phone = '+19142654371'
  AND start_time BETWEEN '2026-05-20 12:00:00' AND '2026-05-20 15:00:00'
ORDER BY start_time DESC;
```

- 用 `contact_phone` 字段最稳——不管接还是打，客户号码都在这一列
- 时间窗 ±2h 避免命中历史同号通话
- 如果同时段多通 → 让客户进一步描述（"是早上还是下午？接的还是打的？"）

### 如果不确定客户电话归在哪一列

也可以用 `from` / `to` 任一边匹配兜底：

```sql
SELECT
  telephony_session_id,
  from_phone_number,
  to_phone_number,
  contact_phone,
  start_time
FROM calls
WHERE (from_phone_number = '+19142654371' OR to_phone_number = '+19142654371')
  AND start_time BETWEEN '2026-05-20 12:00:00' AND '2026-05-20 15:00:00'
ORDER BY start_time DESC;
```

### Neon 怎么连

| 方式                                                                               | 适合谁            |
| -------------------------------------------------------------------------------- | -------------- |
| Neon Console SQL Editor ([https://console.neon.tech](https://console.neon.tech)) | 偶尔查、不想搭客户端     |
| `psql` (CLI)                                                                     | 重度查询、需要 export |
| 项目 IDE 集成 (Drizzle Studio / DBeaver)                                             | 长期             |

连接串在 SSM Parameter Store。Test 环境路径 / 凭据找 Platform team 拿，**不要把 connection string commit 进 git**。

***

## Step 3: CloudWatch Insights 拿 OpenRouter Generation ID

### 选对 Log Group

| Lambda                | Log Group 模式                                                      |
| --------------------- | ----------------------------------------------------------------- |
| ai-analysis-processor | `/aws/lambda/call-analytics-{env}-ai-analysis-processor-{region}` |
| contacts-analyzer     | `/aws/lambda/call-analytics-{env}-contacts-analyzer-{region}`     |

`{env}` = `test` / `pre` / `prod`，`{region}` = `us-west-2` / `us-east-2` / `us-east-1`。实际名字以 AWS Console "Log groups" 列表为准。

### Query：按 telephonySessionId 反查

```sql
fields @timestamp, openrouterId, provider, model, inputTokens, outputTokens,
       cacheReadTokens, cacheReadRatio, costUsd, finishReason
| filter @message like /ai_usage/
| filter telephonySessionId = "abc123"
| sort @timestamp asc
```

**正常情况返回 3 行**（ai-analysis 3-stage pipeline: triage → classify → coaching）。每行有自己的 `openrouterId`。

### ai\_usage 字段对照表

| 字段                | 类型                    | 含义                                                   | SDK 路径                                           |
| ----------------- | --------------------- | ---------------------------------------------------- | ------------------------------------------------ |
| `openrouterId`    | string (可能 undefined) | OpenRouter Generation ID，格式 `gen-{timestamp}-{rand}` | `result.response.id`                             |
| `provider`        | string (可能 undefined) | 上游 provider，如 `"AtlasCloud"` / `"Alibaba Cloud Int"` | `result.providerMetadata.openrouter.provider`    |
| `model`           | string                | 模型 ID，如 `"deepseek/deepseek-v4-flash"`               | 我们传的                                             |
| `inputTokens`     | number                | 总 input token 数                                      | `result.usage.inputTokens`                       |
| `outputTokens`    | number                | 总 output token 数                                     | `result.usage.outputTokens`                      |
| `cacheReadTokens` | number (可能 undefined) | 命中 cache 的 input token                               | `result.usage.inputTokenDetails.cacheReadTokens` |
| `cacheReadRatio`  | number (0-1)          | `cacheReadTokens / inputTokens` (3-decimal)          | invoke.ts 计算                                     |
| `costUsd`         | number (可能 undefined) | OpenRouter 已计费的金额                                    | `providerMetadata.openrouter.usage.cost`         |
| `finishReason`    | string                | `"stop"` / `"length"` / `"content-filter"` 等         | `result.finishReason`                            |

⚠️ **`openrouterId` 偶尔可能 undefined**（SDK shape 变 / provider 异常 / log 抽取失败）。如果反查不到 → 用 fallback 流程（章节 [当 openrouterId 缺失](#当-openrouterid-缺失)）。

***

## Step 4: OpenRouter Activity Page

### 直接打开 URL

```
https://openrouter.ai/activity/{openrouterId}
```

例：`https://openrouter.ai/activity/gen-1779299823-8nd5Ci0U0mwlGRruPcXq`

页面叫 **Generation details**，会显示：

| Section               | 内容                                                                     |
| --------------------- | ---------------------------------------------------------------------- |
| Header                | 模型 + provider + Cached badge（如果命中）                                     |
| 顶部 metrics            | Provider latency / Throughput tok/s / Cost USD / Tokens 拼接 / Fallbacks |
| **Overview**          | Model ID / Canonical ID / Data policy                                  |
| **Request**           | Request ID（开发用）/ Generation ID（URL 里那个）/ Finish reason / Streaming     |
| **Provider routing**  | 实际走的 provider 链 + fallback 历史                                          |
| **Tokens**            | input/output details, cache breakdown                                  |
| **Generation params** | temperature / max\_tokens / response\_format 等实际发送参数                   |

### 没权限？

OpenRouter Activity 页要求 API key 所有者账号登录。没账号 → 联系 platform team 拿 read access，或让有权限的人代查。

### 备用 API（无 UI 也能查）

```bash
# 从 SSM 临时取 API key
OPENROUTER_API_KEY=$(aws ssm get-parameter \
  --name /callytics/openrouter-api-key \
  --with-decryption \
  --query 'Parameter.Value' \
  --output text)

# 查 generation
curl -s -H "Authorization: Bearer ${OPENROUTER_API_KEY}" \
  "https://openrouter.ai/api/v1/generation?id=gen-1779299823-8nd5Ci0U0mwlGRruPcXq" | jq .

# 用完清掉
unset OPENROUTER_API_KEY
```

⚠️ **API key 别 commit 进 git / 别贴进 chat**。用完立刻清，shell history 也清。

***

## 完整示例：从客户报告到 OpenRouter

### 客户原话

> "2026-05-20 下午 1:15 左右有通电话 (914) 265-4371，你们的 AI 分析说是 voicemail 但其实是真人在说话。"

### Step 1 — normalize 电话

```
(914) 265-4371 → +19142654371
```

### Step 2 — Neon 查

```sql
SELECT telephony_session_id, store_id, start_time, s3_analysis_path
FROM calls
WHERE contact_phone = '+19142654371'
  AND start_time BETWEEN '2026-05-20 12:00:00' AND '2026-05-20 15:00:00'
ORDER BY start_time DESC;
```

返回：

```
telephony_session_id              | store_id                              | start_time          | s3_analysis_path
s-abc123def456                    | a1b2c3d4-...                          | 2026-05-20 13:14:52 | s3://...
```

→ `telephony_session_id = 's-abc123def456'`

### Step 3 — CloudWatch Insights

Log group: `/aws/lambda/call-analytics-prod-ai-analysis-processor-us-east-1`

```sql
fields @timestamp, openrouterId, provider, model, cacheReadRatio, costUsd, finishReason
| filter @message like /ai_usage/
| filter telephonySessionId = "s-abc123def456"
| sort @timestamp asc
```

返回 3 行（triage / classify / coaching）：

```
@timestamp           | openrouterId                          | provider    | cacheReadRatio | costUsd  | finishReason
2026-05-20 13:14:55  | gen-1779299695-aBcDeFg                | AtlasCloud  | 0.92          | 0.000122 | stop
2026-05-20 13:14:57  | gen-1779299697-hIjKlMn                | AtlasCloud  | 0.88          | 0.000774 | stop
2026-05-20 13:15:00  | gen-1779299700-oPqRsTu                | AtlasCloud  | 0.85          | 0.000639 | stop
```

### Step 4 — OpenRouter

打开第一行（triage）：`https://openrouter.ai/activity/gen-1779299695-aBcDeFg`

Generation details 页面看：

- 输入 transcript 是不是被截断了
- 模型实际返回的 raw JSON 是不是 `{"call_state": "voicemail", ...}`
- finish\_reason 是不是 `stop`

如果 raw JSON 就是 `voicemail` → 模型判断错了，**prompt 问题**或**transcript 转录质量问题**（去对 transcribe-processor 的输出）。
如果 raw JSON 是 `human_conversation` 但 Neon 存的是 voicemail → **下游 mapping bug**（去查 ai-analysis-processor 的 post-process 逻辑）。

***

## 当 `openrouterId` 缺失

如果 CloudWatch `ai_usage` 行里 `openrouterId` 是 `undefined`（SDK shape 变 / provider 异常 / log 抽取失败），走 degraded path：

1. 拿同一行的 4 元组 `(timestamp, inputTokens, outputTokens, costUsd)`
2. 打开 [openrouter.ai/activity](https://openrouter.ai/activity)
3. App filter 选对应 Lambda（`callytics-ai-analysis` / `callytics-contacts-analyzer`）
4. 时间窗口卡到目标秒附近 ±10s
5. 用 token 数 + cost 在 dashboard 行里手动对

⚠️ 同 store 同时段重复 transcript 可能撞数字。这是 **degraded path**，频繁触发说明 telemetry 出问题，给 platform team 报。

***

## Dashboard 整体观察（不针对单 call）

定期巡检 / 看大盘时用，不是 per-call 反查。

### 打开

`https://openrouter.ai/activity` — 默认显示账号下所有 generation，时间倒序。

### 关键 filter

| Filter         | 用法                                                        | 用在哪                    |
| -------------- | --------------------------------------------------------- | ---------------------- |
| **App**        | 选 `callytics-ai-analysis` 或 `callytics-contacts-analyzer` | 分开看两个 Lambda 流量        |
| **Model**      | 选 `deepseek/deepseek-v4-flash`                            | 跟其他模型流量混了的话隔离          |
| **Provider**   | 选 `AtlasCloud` / `Alibaba Cloud Int` 等                    | 看 sticky routing 是否在工作 |
| **Time range** | 6h / 24h / 7d / 自定义                                       | 圈定调查窗口                 |

### 看什么

- **Cached badge** 出现率：DeepSeek implicit cache hit rate 应该 90%+（社区数据）。整列长期没 Cached badge → prompt prefix 飘了，去 grep `prompts.ts` 找 `${...}` 插值
- **Provider 列分布**：sticky routing 应该把同 prompt 路由到同 provider 最大化 cache。频繁切换 → 报告给 platform team
- **Finish reason** 异常：`length` 多 → maxTokens 设太小；`content-filter` 多 → prompt 触发 moderation，检查 transcript 内容

***

## 6 个常见 debug 场景

### 场景 1：客户报告"某通分析全错"

照 4-step 流程走完。重点看 Generation details 的 raw response：

- raw response 跟 Neon 存的一致 → 模型判断错（prompt 或 transcript 质量问题）
- raw response 跟 Neon 存的不一致 → 下游 mapping bug

### 场景 2：某段时间 cost 异常高

```sql
fields @timestamp, openrouterId, costUsd, cacheReadRatio, inputTokens
| filter @message like /ai_usage/
| filter @timestamp > now() - 2h
| sort costUsd desc
| limit 20
```

拿 top costUsd 行的 openrouterId → Activity 页看：

- `inputTokens` 暴涨 → transcript 太长（去对 transcribe-processor 看是不是漏切）
- `cacheReadRatio = 0` → cache miss（prompt 飘了 / cold start / sticky routing 没生效）

### 场景 3：cache 命中率长期是 0

```sql
fields @timestamp, model, cacheReadRatio
| filter @message like /ai_usage/
| stats avg(cacheReadRatio) as avg_hit by bin(1h)
```

连续多小时 `avg_hit ≈ 0`：

1. 检查 `lambda/ai-analysis-processor/src/core/stages/prompts.ts` 有没人加 `${...}` 插值
2. 跑 `cd lambda/ai-analysis-processor && npm test tests/unit/prompts-cache-invariant.test.ts`，测试应该挂；如果没挂说明 prompt 改动绕过了 invariant test

### 场景 4：dashboard 显示 "Unknown" App

PR #967 已合并 + deploy 但 dashboard 还看到 `Unknown` → Lambda 还在跑旧版本：

```bash
# 看 Lambda 当前部署的 commit
aws lambda get-function \
  --function-name call-analytics-test-ai-analysis-processor-us-west-2 \
  --query 'Configuration.CodeSha256'

# 强制重新部署
cd ~/workspace/callytics-infrastructure
npx cdk deploy CallAnalytics-Test-Lambda --require-approval never
```

### 场景 5：finishReason 是 `length`

输出被截断。Activity 页看 maxTokens 实际值，对比 Lambda 配置：

| Lambda                | maxTokens 当前值 | 配置位置                                                                 |
| --------------------- | ------------- | -------------------------------------------------------------------- |
| ai-analysis-processor | 4096          | `lambda/ai-analysis-processor/src/infrastructure/ai/invoke-model.ts` |
| contacts-analyzer     | 4000          | `lambda/contacts-analyzer/src/infrastructure/ai-client.ts`           |

如果 transcript 极长且需要长输出 → 提 PR 改 maxTokens；如果偶发 → 看 input 是不是异常长的 transcript。

### 场景 6：provider 频繁切换 / fallback 多

Activity 页 Provider routing section 显示 fallback 链。某 primary provider 频繁 fallback：

1. OpenRouter dashboard 看 Provider 列分布
2. sticky routing 失效 → 联系 OpenRouter support（平台层）
3. 短期 mitigation：在 `lambda/shared/utils/ai/invoke.ts` 的 `createOpenRouter()` 加 `provider: { order: ['AtlasCloud'], allow_fallbacks: false }`（**会牺牲 availability**，先讨论再做）

***

## Identity 字段速查（哪个 ID 反查什么）

| ID 字段                      | 哪儿能看到                                                                                   | 反查什么                                        |
| -------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------- |
| **Phone number** (E.164)   | Studio dashboard / Neon `calls.from_phone_number, to_phone_number, contact_phone`       | 客户视角入口                                      |
| `telephonySessionId`       | Neon `calls.telephony_session_id` / DDB `call-analysis` PK / CloudWatch `loggerContext` | 一通电话的完整 pipeline 痕迹                         |
| `openrouterId` (`gen-xxx`) | CloudWatch `ai_usage.openrouterId` / OpenRouter dashboard "Generation ID" 列             | OpenRouter 一次 AI call 的完整 payload + routing |
| `requestId` (`req-xxx`)    | OpenRouter dashboard "Request ID"（Activity 页）                                           | OpenRouter 内部 trace（开发用，operator 通常不用）      |
| `awsRequestId`             | CloudWatch `loggerContext.requestId` / `context.awsRequestId`                           | Lambda 一次 invocation 的所有 log                |

**关键关系**：

- 1 个 phone number **多对多** telephony\_session\_id（同号码可以打多次电话）
- 1 个 telephony\_session\_id **一对多** openrouterId（一通电话触发 3 次 AI call）
- 1 个 openrouterId **永远对应唯一 1 通**

***

## 哪儿改 log 字段

只需要知道源在哪 / 想改 log 字段的人去看：

| 关注点                  | 文件路径（`callytics-infrastructure`）                                                      |
| -------------------- | ------------------------------------------------------------------------------------- |
| Generation ID 提取     | `lambda/shared/utils/ai/invoke.ts` 的 `logUsage()` 函数                                  |
| App 名传给 OpenRouter   | `lambda/shared/utils/ai/invoke.ts` 的 `createOpenRouter({ appName, appUrl })`          |
| ai-analysis App 名    | `lambda/ai-analysis-processor/src/infrastructure/ai/invoke-model.ts` `const APP_NAME` |
| contacts App 名       | `lambda/contacts-analyzer/src/infrastructure/ai-client.ts` `const APP_NAME`           |
| Cache invariant test | `lambda/ai-analysis-processor/tests/unit/prompts-cache-invariant.test.ts`             |

***

## 相关链接

- **OpenRouter dashboard**: [https://openrouter.ai/activity](https://openrouter.ai/activity)
- **OpenRouter API Reference**: [https://openrouter.ai/docs/api/reference/overview](https://openrouter.ai/docs/api/reference/overview)
- **Prompt Caching 解释**: [https://openrouter.ai/docs/features/prompt-caching](https://openrouter.ai/docs/features/prompt-caching)
- **Usage Accounting 解释**: [https://openrouter.ai/docs/use-cases/usage-accounting](https://openrouter.ai/docs/use-cases/usage-accounting)
- **AI Analysis Runbook**: [ai-analysis.md](/operations/runbooks/ai-analysis.md)
- **Pipeline Monitoring**: [pipeline-monitoring.md](/operations/runbooks/pipeline-monitoring.md)
- **Reprocess AI Analysis SOP**: [../reprocess-ai-analysis.md](/operations/reprocess-ai-analysis.md)
- **Neon `calls` 表 schema**: `@retaintive/common/src/db/schema/calls.ts`

***

## 历史 / 设计决策

**为什么不存 `requestId`？** OpenRouter 里 Request ID（`req-xxx`）是开发用的内部 trace，operator 反查永远用 Generation ID（`gen-xxx`），所以我们只 log 后者。如果有一天发现需要 `requestId` → 改 `lambda/shared/utils/ai/invoke.ts` 的 `logUsage()` 加字段。

**为什么 App 名是 `callytics-ai-analysis` 不是 `callytics-ai-analysis-triage` / `-classify` / `-coaching`？** stage 信息走 CloudWatch（哪个 stage 在调用 logger 看 service 字段就知道），dashboard App 维度只到 Lambda 粒度，避免把 dashboard column 拆得过细。

**为什么入口是电话号码不是 telephonySessionId？** 客户视角永远从 Studio dashboard 开始，他们看不到 telephonySessionId（那是后端 ID）。Engineer 视角已知 telephonySessionId 时可以从 Step 2 开始跳过 Step 1。
