跳转至

dynamic-form

第5章 · Coding Agent 与代码生成 · 配套项目 chapter5/dynamic-form

项目说明

实验 5-9:动态表单生成的意图澄清系统(★★)

目的

验证 Agent 在面对信息不完整的用户请求时,不是逐条一问一答,而是动态生成 一个自包含的 HTML 表单来一次性澄清意图。表单内置级联逻辑(某些字段仅在特定 选择下才显示、某些下拉选项随另一字段动态变化),用户一次提交即可补全全部 信息;前端把表单汇总成 JSON 交回 Agent,Agent 解析后继续任务。

验收场景:用户输入"我想订一张去北京的机票",Agent 生成的表单包含: - 出发城市(文本输入) - 出发日期(日期选择器) - 旅行类型(单选:单程 / 往返) - 返程日期(仅当选择"往返"时才显示) ← 级联逻辑①:显示/隐藏 - 舱位(下拉:经济舱 / 公务舱 / 头等舱) - 免费托运行李额度(可选项随"舱位"动态变化) ← 级联逻辑②:动态选项

机制

demo.py 分三步验证,两种运行模式机制完全一致,只是"谁来写表单代码"不同:

  • 在线(默认):把用户请求发给 OpenAI,system prompt 约束它输出一个自包含的 HTML(内联 <style> + <script>,含级联显示逻辑和"提交汇总为 JSON"逻辑), 需要 OPENAI_API_KEY
  • 离线(--offline:不调用 LLM,用内置机票 schema 确定性渲染同样的级联 表单,无需 API Key。离线渲染出的表单同样真实可用、可在浏览器打开,且含两类 级联逻辑(显示/隐藏 + 下拉选项动态更新)。未设置 OPENAI_API_KEY 时自动回落到 离线模式。

三步流程:

  1. 生成表单:在线让模型生成,或离线由内置 schema 渲染,保存为 generated_form.html(路径可用 --output 覆盖)。
  2. 结构化校验(不依赖浏览器):用 BeautifulSoup + 正则检查表单确实含要求的 字段,且返程字段带"仅往返显示"的 JS toggle 逻辑,并打印级联逻辑的脚本证据。
  3. 模拟提交:构造一份用户提交的 JSON(往返场景),喂回 Agent;Agent 解析后 输出订票摘要(在线交给模型,离线用确定性模板),验证"解析 JSON → 继续任务"闭环。

运行

pip install -r requirements.txt
cp env.example .env                 # 填入 OPENAI_API_KEY(未配置时设 OPENROUTER_API_KEY 自动改走 OpenRouter)

python demo.py                      # 在线:Agent 调 OpenAI 生成(需 API Key)
python demo.py --offline            # 离线:内置 schema 确定性渲染,无需 API Key
python demo.py --offline --serve    # 离线渲染后起本地服务,浏览器实时体验级联/提交
python demo.py --model gpt-5.6     # 可选:覆盖在线模型(--offline 下忽略)
python demo.py --request "我想订去东京的机票"   # 可选:自定义模糊请求
python demo.py --help               # 查看全部参数

命令行参数:

参数 说明 默认
-r / --request TEXT 用户的模糊请求(意图) 我想订一张去北京的机票
-o / --output PATH 生成的 HTML 输出路径(相对路径按脚本目录解析) generated_form.html
--model NAME 覆盖在线模型名(--offline 下忽略) 环境变量 MODEL,缺省 gpt-5.6-luna
--offline 离线模式:内置 schema 确定性渲染,无需 API Key 关(未设 Key 时自动开启)
--serve 生成后起本地 HTTP 服务并打开浏览器,真实体验级联/提交
--port N --serve 使用的端口 8000

跑通后: - 生成的表单存为 generated_form.html,可手动在浏览器打开(或用 --serve 自动打开),切换"单程/往返"即可看到返程日期字段的级联显示效果,切换舱位可看到 行李额度下拉的动态更新,点"提交"会在页面底部打印汇总 JSON。 - 终端会打印字段校验结果、级联逻辑证据、以及 Agent 对提交 JSON 的解析摘要。

环境变量: - OPENAI_API_KEY(在线模式必填;未设置时自动回落到离线模式) - OPENAI_BASE_URL(可选,兼容 OpenAI 协议的第三方端点) - MODEL(可选,默认 gpt-5.6-luna

真实运行输出

离线模式(--offline,确定性渲染,无需 API Key)

[步骤 2] 结构化校验表单字段与级联逻辑:
  [PASS] 出发城市(文本输入)
  [PASS] 出发日期(日期选择器)
  [PASS] 旅行类型(单选:单程)
  [PASS] 旅行类型(单选:往返)
  [PASS] 返程日期(日期选择器)
  [PASS] 返程字段级联逻辑(仅往返显示)

[步骤 3] 解析 JSON 并继续任务,输出订票摘要:
已收到您的订票信息:上海 → 北京,出发日期 2026-08-01。
行程类型:往返,返程日期 2026-08-07。
舱位:公务舱,免费托运 2 件。
正在为您检索航班...

在线模式(默认,模型 gpt-5.6-luna

[步骤 2] 结构化校验表单字段与级联逻辑:
  [PASS] 出发城市(文本输入) / [PASS] 出发日期(日期选择器)
  [PASS] 旅行类型(单选:单程/往返) / [PASS] 返程日期(日期选择器)
  [PASS] 返程字段级联逻辑(仅往返显示)

  级联逻辑证据(脚本节选):
    | const returnDateField = document.getElementById('return_date_field');
    | returnDateField.style.display = roundTrip ? 'block' : 'none';

[步骤 3] Agent 解析 JSON 并继续任务,输出订票摘要:
您选择的航段为:从上海出发,前往北京。出发日期为2026年8月1日,返程日期为
2026年8月7日。行程类型为往返,舱位为商务舱,携带行李数量为2件。正在为您检索航班...

局限

  • 字段命名不完全可控(仅在线模式):不同模型/温度下,生成的 name、id 写法 可能不同。system prompt 已约定英文 name 标识(departure_city / departure_date / trip_type / return_date),校验也采用鲁棒的关键词/属性 匹配(先按约定 name 找,找不到再退化到语义匹配 + 文本关键词),因此偶发命名 漂移一般仍能通过。若某项 FAIL,可打开 generated_form.html 查看模型实际输出。 离线模式由内置 schema 确定性渲染,命名/结构完全稳定,不受此限。
  • 级联效果的两种验证:默认不依赖真实浏览器,级联逻辑通过"静态解析 JS + 关键词 匹配"来间接验证;要看真实级联点击效果,加 --serve(离线渲染的表单 + 本地服务) 或手动打开生成的 HTML。
  • 提交是模拟的:步骤 3 用一份构造的 JSON 代替真实前端提交,用来验证 Agent 的"解析 → 继续任务"环节;真实系统里这份 JSON 由表单 submit 回调 POST 回后端。
  • 在线生成质量依赖模型;temperature=0 以尽量稳定复现。

源代码

demo.py

"""实验 5-9:动态表单生成的意图澄清系统(★★)

核心思路
--------
当用户请求缺少关键信息时,Agent 不是逐条追问,而是**动态生成一个自包含的
HTML 表单**(含级联显示逻辑),让用户"一次提交"补全所有澄清点;前端把表单
汇总成 JSON 交回 Agent,Agent 解析后继续任务。

本 demo 分三步验证(不依赖真实浏览器):
  1) 生成澄清表单 HTML,保存为 generated_form.html;
  2) 用 BeautifulSoup 结构化校验:确实含 出发城市/出发日期/旅行类型(单程,往返)/
     返程日期,且返程字段带"仅往返显示"的级联 JS 逻辑;
  3) 模拟一次用户提交(构造 JSON),喂回 Agent,Agent 解析后打印订票摘要。

两种运行模式(机制完全一致,只是"谁来写表单代码"不同):
  * 默认(在线):让 Agent 真实调用 OpenAI 生成表单 HTML,需 OPENAI_API_KEY;
  * --offline   :不调用 LLM,用内置机票 schema **确定性渲染**级联表单,无需 API Key。
    离线渲染出的表单同样是真实可用、可在浏览器打开、含两类级联逻辑(显示/隐藏 +
    动态更新可选项)的自包含 HTML。

运行:
  python demo.py                       # 在线:Agent 调 OpenAI 生成(需 API Key)
  python demo.py --offline             # 离线:内置 schema 确定性渲染,无需 API Key
  python demo.py --offline --serve     # 离线渲染后启动本地服务,浏览器实时体验级联/提交
  python demo.py --help                # 查看全部参数

环境变量:
  OPENAI_API_KEY   (在线模式必填;未设置时自动回落到 --offline)
  OPENAI_BASE_URL  (可选,切换到兼容 OpenAI 协议的服务)
  MODEL            (可选,默认 gpt-5.6-luna)
"""

import os
import re
import json
import argparse

from bs4 import BeautifulSoup

# openai 仅在线模式需要;离线模式不导入,缺包也能跑(延迟到用时再 import)

# 加载 .env(若存在),方便本地运行
try:
    from dotenv import load_dotenv

    load_dotenv()
except Exception:  # dotenv 是可选依赖
    pass


SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
USER_REQUEST = "我想订一张去北京的机票"


# ---------------------------------------------------------------------------
# 配置(在线模式)
# ---------------------------------------------------------------------------
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"


def _map_to_openrouter_model(model: str) -> str:
    """把直连模型名映射为 OpenRouter 上的 id(非可映射 id 统一兜底到当前廉价旗舰)。"""
    if not model or "/" in model:
        return model or "openai/gpt-5.6-luna"
    m = model.lower()
    if m.startswith(("gpt-", "o1", "o3", "o4")):
        return "openai/" + model
    if m.startswith("claude"):
        if "haiku" in m:
            return "anthropic/claude-haiku-4.5"
        if "sonnet" in m:
            return "anthropic/claude-sonnet-4.6"
        return "anthropic/claude-opus-4.8"
    if m.startswith("gemini"):
        return "google/" + model
    return "openai/gpt-5.6-luna"


def build_client_and_model(model_override=None):
    """构造 OpenAI 客户端与模型名,含通用 OpenRouter 兜底。"""
    from openai import OpenAI  # 延迟导入:离线模式无需安装/配置 openai

    model = model_override or os.getenv("MODEL", "gpt-5.6-luna")
    api_key = os.getenv("OPENAI_API_KEY")
    base_url = os.getenv("OPENAI_BASE_URL")
    orkey = os.getenv("OPENROUTER_API_KEY")
    # 无直连 key,或默认 gpt-5.x(直连需组织实名认证)时改走 OpenRouter。
    prefer_or = bool(orkey) and (model or "").lower().startswith("gpt-5")
    if prefer_or or (not api_key and orkey):
        api_key, base_url, model = orkey, OPENROUTER_BASE_URL, _map_to_openrouter_model(model)
    if not api_key:
        raise SystemExit("未找到 OPENAI_API_KEY(或 OPENROUTER_API_KEY 兜底),请先在环境变量或 .env 中设置,或改用 --offline。")

    client = (
        OpenAI(api_key=api_key, base_url=base_url)
        if base_url
        else OpenAI(api_key=api_key)
    )
    return client, model


def _temp_for(model):
    """推理模型(gpt-5 / o 系列等)不接受 temperature=0。"""
    return (1 if any(k in (model or "").lower()
                     for k in ("gpt-5", "o1", "o3", "o4", "thinking", "reasoner", "kimi-k3"))
            else 0)


# ---------------------------------------------------------------------------
# 步骤 1(在线):让 Agent 生成澄清表单
# ---------------------------------------------------------------------------
FORM_SYSTEM_PROMPT = """你是一个"意图澄清"助手。用户会给出一个信息不完整的请求,
你的任务不是直接追问,而是**生成一个自包含的 HTML 表单**,让用户一次性补全所有
缺失信息。

严格要求(订机票场景):
1. 表单必须包含以下字段,字段的 name 属性必须使用给定的英文标识:
   - 出发城市:文本输入框,name="departure_city"
   - 出发日期:日期选择器 <input type="date">,name="departure_date"
   - 旅行类型:单选按钮 <input type="radio" name="trip_type">,两个选项
     value="one_way"(单程)和 value="round_trip"(往返)
   - 返程日期:日期选择器,name="return_date",放在 id="return_date_field" 的
     容器里
2. **级联逻辑(关键)**:返程日期字段默认隐藏,只有当旅行类型选择"往返"
   (round_trip) 时才通过 JavaScript 显示出来;选回"单程"时再次隐藏。
3. 提交时用 JavaScript 阻止默认提交,把所有字段汇总为一个 JSON 对象,
   key 使用上面的英文 name,并显示在 id="result" 的元素里
   (例如 <pre id="result"></pre>)。
4. 输出必须是**完整、自包含**的 HTML(含 <style> 和 <script>,内联,不引用外部
   资源),可直接保存为 .html 文件在浏览器打开。

只输出 HTML 代码本身,不要任何解释文字,不要用 markdown 代码块包裹。"""


def generate_form(client, model, user_request):
    """调用模型生成澄清表单的 HTML。"""
    resp = client.chat.completions.create(
        model=model,
        temperature=_temp_for(model),
        messages=[
            {"role": "system", "content": FORM_SYSTEM_PROMPT},
            {
                "role": "user",
                "content": f"用户请求:{user_request}\n请为其中缺失的信息生成澄清表单。",
            },
        ],
    )
    html = resp.choices[0].message.content.strip()
    # 模型偶尔会用 ```html ... ``` 包裹,稳妥起见剥掉围栏
    html = re.sub(r"^```(?:html)?\s*", "", html)
    html = re.sub(r"\s*```$", "", html)
    return html.strip()


# ---------------------------------------------------------------------------
# 步骤 1(离线):内置机票 schema + 确定性级联表单渲染器
# ---------------------------------------------------------------------------
# schema 用声明式方式描述澄清点,渲染器把它变成自包含的级联 HTML。
# 支持两类"级联":
#   show_when   —— 某字段仅当另一字段等于某值时才显示(返程日期 = 仅往返显示);
#   options_when —— 某下拉框的可选项随另一字段的取值动态更新(行李额度 = 随舱位变化)。
FLIGHT_FORM_SCHEMA = {
    "title": "机票预订 · 意图澄清表单",
    "fields": [
        {
            "name": "departure_city",
            "label": "出发城市",
            "type": "text",
            "placeholder": "如:上海",
            "required": True,
        },
        {
            "name": "departure_date",
            "label": "出发日期",
            "type": "date",
            "required": True,
        },
        {
            "name": "trip_type",
            "label": "旅行类型",
            "type": "radio",
            "default": "one_way",
            "options": [
                {"value": "one_way", "label": "单程"},
                {"value": "round_trip", "label": "往返"},
            ],
        },
        {
            "name": "return_date",
            "label": "返程日期",
            "type": "date",
            "container_id": "return_date_field",
            # 级联①:仅当 trip_type == round_trip 时显示
            "show_when": {"field": "trip_type", "equals": "round_trip"},
        },
        {
            "name": "cabin_class",
            "label": "舱位",
            "type": "select",
            "default": "economy",
            "options": [
                {"value": "economy", "label": "经济舱"},
                {"value": "business", "label": "公务舱"},
                {"value": "first", "label": "头等舱"},
            ],
        },
        {
            "name": "baggage_count",
            "label": "免费托运行李额度",
            "type": "select",
            # 级联②:可选项随舱位(cabin_class)动态变化
            "options_when": {
                "field": "cabin_class",
                "map": {
                    "economy": [
                        {"value": "0", "label": "仅手提行李"},
                        {"value": "1", "label": "1 件(≤23kg)"},
                    ],
                    "business": [
                        {"value": "0", "label": "仅手提行李"},
                        {"value": "1", "label": "1 件(≤32kg)"},
                        {"value": "2", "label": "2 件(≤32kg)"},
                    ],
                    "first": [
                        {"value": "1", "label": "1 件(≤32kg)"},
                        {"value": "2", "label": "2 件(≤32kg)"},
                        {"value": "3", "label": "3 件(≤32kg)"},
                    ],
                },
            },
        },
    ],
}


def _extract_destination(user_request):
    """从"去XX的机票"里粗抽目的地,作为表单常量随提交一起带回。抽不到就回落 None。"""
    m = re.search(r"去(.+?)的?(?:机票|航班|票)", user_request)
    if m:
        return m.group(1).strip()
    return None


def _render_field_html(f):
    """把单个字段 schema 渲染成 HTML 片段。"""
    ftype = f["type"]
    name = f["name"]
    label = f.get("label", "")
    required = " required" if f.get("required") else ""

    if ftype in ("text", "date"):
        ph = f' placeholder="{f["placeholder"]}"' if f.get("placeholder") else ""
        inner = (
            f'<label class="fld-label" for="{name}">{label}</label>'
            f'<input class="fld-input" type="{ftype}" id="{name}" name="{name}"{ph}{required}>'
        )
    elif ftype == "radio":
        opts = []
        for o in f["options"]:
            checked = " checked" if f.get("default") == o["value"] else ""
            opts.append(
                f'<label class="radio"><input type="radio" name="{name}" '
                f'value="{o["value"]}"{checked}> {o["label"]}</label>'
            )
        inner = (
            f'<span class="fld-label">{label}</span>'
            f'<div class="radio-row">{"".join(opts)}</div>'
        )
    elif ftype == "select":
        opts = ""
        for o in f.get("options", []):
            selected = " selected" if f.get("default") == o["value"] else ""
            opts += f'<option value="{o["value"]}"{selected}>{o["label"]}</option>'
        inner = (
            f'<label class="fld-label" for="{name}">{label}</label>'
            f'<select class="fld-input" id="{name}" name="{name}">{opts}</select>'
        )
    else:
        raise ValueError(f"未知字段类型:{ftype}")

    container_id = f.get("container_id")
    cid = f' id="{container_id}"' if container_id else ""
    # 带 show_when 的字段默认隐藏,交给 JS 在加载时按当前取值决定是否显示(避免闪现)
    hidden = ' style="display:none"' if f.get("show_when") else ""
    return f'<div class="field"{cid}{hidden}>{inner}</div>'


# 通用级联运行时:读取内联的 FORM_CONFIG,处理 show_when / options_when,
# 并在提交时把"当前可见字段"汇总成 JSON。纯原生 JS,无外部依赖。
_RUNTIME_JS = """
const FORM_CONFIG = __CONFIG__;
const form = document.getElementById('clarify-form');

function valueOf(name) {
  const el = form.elements[name];
  if (!el) return '';
  return el.value || '';
}

function applyCascade() {
  FORM_CONFIG.fields.forEach(function (f) {
    // 级联①:show_when —— 控制字段容器显示/隐藏
    if (f.show_when) {
      const box = document.getElementById(f.container_id);
      if (box) {
        const show = valueOf(f.show_when.field) === f.show_when.equals;
        box.style.display = show ? '' : 'none';
      }
    }
    // 级联②:options_when —— 根据另一字段的取值动态重建下拉可选项
    if (f.options_when) {
      const sel = form.elements[f.name];
      if (sel) {
        const key = valueOf(f.options_when.field);
        const opts = f.options_when.map[key] || [];
        const prev = sel.value;
        sel.innerHTML = '';
        opts.forEach(function (o) {
          const opt = document.createElement('option');
          opt.value = o.value;
          opt.textContent = o.label;
          sel.appendChild(opt);
        });
        if (opts.some(function (o) { return o.value === prev; })) sel.value = prev;
      }
    }
  });
}

form.addEventListener('change', applyCascade);
applyCascade();  // 首次加载即应用一次

form.addEventListener('submit', function (e) {
  e.preventDefault();
  const data = {};
  FORM_CONFIG.fields.forEach(function (f) {
    // 隐藏(未展开)的级联字段不计入提交结果
    if (f.container_id) {
      const box = document.getElementById(f.container_id);
      if (box && box.style.display === 'none') return;
    }
    const v = valueOf(f.name);
    if (v !== '') data[f.name] = v;
  });
  Object.assign(data, FORM_CONFIG.constants || {});
  document.getElementById('result').textContent = JSON.stringify(data, null, 2);
});
"""

_PAGE_TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<style>
  :root { color-scheme: light dark; }
  body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
         max-width: 560px; margin: 32px auto; padding: 0 20px; line-height: 1.6; }
  h1 { font-size: 20px; }
  .req { color: #666; font-size: 13px; margin: -6px 0 18px; }
  .field { margin-bottom: 16px; }
  .fld-label { display: block; font-weight: 600; margin-bottom: 6px; }
  .fld-input { width: 100%; box-sizing: border-box; padding: 8px 10px;
               border: 1px solid #bbb; border-radius: 6px; font-size: 15px; }
  .radio-row { display: flex; gap: 18px; }
  .radio { font-weight: normal; }
  button { margin-top: 8px; padding: 10px 18px; font-size: 15px; border: 0;
           border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
  #result { background: #f5f5f5; color: #111; padding: 12px; border-radius: 6px;
            white-space: pre-wrap; margin-top: 18px; min-height: 1em; }
</style>
</head>
<body>
<h1>__TITLE__</h1>
<p class="req">原始请求:__REQUEST__ —— 请一次性补全以下信息(返程日期仅在选择"往返"时出现;行李额度随舱位变化)。</p>
<form id="clarify-form">
__FIELDS__
  <button type="submit">提交</button>
</form>
<pre id="result"></pre>
<script>
__SCRIPT__
</script>
</body>
</html>
"""


def render_form_html(schema, user_request):
    """把声明式 schema 确定性渲染成自包含的级联 HTML(离线路径,不调用 LLM)。"""
    fields_html = "\n".join(_render_field_html(f) for f in schema["fields"])

    # 供 JS 运行时使用的精简配置(只保留级联所需的键)
    js_fields = []
    for f in schema["fields"]:
        entry = {"name": f["name"]}
        if f.get("container_id"):
            entry["container_id"] = f["container_id"]
        if f.get("show_when"):
            entry["show_when"] = f["show_when"]
        if f.get("options_when"):
            entry["options_when"] = f["options_when"]
        js_fields.append(entry)

    constants = {}
    dest = _extract_destination(user_request)
    if dest:
        constants["destination_city"] = dest  # 目的地已在原始请求里给出,随提交一起带回
    config = {"fields": js_fields, "constants": constants}

    script = _RUNTIME_JS.replace("__CONFIG__", json.dumps(config, ensure_ascii=False))
    return (
        _PAGE_TEMPLATE.replace("__TITLE__", schema["title"])
        .replace("__REQUEST__", user_request)
        .replace("__FIELDS__", fields_html)
        .replace("__SCRIPT__", script)
    )


# ---------------------------------------------------------------------------
# 步骤 2:结构化校验表单
# ---------------------------------------------------------------------------
def validate_form(html):
    """用 BeautifulSoup + 正则做鲁棒校验。

    由于模型生成的具体标签写法不完全可控,这里采用"关键词/属性匹配"的鲁棒策略:
    只要能定位到语义等价的控件即算通过,并把每一项证据打印出来。
    返回 (是否全部通过, 报告字典, 脚本文本)。
    """
    soup = BeautifulSoup(html, "html.parser")
    report = {}

    # (a) 出发城市:文本输入
    dep_city = soup.find("input", attrs={"name": re.compile("departure_city", re.I)})
    if dep_city is None:
        # 退化匹配:任意与"出发城市"相关的文本框
        dep_city = soup.find(
            "input", attrs={"name": re.compile("depart.*city|from.*city|city", re.I)}
        )
    report["出发城市(文本输入)"] = bool(
        dep_city is not None
        and (dep_city.get("type") in (None, "text"))
    )

    # (b) 出发日期:日期选择器
    dep_date = soup.find(
        "input",
        attrs={"type": "date", "name": re.compile("departure_date|depart.*date", re.I)},
    )
    if dep_date is None:
        dep_date = soup.find("input", attrs={"type": "date"})
    report["出发日期(日期选择器)"] = bool(dep_date is not None)

    # (c) 旅行类型:单选,含 单程/往返
    radios = soup.find_all("input", attrs={"type": "radio"})
    radio_values = {r.get("value", "").lower() for r in radios}
    has_one_way = any("one" in v or "单程" in v for v in radio_values)
    has_round = any("round" in v or "往返" in v for v in radio_values)
    # 也允许通过文本判断
    text_all = html.lower()
    has_one_way = has_one_way or ("单程" in html)
    has_round = has_round or ("往返" in html)
    report["旅行类型(单选:单程)"] = bool(len(radios) >= 2 and has_one_way)
    report["旅行类型(单选:往返)"] = bool(len(radios) >= 2 and has_round)

    # (d) 返程日期:日期选择器
    ret_date = soup.find(
        "input", attrs={"name": re.compile("return_date|return.*date", re.I)}
    )
    report["返程日期(日期选择器)"] = bool(
        ret_date is not None or "return_date" in text_all
    )

    # (e) 级联逻辑:返程字段有"仅往返显示"的 JS toggle
    #     鲁棒判断:脚本里同时出现 (round_trip 或 往返) 与 (显示/隐藏控制) 及
    #     返程字段的引用。
    script_text = " ".join(s.get_text() for s in soup.find_all("script"))
    cond_display = bool(
        re.search(r"round_trip|往返", script_text)
        and re.search(
            r"return_date|return_date_field|returnDate", script_text, re.I
        )
        and re.search(
            r"display|hidden|style|classList|\.hide|\.show|toggle", script_text, re.I
        )
    )
    report["返程字段级联逻辑(仅往返显示)"] = cond_display

    all_pass = all(report.values())
    return all_pass, report, script_text


# ---------------------------------------------------------------------------
# 步骤 3:模拟用户提交,喂回 Agent 继续任务
# ---------------------------------------------------------------------------
PARSE_SYSTEM_PROMPT = """你是订机票助手。用户已经通过澄清表单一次性提交了 JSON 格式
的补全信息。请解析这些信息并给出一段简洁的中文"订票摘要",确认航段、日期、行程类型。
如果是单程(one_way)则不要提返程;如果是往返(round_trip)则必须包含返程日期。
最后追加一句下一步操作提示(如"正在为您检索航班...")。只输出摘要文本。"""


def continue_task(client, model, original_request, submitted_json):
    """把用户提交的 JSON 交回 Agent(在线),生成订票摘要。"""
    resp = client.chat.completions.create(
        model=model,
        temperature=_temp_for(model),
        messages=[
            {"role": "system", "content": PARSE_SYSTEM_PROMPT},
            {
                "role": "user",
                "content": (
                    f"原始请求:{original_request}\n"
                    f"表单提交的 JSON 数据:\n{json.dumps(submitted_json, ensure_ascii=False, indent=2)}"
                ),
            },
        ],
    )
    return resp.choices[0].message.content.strip()


_CABIN_CN = {"economy": "经济舱", "business": "公务舱", "first": "头等舱"}


def summarize_offline(submitted):
    """离线路径:不调用 LLM,用确定性模板把提交 JSON 解析成订票摘要。

    这一步只是字符串格式化(不是伪造 LLM 输出),用来演示"解析 JSON → 继续任务"
    的闭环;在线模式下这段由 continue_task() 交给模型完成。
    """
    dep = submitted.get("departure_city", "?")
    dest = submitted.get("destination_city", "目的地")
    ddate = submitted.get("departure_date", "?")
    lines = [f"已收到您的订票信息:{dep}{dest},出发日期 {ddate}。"]
    if submitted.get("trip_type") == "round_trip":
        lines.append(f"行程类型:往返,返程日期 {submitted.get('return_date', '?')}。")
    else:
        lines.append("行程类型:单程。")
    if submitted.get("cabin_class"):
        cabin = _CABIN_CN.get(submitted["cabin_class"], submitted["cabin_class"])
        bag = submitted.get("baggage_count")
        bag_txt = f",免费托运 {bag} 件" if bag not in (None, "", "0") else ",无免费托运"
        lines.append(f"舱位:{cabin}{bag_txt}。")
    lines.append("正在为您检索航班...")
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# 可选:本地起 HTTP 服务,真实体验级联/提交
# ---------------------------------------------------------------------------
def serve_html(path, port):
    """在 path 所在目录起一个本地静态服务,并打开浏览器指向该 HTML。"""
    import http.server
    import socketserver
    import functools
    import webbrowser

    directory = os.path.dirname(os.path.abspath(path))
    fname = os.path.basename(path)
    handler = functools.partial(
        http.server.SimpleHTTPRequestHandler, directory=directory
    )
    url = f"http://127.0.0.1:{port}/{fname}"
    with socketserver.TCPServer(("127.0.0.1", port), handler) as httpd:
        print("\n" + "=" * 68)
        print(f"本地服务已启动:{url}")
        print("在浏览器里切换 单程/往返 看返程字段级联显示;切换舱位看行李额度动态更新;")
        print("点『提交』在页面底部看到汇总的 JSON。按 Ctrl+C 停止服务。")
        print("=" * 68)
        try:
            webbrowser.open(url)
        except Exception:
            pass
        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            print("\n已停止本地服务。")


# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def build_arg_parser():
    """构造命令行参数解析器(提供中文 --help)。"""
    parser = argparse.ArgumentParser(
        description="实验 5-9:动态表单生成的意图澄清系统 —— 从一个模糊请求生成含级联逻辑的"
        "自包含 HTML 表单,用户一次提交,Agent 解析 JSON 继续任务。默认走 OpenAI(需 "
        "OPENAI_API_KEY);加 --offline 用内置 schema 确定性渲染同样的级联表单,无需 API Key。",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "-r",
        "--request",
        default=USER_REQUEST,
        metavar="TEXT",
        help=f"用户的模糊请求(意图)。默认:{USER_REQUEST}。"
        "(--offline 下渲染的是内置机票 schema,此项主要用于抽取目的地并展示。)",
    )
    parser.add_argument(
        "-o",
        "--output",
        default="generated_form.html",
        metavar="PATH",
        help="生成的 HTML 表单输出路径(相对路径按脚本所在目录解析)。默认:generated_form.html。",
    )
    parser.add_argument(
        "--model",
        default=None,
        help="覆盖模型名(否则读环境变量 MODEL,默认 gpt-5.6-luna)。--offline 下忽略此项。",
    )
    parser.add_argument(
        "--offline",
        action="store_true",
        help="离线模式:不调用 LLM,用内置机票 schema 确定性渲染级联表单,无需 API Key。",
    )
    parser.add_argument(
        "--serve",
        action="store_true",
        help="生成后启动本地 HTTP 服务并打开浏览器,真实体验级联显示与提交汇总。",
    )
    parser.add_argument(
        "--port",
        type=int,
        default=8000,
        metavar="N",
        help="--serve 使用的端口(默认 8000)。",
    )
    return parser


def main():
    args = build_arg_parser().parse_args()

    out_path = args.output
    if not os.path.isabs(out_path):
        out_path = os.path.join(SCRIPT_DIR, out_path)

    # 离线判定:显式 --offline,或既无 OPENAI_API_KEY 也无 OPENROUTER_API_KEY 时自动回落
    offline = args.offline
    if not offline and not (os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY")):
        print("未检测到 OPENAI_API_KEY(或 OPENROUTER_API_KEY 兜底),自动切换到离线模式(等价于 --offline)。\n")
        offline = True

    client = model = None
    if offline:
        mode_desc = "离线(内置 schema 确定性渲染,无需 API Key)"
    else:
        client, model = build_client_and_model(args.model)
        mode_desc = f"在线(Agent 调用 OpenAI,模型 {model})"

    print("=" * 68)
    print(f"用户请求: {args.request}")
    print(f"运行模式: {mode_desc}")
    print("=" * 68)

    # --- 步骤 1:生成表单 ---
    print("\n[步骤 1] 生成澄清表单 HTML ...")
    if offline:
        html = render_form_html(FLIGHT_FORM_SCHEMA, args.request)
    else:
        html = generate_form(client, model, args.request)
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(html)
    print(f"  已保存到 {out_path} (共 {len(html)} 字符,可手动在浏览器打开看级联效果)")

    # --- 步骤 2:结构化校验 ---
    print("\n[步骤 2] 结构化校验表单字段与级联逻辑:")
    all_pass, report, script_text = validate_form(html)
    for name, ok in report.items():
        print(f"  [{'PASS' if ok else 'FAIL'}] {name}")

    # 打印级联逻辑证据(脚本中相关片段)
    evidence_lines = [
        ln.strip()
        for ln in script_text.splitlines()
        if re.search(r"round_trip|往返|return_date|display|hidden|toggle|classList", ln, re.I)
    ]
    if evidence_lines:
        print("\n  级联逻辑证据(脚本节选):")
        for ln in evidence_lines[:8]:
            print(f"    | {ln}")

    if not all_pass:
        print("\n  警告:部分字段校验未通过(模型输出不稳定)。可查看生成的 HTML 排查。")

    # --- 步骤 3:模拟一次提交,Agent 继续任务 ---
    print("\n[步骤 3] 模拟用户一次性提交表单(往返场景):")
    submitted = {
        "departure_city": "上海",
        "departure_date": "2026-08-01",
        "trip_type": "round_trip",
        "return_date": "2026-08-07",
        "cabin_class": "business",
        "baggage_count": "2",
        # 目的地来自原始请求(北京),一并带上
        "destination_city": _extract_destination(args.request) or "北京",
    }
    print(json.dumps(submitted, ensure_ascii=False, indent=2))

    print("\n[步骤 3] 解析 JSON 并继续任务,输出订票摘要:")
    if offline:
        summary = summarize_offline(submitted)
        note = "(离线确定性模板)"
    else:
        summary = continue_task(client, model, args.request, submitted)
        note = f"(模型 {model})"
    print("-" * 68)
    print(summary)
    print(f"-" * 68 + f"  {note}")

    # 结果汇总
    print("\n" + "=" * 68)
    print(f"表单字段/级联校验: {'全部通过' if all_pass else '部分未通过'}")
    print("提交 JSON 解析: 成功(见上方订票摘要)")
    print("=" * 68)

    # --- 可选:本地起服务,真实体验 ---
    if args.serve:
        serve_html(out_path, args.port)


if __name__ == "__main__":
    main()