外观
转人工业务示例
AI 外呼通话转人工,用的是会议室桥接模型:把客户腿放进一个 FreeSWITCH 会议室,再把坐席分机 也 originate 进同一会议室,两边在会议里通话。这样后续「加人/减人/换人」(盲转、协商转、主管监听/强插) 都能在同一个会议室上动态做,互不影响。
阅读前请先读 integration.md 与 outbound-business-example.md。
1. 角色与组件
| 组件 | 位置 | 职责 |
|---|---|---|
| AI 外呼业务 | callflow-esl *-business.ts | 识别转人工意图、生成会议室 ID、请求路由、把客户腿入会、回退 |
| 转人工 helper | callflow-esl business/shared/human-transfer.ts | 封装 POST /api/transfers/route 调用 |
| 路由入口 | callout-server handleTransferRoute | 预占空闲坐席 + originate 坐席入会 + 登记会议会话 |
| 坐席预占 | callout-server services/agent-routing.ts | Redis 锁 + 行锁原子选坐席、置 busy / 复位 idle |
| 会议会话 | callout-server services/conference-sessions.ts + conference_sessions 表 | 会话事实源(会议室、客户腿、主坐席) |
| 坐席入会业务 | callflow-esl agent-conference-guest-business.ts | 坐席接通后 ctx.conference.join 进会议室 |
| 坐席间流转 | callout-server handlers/conference-sessions.ts | 加/踢成员(盲转、协商转、主管监听/强插) |
| 兜底对账 | callout-server services/conference-reconcile.ts | 关闭僵尸会话、释放卡住坐席 |
2. 端到端流程
AI业务: 识别"转人工"
│ 1. 生成 conferenceId = transfer-<uuid>,播报"正在转接",停掉自己的 ASR
▼
AI业务 ──POST /api/transfers/route──▶ callout-server
│ │ 2. reserveIdleAgent(): Redis锁+行锁 原子预占在线坐席,置 busy
│ │ 3. bgapi originate user/<分机> &socket(callflow-esl)
│ │ {business_code=agent-conference-guest, conference_id, callout_*}
│ │ 4. openConferenceSession(): 登记 conference_sessions(主坐席=该坐席)
│ ◀──── { routed, agent, conferenceId, softphoneCallId } ────
│ 5. routed=true → 客户腿 ctx.conference.join({ conferenceId })
│ routed=false(no_agent) → 播报"稍后联系您" + 挂机或按业务策略收尾
▼
FreeSWITCH 接通坐席分机 → 回连 callflow-esl → agent-conference-guest 业务
│ 6. answer → 读 conference_id → ctx.conference.join({ conferenceId, flags })
▼
客户与坐席在同一会议室通话 ✅
│ 7. 看门狗:振铃超时后若会议成员<2 → 改判未接通、播报、挂机
│ 8. 末位坐席离开/客户挂断 → 挂机事件 或 conferenceReconcile 收尾、releaseAgent3. AI 业务侧:请求路由 + 入会 + 回退
3.1 调用路由 helper
business/shared/human-transfer.ts 把路由请求封装好,失败一律归一化为 { routed: false, reason }, 让业务安全回退:
ts
import { requestHumanAgentTransfer } from "./shared/human-transfer.ts";
const route = await requestHumanAgentTransfer(ctx, {
conferenceId, // 业务生成的会议室 ID
requestId: calloutRequestId ?? undefined,
campaignId: campaignId ?? undefined,
contactId: contactId ?? undefined,
customerNumber: ctx.channel.destinationNumber ?? null,
customerCallUuid: ctx.channel.uuid, // 客户A腿UUID,供末位坐席离开时收尾
ringTimeoutSeconds: 30, // 坐席分机振铃超时
});helper 内部:从 ctx.config.callout.calloutServer.baseUrl 构造 /api/transfers/route,带 X-Callout-Internal-Token(= calloutServer.token),POST JSON,把响应 data 原样返回 (HumanTransferRouteResult):
ts
interface HumanTransferRouteResult {
routed: boolean; // FreeSWITCH 是否已接受坐席后台 originate job
reason?: string; // no_agent / originate_failed / not_configured / http_xxx / request_error
conferenceId?: string;
agent?: { id: number; name: string; extension: string | null } | null;
softphoneCallId?: number;
ringTimeoutSeconds?: number;
}3.2 完整 transferToAgent(精简自 demo 业务)
ts
const transferToAgent = async (): Promise<void> => {
if (shuttingDown) return;
shuttingDown = true;
await speakTurn("transfer", { kind: "tts", text: "好的,正在为您转接人工坐席,请稍候。" });
await asrSession.stop("transfer-to-agent"); // 转人工后不再驱动 AI 流程
const route = await requestHumanAgentTransfer(ctx, {
conferenceId, requestId: calloutRequestId ?? undefined,
campaignId: campaignId ?? undefined, contactId: contactId ?? undefined,
customerNumber: ctx.channel.destinationNumber ?? null,
customerCallUuid: ctx.channel.uuid, ringTimeoutSeconds: 30,
});
if (!route.routed) { // 无坐席/提交失败 → 回退
outcome = { outcomeCode: "completed", outcomePriority: "B", outcomeCategory: "transfer_failed",
prompt: TRANSFER_UNAVAILABLE_PROMPT, resultSummary: `要求转人工但未接入(${route.reason})` };
await speakTurn("transfer-unavailable", { kind: "tts", text: TRANSFER_UNAVAILABLE_PROMPT });
await ctx.hangup("NORMAL_CLEARING");
return;
}
outcome = { outcomeCode: "transferred", outcomePriority: "A", outcomeCategory: "transfer_requested",
prompt: TRANSFER_PROMPT, resultSummary: `已路由到坐席 ${route.agent?.name}(会议 ${conferenceId})` };
// 看门狗:振铃超时(+5s)后若会议成员 < 2,说明坐席没进来,改判未接通并挂机以中断 join 阻塞
const watchdog = setTimeout(() => void (async () => {
const reply = await ctx.api(`conference ${conferenceId} count`);
if (Number.parseInt(reply.trim(), 10) >= 2) return;
outcome = { outcomeCode: "completed", outcomePriority: "B", outcomeCategory: "transfer_failed",
prompt: TRANSFER_TIMEOUT_PROMPT, resultSummary: "坐席振铃超时未接,本次未接通" };
await ctx.conference.play({ conferenceId, input: { kind: "tts", text: TRANSFER_TIMEOUT_PROMPT } }).catch(() => undefined);
await ctx.hangup("NORMAL_CLEARING");
})(), (30 + 5) * 1000);
try {
await ctx.conference.join({ conferenceId }); // 客户腿入会,阻塞至退出会议
} finally {
clearTimeout(watchdog);
}
};要点:
- 业务自己生成
conferenceId(如transfer-<uuid>),客户腿和坐席腿据它汇合。 routed=true只代表坐席后台 originate job 已被接受,不代表坐席已接听。坐席是否真进会议室 由看门狗用conference <id> count兜底(成员 < 2 视为没进来)。- 转人工成功后业务终态为
transferred,最终仍会照常POST /api/call-results回写。
4. callout-server 侧:POST /api/transfers/route
内部接口(token 鉴权,与 /api/call-results 同款;非会话鉴权)。请求体:
jsonc
{
"conferenceId": "transfer-xxxx", // 必填
"requestId": "<callout_request_id>",
"campaignId": 1, "contactId": 100, "attemptId": 555,
"customerNumber": "13800138000",
"customerCallUuid": "<客户A腿UUID>",
"ringTimeoutSeconds": 30
}响应 data(注意:即使无坐席/失败也返回 ok:true,用 routed 区分):
jsonc
{ "routed": true, "conferenceId": "transfer-xxxx",
"agent": { "id": 7, "name": "坐席A", "extension": "1007" },
"softphoneCallId": 88, "ringTimeoutSeconds": 30 }
// 无坐席:{ "routed": false, "reason": "no_agent", "conferenceId": "...", "agent": null }处理逻辑(handleTransferRoute):
reserveIdleAgent(deps)原子预占一个空闲在线坐席(见下)。无坐席 → 记agent_transfer_no_agent运行事件,返回routed:false, reason:no_agent。- 预生成
jobUuid与channelUuid,插一条agent_call_records(direction=transfer,status=requested),并把两个 UUID 与conferenceId一并写入 payload。 submitAgentConferenceCall后台 originate 坐席分机入会 (business_code=agent-conference-guest、conference_id、origination_uuid=channelUuid、call_timeout=ringTimeoutSeconds等)。- originate 被接受 →
openConferenceSession登记conference_sessions(主坐席 = 该坐席、客户腿 UUID); 失败 →releaseAgent复位坐席。 - 把转人工信息并入
call_attempts.payload.result.transferToAgent,记agent_transfer_routed运行事件。
客户挂机收尾:POST /api/transfers/end
kb-audio-chat-business 在 /route 成功后保存会议 ID;客户腿进入 finally 时,使用 callout.calloutServer.baseUrl 固定构造 /api/transfers/end,复用该服务 token 与超时发送:
json
{ "conferenceId": "transfer-xxxx", "customerCallUuid": "<客户A腿UUID>", "reason": "customer_hangup" }接口校验客户 UUID 与会议会话一致,重复请求或已结束会话幂等返回成功。首次收尾会先关闭 会议会话,再将该会议全部 requested/submitted/answered 坐席和主管腿标记终止:接通腿为 completed,未接通腿为 failed,挂机原因为 ORIGINATOR_CANCEL;随后释放忙碌坐席并对 预生成的 channelUuid 执行 uuid_kill。事件监听同时订阅 CHANNEL_CREATE:如果通道晚于 收尾创建,会读取 terminationRequested=customer_hangup 再次立即挂断;晚到的 CHANNEL_ANSWER 不能把已取消记录推进回 answered。
因此客户在坐席振铃、坐席已接听入会、协商转新增坐席或主管监听任一阶段挂机,会议内全部 人工 SIP 腿都会收到 BYE。反向的“末位坐席离开时挂断客户腿”规则仍保留。
坐席预占(agent-routing.ts)
reserveIdleAgent 用 Redis 锁(跨实例)+ FOR UPDATE SKIP LOCKED(库内) 保证两通转人工不会抢到同一坐席:
- 候选条件:启用、未软删、状态为
idle(示闲)、配置了非空分机。滚动升级期间查询侧仍兼容尚未归一的历史online数据。 - 排序:
lastStatusAt最久空闲优先。 - 命中后立刻置
busy,下次预占不会再选到它。 releaseAgent:坐席没有其它进行中的转接软电话腿时,把busy复位idle(由坐席腿挂断/振铃超时触发,避免一次未接通长期卡 busy)。
5. 坐席入会业务 agent-conference-guest
坐席分机被 originate 接通后,FreeSWITCH 按 business_code=agent-conference-guest 路由到这个业务。 它只做一件事:读会议室 ID 并 join。完整源码:
ts
const handler: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
const conferenceId = await ctx.getVariable("conference_id");
if (!conferenceId) { // 缺参数兜底,避免空 join 抛错
await ctx.speak({ kind: "tts", text: "转接参数缺失,无法加入会议。" });
await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
return;
}
// callout-server 透传角色与会议 flags(主管静默监听 = mute)
const role = await ctx.getVariable("callout_conference_role");
const flagsRaw = await ctx.getVariable("callout_conference_flags");
const flags = flagsRaw ? flagsRaw.split("|").map((f) => f.trim()).filter(Boolean) : [];
await ctx.conference.join({ conferenceId, ...(flags.length > 0 ? { flags } : {}) });
};
export const agentConferenceGuestBusiness = defineBusiness({
code: "agent-conference-guest",
desc: "坐席入会(AI 转人工)业务",
handler,
});这个业务对「首次转人工 / 后续坐席间流转 / 主管监听」是通用的——加成员只是再 originate 一条坐席腿 进同一会议室,业务无需改动。
6. 坐席间流转(监控台/坐席操作)
围绕同一个会议室做成员动态进出。
6.1 查看会话
GET /api/conference-sessions(需campaign:dispatch):进行中会话列表,每条附 FreeSWITCH 实时成员数liveMemberCount与liveStatus(live/ended/unknown,只读校准,不写库)。GET /api/conference-sessions/:conferenceId:会话详情 + 成员腿(participants: SoftphoneCallRow[])。
6.2 加成员
POST /api/conference-sessions/:conferenceId/participants
jsonc
// 协商转/三方:加新坐席,主坐席不变(primary 不动)
{ "role": "agent", "agentId": 9 }
// 盲转:加新坐席成功后踢掉旧主坐席,并把 primary 切到新坐席
{ "role": "agent", "agentId": 9, "replacePrimary": true }
// 从空闲池自动选坐席(不指定 agentId)
{ "role": "agent" }
// 主管静默监听(mute)
{ "role": "supervisor", "agentId": 2, "mode": "monitor" }
// 主管强插(可发言)
{ "role": "supervisor", "agentId": 2, "mode": "barge" }语义:
role=agent(默认,需agent:operate):指定agentId走reserveSpecificAgent,否则从空闲池reserveIdleAgent预占。replacePrimary=true= 盲转;否则 = 协商转/三方。role=supervisor(需campaign:dispatch):必须带agentId,不占用坐席池(可旁听多路);mode=monitor→ 以muteflag 静默监听;mode=barge→ 正常可发言。- 实现:插
agent_call_records→submitAgentConferenceCall后台 originate 坐席分机入会 (固定business_code=agent-conference-guest),FreeSWITCH 接受 job 后即返回。成员记录 同样预存channelUuid;落库后会再次确认会话仍为 active,客户已收尾时不再提交 originate。 - 响应:
{ added: true, conferenceId, role, agent, softphoneCallId, replacedSoftphoneCallId }; 无坐席/提交失败时{ added: false, reason: "no_agent"|"agent_unavailable"|"originate_failed"|... }。 - 记
conference_participant_added运行事件。
6.3 踢成员
DELETE /api/conference-sessions/:conferenceId/participants/:softphoneCallId(需 agent:operate): 对该成员腿 uuid_kill。用于协商转完成(移除旧主坐席)、取消(移除新坐席)、主管退出。 移除的若是当前主坐席,会自动把 primary 提升为该会议剩余的最新一条 answered 坐席腿(协商转完成后归属正确)。 记 conference_participant_removed 运行事件。
6.4 典型操作组合
- 盲转:加成员
{role:agent, agentId, replacePrimary:true}→ 旧主坐席被自动踢、primary 切新坐席。 - 协商转:先
{role:agent, agentId}(三方,原坐席介绍)→ 介绍完踢掉旧主坐席(DELETE 旧 softphoneCallId)。 - 主管监听:
{role:supervisor, agentId, mode:"monitor"};需要插话时再{...mode:"barge"}或退出后重进。
7. 坐席工作台触发的转人工 / 回拨 / 呼出
除 AI 自动转人工(第 2–6 节的会议模型)外,坐席也可在工作台主动发起通话(均需 agent:operate,落 agent_call_records)。 注意这一组与 AI 会议转人工是两套机制:它们经 submitAgentBridgeCall —— 先 originate 坐席分机、 接通后 &bridge('<客户拨号串>') 直接桥接客户号码,是对客户发起的新一通桥接呼叫, 并不接管正在进行的 AI 会议。
| 接口 | 方向 | 行为 |
|---|---|---|
POST /api/agents/:id/transfers | transfer | 对指定 attemptId 对应的客户号码发起坐席↔客户桥接呼叫;在 call_attempts.payload.result.transferToAgent 登记转接标记 |
POST /api/agents/:id/callbacks | callback | 对联系人发起人工回拨:originate 坐席分机、接通后 &bridge 客户号码 |
POST /api/agents/:id/manual-calls | manual_call | 坐席对任意客户号码人工呼出(同样 originate 坐席→&bridge 客户) |
POST /api/agents/:id/transfers 请求体:{ "attemptId": 555, "note": "..." }。
选型:通话正在进行中要把人工接进来用 AI 会议转人工(
/api/transfers/route+ctx.conference.join); 事后/工作台要由坐席重新接触该客户用这一组桥接接口。
8. 僵尸会话兜底
如果客户先挂断且事件丢失,会议在 FreeSWITCH 已结束但库里仍 active。后台 conferenceReconcile worker (默认每 30s、graceMs 60s 宽限)用 conference <id> list count 校准:成员数为 0 且过宽限期, 就回收卡住的坐席腿、挂断残留客户腿、关闭会话并记 conference_session_reconciled 运行事件。 FreeSWITCH 不可达时整轮跳过,绝不误关。
9. 检查清单
- [ ] callflow-esl 配了
callout.calloutServer.baseUrl / token / requestTimeoutMs。 - [ ] AI 业务自己生成
conferenceId,请求路由时带customerCallUuid(末位坐席离开收尾要用)。 - [ ]
routed=false有回退分支(播报稍后联系 + 挂机或按业务策略收尾),不要硬等。 - [ ] 用看门狗 +
conference <id> count处理坐席振铃超时未接。 - [ ]
agent-conference-guest业务已注册(business-registry.ts+db:push)。 - [ ] 坐席在 callout-server 配了正确的
phoneExtension,且 FreeSWITCH 能user/<分机>拨到。 - [ ] 转人工成功后业务仍照常回写
/api/call-results(如在payload.result.demo.outcomeCode标记transferred)。