跳转至

phone-agent

第9章 · 多模态与实时交互 · 配套项目 chapter9/phone-agent

项目说明

实验 9-2:使用 PineClaw Voice API 构建电话 Agent

配套《深入理解 AI Agent》第 9 章实验 9-2。

目的

真实世界里很多 Agent 任务离不开拨打真实电话——联系客服协商账单、预约餐厅、 确认订单。本实验演示语音 Agent 的一个重要应用方向:Agent 不仅能与用户语音对话, 还能代替用户与外部世界进行电话交互

上层是一个标准的 ReAct Agent:接到一个自然语言任务(如"打电话给宽带客服, 查询本月账单为何多扣了 50 元并要求解释"),它自己想清楚要拨的号码、通话目标和 上下文,调用 make_phone_call 工具完成整段通话,读取返回的结构化通话记录, 必要时追问/再拨,最后向用户汇报结果。

电话语音 API 的抽象

生产级电话语音 API(如 PineClaw Voice API,作者团队开发) 把一整通电话封装成一次工具调用

record = make_phone_call(phone_number, goal, context)

你只提供三样东西——号码、目标、上下文——它的语音 Agent 就会自动完成:

  • 拨号:接通被叫方;
  • IVR 导航:应对"查询请按 1,转人工请按 0"这类按键菜单;
  • 多轮对话:接通人工后围绕目标交涉、追问、确认关键信息;
  • 转录:把整段通话转成文字。

最后返回一份结构化通话记录,而不是一段裸录音。这正是它能塞进 ReAct 循环的原因: Agent 拿到的是结构化字段(是否达成目标、抽取的关键信息、逐轮 transcript),可以直接 据此决策与汇报。本实验返回体的形状(见 pine_voice.pyCallRecord):

字段 含义
call_id 通话唯一 ID
phone_number / goal 本次通话的号码与目标
status / goal_achieved 通话状态 / 是否达成目标
duration_seconds 通话时长
summary 一句话通话摘要
key_fields 抽取的关键信息(扣费原因 / 金额 / 确认号 / 时间…)
transcript 逐轮对话 [{speaker, text}, ...]
follow_up_needed / follow_up_reason 是否仍需追问及原因

关于 mock(重要)

真实 PineClaw Voice API 需要 PINECLAW_API_KEY 并会拨打真实电话号码。为便于离线跑通, 本实验用一个本地模拟客户端替代真实 APIpine_voice.py):

  • 不接触真实电话网络,也不需要 PineClaw key
  • make_phone_call 内部用 OpenAI 扮演被叫方——先当自动 IVR 语音菜单,被"转人工" 后再扮演人工客服——与去电的语音 Agent 进行一段多轮对话(模拟 IVR 导航 + 客服应答), 然后把 transcript 归纳成上表的结构化记录;
  • 关键在于:模拟客户端与真实 API 的输入/输出契约完全一致,因此上层 ReAct Agent 的代码在切换到真实 PineClaw SDK 时几乎无需改动。

所以本实验里出现的"扣费原因""确认号"等都是模型即时编造的模拟情节,仅用于演示 数据流,不代表任何真实通话。

真实接入 PineClaw

agent.py 里对模拟 make_phone_call 的调用替换为真实 SDK 即可,其余逻辑不变:

# pip install pine-voice
from pine_voice import PineVoiceClient   # 真实 SDK(示意)

client = PineVoiceClient(api_key=os.environ["PINECLAW_API_KEY"])

def make_phone_call(phone_number, goal, context=""):
    call = client.calls.create(to=phone_number, goal=goal, context=context)
    result = call.wait()          # 阻塞直到通话结束(可能是分钟级到小时级)
    return result.to_dict()       # 返回同形状的结构化通话记录

真实使用请以 PineClaw 官方文档为准;建议先拨打自己的手机验证连通性。

运行

cd chapter9/phone-agent
pip install -r requirements.txt

cp env.example .env
# 编辑 .env,填入 OPENROUTER_API_KEY 或 OPENAI_API_KEY(至少其一)

python demo.py
python demo.py --task "帮我打电话给餐厅订今晚 7 点 4 人的位子"   # 自定义电话任务
python demo.py --dry-run                                       # 离线跑通,无需任何 API Key
python demo.py --help                                          # 查看全部参数

命令行参数(python demo.py --help 有中文说明):

参数 作用
--task 自定义电话任务(自然语言)。默认用书中的宽带账单示例
--phone 可选:对方电话号码。作为已知信息交给 Agent(dry-run 下直接用作被叫号码)
--goal 可选:明确的通话目标。作为已知信息交给 Agent(dry-run 下直接用作通话目标)
--model 可选:覆盖模型(默认取 OPENAI_MODEL,即 gpt-5.6-luna
--dry-run 离线脚本模式:不联网、不需要任何 API Key,仅演示 ReAct 循环与数据契约的形状

demo.py 会真实调用 OpenAI(除非加 --dry-run),打印三段内容: (a) ReAct Agent 的轨迹(思考 + 发起 make_phone_call); (b) 返回的结构化通话记录(多轮 transcript + 是否达成目标 + 关键字段); (c) Agent 基于通话结果向用户的最终汇报。

模型与回退:默认聊天模型 gpt-5.6-luna。解析优先级为 OPENAI_API_KEY(可选 OPENAI_BASE_URL 指向兼容网关,如 Moonshot kimi-k3 / 火山方舟) > OPENROUTER_API_KEY(自动把模型映射为 openai/gpt-5.6-lunaprovider/model 形式)。 由于 gpt-5.6* 直连 OpenAI 需组织实名认证,推荐用 OpenRouter;只需在 .env 里填 OPENROUTER_API_KEY 即可(此时不要设 OPENAI_API_KEY,否则会优先直连)。

两级「模拟」的区别

本实验里有两层各自独立的模拟,别混淆:

  • 默认(mock)pine_voice.py 替换掉真实电话网络,但 ReAct Agent 与被叫方对话 仍由 OpenAI 实时生成——所以需要 OPENAI_API_KEY,每次对话/字段都不同。
  • --dry-run(离线脚本):连 LLM 也不调用,make_phone_call 直接返回一份固定脚本的 结构化通话记录。用于在没有任何 API Key、完全离线时也能把整条 ReAct 循环 (思考 → 调用工具 → 读结构化记录 → 汇报)跑通、看清其形状。脚本里的确认号由目标哈希派生, 可复现,不代表任何真实通话

预期输出示例(真实节选)

[Agent 调用工具 make_phone_call] 入参:
    phone_number = 10010
    goal         = 查询本月宽带账单多扣的50元原因,并要求处理误扣。
    context      = 宽带账号 hz-88231

[PineClaw 返回结构化通话记录]
  状态           : completed  |  是否达成目标: True
  摘要           : 用户成功查询到宽带账单多扣50元的原因并申请了退款。
  关键字段(key_fields):
      - 扣费原因: 系统自动调整套餐费用
      - 涉及金额: 50元
      - 确认号: RW20231015
      - 处理结果: 退款申请已成功提交
  通话转录(transcript):
      << [被叫方] 欢迎致电客服热线!账单查询请按 1,业务办理请按 2,人工服务请按 0。
      >> [语音Agent] 我按 0 转人工。
      << [被叫方] 您好,我是客服代表,工号12345,请问有什么可以帮助您的?
      >> [语音Agent] 你好,我发现宽带账单多扣了50元,能帮我查一下原因吗?账号是 hz-88231。
      ...(多轮交涉、核对、给出确认号)...

Agent 向用户的最终汇报:
  已成功拨打客服热线并查询原因:扣费原因=系统自动调整套餐费用;已提交退款,确认号 RW20231015。

注意:IVR 菜单、工号、确认号等都是模型即时编造的模拟情节(见「关于 mock」), 仅演示数据流;每次运行的对话与字段会不同,但「IVR 导航 → 转人工多轮交涉 → 结构化 记录 → Agent 汇报」的形状稳定复现。

文件说明

文件 作用
pine_voice.py PineClaw Voice API 的本地模拟客户端,提供 make_phone_call 工具
agent.py make_phone_call 当工具的 ReAct Agent(OpenAI function calling)
demo.py 端到端演示:一个电话任务从下达到汇报
requirements.txt / env.example 依赖与环境变量模板

源代码

agent.py

"""
电话 Agent —— 一个把 PineClaw Voice(make_phone_call)当作工具的 ReAct Agent。

上层是标准的 ReAct 循环(OpenAI function calling 实现):
    用户给一个任务(如"打电话给宽带客服,问清本月为何多扣 50 元并要求解释")
        -> Agent 思考需要哪些参数(号码 / 目标 / 上下文)
        -> 调用 make_phone_call 工具完成整段通话
        -> 读取返回的【结构化通话记录】
        -> 若信息不足则追问/再拨,否则向用户给出最终汇报

工具 make_phone_call 由 pine_voice 模块提供(对真实 PineClaw Voice API 的本地模拟)。
"""

from __future__ import annotations

import json
import re
from typing import Any, Callable

# 复用 pine_voice 里的统一 client/模型解析(OPENAI_API_KEY 直连,缺失则回退 OpenRouter)。
from pine_voice import make_phone_call, _get_client, default_model

# 最多允许 Agent 发起几次工具调用(含追问/再拨),防止死循环。
_MAX_STEPS = 6

_SYSTEM_PROMPT = (
    "你是一名『电话助理』Agent。用户会交给你一个需要打电话才能完成的任务,"
    "你要代表用户把电话打好并汇报结果。\n\n"
    "你有一个工具 make_phone_call,它会把整通电话(拨号、IVR 菜单导航、与客服多轮对话、"
    "转录)全部交给 PineClaw 语音 Agent 完成,并返回结构化通话记录。\n\n"
    "工作方式(ReAct):\n"
    "1. 先想清楚要拨打的号码、通话目标、需要带上的上下文(账号/姓名/已知信息)。\n"
    "2. 调用 make_phone_call 完成通话。\n"
    "3. 读取返回的结构化记录(goal_achieved / key_fields / summary / transcript)。\n"
    "4. 若目标未达成或信息不足(follow_up_needed 为真),可以带着更明确的目标再拨一次"
    "(但总次数有限,不要无谓重拨)。\n"
    "5. 目标达成后,用简洁中文向用户汇报:结论 + 关键信息(金额/原因/确认号/时间等)"
    "+ 后续建议(如有)。\n\n"
    "注意:如果任务里连电话号码都没有,就用一个合理的占位号码并在汇报中说明。"
    "始终基于工具真实返回的通话记录来汇报,不要编造通话没有提到的信息。"
)

_TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "make_phone_call",
            "description": (
                "代表用户拨打一通真实电话。PineClaw 语音 Agent 会完成拨号、IVR 菜单导航、"
                "与对方多轮对话,并返回结构化通话记录(含 transcript、是否达成目标、"
                "抽取的关键字段)。"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "phone_number": {
                        "type": "string",
                        "description": "被叫电话号码,如 10000 或 400-810-xxxx。",
                    },
                    "goal": {
                        "type": "string",
                        "description": "本次通话要达成的明确目标(一句话)。",
                    },
                    "context": {
                        "type": "string",
                        "description": "辅助上下文:账号、姓名、已知金额、时间等,可为空。",
                    },
                },
                "required": ["phone_number", "goal"],
            },
        },
    }
]


def run_agent(
    task: str,
    on_event: Callable[[str, Any], None] | None = None,
    *,
    model: str | None = None,
    phone_hint: str | None = None,
    goal_hint: str | None = None,
    dry_run: bool = False,
) -> str:
    """
    运行 ReAct 电话 Agent。

    参数:
        task:       用户的自然语言任务。
        on_event:   可选回调,用于外部观察 Agent 轨迹。
                    事件类型:'think'(Agent 思考文本) / 'call'(工具入参) /
                    'record'(结构化通话记录) / 'final'(最终汇报)。
        model:      覆盖使用的模型(默认取环境变量 OPENAI_MODEL)。
        phone_hint: 可选的电话号码;给定时作为已知信息交给 Agent(dry-run 下直接用作被叫号码)。
        goal_hint:  可选的通话目标;给定时作为已知信息交给 Agent(dry-run 下直接用作通话目标)。
        dry_run:    若为真,走完全离线的脚本化 ReAct 轨迹(不联网、不需要任何 API Key)。

    返回:
        Agent 面向用户的最终汇报文本。
    """

    def emit(kind: str, payload: Any) -> None:
        if on_event:
            on_event(kind, payload)

    if dry_run:
        return _run_agent_dryrun(task, emit, phone_hint, goal_hint)

    model = model or default_model()

    # 若显式给了号码/目标,就作为「已知信息」拼进用户消息,Agent 仍自行决定如何使用。
    hints = []
    if phone_hint:
        hints.append(f"已知对方电话号码:{phone_hint}")
    if goal_hint:
        hints.append(f"用户明确的通话目标:{goal_hint}")
    user_content = task if not hints else task + "\n\n(" + ";".join(hints) + ")"

    messages: list[dict[str, Any]] = [
        {"role": "system", "content": _SYSTEM_PROMPT},
        {"role": "user", "content": user_content},
    ]

    for _ in range(_MAX_STEPS):
        resp = _get_client().chat.completions.create(
            model=model,
            messages=messages,
            tools=_TOOLS,
            tool_choice="auto",
            temperature=0.3,
        )
        msg = resp.choices[0].message

        # 附上模型这一步返回的 assistant 消息(可能同时含思考文本与工具调用)。
        messages.append(msg.model_dump(exclude_none=True))

        if msg.content:
            emit("think", msg.content)

        if not msg.tool_calls:
            # 没有工具调用 = Agent 给出了最终答复。
            final = msg.content or ""
            emit("final", final)
            return final

        # 逐个执行工具调用(本例只有 make_phone_call)。
        for tc in msg.tool_calls:
            if tc.function.name != "make_phone_call":
                result = {"error": f"未知工具 {tc.function.name}"}
            else:
                args = json.loads(tc.function.arguments or "{}")
                emit("call", args)
                result = make_phone_call(
                    phone_number=args.get("phone_number", "10000"),
                    goal=args.get("goal", task),
                    context=args.get("context", ""),
                    model=model,
                )
                emit("record", result)

            messages.append(
                {
                    "role": "tool",
                    "tool_call_id": tc.id,
                    "name": "make_phone_call",
                    "content": json.dumps(result, ensure_ascii=False),
                }
            )

    # 兜底:步数用尽仍未收敛,逼模型直接总结一次。
    messages.append(
        {
            "role": "user",
            "content": "请根据以上通话记录,立即给用户一份最终汇报,不要再打电话了。",
        }
    )
    resp = _get_client().chat.completions.create(
        model=model, messages=messages, temperature=0.3
    )
    final = resp.choices[0].message.content or ""
    emit("final", final)
    return final


def _guess_phone(task: str) -> str | None:
    """从任务文本里粗略抽取一个像电话号码的数字串(>=4 位,避开『50 元』这类金额)。"""
    for m in re.finditer(r"\d{4,}", task):
        return m.group(0)
    return None


def _guess_context(task: str) -> str:
    """从任务文本里粗略抽取账号等上下文(仅用于 dry-run 的启发式,不求完备)。"""
    m = re.search(r"账[号户][^A-Za-z0-9]{0,3}([A-Za-z0-9][A-Za-z0-9\-]{2,})", task)
    if m:
        return f"账号 {m.group(1)}"
    return ""


def _run_agent_dryrun(
    task: str,
    emit: Callable[[str, Any], None],
    phone_hint: str | None,
    goal_hint: str | None,
) -> str:
    """
    完全离线的脚本化 ReAct 轨迹:不调用任何 LLM/电话 API,仅演示循环的形状——
    思考(推断参数) → 行动(make_phone_call, dry_run) → 观察(结构化记录) → 汇报。
    """
    phone = phone_hint or _guess_phone(task) or "10010"
    goal = goal_hint or task
    context = _guess_context(task)

    emit(
        "think",
        "这是一个需要打电话才能完成的任务。我先确定通话三要素——"
        f"号码={phone};目标={goal};上下文={context or '(无)'}。参数已足够,现在发起通话。",
    )

    call_args = {"phone_number": phone, "goal": goal, "context": context}
    emit("call", call_args)
    record = make_phone_call(**call_args, dry_run=True)
    emit("record", record)

    kf = record.get("key_fields", {}) or {}
    kf_text = ";".join(f"{k}={v}" for k, v in kf.items()) or "(无)"
    final = (
        f"已(dry-run 脚本模拟)拨打 {phone} 并完成通话。\n"
        f"结论:{record.get('summary', '')}\n"
        f"关键信息:{kf_text}。"
    )
    emit("final", final)
    return final

demo.py

"""
实验 9-2 演示:用 ReAct Agent + PineClaw Voice(模拟)完成一个电话任务。

运行:
    python demo.py

它会真实调用 OpenAI:一边驱动上层 ReAct Agent 决策,一边在 make_phone_call 内部
用 OpenAI 扮演被叫方(IVR + 客服)完成一整段多轮通话,最后打印:
  (a) Agent 的 ReAct 轨迹(思考 + 发起 make_phone_call)
  (b) 返回的结构化通话记录(多轮 transcript + 是否达成目标 + 关键字段)
  (c) Agent 基于通话结果向用户的最终汇报
"""

from __future__ import annotations

import argparse
import os
import sys

try:
    from dotenv import load_dotenv

    load_dotenv()
except ImportError:
    pass

from agent import run_agent


def _hr(title: str = "") -> None:
    line = "─" * 72
    if title:
        print(f"\n{line}\n{title}\n{line}")
    else:
        print(line)


def _print_record(rec: dict) -> None:
    print(f"  call_id        : {rec['call_id']}")
    print(f"  被叫号码       : {rec['phone_number']}")
    print(f"  状态           : {rec['status']}  |  是否达成目标: {rec['goal_achieved']}")
    print(f"  通话时长(模拟) : {rec['duration_seconds']} 秒")
    print(f"  摘要           : {rec['summary']}")
    print("  关键字段(key_fields):")
    if rec["key_fields"]:
        for k, v in rec["key_fields"].items():
            print(f"      - {k}: {v}")
    else:
        print("      (无)")
    print(f"  需要追问       : {rec['follow_up_needed']}  {rec.get('follow_up_reason', '')}")
    print("  通话转录(transcript):")
    for turn in rec["transcript"]:
        speaker = turn["speaker"]
        # 简单对齐:语音Agent 用 >>,被叫方用 <<
        arrow = ">>" if speaker == "语音Agent" else "<<"
        print(f"      {arrow} [{speaker}] {turn['text']}")


_DEFAULT_TASK = (
    "帮我打电话给宽带客服(客服热线 10010),查询本月账单为什么多扣了 50 元,"
    "要求对方解释清楚原因,如果是误扣就请他们处理。我的宽带账号是 hz-88231。"
)


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(
        description="实验 9-2:把 PineClaw Voice(make_phone_call)当工具的 ReAct 电话 Agent。"
                    "给一个自然语言电话任务,Agent 自行决定号码/目标/上下文并(模拟)拨打,"
                    "读取结构化通话记录后向用户汇报。",
        epilog="示例:\n"
               "  python demo.py                       # 书中默认的宽带账单任务(需 OPENAI_API_KEY)\n"
               "  python demo.py --dry-run             # 完全离线:脚本化 ReAct 轨迹,无需任何 API Key\n"
               "  python demo.py --task \"帮我打电话给餐厅订今晚 7 点 4 人的位子\" --phone 021-8888\n"
               "  python demo.py --model gpt-5.6-luna        # 覆盖模型",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p.add_argument("--task", default=_DEFAULT_TASK,
                   help="自定义电话任务(自然语言)。默认用书中的宽带账单示例。")
    p.add_argument("--phone", default=None, metavar="号码",
                   help="可选:对方电话号码。给定时作为已知信息交给 Agent(dry-run 下直接用作被叫号码)。")
    p.add_argument("--goal", default=None, metavar="目标",
                   help="可选:明确的通话目标。给定时作为已知信息交给 Agent(dry-run 下直接用作通话目标)。")
    p.add_argument("--model", default=None, metavar="模型",
                   help="可选:覆盖使用的模型(默认取环境变量 OPENAI_MODEL,即 gpt-5.6-luna)。")
    p.add_argument("--dry-run", action="store_true",
                   help="离线脚本模式:不联网、不需要任何 API Key,仅演示 ReAct 循环与数据契约的形状。")
    return p.parse_args()


def main() -> None:
    args = parse_args()
    if not args.dry_run and not (os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY")):
        print("错误:未检测到 OPENAI_API_KEY 或 OPENROUTER_API_KEY。请复制 env.example 为 .env "
              "并至少填入其一,或改用 python demo.py --dry-run 走完全离线的脚本演示。")
        sys.exit(1)

    # 书中示例任务:注意这里只给了自然语言任务,Agent 需自行决定通话参数。
    task = args.task

    _hr("用户任务")
    print(task)
    if args.dry_run:
        print("\n[模式] dry-run 离线脚本模拟:以下轨迹与通话记录均为固定脚本,"
              "不调用任何 LLM/电话 API,仅用于演示 ReAct 循环的形状。")

    _hr("ReAct Agent 轨迹")

    def on_event(kind: str, payload) -> None:
        if kind == "think":
            print(f"\n[Agent 思考] {payload}")
        elif kind == "call":
            print("\n[Agent 调用工具 make_phone_call] 入参:")
            print(f"    phone_number = {payload.get('phone_number')}")
            print(f"    goal         = {payload.get('goal')}")
            print(f"    context      = {payload.get('context', '')}")
        elif kind == "record":
            print("\n[PineClaw 返回结构化通话记录]")
            _print_record(payload)
        elif kind == "final":
            pass  # 最终汇报单独打印

    final = run_agent(
        task,
        on_event=on_event,
        model=args.model,
        phone_hint=args.phone,
        goal_hint=args.goal,
        dry_run=args.dry_run,
    )

    _hr("Agent 向用户的最终汇报")
    print(final)
    print()


if __name__ == "__main__":
    main()

pine_voice.py

"""
pine_voice —— PineClaw Voice API 的本地【模拟】客户端。

真实的 PineClaw Voice API(https://pineclaw.com/ ,作者团队开发)是一套生产级
电话语音 API:你把「电话号码 + 目标 + 上下文」交给它,它的语音 Agent 会自动完成
拨号、IVR 菜单导航("查询请按 1,转人工请按 0")、与真人多轮对话、实时转录,
最后把整段通话浓缩成一份【结构化通话记录】返回。

本文件不接触真实电话网络,也不需要 PineClaw key。它用 OpenAI 扮演「被叫方」
(IVR 语音菜单 + 人工客服)与「去电的语音 Agent」进行一段多轮对话,从而在本地
复现同样的输入/输出契约:

    record = make_phone_call(phone_number, goal, context)

返回的 record 与真实 API 的形状保持一致(transcript / goal_achieved / key_fields ...),
因此上层 ReAct Agent 的代码在切换到真实 PineClaw SDK 时几乎无需改动。

真实接入方式见本目录 README.md「真实接入 PineClaw」一节。
"""

from __future__ import annotations

import hashlib
import json
import os
import re
import time
import uuid
from dataclasses import dataclass, field, asdict
from typing import Any

from openai import OpenAI

# 默认聊天模型:当前便宜旗舰。可用 OPENAI_MODEL 覆盖(如 Moonshot 的 kimi-k3)。
_DEFAULT_MODEL = "gpt-5.6-luna"


def _map_openrouter_model(model: str) -> str:
    """把常见模型名映射为 OpenRouter 的 provider/model 形式。"""
    if "/" in model:            # 已是 provider/model,原样透传
        return model
    if model.startswith("gpt-"):
        return "openai/" + model
    if model.startswith("claude-"):
        return "anthropic/claude-opus-4.8"
    return "openai/gpt-5.6-luna"


# 延迟创建 client:缺 Key 时由 demo.py 给出友好提示,而非 import 期裸异常。
# 通用回退:优先 OPENAI_API_KEY(可选 OPENAI_BASE_URL 指向兼容网关,如 Moonshot),
# 否则用 OPENROUTER_API_KEY 走 OpenRouter,否则给出清晰错误。
# timeout + 自动重试,避免单次网络/SSL 抖动让整段模拟通话崩溃。
_client = None
_active_model = None


def _resolve() -> tuple[OpenAI, str]:
    global _client, _active_model
    if _client is not None:
        return _client, _active_model
    model = os.getenv("OPENAI_MODEL") or _DEFAULT_MODEL
    if os.getenv("OPENAI_API_KEY"):
        # 直连 OpenAI(或 OPENAI_BASE_URL 指定的兼容网关,如 Moonshot kimi-k3)。
        _client = OpenAI(
            base_url=os.getenv("OPENAI_BASE_URL") or None,
            timeout=60.0,
            max_retries=3,
        )
        _active_model = model
    elif os.getenv("OPENROUTER_API_KEY"):
        # 回退到 OpenRouter:注意 gpt-5.6* 直连 OpenAI 需组织实名认证,走 OpenRouter 免此步。
        _client = OpenAI(
            base_url="https://openrouter.ai/api/v1",
            api_key=os.getenv("OPENROUTER_API_KEY"),
            timeout=60.0,
            max_retries=3,
        )
        _active_model = _map_openrouter_model(model)
    else:
        raise RuntimeError(
            "未检测到 OPENAI_API_KEY 或 OPENROUTER_API_KEY。请在 .env / 环境变量中至少配置其一"
            "(见 env.example),或用 python demo.py --dry-run 走完全离线的脚本演示。"
        )
    return _client, _active_model


def _get_client() -> OpenAI:
    return _resolve()[0]


def default_model() -> str:
    """当前生效的聊天模型名(已按 OpenRouter 映射解析)。"""
    return _resolve()[1]

# 一次模拟通话里「去电语音 Agent ↔ 被叫方」最多来回的轮数(一轮 = 双方各说一次)。
_MAX_TURNS = 8

# 结束标记:去电语音 Agent 认为通话目标已完成/无法推进时,在发言末尾附上它。
_END_TOKEN = "[END_CALL]"


# --------------------------------------------------------------------------- #
# 结构化通话记录 —— 与真实 PineClaw Voice API 返回体保持同一形状
# --------------------------------------------------------------------------- #
@dataclass
class CallRecord:
    call_id: str
    phone_number: str
    goal: str
    status: str                       # "completed" / "failed"
    goal_achieved: bool               # 是否达成通话目标
    duration_seconds: int             # 通话时长(模拟值)
    summary: str                      # 一句话通话摘要
    key_fields: dict[str, Any]        # 从通话中抽取的关键信息(确认号、金额、时间...)
    transcript: list[dict[str, str]]  # 逐轮对话:{"speaker": ..., "text": ...}
    follow_up_needed: bool            # 是否仍需追问/再拨
    follow_up_reason: str             # 若需追问,原因是什么

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)


# --------------------------------------------------------------------------- #
# 内部:调用 OpenAI 生成一次发言
# --------------------------------------------------------------------------- #
def _chat(
    messages: list[dict[str, str]],
    temperature: float = 0.7,
    model: str | None = None,
) -> str:
    resp = _get_client().chat.completions.create(
        model=model or default_model(),
        messages=messages,
        temperature=temperature,
    )
    return (resp.choices[0].message.content or "").strip()


def _callee_system_prompt(goal: str, context: str) -> str:
    """被叫方:先当自动 IVR 语音菜单,被转人工后再扮演人工客服。"""
    return (
        "你在参与一段【电话客服】通话的角色扮演,扮演【被叫方】(企业客服热线)。\n"
        "整通电话分两个阶段,请自然衔接:\n"
        "1) 开场你是【自动语音应答(IVR)系统】:先播报欢迎语,再报一个简短的按键菜单"
        "(例如『账单查询请按 1,业务办理请按 2,人工服务请按 0』)。\n"
        "2) 当来电者选择转人工(按 0 或明确要求人工)后,你切换为【人工客服代表】,"
        "自报工号,然后正常应答。\n\n"
        "扮演要求:\n"
        "- 全程用简体中文口语,像真实电话一样简短自然,不要旁白、不要括号动作。\n"
        "- 你可以合理编造本企业的业务细节(如扣费原因、套餐、确认号),要具体、前后一致。\n"
        f"- 来电者此行的目标大致是:{goal}\n"
        "- 你不必无条件满足对方,可以先解释、再看情况处理;但对方礼貌且诉求合理时,"
        "应能给出明确结论(原因/处理结果/确认号等)。\n"
        "- 每次只说你(被叫方)这一方的一段话,不要替对方说话。"
    )


def _caller_system_prompt(phone_number: str, goal: str, context: str) -> str:
    """去电的语音 Agent:代表用户拨打电话、导航 IVR、达成目标。"""
    return (
        "你是 PineClaw 电话语音 Agent,正代表用户拨打一通真实电话去办事。\n"
        f"- 拨打号码:{phone_number}\n"
        f"- 通话目标:{goal}\n"
        f"- 已知上下文:{context or '(无额外上下文)'}\n\n"
        "行为要求:\n"
        "- 全程用简体中文口语,礼貌、简短、直奔目标。\n"
        "- 开场若遇到 IVR 按键菜单,请明确说出你的选择(如『我按 0 转人工』)来导航。\n"
        "- 接通人工后清晰说明来意,围绕目标追问,主动核对并记住关键信息"
        "(金额、原因、确认号、时间等)。\n"
        "- 目标达成、或对方明确无法处理时,礼貌道谢结束通话,并在该句结尾附上标记 "
        f"{_END_TOKEN}\n"
        "- 每次只说你(来电者)这一方的一段话,不要替对方说话,也不要附加旁白。"
    )


def _run_dialog(
    phone_number: str, goal: str, context: str, model: str | None = None
) -> list[dict[str, str]]:
    """驱动两个 LLM 角色来回对话,产出逐轮 transcript。"""
    caller_sys = _caller_system_prompt(phone_number, goal, context)
    callee_sys = _callee_system_prompt(goal, context)

    # 各自维护自己视角的消息历史。
    caller_msgs = [{"role": "system", "content": caller_sys}]
    callee_msgs = [{"role": "system", "content": callee_sys}]

    transcript: list[dict[str, str]] = []

    # 被叫方先开口(IVR 欢迎语 + 菜单)。
    callee_msgs.append({"role": "user", "content": "(电话已接通,请开始。)"})
    callee_line = _chat(callee_msgs, model=model)
    callee_msgs.append({"role": "assistant", "content": callee_line})
    caller_msgs.append({"role": "user", "content": callee_line})
    transcript.append({"speaker": "被叫方", "text": callee_line})

    for _ in range(_MAX_TURNS):
        # 去电语音 Agent 发言
        caller_line = _chat(caller_msgs, model=model)
        ended = _END_TOKEN in caller_line
        caller_line_clean = caller_line.replace(_END_TOKEN, "").strip()
        caller_msgs.append({"role": "assistant", "content": caller_line})
        callee_msgs.append({"role": "user", "content": caller_line_clean})
        transcript.append({"speaker": "语音Agent", "text": caller_line_clean})
        if ended:
            break

        # 被叫方回应
        callee_line = _chat(callee_msgs, model=model)
        callee_msgs.append({"role": "assistant", "content": callee_line})
        caller_msgs.append({"role": "user", "content": callee_line})
        transcript.append({"speaker": "被叫方", "text": callee_line})

    return transcript


def _extract_structured(
    goal: str, transcript: list[dict[str, str]], model: str | None = None
) -> dict[str, Any]:
    """通话结束后,用一次 LLM 调用把 transcript 归纳为结构化字段。"""
    dialog_text = "\n".join(f"{t['speaker']}{t['text']}" for t in transcript)
    prompt = (
        "下面是一段电话通话的完整转录。请你作为通话分析器,输出一个 JSON 对象,字段如下:\n"
        '  "goal_achieved": 布尔,本次通话是否达成了给定目标;\n'
        '  "summary": 字符串,一句话中文通话摘要;\n'
        '  "key_fields": 对象,从通话中抽取的关键信息键值对(如 扣费原因/涉及金额/'
        "确认号/处理结果/预约时间 等,键用中文,没有就留空对象);\n"
        '  "follow_up_needed": 布尔,用户是否还需要进一步追问或再次拨打;\n'
        '  "follow_up_reason": 字符串,若需追问说明原因,否则空字符串。\n\n'
        f"【通话目标】{goal}\n\n"
        f"【通话转录】\n{dialog_text}\n\n"
        "只输出 JSON,不要额外解释。"
    )
    raw = _chat(
        [
            {"role": "system", "content": "你是严谨的通话记录结构化器,只输出合法 JSON。"},
            {"role": "user", "content": prompt},
        ],
        temperature=0.0,
        model=model,
    )
    structured = _safe_json(raw)
    # 模型偶尔输出 JSON 数组等合法但非对象的 JSON;归一化为空 dict,
    # 交给下游 .get 默认值兜底,而不是让 .get 在 list 上崩溃。
    if not isinstance(structured, dict):
        structured = {}
    return structured


def _safe_json(raw: str) -> dict[str, Any]:
    """从模型输出里稳妥地取出 JSON(容忍 ```json 包裹)。"""
    text = raw.strip()
    text = re.sub(r"^```(?:json)?", "", text).strip()
    text = re.sub(r"```$", "", text).strip()
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        m = re.search(r"\{.*\}", text, re.DOTALL)
        if m:
            try:
                return json.loads(m.group(0))
            except json.JSONDecodeError:
                pass
    return {
        "goal_achieved": False,
        "summary": "(结构化解析失败,请查看原始 transcript)",
        "key_fields": {},
        "follow_up_needed": True,
        "follow_up_reason": "通话记录未能被自动结构化。",
    }


# --------------------------------------------------------------------------- #
# 离线 dry-run —— 完全不联网、不需要任何 API Key 的脚本化通话记录
# --------------------------------------------------------------------------- #
def _dryrun_record(phone_number: str, goal: str, context: str) -> dict[str, Any]:
    """
    返回一份【脚本化】的结构化通话记录,全程不调用任何 API、不联网。

    用途:当既没有电话账号、也没有 OPENAI_API_KEY 时,仍能把整条 ReAct 循环
    (思考 → 调用 make_phone_call → 读取结构化记录 → 汇报)离线跑通,展示其形状。
    transcript / key_fields 均为固定脚本,不代表任何真实通话。
    """
    ctx = context or "(无额外上下文)"
    # 由 goal 派生一个稳定、可复现的确认号(而非随机编造,便于对齐说明)。
    conf = "PC" + hashlib.sha1(goal.encode("utf-8")).hexdigest()[:8].upper()
    transcript = [
        {"speaker": "被叫方", "text": "您好,欢迎致电客服热线。业务查询请按 1,人工服务请按 0。"},
        {"speaker": "语音Agent", "text": "我按 0 转人工。"},
        {"speaker": "被叫方", "text": "您好,人工客服工号 3021,请问有什么可以帮您?"},
        {"speaker": "语音Agent", "text": f"你好,我这边的诉求是:{goal}。相关信息:{ctx}。"},
        {"speaker": "被叫方",
         "text": f"好的,已为您核实并受理,给您一个确认号 {conf},后续可凭此查询进度。"},
        {"speaker": "语音Agent", "text": f"好的,确认号 {conf},我记下了,谢谢,再见。"},
    ]
    record = CallRecord(
        call_id="pc_dryrun_" + hashlib.sha1((phone_number + goal).encode()).hexdigest()[:8],
        phone_number=phone_number,
        goal=goal,
        status="completed",
        goal_achieved=True,
        duration_seconds=12 * len(transcript) + 8,
        summary=f"(dry-run 脚本模拟)已就“{goal}”联系 {phone_number} 并受理,确认号 {conf}。",
        key_fields={"处理结果": "已受理", "确认号": conf, "对方号码": phone_number},
        transcript=transcript,
        follow_up_needed=False,
        follow_up_reason="",
    )
    return record.to_dict()


# --------------------------------------------------------------------------- #
# 对外唯一入口 —— 对齐真实 PineClaw Voice API 的 make_phone_call
# --------------------------------------------------------------------------- #
def make_phone_call(
    phone_number: str,
    goal: str,
    context: str = "",
    *,
    model: str | None = None,
    dry_run: bool = False,
) -> dict[str, Any]:
    """
    发起一通(模拟的)电话,由语音 Agent 全程完成,返回结构化通话记录(dict)。

    参数:
        phone_number: 被叫号码(本模拟不真正拨号,仅记录)。
        goal:         本次通话要达成的目标,例如
                      "查询本月宽带账单为何多扣了 50 元并要求解释"。
        context:      辅助上下文,如账号、姓名、已知信息等(可为空)。
        model:        覆盖内部模拟对话所用的 OpenAI 模型(默认取 OPENAI_MODEL)。
        dry_run:      若为真,则走完全离线的脚本化路径(不联网、不需要任何 API Key)。

    返回:
        CallRecord.to_dict(),包含 transcript / goal_achieved / key_fields 等。
    """
    if dry_run:
        return _dryrun_record(phone_number, goal, context)

    started = time.time()
    transcript = _run_dialog(phone_number, goal, context, model=model)
    structured = _extract_structured(goal, transcript, model=model)

    # 用对话轮数粗略折算一个「通话时长」,纯属演示展示用。
    duration = 12 * len(transcript) + 8

    # key_fields 可能是模型误输出的数组/标量等非对象类型,统一归一化为 dict。
    key_fields = structured.get("key_fields")
    if not isinstance(key_fields, dict):
        key_fields = {}

    record = CallRecord(
        call_id=f"pc_{uuid.uuid4().hex[:12]}",
        phone_number=phone_number,
        goal=goal,
        status="completed",
        goal_achieved=bool(structured.get("goal_achieved", False)),
        duration_seconds=duration,
        summary=str(structured.get("summary", "")),
        key_fields=key_fields,
        transcript=transcript,
        follow_up_needed=bool(structured.get("follow_up_needed", False)),
        follow_up_reason=str(structured.get("follow_up_reason", "")),
    )
    _ = started  # 真实 API 会用真实起止时间;此处 duration 为模拟值
    return record.to_dict()

test_structured_coercion.py

"""回归测试:通话结构化结果类型异常时,make_phone_call 不应崩溃。

覆盖两类模型失误(此前会让 record 组装阶段 AttributeError/ValueError):
  1) 结构化输出是 JSON 数组而非对象 -> 归一化为空 dict,走默认值;
  2) key_fields 是数组而非对象 -> 归一化为 {}。
不依赖真实 API:_run_dialog / _chat 被打桩。
"""

import sys
from pathlib import Path
from types import ModuleType

sys.path.insert(0, str(Path(__file__).parent))

try:
    import openai  # noqa: F401
except ImportError:
    sys.modules["openai"] = ModuleType("openai")
    sys.modules["openai"].OpenAI = object

import pine_voice

pine_voice._run_dialog = lambda *a, **k: [{"speaker": "被叫方", "text": "您好"}]


def test_json_array_structured_output_falls_back_to_defaults():
    pine_voice._chat = lambda *a, **k: '[{"goal_achieved": true}]'
    record = pine_voice.make_phone_call("10010", "查询账单")
    assert record["goal_achieved"] is False
    assert record["summary"] == ""
    assert record["key_fields"] == {}
    assert record["status"] == "completed"


def test_non_dict_key_fields_coerced_to_empty_dict():
    pine_voice._chat = lambda *a, **k: (
        '{"goal_achieved": true, "summary": "s", "key_fields": ["确认号", "金额"],'
        ' "follow_up_needed": false, "follow_up_reason": ""}')
    record = pine_voice.make_phone_call("10010", "查询账单")
    assert record["goal_achieved"] is True
    assert record["key_fields"] == {}


def test_wellformed_structured_output_unchanged():
    pine_voice._chat = lambda *a, **k: (
        '{"goal_achieved": true, "summary": "已受理", '
        '"key_fields": {"确认号": "PC123"}, '
        '"follow_up_needed": false, "follow_up_reason": ""}')
    record = pine_voice.make_phone_call("10010", "查询账单")
    assert record["goal_achieved"] is True
    assert record["summary"] == "已受理"
    assert record["key_fields"] == {"确认号": "PC123"}