"""实验 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" - 出发日期:日期选择器 ,name="departure_date" - 旅行类型:单选按钮 ,两个选项 value="one_way"(单程)和 value="round_trip"(往返) - 返程日期:日期选择器,name="return_date",放在 id="return_date_field" 的 容器里 2. **级联逻辑(关键)**:返程日期字段默认隐藏,只有当旅行类型选择"往返" (round_trip) 时才通过 JavaScript 显示出来;选回"单程"时再次隐藏。 3. 提交时用 JavaScript 阻止默认提交,把所有字段汇总为一个 JSON 对象, key 使用上面的英文 name,并显示在 id="result" 的元素里 (例如
)。
4. 输出必须是**完整、自包含**的 HTML(含 


__TITLE__

原始请求:__REQUEST__ —— 请一次性补全以下信息(返程日期仅在选择"往返"时出现;行李额度随舱位变化)。

__FIELDS__




"""


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", 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("-" * 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()