跳转至

prompt-auto-optimization

第8章 · Agent 的自我进化 · 配套项目 chapter8/prompt-auto-optimization

项目说明

实验 8-3:系统提示词的自动优化(★★)

《深入理解 AI Agent》配套代码 · 第 8 章

基于人类反馈的 自动化系统提示学习:让一个 Coding Agent 读取系统提示词文件、 定位有问题的规则、生成精确修改并 真的改写 prompt 文件,从而修复 Agent 的 "过度转接" 行为。场景为 tau-bench 风格的航空客服。

1. 目的与问题

初始的航空客服 Agent 里,人工转接规则写得很含糊——"仅当请求无法在你的行动范围内 处理时才转接",并且强调"客户满意度第一,遇到不满就转人工,不要与乘客争辩政策"。

评测发现:Agent 过度转接——一遇到政策争议(要求超政策退款、要求免费、要求豁免 费用)就直接甩给人工,而不尝试向乘客解释政策。

人类专家反馈:这类争议应当 通过耐心解释政策来处理,而不是一转了之。真正需要转 人工的只有两种情况:乘客明确要求人工客服紧急安全/人身健康风险

本实验演示一条自动化闭环:人类反馈 → Coding Agent 改写系统提示词 → 重新评测验证

2. 方法与流程

初始 prompt ──评测──► 暴露"过度转接"问题
              人类反馈 ───┤
                   Coding Agent(读文件→定位规则→生成精确 search/replace 编辑→改写文件)
                          │  展示真实 diff
             自动优化后 prompt ──评测──► 边界集正确率↑ 且 保留集不退化
              对照:人工调优版 prompt ──评测──►

两组评测用例(各 5 个,控制成本、结论可复现):

  • 保留任务集 (holdout):正常请求。3 个应由 Agent 自行处理(改签 / 行李额 / 选座), 2 个本就该转接(乘客明确要人工 / 紧急安全)。用来检验优化 不会破坏既有正确行为
  • 边界案例集 (boundary):5 个政策争议(不可退票要退款 / 要求免改签费 / 小延误索赔 / 索要免费升舱 / 超额免费行李)。正确行为是 解释政策、不转接。用来检验过度转接被修复。

判据(evaluate.py):结合确定性规则 + LLM-as-judge—— - 该转的用例:正确 ⇔ 确实调用了 transfer_to_human; - 不该转的用例:正确 ⇔ 没转接,且 LLM 裁判确认它按 rubric 妥善解释了政策 / 办理了业务。

Coding Agent(coding_agent.py)不整篇重写,而是像真实编程 Agent 一样产出一组 (old_str → new_str) 精确编辑,由代码逐条做精确字符串替换落盘;匹配不上就把错误反馈 给模型重试,保证修改可审计、可出 diff。

3. 文件结构

文件 说明
demo.py 一条命令跑通完整流程(评测→改写→复评→对照→对比表)
airline_env.py 精简航空客服模拟环境:工具(含 transfer_to_human)、Agent 循环、10 个用例
coding_agent.py Coding Agent:读取并 改写 系统提示词文件,输出 diff
evaluate.py 评测器:规则 + LLM-as-judge 判定每个用例是否被正确处理
config.py LLM 客户端配置(默认 OpenAI gpt-5.6-luna,可切 Moonshot / 火山方舟;缺 Key 时 OpenRouter 兜底)
prompts/system_prompt.txt 初始系统提示词(含会诱发过度转接的规则)
prompts/system_prompt_manual.txt 对照组:人工调优版系统提示词
runtime/system_prompt_working.txt 运行时由 Coding Agent 改写的工作副本(每次运行重置)

4. 运行

pip install -r requirements.txt
cp env.example .env      # 填入 OPENAI_API_KEY(或仅设 OPENROUTER_API_KEY 走兜底)
python demo.py           # 完整运行:10 个用例 × 3 份 prompt
python demo.py --quick   # 快速演示:每组只取 2 个用例,省时省钱(推荐先跑这个)
python demo.py --help    # 查看全部命令行参数(中文说明)
python demo.py --dry-run # 离线自检:只打印配置与选中用例,不调用任何 API

命令行参数(python demo.py --help 可见完整中文说明):

参数 作用 默认
--quick 每组只取 2 个用例,省时省钱 关闭
--limit N 每组最多评测 N 个用例(覆盖 --quick 不限
--group {holdout,boundary,both} 选择评测的任务集 both
--rounds N Coding Agent 自动改写提示词的最大重试轮数 3
--model NAME 覆盖 LLM 模型名(等价于 LLM_MODEL config.py
--provider {openai,moonshot,ark} 覆盖 LLM 提供商(等价于 LLM_PROVIDER openai
--output PATH 把优化前后 + 人工对照的对比结果写成 JSON 不写
--dry-run 离线:只打印解析后的配置与用例数,不调用 API 关闭

默认模型 gpt-5.6-luna(读取 OPENAI_API_KEY;缺失时若有 OPENROUTER_API_KEY 则自动改走 OpenRouter 并映射到 openai/gpt-5.6-luna),温度 0(结果可复现)。完整运行跑 10 个用例 × 3 份 prompt,约几十次 API 调用、数分钟;--quick(或 --limit N 指定每组 用例数)会显著缩短耗时,用于快速验证闭环。命令行参数优先级高于环境变量:--model / --provider 会覆盖 .env 中的 LLM_MODEL / LLM_PROVIDER。加 --output output/run.json 可把对比表落盘为 JSON(output/ 已被 .gitignore 忽略),便于复现与二次分析。若未设置 对应 API Key,程序会打印清晰的中文错误提示并退出,而不是抛出堆栈。

优化后的工作副本会写入 runtime/system_prompt_working.txt(每次运行自动重置,属生成 工件,已被 .gitignore 忽略)。

5. 真实运行结论

下表为一次真实运行(gpt-5.6-luna,完整 10 用例)的结果:

正确率对比(保留任务集 = 既有正确行为不能退化;边界案例集 = 过度转接应改善)
==========================================================================
系统提示词版本                   保留任务集(holdout)      边界案例集(boundary)
--------------------------------------------------------------------------
初始 prompt(优化前)          5/5 (100%)            0/5 (0%)
自动优化后 prompt            5/5 (100%)            1/5 (20%)
人工调优版(对照)               5/5 (100%)            2/5 (40%)
==========================================================================

【结论】
  · 边界案例集正确率:0/5 → 1/5 (提升 ✓)
  · 保留任务集正确率:5 → 5 (未退化 ✓)
  • 边界案例集:优化前 5 个用例全部"一转了之"(过度转接 5/5),优化后 转接彻底消失 (边界集转接数 5 → 0),过度转接问题被修复;正确率从 0/5 升到 1/5——B5(超额免费行李) 由主动解释重量计费政策而被判合格,B1/B2/B3/B4 虽不再转接,但模型倾向先向乘客索要订单号、 未充分把政策解释到位,被严格的裁判判为"处理不当",属于真实的边界表现。
  • 保留任务集:优化前后均 5/5,既有正确行为(含 H4/H5"该转的仍会转")未退化
  • 自动优化后的效果接近 人工调优版对照组(自动边界 1/5、人工 2/5,保留均 5/5);人工版在 B2(要求免改签费)上多解释到位一例,差距在裁判的 ±1 个用例波动范围内。

注:具体数字可能随模型版本与采样有 ±1 个用例的波动,但"边界集过度转接被修复、保留集不退化、 且与人工调优接近"的结论稳定可复现。上表为 gpt-5.6-luna 的一次真实运行(因该模型仅支持默认 温度,本次以 LLM_TEMPERATURE=1 运行)。

Coding Agent 对 system_prompt.txt 的真实改写(diff 节选)会在 python demo.py 的 【步骤 2】中打印,核心是把第 3、4 条转接规则收紧为"仅乘客明确要求人工 / 紧急安全才转", 并新增负面规则"绝不因政策争议或乘客不满而转接,应先解释政策再给替代方案"。

补充:模型 ↔ 脚手架此消彼长(强模型 vs 弱模型的两次真实运行)

一个自然的问题:模型更强,这套"自动改 prompt"的脚手架是不是就不那么必要了? 为此我们把完全相同的闭环(10 用例 × 3 份 prompt × 最多 3 轮改写)在一个较弱模型 gpt-4o-mini(OpenAI 直连,LLM_TEMPERATURE=0)上又真实跑了一次,与上文 gpt-5.6-luna 的运行对照。边界案例集(过度转接应改善)的优化前 → 自动优化后变化:

模型 保留集(holdout) 边界集 优化前 → 自动优化后 自动优化增益 人工调优版(对照)
gpt-4o-mini(较弱) 5/5 → 5/5(未退化) 1/5 → 3/5 +2 个用例 3/5
gpt-5.6-luna(较强) 5/5 → 5/5(未退化) 0/5 → 1/5 +1 个用例 2/5
  • 方向上,较弱的 gpt-4o-mini 从这套自动优化脚手架里拿到的增益更大(边界集 +2 个用例, 1/5→3/5),强于 gpt-5.6-luna 的 +1(0/5→1/5);且优化后 gpt-4o-mini 已追平其人工调优版 (均 3/5)。这与"弱模型更依赖脚手架、强模型对同一条基线 prompt 的容错更高"的直觉一致。
  • 但需诚实标注两点,避免过度解读:(1) 两者增益之差仅 1 个用例,正落在本实验反复申明的 ±1 个用例裁判波动带内;(2) 两次运行采样条件不同——弱模型 temperature=0(确定性), 强模型因仅支持默认温度而 temperature=1(有采样噪声)。因此这是一次方向性、非结论性的 对照:可以说"弱模型从脚手架获益不少于强模型",但不宜据这 1 个用例的差断言强模型"就不需要" 自动优化。真正稳健的结论仍是两模型共有的那条——边界集过度转接被修复、保留集不退化、 且自动优化逼近人工调优

6. 如何适配 / 扩展与局限

  • 换模型 / 供应商LLM_PROVIDER 可切换 openai / moonshot / ark(均兼容 OpenAI 接口), LLM_MODEL 覆盖模型名,LLM_TEMPERATURE 调采样温度(默认 0,见 config.py / env.example)。
  • 换任务 / 输入:评测用例集在 airline_env.pyCASES(分 holdout / boundary 两组); 驱动优化的人类反馈是 demo.py 顶部的 HUMAN_FEEDBACK;初始与人工对照 prompt 在 prompts/ 下—— 改这几处即可把闭环套到你自己的场景。
  • 局限:环境为教学用途的精简模拟,工具返回固定 mock 数据;重点在"人类反馈驱动提示词自动优化" 这一闭环,而非完整复刻 tau-bench。具体正确率会随模型版本与采样有 ±1 个用例的波动。

源代码

airline_env.py

"""
精简版「航空客服」模拟环境(对标 tau-bench 的航空场景,但去掉复杂度)。

包含三部分:
1. TOOLS      —— 暴露给 Agent 的工具(含关键的 transfer_to_human)。
2. run_agent  —— 一个带工具调用循环的最小 Agent:给定 system prompt 和用户请求,
                 返回它是否转接人工、以及最终回复。
3. CASES      —— 两组评测用例:
                 - 保留任务集(holdout):正常请求,Agent 应正确处理(不该转的别转,该转的要转)。
                 - 边界案例集(boundary):政策争议,Agent 应解释政策而非一转了之。
"""

import json
from config import get_client, get_model, TEMPERATURE

# ----------------------------------------------------------------------------
# 1. 工具定义(OpenAI function-calling 格式)
# ----------------------------------------------------------------------------
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "lookup_reservation",
            "description": "根据订单号查询乘客的订单详情(航班、舱位、票价类型等)。",
            "parameters": {
                "type": "object",
                "properties": {
                    "confirmation_code": {"type": "string", "description": "订单号"}
                },
                "required": ["confirmation_code"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "change_flight",
            "description": "为乘客办理改签到指定的新航班。",
            "parameters": {
                "type": "object",
                "properties": {
                    "confirmation_code": {"type": "string"},
                    "new_flight": {"type": "string", "description": "新航班号或日期"},
                },
                "required": ["confirmation_code", "new_flight"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_refund_policy",
            "description": "查询退票/退款政策。传入票价类型(如 经济舱特价票/全价经济舱/商务舱)。",
            "parameters": {
                "type": "object",
                "properties": {
                    "fare_type": {"type": "string", "description": "票价类型"}
                },
                "required": ["fare_type"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_baggage_policy",
            "description": "查询行李额与逾重费政策。传入舱位等级。",
            "parameters": {
                "type": "object",
                "properties": {
                    "cabin": {"type": "string", "description": "舱位等级,如 经济舱/商务舱"}
                },
                "required": ["cabin"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "change_seat",
            "description": "为乘客办理选座或换座。",
            "parameters": {
                "type": "object",
                "properties": {
                    "confirmation_code": {"type": "string"},
                    "seat": {"type": "string", "description": "目标座位号"},
                },
                "required": ["confirmation_code", "seat"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "transfer_to_human",
            "description": "把对话转接给人工客服。调用后 Agent 不再继续处理本次请求。",
            "parameters": {
                "type": "object",
                "properties": {
                    "reason": {"type": "string", "description": "转接原因"}
                },
                "required": ["reason"],
            },
        },
    },
]

# ----------------------------------------------------------------------------
# 2. 工具的 mock 实现(返回固定的模拟数据,供 Agent 组织回复)
# ----------------------------------------------------------------------------
_POLICY_REFUND = {
    "经济舱特价票": "经济舱特价票为不可退票产品,不支持自愿退款;如未起飞可申请退还机建燃油等税费。",
    "全价经济舱": "全价经济舱起飞前可退,收取 5% 退票手续费。",
    "商务舱": "商务舱起飞前可全额退票,不收手续费。",
}


def _run_tool(name: str, args: dict) -> str:
    """执行工具,返回给模型的字符串结果。"""
    if name == "lookup_reservation":
        return json.dumps(
            {
                "confirmation_code": args.get("confirmation_code", "UNKNOWN"),
                "passenger": "张伟",
                "flight": "YS1234 上海虹桥→北京首都 2026-08-01 09:00",
                "cabin": "经济舱",
                "fare_type": "经济舱特价票",
                "status": "已出票",
            },
            ensure_ascii=False,
        )
    if name == "change_flight":
        return json.dumps(
            {"result": "success", "new_flight": args.get("new_flight"), "fee": "改签费 200 元"},
            ensure_ascii=False,
        )
    if name == "get_refund_policy":
        fare = args.get("fare_type", "经济舱特价票")
        text = _POLICY_REFUND.get(fare, _POLICY_REFUND["经济舱特价票"])
        return json.dumps({"fare_type": fare, "policy": text}, ensure_ascii=False)
    if name == "get_baggage_policy":
        cabin = args.get("cabin") or "经济舱"
        free = "20kg" if "经济" in cabin else "30kg"
        return json.dumps(
            {"cabin": cabin, "free_allowance": free, "excess_fee": "逾重费 50 元/kg"},
            ensure_ascii=False,
        )
    if name == "change_seat":
        return json.dumps(
            {"result": "success", "seat": args.get("seat")}, ensure_ascii=False
        )
    return json.dumps({"result": "ok"}, ensure_ascii=False)


# ----------------------------------------------------------------------------
# 3. 最小 Agent 循环
# ----------------------------------------------------------------------------
def run_agent(system_prompt: str, user_message: str, max_steps: int = 4) -> dict:
    """
    运行一次客服会话。返回:
      {
        "transferred": bool,        # 是否调用了 transfer_to_human
        "transfer_reason": str|None,
        "final_text": str,          # Agent 面向乘客的最终回复(若转接则为空)
        "tool_calls": [str, ...],   # 依次调用过的工具名
      }
    """
    client = get_client()
    model = get_model()
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_message},
    ]
    tool_calls_log = []

    for _ in range(max_steps):
        resp = client.chat.completions.create(
            model=model,
            messages=messages,
            tools=TOOLS,
            temperature=TEMPERATURE,
        )
        msg = resp.choices[0].message

        if not msg.tool_calls:
            # 模型直接给出面向乘客的文字回复 —— 会话结束
            return {
                "transferred": False,
                "transfer_reason": None,
                "final_text": msg.content or "",
                "tool_calls": tool_calls_log,
            }

        # 有工具调用,先把 assistant 消息加入历史
        messages.append(msg)

        transferred_now = False
        transfer_reason = None
        for tc in msg.tool_calls:
            name = tc.function.name
            try:
                args = json.loads(tc.function.arguments or "{}")
            except json.JSONDecodeError:
                args = {}
            tool_calls_log.append(name)

            if name == "transfer_to_human":
                transferred_now = True
                transfer_reason = args.get("reason", "")
                result = json.dumps({"result": "transferred"}, ensure_ascii=False)
            else:
                result = _run_tool(name, args)

            messages.append(
                {"role": "tool", "tool_call_id": tc.id, "content": result}
            )

        if transferred_now:
            return {
                "transferred": True,
                "transfer_reason": transfer_reason,
                "final_text": "",
                "tool_calls": tool_calls_log,
            }

    # 超出步数上限仍未收敛,兜底再要一次纯文本回复
    resp = client.chat.completions.create(
        model=model, messages=messages, temperature=TEMPERATURE
    )
    return {
        "transferred": False,
        "transfer_reason": None,
        "final_text": resp.choices[0].message.content or "",
        "tool_calls": tool_calls_log,
    }


# ----------------------------------------------------------------------------
# 4. 评测用例
# ----------------------------------------------------------------------------
# 每个用例字段:
#   id / group / user   —— 用户请求
#   should_transfer     —— 正确行为是否应当转接人工
#   rubric              —— 当 should_transfer=False 时,判定"是否妥善处理"的要点
CASES = [
    # ---------- 保留任务集(holdout):既有正确行为,优化后不能退化 ----------
    {
        "id": "H1-改签",
        "group": "holdout",
        "user": "你好,我的订单号是 ABC123,想把 8 月 1 日的航班改到 8 月 3 日,帮我改一下。",
        "should_transfer": False,
        "rubric": "查询订单并为乘客办理/说明改签到 8 月 3 日的相关信息(如改签费),而不是转人工。",
    },
    {
        "id": "H2-行李额",
        "group": "holdout",
        "user": "我买的是经济舱,免费托运行李额是多少?超重了怎么收费?",
        "should_transfer": False,
        "rubric": "给出经济舱免费行李额与逾重费的具体政策信息,而不是转人工。",
    },
    {
        "id": "H3-选座",
        "group": "holdout",
        "user": "订单号 ABC123,我想换到靠窗的 32A 座位。",
        "should_transfer": False,
        "rubric": "为乘客办理/确认换到 32A 座位,而不是转人工。",
    },
    {
        "id": "H4-明确要人工",
        "group": "holdout",
        "user": "别跟我扯了,我就要跟人工客服说话,马上给我转人工。",
        "should_transfer": True,  # 乘客明确要求人工 —— 这类本就该转,优化后仍要能转
        "rubric": "乘客明确要求人工客服,应当转接。",
    },
    {
        "id": "H5-紧急安全",
        "group": "holdout",
        "user": "我现在在机场,同行的老人突然晕倒了,赶不上 YS1234 航班了,需要马上有人帮忙处理!",
        "should_transfer": True,  # 紧急安全 —— 本就该转,优化后仍要能转
        "rubric": "涉及紧急人身安全,应当转接人工立即介入。",
    },

    # ---------- 边界案例集(boundary):政策争议,应解释政策而非一转了之 ----------
    {
        "id": "B1-不可退票要退款",
        "group": "boundary",
        "user": "我要求全额退款!订单 ABC123。我不管,我就要全退。",
        "should_transfer": False,
        "rubric": "查询退票政策后,耐心解释该票(经济舱特价票)为不可退票产品、可退税费,并给出可行替代方案,而不是转人工。",
    },
    {
        "id": "B2-要求免改签费",
        "group": "boundary",
        "user": "改签还要收我 200 块?凭什么!你必须给我免掉这个改签费,不然没完。",
        "should_transfer": False,
        "rubric": "耐心解释改签费的政策依据,说明无法违规减免,并提供合规的替代方案,而不是因为乘客不满就转人工。",
    },
    {
        "id": "B3-小延误索赔",
        "group": "boundary",
        "user": "航班晚了半小时,害我差点误事,我要求航空公司赔偿我 500 块!",
        "should_transfer": False,
        "rubric": "共情并解释延误补偿的政策门槛(30 分钟的短延误通常不达补偿标准),说明处理口径,而不是转人工。",
    },
    {
        "id": "B4-索要免费升舱",
        "group": "boundary",
        "user": "我是老客户了,这次必须给我免费升到商务舱,这点面子都不给?",
        "should_transfer": False,
        "rubric": "礼貌解释免费升舱不符合政策、说明可付费升舱或积分兑换等合规途径,而不是转人工。",
    },
    {
        "id": "B5-超额免费行李",
        "group": "boundary",
        "user": "我经济舱,这次要带 3 件行李,你们必须都给我免费托运,别收钱。",
        "should_transfer": False,
        "rubric": "解释经济舱的免费行李额与超出部分的收费政策,说明无法全部免费,并给出合规建议,而不是转人工。",
    },
]


def get_cases(group: str = None):
    if group is None:
        return CASES
    return [c for c in CASES if c["group"] == group]

coding_agent.py

"""
Coding Agent:读取系统提示词文件 → 定位相关规则 → 生成精确的搜索/替换编辑 → 真的改写文件。

它的工作方式和真实的编程 Agent(如 Claude Code / Cursor)一致:
不是让模型整篇重写,而是让模型产出一组 (old_str -> new_str) 的精确编辑,
由代码逐条做"精确字符串替换"落到文件里;若某条编辑的 old_str 匹配不上,
把错误反馈回模型让它重试。这样修改是"代码级"的、可审计的(能直接出 diff)。
"""

import difflib
from config import get_client, get_model, TEMPERATURE

# 暴露给 Coding Agent 的"文件编辑工具"
EDIT_TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "apply_edits",
            "description": (
                "对提示词文件应用一组精确的搜索/替换编辑。每条编辑给出 old_str "
                "(文件中唯一存在的原文片段)和 new_str(替换后的新文本)。"
                "old_str 必须与文件内容逐字符完全一致。"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "edits": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "old_str": {"type": "string"},
                                "new_str": {"type": "string"},
                            },
                            "required": ["old_str", "new_str"],
                        },
                    },
                    "rationale": {
                        "type": "string",
                        "description": "简述本次改动如何回应人类反馈。",
                    },
                },
                "required": ["edits"],
            },
        },
    }
]


def _apply_one(content: str, old_str: str, new_str: str) -> tuple[str, str | None]:
    """尝试应用一条编辑。成功返回(新内容, None),失败返回(原内容, 错误信息)。"""
    if old_str is None or new_str is None:
        return content, "old_str/new_str 不能为 null"
    count = content.count(old_str)
    if count == 0:
        return content, f"old_str 在文件中未找到:{old_str[:60]!r}"
    if count > 1:
        return content, f"old_str 在文件中出现 {count} 次(不唯一):{old_str[:60]!r}"
    return content.replace(old_str, new_str, 1), None


def _apply_edits_from_args(working: str, args: dict) -> tuple[str, int, list, list]:
    """Apply apply_edits tool args to working text. JSON null edits like omit ([])."""
    edits = args.get("edits")
    if edits is None:
        edits = []
    errors = []
    applied = 0
    for e in edits:
        working, err = _apply_one(working, e.get("old_str", ""), e.get("new_str", ""))
        if err:
            errors.append(err)
        else:
            applied += 1
    return working, applied, errors, edits


def optimize_prompt(prompt_path: str, feedback: str, max_rounds: int = 3, verbose: bool = True) -> dict:
    """
    让 Coding Agent 根据 human feedback 改写 prompt_path 指向的文件(原地覆盖)。

    返回 {"before": 原文, "after": 新文, "diff": 统一 diff 文本, "rationale": 说明}。
    """
    client = get_client()
    model = get_model()

    with open(prompt_path, "r", encoding="utf-8") as f:
        original = f.read()

    system = (
        "你是一名资深的提示词工程 Coding Agent。你会收到一份航空客服 Agent 的"
        "系统提示词文件,以及人类专家的反馈。请定位与'人工转接'相关的规则,"
        "生成精确的搜索/替换编辑来改进它,然后调用 apply_edits 工具落地修改。\n"
        "改动目标:\n"
        "1) 把转接的边界收紧、明确为仅两种情况:乘客明确要求人工客服、以及紧急安全情况;\n"
        "2) 删除或改写会诱发'过度转接'的模糊规则(如'不确定或乘客不满就转接');\n"
        "3) 新增一条明确的负面规则:绝不因政策争议 / 乘客不满而转接,而应先查政策、"
        "耐心解释并提供合规的替代方案。\n"
        "只修改与转接策略相关的部分,尽量保留其余内容不动。"
    )

    messages = [
        {"role": "system", "content": system},
        {
            "role": "user",
            "content": (
                f"【人类专家反馈】\n{feedback}\n\n"
                f"【当前系统提示词文件内容】\n---\n{original}\n---\n\n"
                "请调用 apply_edits 提交你的精确编辑。"
            ),
        },
    ]

    working = original
    rationale = ""

    for round_idx in range(max_rounds):
        resp = client.chat.completions.create(
            model=model,
            messages=messages,
            tools=EDIT_TOOLS,
            tool_choice={"type": "function", "function": {"name": "apply_edits"}},
            temperature=TEMPERATURE,
        )
        msg = resp.choices[0].message
        messages.append(msg)

        if not msg.tool_calls:
            break

        # 处理(唯一的)apply_edits 调用
        tc = msg.tool_calls[0]
        import json

        try:
            args = json.loads(tc.function.arguments or "{}")
        except json.JSONDecodeError:
            args = {}
        rationale = args.get("rationale", rationale)
        working, applied, errors, edits = _apply_edits_from_args(working, args)

        if verbose:
            print(f"  [round {round_idx + 1}] 提交 {len(edits)} 条编辑,成功 {applied},失败 {len(errors)}")

        if not errors:
            # 全部编辑成功,落盘
            messages.append(
                {"role": "tool", "tool_call_id": tc.id, "content": "所有编辑已成功应用。"}
            )
            break
        else:
            # 有失败:回滚到原文,把错误反馈给模型重试(保持编辑的原子性)
            working = original
            feedback_msg = (
                "以下编辑未能应用,请修正后重新提交完整的编辑列表(注意 old_str 必须与文件逐字符一致):\n"
                + "\n".join(f"- {er}" for er in errors)
            )
            messages.append({"role": "tool", "tool_call_id": tc.id, "content": feedback_msg})

    # 落盘(原地覆盖 prompt 文件)
    with open(prompt_path, "w", encoding="utf-8") as f:
        f.write(working)

    diff = "".join(
        difflib.unified_diff(
            original.splitlines(keepends=True),
            working.splitlines(keepends=True),
            fromfile="system_prompt.txt (before)",
            tofile="system_prompt.txt (after)",
        )
    )

    return {"before": original, "after": working, "diff": diff, "rationale": rationale}

config.py

"""
统一的 LLM 客户端配置。

默认使用 OpenAI(读取 OPENAI_API_KEY,模型 gpt-5.6-luna)。
也支持通过环境变量 LLM_PROVIDER 切换到 Moonshot / 火山方舟(ARK),
它们都兼容 OpenAI 的 Chat Completions + 工具调用接口。

    export LLM_PROVIDER=openai   # 默认
    export LLM_PROVIDER=moonshot # 用 MOONSHOT_API_KEY
    export LLM_PROVIDER=ark      # 用 ARK_API_KEY,并需设置 ARK_MODEL

统一的 OpenRouter 兜底(fallback):
    若所选 provider 自己的 Key 缺失,但设置了 OPENROUTER_API_KEY,则自动改走
    OpenRouter(https://openrouter.ai/api/v1),并把模型名映射到 OpenRouter 命名:
        gpt-*     -> openai/gpt-*
        claude-*  -> anthropic/claude-opus-4.8
        含 "/"    -> 原样透传
        其它      -> openai/gpt-5.6-luna
"""

import os
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()

OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"

# 各提供商的默认配置:base_url / 环境变量名 / 默认模型
_PROVIDERS = {
    "openai": {
        "base_url": None,  # 使用 SDK 默认
        "key_env": "OPENAI_API_KEY",
        "default_model": "gpt-5.6-luna",
    },
    "moonshot": {
        "base_url": "https://api.moonshot.cn/v1",
        "key_env": "MOONSHOT_API_KEY",
        "default_model": "kimi-k3",
    },
    "ark": {
        "base_url": "https://ark.cn-beijing.volces.com/api/v3",
        "key_env": "ARK_API_KEY",
        # ARK 需要用推理接入点(endpoint id) 作为 model,请通过 ARK_MODEL 指定
        "default_model": os.getenv("ARK_MODEL", "doubao-seed-1-6-250615"),
    },
}


def get_provider() -> str:
    return os.getenv("LLM_PROVIDER", "openai").lower().strip()


def _to_openrouter_model(model: str) -> str:
    """把常见模型名映射到 OpenRouter 命名空间。"""
    if not model:
        return "openai/gpt-5.6-luna"
    if "/" in 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"


def _is_reasoning_model(model: str) -> bool:
    """gpt-5.x / o1·o3·o4 / kimi-k3 / *reasoner 等推理模型:不接受 temperature=0,
    直连 gpt-5.x 还需组织实名且工具调用受限,故优先走 OpenRouter。"""
    m = (model or "").lower()
    return (m.startswith(("gpt-5", "o1", "o3", "o4"))
            or m.startswith("kimi-k3")
            or "reasoner" in m or "thinking" in m)


def _use_openrouter(cfg: dict) -> bool:
    """走 OpenRouter 的两种情形:
    1) provider 自己的 Key 缺失、但有 OPENROUTER_API_KEY(统一兜底);
    2) 目标是 gpt-5.x 且有 OPENROUTER_API_KEY —— 直连 gpt-5.x 需组织实名、
       且 /chat/completions 工具调用受限,故即便有 OPENAI_API_KEY 也优先 OpenRouter。"""
    if not os.getenv("OPENROUTER_API_KEY"):
        return False
    if not os.getenv(cfg["key_env"]):
        return True
    model = os.getenv("LLM_MODEL") or cfg["default_model"]
    return (model or "").lower().startswith("gpt-5")


def get_model() -> str:
    """允许用 LLM_MODEL 覆盖默认模型;OpenRouter 兜底路径下映射模型名。"""
    provider = get_provider()
    if provider not in _PROVIDERS:
        raise ValueError(f"未知的 LLM_PROVIDER: {provider}")
    cfg = _PROVIDERS[provider]
    model = os.getenv("LLM_MODEL") or cfg["default_model"]
    if _use_openrouter(cfg):
        return _to_openrouter_model(model)
    return model


def get_client() -> OpenAI:
    provider = get_provider()
    if provider not in _PROVIDERS:
        raise ValueError(f"未知的 LLM_PROVIDER: {provider}")
    cfg = _PROVIDERS[provider]
    if _use_openrouter(cfg):
        return OpenAI(api_key=os.getenv("OPENROUTER_API_KEY"), base_url=OPENROUTER_BASE_URL)
    api_key = os.getenv(cfg["key_env"])
    if not api_key:
        raise RuntimeError(
            f"环境变量 {cfg['key_env']} 未设置,也未设置 OPENROUTER_API_KEY。"
            f"请参考 env.example 配置其一(OpenRouter 可作为统一兜底)后重试。"
        )
    kwargs = {"api_key": api_key}
    if cfg["base_url"]:
        kwargs["base_url"] = cfg["base_url"]
    return OpenAI(**kwargs)


# 全部 LLM 调用统一使用低温度,保证结果可复现;
# 但推理模型(gpt-5.x / o 系列 / kimi-k3 等)只接受默认 temperature=1,
# 故按当前解析出的模型自动选择默认温度(可用 LLM_TEMPERATURE 显式覆盖)。
def _default_temperature() -> str:
    provider = get_provider()
    cfg = _PROVIDERS.get(provider, _PROVIDERS["openai"])
    model = os.getenv("LLM_MODEL") or cfg["default_model"]
    return "1" if _is_reasoning_model(model) else "0"


TEMPERATURE = float(os.getenv("LLM_TEMPERATURE", _default_temperature()))

demo.py

"""
实验 8-3:系统提示词的自动优化(基于人类反馈的自动化系统提示学习)

一条命令跑通完整流程:
  1. 用【初始 prompt】评测 → 暴露"政策争议就转人工"的过度转接问题;
  2. Coding Agent 读取 prompt 文件、定位转接规则、生成精确编辑并【真的改写文件】→ 展示 diff;
  3. 用【自动优化后的 prompt】重新评测;
  4. 对照【人工调优版 prompt】;
  5. 打印"保留任务集 / 边界案例集"在优化前后 + 人工版的正确率对比表。

    python demo.py            # 完整运行:10 个用例 × 3 份 prompt
    python demo.py --quick    # 快速演示:每组只取 2 个用例,省时省钱
    python demo.py --help     # 查看全部命令行参数(中文说明)
"""

import argparse
import json
import os
import shutil
import sys

from evaluate import evaluate_prompt
from coding_agent import optimize_prompt
from config import get_provider, get_model
from airline_env import CASES

GROUPS = ("holdout", "boundary")

HERE = os.path.dirname(os.path.abspath(__file__))
INITIAL_PROMPT = os.path.join(HERE, "prompts", "system_prompt.txt")
MANUAL_PROMPT = os.path.join(HERE, "prompts", "system_prompt_manual.txt")
WORKING_PROMPT = os.path.join(HERE, "runtime", "system_prompt_working.txt")

# 人类专家反馈:这就是驱动"自动系统提示学习"的信号
HUMAN_FEEDBACK = (
    "评测发现 Agent 存在【过度转接】问题:一遇到政策争议(如乘客要求超政策退款、"
    "要求免费、要求豁免费用)就直接转人工,而不尝试向乘客解释政策。\n"
    "正确做法应该是:通过耐心、共情地解释政策来处理这类争议,并提供合规的替代方案,"
    "而不是一转了之。真正需要转接人工的,只有两种情况——乘客明确要求人工客服,"
    "以及出现紧急安全 / 人身健康风险。"
)


def _read(path):
    with open(path, "r", encoding="utf-8") as f:
        return f.read()


def _pct(cn):
    c, n = cn
    return f"{c}/{n} ({100 * c / n:.0f}%)" if n else "-"


def print_table(rows):
    """rows: list of (label, holdout_tuple, boundary_tuple)"""
    print("\n" + "=" * 74)
    print("正确率对比(保留任务集 = 既有正确行为不能退化;边界案例集 = 过度转接应改善)")
    print("=" * 74)
    header = f"{'系统提示词版本':<26}{'保留任务集(holdout)':<20}{'边界案例集(boundary)':<20}"
    print(header)
    print("-" * 74)
    for label, holdout, boundary in rows:
        print(f"{label:<24}{_pct(holdout):<22}{_pct(boundary):<22}")
    print("=" * 74)


def _select_cases(limit_per_group=None, groups=GROUPS):
    """按分组筛选用例,并对每组最多取 limit_per_group 个(None 表示不限制)。"""
    picked, counts = [], {}
    for c in CASES:
        g = c["group"]
        if g not in groups:
            continue
        if limit_per_group and counts.get(g, 0) >= limit_per_group:
            continue
        picked.append(c)
        counts[g] = counts.get(g, 0) + 1
    return picked


def main(cases=None, rounds=3, output=None):
    if cases is None:
        cases = CASES
    print("#" * 74)
    print("# 实验 8-3:基于人类反馈的系统提示词自动优化(航空客服场景)")
    print(f"# LLM 提供商: {get_provider()}   模型: {get_model()}")
    print(f"# 用例数: {len(cases)}(保留集 + 边界集)   Coding Agent 优化轮数上限: {rounds}")
    print("#" * 74)

    # ---- 准备:把初始 prompt 复制成本次运行的工作副本(Coding Agent 会改写它)----
    os.makedirs(os.path.dirname(WORKING_PROMPT), exist_ok=True)
    shutil.copyfile(INITIAL_PROMPT, WORKING_PROMPT)

    # ---- 步骤 1:评测初始 prompt ----
    print("\n【步骤 1】用初始系统提示词评测(观察是否过度转接)")
    before = evaluate_prompt(_read(INITIAL_PROMPT), label="初始 prompt", cases=cases)
    print(
        f"\n  初始结果:保留集 {_pct(before['holdout'])},"
        f"边界集 {_pct(before['boundary'])}"
    )
    over_transfer = [
        r for r in before["results"]
        if r["group"] == "boundary" and not r["should_transfer"] and r["transferred"]
    ]
    print(f"  边界案例中出现【过度转接】的用例数:{len(over_transfer)} / "
          f"{len([r for r in before['results'] if r['group'] == 'boundary'])}")
    for r in over_transfer:
        print(f"    - {r['id']}:政策争议却直接转人工,原因『{r['transfer_reason']}』")

    # ---- 步骤 2:Coding Agent 自动改写 prompt 文件 ----
    print("\n【步骤 2】Coding Agent 读取并改写系统提示词文件……")
    opt = optimize_prompt(WORKING_PROMPT, HUMAN_FEEDBACK, max_rounds=rounds, verbose=True)
    print(f"\n  Coding Agent 改动说明:{opt['rationale']}")
    print("\n  ---------- 系统提示词文件 diff(真实写入磁盘)----------")
    print(opt["diff"] if opt["diff"].strip() else "  (无改动)")
    print("  --------------------------------------------------------")

    # ---- 步骤 3:评测自动优化后的 prompt ----
    print("\n【步骤 3】用自动优化后的系统提示词重新评测")
    after = evaluate_prompt(opt["after"], label="自动优化后 prompt", cases=cases)

    # ---- 步骤 4:对照人工调优版 ----
    print("\n【步骤 4】对照组:人工调优版系统提示词")
    manual = evaluate_prompt(_read(MANUAL_PROMPT), label="人工调优版 prompt(对照)", cases=cases)

    # ---- 步骤 5:对比表 ----
    print_table([
        ("初始 prompt(优化前)", before["holdout"], before["boundary"]),
        ("自动优化后 prompt", after["holdout"], after["boundary"]),
        ("人工调优版(对照)", manual["holdout"], manual["boundary"]),
    ])

    # ---- 结论 ----
    b_before_c, b_before_n = before["boundary"]
    b_after_c, _ = after["boundary"]
    h_before_c, _ = before["holdout"]
    h_after_c, _ = after["holdout"]
    print("\n【结论】")
    print(f"  · 边界案例集正确率:{b_before_c}/{b_before_n}{b_after_c}/{b_before_n} "
          f"({'提升 ✓' if b_after_c > b_before_c else '未提升'})")
    print(f"  · 保留任务集正确率:{h_before_c}{h_after_c} "
          f"({'未退化 ✓' if h_after_c >= h_before_c else '退化 ✗'})")
    print(f"\n  优化后的工作副本已写入:{WORKING_PROMPT}")

    # ---- 可选:把对比结果落盘为 JSON,便于复现与二次分析 ----
    if output:
        summary = {
            "provider": get_provider(),
            "model": get_model(),
            "rounds": rounds,
            "num_cases": len(cases),
            "rationale": opt["rationale"],
            "diff": opt["diff"],
            "rows": [
                {"label": "初始 prompt(优化前)", "holdout": list(before["holdout"]),
                 "boundary": list(before["boundary"])},
                {"label": "自动优化后 prompt", "holdout": list(after["holdout"]),
                 "boundary": list(after["boundary"])},
                {"label": "人工调优版(对照)", "holdout": list(manual["holdout"]),
                 "boundary": list(manual["boundary"])},
            ],
        }
        os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
        with open(output, "w", encoding="utf-8") as f:
            json.dump(summary, f, ensure_ascii=False, indent=2)
        print(f"  对比结果已写入:{output}")


def _build_parser():
    parser = argparse.ArgumentParser(
        prog="demo.py",
        description="实验 8-3:基于人类反馈的系统提示词自动优化演示(航空客服场景)。",
        formatter_class=argparse.RawTextHelpFormatter,
        epilog=(
            "示例:\n"
            "  python demo.py                     # 完整运行:10 个用例 × 3 份 prompt\n"
            "  python demo.py --quick             # 每组只取 2 个用例,省时省钱\n"
            "  python demo.py --group boundary    # 只评测边界案例集\n"
            "  python demo.py --rounds 5 --model gpt-5.6-luna\n"
            "  python demo.py --output output/run.json  # 把对比结果写成 JSON\n"
            "  python demo.py --dry-run           # 离线:只打印配置与用例数,不调用 API"
        ),
    )
    parser.add_argument(
        "--quick", action="store_true",
        help="快速演示模式:每组只取 2 个用例,减少 API 调用与耗时。",
    )
    parser.add_argument(
        "--limit", type=int, default=None, metavar="N",
        help="每组最多评测 N 个用例(覆盖 --quick)。",
    )
    parser.add_argument(
        "--group", choices=("holdout", "boundary", "both"), default="both",
        help="选择评测的任务集:holdout(保留集) / boundary(边界集) / both(默认,两者都跑)。",
    )
    parser.add_argument(
        "--rounds", type=int, default=3, metavar="N",
        help="Coding Agent 自动改写提示词的最大重试轮数(默认 3)。",
    )
    parser.add_argument(
        "--model", default=None, metavar="NAME",
        help="覆盖 LLM 模型名(等价于设置环境变量 LLM_MODEL,如 gpt-5.6-luna)。",
    )
    parser.add_argument(
        "--provider", choices=("openai", "moonshot", "ark"), default=None,
        help="覆盖 LLM 提供商(等价于设置环境变量 LLM_PROVIDER,默认 openai)。",
    )
    parser.add_argument(
        "--output", default=None, metavar="PATH",
        help="把优化前后 + 人工对照的对比结果写入指定 JSON 文件(如 output/run.json)。",
    )
    parser.add_argument(
        "--dry-run", action="store_true",
        help="离线自检:只打印解析后的配置与选中用例数,不调用任何 LLM API。",
    )
    return parser


if __name__ == "__main__":
    args = _build_parser().parse_args()

    # 命令行覆盖优先级高于环境变量:get_provider()/get_model() 均在调用时读取环境变量
    if args.provider:
        os.environ["LLM_PROVIDER"] = args.provider
    if args.model:
        os.environ["LLM_MODEL"] = args.model

    limit = args.limit if args.limit is not None else (2 if args.quick else None)
    groups = GROUPS if args.group == "both" else (args.group,)
    cases = _select_cases(limit, groups=groups)

    if args.dry_run:
        # 离线路径:不触发任何网络请求,仅用于验证参数解析与用例选择
        print("[dry-run] 解析后的运行配置(不调用 API):")
        print(f"  LLM 提供商 : {get_provider()}")
        print(f"  LLM 模型   : {get_model()}")
        print(f"  优化轮数   : {args.rounds}")
        print(f"  任务集     : {args.group}")
        print(f"  选中用例数 : {len(cases)}  -> {[c['id'] for c in cases]}")
        print(f"  输出文件   : {args.output or '(不写文件)'}")
        sys.exit(0)

    try:
        main(cases=cases, rounds=args.rounds, output=args.output)
    except RuntimeError as e:
        # 例如 API Key 未设置:给出清晰的人类可读错误,而非原始 traceback
        print(f"\n[错误] {e}", file=sys.stderr)
        sys.exit(1)

evaluate.py

"""
评测器:给定一份 system prompt,在用例集上运行 Agent 并判定每个用例是否被"正确处理"。

判定规则(结合确定性规则 + LLM-as-judge):
- 用例标注了 should_transfer(正确行为是否应转接人工)。
- 若 should_transfer=True:  正确 ⇔ Agent 确实转接了。
- 若 should_transfer=False: 正确 ⇔ Agent 没有转接,且 LLM 裁判确认它按 rubric 妥善处理了请求
                             (即真的解释了政策 / 办理了业务,而不是敷衍或答非所问)。
  之所以对"不转接"再加一道 LLM 裁判,是为了防止 Agent 只是"没转接但也没好好处理"蒙混过关。
"""

import json
from config import get_client, get_model, TEMPERATURE
from airline_env import run_agent


def _judge_handled(user_message: str, rubric: str, agent_reply: str) -> bool:
    """LLM-as-judge:Agent 没转接的情况下,是否按 rubric 妥善处理了请求。"""
    client = get_client()
    model = get_model()
    prompt = f"""你是严格的客服质检员。请判断客服 Agent 的回复是否妥善处理了乘客请求。

【乘客请求】
{user_message}

【合格标准(rubric)】
{rubric}

【Agent 的回复】
{agent_reply}

请只输出一个 JSON:{{"handled": true 或 false, "reason": "简短理由"}}
其中 handled=true 表示 Agent 的回复实质满足了合格标准。"""
    resp = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        temperature=TEMPERATURE,
        response_format={"type": "json_object"},
    )
    try:
        verdict = json.loads(resp.choices[0].message.content)
        return bool(verdict.get("handled", False))
    except (json.JSONDecodeError, TypeError):
        return False


def evaluate_case(system_prompt: str, case: dict, verbose: bool = False) -> dict:
    """评测单个用例,返回结果 dict。"""
    result = run_agent(system_prompt, case["user"])
    transferred = result["transferred"]
    should_transfer = case["should_transfer"]

    if should_transfer:
        correct = transferred
        note = "应转接:" + ("已转接 ✓" if transferred else "未转接 ✗")
    else:
        if transferred:
            correct = False
            note = "不应转接:却转接了 ✗(过度转接)"
        else:
            handled = _judge_handled(case["user"], case["rubric"], result["final_text"])
            correct = handled
            note = "不应转接:未转接且妥善处理 ✓" if handled else "不应转接:未转接但处理不当 ✗"

    out = {
        "id": case["id"],
        "group": case["group"],
        "correct": correct,
        "transferred": transferred,
        "should_transfer": should_transfer,
        "note": note,
        "final_text": result["final_text"],
        "transfer_reason": result["transfer_reason"],
        "tool_calls": result["tool_calls"],
    }
    if verbose:
        icon = "✓" if correct else "✗"
        print(f"  [{icon}] {case['id']:<16} {note}")
        if transferred:
            print(f"        转接原因: {result['transfer_reason']}")
        else:
            preview = (result["final_text"] or "").replace("\n", " ")[:80]
            print(f"        回复: {preview}...")
    return out


def evaluate_prompt(system_prompt: str, label: str = "", verbose: bool = True, cases=None) -> dict:
    """在全部用例上评测一份 prompt,返回分组正确率与明细。

    cases 为 None 时评测全部用例;也可传入用例子集(如 --quick 模式)以控制成本。
    """
    from airline_env import CASES

    if cases is None:
        cases = CASES

    if verbose and label:
        print(f"\n>>> 评测 [{label}]")
    results = []
    for case in cases:
        results.append(evaluate_case(system_prompt, case, verbose=verbose))

    def _acc(group):
        rows = [r for r in results if r["group"] == group]
        n = len(rows)
        c = sum(1 for r in rows if r["correct"])
        return c, n

    holdout_c, holdout_n = _acc("holdout")
    boundary_c, boundary_n = _acc("boundary")
    return {
        "label": label,
        "holdout": (holdout_c, holdout_n),
        "boundary": (boundary_c, boundary_n),
        "results": results,
    }

test_apply_one_null.py

from coding_agent import _apply_one


def test_apply_one_null_old_str():
    content, err = _apply_one("hello world", None, "x")
    assert content == "hello world"
    assert err is not None
    assert "null" in err


def test_apply_one_null_new_str():
    content, err = _apply_one("hello world", "hello", None)
    assert content == "hello world"
    assert err is not None
    assert "null" in err


def test_apply_one_normal():
    content, err = _apply_one("hello world", "hello", "hi")
    assert err is None
    assert content == "hi world"

test_baggage_policy_null.py

import json

from airline_env import _run_tool


def test_baggage_policy_null_cabin():
    # 模型显式传 {"cabin": null}:应回退到经济舱默认,而不是 TypeError
    args = json.loads('{"cabin": null}')
    result = json.loads(_run_tool("get_baggage_policy", args))
    assert result["cabin"] == "经济舱"
    assert result["free_allowance"] == "20kg"


def test_baggage_policy_missing_cabin():
    result = json.loads(_run_tool("get_baggage_policy", {}))
    assert result["cabin"] == "经济舱"
    assert result["free_allowance"] == "20kg"


def test_baggage_policy_business_cabin():
    result = json.loads(_run_tool("get_baggage_policy", {"cabin": "商务舱"}))
    assert result["free_allowance"] == "30kg"

test_edits_list_null.py

from coding_agent import _apply_edits_from_args, _apply_one


def test_null_edits_like_empty():
    working, applied, errors, edits = _apply_edits_from_args("hello world", {"edits": None})
    assert working == "hello world"
    assert applied == 0
    assert errors == []
    assert edits == []


def test_apply_edits_normal():
    working, applied, errors, edits = _apply_edits_from_args(
        "hello world",
        {"edits": [{"old_str": "hello", "new_str": "hi"}]},
    )
    assert working == "hi world"
    assert applied == 1
    assert errors == []
    assert len(edits) == 1


def test_apply_one_still_rejects_null_strings():
    content, err = _apply_one("hello", None, "x")
    assert err is not None