跳到正文

与 callout-server 协同交互与接口规范 ​

callflow-esl 作为 FreeSWITCH 通话编排底座与语音 Runtime,与外呼调度运营及 AI 策略中台服务 callout-server 紧密协同。本文档系统梳理两服务间的架构拓扑、呼叫建立变量注入、Callout 协同 SDK 用法、上行业务接口契约(附 TypeScript 调用示例)、下行控制接口契约以及完整端到端外呼业务开发样板。


1. 架构总览与通信拓扑 ​

1.1 系统职责划分 ​

模块系统定位核心职责
callflow-esl系统一:呼叫编排底座 / 语音 Runtime接收 FreeSWITCH outbound socket 回连,负责流式 ASR / TTS 音频流转发、VAD 打断、双声道录音、会议室混音管理、流程图节点解释执行与通话生命周期管理。
callout-server系统二:外呼中台 / 坐席与 AI 策略中心负责外呼活动(Campaign)与联系人调度、线路并发控制、坐席权限与软电话管理、实时盯屏监控、以及原生内嵌的 AI 策略中台(流程脚本发布、NLU 意图识别与槽位抽取、大模型流式 Chat)。

1.2 通信维度与交互全景 ​

两服务之间通过四条主要通信通道协同:

1.3 鉴权与配置契约 ​

  • 上行调用鉴权(callflow-esl -> callout-server): 在 apps/callflow-esl/config.toml 中配置 [callout] 段:
    toml
    [callout]
    baseUrl = "http://127.0.0.1:9920"
    token = "your-service-api-token"
    requestTimeoutMs = 10000
    callflow-esl 发出请求时,如果 token 非空,将自动附加 HTTP 请求头: X-Callout-Internal-Token: <token> 该 token 必须与 callout-server 的 server.serviceApi.authToken(或环境变量 CALLOUT_SERVICE_API_AUTH_TOKEN)一致。
  • 下行调用鉴权(callout-server -> callflow-esl): 在 apps/callout-server/config.toml 中配置 [cells.callflowEsl] 段:
    toml
    [cells.callflowEsl]
    baseUrl = "http://127.0.0.1:9912"
    token = "your-shared-esl-token"
    callout-server 访问 callflow-esl 的内部控制接口时,附带 HTTP 请求头: token: <token> 该 token 必须与 callflow-esl 的 httpServer.authToken 一致。

2. 呼叫建立与通道变量注入 ​

callout-server 调度器通过 FreeSWITCH Inbound ESL 发起后台呼叫命令:

text
bgapi originate {var1=val1,var2=val2...}<dialString> &socket('<callbackHost>:<callbackPort> async full')
  • 接通后,FreeSWITCH 主动连接 callflow-esl 监听的 Outbound ESL 端口(默认 9911)。
  • 呼叫上下文由花括号 {...} 内的通道变量(Channel Variables)一次性注入。业务可通过 Callout SDK 统一提取。

2.1 通道变量字典 ​

通道变量名数据类型注入场景用途与消费逻辑
business_codestring全部外呼业务路由键:callflow-esl 接通后优先根据此变量选择执行的业务处理器(如 callout-flow-business、agent-conference-guest)。
script_idstring (数字文本)流程脚本外呼流程脚本 ID:callout-flow-business 接通后据此调用 POST /api/scripts/runtime 拉取流程定义。
callout_request_idstring (UUID)普通外呼外呼请求 ID:单次外呼请求的全局唯一标识,结果回写时作为最高优先级的定位索引。
callout_campaign_idstring (数字文本)普通外呼外呼任务 ID:关联外呼活动,回写结果与上报进度时携带。
callout_contact_idstring (数字文本)普通外呼联系人 ID:关联具体外呼客户记录。
callout_attempt_idstring (数字文本)普通外呼 / 转人工拨打尝试 ID:单次拨打尝试的数据库记录 ID(存在时透传)。
outbound_request_idstring (UUID)普通外呼FreeSWITCH 侧 originate 命令内部标识(主要用于全链路日志与排障关联)。
origination_caller_id_numberstring全部外呼主叫号码,由选中线路及号码池配置渲染透传。
origination_caller_id_namestring全部外呼主叫名称显示。
conference_idstring坐席入会 / 转人工目标会议室 ID:坐席入会业务(agent-conference-guest)据此直接进入客户所在会议室。
callout_conference_rolestring坐席入会成员角色:agent(常规坐席,全双工)或 supervisor(主管静默监听)。
callout_conference_flagsstring坐席入会会议属性 flags,以 | 分隔(如主管监听为 mute,不向会议室发声)。
callout_agent_call_record_idstring (数字文本)坐席入会 / 软电话关联 agent_call_records 记录 ID,供事件回写接听与挂断状态。
callout_agent_idstring (数字文本)坐席入会坐席工号/ID。
callout_directionstring坐席入会腿方向标识(transfer / supervisor)。
call_timeoutstring (数字文本)坐席入会坐席分机振铃超时秒数,超时未接听则释放。
任务与联系人自定义变量string普通外呼从任务 variables 与联系人 metadata 注入的字符串键值对,用于话术文本模板渲染(如 、)。

IMPORTANT

变量类型约束:FreeSWITCH originate 仅支持纯字符串键值对。variables 与 metadata 中非字符串字段(数字、布尔、嵌套对象)在注入前会被过滤掉;需要在话术中引用的变量请确保转换为字符串。

2.2 统一代码调用入口(Callout 协同 SDK) ​

本文档涉及的通道变量提取、Redis 模板变量加载、双声道录音启动、上行 HTTP 交互、转人工看门狗与实时盯屏上报,全部统一收拢在 src/business/shared/callout/index.ts 模块下:

  • context.ts:通道变量解析、Redis 初始变量读取、双声道录音启动、通话时长与客户识别文本提取;
  • http-client.ts:拉取流程脚本、NLU 决策/抽取、Chat 流式 SSE、通话结果安全回写;
  • transfer.ts:转人工 ACD 路由、会议多角色实时转写、两阶段坐席看门狗与一键转人工高层封装;
  • live-session.ts:实时盯屏进度上报(postCallProgressSafely)、自动上报转写数组(createReportingTranscript)与进行中会话 Redis 注册表(liveCallSessionRegistry);
  • client.ts:绑定 BusinessContext 的实例工厂 createCalloutClient(ctx)。

支持两种完全等价的调用风格:

  1. 上下文绑定实例风格(推荐):在业务入口调用一次 const callout = createCalloutClient(ctx),后续方法调用免传 ctx;
  2. 独立纯函数风格:按需导入 readCalloutIdentity(ctx)、requestDecision(ctx, ...)、executeHumanTransfer(ctx, ...) 等函数。
交互阶段 / 契约路径createCalloutClient(ctx) 实例方法独立纯函数(src/business/shared/callout)说明
读取外呼核心通道变量(callout_* / script_id)callout.readIdentity()readCalloutIdentity(ctx)提取 { requestId, campaignId, contactId, attemptId, scriptId }
读取坐席/主管入会腿通道变量(conference_id 等)callout.readConferenceGuestIdentity()readCalloutConferenceGuestIdentity(ctx)提取 { conferenceId, role, flags, agentCallRecordId, agentId, direction }
加载 Redis 初始模板变量(callout:vars:{requestId})callout.loadVariables(requestId)loadCalloutVariables(ctx, requestId)非阻塞安全读取任务/联系人预设变量,异常回退 {}
启动外呼双声道分轨录音callout.startRecording(options)startCalloutRecordingSafely(ctx, options)左声道客户 / 右声道 AI,失败仅记日志返回 null
POST /api/scripts/runtimecallout.fetchRuntimeScript(scriptId)fetchRuntimeScript(ctx, scriptId)拉取已发布的流程图 JSON 定义
POST /api/ai/nlu/analyze (action="decision")callout.requestDecision(text, branches, profile)requestDecision(ctx, text, branches, profile, options)多分支意图分类决策
POST /api/ai/nlu/analyze (action="extract")callout.requestExtract(text, fields, profile)requestExtract(ctx, text, fields, profile, options)结构化槽位抽取
POST /api/ai/chat/stream (SSE)callout.requestChatStream(input, onDelta, signal)requestChatStream(ctx, input, onDelta, signal)流式多轮大模型对话,实时回调 onDelta
POST /api/transfers/routecallout.requestTransfer(request)requestHumanAgentTransfer(ctx, request)请求 ACD 预占坐席并向坐席分机发起后台呼叫
看门狗入会监控callout.joinConferenceWithWatchdog(input)joinConferenceWithAgentWatchdog(ctx, input)客户腿入会议室并启动两阶段坐席接听/在会看门狗
会议多角色实时转写callout.startTransferConferenceHear(input)startTransferConferenceHear(ctx, input)区分 customer / agent / supervisor 追加转写并推流盯屏
转人工全流程组合封装(高层 API)callout.executeTransfer(options)executeHumanTransfer(ctx, options)一键串联 ACD 路由 + 会议级多角色转写 + 看门狗入会与清理
POST /api/transfers/endcallout.requestTransferEnd(request)requestHumanAgentTransferEnd(ctx, request)客户挂机后通知 callout-server 批量收尾人工腿
POST /api/call-progresscallout.postProgress(event)postCallProgressSafely(ctx, event)实时上报盯屏状态与分段转写(配合 createReportingTranscript)
POST /api/call-resultscallout.postResult(payload)postCalloutResultSafely(ctx, payload)通话结束 finally 阶段非阻塞安全回写录音、转写与业务结果

上下文初始化代码示例(读取身份变量、模板变量与启动双声道录音) ​

ts
import { createCalloutClient } from "../shared/callout/index.ts";

const callout = createCalloutClient(ctx);

// 1. 一键读取外呼身份变量(直拨测试时各字段可能为 null)
const { requestId, campaignId, contactId, attemptId, scriptId } =
  await callout.readIdentity();

// 2. 从 Redis 加载任务与联系人预设变量(如 customerName、balance 等),Redis 无数据或异常时安全返回 {}
const variables = await callout.loadVariables(requestId);

// 3. 启动双声道分轨录音(左声道客户、右声道机器人),录音失败仅记日志不中断通话
const recording = await callout.startRecording({
  prefix: "my-outbound",
  requestId
});

如果是坐席入会/主管监听腿业务(如 agent-conference-guest-business),则调用 readConferenceGuestIdentity():

ts
const guest = await callout.readConferenceGuestIdentity();
// guest.conferenceId -> "transfer-xxx"
// guest.role         -> "agent" | "supervisor"
// guest.flags        -> ["mute"] (主管静默监听时自动解析为数组)

3. 上行业务接口契约与代码示例(callflow-esl -> callout-server) ​

callflow-esl 的上行请求模块(src/business/shared/callout/http-client.ts,路径常量定义于 src/config/callout-api.ts)统一遵守以下契约:

  • 请求头:自动携带 Content-Type: application/json、X-Callout-Internal-Token、X-Call-UUID(通话 UUID)、X-Request-ID(追踪 ID)。
  • 统一响应信封:除 SSE 接口外,所有 JSON 接口均遵循 { "code": "0000", "data": ..., "msg": "..." } 协议规范。非 0000 视为业务错误。
  • 超时与熔断:请求均绑定 requestTimeoutMs 超时中止信号(AbortSignal)。

3.1 拉取流程脚本运行时定义 ​

  • 路径:POST /api/scripts/runtime
  • SDK 函数:callout.fetchRuntimeScript(scriptId) / fetchRuntimeScript(ctx, scriptId)
  • 调用时机:callout-flow-business 接通后,在开始执行话术前根据通道变量 script_id 拉取。
  • 请求体:{ "scriptId": 12 }(正整数或非空字符串)。
  • 响应体 data 结构:
    json
    {
      "id": 12,
      "name": "信用卡分期外呼",
      "description": "2026年营销流程",
      "flow_json": {
        "nodes": [
          { "id": "node_start", "type": "start" },
          {
            "id": "node_greeting",
            "type": "send",
            "data": { "text": "您好,这里是xx银行", "interruptible": true }
          },
          {
            "id": "node_collect_intent",
            "type": "router",
            "data": {
              "prompt": "请问您最近有大额消费分期需求吗?",
              "decision_profile": "credit_card_intent",
              "branches": [
                { "key": "agree", "desc": "表示同意或需要" },
                { "key": "reject", "desc": "明确拒绝" },
                { "key": "fallback", "desc": "答非所问或未识别" }
              ]
            }
          },
          {
            "id": "node_transfer",
            "type": "transfer",
            "data": {
              "prompt": "正在为您转接人工专属客服,请稍候",
              "ring_timeout_seconds": 30
            }
          },
          {
            "id": "node_end",
            "type": "end",
            "data": { "text": "感谢您的接听,祝您生活愉快,再见!" }
          }
        ],
        "edges": [
          { "source": "node_start", "target": "node_greeting" },
          { "source": "node_greeting", "target": "node_collect_intent" },
          {
            "source": "node_collect_intent",
            "sourceHandle": "agree",
            "target": "node_transfer"
          },
          {
            "source": "node_collect_intent",
            "sourceHandle": "reject",
            "target": "node_end"
          },
          {
            "source": "node_collect_intent",
            "sourceHandle": "fallback",
            "target": "node_end"
          }
        ]
      }
    }

TypeScript 调用示例:

ts
if (!scriptId) {
  throw new Error("缺少通道变量 script_id");
}
const script = await callout.fetchRuntimeScript(scriptId);
ctx.log("script_loaded", {
  scriptId: script.id,
  scriptName: script.name
});

3.2 NLU 语义决策与槽位抽取 ​

  • 路径:POST /api/ai/nlu/analyze
  • SDK 函数:
    • 分支意图决策:callout.requestDecision(text, branches, profile?, options?) / requestDecision(ctx, ...)
    • 结构化槽位抽取:callout.requestExtract(text, fields, profile?, options?) / requestExtract(ctx, ...)

(1) 分支决策请求(action="decision") ​

  • 请求体:
    json
    {
      "action": "decision",
      "profile": "credit_card_intent",
      "text": "行啊,你们分期手续费是多少",
      "branches": [
        { "key": "agree", "desc": "客户同意办理分期" },
        { "key": "inquiry_fee", "desc": "询问费率或手续费" },
        { "key": "reject", "desc": "客户拒绝办理" },
        { "key": "fallback", "desc": "兜底分支" }
      ],
      "systemPrompt": "可选覆盖的系统提示词",
      "temperature": 0.1
    }
  • 响应体 data:
    json
    {
      "key": "inquiry_fee",
      "confidence": 0.95,
      "reason": "客户表达了对分期手续费的询问"
    }

    callflow-esl 校验逻辑:返回的 key 必须严格包含在 branches 的候选列表中;当 confidence <= 0.5 或理由包含“未识别/兜底/超时”时,自动记录 callout_flow_decision_fallback 告警日志。

TypeScript 调用示例(意图分支路由):

ts
const decision = await callout.requestDecision(
  customerText,
  [
    { key: "agree", desc: "客户同意办理或表示感兴趣" },
    { key: "inquiry_fee", desc: "客户询问费率、利息或手续费" },
    { key: "reject", desc: "客户明确拒绝或不需要" },
    { key: "fallback", desc: "答非所问或意图不明确" }
  ],
  "credit_card_intent", // 可选:对应 callout-server 配置的策略 profile
  {
    temperature: 0.1 // 可选:覆盖推理温度或 systemPrompt / requestId
  }
);

switch (decision.key) {
  case "agree":
    // 进入办理或转人工逻辑
    break;
  case "inquiry_fee":
    // 播报费率说明
    break;
  case "reject":
  default:
    // 播报结束语并挂机
    break;
}

(2) 槽位抽取请求(action="extract") ​

  • 请求体:
    json
    {
      "action": "extract",
      "profile": "customer_info_extract",
      "text": "我打算分12期,每个月还2000左右吧",
      "fields": [
        {
          "key": "installment_terms",
          "desc": "分期期数",
          "type": "number",
          "required": true
        },
        {
          "key": "monthly_budget",
          "desc": "月还款预算",
          "type": "number",
          "required": false
        }
      ]
    }
  • 响应体 data:
    json
    {
      "fields": {
        "installment_terms": 12,
        "monthly_budget": 2000
      },
      "confidence": 0.92,
      "complete": true
    }

TypeScript 调用示例(多字段槽位抽取):

ts
const extractResult = await callout.requestExtract(
  customerText,
  [
    {
      key: "installment_terms",
      desc: "分期期数(如 3、6、12、24)",
      type: "number",
      required: true
    },
    {
      key: "monthly_budget",
      desc: "每月还款预算金额(元)",
      type: "number",
      required: false
    }
  ],
  "customer_info_extract"
);

if (extractResult.complete) {
  // 必填槽位均已集齐,合并到会话变量表
  Object.assign(variables, extractResult.fields);
}

3.3 大模型多轮流式对话(SSE) ​

  • 路径:POST /api/ai/chat/stream
  • SDK 函数:callout.requestChatStream(input, onDelta, signal?) / requestChatStream(ctx, input, onDelta, signal?)
  • 请求头:Accept: text/event-stream
  • 调用时机:流程图执行到 chat 自由问答节点或业务需要调用中台托管大模型流式对话时。
  • 请求体:
    json
    {
      "profile": "general_sales_chat",
      "systemPrompt": "你是一名银行客服代表...",
      "exitPrompt": "判断对话是否可以结束...",
      "messages": [
        { "role": "system", "content": "..." },
        { "role": "user", "content": "那如果我提前还款怎么收费?" }
      ],
      "branches": [
        { "key": "continue", "desc": "继续常规问答" },
        { "key": "apply", "desc": "客户明确提出要申请" },
        { "key": "exit", "desc": "客户表示不再需要并结束通话" }
      ],
      "turn": 1
    }
  • SSE 事件流协议:
    • event: delta:增量吐字,触发 onDelta(delta) 回调。
      text
      event: delta
      data: {"delta": "如果提前还款,"}
    • event: complete:对话回合结束,作为 requestChatStream 的 Promise 返回值返回完整应答与分支判定。
      text
      event: complete
      data: {
        "reply": "如果提前还款,我们免收后续分期的手续费,仅收取小额违约金。",
        "outcome": "continue",
        "usage": { "promptTokens": 120, "completionTokens": 32 }
      }
    • event: error:大模型或服务端异常,SDK 自动抛出 Error 供业务捕获降级。

TypeScript 调用示例(流式接收 + 支持客户插话 Abort):

ts
const abortController = new AbortController();

const chatResult = await callout.requestChatStream(
  {
    profile: "general_sales_chat",
    systemPrompt: "你是一名专业温和的银行信用卡客服代表,回答控制在50字以内。",
    exitPrompt: "当客户明确表示立即申请(apply)或不再需要(exit)时结束对话。",
    messages: [
      { role: "user", content: "如果我提前还款怎么收费?" }
    ],
    branches: [
      { key: "continue", desc: "继续常规问答" },
      { key: "apply", desc: "客户明确提出立即申请办理" },
      { key: "exit", desc: "客户表示不需要或希望结束通话" }
    ],
    turn: 1
  },
  delta => {
    // 每收到一段增量文本立即回调,可送入 StreamingSentenceBuffer 切句播报
    sentenceBuffer.push(delta);
  },
  abortController.signal
);

// chatResult.reply   -> 完整回复文本
// chatResult.outcome -> "continue" | "apply" | "exit"

3.4 AI 转人工 ACD 路由与看门狗监控 ​

  • 路径:POST /api/transfers/route
  • 模块实现:src/business/shared/callout/transfer.ts
  • 请求体:
    json
    {
      "conferenceId": "transfer-d75acc71-4043-4abd-b1a2-bc6593ac59fc",
      "requestId": "callout-req-1234",
      "campaignId": 10,
      "contactId": 502,
      "attemptId": 8012,
      "customerNumber": "13800138000",
      "customerCallUuid": "fs-channel-uuid-customer",
      "ringTimeoutSeconds": 30,
      "agentId": null
    }
  • 响应体 data:
    json
    {
      "routed": true,
      "reason": "agent_originated",
      "conferenceId": "transfer-d75acc71-4043-4abd-b1a2-bc6593ac59fc",
      "agent": {
        "id": 101,
        "name": "张客服",
        "extension": "1001"
      },
      "softphoneCallId": 2345,
      "ringTimeoutSeconds": 30
    }

方式一:高层一键转人工 callout.executeTransfer(推荐) ​

绝大多数外呼业务只需调用 callout.executeTransfer(options)(或纯函数 executeHumanTransfer(ctx, options)),它会在内部自动串联:

  1. 调用 POST /api/transfers/route 预占空闲坐席(或指定 agentId)并后台呼叫坐席分机;
  2. 若 routed === true,触发 onRouted 钩子,并开启会议室多角色(客户/坐席/主管)实时 ASR 转写;
  3. 将客户腿移入 FreeSWITCH 会议室并启动两阶段看门狗(阶段一:监控坐席是否在 ringTimeoutSeconds 内接听入会;阶段二:监控坐席离会后自动退会,避免客户被独留在空会议室);
  4. 会议结束后自动关闭会议级转写。
ts
// 播报转接提示音后,一键执行转人工完整闭环
await speakBot("transfer-prompt", "正在为您转接专属人工客服,请稍候。");

const transfer = await callout.executeTransfer({
  requestId,
  campaignId,
  contactId,
  attemptId,
  ringTimeoutSeconds: 30,
  // 传入 transcript 与 relMs,自动将会议室里客户和坐席的对话追加到 transcript(并实时推流盯屏)
  transcribe: {
    transcript,
    relMs
  },
  onRouted: async (_route, targetConferenceId) => {
    // 路由成功后记录会议 ID(供 finally 阶段兜底调用 requestTransferEnd)
    activeConferenceId = targetConferenceId;
    // 若当前开启了客户单呼 ASR,需在进入会议前停止,让位于会议级多角色转写
    await customerAsrSession?.stop("enter-transfer-conference").catch(() => undefined);
  }
});

if (!transfer.agentJoined && !transfer.hungUp) {
  // 坐席全忙或振铃超时未接听,客户已被看门狗安全移出会议室,可播报安抚话术
  await speakBot("transfer-busy", "抱歉,当前人工坐席全忙,稍后我们将安排专员回电给您。");
}

方式二:细粒度分步调用(适用于坐席强插介入失败需回滚恢复 AI 的场景) ​

当业务需要在坐席振铃超时后无缝恢复客户单呼 ASR 并继续由 AI 对话(如 kb-llm-chat-business 中的盯屏坐席强插介入)时,可分步调用底层函数:

ts
const route = await callout.requestTransfer({
  conferenceId,
  requestId,
  campaignId,
  contactId,
  attemptId,
  customerNumber: ctx.channel.destinationNumber ?? null,
  customerCallUuid: ctx.channel.uuid,
  ringTimeoutSeconds: 30,
  agentId: interventionRequest.agentId // 指定预占点击“介入”的那位坐席
});

if (route.routed) {
  const targetConferenceId = route.conferenceId ?? conferenceId;
  await customerAsrSession?.stop("intervention-transfer").catch(() => undefined);

  // 开启会议多角色转写
  const confHear = await callout.startTransferConferenceHear({
    conferenceId: targetConferenceId,
    customerCallUuid: ctx.channel.uuid,
    transcript,
    relMs
  });

  // 客户腿入会并等待看门狗结果
  const watch = await callout.joinConferenceWithWatchdog({
    conferenceId: targetConferenceId,
    ringTimeoutSeconds: route.ringTimeoutSeconds ?? 30
  });

  if (!watch.agentJoined && !watch.hungUp) {
    // 坐席超时未入会:停止会议转写,重启客户单呼 ASR 恢复 AI 接待
    await confHear.stop("agent-join-timeout");
    customerAsrSession = await startCustomerAsr();
  }
}

3.5 客户挂机收尾会议人工腿 ​

  • 路径:POST /api/transfers/end
  • SDK 函数:callout.requestTransferEnd(request) / requestHumanAgentTransferEnd(ctx, request)
  • 调用时机:客户通道挂断(finally 阶段),通知 callout-server 挂断仍在振铃或仍在会议中的坐席/主管分机腿。
  • 请求体:
    json
    {
      "conferenceId": "transfer-d75acc71-4043-4abd-b1a2-bc6593ac59fc",
      "customerCallUuid": "fs-channel-uuid-customer",
      "reason": "customer_hangup"
    }

TypeScript 调用示例(在 finally 中安全收尾):

ts
if (activeConferenceId) {
  await callout.requestTransferEnd({
    conferenceId: activeConferenceId,
    customerCallUuid: ctx.channel.uuid,
    reason: "customer_hangup"
  });
}

3.6 通话结果安全回写 ​

  • 路径:POST /api/call-results
  • SDK 函数:callout.postResult(payload) / postCalloutResultSafely(ctx, payload)
  • 调用时机:通话结束的 finally 阶段。该调用具备非阻塞容错保护(缺少 requestId 时自动跳过,网络或对端错误仅记结构化日志,绝不向外抛错打断通道释放)。
  • 请求体结构:
    json
    {
      "requestId": "callout-req-1234",
      "campaignId": 10,
      "contactId": 502,
      "status": "completed",
      "durationSeconds": 45,
      "hangupCause": "NORMAL_CLEARING",
      "recordingShortPath": "2026-09-25/call-1234.wav",
      "recordingUrl": "/recordings/2026-09-25/call-1234.wav",
      "recognizedText": "我想了解信用卡分期...",
      "payload": {
        "result": {
          "scriptId": 12,
          "finalNodeId": "node_end",
          "variables": {
            "installment_terms": 12,
            "user_intent": "agree"
          },
          "transcript": [
            {
              "speaker": "bot",
              "startMs": 0,
              "endMs": 2500,
              "text": "您好,这里是xx银行"
            },
            {
              "speaker": "customer",
              "startMs": 3100,
              "endMs": 6200,
              "text": "我想了解分期"
            }
          ],
          "routeTrace": [
            { "step": 0, "nodeId": "node_start", "type": "start" },
            { "step": 1, "nodeId": "node_greeting", "type": "send" }
          ],
          "error": null
        }
      }
    }

TypeScript 调用示例:

ts
import {
  extractCustomerRecognizedText,
  inferDurationSeconds
} from "../shared/callout/index.ts";

const endedAt = new Date();
const durationSeconds = inferDurationSeconds(answeredAt, endedAt);
// 仅提取 speaker === "customer" 的有效发言拼接文本,供质检与文本情绪分析消费
const recognizedText = extractCustomerRecognizedText(transcript);

await callout.postResult({
  ...(requestId ? { requestId } : {}),
  ...(campaignId ? { campaignId } : {}),
  ...(contactId ? { contactId } : {}),
  callStatus: outcome === "completed" ? "completed" : "failed",
  hangupCause: resultHangupCause,
  answeredAt: answeredAt.toISOString(),
  endedAt: endedAt.toISOString(),
  durationSeconds,
  ...(recording?.fileName
    ? {
        recordingUrl: recording.fileName,
        recordingDurationSeconds: durationSeconds,
        recordingMimeType: "audio/wav"
      }
    : {}),
  transcript,
  payload: {
    result: {
      type: "my_custom_business",
      outcome,
      errorMessage,
      variables,
      recognizedText: recognizedText || null
    }
  }
});

3.7 实时盯屏进度、分段转写上报与反向控制 ​

  • 路径:POST /api/call-progress
  • 模块实现:src/business/shared/callout/live-session.ts
  • 调用时机:通话接通("started")、每轮对话产生("segment")、人工介入状态变更("intervention")以及通话结束("ended")时实时触发。
  • 请求体:
    json
    {
      "type": "segment",
      "requestId": "callout-req-1234",
      "campaignId": 10,
      "contactId": 502,
      "segment": {
        "speaker": "customer",
        "startMs": 3100,
        "endMs": 6200,
        "text": "手续费怎么算呢?"
      },
      "timestamp": "2026-09-25T09:30:15.123Z"
    }

实时盯屏与反向控制完整代码示例 ​

ts
import {
  createReportingTranscript,
  liveCallSessionRegistry,
  postCallProgressSafely,
  type CallProgressEvent,
  type LiveTranscriptSegment
} from "../shared/callout/index.ts";

// 1. 构造进度事件工厂函数(自动补齐当前通话身份与时间戳)
const progressEvent = (
  type: CallProgressEvent["type"],
  extra: Partial<CallProgressEvent> = {}
): CallProgressEvent => ({
  type,
  ...(attemptId ? { attemptId } : {}),
  ...(requestId ? { requestId } : {}),
  ...(campaignId ? { campaignId } : {}),
  ...(contactId ? { contactId } : {}),
  customerNumber: ctx.channel.destinationNumber ?? null,
  scriptId: null,
  timestamp: new Date().toISOString(),
  ...extra
});

// 2. 在分布式 Redis 中注册实时会话,并异步非阻塞推送 "started" 事件
const liveSession = await liveCallSessionRegistry.register(
  {
    attemptId: attemptId ?? null,
    requestId: requestId ?? null,
    campaignId: campaignId ?? null,
    contactId: contactId ?? null
  },
  ctx.channel.uuid
);
void postCallProgressSafely(ctx, progressEvent("started"));

// 3. 创建带“自动实时上报”钩子的转写数组:
//    每次执行 transcript.push(segment) 时,既会立即向盯屏推送 "segment" 事件,
//    又会把片段保存在本地数组中供挂机 finally 阶段完整回写 /api/call-results。
const transcript: LiveTranscriptSegment[] = createReportingTranscript(segment => {
  void postCallProgressSafely(ctx, progressEvent("segment", { segment }));
});

// 4. 【最佳实践】机器人播报封装:开播即 push 实时回显,播完原地回填 endMs
const speakBot = async (phase: string, text: string): Promise<void> => {
  const startMs = relMs();
  const segment: LiveTranscriptSegment = {
    speaker: "bot",
    startMs,
    endMs: startMs,
    text,
    metadata: { phase }
  };
  // 在 await ctx.speak 之前 push:确保机器人一开口前端盯屏就显示字幕,且客户中途插话时字幕顺序不倒置
  transcript.push(segment);
  try {
    await ctx.speak({ kind: "tts", text, interruptible: true });
    // 播报结束后原地更新对象引用上的 endMs,保证挂机落库时拥有真实播报时长
    segment.endMs = relMs();
  } catch (error) {
    // 播报异常时从本地数组中移除该未播出的片段
    const idx = transcript.indexOf(segment);
    if (idx !== -1) transcript.splice(idx, 1);
    ctx.log("speak_bot_failed", {
      phase,
      error: error instanceof Error ? error.message : String(error)
    });
  }
};

4. 下行控制接口契约(callout-server -> callflow-esl) ​

callout-server 及其代理前端会根据通话所在的 Cell 地址,直接调用 callflow-esl 暴露的控制面接口(默认端口 9912,请求头携带 token: <token>):

接口方法与路径请求体关键字段作用与 ESL 内部执行行为
GET /health无探活与容量感知:返回 { "code": "0000", "data": { "activeSessions": 8 }, "msg": "请求成功" },供 callout-server 派发调度器评估当前节点的剩余并发槽位。
POST /live-call-sessions/intervene{ "sessionKey": "...", "agentId": 101, "agentName": "..." }人工介入强插:坐席在盯屏页点击“介入”,向该通话注入强插介入信号,触发业务将客户从当前话术转入人工会议室。
POST /live-call-sessions/intervention/cancel{ "sessionKey": "...", "agentId": 101 }取消待处理介入:坐席取消尚未被业务消费的介入请求;若业务已进入转接则返回 HTTP 409 冲突。
POST /live-call-sessions/hangup{ "sessionKey": "..." }主管强拆挂断:下发 NORMAL_CLEARING 挂机信号,由持有客户通道的业务实例执行强制挂断并触发正常收尾。
POST /softphone-sessions/create{ "targetNumber": "...", "serviceNumber": "..." }创建浏览器软电话会话:初始化 BrowserSoftphoneSessionHub 实例,分配会议并管理 WebRTC/SIP 通道。
GET /softphone-sessions/eventsSSE 请求(Query 参数 ?sessionId=xxx)软电话实时事件流:向前端代理推送转写、TTS 状态与通道状态变更事件。
POST /softphone-sessions/messages{ "sessionId": "...", "text": "..." }下发文本与 TTS:坐席在工作台键入文本,驱动 ESL 执行 TTS 合成并向客户通道混音播放。
POST /softphone-sessions/controls{ "sessionId": "...", "type": "hold" }坐席会话控制:执行静音、解静音、踢出成员等会控操作。
POST /softphone-sessions/end{ "sessionId": "...", "reason": "..." }销毁软电话会话:挂断双方通话并释放软电话会话内存。

5. 完整端到端外呼业务开发样板 ​

以下提供一份可直接复制使用的自定义外呼业务完整样板,串联了接通应答、身份与 Redis 变量读取、实时盯屏注册、双声道录音、开播即回显的 speakBot、NLU 意图决策、一键转人工、主管强拆轮询以及 finally 安全回写:

ts
import { defineBusiness } from "../../types/business.ts";
import {
  createCalloutClient,
  createReportingTranscript,
  extractCustomerRecognizedText,
  inferDurationSeconds,
  liveCallSessionRegistry,
  type CallProgressEvent,
  type LiveTranscriptSegment
} from "../shared/callout/index.ts";

export const customOutboundBusiness = defineBusiness({
  code: "custom-outbound-business",
  category: "outbound",
  desc: "自定义外呼业务标准样板(含盯屏上报、NLU 决策、一键转人工与结果回写)",
  handler: async ctx => {
    // 1. 应答呼叫并等待媒体稳定
    await ctx.execute("answer");
    await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

    const answeredAt = new Date();
    const answeredAtMs = answeredAt.getTime();
    const relMs = (): number => Math.max(0, Date.now() - answeredAtMs);

    // 2. 初始化 Callout 协同客户端,读取身份变量与 Redis 预设变量
    const callout = createCalloutClient(ctx);
    const { requestId, campaignId, contactId, attemptId } =
      await callout.readIdentity();
    const variables = await callout.loadVariables(requestId);

    // 3. 注册实时盯屏会话并创建自动推流 transcript 数组
    const progressEvent = (
      type: CallProgressEvent["type"],
      extra: Partial<CallProgressEvent> = {}
    ): CallProgressEvent => ({
      type,
      ...(attemptId ? { attemptId } : {}),
      ...(requestId ? { requestId } : {}),
      ...(campaignId ? { campaignId } : {}),
      ...(contactId ? { contactId } : {}),
      customerNumber: ctx.channel.destinationNumber ?? null,
      scriptId: null,
      timestamp: new Date().toISOString(),
      ...extra
    });

    const liveSession = await liveCallSessionRegistry.register(
      {
        attemptId: attemptId ?? null,
        requestId: requestId ?? null,
        campaignId: campaignId ?? null,
        contactId: contactId ?? null
      },
      ctx.channel.uuid
    );
    void callout.postProgress(progressEvent("started"));

    const transcript: LiveTranscriptSegment[] = createReportingTranscript(
      segment => {
        void callout.postProgress(progressEvent("segment", { segment }));
      }
    );

    // 4. 启动双声道分轨录音
    const recording = await callout.startRecording({
      prefix: "custom-outbound",
      requestId
    });

    let outcome: "completed" | "failed" = "completed";
    let errorMessage: string | null = null;
    let resultHangupCause = "NORMAL_CLEARING";
    let hangupIssued = false;
    let activeConferenceId: string | null = null;

    // 5. 封装机器人播报(开播即 push 实时回显,播完原地更新 endMs)
    const speakBot = async (phase: string, text: string): Promise<void> => {
      const startMs = relMs();
      const segment: LiveTranscriptSegment = {
        speaker: "bot",
        startMs,
        endMs: startMs,
        text,
        metadata: { phase }
      };
      transcript.push(segment);
      try {
        await ctx.speak({ kind: "tts", text, interruptible: true });
        segment.endMs = relMs();
      } catch (error) {
        const idx = transcript.indexOf(segment);
        if (idx !== -1) transcript.splice(idx, 1);
        ctx.log("custom_outbound_speak_failed", {
          phase,
          error: error instanceof Error ? error.message : String(error)
        });
      }
    };

    // 6. 启动后台定时轮询:响应运营台下发的「主管强拆挂断」信号
    const controlTimer = setInterval(() => {
      if (hangupIssued) return;
      void (async () => {
        const hangup = await liveCallSessionRegistry.consumeHangup(liveSession);
        if (hangup && !ctx.getHangupInfo()) {
          hangupIssued = true;
          resultHangupCause = hangup.cause;
          await ctx.hangup(hangup.cause);
        }
      })().catch(() => undefined);
    }, 1000);

    try {
      // 7. 播报开场白并听取客户回答
      const customerName =
        typeof variables.name === "string" ? variables.name : "先生/女士";
      await speakBot(
        "opening",
        `您好${customerName},这里是客服中心,请问您现在方便接听吗?`
      );

      const hearStartMs = relMs();
      const hearResult = await ctx.hear({
        noSpeechTimeoutMs: 10000,
        noTextTimeoutMs: 15000
      });
      const customerText = hearResult.text.trim();

      if (customerText) {
        transcript.push({
          speaker: "customer",
          startMs: hearStartMs,
          endMs: relMs(),
          text: customerText,
          metadata: { status: hearResult.status }
        });

        // 8. 调用 NLU 意图决策
        const decision = await callout.requestDecision(customerText, [
          { key: "agree", desc: "方便接听或同意进一步沟通" },
          { key: "transfer", desc: "要求转人工客服" },
          { key: "reject", desc: "不方便或拒绝" },
          { key: "fallback", desc: "其他未明确意图" }
        ]);

        if (decision.key === "transfer" || decision.key === "agree") {
          await speakBot("transfer", "正在为您转接专属顾问,请稍候。");
          const transfer = await callout.executeTransfer({
            requestId,
            campaignId,
            contactId,
            attemptId,
            ringTimeoutSeconds: 30,
            transcribe: { transcript, relMs },
            onRouted: (_route, targetConferenceId) => {
              activeConferenceId = targetConferenceId;
            }
          });
          if (!transfer.agentJoined && !transfer.hungUp) {
            await speakBot("busy", "抱歉,当前坐席全忙,稍后将安排专员回电。");
          }
        } else {
          await speakBot("goodbye", "打扰您了,祝您生活愉快,再见。");
        }
      }
    } catch (error) {
      outcome = "failed";
      errorMessage = error instanceof Error ? error.message : String(error);
      ctx.log("custom_outbound_failed", { error: errorMessage });
    } finally {
      // 9. 终态清理与安全回写(无论正常结束或异常中断必然执行)
      clearInterval(controlTimer);
      await liveCallSessionRegistry
        .unregister(liveSession)
        .catch(() => undefined);
      void callout.postProgress(progressEvent("ended", { outcome }));

      if (activeConferenceId) {
        await callout.requestTransferEnd({
          conferenceId: activeConferenceId,
          customerCallUuid: ctx.channel.uuid,
          reason: "customer_hangup"
        });
      }

      const endedAt = new Date();
      const durationSeconds = inferDurationSeconds(answeredAt, endedAt);
      const recognizedText = extractCustomerRecognizedText(transcript);

      await callout.postResult({
        ...(requestId ? { requestId } : {}),
        ...(campaignId ? { campaignId } : {}),
        ...(contactId ? { contactId } : {}),
        callStatus: outcome === "completed" ? "completed" : "failed",
        hangupCause: resultHangupCause,
        answeredAt: answeredAt.toISOString(),
        endedAt: endedAt.toISOString(),
        durationSeconds,
        ...(recording?.fileName
          ? {
              recordingUrl: recording.fileName,
              recordingDurationSeconds: durationSeconds,
              recordingMimeType: "audio/wav"
            }
          : {}),
        transcript,
        payload: {
          result: {
            type: "custom_outbound",
            outcome,
            errorMessage,
            variables,
            recognizedText: recognizedText || null
          }
        }
      });

      if (!hangupIssued) {
        await ctx.hangup();
      }
    }
  }
});

6. 分布式协同与容灾兜底策略 ​

  1. 结果回写兜底(No Call Trapping):
    • 结果回写(postCalloutResultSafely / callout.postResult)位于通话 finally 块中。缺少 requestId 或网络超时不抛出异常,防止干扰 FreeSWITCH 通道 hangup 与录音文件闭环。
  2. 看门狗防孤儿会话(Watchdog & Auto-cleanup):
    • 客户转人工时,若坐席分机呼叫超时(默认 30 秒)未应答,看门狗触发自动退会,向客户播报“坐席全忙”并安全挂机,杜绝客户被孤立在静音会议室的现象。
    • 客户率先挂机时,ESL 异步触发 POST /api/transfers/end(callout.requestTransferEnd),通知 callout-server 挂断残留的坐席/主管分机腿。
  3. 流程节点异常降级(Graceful Fallback):
    • 当 NLU(decision / extract)或 Chat 服务不可用时,系统自动捕捉异常并导向节点配置的 fallback 分支;若未显式配置连线,则进入优雅播报收尾,避免挂死呼叫。
  4. 共享状态生命周期(Redis TTL):
    • 盯屏快照与会话注册表全部赋予 Redis TTL;即便进程意外终止,快照也会在设定的 TTL 毫秒后自动消除,防止产生“幽灵通话”。

文档与代码在同一仓库维护,现有 Markdown 是唯一内容源。